feat(server): implemente synchronisation et serveur avec fixtures (#187)

- Implémente la couche de synchronisation avec le serveur
- Ajoute les fixtures versionnées pour les tests
- Met à jour Drift database et repositories pour le support sync
- Améliore les tests de synchronisation
- Corrige et améliore le watch companion pour la collecte de métriques

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 23:07:31 +02:00
parent d1edb2397e
commit fe3608cc4a
31 changed files with 5056 additions and 394 deletions

View File

@ -42,9 +42,6 @@ object WatchBridgePlugin {
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 =
"android.permission.health.READ_HEART_RATE"
private var appContext: Context? = null
private var activity: Activity? = null
private var projectionSink: EventChannel.EventSink? = null
@ -570,24 +567,11 @@ object WatchBridgePlugin {
}
private fun requiredSensorPermissions(): List<String> {
val heartRatePermission = if (Build.VERSION.SDK_INT >= 36) {
READ_HEART_RATE_PERMISSION
} else {
android.Manifest.permission.BODY_SENSORS
}
return listOf(
heartRatePermission,
android.Manifest.permission.ACTIVITY_RECOGNITION,
android.Manifest.permission.ACCESS_FINE_LOCATION,
)
return WatchSensorPermissionPolicy.requiredSensorPermissions(Build.VERSION.SDK_INT)
}
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
return WatchSensorPermissionPolicy.requiredRuntimePermissions(Build.VERSION.SDK_INT)
}
private fun telemetryContext(projection: Map<String, Any?>): Map<String, Any?> {
@ -631,6 +615,31 @@ object WatchBridgePlugin {
}
}
internal object WatchSensorPermissionPolicy {
private const val READ_HEART_RATE_PERMISSION =
"android.permission.health.READ_HEART_RATE"
fun requiredSensorPermissions(sdkInt: Int): List<String> {
val heartRatePermission = if (sdkInt >= 36) {
READ_HEART_RATE_PERMISSION
} else {
android.Manifest.permission.BODY_SENSORS
}
return listOf(
heartRatePermission,
android.Manifest.permission.ACTIVITY_RECOGNITION,
)
}
fun requiredRuntimePermissions(sdkInt: Int): List<String> {
val permissions = requiredSensorPermissions(sdkInt).toMutableList()
if (sdkInt >= Build.VERSION_CODES.TIRAMISU) {
permissions.add(android.Manifest.permission.POST_NOTIFICATIONS)
}
return permissions
}
}
private fun JSONObject.toMap(): Map<String, Any?> {
val output = linkedMapOf<String, Any?>()
val keys = keys()

View File

@ -116,6 +116,11 @@ internal class WatchHeartRateCollector(
}
var updated = false
var latestHeartRateBpm: Int? = null
var distanceDeltaMeters = 0.0
var distancePointCount = 0
var caloriesDeltaKcal = 0.0
var caloriesPointCount = 0
val receivedAtEpochMs = System.currentTimeMillis()
for (point in update.latestMetrics.getData(DataType.HEART_RATE_BPM)) {
latestHeartRateBpm = recordHeartRate(point.value)
}
@ -127,6 +132,8 @@ internal class WatchHeartRateCollector(
val value = point.value
if (value > 0) {
distanceMeters = (distanceMeters ?: 0.0) + value
distanceDeltaMeters += value
distancePointCount += 1
updated = true
}
}
@ -134,12 +141,18 @@ internal class WatchHeartRateCollector(
val value = point.value
if (value > 0) {
caloriesKcal = (caloriesKcal ?: 0.0) + value
caloriesDeltaKcal += value
caloriesPointCount += 1
updated = true
}
}
if (latestHeartRateBpm != null || updated) {
logHotPath(
"exercise metrics received sessionId=$sessionId bpm=$latestHeartRateBpm distance=$distanceMeters calories=$caloriesKcal",
"exercise metrics received sessionId=$sessionId " +
"receivedAtEpochMs=$receivedAtEpochMs bpm=$latestHeartRateBpm " +
"distanceDelta=$distanceDeltaMeters distancePoints=$distancePointCount " +
"distanceTotal=$distanceMeters caloriesDelta=$caloriesDeltaKcal " +
"caloriesPoints=$caloriesPointCount caloriesTotal=$caloriesKcal",
)
sendSample(latestHeartRateBpm)
}
@ -334,7 +347,7 @@ internal class WatchHeartRateCollector(
val requestedTypeNames = (
executionContext["healthServicesExerciseTypeStrategy"] as? List<*>
)?.filterIsInstance<String>().orEmpty()
val configs = exerciseConfigsFromCapabilities(
val configs = WatchExerciseMetricsConfigFactory.fromCapabilities(
capabilities,
requestedTypeNames,
canUseGps = canStartGpsExercise(context),
@ -447,74 +460,6 @@ internal class WatchHeartRateCollector(
}
}
private fun exerciseConfigsFromCapabilities(
capabilities: androidx.health.services.client.data.ExerciseCapabilities,
requestedTypeNames: List<String>,
canUseGps: Boolean,
): List<ExerciseConfig> {
val requestedTypes = exerciseTypesFromNames(requestedTypeNames)
val distanceCaloriesConfigs = mutableListOf<ExerciseConfig>()
val caloriesConfigs = mutableListOf<ExerciseConfig>()
val heartRateConfigs = mutableListOf<ExerciseConfig>()
for (exerciseType in requestedTypes) {
if (exerciseType !in capabilities.supportedExerciseTypes) {
continue
}
val supported = capabilities.getExerciseTypeCapabilities(exerciseType)
.supportedDataTypes
val supportsHeartRate = DataType.HEART_RATE_BPM in supported
if (
canUseGps &&
DataType.DISTANCE in supported &&
DataType.CALORIES in supported
) {
val dataTypes = mutableSetOf<androidx.health.services.client.data.DataType<*, *>>(
DataType.DISTANCE,
DataType.CALORIES,
)
if (supportsHeartRate) {
dataTypes.add(DataType.HEART_RATE_BPM)
}
distanceCaloriesConfigs.add(
ExerciseConfig.builder(exerciseType)
.setDataTypes(dataTypes)
.setIsAutoPauseAndResumeEnabled(false)
.setIsGpsEnabled(true)
.build(),
)
}
if (DataType.CALORIES in supported) {
val dataTypes = mutableSetOf<androidx.health.services.client.data.DataType<*, *>>(
DataType.CALORIES,
)
if (supportsHeartRate) {
dataTypes.add(DataType.HEART_RATE_BPM)
}
caloriesConfigs.add(
ExerciseConfig.builder(exerciseType)
.setDataTypes(dataTypes)
.setIsAutoPauseAndResumeEnabled(false)
.setIsGpsEnabled(false)
.build(),
)
} else if (supportsHeartRate) {
heartRateConfigs.add(
ExerciseConfig.builder(exerciseType)
.setDataTypes(setOf(DataType.HEART_RATE_BPM))
.setIsAutoPauseAndResumeEnabled(false)
.setIsGpsEnabled(false)
.build(),
)
} else {
Log.w(
TAG,
"exercise type lacks usable metrics type=$exerciseType supported=$supported",
)
}
}
return distanceCaloriesConfigs + caloriesConfigs + heartRateConfigs
}
private fun canStartGpsExercise(context: Context): Boolean {
if (
context.checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) !=
@ -533,31 +478,6 @@ internal class WatchHeartRateCollector(
}
}
private fun exerciseTypesFromNames(names: List<String>): List<ExerciseType> {
val mapped = names.mapNotNull { name ->
when (name) {
"RUNNING" -> ExerciseType.RUNNING
"WALKING" -> ExerciseType.WALKING
"HIGH_INTENSITY_INTERVAL_TRAINING" ->
ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING
"WORKOUT" -> ExerciseType.WORKOUT
else -> null
}
}.toMutableList()
val fallback = listOf(
ExerciseType.RUNNING,
ExerciseType.WALKING,
ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING,
ExerciseType.WORKOUT,
)
for (exerciseType in fallback) {
if (exerciseType !in mapped) {
mapped.add(exerciseType)
}
}
return mapped.distinct()
}
private fun stopExerciseMetrics(context: Context) {
if (!exerciseMetricsStarted && !exerciseMetricsStartInFlight) {
return
@ -786,6 +706,138 @@ internal class WatchHeartRateCollector(
}
}
internal object WatchExerciseMetricsConfigFactory {
fun fromCapabilities(
capabilities: androidx.health.services.client.data.ExerciseCapabilities,
requestedTypeNames: List<String>,
canUseGps: Boolean,
): List<ExerciseConfig> {
return plansFromCapabilities(capabilities, requestedTypeNames, canUseGps)
.map { plan ->
buildConfig(plan.exerciseType, plan.dataTypes, enableGps = plan.enableGps)
}
}
fun plansFromCapabilities(
capabilities: androidx.health.services.client.data.ExerciseCapabilities,
requestedTypeNames: List<String>,
canUseGps: Boolean,
): List<WatchExerciseMetricsConfigPlan> {
val gpsDistanceConfigs = mutableListOf<WatchExerciseMetricsConfigPlan>()
val distanceConfigs = mutableListOf<WatchExerciseMetricsConfigPlan>()
val caloriesConfigs = mutableListOf<WatchExerciseMetricsConfigPlan>()
val heartRateConfigs = mutableListOf<WatchExerciseMetricsConfigPlan>()
for (exerciseType in exerciseTypesFromNames(requestedTypeNames)) {
if (exerciseType !in capabilities.supportedExerciseTypes) {
continue
}
val supported = capabilities.getExerciseTypeCapabilities(exerciseType)
.supportedDataTypes
val dataTypes = usableDataTypes(supported)
when {
DataType.DISTANCE in dataTypes -> {
if (canUseGps) {
gpsDistanceConfigs.add(
WatchExerciseMetricsConfigPlan(
exerciseType,
dataTypes,
enableGps = true,
),
)
}
distanceConfigs.add(
WatchExerciseMetricsConfigPlan(
exerciseType,
dataTypes,
enableGps = false,
),
)
}
DataType.CALORIES in dataTypes ->
caloriesConfigs.add(
WatchExerciseMetricsConfigPlan(
exerciseType,
dataTypes,
enableGps = false,
),
)
DataType.HEART_RATE_BPM in dataTypes ->
heartRateConfigs.add(
WatchExerciseMetricsConfigPlan(
exerciseType,
dataTypes,
enableGps = false,
),
)
else -> Log.w(
"GTWatchHeartRate",
"exercise type lacks usable metrics type=$exerciseType supported=$supported",
)
}
}
return gpsDistanceConfigs + distanceConfigs + caloriesConfigs + heartRateConfigs
}
private fun usableDataTypes(
supported: Set<androidx.health.services.client.data.DataType<*, *>>,
): Set<androidx.health.services.client.data.DataType<*, *>> {
val dataTypes = mutableSetOf<androidx.health.services.client.data.DataType<*, *>>()
if (DataType.DISTANCE in supported) {
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 dataTypes
}
private fun buildConfig(
exerciseType: ExerciseType,
dataTypes: Set<androidx.health.services.client.data.DataType<*, *>>,
enableGps: Boolean,
): ExerciseConfig {
return ExerciseConfig.builder(exerciseType)
.setDataTypes(dataTypes)
.setIsAutoPauseAndResumeEnabled(false)
.setIsGpsEnabled(enableGps)
.build()
}
private fun exerciseTypesFromNames(names: List<String>): List<ExerciseType> {
val mapped = names.mapNotNull { name ->
when (name) {
"RUNNING" -> ExerciseType.RUNNING
"WALKING" -> ExerciseType.WALKING
"HIGH_INTENSITY_INTERVAL_TRAINING" ->
ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING
"WORKOUT" -> ExerciseType.WORKOUT
else -> null
}
}.toMutableList()
val fallback = listOf(
ExerciseType.RUNNING,
ExerciseType.WALKING,
ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING,
ExerciseType.WORKOUT,
)
for (exerciseType in fallback) {
if (exerciseType !in mapped) {
mapped.add(exerciseType)
}
}
return mapped.distinct()
}
}
internal data class WatchExerciseMetricsConfigPlan(
val exerciseType: ExerciseType,
val dataTypes: Set<androidx.health.services.client.data.DataType<*, *>>,
val enableGps: Boolean,
)
internal object WatchExerciseMetricsRetryPolicy {
fun isDistanceSecurityFailure(
dataTypes: Set<androidx.health.services.client.data.DataType<*, *>>,

View File

@ -1,8 +1,12 @@
package com.gametime.watch.bridge
import androidx.health.services.client.data.DataType
import androidx.health.services.client.data.ExerciseCapabilities
import androidx.health.services.client.data.ExerciseType
import androidx.health.services.client.data.ExerciseTypeCapabilities
import java.util.concurrent.ExecutionException
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@ -55,4 +59,73 @@ class WatchExerciseMetricsRetryPolicyTest {
assertTrue(WatchExerciseMetricsRetryPolicy.canRetryDistance(3))
assertFalse(WatchExerciseMetricsRetryPolicy.canRetryDistance(4))
}
@Test
fun distanceConfigDoesNotRequireGpsOrCalories() {
val configs = WatchExerciseMetricsConfigFactory.plansFromCapabilities(
capabilities(
ExerciseType.RUNNING to setOf(DataType.DISTANCE, DataType.HEART_RATE_BPM),
),
requestedTypeNames = listOf("RUNNING"),
canUseGps = false,
)
assertEquals(1, configs.size)
assertEquals(ExerciseType.RUNNING, configs.single().exerciseType)
assertFalse(configs.single().enableGps)
assertEquals(
setOf(DataType.DISTANCE, DataType.HEART_RATE_BPM),
configs.single().dataTypes,
)
}
@Test
fun gpsDistanceConfigKeepsNonGpsFallback() {
val configs = WatchExerciseMetricsConfigFactory.plansFromCapabilities(
capabilities(
ExerciseType.RUNNING to setOf(
DataType.DISTANCE,
DataType.CALORIES,
DataType.HEART_RATE_BPM,
),
),
requestedTypeNames = listOf("RUNNING"),
canUseGps = true,
)
assertEquals(2, configs.size)
assertTrue(configs[0].enableGps)
assertFalse(configs[1].enableGps)
assertEquals(configs[0].dataTypes, configs[1].dataTypes)
assertTrue(DataType.DISTANCE in configs[0].dataTypes)
assertTrue(DataType.CALORIES in configs[0].dataTypes)
}
@Test
fun runtimeSensorPermissionsDoNotRequireFineLocation() {
val permissions = WatchSensorPermissionPolicy.requiredRuntimePermissions(sdkInt = 35)
assertTrue(android.Manifest.permission.BODY_SENSORS in permissions)
assertTrue(android.Manifest.permission.ACTIVITY_RECOGNITION in permissions)
assertTrue(android.Manifest.permission.POST_NOTIFICATIONS in permissions)
assertFalse(android.Manifest.permission.ACCESS_FINE_LOCATION in permissions)
}
private fun capabilities(
vararg supportedTypes: Pair<
ExerciseType,
Set<androidx.health.services.client.data.DataType<*, *>>,
>,
): ExerciseCapabilities {
return ExerciseCapabilities(
supportedTypes.associate { (exerciseType, dataTypes) ->
exerciseType to ExerciseTypeCapabilities(
dataTypes,
emptyMap(),
emptyMap(),
false,
)
},
)
}
}

View File

@ -2,3 +2,4 @@
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
org.gradle.java.home=/usr/lib/jvm/java-21-openjdk