fix(watch): stabilise stats live, foreground et resync apres perte de connexion (#179-#189)

Reduit le cout radio des samples live et la cadence des projections telephone -> montre (#179-#183).
Restaure les statistiques live FC/distance/calories et le maintien foreground/ongoing activity (#184-#185).
Fiabilise le demarrage de seance et l'orchestration des permissions montre (#187).
Renforce la resynchronisation des statistiques live et du score apres perte puis retour de connexion (#188-#189).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 08:08:45 +02:00
parent 639231e3fd
commit 7130635177
15 changed files with 1023 additions and 119 deletions

View File

@ -38,6 +38,7 @@ class WatchBridgeListenerService : WearableListenerService() {
requestsResync = capabilityInfo.nodes.isNotEmpty(),
)
if (capabilityInfo.nodes.isNotEmpty()) {
WatchBridgePlugin.resyncLiveStatsAfterReconnect(applicationContext)
WatchBridgePlugin.requestLatestProjection(applicationContext)
}
}

View File

@ -275,6 +275,7 @@ object WatchBridgePlugin {
"requestResync" -> {
requestLatestProjection(context)
requestCapabilityRefresh(context)
resyncLiveStatsAfterReconnect(context)
result.success(null)
}
"invalidateActiveProjection" -> {
@ -291,7 +292,7 @@ object WatchBridgePlugin {
activeProjectionExpiryRunnable?.let { mainHandler.removeCallbacks(it) }
activeProjectionExpiryRunnable = null
WatchOngoingActivityController.cancel(context)
WatchHeartRateForegroundService.stop(context)
WatchHeartRateForegroundService.stop(context, force = true)
heartRateCollector.finishCurrentSession(context)
}
@ -347,6 +348,9 @@ object WatchBridgePlugin {
Wearable.getCapabilityClient(context)
.getCapability(PHONE_CAPABILITY, CapabilityClient.FILTER_REACHABLE)
.addOnSuccessListener { capability ->
if (capability.nodes.isNotEmpty()) {
resyncLiveStatsAfterReconnect(context)
}
emitConnection(
isReachable = capability.nodes.isNotEmpty(),
requestsResync = capability.nodes.isNotEmpty(),
@ -357,6 +361,12 @@ object WatchBridgePlugin {
}
}
fun resyncLiveStatsAfterReconnect(context: Context) {
heartRateCollector.onPhoneReconnected(context)
val projection = lastSensorProjection ?: lastActiveProjection ?: return
updateHeartRateCollection(context, projection)
}
fun requestLatestProjection(context: Context) {
val uri = Uri.Builder()
.scheme("wear")
@ -468,13 +478,13 @@ object WatchBridgePlugin {
val sessionId = projection["deviceSessionId"] as? String ?: ""
if (phase == "noActiveSession" || sessionId.isBlank()) {
lastSensorProjection = null
WatchHeartRateForegroundService.stop(context)
WatchHeartRateForegroundService.stop(context, force = phase == "noActiveSession")
heartRateCollector.finishCurrentSession(context)
return
}
lastSensorProjection = projection
val shouldAggregate = phase == "running"
if (!hasRequiredSensorPermissions(context)) {
if (!hasRequiredRuntimePermissions(context)) {
WatchHeartRateForegroundService.stop(context)
heartRateCollector.noteActiveSession(
sessionId,
@ -506,12 +516,18 @@ object WatchBridgePlugin {
}
}
private fun hasRequiredRuntimePermissions(context: Context): Boolean {
return requiredRuntimePermissions().all { permission ->
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
}
}
private fun requestSensorPermissionsOnce() {
val activity = activity ?: run {
Log.d(TAG, "sensor permission request pending: activity unavailable")
return
}
val permissions = requiredSensorPermissions()
val permissions = requiredRuntimePermissions()
.filter { activity.checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED }
.toTypedArray()
if (permissions.isEmpty()) {
@ -546,7 +562,7 @@ object WatchBridgePlugin {
private fun requestPendingSensorPermissionIfPossible() {
val context = appContext ?: return
if (!pendingSensorPermissionRequest || hasRequiredSensorPermissions(context)) {
if (!pendingSensorPermissionRequest || hasRequiredRuntimePermissions(context)) {
return
}
requestSensorPermissionsOnce()
@ -565,6 +581,14 @@ object WatchBridgePlugin {
)
}
private fun requiredRuntimePermissions(): List<String> {
val permissions = requiredSensorPermissions().toMutableList()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions.add(android.Manifest.permission.POST_NOTIFICATIONS)
}
return permissions
}
private fun telemetryContext(projection: Map<String, Any?>): Map<String, Any?> {
return mapOf(
"programIndex" to projection["programIndex"],

View File

@ -1,6 +1,9 @@
package com.gametime.watch.bridge
import android.content.Context
import android.content.pm.ApplicationInfo
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.health.services.client.ExerciseClient
import androidx.health.services.client.ExerciseUpdateCallback
@ -16,6 +19,7 @@ import androidx.health.services.client.data.ExerciseEvent
import androidx.health.services.client.data.ExerciseLapSummary
import androidx.health.services.client.data.ExerciseType
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.Node
import com.google.android.gms.wearable.Wearable
import org.json.JSONObject
import java.nio.charset.StandardCharsets
@ -29,8 +33,16 @@ internal class WatchHeartRateCollector(
) {
private companion object {
const val TAG = "GTWatchHeartRate"
const val SAMPLE_FLUSH_INTERVAL_MS = 1500L
const val NODE_CACHE_TTL_MS = 10000L
}
private val mainHandler = Handler(Looper.getMainLooper())
private var pendingSample: Map<String, Any?>? = null
private var sampleFlushRunnable: Runnable? = null
private var cachedReachableNodes: List<Node> = emptyList()
private var cachedReachableNodesAtEpochMs = 0L
private var nodeLookupInFlight = false
private var sessionId: String? = null
private var sampleCount = 0
private var sampleSum = 0.0
@ -43,6 +55,8 @@ internal class WatchHeartRateCollector(
private val registeredDataTypes = mutableSetOf<DeltaDataType<*, *>>()
private var exerciseMetricsStarted = false
private var exerciseMetricsStartInFlight = false
private var exerciseHeartRateSupported = false
private var exerciseHeartRateObserved = false
private var shouldAggregate = false
private var appContext: Context? = null
@ -63,8 +77,7 @@ internal class WatchHeartRateCollector(
latestHeartRateBpm = recordHeartRate(point.value)
}
if (latestHeartRateBpm != null) {
Log.d(
TAG,
logHotPath(
"heart rate data received sessionId=$sessionId bpm=$latestHeartRateBpm",
)
sendSample(latestHeartRateBpm)
@ -90,6 +103,14 @@ internal class WatchHeartRateCollector(
return
}
var updated = false
var latestHeartRateBpm: Int? = null
for (point in update.latestMetrics.getData(DataType.HEART_RATE_BPM)) {
latestHeartRateBpm = recordHeartRate(point.value)
}
if (latestHeartRateBpm != null && !exerciseHeartRateObserved) {
exerciseHeartRateObserved = true
appContext?.let(::unregister)
}
for (point in update.latestMetrics.getData(DataType.DISTANCE)) {
val value = point.value
if (value > 0) {
@ -104,12 +125,11 @@ internal class WatchHeartRateCollector(
updated = true
}
}
if (updated) {
Log.d(
TAG,
"exercise metrics received sessionId=$sessionId distance=$distanceMeters calories=$caloriesKcal",
if (latestHeartRateBpm != null || updated) {
logHotPath(
"exercise metrics received sessionId=$sessionId bpm=$latestHeartRateBpm distance=$distanceMeters calories=$caloriesKcal",
)
sendSample(null)
sendSample(latestHeartRateBpm)
}
}
@ -145,23 +165,31 @@ internal class WatchHeartRateCollector(
}
}
fun onPhoneReconnected(context: Context) {
appContext = context.applicationContext
cachedReachableNodes = emptyList()
cachedReachableNodesAtEpochMs = 0L
flushPendingSample(context, forceNodeRefresh = true)
}
fun start(context: Context) {
if (sessionId.isNullOrBlank()) {
return
}
appContext = context.applicationContext
val measureClient = HealthServices.getClient(context).measureClient
registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM)
startMeasureHeartRateFallback(context)
startExerciseMetrics(context)
}
fun pause(context: Context) {
shouldAggregate = false
flushPendingSample(context, forceNodeRefresh = false)
unregister(context)
stopExerciseMetrics(context)
}
fun finishCurrentSession(context: Context) {
flushPendingSample(context, forceNodeRefresh = false)
unregister(context)
stopExerciseMetrics(context)
val completedSessionId = sessionId
@ -209,22 +237,8 @@ internal class WatchHeartRateCollector(
"caloriesKcal" to caloriesKcal,
)
onLocalSample(sample)
val payload = JSONObject(sample).toString().toByteArray(StandardCharsets.UTF_8)
Wearable.getCapabilityClient(context)
.getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE)
.addOnSuccessListener { capability ->
Log.d(
TAG,
"send sample sessionId=$activeSessionId bpm=$bpm distance=$distanceMeters calories=$caloriesKcal nodes=${capability.nodes.size}",
)
for (node in capability.nodes) {
Wearable.getMessageClient(context)
.sendMessage(node.id, sensorSamplePath, payload)
}
}
.addOnFailureListener { error ->
Log.w(TAG, "sample capability lookup failed", error)
}
pendingSample = sample
scheduleSampleFlush(context)
}
private fun sendSummary(context: Context, completedSessionId: String) {
@ -245,6 +259,7 @@ internal class WatchHeartRateCollector(
Wearable.getCapabilityClient(context)
.getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE)
.addOnSuccessListener { capability ->
cacheReachableNodes(capability.nodes.toList())
Log.d(
TAG,
"send summary sessionId=$completedSessionId samples=$sampleCount nodes=${capability.nodes.size}",
@ -300,9 +315,17 @@ internal class WatchHeartRateCollector(
val config = exerciseConfigFromCapabilities(capabilities)
if (config == null) {
exerciseMetricsStartInFlight = false
Log.w(TAG, "no exercise type supports distance metrics sessionId=$sessionId")
Log.w(TAG, "no exercise type supports heart rate or distance sessionId=$sessionId")
if (shouldAggregate) {
startMeasureHeartRateFallback(context)
}
return@addListener
}
if (!shouldAggregate) {
exerciseMetricsStartInFlight = false
return@addListener
}
exerciseHeartRateSupported = DataType.HEART_RATE_BPM in config.dataTypes
exerciseClient.setUpdateCallback(context.mainExecutor, exerciseCallback)
val startFuture = exerciseClient.startExerciseAsync(config)
startFuture.addListener(
@ -318,6 +341,10 @@ internal class WatchHeartRateCollector(
} catch (error: Exception) {
Log.w(TAG, "exercise metrics start failed", error)
clearExerciseCallback(exerciseClient)
exerciseHeartRateSupported = false
if (shouldAggregate) {
startMeasureHeartRateFallback(context)
}
}
},
context.mainExecutor,
@ -340,6 +367,7 @@ internal class WatchHeartRateCollector(
ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING,
ExerciseType.WORKOUT,
)
var heartRateOnlyConfig: ExerciseConfig? = null
for (exerciseType in requestedTypes) {
if (exerciseType !in capabilities.supportedExerciseTypes) {
continue
@ -348,23 +376,37 @@ internal class WatchHeartRateCollector(
.supportedDataTypes
val dataTypes = mutableSetOf<androidx.health.services.client.data.DataType<*, *>>()
if (DataType.DISTANCE !in supported) {
Log.w(
TAG,
"exercise type lacks distance type=$exerciseType supported=$supported",
)
if (DataType.HEART_RATE_BPM !in supported) {
Log.w(
TAG,
"exercise type lacks distance and heart rate type=$exerciseType supported=$supported",
)
continue
}
dataTypes.add(DataType.HEART_RATE_BPM)
if (heartRateOnlyConfig == null) {
heartRateOnlyConfig = ExerciseConfig.builder(exerciseType)
.setDataTypes(dataTypes)
.setIsAutoPauseAndResumeEnabled(false)
.setIsGpsEnabled(false)
.build()
}
continue
}
dataTypes.add(DataType.DISTANCE)
if (DataType.CALORIES in supported) {
dataTypes.add(DataType.CALORIES)
}
if (DataType.HEART_RATE_BPM in supported) {
dataTypes.add(DataType.HEART_RATE_BPM)
}
return ExerciseConfig.builder(exerciseType)
.setDataTypes(dataTypes)
.setIsAutoPauseAndResumeEnabled(false)
.setIsGpsEnabled(true)
.build()
}
return null
return heartRateOnlyConfig
}
private fun stopExerciseMetrics(context: Context) {
@ -378,6 +420,7 @@ internal class WatchHeartRateCollector(
}
exerciseMetricsStarted = false
exerciseMetricsStartInFlight = false
exerciseHeartRateSupported = false
}
private fun clearExerciseCallback(exerciseClient: ExerciseClient) {
@ -389,6 +432,9 @@ internal class WatchHeartRateCollector(
}
private fun reset(nextSessionId: String?) {
sampleFlushRunnable?.let { mainHandler.removeCallbacks(it) }
sampleFlushRunnable = null
pendingSample = null
sessionId = nextSessionId
sampleCount = 0
sampleSum = 0.0
@ -399,5 +445,102 @@ internal class WatchHeartRateCollector(
sampleSequence = 0
executionContext = emptyMap()
shouldAggregate = false
exerciseHeartRateSupported = false
exerciseHeartRateObserved = false
}
private fun startMeasureHeartRateFallback(context: Context) {
if (exerciseHeartRateSupported) {
return
}
val measureClient = HealthServices.getClient(context).measureClient
registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM)
Log.d(TAG, "heart rate fallback MeasureClient active sessionId=$sessionId")
}
private fun logHotPath(message: String) {
val context = appContext ?: return
if ((context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0) {
Log.d(TAG, message)
}
}
private fun scheduleSampleFlush(context: Context) {
if (sampleFlushRunnable != null) {
return
}
val appContext = context.applicationContext
sampleFlushRunnable = Runnable {
sampleFlushRunnable = null
flushPendingSample(appContext, forceNodeRefresh = false)
}.also { runnable ->
mainHandler.postDelayed(runnable, SAMPLE_FLUSH_INTERVAL_MS)
}
}
private fun flushPendingSample(context: Context, forceNodeRefresh: Boolean) {
val sample = pendingSample ?: return
pendingSample = null
sampleFlushRunnable?.let { mainHandler.removeCallbacks(it) }
sampleFlushRunnable = null
sendSampleToNodes(context, sample, forceNodeRefresh)
}
private fun sendSampleToNodes(
context: Context,
sample: Map<String, Any?>,
forceNodeRefresh: Boolean,
) {
val payload = JSONObject(sample).toString().toByteArray(StandardCharsets.UTF_8)
val cachedNodes = cachedNodesIfFresh()
if (!forceNodeRefresh && cachedNodes.isNotEmpty()) {
sendSamplePayload(context, payload, cachedNodes, sample)
return
}
if (nodeLookupInFlight && cachedReachableNodes.isNotEmpty()) {
sendSamplePayload(context, payload, cachedReachableNodes, sample)
return
}
nodeLookupInFlight = true
Wearable.getCapabilityClient(context)
.getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE)
.addOnSuccessListener { capability ->
nodeLookupInFlight = false
val nodes = capability.nodes.toList()
cacheReachableNodes(nodes)
sendSamplePayload(context, payload, nodes, sample)
}
.addOnFailureListener { error ->
nodeLookupInFlight = false
Log.w(TAG, "sample capability lookup failed", error)
}
}
private fun sendSamplePayload(
context: Context,
payload: ByteArray,
nodes: List<Node>,
sample: Map<String, Any?>,
) {
logHotPath(
"send sample sessionId=${sample["sessionId"]} bpm=${sample["heartRateBpm"]} distance=${sample["distanceMeters"]} calories=${sample["caloriesKcal"]} nodes=${nodes.size}",
)
for (node in nodes) {
Wearable.getMessageClient(context)
.sendMessage(node.id, sensorSamplePath, payload)
}
}
private fun cachedNodesIfFresh(): List<Node> {
val now = System.currentTimeMillis()
if (now - cachedReachableNodesAtEpochMs > NODE_CACHE_TTL_MS) {
return emptyList()
}
return cachedReachableNodes
}
private fun cacheReachableNodes(nodes: List<Node>) {
cachedReachableNodes = nodes
cachedReachableNodesAtEpochMs = System.currentTimeMillis()
}
}

View File

@ -21,17 +21,25 @@ internal class WatchHeartRateForegroundService : Service() {
const val CHANNEL_NAME = "Collecte cardio GameTime"
const val NOTIFICATION_ID = 9102
const val EXTRA_EXERCISE_NAME = "exerciseName"
private var lastCommandKey: String? = null
private var serviceRequested = false
fun start(context: Context, projection: Map<String, Any?>) {
val sessionId = projection["deviceSessionId"] as? String ?: ""
val phase = projection["phase"] as? String ?: "noActiveSession"
if (sessionId.isBlank() || phase != "running") {
stop(context)
stop(context, force = phase == "noActiveSession")
return
}
val exerciseName = (projection["exerciseName"] as? String)
?.takeIf { it.isNotBlank() }
?: "Séance en cours"
val key = "$sessionId|$phase|$exerciseName"
if (serviceRequested && key == lastCommandKey) {
return
}
serviceRequested = true
lastCommandKey = key
ContextCompat.startForegroundService(
context,
Intent(context, WatchHeartRateForegroundService::class.java)
@ -39,7 +47,12 @@ internal class WatchHeartRateForegroundService : Service() {
)
}
fun stop(context: Context) {
fun stop(context: Context, force: Boolean = false) {
if (!force && !serviceRequested) {
return
}
serviceRequested = false
lastCommandKey = null
context.stopService(Intent(context, WatchHeartRateForegroundService::class.java))
}
}

View File

@ -21,25 +21,47 @@ object WatchOngoingActivityController {
private const val CHANNEL_NAME = "Séance GameTime"
private const val NOTIFICATION_ID = 9101
private const val ONGOING_ACTIVITY_ID = 9101
private const val REPUBLISH_INTERVAL_MS = 60000L
private var lastAppliedKey: String? = null
private var lastAppliedAtEpochMs = 0L
fun update(context: Context, projection: Map<String, Any?>, activity: Activity?) {
val phase = projection["phase"] as? String ?: "noActiveSession"
val sessionId = projection["deviceSessionId"] as? String ?: ""
if (phase == "noActiveSession" || sessionId.isBlank()) {
cancel(context)
cancel(context, force = phase == "noActiveSession")
return
}
val key = ongoingKey(projection)
val now = System.currentTimeMillis()
if (key == lastAppliedKey && now - lastAppliedAtEpochMs < REPUBLISH_INTERVAL_MS) {
return
}
if (!hasPostNotificationsPermission(context)) {
Log.d(TAG, "skip ongoing activity: POST_NOTIFICATIONS not granted")
return
}
lastAppliedKey = key
lastAppliedAtEpochMs = now
post(context, projection)
}
fun cancel(context: Context) {
fun cancel(context: Context, force: Boolean = true) {
if (!force && lastAppliedKey == null) {
return
}
lastAppliedKey = null
lastAppliedAtEpochMs = 0L
NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
}
private fun ongoingKey(projection: Map<String, Any?>): String {
val phase = projection["phase"] as? String ?: "noActiveSession"
val sessionId = projection["deviceSessionId"] as? String ?: ""
val exerciseName = projection["exerciseName"] as? String ?: ""
return "$sessionId|$phase|$exerciseName"
}
private fun post(context: Context, projection: Map<String, Any?>) {
ensureNotificationChannel(context)
val touchIntent = PendingIntent.getActivity(