Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions common/src/main/java/org/runnerup/common/util/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions wear/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions wear/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@
</intent-filter>
</activity>

<activity
android:name=".view.RequestPermissionActivity"
android:theme="@style/Theme.AppCompat.NoActionBar"
android:taskAffinity="" />

<service
android:exported="true"
android:name=".service.ListenerService">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
37 changes: 37 additions & 0 deletions wear/src/main/java/org/runnerup/view/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,25 @@

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;
import android.content.ComponentName;
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;
Expand Down Expand Up @@ -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
Expand Down
187 changes: 187 additions & 0 deletions wear/src/main/java/org/runnerup/view/RequestPermissionActivity.java
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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.
*
* <p>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:
*
* <ul>
* <li>Checking if the permission is already granted.
* <li>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.
* <li>Launching the system permission dialog using the {@link
* ActivityResultContracts.RequestPermission} contract.
* </ul>
*
* <p>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}).
*
* <p>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<String> requestPermissionLauncher;
private View rationaleUIRoot;

private final ActivityResultCallback<Boolean> 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;
}
}
61 changes: 61 additions & 0 deletions wear/src/main/res/layout/activity_request_permission.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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 <http://www.gnu.org/licenses/>.
-->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/rationale_layout_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:background="@color/black"
android:fillViewport="true"
tools:context=".view.RequestPermissionActivity">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical"
android:padding="15dp">

<ImageView
android:id="@+id/rationale_icon"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginBottom="8dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_ongoing_notification" />

<TextView
android:id="@+id/rationale_message"
style="@style/TextAppearance.AppCompat.Large"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:gravity="center"
android:paddingHorizontal="3dp"
tools:text="@string/permission_rationale_message" />

<Button
android:id="@+id/rationale_button_ok"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:minWidth="64dp"
android:text="@string/OK" />
</LinearLayout>

</ScrollView>
4 changes: 4 additions & 0 deletions wear/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="permission_rationale_message">RunnerUp requires the %1$s permission to function correctly. Please grant the permission.</string>
</resources>