diff --git a/common/src/main/java/org/runnerup/common/util/Constants.java b/common/src/main/java/org/runnerup/common/util/Constants.java index 05ae8c811..72bdf9800 100644 --- a/common/src/main/java/org/runnerup/common/util/Constants.java +++ b/common/src/main/java/org/runnerup/common/util/Constants.java @@ -202,6 +202,14 @@ interface Intents { String START_WORKOUT = BuildConfig.applicationIdFull + ".START_WORKOUT"; String PAUSE_WORKOUT = BuildConfig.applicationIdFull + ".PAUSE_WORKOUT"; String RESUME_WORKOUT = BuildConfig.applicationIdFull + ".RESUME_WORKOUT"; + // Used from Wear: Request permission + String EXTRA_PERMISSION_TO_REQUEST = + BuildConfig.applicationIdFull + ".EXTRA_PERMISSION_TO_REQUEST"; + // Used from Wear: Broadcast permission result + String ACTION_PERMISSION_RESULT = BuildConfig.applicationIdFull + ".ACTION_PERMISSION_RESULT"; + String EXTRA_PERMISSION_GRANTED = BuildConfig.applicationIdFull + ".EXTRA_PERMISSION_GRANTED"; + String EXTRA_REQUESTED_PERMISSION_NAME = + BuildConfig.applicationIdFull + ".EXTRA_REQUESTED_PERMISSION_NAME"; } interface TRACKER_STATE { diff --git a/wear/build.gradle b/wear/build.gradle index ce1dc77da..c80671141 100644 --- a/wear/build.gradle +++ b/wear/build.gradle @@ -79,6 +79,9 @@ dependencies { implementation "androidx.wear:wear-ongoing:1.0.0" // Includes LocusIdCompat and new Notification categories for Ongoing Activity. implementation "androidx.core:core:1.16.0" + + implementation "androidx.activity:activity:1.10.1" + implementation "androidx.fragment:fragment:1.8.8" } def props = new Properties() diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 66a6dc1ae..bec15e01c 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -47,6 +47,11 @@ + + diff --git a/wear/src/main/java/org/runnerup/service/ListenerService.java b/wear/src/main/java/org/runnerup/service/ListenerService.java index c9afe46a3..1aedad9bf 100644 --- a/wear/src/main/java/org/runnerup/service/ListenerService.java +++ b/wear/src/main/java/org/runnerup/service/ListenerService.java @@ -203,6 +203,7 @@ private void showNotification() { } // check if we have permission to post notification + // note: permission request is initiated by MainActivity; this service won't ask if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != android.content.pm.PackageManager.PERMISSION_GRANTED) { diff --git a/wear/src/main/java/org/runnerup/view/MainActivity.java b/wear/src/main/java/org/runnerup/view/MainActivity.java index 65ee0a678..dc53832a1 100644 --- a/wear/src/main/java/org/runnerup/view/MainActivity.java +++ b/wear/src/main/java/org/runnerup/view/MainActivity.java @@ -18,6 +18,7 @@ import static com.google.android.gms.wearable.PutDataRequest.WEAR_URI_SCHEME; +import android.Manifest; import android.app.Activity; import android.app.Fragment; import android.app.FragmentManager; @@ -25,14 +26,17 @@ import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; +import android.content.pm.PackageManager; import android.graphics.Point; import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.IBinder; import android.support.wearable.view.DotsPageIndicator; import android.support.wearable.view.FragmentGridPagerAdapter; import android.support.wearable.view.GridViewPager; +import android.util.Log; import android.widget.LinearLayout; import com.google.android.gms.wearable.DataClient; import com.google.android.gms.wearable.PutDataRequest; @@ -80,6 +84,39 @@ protected void onCreate(Bundle savedInstanceState) { dotsPageIndicator.setOnAdapterChangeListener(dot2); dot2.setPager(pager); mGoogleApiClient = Wearable.getDataClient(this); + + requestPostNotificationsPermission(); + } + + /** + * Checks if the POST_NOTIFICATIONS permission is required (Android 13+) and, if so, whether it + * has been granted. If the permission is needed but not granted, this method launches the {@link + * RequestPermissionActivity} to ask the user. + */ + private void requestPostNotificationsPermission() { + // Permission is not applicable for versions below Android 13 (TIRAMISU) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Log.d(getClass().getSimpleName(), "POST_NOTIFICATIONS permission not applicable below Android 13."); + return; + } + + // Permission has already been granted + if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED) { + Log.d(getClass().getSimpleName(), "POST_NOTIFICATIONS permission already granted."); + return; + } + + // Permission is needed and not yet granted + Log.i(getClass().getSimpleName(), + "POST_NOTIFICATIONS permission is not granted. Launching RequestPermissionActivity."); + + // Prepare and launch the activity responsible for handling the permission request + Intent permissionIntent = new Intent(this, RequestPermissionActivity.class); + permissionIntent.putExtra( // Pass the permission being requested as an extra + Constants.Intents.EXTRA_PERMISSION_TO_REQUEST, Manifest.permission.POST_NOTIFICATIONS); + + startActivity(permissionIntent); } @Override diff --git a/wear/src/main/java/org/runnerup/view/RequestPermissionActivity.java b/wear/src/main/java/org/runnerup/view/RequestPermissionActivity.java new file mode 100644 index 000000000..837561f37 --- /dev/null +++ b/wear/src/main/java/org/runnerup/view/RequestPermissionActivity.java @@ -0,0 +1,187 @@ +/* + * Copyright (C) 2025 robert.jonsson75@gmail.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.runnerup.view; + +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.util.Log; +import android.view.View; +import android.widget.Button; +import android.widget.TextView; +import androidx.activity.result.ActivityResultCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import org.runnerup.R; +import org.runnerup.common.util.Constants; + +/** + * An activity dedicated to handling a single runtime permission request. + * + *

This activity is designed to be launched with an {@link Intent} containing the specific + * permission string to request (via {@link Constants.Intents#EXTRA_PERMISSION_TO_REQUEST}). It + * manages the standard Android permission request flow, including: + * + *

    + *
  • Checking if the permission is already granted. + *
  • Showing a rationale explanation to the user if {@link + * #shouldShowRequestPermissionRationale(String)} indicates it's necessary. The rationale UI + * includes a message and an "OK" button to proceed. + *
  • Launching the system permission dialog using the {@link + * ActivityResultContracts.RequestPermission} contract. + *
+ * + *

Upon completion of the permission request flow (whether granted or denied by the user), this + * activity finishes itself and communicates the outcome by sending a local broadcast. The broadcast + * {@link Intent} uses the action {@link Constants.Intents#ACTION_PERMISSION_RESULT} and includes + * extras for the granted status ({@link Constants.Intents#EXTRA_PERMISSION_GRANTED}) and the name + * of the permission that was requested ({@link Constants.Intents#EXTRA_REQUESTED_PERMISSION_NAME}). + * + *

Callers (like other Activities or Services) can register a {@link + * LocalBroadcastManager#getInstance(Context)} with an {@link IntentFilter} for {@link + * Constants.Intents#ACTION_PERMISSION_RESULT} to receive the result of the permission request. + */ +public class RequestPermissionActivity extends AppCompatActivity { + + private static final String TAG = "RequestPermissionActivity"; + private String permissionToRequest; + private ActivityResultLauncher requestPermissionLauncher; + private View rationaleUIRoot; + + private final ActivityResultCallback resultCallback = + isGranted -> { + rationaleUIRoot.setVisibility(View.GONE); + + if (isGranted) { + Log.d(TAG, "resultCallback: " + permissionToRequest + " granted by user."); + sendPermissionResultAndFinish(true); + } else { + Log.w(TAG, permissionToRequest + " denied by user."); + // TODO: As per Android guidelines, implement UI to inform the user that the specific + // feature (requiring permissionToRequest) is unavailable due to denial. + // For now, just send the denial result back to the caller. + sendPermissionResultAndFinish(false); + } + }; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Finish early if there is no permission to request + Intent intent = getIntent(); + if (intent == null || !intent.hasExtra(Constants.Intents.EXTRA_PERMISSION_TO_REQUEST)) { + Log.e(TAG, "Activity started without EXTRA_PERMISSION_TO_REQUEST. Finishing."); + sendPermissionResultAndFinish(false); + return; + } + + setContentView(R.layout.activity_request_permission); + rationaleUIRoot = findViewById(R.id.rationale_layout_root); + rationaleUIRoot.setVisibility(View.GONE); // Initially, hide the rationale UI + + // Prepare the permission rationale message + permissionToRequest = intent.getStringExtra(Constants.Intents.EXTRA_PERMISSION_TO_REQUEST); + String simplePermissionName = getSimplePermissionName(permissionToRequest); + String rationaleMessage = + getString(R.string.permission_rationale_message, simplePermissionName); + TextView rationaleMessageTextView = findViewById(R.id.rationale_message); + rationaleMessageTextView.setText(rationaleMessage); + + // Set up the "OK" button in the rationale UI. When clicked, it hides the rationale + // and proceeds to request the actual system permission. + Button okButton = findViewById(R.id.rationale_button_ok); + okButton.setOnClickListener( + v -> { + Log.d(TAG, "OK button clicked after rationale shown. Requesting permission."); + rationaleUIRoot.setVisibility(View.GONE); + requestPermissionLauncher.launch(permissionToRequest); + }); + + requestPermissionLauncher = + registerForActivityResult(new ActivityResultContracts.RequestPermission(), resultCallback); + + startRequestPermissionFlow(); + } + + private void startRequestPermissionFlow() { + // 1. Check if permission is already granted. + if (ContextCompat.checkSelfPermission(this, permissionToRequest) + == PackageManager.PERMISSION_GRANTED) { + Log.i(TAG, "startRequestPermissionFlow: Permission is already granted. Finishing."); + sendPermissionResultAndFinish(true); + return; + } + + // 2. If not already granted, check if a rationale should be shown. + Log.d( + TAG, "startRequestPermissionFlow: Permission is not already granted. Checking rationale."); + boolean shouldShowRationale = shouldShowRequestPermissionRationale(permissionToRequest); + + if (shouldShowRationale) { + // 3a. Rationale needed: Make rationale UI visible. + Log.d(TAG, "startRequestPermissionFlow: Rationale needed. Making UI visible."); + // TODO: As per Android guidelines, in an educational UI, explain to the user why the app + // requires this permission. In this UI, include a "cancel" or "no thanks" button. + // For now, just show the rationale message with an OK button. + rationaleUIRoot.setVisibility(View.VISIBLE); + } else { + // 3b. No rationale needed: Directly request permission. + Log.d( + TAG, "startRequestPermissionFlow: No rationale needed. Requesting permission directly."); + requestPermissionLauncher.launch(permissionToRequest); + } + } + + private void sendPermissionResultAndFinish(boolean granted) { + Log.d(TAG, "sendPermissionResultAndFinish: result=" + granted); + + Intent resultIntent = new Intent(Constants.Intents.ACTION_PERMISSION_RESULT); + resultIntent.putExtra(Constants.Intents.EXTRA_PERMISSION_GRANTED, granted); + resultIntent.putExtra(Constants.Intents.EXTRA_REQUESTED_PERMISSION_NAME, permissionToRequest); + resultIntent.setPackage(getPackageName()); + LocalBroadcastManager.getInstance(this).sendBroadcast(resultIntent); + + finish(); + } + + /** + * Converts a full Android permission string (e.g., "android.permission.POST_NOTIFICATIONS") into + * a more human-readable, simplified name (e.g., "POST NOTIFICATIONS"). If the permission string + * does not start with "android.permission.", it's returned as is. + * + * @param permission The full permission string. + * @return A simplified, human-readable version of the permission name, or "Unknown" if the input + * permission is null. + */ + private String getSimplePermissionName(String permission) { + if (permission == null) return "Unknown"; + + if (permission.startsWith("android.permission.")) { + return permission.substring("android.permission.".length()).replace("_", " "); + } + + return permission; + } +} diff --git a/wear/src/main/res/layout/activity_request_permission.xml b/wear/src/main/res/layout/activity_request_permission.xml new file mode 100644 index 000000000..40bfed007 --- /dev/null +++ b/wear/src/main/res/layout/activity_request_permission.xml @@ -0,0 +1,61 @@ + + + + + + + + + +