fix(watch): finalise correctif sync workoutHistory/exercise et distance live montre (#157)

This commit is contained in:
2026-07-29 11:22:17 +02:00
parent 6f913e4e8d
commit 30c6259748
28 changed files with 1658 additions and 248 deletions

View File

@ -7,6 +7,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission
android:name="android.permission.BODY_SENSORS"
android:maxSdkVersion="35" />
@ -36,7 +37,7 @@
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:launchMode="singleTask"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:windowSoftInputMode="adjustResize">
@ -47,6 +48,10 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="com.gametime.watch.OPEN_ACTIVE_SESSION" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<meta-data
android:name="flutterEmbedding"

View File

@ -1,5 +1,6 @@
package com.gametime.watch
import android.content.Intent
import android.os.Bundle
import androidx.wear.ambient.AmbientModeSupport
import com.gametime.watch.bridge.WatchBridgePlugin
@ -13,6 +14,18 @@ class MainActivity :
super.onCreate(savedInstanceState)
AmbientModeSupport.attach(this)
WatchBridgePlugin.attachActivity(this)
WatchBridgePlugin.handleActivityReentry(this, intent)
}
override fun onResume() {
super.onResume()
WatchBridgePlugin.handleActivityReentry(this, intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
WatchBridgePlugin.handleActivityReentry(this, intent)
}
override fun onDestroy() {

View File

@ -34,6 +34,7 @@ object WatchBridgePlugin {
const val ACK_PATH = "/gametime/phone/ack"
const val STATE_PATH = "/gametime/phone/projection"
const val PHONE_CAPABILITY = "gametime_phone_companion"
const val ACTION_OPEN_ACTIVE_SESSION = "com.gametime.watch.OPEN_ACTIVE_SESSION"
private const val SENSOR_PERMISSION_REQUEST = 4106
private const val SENSOR_PERMISSION_RETRY_DELAY_MS = 30000L
private const val READ_HEART_RATE_PERMISSION =
@ -56,6 +57,9 @@ object WatchBridgePlugin {
private var lastSensorPermissionRequestEpochMs = 0L
private var pendingSensorPermissionRequest = false
private var lastSensorProjection: Map<String, Any?>? = null
private var lastActiveProjection: Map<String, Any?>? = null
private var lastActiveProjectionReceivedAtEpochMs = 0L
private var activeProjectionExpiryRunnable: Runnable? = null
fun attachApplicationContext(context: Context) {
appContext = context.applicationContext
@ -123,6 +127,16 @@ object WatchBridgePlugin {
requestPendingSensorPermissionIfPossible()
}
fun handleActivityReentry(activity: Activity, intent: android.content.Intent?) {
attachActivity(activity)
val context = activity.applicationContext
requestCapabilityRefresh(context)
requestLatestProjection(context)
if (intent?.action == ACTION_OPEN_ACTIVE_SESSION && hasFreshActiveProjection()) {
emitProjection(lastActiveProjection ?: return)
}
}
fun detachActivity(activity: Activity) {
if (this.activity === activity) {
this.activity = null
@ -159,7 +173,9 @@ object WatchBridgePlugin {
}
fun emitProjection(payload: Map<String, Any?>): Boolean {
rememberActiveProjection(payload)
appContext?.let {
scheduleActiveProjectionExpiry(it, payload)
WatchOngoingActivityController.update(it, payload, activity)
updateHeartRateCollection(it, payload)
}
@ -229,10 +245,24 @@ object WatchBridgePlugin {
requestCapabilityRefresh(context)
result.success(null)
}
"invalidateActiveProjection" -> {
invalidateActiveProjection(context)
result.success(null)
}
else -> result.notImplemented()
}
}
fun invalidateActiveProjection(context: Context) {
lastActiveProjection = null
lastActiveProjectionReceivedAtEpochMs = 0L
activeProjectionExpiryRunnable?.let { mainHandler.removeCallbacks(it) }
activeProjectionExpiryRunnable = null
WatchOngoingActivityController.cancel(context)
WatchHeartRateForegroundService.stop(context)
heartRateCollector.finishCurrentSession(context)
}
private fun sendCommand(
context: Context,
arguments: Any?,
@ -318,6 +348,63 @@ object WatchBridgePlugin {
}
}
fun openActiveSessionIntent(context: Context): android.content.Intent {
return android.content.Intent(context, com.gametime.watch.MainActivity::class.java).apply {
action = ACTION_OPEN_ACTIVE_SESSION
flags = android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP or
android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP
}
}
private fun rememberActiveProjection(projection: Map<String, Any?>) {
val phase = projection["phase"] as? String ?: "noActiveSession"
val sessionId = projection["deviceSessionId"] as? String ?: ""
if (phase == "noActiveSession" || sessionId.isBlank()) {
lastActiveProjection = null
lastActiveProjectionReceivedAtEpochMs = 0L
activeProjectionExpiryRunnable?.let { mainHandler.removeCallbacks(it) }
activeProjectionExpiryRunnable = null
return
}
lastActiveProjection = projection
lastActiveProjectionReceivedAtEpochMs = System.currentTimeMillis()
}
private fun scheduleActiveProjectionExpiry(context: Context, projection: Map<String, Any?>) {
activeProjectionExpiryRunnable?.let { mainHandler.removeCallbacks(it) }
val phase = projection["phase"] as? String ?: "noActiveSession"
val sessionId = projection["deviceSessionId"] as? String ?: ""
if (phase == "noActiveSession" || sessionId.isBlank()) {
activeProjectionExpiryRunnable = null
return
}
val now = System.currentTimeMillis()
val expiresAt = (projection["expiresAtEpochMs"] as? Number)?.toLong() ?: 0L
val delayMs = if (expiresAt > 0L) {
(expiresAt - now).coerceAtLeast(0L)
} else {
12000L
}
val appContext = context.applicationContext
activeProjectionExpiryRunnable = Runnable {
if (!hasFreshActiveProjection()) {
invalidateActiveProjection(appContext)
}
}.also { runnable ->
mainHandler.postDelayed(runnable, delayMs + 250L)
}
}
private fun hasFreshActiveProjection(): Boolean {
val projection = lastActiveProjection ?: return false
val expiresAt = (projection["expiresAtEpochMs"] as? Number)?.toLong() ?: 0L
val now = System.currentTimeMillis()
if (expiresAt > 0L) {
return now < expiresAt
}
return now - lastActiveProjectionReceivedAtEpochMs <= 12000L
}
private fun updateHeartRateCollection(context: Context, projection: Map<String, Any?>) {
val phase = projection["phase"] as? String ?: "noActiveSession"
val sessionId = projection["deviceSessionId"] as? String ?: ""
@ -416,6 +503,7 @@ object WatchBridgePlugin {
return listOf(
heartRatePermission,
android.Manifest.permission.ACTIVITY_RECOGNITION,
android.Manifest.permission.ACCESS_FINE_LOCATION,
)
}

View File

@ -2,6 +2,8 @@ package com.gametime.watch.bridge
import android.content.Context
import android.util.Log
import androidx.health.services.client.ExerciseClient
import androidx.health.services.client.ExerciseUpdateCallback
import androidx.health.services.client.HealthServices
import androidx.health.services.client.MeasureClient
import androidx.health.services.client.MeasureCallback
@ -9,6 +11,10 @@ import androidx.health.services.client.data.Availability
import androidx.health.services.client.data.DataPointContainer
import androidx.health.services.client.data.DataType
import androidx.health.services.client.data.DeltaDataType
import androidx.health.services.client.data.ExerciseConfig
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.Wearable
import org.json.JSONObject
@ -35,10 +41,12 @@ internal class WatchHeartRateCollector(
private var sampleSequence = 0
private var executionContext: Map<String, Any?> = emptyMap()
private val registeredDataTypes = mutableSetOf<DeltaDataType<*, *>>()
private var exerciseMetricsStarted = false
private var exerciseMetricsStartInFlight = false
private var shouldAggregate = false
private var appContext: Context? = null
private val callback = object : MeasureCallback {
private val measureCallback = object : MeasureCallback {
override fun onAvailabilityChanged(
dataType: DeltaDataType<*, *>,
availability: Availability,
@ -54,21 +62,11 @@ internal class WatchHeartRateCollector(
for (point in data.getData(DataType.HEART_RATE_BPM)) {
latestHeartRateBpm = recordHeartRate(point.value)
}
var updatedDistance = false
for (point in data.getData(DataType.DISTANCE)) {
if (point.value > 0) {
distanceMeters = (distanceMeters ?: 0.0) + point.value
updatedDistance = true
}
}
var updatedCalories = false
for (point in data.getData(DataType.CALORIES)) {
if (point.value > 0) {
caloriesKcal = (caloriesKcal ?: 0.0) + point.value
updatedCalories = true
}
}
if (latestHeartRateBpm != null || updatedDistance || updatedCalories) {
if (latestHeartRateBpm != null) {
Log.d(
TAG,
"heart rate data received sessionId=$sessionId bpm=$latestHeartRateBpm",
)
sendSample(latestHeartRateBpm)
}
}
@ -78,6 +76,57 @@ internal class WatchHeartRateCollector(
}
}
private val exerciseCallback = object : ExerciseUpdateCallback {
override fun onRegistered() {
Log.d(TAG, "exercise update callback registered sessionId=$sessionId")
}
override fun onRegistrationFailed(throwable: Throwable) {
Log.w(TAG, "exercise update callback registration failed", throwable)
}
override fun onExerciseUpdateReceived(update: androidx.health.services.client.data.ExerciseUpdate) {
if (!shouldAggregate) {
return
}
var updated = false
for (point in update.latestMetrics.getData(DataType.DISTANCE)) {
val value = point.value
if (value > 0) {
distanceMeters = (distanceMeters ?: 0.0) + value
updated = true
}
}
for (point in update.latestMetrics.getData(DataType.CALORIES)) {
val value = point.value
if (value > 0) {
caloriesKcal = (caloriesKcal ?: 0.0) + value
updated = true
}
}
if (updated) {
Log.d(
TAG,
"exercise metrics received sessionId=$sessionId distance=$distanceMeters calories=$caloriesKcal",
)
sendSample(null)
}
}
override fun onLapSummaryReceived(lapSummary: ExerciseLapSummary) {}
override fun onAvailabilityChanged(
dataType: androidx.health.services.client.data.DataType<*, *>,
availability: Availability,
) {
Log.d(TAG, "exercise availability dataType=$dataType availability=$availability")
}
override fun onExerciseEventReceived(event: ExerciseEvent) {
Log.d(TAG, "exercise event sessionId=$sessionId event=$event")
}
}
fun noteActiveSession(
nextSessionId: String,
shouldAggregate: Boolean,
@ -103,17 +152,18 @@ internal class WatchHeartRateCollector(
appContext = context.applicationContext
val measureClient = HealthServices.getClient(context).measureClient
registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM)
registerMeasureCallbackIfNeeded(measureClient, DataType.DISTANCE)
registerMeasureCallbackIfNeeded(measureClient, DataType.CALORIES)
startExerciseMetrics(context)
}
fun pause(context: Context) {
shouldAggregate = false
unregister(context)
stopExerciseMetrics(context)
}
fun finishCurrentSession(context: Context) {
unregister(context)
stopExerciseMetrics(context)
val completedSessionId = sessionId
if (!completedSessionId.isNullOrBlank() && sampleCount >= 3) {
sendSummary(context, completedSessionId)
@ -188,6 +238,8 @@ internal class WatchHeartRateCollector(
"minHeartRateBpm" to min,
"averageHeartRateBpm" to sampleSum / sampleCount,
"maxHeartRateBpm" to max,
"distanceMeters" to distanceMeters,
"caloriesKcal" to caloriesKcal,
),
).toString().toByteArray(StandardCharsets.UTF_8)
Wearable.getCapabilityClient(context)
@ -213,10 +265,9 @@ internal class WatchHeartRateCollector(
}
val measureClient = HealthServices.getClient(context).measureClient
for (dataType in registeredDataTypes.toList()) {
measureClient.unregisterMeasureCallbackAsync(dataType, callback)
measureClient.unregisterMeasureCallbackAsync(dataType, measureCallback)
}
registeredDataTypes.clear()
appContext = null
}
private fun registerMeasureCallbackIfNeeded(
@ -227,7 +278,7 @@ internal class WatchHeartRateCollector(
return
}
try {
measureClient.registerMeasureCallback(dataType, callback)
measureClient.registerMeasureCallback(dataType, measureCallback)
registeredDataTypes.add(dataType)
Log.d(TAG, "measure callback registered dataType=$dataType sessionId=$sessionId")
} catch (error: RuntimeException) {
@ -235,6 +286,108 @@ internal class WatchHeartRateCollector(
}
}
private fun startExerciseMetrics(context: Context) {
if (exerciseMetricsStarted || exerciseMetricsStartInFlight) {
return
}
val exerciseClient = HealthServices.getClient(context).exerciseClient
exerciseMetricsStartInFlight = true
val capabilitiesFuture = exerciseClient.getCapabilitiesAsync()
capabilitiesFuture.addListener(
{
try {
val capabilities = capabilitiesFuture.get()
val config = exerciseConfigFromCapabilities(capabilities)
if (config == null) {
exerciseMetricsStartInFlight = false
Log.w(TAG, "no exercise type supports distance metrics sessionId=$sessionId")
return@addListener
}
exerciseClient.setUpdateCallback(context.mainExecutor, exerciseCallback)
val startFuture = exerciseClient.startExerciseAsync(config)
startFuture.addListener(
{
exerciseMetricsStartInFlight = false
try {
startFuture.get()
exerciseMetricsStarted = true
Log.d(
TAG,
"exercise metrics started sessionId=$sessionId type=${config.exerciseType} dataTypes=${config.dataTypes}",
)
} catch (error: Exception) {
Log.w(TAG, "exercise metrics start failed", error)
clearExerciseCallback(exerciseClient)
}
},
context.mainExecutor,
)
} catch (error: Exception) {
exerciseMetricsStartInFlight = false
Log.w(TAG, "exercise capabilities lookup failed", error)
}
},
context.mainExecutor,
)
}
private fun exerciseConfigFromCapabilities(
capabilities: androidx.health.services.client.data.ExerciseCapabilities,
): ExerciseConfig? {
val requestedTypes = listOf(
ExerciseType.WORKOUT,
ExerciseType.RUNNING,
ExerciseType.WALKING,
ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING,
)
for (exerciseType in requestedTypes) {
if (exerciseType !in capabilities.supportedExerciseTypes) {
continue
}
val supported = capabilities.getExerciseTypeCapabilities(exerciseType)
.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",
)
continue
}
dataTypes.add(DataType.DISTANCE)
if (DataType.CALORIES in supported) {
dataTypes.add(DataType.CALORIES)
}
return ExerciseConfig.builder(exerciseType)
.setDataTypes(dataTypes)
.setIsAutoPauseAndResumeEnabled(false)
.setIsGpsEnabled(true)
.build()
}
return null
}
private fun stopExerciseMetrics(context: Context) {
if (!exerciseMetricsStarted && !exerciseMetricsStartInFlight) {
return
}
val exerciseClient = HealthServices.getClient(context).exerciseClient
clearExerciseCallback(exerciseClient)
if (exerciseMetricsStarted) {
exerciseClient.endExerciseAsync()
}
exerciseMetricsStarted = false
exerciseMetricsStartInFlight = false
}
private fun clearExerciseCallback(exerciseClient: ExerciseClient) {
try {
exerciseClient.clearUpdateCallbackAsync(exerciseCallback)
} catch (error: RuntimeException) {
Log.w(TAG, "clear exercise update callback failed", error)
}
}
private fun reset(nextSessionId: String?) {
sessionId = nextSessionId
sampleCount = 0

View File

@ -13,7 +13,6 @@ import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import com.gametime.watch.MainActivity
import com.gametime.watch.R
internal class WatchHeartRateForegroundService : Service() {
@ -71,9 +70,7 @@ internal class WatchHeartRateForegroundService : Service() {
val touchIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
},
WatchBridgePlugin.openActiveSessionIntent(this),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)

View File

@ -6,14 +6,12 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.wear.ongoing.OngoingActivity
import androidx.wear.ongoing.Status
import com.gametime.watch.MainActivity
import com.gametime.watch.R
object WatchOngoingActivityController {
@ -47,9 +45,7 @@ object WatchOngoingActivityController {
val touchIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
},
WatchBridgePlugin.openActiveSessionIntent(context),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val exerciseName = (projection["exerciseName"] as? String)