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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml
new file mode 100644
index 000000000..f7da81233
--- /dev/null
+++ b/wear/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ RunnerUp requires the %1$s permission to function correctly. Please grant the permission.
+
\ No newline at end of file