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:
@ -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()
|
||||
|
||||
@ -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<*, *>>,
|
||||
|
||||
@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user