From 12b97a6020c7dda693349b48f625d0a69446a8a0 Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Fri, 18 Jul 2025 12:16:18 +0200 Subject: [PATCH 01/25] Add 'Wear OS' menu option to HRSettingsActivity Introduces a "Wear OS devices" menu option in HRSettingsActivity. This allows users to select a paired Wear OS device running the RunnerUp companion app as their heart rate monitor source. --- app/res/menu/hrsettings_menu.xml | 6 ++++++ app/res/values/pref_keys.xml | 1 + app/src/main/org/runnerup/view/HRSettingsActivity.java | 1 + common/src/main/res/values/strings.xml | 1 + hrdevice/res/values/strings.xml | 1 + 5 files changed, 10 insertions(+) diff --git a/app/res/menu/hrsettings_menu.xml b/app/res/menu/hrsettings_menu.xml index d8c57df0e..65b1fb9eb 100644 --- a/app/res/menu/hrsettings_menu.xml +++ b/app/res/menu/hrsettings_menu.xml @@ -38,6 +38,12 @@ android:orderInCategory="100" android:title="@string/Experimental_HRM_devices" /> + + pref_bt_experimental pref_bt_mock pref_bt_debug + pref_bt_wear_os hrzAge hrzSex diff --git a/app/src/main/org/runnerup/view/HRSettingsActivity.java b/app/src/main/org/runnerup/view/HRSettingsActivity.java index 0e7df8ef2..5b0a73f4e 100644 --- a/app/src/main/org/runnerup/view/HRSettingsActivity.java +++ b/app/src/main/org/runnerup/view/HRSettingsActivity.java @@ -194,6 +194,7 @@ public void onDestroy() { put(R.id.menu_hrdevice_paired_ble, R.string.pref_bt_paired_ble); put(R.id.menu_hrdevice_experimental, R.string.pref_bt_experimental); put(R.id.menu_hrdevice_mock, R.string.pref_bt_mock); + put(R.id.menu_hrdevice_wear_os, R.string.pref_bt_wear_os); put(R.id.menu_hrdevice_debug, R.string.pref_bt_debug); } }; diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index a4eb67e9f..010d3dc6f 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -206,6 +206,7 @@ Experimental HRM devices Paired BLE devices Experimental features + Wear OS devices Mock HRM devices Debug HRM About RunnerUp diff --git a/hrdevice/res/values/strings.xml b/hrdevice/res/values/strings.xml index 977855931..9a6b7d306 100644 --- a/hrdevice/res/values/strings.xml +++ b/hrdevice/res/values/strings.xml @@ -4,4 +4,5 @@ pref_bt_paired_ble pref_bt_experimental pref_bt_mock + pref_bt_wear_os From 061a94c7caa356ed53d1e9204d0dd3cebfa850c1 Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Fri, 18 Jul 2025 12:57:34 +0200 Subject: [PATCH 02/25] Add skeleton implementation for WearHRProvider Introduces WearHRProvider in the phone module, which implements the HRProvider interface. This class is intended to manage heart rate data from connected Wear OS devices. The current implementation is a skeleton, with all methods being placeholders (stubs) for now. --- hrdevice/src/org/runnerup/hr/HRManager.java | 9 ++ .../src/org/runnerup/hr/WearHRProvider.java | 143 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 hrdevice/src/org/runnerup/hr/WearHRProvider.java diff --git a/hrdevice/src/org/runnerup/hr/HRManager.java b/hrdevice/src/org/runnerup/hr/HRManager.java index ad2361a7b..a88135fd0 100644 --- a/hrdevice/src/org/runnerup/hr/HRManager.java +++ b/hrdevice/src/org/runnerup/hr/HRManager.java @@ -79,6 +79,10 @@ private static HRProvider getHRProviderImpl(Context ctx, String src) { return new Bt20Base.StHRMv1(ctx); } + if (src.contentEquals(WearHRProvider.NAME)) { + return new WearHRProvider(ctx); + } + if (src.contentEquals(MockHRProvider.NAME)) { return new MockHRProvider(ctx); } @@ -99,6 +103,7 @@ public static List getHRProviderList(Context ctx) { Resources res = ctx.getResources(); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(ctx); boolean experimental = prefs.getBoolean(res.getString(R.string.pref_bt_experimental), false); + boolean wear_os = prefs.getBoolean(res.getString(R.string.pref_bt_wear_os), false); boolean mock = prefs.getBoolean(res.getString(R.string.pref_bt_mock), false); List providers = new ArrayList<>(); @@ -125,6 +130,10 @@ public static List getHRProviderList(Context ctx) { } } + if (wear_os) { + providers.add(new WearHRProvider(ctx)); + } + if (mock) { providers.add(new MockHRProvider(ctx)); } diff --git a/hrdevice/src/org/runnerup/hr/WearHRProvider.java b/hrdevice/src/org/runnerup/hr/WearHRProvider.java new file mode 100644 index 000000000..7f7de5183 --- /dev/null +++ b/hrdevice/src/org/runnerup/hr/WearHRProvider.java @@ -0,0 +1,143 @@ +/* + * 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.hr; + +import android.content.Context; +import android.os.Handler; +import android.util.Log; +import androidx.appcompat.app.AppCompatActivity; + +public class WearHRProvider implements HRProvider { + private static final String TAG = "WearHRProvider"; + public static final String NAME = "Wear OS"; + + private final Context context; + private HRClient hrClient; + private Handler hrClientHandler; + private boolean isScanning; + + public WearHRProvider(Context context) { + Log.d(TAG, "WearHRProvider: context=" + context); + } + + @Override + public String getName() { + return NAME; + } + + @Override + public String getProviderName() { + return NAME; + } + + @Override + public boolean isEnabled() { + return true; + } + + @Override + public boolean startEnableIntent(AppCompatActivity activity, int requestCode) { + return false; // Assume watch is already paired/connected to phone + } + + @Override + public void open(Handler handler, HRClient hrClient) { + Log.d(TAG, "open: handler=" + handler + ", hrClient=" + hrClient); + + this.hrClient = hrClient; + this.hrClientHandler = handler; + + hrClient.onOpenResult(true); + } + + @Override + public void close(String from) {} + + @Override + public boolean includePairingBLE() { + return false; + } + + @Override + public boolean isScanning() { + return isScanning; + } + + @Override + public boolean isConnected() { + return false; + } + + @Override + public boolean isConnecting() { + return false; + } + + @Override + public void startScan() { + Log.d(TAG, "startScan"); + + if (isScanning) { + Log.d(TAG, "startScan: Scan already in progress."); + return; + } + + isScanning = true; + } + + @Override + public void stopScan() { + Log.d(TAG, "stopScan"); + isScanning = false; + } + + @Override + public void connect(HRDeviceRef ref) { + + } + + @Override + public void disconnect() { + + } + + @Override + public int getHRValue() { + return 0; + } + + @Override + public long getHRValueTimestamp() { + return 0; + } + + @Override + public long getHRValueElapsedRealtime() { + return 0; + } + + @Override + public HRData getHRData() { + return null; + } + + @Override + public int getBatteryLevel() { + return HRProvider.BATTERY_LEVEL_UNAVAILABLE; + } +} From 7be7a561d6fd16372ed2bb7651b624ed7bde664a Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Fri, 18 Jul 2025 13:15:31 +0200 Subject: [PATCH 03/25] Enhance WearHRProvider with CapabilityClient discovery Improves WearHRProvider to discover Wear OS devices that can provide heart rate data to RunnerUp by using the CapabilityClient. Details: - Introduces Constants.Wear.Capability.HEART_RATE_PROVIDER (runnerup_hr_provider) for wearables to advertise. - startScan() now queries CapabilityClient for nodes advertising this specific capability. - Discovered nodes are mapped to `HRDeviceRef` objects using their display name and ID. --- .../org/runnerup/common/util/Constants.java | 6 +++ hrdevice/build.gradle | 3 ++ .../src/org/runnerup/hr/WearHRProvider.java | 48 +++++++++++++++++++ 3 files changed, 57 insertions(+) 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 9ab4f813c..c9bc7cab9 100644 --- a/common/src/main/java/org/runnerup/common/util/Constants.java +++ b/common/src/main/java/org/runnerup/common/util/Constants.java @@ -274,5 +274,11 @@ interface RunInfo { interface TrackerState { String STATE = "state"; } + + interface Capability { + // Important: The wear app must advertise this exact same capability name in its + // res/values/wear.xml file. + String HEART_RATE_PROVIDER = "runnerup_hr_provider"; + } } } diff --git a/hrdevice/build.gradle b/hrdevice/build.gradle index cdfa31ac3..bb8cd8f39 100644 --- a/hrdevice/build.gradle +++ b/hrdevice/build.gradle @@ -60,8 +60,11 @@ repositories { } dependencies { + implementation project(':common') + implementation "androidx.appcompat:appcompat:${rootProject.ext.appcompat_version}" implementation "androidx.preference:preference:${rootProject.ext.preference_version}" + implementation "com.google.android.gms:play-services-wearable:${rootProject.ext.googlePlayServicesWearableVersion}" if (rootProject.ext.antPlusLibName) { implementation(name: rootProject.ext.antPlusLibName, ext: 'aar') diff --git a/hrdevice/src/org/runnerup/hr/WearHRProvider.java b/hrdevice/src/org/runnerup/hr/WearHRProvider.java index 7f7de5183..43bb1de5f 100644 --- a/hrdevice/src/org/runnerup/hr/WearHRProvider.java +++ b/hrdevice/src/org/runnerup/hr/WearHRProvider.java @@ -21,6 +21,13 @@ import android.os.Handler; import android.util.Log; import androidx.appcompat.app.AppCompatActivity; +import com.google.android.gms.tasks.Task; +import com.google.android.gms.wearable.CapabilityClient; +import com.google.android.gms.wearable.CapabilityInfo; +import com.google.android.gms.wearable.Node; +import com.google.android.gms.wearable.Wearable; +import java.util.Set; +import org.runnerup.common.util.Constants; public class WearHRProvider implements HRProvider { private static final String TAG = "WearHRProvider"; @@ -33,6 +40,7 @@ public class WearHRProvider implements HRProvider { public WearHRProvider(Context context) { Log.d(TAG, "WearHRProvider: context=" + context); + this.context = context; } @Override @@ -98,6 +106,46 @@ public void startScan() { } isScanning = true; + + // Get nodes with capability to provide heart rate data for RunnerUp + CapabilityClient capabilityClient = Wearable.getCapabilityClient(context); + Task capabilityInfoTask = capabilityClient.getCapability( + Constants.Wear.Capability.HEART_RATE_PROVIDER, + CapabilityClient.FILTER_REACHABLE // Only currently connected and reachable nodes + ); + + capabilityInfoTask.addOnSuccessListener(capabilityInfo -> { + Set connectedNodes = capabilityInfo.getNodes(); + Log.d(TAG, "startScan: Successfully fetched capability info. Nodes found: " + connectedNodes.size()); + + if (!connectedNodes.isEmpty()) { + for (Node node : connectedNodes) { + Log.d(TAG, "startScan: Found capable node: " + node.getDisplayName() + " (" + node.getId() + ")"); + // Create an HRDeviceRef for each found node + HRDeviceRef deviceRef = HRDeviceRef.create( + NAME, // Provider name + node.getDisplayName(), // Device name + node.getId() // Device address (using node ID as address) + ); + + if (hrClientHandler != null && hrClient != null) { + hrClientHandler.post(() -> hrClient.onScanResult(deviceRef)); + } + } + } + else { + if (hrClientHandler != null) { + hrClient.log(this, "No Wear OS device found with HR capability"); + } + } + + stopScan(); + }); + + capabilityInfoTask.addOnFailureListener(e -> { + Log.e(TAG, "startScan: Failed to get capabilities", e); + stopScan(); + }); } @Override From 1fcf4525b7af398bac44b081a643979378db08e7 Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Fri, 18 Jul 2025 13:54:51 +0200 Subject: [PATCH 04/25] Declare heart rate monitoring capability Adds the wear.xml file, which defines the android_wear_capabilities string array. This array declares the wear app's heart rate monitoring capability, allowing the RunnerUp phone app to discover and interact with it. --- wear/src/main/res/values/wear.xml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 wear/src/main/res/values/wear.xml diff --git a/wear/src/main/res/values/wear.xml b/wear/src/main/res/values/wear.xml new file mode 100644 index 000000000..90f76f628 --- /dev/null +++ b/wear/src/main/res/values/wear.xml @@ -0,0 +1,7 @@ + + + + + runnerup_hr_provider + + \ No newline at end of file From bf01b2894b7a5bc87a27a71deb97c65f51aa9e2c Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Fri, 18 Jul 2025 19:03:05 +0200 Subject: [PATCH 05/25] Implement connect/disconnect logic in WearHRProvider This commit implements the connect and disconnect methods in WearHRProvider. New message paths for communication is also implemented. Key changes: - Connect: Sends a MSG_CMD_HR_START message to the Wear OS device to initiate heart rate data transmission. Updates connection status and notifies HRClient of the result. - Disconnect: Sends a MSG_CMD_HR_STOP message to the Wear OS device to stop heart rate data transmission. Updates connection status, notifies HRClient, and resets internal state. - Reset: A new method to clear connection and scanning states. --- .../org/runnerup/common/util/Constants.java | 4 + .../src/org/runnerup/hr/WearHRProvider.java | 89 ++++++++++++++++++- 2 files changed, 91 insertions(+), 2 deletions(-) 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 c9bc7cab9..17df27c1a 100644 --- a/common/src/main/java/org/runnerup/common/util/Constants.java +++ b/common/src/main/java/org/runnerup/common/util/Constants.java @@ -260,6 +260,10 @@ interface Path { String MSG_CMD_WORKOUT_RESUME = PREFIX + "/workout/resume"; String MSG_CMD_WORKOUT_NEW_LAP = PREFIX + "/workout/new_lap"; String MSG_CMD_WORKOUT_START = PREFIX + "/workout/start"; + + /* Msg: start/stop HR from phone to wear */ + String MSG_CMD_HR_START = PREFIX + "/hr/start"; + String MSG_CMD_HR_STOP = PREFIX + "/hr/stop"; } interface RunInfo { diff --git a/hrdevice/src/org/runnerup/hr/WearHRProvider.java b/hrdevice/src/org/runnerup/hr/WearHRProvider.java index 43bb1de5f..088d93e73 100644 --- a/hrdevice/src/org/runnerup/hr/WearHRProvider.java +++ b/hrdevice/src/org/runnerup/hr/WearHRProvider.java @@ -37,6 +37,10 @@ public class WearHRProvider implements HRProvider { private HRClient hrClient; private Handler hrClientHandler; private boolean isScanning; + private boolean isConnecting; + private boolean isConnected; + private boolean isDisconnecting; + private String connectedNodeId; public WearHRProvider(Context context) { Log.d(TAG, "WearHRProvider: context=" + context); @@ -88,12 +92,12 @@ public boolean isScanning() { @Override public boolean isConnected() { - return false; + return isConnected; } @Override public boolean isConnecting() { - return false; + return isConnecting; } @Override @@ -156,12 +160,93 @@ public void stopScan() { @Override public void connect(HRDeviceRef ref) { + Log.d(TAG, "connect: device name=" + ref.getName() + ", address=" + ref.getAddress()); + + if (isConnecting || isConnected) { + Log.d(TAG, "connect: Already connecting or connected."); + return; + } + // "Connecting" means sending a message to the Wear OS app to start sending HR data + isConnecting = true; + connectedNodeId = ref.getAddress(); // Store the node ID we are trying to connect to + + // Send a message to the Wear OS app to start sending HR + Log.d(TAG, "connect: Sending " + Constants.Wear.Path.MSG_CMD_HR_START + " message to node: " + connectedNodeId); + Wearable.getMessageClient(context) + .sendMessage( + connectedNodeId, + Constants.Wear.Path.MSG_CMD_HR_START, + null // No payload needed for the start command + ) + .addOnSuccessListener( + integer -> { + Log.d(TAG, "connect: Start HR message sent successfully to " + connectedNodeId); + if (isConnecting) { + isConnected = true; + isConnecting = false; + + hrClientHandler.post( + () -> { + if (hrClient != null) { + hrClient.onConnectResult(true); + } + }); + } + }) + .addOnFailureListener( + e -> { + Log.e(TAG, "Failed to send Start HR message to " + connectedNodeId, e); + + hrClientHandler.post( + () -> { + if (hrClient != null) { + hrClient.onConnectResult(false); + reset(); + } + }); + }); } @Override public void disconnect() { + Log.d(TAG, "disconnect"); + + if (!isConnected || isDisconnecting) { + Log.d(TAG, "disconnect: Not connected or already disconnecting."); + return; + } + + isDisconnecting = true; + + // Send a message to the Wear OS app to stop sending HR + if (connectedNodeId != null) { + Log.d(TAG, "disconnect: Sending " + Constants.Wear.Path.MSG_CMD_HR_STOP + " message to node: " + connectedNodeId); + Wearable.getMessageClient(context).sendMessage( + connectedNodeId, + Constants.Wear.Path.MSG_CMD_HR_STOP, + null // No payload needed for the stop command + ).addOnCompleteListener(task -> { + // Regardless of success or failure, consider us to be disconnected + Log.d(TAG, "disconnect: Disconnected from " + connectedNodeId); + + if (hrClientHandler != null && hrClient != null) { + hrClientHandler.post(() -> { + hrClient.onDisconnectResult(true); + reset(); + }); + } + }); + } + } + private void reset() { + Log.d(TAG, "reset"); + isConnecting = false; + isConnected = false; + isDisconnecting = false; + isScanning = false; + connectedNodeId = null; } @Override From d2d7d4b73c73bd590a73aa657dee8a506a27fcff Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Fri, 18 Jul 2025 19:39:35 +0200 Subject: [PATCH 06/25] Add helper method for posting actions to HRClient Add postToHRClient(Runnable) helper method to encapsulate the logic for safely posting runnable actions to the HRClient's handler thread. --- .../src/org/runnerup/hr/WearHRProvider.java | 63 +++++++++---------- 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/hrdevice/src/org/runnerup/hr/WearHRProvider.java b/hrdevice/src/org/runnerup/hr/WearHRProvider.java index 088d93e73..231269b32 100644 --- a/hrdevice/src/org/runnerup/hr/WearHRProvider.java +++ b/hrdevice/src/org/runnerup/hr/WearHRProvider.java @@ -74,7 +74,7 @@ public void open(Handler handler, HRClient hrClient) { this.hrClient = hrClient; this.hrClientHandler = handler; - hrClient.onOpenResult(true); + postToHRClient(() -> hrClient.onOpenResult(true)); } @Override @@ -132,15 +132,11 @@ public void startScan() { node.getId() // Device address (using node ID as address) ); - if (hrClientHandler != null && hrClient != null) { - hrClientHandler.post(() -> hrClient.onScanResult(deviceRef)); - } + postToHRClient(() -> hrClient.onScanResult(deviceRef)); } } else { - if (hrClientHandler != null) { - hrClient.log(this, "No Wear OS device found with HR capability"); - } + postToHRClient(() -> hrClient.log(this, "No Wear OS device found with HR capability")); } stopScan(); @@ -185,26 +181,15 @@ public void connect(HRDeviceRef ref) { if (isConnecting) { isConnected = true; isConnecting = false; - - hrClientHandler.post( - () -> { - if (hrClient != null) { - hrClient.onConnectResult(true); - } - }); + postToHRClient(() -> hrClient.onConnectResult(true)); } }) .addOnFailureListener( e -> { - Log.e(TAG, "Failed to send Start HR message to " + connectedNodeId, e); - - hrClientHandler.post( - () -> { - if (hrClient != null) { - hrClient.onConnectResult(false); - reset(); - } - }); + Log.e(TAG, "Failed to send Start HR message to " + connectedNodeId + ": " + e.getMessage()); + isConnected = false; + isConnecting = false; + postToHRClient(() -> hrClient.onConnectResult(false)); }); } @@ -217,9 +202,8 @@ public void disconnect() { return; } - isDisconnecting = true; - // Send a message to the Wear OS app to stop sending HR + isDisconnecting = true; if (connectedNodeId != null) { Log.d(TAG, "disconnect: Sending " + Constants.Wear.Path.MSG_CMD_HR_STOP + " message to node: " + connectedNodeId); Wearable.getMessageClient(context).sendMessage( @@ -229,13 +213,8 @@ public void disconnect() { ).addOnCompleteListener(task -> { // Regardless of success or failure, consider us to be disconnected Log.d(TAG, "disconnect: Disconnected from " + connectedNodeId); - - if (hrClientHandler != null && hrClient != null) { - hrClientHandler.post(() -> { - hrClient.onDisconnectResult(true); - reset(); - }); - } + postToHRClient(() -> hrClient.onDisconnectResult(true)); + reset(); }); } } @@ -273,4 +252,24 @@ public HRData getHRData() { public int getBatteryLevel() { return HRProvider.BATTERY_LEVEL_UNAVAILABLE; } + + /** + * Helper method to safely post actions to be executed on the {@link HRClient}'s handler thread. + * + * @param action The action to perform on the hrClient. + */ + private void postToHRClient(Runnable action) { + if (hrClientHandler != null && hrClient != null) { + hrClientHandler.post(() -> { + // Re-check hrClient as it might have been nulled out between posting and execution + if (hrClient != null) { + action.run(); + } else { + Log.w(TAG, "postToHRClient: hrClient became null before action execution on handler."); + } + }); + } else { + Log.w(TAG, "postToHRClient: Cannot post to hrClient: hrClientHandler or hrClient is null."); + } + } } From a61814134a2539cd93e058626ed57517b64c5d64 Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Wed, 16 Jul 2025 13:37:01 +0200 Subject: [PATCH 07/25] Add body sensors permission --- wear/src/main/AndroidManifest.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index dcf1ef717..956e0b064 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -18,6 +18,7 @@ xmlns:tools="http://schemas.android.com/tools"> + Date: Fri, 18 Jul 2025 20:24:50 +0200 Subject: [PATCH 08/25] Implement service to receive HR control messages from phone Introduces HeartRateListenerService, extending WearableListenerService. This service acts as the entry point for commands sent from the phone to manage heart rate monitoring on the wearable (start and stop). The service is configured to filter messages only for the "/org.runnerup/hr" path. This initial version is mainly a skeleton, with all methods being placeholders (stubs) for now. --- wear/src/main/AndroidManifest.xml | 12 ++++ .../runnerup/hr/HeartRateListenerService.java | 55 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 wear/src/main/java/org/runnerup/hr/HeartRateListenerService.java diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 956e0b064..29065d4b7 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -69,6 +69,18 @@ + + + + + + + diff --git a/wear/src/main/java/org/runnerup/hr/HeartRateListenerService.java b/wear/src/main/java/org/runnerup/hr/HeartRateListenerService.java new file mode 100644 index 000000000..7617e9c32 --- /dev/null +++ b/wear/src/main/java/org/runnerup/hr/HeartRateListenerService.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2014 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.hr; + +import android.util.Log; +import androidx.annotation.NonNull; +import com.google.android.gms.wearable.MessageEvent; +import com.google.android.gms.wearable.WearableListenerService; + +/** + * A {@link WearableListenerService} that listens for messages from a connected phone + * to control heart rate monitoring on the wearable device. + *

+ * It receives messages via the Wearable Data Layer. Specifically, it handles: + *

    + *
  • {@link Constants.Wear.Path#MSG_CMD_HR_START}: Starts the {@link HeartRateService}, + * passing the source node ID of the phone as an extra.
  • + *
  • {@link Constants.Wear.Path#MSG_CMD_HR_STOP}: Stops the {@link HeartRateService}.
  • + *
+ */ +public class HeartRateListenerService extends WearableListenerService { + private static final String TAG = "HeartRateListenerService"; + + @Override + public void onCreate() { + super.onCreate(); + Log.d(TAG, "onCreate"); + } + + @Override + public void onDestroy() { + super.onDestroy(); + Log.d(TAG, "onDestroy"); + } + + @Override + public void onMessageReceived(@NonNull MessageEvent messageEvent) { + Log.d(TAG, "onMessageReceived: " + messageEvent.getPath() + " from " + messageEvent.getSourceNodeId()); + } +} From 6c9a6426d9b07fe7998eb0e1dd732198e5b2e282 Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Wed, 16 Jul 2025 16:19:08 +0200 Subject: [PATCH 09/25] Implement service to manage heart rate sensor Introduces HeartRateService intended to manage heart rate sensor interactions and data transmission to the phone. This initial version includes: - Basic service lifecycle methods (onCreate, onStartCommand, onDestroy). - Placeholder methods for SensorEventListener callbacks (onSensorChanged, onAccuracyChanged). - A placeholder method for sending heart rate data to the phone (sendHeartRateToPhone(int). --- wear/src/main/AndroidManifest.xml | 1 + .../org/runnerup/hr/HeartRateService.java | 91 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 wear/src/main/java/org/runnerup/hr/HeartRateService.java diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 29065d4b7..453cd7235 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -68,6 +68,7 @@ + . + */ + +package org.runnerup.hr; + +import android.app.Service; +import android.content.Intent; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.os.IBinder; +import android.util.Log; + +/** + * A {@link Service} that monitors heart rate data using the device's {@link Sensor#TYPE_HEART_RATE} + * and sends this data to a connected phone via the Wearable Data Layer API. This service is started + * by {@link HeartRateListenerService} in response to commands from the phone. + */ +public class HeartRateService extends Service implements SensorEventListener { + private static final String TAG = "HeartRateService"; + + private int currentHeartRate = 0; + + @Override + public IBinder onBind(Intent intent) { + // We don't provide binding, so return null + return null; + } + + @Override + public void onCreate() { + super.onCreate(); + Log.d(TAG, "onCreate"); + + // TODO: Initialize the sensor. + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Log.d(TAG, "onStartCommand: intent=" + intent); + + // TODO: Start listening to the sensor. + + return START_STICKY; + } + + @Override + public void onDestroy() { + super.onDestroy(); + Log.d(TAG, "onDestroy"); + + // TODO: Stop listening to the sensor. + } + + @Override + public void onSensorChanged(SensorEvent event) { + Log.d(TAG, "onSensorChanged: sensor=" + event.sensor.getName()); + + // TODO: Handle the sensor event, and send heart rate data to the phone. + } + + @Override + public void onAccuracyChanged(Sensor sensor, int accuracy) { + Log.d(TAG, "onAccuracyChanged: sensor=" + sensor.getName() + ", accuracy=" + accuracy); + + // TODO: Handle the accuracy change. + // When the accuracy is SENSOR_STATUS_UNRELIABLE or SENSOR_STATUS_NO_CONTACT, + // the heart rate value should be discarded. + // https://developer.android.com/reference/android/hardware/Sensor#TYPE_HEART_RATE + } + + private void sendHeartRateToPhone(int bpm) { + Log.d(TAG, "sendHeartRateToPhone: bpm=" + bpm); + + // TODO: Implement sending heart rate data to the phone using the Wearable Data Layer API. + } +} From ebc7a479f2d7135e004ff6756ce695d7ff51554b Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Wed, 16 Jul 2025 16:51:20 +0200 Subject: [PATCH 10/25] Initialize and manage HR sensor in HeartRateService HeartRateService now handles the lifecycle of the heart rate sensor: - Initializes SensorManager and obtains the TYPE_HEART_RATE sensor in onCreate(). - Registers as a SensorEventListener for heart rate events in onStartCommand(). - Unregisters the sensor listener in onDestroy() to release resources. --- .../org/runnerup/hr/HeartRateService.java | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/wear/src/main/java/org/runnerup/hr/HeartRateService.java b/wear/src/main/java/org/runnerup/hr/HeartRateService.java index e0be977ea..cd0208d1e 100644 --- a/wear/src/main/java/org/runnerup/hr/HeartRateService.java +++ b/wear/src/main/java/org/runnerup/hr/HeartRateService.java @@ -18,10 +18,12 @@ package org.runnerup.hr; import android.app.Service; +import android.content.Context; import android.content.Intent; import android.hardware.Sensor; import android.hardware.SensorEvent; import android.hardware.SensorEventListener; +import android.hardware.SensorManager; import android.os.IBinder; import android.util.Log; @@ -34,6 +36,8 @@ public class HeartRateService extends Service implements SensorEventListener { private static final String TAG = "HeartRateService"; private int currentHeartRate = 0; + private SensorManager sensorManager; + private Sensor heartRateSensor; @Override public IBinder onBind(Intent intent) { @@ -46,24 +50,58 @@ public void onCreate() { super.onCreate(); Log.d(TAG, "onCreate"); - // TODO: Initialize the sensor. + // Initialize the SensorManager and attempt to get the default heart rate sensor. + sensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE); + if (sensorManager != null) { + heartRateSensor = sensorManager.getDefaultSensor(Sensor.TYPE_HEART_RATE); + } + + if (heartRateSensor == null) { + Log.e(TAG, "Heart rate sensor not available."); + stopSelf(); // Stop the service if sensor is not found + } } @Override public int onStartCommand(Intent intent, int flags, int startId) { Log.d(TAG, "onStartCommand: intent=" + intent); - // TODO: Start listening to the sensor. + // TODO: Check permission before start listening to the sensor. + startHeartRateMonitoring(); return START_STICKY; } + private void startHeartRateMonitoring() { + if (sensorManager != null && heartRateSensor != null) { + // TODO: Use SENSOR_DELAY_UI for faster updates? + boolean registered = sensorManager.registerListener( + this, heartRateSensor, SensorManager.SENSOR_DELAY_NORMAL); + if (registered) { + Log.d(TAG, "Heart rate sensor listener registered."); + } else { + Log.e(TAG, "Failed to register heart rate sensor listener."); + stopSelf(); // Stop if registration fails + } + } else { + Log.e(TAG, "SensorManager or HeartRateSensor is null in startHeartRateMonitoring."); + stopSelf(); + } + } + @Override public void onDestroy() { super.onDestroy(); Log.d(TAG, "onDestroy"); - // TODO: Stop listening to the sensor. + stopHeartRateMonitoring(); + } + + private void stopHeartRateMonitoring() { + if (sensorManager != null) { + sensorManager.unregisterListener(this); + Log.d(TAG, "Heart rate sensor listener unregistered."); + } } @Override From a9c353b914ad3f70894a6ccd3999f7031002df00 Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Wed, 16 Jul 2025 18:25:21 +0200 Subject: [PATCH 11/25] Extract and log heart rate value from SensorEvent --- wear/src/main/java/org/runnerup/hr/HeartRateService.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/wear/src/main/java/org/runnerup/hr/HeartRateService.java b/wear/src/main/java/org/runnerup/hr/HeartRateService.java index cd0208d1e..6aaf28a31 100644 --- a/wear/src/main/java/org/runnerup/hr/HeartRateService.java +++ b/wear/src/main/java/org/runnerup/hr/HeartRateService.java @@ -108,7 +108,14 @@ private void stopHeartRateMonitoring() { public void onSensorChanged(SensorEvent event) { Log.d(TAG, "onSensorChanged: sensor=" + event.sensor.getName()); - // TODO: Handle the sensor event, and send heart rate data to the phone. + if (event.sensor.getType() == Sensor.TYPE_HEART_RATE) { + if (event.values.length > 0) { + currentHeartRate = (int) event.values[0]; // Heart rate in beats per minute (bpm) + Log.d(TAG, "onSensorChanged: Current Heart Rate: " + currentHeartRate + " bpm"); + + sendHeartRateToPhone(currentHeartRate); + } + } } @Override From c080790033d5551de11292f3c83b9bec9e06e4f1 Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Sat, 19 Jul 2025 13:38:48 +0200 Subject: [PATCH 12/25] Allow phone to control HeartRateService start/stop HeartRateListenerService now starts/stops HeartRateService based on messages from the phone. - onMessageReceived reacts to: - MSG_CMD_HR_START: Starts `HeartRateService`, passing source node ID. - MSG_CMD_HR_STOP: Stops `HeartRateService`. (Constants like `Constants.Wear.Path` and `Constants.Intents` are used for paths and extras respectively). --- .../java/org/runnerup/common/util/Constants.java | 2 ++ .../org/runnerup/hr/HeartRateListenerService.java | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) 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 17df27c1a..3ce49b1fa 100644 --- a/common/src/main/java/org/runnerup/common/util/Constants.java +++ b/common/src/main/java/org/runnerup/common/util/Constants.java @@ -205,6 +205,8 @@ interface Intents { String START_WORKOUT = BuildConfig.applicationIdFull + ".START_WORKOUT"; String PAUSE_WORKOUT = BuildConfig.applicationIdFull + ".PAUSE_WORKOUT"; String RESUME_WORKOUT = BuildConfig.applicationIdFull + ".RESUME_WORKOUT"; + /** ID of the node (device) that sent the message/data */ + String EXTRA_SOURCE_NODE_ID = BuildConfig.applicationIdFull + ".EXTRA_SOURCE_NODE_ID"; // Used from Wear: Request permission String EXTRA_PERMISSION_TO_REQUEST = BuildConfig.applicationIdFull + ".EXTRA_PERMISSION_TO_REQUEST"; diff --git a/wear/src/main/java/org/runnerup/hr/HeartRateListenerService.java b/wear/src/main/java/org/runnerup/hr/HeartRateListenerService.java index 7617e9c32..2e1a665a4 100644 --- a/wear/src/main/java/org/runnerup/hr/HeartRateListenerService.java +++ b/wear/src/main/java/org/runnerup/hr/HeartRateListenerService.java @@ -17,10 +17,12 @@ package org.runnerup.hr; +import android.content.Intent; import android.util.Log; import androidx.annotation.NonNull; import com.google.android.gms.wearable.MessageEvent; import com.google.android.gms.wearable.WearableListenerService; +import org.runnerup.common.util.Constants; /** * A {@link WearableListenerService} that listens for messages from a connected phone @@ -50,6 +52,17 @@ public void onDestroy() { @Override public void onMessageReceived(@NonNull MessageEvent messageEvent) { - Log.d(TAG, "onMessageReceived: " + messageEvent.getPath() + " from " + messageEvent.getSourceNodeId()); + String path = messageEvent.getPath(); + String sourceNodeId = messageEvent.getSourceNodeId(); // ID of the phone that sent the message + Log.d(TAG, "onMessageReceived: " + path + " from " + sourceNodeId); + + Intent serviceIntent = new Intent(this, HeartRateService.class); + + if (Constants.Wear.Path.MSG_CMD_HR_START.equals(path)) { + serviceIntent.putExtra(Constants.Intents.EXTRA_SOURCE_NODE_ID, sourceNodeId); + startService(serviceIntent); + } else if (Constants.Wear.Path.MSG_CMD_HR_STOP.equals(path)) { + stopService(serviceIntent); + } } } From f0cdf25955be08c1ea3f9dfe3dc3ac71d3bc6cdf Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Sat, 19 Jul 2025 18:24:37 +0200 Subject: [PATCH 13/25] Send HR data to phone with accuracy filtering Enables the watch to send heart rate data to the connected phone. Key changes: - The phone's node ID is obtained from HeartRateService's start intent (extra: Constants.Intents.EXTRA_SOURCE_NODE_ID). - onSensorChanged now checks sensor accuracy and data is only processed if accuracy is not SENSOR_STATUS_UNRELIABLE or SENSOR_STATUS_NO_CONTACT. - Validated HR data is sent to the phone using MessageClient on path Constants.Wear.Path.MSG_DATA_HR. --- .../org/runnerup/common/util/Constants.java | 3 ++ .../org/runnerup/hr/HeartRateService.java | 44 +++++++++++++++---- 2 files changed, 38 insertions(+), 9 deletions(-) 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 3ce49b1fa..e9005052c 100644 --- a/common/src/main/java/org/runnerup/common/util/Constants.java +++ b/common/src/main/java/org/runnerup/common/util/Constants.java @@ -266,6 +266,9 @@ interface Path { /* Msg: start/stop HR from phone to wear */ String MSG_CMD_HR_START = PREFIX + "/hr/start"; String MSG_CMD_HR_STOP = PREFIX + "/hr/stop"; + + /* Msg: Heart rate data from wear to phone */ + String MSG_HEART_RATE = PREFIX + "/hr/data"; } interface RunInfo { diff --git a/wear/src/main/java/org/runnerup/hr/HeartRateService.java b/wear/src/main/java/org/runnerup/hr/HeartRateService.java index 6aaf28a31..931ef6dc8 100644 --- a/wear/src/main/java/org/runnerup/hr/HeartRateService.java +++ b/wear/src/main/java/org/runnerup/hr/HeartRateService.java @@ -26,6 +26,8 @@ import android.hardware.SensorManager; import android.os.IBinder; import android.util.Log; +import com.google.android.gms.wearable.Wearable; +import org.runnerup.common.util.Constants; /** * A {@link Service} that monitors heart rate data using the device's {@link Sensor#TYPE_HEART_RATE} @@ -35,7 +37,8 @@ public class HeartRateService extends Service implements SensorEventListener { private static final String TAG = "HeartRateService"; - private int currentHeartRate = 0; + /** Node ID of the connected phone. */ + private String sourceNodeId; private SensorManager sensorManager; private Sensor heartRateSensor; @@ -66,10 +69,21 @@ public void onCreate() { public int onStartCommand(Intent intent, int flags, int startId) { Log.d(TAG, "onStartCommand: intent=" + intent); + if (intent != null) { + sourceNodeId = intent.getStringExtra(Constants.Intents.EXTRA_SOURCE_NODE_ID); + } + + if (sourceNodeId == null) { + Log.e(TAG, "onStartCommand: sourceNodeId is null. Stopping service."); + stopSelf(); + return START_NOT_STICKY; + } + // TODO: Check permission before start listening to the sensor. startHeartRateMonitoring(); - return START_STICKY; + // Ensures the Intent (with sourceNodeId) is redelivered if the service restarts + return START_REDELIVER_INTENT; } private void startHeartRateMonitoring() { @@ -109,8 +123,17 @@ public void onSensorChanged(SensorEvent event) { Log.d(TAG, "onSensorChanged: sensor=" + event.sensor.getName()); if (event.sensor.getType() == Sensor.TYPE_HEART_RATE) { + // When the accuracy is SENSOR_STATUS_UNRELIABLE or SENSOR_STATUS_NO_CONTACT, + // the heart rate value should be discarded. + // https://developer.android.com/reference/android/hardware/Sensor#TYPE_HEART_RATE + if (event.accuracy == SensorManager.SENSOR_STATUS_UNRELIABLE + || event.accuracy == SensorManager.SENSOR_STATUS_NO_CONTACT) { + Log.d(TAG, "onSensorChanged: HR value accuracy is unreliable or no contact."); + return; + } + if (event.values.length > 0) { - currentHeartRate = (int) event.values[0]; // Heart rate in beats per minute (bpm) + int currentHeartRate = Math.round(event.values[0]); // Heart rate in beats per minute (bpm) Log.d(TAG, "onSensorChanged: Current Heart Rate: " + currentHeartRate + " bpm"); sendHeartRateToPhone(currentHeartRate); @@ -121,16 +144,19 @@ public void onSensorChanged(SensorEvent event) { @Override public void onAccuracyChanged(Sensor sensor, int accuracy) { Log.d(TAG, "onAccuracyChanged: sensor=" + sensor.getName() + ", accuracy=" + accuracy); - - // TODO: Handle the accuracy change. - // When the accuracy is SENSOR_STATUS_UNRELIABLE or SENSOR_STATUS_NO_CONTACT, - // the heart rate value should be discarded. - // https://developer.android.com/reference/android/hardware/Sensor#TYPE_HEART_RATE } private void sendHeartRateToPhone(int bpm) { Log.d(TAG, "sendHeartRateToPhone: bpm=" + bpm); - // TODO: Implement sending heart rate data to the phone using the Wearable Data Layer API. + byte[] payload = String.valueOf(bpm).getBytes(); + + // Wearable API clients, such as DataClient and MessageClient, are inexpensive to create. + // So instead of holding onto the clients, recreate them when needed. + // https://developer.android.com/training/wearables/data/overview#recreate-client-instances + Wearable.getMessageClient(this).sendMessage(sourceNodeId, Constants.Wear.Path.MSG_HEART_RATE, payload) + .addOnSuccessListener(integer -> Log.d(TAG, "HR sent successfully: " + bpm)) + .addOnFailureListener(e -> Log.e(TAG, "Error sending HR: " + e.getMessage())); + } } From 10ff19efd101628ad38ad4c621299703e3933fb3 Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Sat, 19 Jul 2025 22:37:10 +0200 Subject: [PATCH 14/25] Enable WearHRProvider to receive HR data from Wear OS WearHRProvider now listens for heart rate messages from Wear OS, parses them, and makes the data available. Key changes: - connect(): Registers a MessageClient.OnMessageReceivedListener to listen for incoming HR data. - disconnect(): Removes the MessageClient.OnMessageReceivedListener to stop listening (prevents resource leaks). - onMessageReceived(): - Parses the incoming message payload to extract the heart rate value (hrValue). - Generates a local timestamp (hrTimestamp) and elapsed realtime (hrElapsedRealtime) for the received data. - The heart rate data (value, timestamp, elapsed realtime) is made accessible via getters. --- .../src/org/runnerup/hr/WearHRProvider.java | 50 +++++++++++++++---- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/hrdevice/src/org/runnerup/hr/WearHRProvider.java b/hrdevice/src/org/runnerup/hr/WearHRProvider.java index 231269b32..1e226d68c 100644 --- a/hrdevice/src/org/runnerup/hr/WearHRProvider.java +++ b/hrdevice/src/org/runnerup/hr/WearHRProvider.java @@ -19,11 +19,13 @@ import android.content.Context; import android.os.Handler; +import android.os.SystemClock; import android.util.Log; import androidx.appcompat.app.AppCompatActivity; import com.google.android.gms.tasks.Task; import com.google.android.gms.wearable.CapabilityClient; import com.google.android.gms.wearable.CapabilityInfo; +import com.google.android.gms.wearable.MessageClient; import com.google.android.gms.wearable.Node; import com.google.android.gms.wearable.Wearable; import java.util.Set; @@ -42,6 +44,10 @@ public class WearHRProvider implements HRProvider { private boolean isDisconnecting; private String connectedNodeId; + private int hrValue = 0; + private long hrTimestamp = 0; + private long hrElapsedRealtime = 0; + public WearHRProvider(Context context) { Log.d(TAG, "WearHRProvider: context=" + context); this.context = context; @@ -182,6 +188,10 @@ public void connect(HRDeviceRef ref) { isConnected = true; isConnecting = false; postToHRClient(() -> hrClient.onConnectResult(true)); + + // Start listening for HR data + // Important: Don't forget to remove the listener to avoid leaking resources. + Wearable.getMessageClient(context).addListener(onHRMessageListener); } }) .addOnFailureListener( @@ -210,12 +220,15 @@ public void disconnect() { connectedNodeId, Constants.Wear.Path.MSG_CMD_HR_STOP, null // No payload needed for the stop command - ).addOnCompleteListener(task -> { - // Regardless of success or failure, consider us to be disconnected - Log.d(TAG, "disconnect: Disconnected from " + connectedNodeId); - postToHRClient(() -> hrClient.onDisconnectResult(true)); - reset(); - }); + ); + + // Regardless of success or failure, consider us to be disconnected + Log.d(TAG, "disconnect: Disconnected from " + connectedNodeId); + postToHRClient(() -> hrClient.onDisconnectResult(true)); + reset(); + + // Stop listening for HR data + Wearable.getMessageClient(context).removeListener(onHRMessageListener); } } @@ -230,22 +243,26 @@ private void reset() { @Override public int getHRValue() { - return 0; + return hrValue; } @Override public long getHRValueTimestamp() { - return 0; + return hrTimestamp; } @Override public long getHRValueElapsedRealtime() { - return 0; + return this.hrElapsedRealtime; } @Override public HRData getHRData() { - return null; + if (hrValue <= 0) { + return null; + } + + return new HRData().setHeartRate(hrValue).setTimestampEstimate(hrTimestamp); } @Override @@ -272,4 +289,17 @@ private void postToHRClient(Runnable action) { Log.w(TAG, "postToHRClient: Cannot post to hrClient: hrClientHandler or hrClient is null."); } } + + private final MessageClient.OnMessageReceivedListener onHRMessageListener = messageEvent -> { + String path = messageEvent.getPath(); + Log.d(TAG, "onMessageReceived: " + path); + + if (Constants.Wear.Path.MSG_HEART_RATE.equals(path)) { + byte[] payload = messageEvent.getData(); + hrValue = Integer.parseInt(new String(payload)); + hrTimestamp = System.currentTimeMillis(); + hrElapsedRealtime = SystemClock.elapsedRealtimeNanos(); + Log.d(TAG, "onMessageReceived: hrValue: " + hrValue); + } + }; } From 0636b25538a675f25c5de1adb4e50aeeb9cdc520 Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Fri, 25 Jul 2025 17:51:17 +0200 Subject: [PATCH 15/25] Add BODY_SENSORS runtime permission check in HeartRateService Introduces a runtime permission check for `BODY_SENSORS` within `HeartRateService` before attempting to start heart rate monitoring. - If permission is not granted, `RequestPermissionActivity` is launched. - `HeartRateService` now uses a `BroadcastReceiver` to listen for the permission result from this activity. - Monitoring only proceeds if permission is subsequently granted. --- .../org/runnerup/hr/HeartRateService.java | 104 ++++++++++++++++-- 1 file changed, 93 insertions(+), 11 deletions(-) diff --git a/wear/src/main/java/org/runnerup/hr/HeartRateService.java b/wear/src/main/java/org/runnerup/hr/HeartRateService.java index 931ef6dc8..0935b93b8 100644 --- a/wear/src/main/java/org/runnerup/hr/HeartRateService.java +++ b/wear/src/main/java/org/runnerup/hr/HeartRateService.java @@ -17,17 +17,24 @@ package org.runnerup.hr; +import android.Manifest; import android.app.Service; +import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; import android.hardware.Sensor; import android.hardware.SensorEvent; import android.hardware.SensorEventListener; import android.hardware.SensorManager; import android.os.IBinder; import android.util.Log; +import androidx.core.content.ContextCompat; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; import com.google.android.gms.wearable.Wearable; import org.runnerup.common.util.Constants; +import org.runnerup.view.RequestPermissionActivity; /** * A {@link Service} that monitors heart rate data using the device's {@link Sensor#TYPE_HEART_RATE} @@ -42,6 +49,8 @@ public class HeartRateService extends Service implements SensorEventListener { private SensorManager sensorManager; private Sensor heartRateSensor; + private BroadcastReceiver hrPermissionReceiver; + @Override public IBinder onBind(Intent intent) { // We don't provide binding, so return null @@ -53,6 +62,8 @@ public void onCreate() { super.onCreate(); Log.d(TAG, "onCreate"); + setupPermissionReceiver(); + // Initialize the SensorManager and attempt to get the default heart rate sensor. sensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE); if (sensorManager != null) { @@ -60,7 +71,7 @@ public void onCreate() { } if (heartRateSensor == null) { - Log.e(TAG, "Heart rate sensor not available."); + Log.e(TAG, "onCreate: Heart rate sensor not available."); stopSelf(); // Stop the service if sensor is not found } } @@ -79,45 +90,79 @@ public int onStartCommand(Intent intent, int flags, int startId) { return START_NOT_STICKY; } - // TODO: Check permission before start listening to the sensor. - startHeartRateMonitoring(); + attemptToStartHeartRateMonitoring(); // Ensures the Intent (with sourceNodeId) is redelivered if the service restarts return START_REDELIVER_INTENT; } private void startHeartRateMonitoring() { + Log.d(TAG, "startHeartRateMonitoring"); + if (sensorManager != null && heartRateSensor != null) { - // TODO: Use SENSOR_DELAY_UI for faster updates? boolean registered = sensorManager.registerListener( this, heartRateSensor, SensorManager.SENSOR_DELAY_NORMAL); if (registered) { - Log.d(TAG, "Heart rate sensor listener registered."); + Log.d(TAG, "startHeartRateMonitoring: Heart rate sensor listener registered."); } else { - Log.e(TAG, "Failed to register heart rate sensor listener."); + Log.e(TAG, "startHeartRateMonitoring: Failed to register heart rate sensor listener."); stopSelf(); // Stop if registration fails } } else { - Log.e(TAG, "SensorManager or HeartRateSensor is null in startHeartRateMonitoring."); + Log.e(TAG, "startHeartRateMonitoring: SensorManager or HeartRateSensor is null in startHeartRateMonitoring."); stopSelf(); } } @Override public void onDestroy() { - super.onDestroy(); Log.d(TAG, "onDestroy"); - stopHeartRateMonitoring(); + // Unregister the receiver to prevent leaks + if (hrPermissionReceiver != null) { + LocalBroadcastManager.getInstance(this).unregisterReceiver(hrPermissionReceiver); + Log.d(TAG, "onDestroy: HR Permission Receiver unregistered."); + hrPermissionReceiver = null; + } + stopHeartRateMonitoring(); // Ensure monitoring is stopped + super.onDestroy(); + } + + private void attemptToStartHeartRateMonitoring() { + Log.d(TAG, "attemptToStartHeartRateMonitoring"); + + if (checkHeartRatePermission()) { + startHeartRateMonitoring(); + } else { + Log.w(TAG, "attemptToStartHeartRateMonitoring: Permission not granted for HR monitoring. Requesting..."); + launchPermissionActivity(); // The result will be handled by hrPermissionReceiver + } } private void stopHeartRateMonitoring() { + Log.d(TAG, "stopHeartRateMonitoring"); + if (sensorManager != null) { sensorManager.unregisterListener(this); - Log.d(TAG, "Heart rate sensor listener unregistered."); + Log.d(TAG, "stopHeartRateMonitoring: Heart rate sensor listener unregistered."); } } + private boolean checkHeartRatePermission() { + boolean permissionGranted = ContextCompat.checkSelfPermission(this, Manifest.permission.BODY_SENSORS) == + PackageManager.PERMISSION_GRANTED; + Log.d(TAG, "checkHeartRatePermission: permissionGranted=" + permissionGranted); + return permissionGranted; + } + + private void launchPermissionActivity() { + Log.d(TAG, "launchPermissionActivity"); + Intent intent = new Intent(this, RequestPermissionActivity.class); + intent.putExtra(Constants.Intents.EXTRA_PERMISSION_TO_REQUEST, Manifest.permission.BODY_SENSORS); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); // Necessary when starting activity from a service + startActivity(intent); + } + @Override public void onSensorChanged(SensorEvent event) { Log.d(TAG, "onSensorChanged: sensor=" + event.sensor.getName()); @@ -147,7 +192,12 @@ public void onAccuracyChanged(Sensor sensor, int accuracy) { } private void sendHeartRateToPhone(int bpm) { - Log.d(TAG, "sendHeartRateToPhone: bpm=" + bpm); + Log.d(TAG, "sendHeartRateToPhone: bpm=" + bpm + ", sourceNodeId=" + sourceNodeId); + + if (sourceNodeId == null) { + Log.e(TAG, "sendHeartRateToPhone: sourceNodeId is null. Not sending HR."); + return; + } byte[] payload = String.valueOf(bpm).getBytes(); @@ -157,6 +207,38 @@ private void sendHeartRateToPhone(int bpm) { Wearable.getMessageClient(this).sendMessage(sourceNodeId, Constants.Wear.Path.MSG_HEART_RATE, payload) .addOnSuccessListener(integer -> Log.d(TAG, "HR sent successfully: " + bpm)) .addOnFailureListener(e -> Log.e(TAG, "Error sending HR: " + e.getMessage())); + } + + private void setupPermissionReceiver() { + Log.d(TAG, "setupPermissionReceiver"); + + hrPermissionReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + Log.d(TAG, "onReceive: action=" + action); + + if (Constants.Intents.ACTION_PERMISSION_RESULT.equals(action)) { + boolean permissionGranted = intent.getBooleanExtra( + Constants.Intents.EXTRA_PERMISSION_GRANTED, false); + + Log.d(TAG, "onReceive: permissionGranted=" + permissionGranted); + + if (permissionGranted) { + startHeartRateMonitoring(); + } + else { + Log.w(TAG, "onReceive: stopping service due to missing permission"); + stopSelf(); // Stop if permission is not granted + // TODO: Notify phone app that permission is missing? + } + } + } + }; + // Register the receiver + IntentFilter filter = new IntentFilter(Constants.Intents.ACTION_PERMISSION_RESULT); + LocalBroadcastManager.getInstance(this).registerReceiver(hrPermissionReceiver, filter); + Log.d(TAG, "setupPermissionReceiver: HR Permission Receiver registered."); } } From 60f799d171150e932bdab893035d08f7f8b674b5 Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Fri, 25 Jul 2025 18:20:24 +0200 Subject: [PATCH 16/25] Improve error handling for Play Services and device scanning - Gracefully handle unavailable/disabled Google Play Services by preventing connection attempts. - Log errors to HRClient if scanning for a Wear OS device fails, instead of only logging internally. --- hrdevice/src/org/runnerup/hr/HRManager.java | 1 + .../src/org/runnerup/hr/WearHRProvider.java | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/hrdevice/src/org/runnerup/hr/HRManager.java b/hrdevice/src/org/runnerup/hr/HRManager.java index a88135fd0..db60e522c 100644 --- a/hrdevice/src/org/runnerup/hr/HRManager.java +++ b/hrdevice/src/org/runnerup/hr/HRManager.java @@ -80,6 +80,7 @@ private static HRProvider getHRProviderImpl(Context ctx, String src) { } if (src.contentEquals(WearHRProvider.NAME)) { + if (!WearHRProvider.checkLibrary(ctx)) return null; return new WearHRProvider(ctx); } diff --git a/hrdevice/src/org/runnerup/hr/WearHRProvider.java b/hrdevice/src/org/runnerup/hr/WearHRProvider.java index 1e226d68c..68b2e724e 100644 --- a/hrdevice/src/org/runnerup/hr/WearHRProvider.java +++ b/hrdevice/src/org/runnerup/hr/WearHRProvider.java @@ -22,6 +22,8 @@ import android.os.SystemClock; import android.util.Log; import androidx.appcompat.app.AppCompatActivity; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GoogleApiAvailability; import com.google.android.gms.tasks.Task; import com.google.android.gms.wearable.CapabilityClient; import com.google.android.gms.wearable.CapabilityInfo; @@ -149,7 +151,8 @@ public void startScan() { }); capabilityInfoTask.addOnFailureListener(e -> { - Log.e(TAG, "startScan: Failed to get capabilities", e); + Log.e(TAG, "startScan: Failed to get capabilities. Error message: " + e.getMessage()); + postToHRClient(() -> hrClient.log(this, e.getMessage())); stopScan(); }); } @@ -302,4 +305,16 @@ private void postToHRClient(Runnable action) { Log.d(TAG, "onMessageReceived: hrValue: " + hrValue); } }; + + public static boolean checkLibrary(Context ctx) { + GoogleApiAvailability apiAvailability = GoogleApiAvailability.getInstance(); + int resultCode = apiAvailability.isGooglePlayServicesAvailable(ctx); + + if (resultCode != ConnectionResult.SUCCESS) { + Log.w(TAG, "Google Play services is not installed or is not enabled on this device."); + return false; + } + + return true; + } } From b887796230186fb4c625f46d371ac40c111a4ef6 Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Sat, 26 Jul 2025 21:32:26 +0200 Subject: [PATCH 17/25] Remove tag from hrdevice manifest The hrdevice module is a library and should not define its own tag. This caused manifest merger conflicts, specifically using the hrdevice module's "@string/app_name" instead of the common module's version. This resulted in the app being labeled incorrectly as "HRDevice" instead of "RunnerUp". Removing the tag from the library module resolves the conflict and ensures the correct application name is used. --- hrdevice/AndroidManifest.xml | 6 ------ hrdevice/res/values/strings.xml | 2 -- 2 files changed, 8 deletions(-) diff --git a/hrdevice/AndroidManifest.xml b/hrdevice/AndroidManifest.xml index 9ca5f35af..5ba4fdea3 100644 --- a/hrdevice/AndroidManifest.xml +++ b/hrdevice/AndroidManifest.xml @@ -14,10 +14,4 @@ android:name="android.permission.BLUETOOTH_CONNECT" android:minSdkVersion="31" /> - - - - diff --git a/hrdevice/res/values/strings.xml b/hrdevice/res/values/strings.xml index 9a6b7d306..67a268509 100644 --- a/hrdevice/res/values/strings.xml +++ b/hrdevice/res/values/strings.xml @@ -1,6 +1,4 @@ - HRDevice - pref_bt_paired_ble pref_bt_experimental pref_bt_mock From 6602c5bbc9289bc5622c0c11bb0522ba72cc40f5 Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Mon, 25 May 2026 20:31:51 +0200 Subject: [PATCH 18/25] Improve parsing of nested Android permissions Updated `getSimplePermissionName` to handle permission strings with multiple sub-packages. --- .../runnerup/view/RequestPermissionActivity.java | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/wear/src/main/java/org/runnerup/view/RequestPermissionActivity.java b/wear/src/main/java/org/runnerup/view/RequestPermissionActivity.java index 837561f37..a8c0bca85 100644 --- a/wear/src/main/java/org/runnerup/view/RequestPermissionActivity.java +++ b/wear/src/main/java/org/runnerup/view/RequestPermissionActivity.java @@ -167,9 +167,9 @@ private void sendPermissionResultAndFinish(boolean granted) { } /** - * 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. + * Converts a full Android permission string (e.g., "android.permission.POST_NOTIFICATIONS" or + * "android.permission.health.READ_HEART_RATE") into a more human-readable, simplified name (e.g., + * "POST NOTIFICATIONS" or "READ HEART RATE"). * * @param permission The full permission string. * @return A simplified, human-readable version of the permission name, or "Unknown" if the input @@ -178,10 +178,11 @@ private void sendPermissionResultAndFinish(boolean granted) { private String getSimplePermissionName(String permission) { if (permission == null) return "Unknown"; - if (permission.startsWith("android.permission.")) { - return permission.substring("android.permission.".length()).replace("_", " "); - } + // Extract the last part of the permission string (e.g., "READ_HEART_RATE") + int lastDotIndex = permission.lastIndexOf('.'); + String simpleName = (lastDotIndex != -1) ? permission.substring(lastDotIndex + 1) : permission; - return permission; + // Replace underscores with spaces for better readability + return simpleName.replace("_", " "); } } From 9f156e6364a822e3ddabe204b35ece229a34eb4d Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Mon, 25 May 2026 20:47:04 +0200 Subject: [PATCH 19/25] Support READ_HEART_RATE permission for Wear OS 6+ - Update `HeartRateService` to check and request permissions based on API level. - Use `HealthPermissions.READ_HEART_RATE` for Wear OS 6 (API 36) and higher. - Use `BODY_SENSORS` for Wear OS 5 (API 35) and lower. - Update `AndroidManifest.xml` to include the new health platform permission. --- wear/src/main/AndroidManifest.xml | 19 ++++++++++++- .../org/runnerup/hr/HeartRateService.java | 28 +++++++++++++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 453cd7235..bb51a2fc9 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -18,9 +18,26 @@ xmlns:tools="http://schemas.android.com/tools"> - + + + + + + + + + The required permission depends on the Android version: + *
    + *
  • Wear OS 6 (API 36) and higher: {@link HealthPermissions#READ_HEART_RATE}
  • + *
  • Wear OS 5 (API 35) and lower: {@link Manifest.permission#BODY_SENSORS}
  • + *
+ * + * @return {@code true} if the required permission is granted, {@code false} otherwise. + */ private boolean checkHeartRatePermission() { - boolean permissionGranted = ContextCompat.checkSelfPermission(this, Manifest.permission.BODY_SENSORS) == + String permission = getRequiredHeartRatePermission(); + boolean permissionGranted = ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED; - Log.d(TAG, "checkHeartRatePermission: permissionGranted=" + permissionGranted); + Log.d(TAG, "checkHeartRatePermission: permission=" + permission + ", granted=" + permissionGranted); return permissionGranted; } + private String getRequiredHeartRatePermission() { + if (Build.VERSION.SDK_INT >= 36) { + return HealthPermissions.READ_HEART_RATE; + } else { + return Manifest.permission.BODY_SENSORS; + } + } + private void launchPermissionActivity() { Log.d(TAG, "launchPermissionActivity"); Intent intent = new Intent(this, RequestPermissionActivity.class); - intent.putExtra(Constants.Intents.EXTRA_PERMISSION_TO_REQUEST, Manifest.permission.BODY_SENSORS); + intent.putExtra(Constants.Intents.EXTRA_PERMISSION_TO_REQUEST, getRequiredHeartRatePermission()); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); // Necessary when starting activity from a service startActivity(intent); } From fee13193718972cfa4cf98302ddf15d7495f8c96 Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Mon, 25 May 2026 21:20:31 +0200 Subject: [PATCH 20/25] Track battery level in HeartRateService - Use a `BroadcastReceiver` to monitor battery status changes on the Wear OS device. - Calculate and log the current battery percentage. --- .../org/runnerup/hr/HeartRateService.java | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/wear/src/main/java/org/runnerup/hr/HeartRateService.java b/wear/src/main/java/org/runnerup/hr/HeartRateService.java index c27ed5df4..e07c88425 100644 --- a/wear/src/main/java/org/runnerup/hr/HeartRateService.java +++ b/wear/src/main/java/org/runnerup/hr/HeartRateService.java @@ -29,6 +29,7 @@ import android.hardware.SensorEventListener; import android.hardware.SensorManager; import android.health.connect.HealthPermissions; +import android.os.BatteryManager; import android.os.Build; import android.os.IBinder; import android.util.Log; @@ -52,6 +53,7 @@ public class HeartRateService extends Service implements SensorEventListener { private Sensor heartRateSensor; private BroadcastReceiver hrPermissionReceiver; + private BroadcastReceiver batteryChangedReceiver; @Override public IBinder onBind(Intent intent) { @@ -65,6 +67,7 @@ public void onCreate() { Log.d(TAG, "onCreate"); setupPermissionReceiver(); + setupBatteryChangedReceiver(); // Initialize the SensorManager and attempt to get the default heart rate sensor. sensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE); @@ -120,12 +123,20 @@ private void startHeartRateMonitoring() { public void onDestroy() { Log.d(TAG, "onDestroy"); - // Unregister the receiver to prevent leaks + // Unregister the HR permission receiver if (hrPermissionReceiver != null) { LocalBroadcastManager.getInstance(this).unregisterReceiver(hrPermissionReceiver); Log.d(TAG, "onDestroy: HR Permission Receiver unregistered."); hrPermissionReceiver = null; } + + // Stop listening for battery changes + if (batteryChangedReceiver != null) { + unregisterReceiver(batteryChangedReceiver); + Log.d(TAG, "onDestroy: Battery Receiver unregistered."); + batteryChangedReceiver = null; + } + stopHeartRateMonitoring(); // Ensure monitoring is stopped super.onDestroy(); } @@ -263,4 +274,31 @@ public void onReceive(Context context, Intent intent) { LocalBroadcastManager.getInstance(this).registerReceiver(hrPermissionReceiver, filter); Log.d(TAG, "setupPermissionReceiver: HR Permission Receiver registered."); } + + private void setupBatteryChangedReceiver() { + Log.d(TAG, "setupBatteryChangedReceiver"); + + batteryChangedReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + Log.d(TAG, "batteryChangedReceiver.onReceive: action=" + action); + + if (Intent.ACTION_BATTERY_CHANGED.equals(intent.getAction())) { + int level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); + int scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1); + int batteryPercent = (int) ((level / (float) scale) * 100); + + Log.d(TAG, "batteryChangedReceiver.onReceive: battery level=" + batteryPercent + "%"); + + // TODO: send battery level to the phone + } + } + }; + + // Register the receiver + IntentFilter filter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED); + registerReceiver(batteryChangedReceiver, filter); + Log.d(TAG, "batteryChangedReceiver: Battery Receiver registered."); + } } From 9b4b9a2c328b604b631b58a43a9eaf47450714f4 Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Mon, 25 May 2026 21:33:56 +0200 Subject: [PATCH 21/25] Send Wear OS battery level to phone --- .../org/runnerup/common/util/Constants.java | 3 +++ .../org/runnerup/hr/HeartRateService.java | 20 ++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) 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 e9005052c..5e0288a93 100644 --- a/common/src/main/java/org/runnerup/common/util/Constants.java +++ b/common/src/main/java/org/runnerup/common/util/Constants.java @@ -269,6 +269,9 @@ interface Path { /* Msg: Heart rate data from wear to phone */ String MSG_HEART_RATE = PREFIX + "/hr/data"; + + /* Msg: Battery level from wear to phone */ + String MSG_BATTERY_LEVEL = PREFIX + "/battery/level"; } interface RunInfo { diff --git a/wear/src/main/java/org/runnerup/hr/HeartRateService.java b/wear/src/main/java/org/runnerup/hr/HeartRateService.java index e07c88425..473c56b84 100644 --- a/wear/src/main/java/org/runnerup/hr/HeartRateService.java +++ b/wear/src/main/java/org/runnerup/hr/HeartRateService.java @@ -242,6 +242,24 @@ private void sendHeartRateToPhone(int bpm) { .addOnFailureListener(e -> Log.e(TAG, "Error sending HR: " + e.getMessage())); } + private void sendBatteryLevelToPhone(int batteryLevel) { + Log.d(TAG, "sendBatteryLevelToPhone: batteryLevel=" + batteryLevel + ", sourceNodeId=" + sourceNodeId); + + if (sourceNodeId == null) { + Log.e(TAG, "sendBatteryLevelToPhone: sourceNodeId is null. Not sending battery level."); + return; + } + + byte[] payload = String.valueOf(batteryLevel).getBytes(); + + // Wearable API clients, such as DataClient and MessageClient, are inexpensive to create. + // So instead of holding onto the clients, recreate them when needed. + // https://developer.android.com/training/wearables/data/overview#recreate-client-instances + Wearable.getMessageClient(this).sendMessage(sourceNodeId, Constants.Wear.Path.MSG_BATTERY_LEVEL, payload) + .addOnSuccessListener(integer -> Log.d(TAG, "Battery level sent successfully: " + batteryLevel)) + .addOnFailureListener(e -> Log.e(TAG, "Error sending battery level: " + e.getMessage())); + } + private void setupPermissionReceiver() { Log.d(TAG, "setupPermissionReceiver"); @@ -291,7 +309,7 @@ public void onReceive(Context context, Intent intent) { Log.d(TAG, "batteryChangedReceiver.onReceive: battery level=" + batteryPercent + "%"); - // TODO: send battery level to the phone + sendBatteryLevelToPhone(batteryPercent); } } }; From 5841179f0829edc43036669db8ad6728face4c8d Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Fri, 5 Jun 2026 20:07:53 +0200 Subject: [PATCH 22/25] Receive Wear OS battery level in WearHRProvider --- hrdevice/src/org/runnerup/hr/WearHRProvider.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/hrdevice/src/org/runnerup/hr/WearHRProvider.java b/hrdevice/src/org/runnerup/hr/WearHRProvider.java index 68b2e724e..dd7f8bbf0 100644 --- a/hrdevice/src/org/runnerup/hr/WearHRProvider.java +++ b/hrdevice/src/org/runnerup/hr/WearHRProvider.java @@ -49,6 +49,7 @@ public class WearHRProvider implements HRProvider { private int hrValue = 0; private long hrTimestamp = 0; private long hrElapsedRealtime = 0; + private int batteryLevel = HRProvider.BATTERY_LEVEL_UNAVAILABLE; public WearHRProvider(Context context) { Log.d(TAG, "WearHRProvider: context=" + context); @@ -270,7 +271,7 @@ public HRData getHRData() { @Override public int getBatteryLevel() { - return HRProvider.BATTERY_LEVEL_UNAVAILABLE; + return batteryLevel; } /** @@ -304,6 +305,11 @@ private void postToHRClient(Runnable action) { hrElapsedRealtime = SystemClock.elapsedRealtimeNanos(); Log.d(TAG, "onMessageReceived: hrValue: " + hrValue); } + else if (Constants.Wear.Path.MSG_BATTERY_LEVEL.equals(path)) { + byte[] payload = messageEvent.getData(); + batteryLevel = Integer.parseInt(new String(payload)); + Log.d(TAG, "onMessageReceived: battery level: " + batteryLevel); + } }; public static boolean checkLibrary(Context ctx) { From 8b406c9fa23c31f2d90f8b62a022f36499e2db31 Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Fri, 14 Aug 2026 12:06:44 +0200 Subject: [PATCH 23/25] Address Copilot review feedback - replace HealthPermissions.READ_HEART_RATE with a literal permission string (HeartRateService) - register HEART_RATE_PROVIDER as a Wear OS capability (AndroidManifest) - validate battery level and scale values before calculating battery percentage (WearHRProvider) - use empty byte arrays instead of null message payloads (WearHRProvider) - clean up wearable client references and unregister listeners in close() (WearHRProvider) - add defensive parsing and validation of incoming heart rate messages (WearHRProvider) - gate WearHRProvider registration on Wearable API availability checks --- hrdevice/src/org/runnerup/hr/HRManager.java | 2 +- .../src/org/runnerup/hr/WearHRProvider.java | 33 ++++++++++++++----- wear/src/main/AndroidManifest.xml | 5 +++ .../org/runnerup/hr/HeartRateService.java | 16 +++++++-- 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/hrdevice/src/org/runnerup/hr/HRManager.java b/hrdevice/src/org/runnerup/hr/HRManager.java index db60e522c..55b44669e 100644 --- a/hrdevice/src/org/runnerup/hr/HRManager.java +++ b/hrdevice/src/org/runnerup/hr/HRManager.java @@ -131,7 +131,7 @@ public static List getHRProviderList(Context ctx) { } } - if (wear_os) { + if (wear_os && WearHRProvider.checkLibrary(ctx)) { providers.add(new WearHRProvider(ctx)); } diff --git a/hrdevice/src/org/runnerup/hr/WearHRProvider.java b/hrdevice/src/org/runnerup/hr/WearHRProvider.java index dd7f8bbf0..0ac292413 100644 --- a/hrdevice/src/org/runnerup/hr/WearHRProvider.java +++ b/hrdevice/src/org/runnerup/hr/WearHRProvider.java @@ -87,7 +87,14 @@ public void open(Handler handler, HRClient hrClient) { } @Override - public void close(String from) {} + public void close(String from) { + Log.d(TAG, "close: from=" + from); + // Best-effort cleanup even if we never reached a fully connected state. + Wearable.getMessageClient(context).removeListener(onHRMessageListener); + reset(); + hrClient = null; + hrClientHandler = null; + } @Override public boolean includePairingBLE() { @@ -183,7 +190,7 @@ public void connect(HRDeviceRef ref) { .sendMessage( connectedNodeId, Constants.Wear.Path.MSG_CMD_HR_START, - null // No payload needed for the start command + new byte[0] // No payload needed for the start command ) .addOnSuccessListener( integer -> { @@ -223,7 +230,7 @@ public void disconnect() { Wearable.getMessageClient(context).sendMessage( connectedNodeId, Constants.Wear.Path.MSG_CMD_HR_STOP, - null // No payload needed for the stop command + new byte[0] // No payload needed for the stop command ); // Regardless of success or failure, consider us to be disconnected @@ -300,15 +307,23 @@ private void postToHRClient(Runnable action) { if (Constants.Wear.Path.MSG_HEART_RATE.equals(path)) { byte[] payload = messageEvent.getData(); - hrValue = Integer.parseInt(new String(payload)); - hrTimestamp = System.currentTimeMillis(); - hrElapsedRealtime = SystemClock.elapsedRealtimeNanos(); - Log.d(TAG, "onMessageReceived: hrValue: " + hrValue); + try { + hrValue = Integer.parseInt(new String(payload)); + hrTimestamp = System.currentTimeMillis(); + hrElapsedRealtime = SystemClock.elapsedRealtimeNanos(); + Log.d(TAG, "onMessageReceived: hrValue: " + hrValue); + } catch (NumberFormatException e) { + Log.w(TAG, "onMessageReceived: invalid HR payload", e); + } } else if (Constants.Wear.Path.MSG_BATTERY_LEVEL.equals(path)) { byte[] payload = messageEvent.getData(); - batteryLevel = Integer.parseInt(new String(payload)); - Log.d(TAG, "onMessageReceived: battery level: " + batteryLevel); + try { + batteryLevel = Integer.parseInt(new String(payload)); + Log.d(TAG, "onMessageReceived: battery level: " + batteryLevel); + } catch (NumberFormatException e) { + Log.w(TAG, "onMessageReceived: invalid battery payload", e); + } } }; diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index bb51a2fc9..6f1c68239 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -85,6 +85,11 @@
+ + + The required permission depends on the Android version: *
    - *
  • Wear OS 6 (API 36) and higher: {@link HealthPermissions#READ_HEART_RATE}
  • + *
  • Wear OS 6 (API 36) and higher: {@code android.permission.health.READ_HEART_RATE}
  • *
  • Wear OS 5 (API 35) and lower: {@link Manifest.permission#BODY_SENSORS}
  • *
* @@ -182,7 +188,7 @@ private boolean checkHeartRatePermission() { private String getRequiredHeartRatePermission() { if (Build.VERSION.SDK_INT >= 36) { - return HealthPermissions.READ_HEART_RATE; + return READ_HEART_RATE_PERMISSION; } else { return Manifest.permission.BODY_SENSORS; } @@ -305,6 +311,10 @@ public void onReceive(Context context, Intent intent) { if (Intent.ACTION_BATTERY_CHANGED.equals(intent.getAction())) { int level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); int scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1); + if (level < 0 || scale <= 0) { + Log.w(TAG, "batteryChangedReceiver.onReceive: unknown battery level/scale: level=" + level + ", scale=" + scale); + return; + } int batteryPercent = (int) ((level / (float) scale) * 100); Log.d(TAG, "batteryChangedReceiver.onReceive: battery level=" + batteryPercent + "%"); From 6828ef1afc96b751ff1fe98c5a3d19797ceef3d1 Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Fri, 14 Aug 2026 12:26:43 +0200 Subject: [PATCH 24/25] Suppress WearRecents lint warning in launchPermissionActivity The lint warning is a false positive. The activity is launched with FLAG_ACTIVITY_NEW_TASK, which is required when starting an activity from a non-Activity context. --- wear/src/main/java/org/runnerup/hr/HeartRateService.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/wear/src/main/java/org/runnerup/hr/HeartRateService.java b/wear/src/main/java/org/runnerup/hr/HeartRateService.java index 1b25eccc8..d396e6c31 100644 --- a/wear/src/main/java/org/runnerup/hr/HeartRateService.java +++ b/wear/src/main/java/org/runnerup/hr/HeartRateService.java @@ -18,6 +18,7 @@ package org.runnerup.hr; import android.Manifest; +import android.annotation.SuppressLint; import android.app.Service; import android.content.BroadcastReceiver; import android.content.Context; @@ -194,6 +195,7 @@ private String getRequiredHeartRatePermission() { } } + @SuppressLint("WearRecents") private void launchPermissionActivity() { Log.d(TAG, "launchPermissionActivity"); Intent intent = new Intent(this, RequestPermissionActivity.class); From 71b8beaf6ef5737f7399360de73ba4bcd8bb437d Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Fri, 14 Aug 2026 12:27:27 +0200 Subject: [PATCH 25/25] Run .\gradlew spotlessApply --- .../org/runnerup/common/util/Constants.java | 2 + .../src/org/runnerup/hr/WearHRProvider.java | 533 +++++++++--------- .../runnerup/hr/HeartRateListenerService.java | 61 +- .../org/runnerup/hr/HeartRateService.java | 145 +++-- 4 files changed, 396 insertions(+), 345 deletions(-) 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 5e0288a93..6021d15ea 100644 --- a/common/src/main/java/org/runnerup/common/util/Constants.java +++ b/common/src/main/java/org/runnerup/common/util/Constants.java @@ -205,8 +205,10 @@ interface Intents { String START_WORKOUT = BuildConfig.applicationIdFull + ".START_WORKOUT"; String PAUSE_WORKOUT = BuildConfig.applicationIdFull + ".PAUSE_WORKOUT"; String RESUME_WORKOUT = BuildConfig.applicationIdFull + ".RESUME_WORKOUT"; + /** ID of the node (device) that sent the message/data */ String EXTRA_SOURCE_NODE_ID = BuildConfig.applicationIdFull + ".EXTRA_SOURCE_NODE_ID"; + // Used from Wear: Request permission String EXTRA_PERMISSION_TO_REQUEST = BuildConfig.applicationIdFull + ".EXTRA_PERMISSION_TO_REQUEST"; diff --git a/hrdevice/src/org/runnerup/hr/WearHRProvider.java b/hrdevice/src/org/runnerup/hr/WearHRProvider.java index 0ac292413..dfdce2f9b 100644 --- a/hrdevice/src/org/runnerup/hr/WearHRProvider.java +++ b/hrdevice/src/org/runnerup/hr/WearHRProvider.java @@ -34,159 +34,176 @@ import org.runnerup.common.util.Constants; public class WearHRProvider implements HRProvider { - private static final String TAG = "WearHRProvider"; - public static final String NAME = "Wear OS"; - - private final Context context; - private HRClient hrClient; - private Handler hrClientHandler; - private boolean isScanning; - private boolean isConnecting; - private boolean isConnected; - private boolean isDisconnecting; - private String connectedNodeId; - - private int hrValue = 0; - private long hrTimestamp = 0; - private long hrElapsedRealtime = 0; - private int batteryLevel = HRProvider.BATTERY_LEVEL_UNAVAILABLE; - - public WearHRProvider(Context context) { - Log.d(TAG, "WearHRProvider: context=" + context); - this.context = context; + private static final String TAG = "WearHRProvider"; + public static final String NAME = "Wear OS"; + + private final Context context; + private HRClient hrClient; + private Handler hrClientHandler; + private boolean isScanning; + private boolean isConnecting; + private boolean isConnected; + private boolean isDisconnecting; + private String connectedNodeId; + + private int hrValue = 0; + private long hrTimestamp = 0; + private long hrElapsedRealtime = 0; + private int batteryLevel = HRProvider.BATTERY_LEVEL_UNAVAILABLE; + + public WearHRProvider(Context context) { + Log.d(TAG, "WearHRProvider: context=" + context); + this.context = context; + } + + @Override + public String getName() { + return NAME; + } + + @Override + public String getProviderName() { + return NAME; + } + + @Override + public boolean isEnabled() { + return true; + } + + @Override + public boolean startEnableIntent(AppCompatActivity activity, int requestCode) { + return false; // Assume watch is already paired/connected to phone + } + + @Override + public void open(Handler handler, HRClient hrClient) { + Log.d(TAG, "open: handler=" + handler + ", hrClient=" + hrClient); + + this.hrClient = hrClient; + this.hrClientHandler = handler; + + postToHRClient(() -> hrClient.onOpenResult(true)); + } + + @Override + public void close(String from) { + Log.d(TAG, "close: from=" + from); + // Best-effort cleanup even if we never reached a fully connected state. + Wearable.getMessageClient(context).removeListener(onHRMessageListener); + reset(); + hrClient = null; + hrClientHandler = null; + } + + @Override + public boolean includePairingBLE() { + return false; + } + + @Override + public boolean isScanning() { + return isScanning; + } + + @Override + public boolean isConnected() { + return isConnected; + } + + @Override + public boolean isConnecting() { + return isConnecting; + } + + @Override + public void startScan() { + Log.d(TAG, "startScan"); + + if (isScanning) { + Log.d(TAG, "startScan: Scan already in progress."); + return; } - @Override - public String getName() { - return NAME; - } - - @Override - public String getProviderName() { - return NAME; - } - - @Override - public boolean isEnabled() { - return true; - } - - @Override - public boolean startEnableIntent(AppCompatActivity activity, int requestCode) { - return false; // Assume watch is already paired/connected to phone - } - - @Override - public void open(Handler handler, HRClient hrClient) { - Log.d(TAG, "open: handler=" + handler + ", hrClient=" + hrClient); - - this.hrClient = hrClient; - this.hrClientHandler = handler; - - postToHRClient(() -> hrClient.onOpenResult(true)); - } - - @Override - public void close(String from) { - Log.d(TAG, "close: from=" + from); - // Best-effort cleanup even if we never reached a fully connected state. - Wearable.getMessageClient(context).removeListener(onHRMessageListener); - reset(); - hrClient = null; - hrClientHandler = null; - } - - @Override - public boolean includePairingBLE() { - return false; - } - - @Override - public boolean isScanning() { - return isScanning; - } - - @Override - public boolean isConnected() { - return isConnected; - } - - @Override - public boolean isConnecting() { - return isConnecting; - } - - @Override - public void startScan() { - Log.d(TAG, "startScan"); - - if (isScanning) { - Log.d(TAG, "startScan: Scan already in progress."); - return; - } - - isScanning = true; - - // Get nodes with capability to provide heart rate data for RunnerUp - CapabilityClient capabilityClient = Wearable.getCapabilityClient(context); - Task capabilityInfoTask = capabilityClient.getCapability( - Constants.Wear.Capability.HEART_RATE_PROVIDER, - CapabilityClient.FILTER_REACHABLE // Only currently connected and reachable nodes - ); - - capabilityInfoTask.addOnSuccessListener(capabilityInfo -> { - Set connectedNodes = capabilityInfo.getNodes(); - Log.d(TAG, "startScan: Successfully fetched capability info. Nodes found: " + connectedNodes.size()); - - if (!connectedNodes.isEmpty()) { - for (Node node : connectedNodes) { - Log.d(TAG, "startScan: Found capable node: " + node.getDisplayName() + " (" + node.getId() + ")"); - // Create an HRDeviceRef for each found node - HRDeviceRef deviceRef = HRDeviceRef.create( - NAME, // Provider name - node.getDisplayName(), // Device name - node.getId() // Device address (using node ID as address) - ); - - postToHRClient(() -> hrClient.onScanResult(deviceRef)); - } - } - else { - postToHRClient(() -> hrClient.log(this, "No Wear OS device found with HR capability")); + isScanning = true; + + // Get nodes with capability to provide heart rate data for RunnerUp + CapabilityClient capabilityClient = Wearable.getCapabilityClient(context); + Task capabilityInfoTask = + capabilityClient.getCapability( + Constants.Wear.Capability.HEART_RATE_PROVIDER, + CapabilityClient.FILTER_REACHABLE // Only currently connected and reachable nodes + ); + + capabilityInfoTask.addOnSuccessListener( + capabilityInfo -> { + Set connectedNodes = capabilityInfo.getNodes(); + Log.d( + TAG, + "startScan: Successfully fetched capability info. Nodes found: " + + connectedNodes.size()); + + if (!connectedNodes.isEmpty()) { + for (Node node : connectedNodes) { + Log.d( + TAG, + "startScan: Found capable node: " + + node.getDisplayName() + + " (" + + node.getId() + + ")"); + // Create an HRDeviceRef for each found node + HRDeviceRef deviceRef = + HRDeviceRef.create( + NAME, // Provider name + node.getDisplayName(), // Device name + node.getId() // Device address (using node ID as address) + ); + + postToHRClient(() -> hrClient.onScanResult(deviceRef)); } + } else { + postToHRClient(() -> hrClient.log(this, "No Wear OS device found with HR capability")); + } - stopScan(); + stopScan(); }); - capabilityInfoTask.addOnFailureListener(e -> { - Log.e(TAG, "startScan: Failed to get capabilities. Error message: " + e.getMessage()); - postToHRClient(() -> hrClient.log(this, e.getMessage())); - stopScan(); + capabilityInfoTask.addOnFailureListener( + e -> { + Log.e(TAG, "startScan: Failed to get capabilities. Error message: " + e.getMessage()); + postToHRClient(() -> hrClient.log(this, e.getMessage())); + stopScan(); }); - } + } - @Override - public void stopScan() { - Log.d(TAG, "stopScan"); - isScanning = false; - } + @Override + public void stopScan() { + Log.d(TAG, "stopScan"); + isScanning = false; + } - @Override - public void connect(HRDeviceRef ref) { - Log.d(TAG, "connect: device name=" + ref.getName() + ", address=" + ref.getAddress()); + @Override + public void connect(HRDeviceRef ref) { + Log.d(TAG, "connect: device name=" + ref.getName() + ", address=" + ref.getAddress()); - if (isConnecting || isConnected) { - Log.d(TAG, "connect: Already connecting or connected."); - return; - } - - // "Connecting" means sending a message to the Wear OS app to start sending HR data - isConnecting = true; - connectedNodeId = ref.getAddress(); // Store the node ID we are trying to connect to + if (isConnecting || isConnected) { + Log.d(TAG, "connect: Already connecting or connected."); + return; + } - // Send a message to the Wear OS app to start sending HR - Log.d(TAG, "connect: Sending " + Constants.Wear.Path.MSG_CMD_HR_START + " message to node: " + connectedNodeId); - Wearable.getMessageClient(context) + // "Connecting" means sending a message to the Wear OS app to start sending HR data + isConnecting = true; + connectedNodeId = ref.getAddress(); // Store the node ID we are trying to connect to + + // Send a message to the Wear OS app to start sending HR + Log.d( + TAG, + "connect: Sending " + + Constants.Wear.Path.MSG_CMD_HR_START + + " message to node: " + + connectedNodeId); + Wearable.getMessageClient(context) .sendMessage( connectedNodeId, Constants.Wear.Path.MSG_CMD_HR_START, @@ -205,137 +222,147 @@ public void connect(HRDeviceRef ref) { Wearable.getMessageClient(context).addListener(onHRMessageListener); } }) - .addOnFailureListener( + .addOnFailureListener( e -> { - Log.e(TAG, "Failed to send Start HR message to " + connectedNodeId + ": " + e.getMessage()); + Log.e( + TAG, + "Failed to send Start HR message to " + connectedNodeId + ": " + e.getMessage()); isConnected = false; isConnecting = false; postToHRClient(() -> hrClient.onConnectResult(false)); }); - } - - @Override - public void disconnect() { - Log.d(TAG, "disconnect"); - - if (!isConnected || isDisconnecting) { - Log.d(TAG, "disconnect: Not connected or already disconnecting."); - return; - } + } - // Send a message to the Wear OS app to stop sending HR - isDisconnecting = true; - if (connectedNodeId != null) { - Log.d(TAG, "disconnect: Sending " + Constants.Wear.Path.MSG_CMD_HR_STOP + " message to node: " + connectedNodeId); - Wearable.getMessageClient(context).sendMessage( - connectedNodeId, - Constants.Wear.Path.MSG_CMD_HR_STOP, - new byte[0] // No payload needed for the stop command - ); - - // Regardless of success or failure, consider us to be disconnected - Log.d(TAG, "disconnect: Disconnected from " + connectedNodeId); - postToHRClient(() -> hrClient.onDisconnectResult(true)); - reset(); - - // Stop listening for HR data - Wearable.getMessageClient(context).removeListener(onHRMessageListener); - } - } + @Override + public void disconnect() { + Log.d(TAG, "disconnect"); - private void reset() { - Log.d(TAG, "reset"); - isConnecting = false; - isConnected = false; - isDisconnecting = false; - isScanning = false; - connectedNodeId = null; + if (!isConnected || isDisconnecting) { + Log.d(TAG, "disconnect: Not connected or already disconnecting."); + return; } - @Override - public int getHRValue() { - return hrValue; + // Send a message to the Wear OS app to stop sending HR + isDisconnecting = true; + if (connectedNodeId != null) { + Log.d( + TAG, + "disconnect: Sending " + + Constants.Wear.Path.MSG_CMD_HR_STOP + + " message to node: " + + connectedNodeId); + Wearable.getMessageClient(context) + .sendMessage( + connectedNodeId, + Constants.Wear.Path.MSG_CMD_HR_STOP, + new byte[0] // No payload needed for the stop command + ); + + // Regardless of success or failure, consider us to be disconnected + Log.d(TAG, "disconnect: Disconnected from " + connectedNodeId); + postToHRClient(() -> hrClient.onDisconnectResult(true)); + reset(); + + // Stop listening for HR data + Wearable.getMessageClient(context).removeListener(onHRMessageListener); } - - @Override - public long getHRValueTimestamp() { - return hrTimestamp; + } + + private void reset() { + Log.d(TAG, "reset"); + isConnecting = false; + isConnected = false; + isDisconnecting = false; + isScanning = false; + connectedNodeId = null; + } + + @Override + public int getHRValue() { + return hrValue; + } + + @Override + public long getHRValueTimestamp() { + return hrTimestamp; + } + + @Override + public long getHRValueElapsedRealtime() { + return this.hrElapsedRealtime; + } + + @Override + public HRData getHRData() { + if (hrValue <= 0) { + return null; } - @Override - public long getHRValueElapsedRealtime() { - return this.hrElapsedRealtime; - } - - @Override - public HRData getHRData() { - if (hrValue <= 0) { - return null; - } - - return new HRData().setHeartRate(hrValue).setTimestampEstimate(hrTimestamp); - } - - @Override - public int getBatteryLevel() { - return batteryLevel; - } - - /** - * Helper method to safely post actions to be executed on the {@link HRClient}'s handler thread. - * - * @param action The action to perform on the hrClient. - */ - private void postToHRClient(Runnable action) { - if (hrClientHandler != null && hrClient != null) { - hrClientHandler.post(() -> { - // Re-check hrClient as it might have been nulled out between posting and execution - if (hrClient != null) { - action.run(); - } else { - Log.w(TAG, "postToHRClient: hrClient became null before action execution on handler."); - } - }); - } else { - Log.w(TAG, "postToHRClient: Cannot post to hrClient: hrClientHandler or hrClient is null."); - } + return new HRData().setHeartRate(hrValue).setTimestampEstimate(hrTimestamp); + } + + @Override + public int getBatteryLevel() { + return batteryLevel; + } + + /** + * Helper method to safely post actions to be executed on the {@link HRClient}'s handler thread. + * + * @param action The action to perform on the hrClient. + */ + private void postToHRClient(Runnable action) { + if (hrClientHandler != null && hrClient != null) { + hrClientHandler.post( + () -> { + // Re-check hrClient as it might have been nulled out between posting and execution + if (hrClient != null) { + action.run(); + } else { + Log.w( + TAG, "postToHRClient: hrClient became null before action execution on handler."); + } + }); + } else { + Log.w(TAG, "postToHRClient: Cannot post to hrClient: hrClientHandler or hrClient is null."); } + } - private final MessageClient.OnMessageReceivedListener onHRMessageListener = messageEvent -> { + private final MessageClient.OnMessageReceivedListener onHRMessageListener = + messageEvent -> { String path = messageEvent.getPath(); Log.d(TAG, "onMessageReceived: " + path); if (Constants.Wear.Path.MSG_HEART_RATE.equals(path)) { - byte[] payload = messageEvent.getData(); - try { - hrValue = Integer.parseInt(new String(payload)); - hrTimestamp = System.currentTimeMillis(); - hrElapsedRealtime = SystemClock.elapsedRealtimeNanos(); - Log.d(TAG, "onMessageReceived: hrValue: " + hrValue); - } catch (NumberFormatException e) { - Log.w(TAG, "onMessageReceived: invalid HR payload", e); - } - } - else if (Constants.Wear.Path.MSG_BATTERY_LEVEL.equals(path)) { - byte[] payload = messageEvent.getData(); - try { - batteryLevel = Integer.parseInt(new String(payload)); - Log.d(TAG, "onMessageReceived: battery level: " + batteryLevel); - } catch (NumberFormatException e) { - Log.w(TAG, "onMessageReceived: invalid battery payload", e); - } + byte[] payload = messageEvent.getData(); + try { + hrValue = Integer.parseInt(new String(payload)); + hrTimestamp = System.currentTimeMillis(); + hrElapsedRealtime = SystemClock.elapsedRealtimeNanos(); + Log.d(TAG, "onMessageReceived: hrValue: " + hrValue); + } catch (NumberFormatException e) { + Log.w(TAG, "onMessageReceived: invalid HR payload", e); + } + } else if (Constants.Wear.Path.MSG_BATTERY_LEVEL.equals(path)) { + byte[] payload = messageEvent.getData(); + try { + batteryLevel = Integer.parseInt(new String(payload)); + Log.d(TAG, "onMessageReceived: battery level: " + batteryLevel); + } catch (NumberFormatException e) { + Log.w(TAG, "onMessageReceived: invalid battery payload", e); + } } - }; + }; - public static boolean checkLibrary(Context ctx) { - GoogleApiAvailability apiAvailability = GoogleApiAvailability.getInstance(); - int resultCode = apiAvailability.isGooglePlayServicesAvailable(ctx); + public static boolean checkLibrary(Context ctx) { + GoogleApiAvailability apiAvailability = GoogleApiAvailability.getInstance(); + int resultCode = apiAvailability.isGooglePlayServicesAvailable(ctx); - if (resultCode != ConnectionResult.SUCCESS) { - Log.w(TAG, "Google Play services is not installed or is not enabled on this device."); - return false; - } - - return true; + if (resultCode != ConnectionResult.SUCCESS) { + Log.w(TAG, "Google Play services is not installed or is not enabled on this device."); + return false; } + + return true; + } } diff --git a/wear/src/main/java/org/runnerup/hr/HeartRateListenerService.java b/wear/src/main/java/org/runnerup/hr/HeartRateListenerService.java index 2e1a665a4..409f16908 100644 --- a/wear/src/main/java/org/runnerup/hr/HeartRateListenerService.java +++ b/wear/src/main/java/org/runnerup/hr/HeartRateListenerService.java @@ -25,44 +25,45 @@ import org.runnerup.common.util.Constants; /** - * A {@link WearableListenerService} that listens for messages from a connected phone - * to control heart rate monitoring on the wearable device. - *

- * It receives messages via the Wearable Data Layer. Specifically, it handles: + * A {@link WearableListenerService} that listens for messages from a connected phone to control + * heart rate monitoring on the wearable device. + * + *

It receives messages via the Wearable Data Layer. Specifically, it handles: + * *

    - *
  • {@link Constants.Wear.Path#MSG_CMD_HR_START}: Starts the {@link HeartRateService}, - * passing the source node ID of the phone as an extra.
  • - *
  • {@link Constants.Wear.Path#MSG_CMD_HR_STOP}: Stops the {@link HeartRateService}.
  • + *
  • {@link Constants.Wear.Path#MSG_CMD_HR_START}: Starts the {@link HeartRateService}, passing + * the source node ID of the phone as an extra. + *
  • {@link Constants.Wear.Path#MSG_CMD_HR_STOP}: Stops the {@link HeartRateService}. *
*/ public class HeartRateListenerService extends WearableListenerService { - private static final String TAG = "HeartRateListenerService"; + private static final String TAG = "HeartRateListenerService"; - @Override - public void onCreate() { - super.onCreate(); - Log.d(TAG, "onCreate"); - } + @Override + public void onCreate() { + super.onCreate(); + Log.d(TAG, "onCreate"); + } - @Override - public void onDestroy() { - super.onDestroy(); - Log.d(TAG, "onDestroy"); - } + @Override + public void onDestroy() { + super.onDestroy(); + Log.d(TAG, "onDestroy"); + } - @Override - public void onMessageReceived(@NonNull MessageEvent messageEvent) { - String path = messageEvent.getPath(); - String sourceNodeId = messageEvent.getSourceNodeId(); // ID of the phone that sent the message - Log.d(TAG, "onMessageReceived: " + path + " from " + sourceNodeId); + @Override + public void onMessageReceived(@NonNull MessageEvent messageEvent) { + String path = messageEvent.getPath(); + String sourceNodeId = messageEvent.getSourceNodeId(); // ID of the phone that sent the message + Log.d(TAG, "onMessageReceived: " + path + " from " + sourceNodeId); - Intent serviceIntent = new Intent(this, HeartRateService.class); + Intent serviceIntent = new Intent(this, HeartRateService.class); - if (Constants.Wear.Path.MSG_CMD_HR_START.equals(path)) { - serviceIntent.putExtra(Constants.Intents.EXTRA_SOURCE_NODE_ID, sourceNodeId); - startService(serviceIntent); - } else if (Constants.Wear.Path.MSG_CMD_HR_STOP.equals(path)) { - stopService(serviceIntent); - } + if (Constants.Wear.Path.MSG_CMD_HR_START.equals(path)) { + serviceIntent.putExtra(Constants.Intents.EXTRA_SOURCE_NODE_ID, sourceNodeId); + startService(serviceIntent); + } else if (Constants.Wear.Path.MSG_CMD_HR_STOP.equals(path)) { + stopService(serviceIntent); } + } } diff --git a/wear/src/main/java/org/runnerup/hr/HeartRateService.java b/wear/src/main/java/org/runnerup/hr/HeartRateService.java index d396e6c31..d5a1f62ec 100644 --- a/wear/src/main/java/org/runnerup/hr/HeartRateService.java +++ b/wear/src/main/java/org/runnerup/hr/HeartRateService.java @@ -52,10 +52,11 @@ public class HeartRateService extends Service implements SensorEventListener { // android.health.connect classes may trigger class-loading failures on older // Wear OS versions where those classes are unavailable. private static final String READ_HEART_RATE_PERMISSION = - "android.permission.health.READ_HEART_RATE"; + "android.permission.health.READ_HEART_RATE"; /** Node ID of the connected phone. */ private String sourceNodeId; + private SensorManager sensorManager; private Sensor heartRateSensor; @@ -112,8 +113,8 @@ private void startHeartRateMonitoring() { Log.d(TAG, "startHeartRateMonitoring"); if (sensorManager != null && heartRateSensor != null) { - boolean registered = sensorManager.registerListener( - this, heartRateSensor, SensorManager.SENSOR_DELAY_NORMAL); + boolean registered = + sensorManager.registerListener(this, heartRateSensor, SensorManager.SENSOR_DELAY_NORMAL); if (registered) { Log.d(TAG, "startHeartRateMonitoring: Heart rate sensor listener registered."); } else { @@ -121,7 +122,9 @@ private void startHeartRateMonitoring() { stopSelf(); // Stop if registration fails } } else { - Log.e(TAG, "startHeartRateMonitoring: SensorManager or HeartRateSensor is null in startHeartRateMonitoring."); + Log.e( + TAG, + "startHeartRateMonitoring: SensorManager or HeartRateSensor is null in startHeartRateMonitoring."); stopSelf(); } } @@ -154,7 +157,9 @@ private void attemptToStartHeartRateMonitoring() { if (checkHeartRatePermission()) { startHeartRateMonitoring(); } else { - Log.w(TAG, "attemptToStartHeartRateMonitoring: Permission not granted for HR monitoring. Requesting..."); + Log.w( + TAG, + "attemptToStartHeartRateMonitoring: Permission not granted for HR monitoring. Requesting..."); launchPermissionActivity(); // The result will be handled by hrPermissionReceiver } } @@ -172,18 +177,21 @@ private void stopHeartRateMonitoring() { * Checks if the app has the necessary permission to access heart rate data. * *

The required permission depends on the Android version: + * *

    - *
  • Wear OS 6 (API 36) and higher: {@code android.permission.health.READ_HEART_RATE}
  • - *
  • Wear OS 5 (API 35) and lower: {@link Manifest.permission#BODY_SENSORS}
  • + *
  • Wear OS 6 (API 36) and higher: {@code android.permission.health.READ_HEART_RATE} + *
  • Wear OS 5 (API 35) and lower: {@link Manifest.permission#BODY_SENSORS} *
* * @return {@code true} if the required permission is granted, {@code false} otherwise. */ private boolean checkHeartRatePermission() { String permission = getRequiredHeartRatePermission(); - boolean permissionGranted = ContextCompat.checkSelfPermission(this, permission) == - PackageManager.PERMISSION_GRANTED; - Log.d(TAG, "checkHeartRatePermission: permission=" + permission + ", granted=" + permissionGranted); + boolean permissionGranted = + ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED; + Log.d( + TAG, + "checkHeartRatePermission: permission=" + permission + ", granted=" + permissionGranted); return permissionGranted; } @@ -199,8 +207,10 @@ private String getRequiredHeartRatePermission() { private void launchPermissionActivity() { Log.d(TAG, "launchPermissionActivity"); Intent intent = new Intent(this, RequestPermissionActivity.class); - intent.putExtra(Constants.Intents.EXTRA_PERMISSION_TO_REQUEST, getRequiredHeartRatePermission()); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); // Necessary when starting activity from a service + intent.putExtra( + Constants.Intents.EXTRA_PERMISSION_TO_REQUEST, getRequiredHeartRatePermission()); + intent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK); // Necessary when starting activity from a service startActivity(intent); } @@ -213,7 +223,7 @@ public void onSensorChanged(SensorEvent event) { // the heart rate value should be discarded. // https://developer.android.com/reference/android/hardware/Sensor#TYPE_HEART_RATE if (event.accuracy == SensorManager.SENSOR_STATUS_UNRELIABLE - || event.accuracy == SensorManager.SENSOR_STATUS_NO_CONTACT) { + || event.accuracy == SensorManager.SENSOR_STATUS_NO_CONTACT) { Log.d(TAG, "onSensorChanged: HR value accuracy is unreliable or no contact."); return; } @@ -245,13 +255,16 @@ private void sendHeartRateToPhone(int bpm) { // Wearable API clients, such as DataClient and MessageClient, are inexpensive to create. // So instead of holding onto the clients, recreate them when needed. // https://developer.android.com/training/wearables/data/overview#recreate-client-instances - Wearable.getMessageClient(this).sendMessage(sourceNodeId, Constants.Wear.Path.MSG_HEART_RATE, payload) - .addOnSuccessListener(integer -> Log.d(TAG, "HR sent successfully: " + bpm)) - .addOnFailureListener(e -> Log.e(TAG, "Error sending HR: " + e.getMessage())); + Wearable.getMessageClient(this) + .sendMessage(sourceNodeId, Constants.Wear.Path.MSG_HEART_RATE, payload) + .addOnSuccessListener(integer -> Log.d(TAG, "HR sent successfully: " + bpm)) + .addOnFailureListener(e -> Log.e(TAG, "Error sending HR: " + e.getMessage())); } private void sendBatteryLevelToPhone(int batteryLevel) { - Log.d(TAG, "sendBatteryLevelToPhone: batteryLevel=" + batteryLevel + ", sourceNodeId=" + sourceNodeId); + Log.d( + TAG, + "sendBatteryLevelToPhone: batteryLevel=" + batteryLevel + ", sourceNodeId=" + sourceNodeId); if (sourceNodeId == null) { Log.e(TAG, "sendBatteryLevelToPhone: sourceNodeId is null. Not sending battery level."); @@ -263,37 +276,39 @@ private void sendBatteryLevelToPhone(int batteryLevel) { // Wearable API clients, such as DataClient and MessageClient, are inexpensive to create. // So instead of holding onto the clients, recreate them when needed. // https://developer.android.com/training/wearables/data/overview#recreate-client-instances - Wearable.getMessageClient(this).sendMessage(sourceNodeId, Constants.Wear.Path.MSG_BATTERY_LEVEL, payload) - .addOnSuccessListener(integer -> Log.d(TAG, "Battery level sent successfully: " + batteryLevel)) - .addOnFailureListener(e -> Log.e(TAG, "Error sending battery level: " + e.getMessage())); + Wearable.getMessageClient(this) + .sendMessage(sourceNodeId, Constants.Wear.Path.MSG_BATTERY_LEVEL, payload) + .addOnSuccessListener( + integer -> Log.d(TAG, "Battery level sent successfully: " + batteryLevel)) + .addOnFailureListener(e -> Log.e(TAG, "Error sending battery level: " + e.getMessage())); } private void setupPermissionReceiver() { Log.d(TAG, "setupPermissionReceiver"); - hrPermissionReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - String action = intent.getAction(); - Log.d(TAG, "onReceive: action=" + action); - - if (Constants.Intents.ACTION_PERMISSION_RESULT.equals(action)) { - boolean permissionGranted = intent.getBooleanExtra( - Constants.Intents.EXTRA_PERMISSION_GRANTED, false); - - Log.d(TAG, "onReceive: permissionGranted=" + permissionGranted); - - if (permissionGranted) { - startHeartRateMonitoring(); + hrPermissionReceiver = + new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + Log.d(TAG, "onReceive: action=" + action); + + if (Constants.Intents.ACTION_PERMISSION_RESULT.equals(action)) { + boolean permissionGranted = + intent.getBooleanExtra(Constants.Intents.EXTRA_PERMISSION_GRANTED, false); + + Log.d(TAG, "onReceive: permissionGranted=" + permissionGranted); + + if (permissionGranted) { + startHeartRateMonitoring(); + } else { + Log.w(TAG, "onReceive: stopping service due to missing permission"); + stopSelf(); // Stop if permission is not granted + // TODO: Notify phone app that permission is missing? + } + } } - else { - Log.w(TAG, "onReceive: stopping service due to missing permission"); - stopSelf(); // Stop if permission is not granted - // TODO: Notify phone app that permission is missing? - } - } - } - }; + }; // Register the receiver IntentFilter filter = new IntentFilter(Constants.Intents.ACTION_PERMISSION_RESULT); @@ -304,27 +319,33 @@ public void onReceive(Context context, Intent intent) { private void setupBatteryChangedReceiver() { Log.d(TAG, "setupBatteryChangedReceiver"); - batteryChangedReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - String action = intent.getAction(); - Log.d(TAG, "batteryChangedReceiver.onReceive: action=" + action); - - if (Intent.ACTION_BATTERY_CHANGED.equals(intent.getAction())) { - int level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); - int scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1); - if (level < 0 || scale <= 0) { - Log.w(TAG, "batteryChangedReceiver.onReceive: unknown battery level/scale: level=" + level + ", scale=" + scale); - return; + batteryChangedReceiver = + new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + Log.d(TAG, "batteryChangedReceiver.onReceive: action=" + action); + + if (Intent.ACTION_BATTERY_CHANGED.equals(intent.getAction())) { + int level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); + int scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1); + if (level < 0 || scale <= 0) { + Log.w( + TAG, + "batteryChangedReceiver.onReceive: unknown battery level/scale: level=" + + level + + ", scale=" + + scale); + return; + } + int batteryPercent = (int) ((level / (float) scale) * 100); + + Log.d(TAG, "batteryChangedReceiver.onReceive: battery level=" + batteryPercent + "%"); + + sendBatteryLevelToPhone(batteryPercent); + } } - int batteryPercent = (int) ((level / (float) scale) * 100); - - Log.d(TAG, "batteryChangedReceiver.onReceive: battery level=" + batteryPercent + "%"); - - sendBatteryLevelToPhone(batteryPercent); - } - } - }; + }; // Register the receiver IntentFilter filter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);