From e7ff1c2a1929f66f482e1d2c2b610ed2695c3443 Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 31 Jul 2026 16:58:07 +0200 Subject: [PATCH] fix(watch): corrige l'URL serveur et ajoute les tests associes (#186) Aligne http_api_client.dart avec la config watch (build.gradle.kts, WatchHeartRateCollector) et couvre le comportement par des tests. Co-Authored-By: Claude Opus 4.8 --- .../remote/http_api_client.dart | 7 +- .../remote/http_api_client_test.dart | 6 +- watch_app/android/app/build.gradle.kts | 2 + .../watch/bridge/WatchHeartRateCollector.kt | 155 ++++++++++++++++++ .../WatchExerciseMetricsRetryPolicyTest.kt | 58 +++++++ 5 files changed, 223 insertions(+), 5 deletions(-) create mode 100644 watch_app/android/app/src/test/kotlin/com/gametime/watch/bridge/WatchExerciseMetricsRetryPolicyTest.kt diff --git a/lib/infrastructure/remote/http_api_client.dart b/lib/infrastructure/remote/http_api_client.dart index 343ee6f..e4fac93 100644 --- a/lib/infrastructure/remote/http_api_client.dart +++ b/lib/infrastructure/remote/http_api_client.dart @@ -18,6 +18,9 @@ final class HttpApiClient { defaultValue: '', ); + static const androidEmulatorDefaultBaseUrl = 'http://10.0.2.2:8090'; + static const localDefaultBaseUrl = 'http://localhost:8080'; + static String get defaultBaseUrl => defaultBaseUrlFor(isAndroid: Platform.isAndroid); @@ -30,9 +33,9 @@ final class HttpApiClient { return configured; } if (isAndroid) { - return 'http://10.0.2.2:8080'; + return androidEmulatorDefaultBaseUrl; } - return 'http://localhost:8080'; + return localDefaultBaseUrl; } final Uri baseUrl; diff --git a/test/infrastructure/remote/http_api_client_test.dart b/test/infrastructure/remote/http_api_client_test.dart index 4aaaff2..68b4c6d 100644 --- a/test/infrastructure/remote/http_api_client_test.dart +++ b/test/infrastructure/remote/http_api_client_test.dart @@ -18,7 +18,7 @@ void main() { test('defaultBaseUrl uses Android emulator host without env override', () { expect( HttpApiClient.defaultBaseUrlFor(isAndroid: true), - 'http://10.0.2.2:8080', + 'http://10.0.2.2:8090', ); }); @@ -26,9 +26,9 @@ void main() { expect( HttpApiClient.defaultBaseUrlFor( isAndroid: true, - configuredBaseUrl: ' http://192.168.1.42:8080 ', + configuredBaseUrl: ' http://192.168.1.75:8090 ', ), - 'http://192.168.1.42:8080', + 'http://192.168.1.75:8090', ); }); diff --git a/watch_app/android/app/build.gradle.kts b/watch_app/android/app/build.gradle.kts index 3e928b6..de81756 100644 --- a/watch_app/android/app/build.gradle.kts +++ b/watch_app/android/app/build.gradle.kts @@ -80,4 +80,6 @@ dependencies { implementation("androidx.wear:wear-ongoing:1.0.0") implementation("com.google.guava:guava:33.6.0-android") implementation("com.google.android.gms:play-services-wearable:19.0.0") + + testImplementation(kotlin("test")) } diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt index 48a4375..13aabfd 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt @@ -27,6 +27,8 @@ import org.json.JSONObject import java.nio.charset.StandardCharsets import kotlin.math.roundToInt +private const val MAX_DISTANCE_SECURITY_RETRIES = 3 + internal class WatchHeartRateCollector( private val phoneCapability: String, private val sensorSummaryPath: String, @@ -36,12 +38,17 @@ internal class WatchHeartRateCollector( private companion object { const val TAG = "GTWatchHeartRate" const val SAMPLE_FLUSH_INTERVAL_MS = 1500L + const val SAMPLE_STALE_TIMEOUT_MS = 20000L + const val SAMPLE_WATCHDOG_INTERVAL_MS = 5000L + const val DISTANCE_RETRY_DELAY_MS = 30000L const val NODE_CACHE_TTL_MS = 10000L } private val mainHandler = Handler(Looper.getMainLooper()) private var pendingSample: Map? = null private var sampleFlushRunnable: Runnable? = null + private var sampleWatchdogRunnable: Runnable? = null + private var distanceRetryRunnable: Runnable? = null private var cachedReachableNodes: List = emptyList() private var cachedReachableNodesAtEpochMs = 0L private var nodeLookupInFlight = false @@ -59,7 +66,10 @@ internal class WatchHeartRateCollector( private var exerciseMetricsStartInFlight = false private var exerciseHeartRateSupported = false private var exerciseHeartRateObserved = false + private var distanceSecurityFailureCount = 0 + private var shouldRetryDistanceAfterSecurityFailure = false private var shouldAggregate = false + private var latestSampleAtEpochMs = 0L private var appContext: Context? = null private val measureCallback = object : MeasureCallback { @@ -179,6 +189,10 @@ internal class WatchHeartRateCollector( return } appContext = context.applicationContext + if (latestSampleAtEpochMs == 0L) { + latestSampleAtEpochMs = System.currentTimeMillis() + } + scheduleSampleWatchdog(context) startMeasureHeartRateFallback(context) startExerciseMetrics(context) } @@ -186,6 +200,8 @@ internal class WatchHeartRateCollector( fun pause(context: Context) { shouldAggregate = false flushPendingSample(context, forceNodeRefresh = false) + cancelSampleWatchdog() + cancelDistanceRetry() unregister(context) stopExerciseMetrics(context) } @@ -223,6 +239,7 @@ internal class WatchHeartRateCollector( val context = appContext ?: return sampleSequence += 1 val capturedAt = System.currentTimeMillis() + latestSampleAtEpochMs = capturedAt val sample = mapOf( "schemaVersion" to 4, "sampleId" to "$activeSessionId-$capturedAt-$sampleSequence", @@ -379,11 +396,26 @@ internal class WatchHeartRateCollector( startFuture.get() exerciseMetricsStartInFlight = false exerciseMetricsStarted = true + onExerciseMetricsStarted(context, config) Log.d( TAG, "exercise metrics started sessionId=$sessionId type=${config.exerciseType} dataTypes=${config.dataTypes}", ) } catch (error: Exception) { + val failedFromFineLocationSecurity = WatchExerciseMetricsRetryPolicy + .isDistanceSecurityFailure(config.dataTypes, error) + if (failedFromFineLocationSecurity) { + distanceSecurityFailureCount += 1 + shouldRetryDistanceAfterSecurityFailure = WatchExerciseMetricsRetryPolicy + .canRetryDistance(distanceSecurityFailureCount) + Log.w( + TAG, + "distance exercise metrics rejected by security " + + "type=${config.exerciseType} attempt=$distanceSecurityFailureCount " + + "willRetry=$shouldRetryDistanceAfterSecurityFailure", + error, + ) + } Log.w( TAG, "exercise metrics start failed type=${config.exerciseType} dataTypes=${config.dataTypes}", @@ -403,6 +435,18 @@ internal class WatchHeartRateCollector( ) } + private fun onExerciseMetricsStarted(context: Context, config: ExerciseConfig) { + if (DataType.DISTANCE in config.dataTypes) { + distanceSecurityFailureCount = 0 + shouldRetryDistanceAfterSecurityFailure = false + cancelDistanceRetry() + return + } + if (shouldRetryDistanceAfterSecurityFailure) { + scheduleDistanceRetry(context) + } + } + private fun exerciseConfigsFromCapabilities( capabilities: androidx.health.services.client.data.ExerciseCapabilities, requestedTypeNames: List, @@ -528,6 +572,37 @@ internal class WatchHeartRateCollector( exerciseHeartRateSupported = false } + private fun retryDistanceMetrics(context: Context) { + val activeSessionId = sessionId + if ( + activeSessionId.isNullOrBlank() || + !shouldAggregate || + !shouldRetryDistanceAfterSecurityFailure || + exerciseMetricsStartInFlight + ) { + return + } + Log.w( + TAG, + "retrying distance exercise metrics sessionId=$activeSessionId attempt=$distanceSecurityFailureCount", + ) + stopExerciseMetrics(context) + startExerciseMetrics(context) + } + + private fun restartExerciseMetrics(context: Context) { + val activeSessionId = sessionId + if (activeSessionId.isNullOrBlank() || !shouldAggregate) { + return + } + Log.w(TAG, "sample watchdog restarting exercise metrics sessionId=$activeSessionId") + stopExerciseMetrics(context) + unregister(context) + latestSampleAtEpochMs = System.currentTimeMillis() + startMeasureHeartRateFallback(context) + startExerciseMetrics(context) + } + private fun clearExerciseCallback(exerciseClient: ExerciseClient) { try { exerciseClient.clearUpdateCallbackAsync(exerciseCallback) @@ -548,12 +623,17 @@ internal class WatchHeartRateCollector( distanceMeters = null caloriesKcal = null sampleSequence = 0 + latestSampleAtEpochMs = 0L executionContext = emptyMap() shouldAggregate = false + cancelSampleWatchdog() + cancelDistanceRetry() exerciseMetricsStarted = false exerciseMetricsStartInFlight = false exerciseHeartRateSupported = false exerciseHeartRateObserved = false + distanceSecurityFailureCount = 0 + shouldRetryDistanceAfterSecurityFailure = false } private fun startMeasureHeartRateFallback(context: Context) { @@ -585,6 +665,60 @@ internal class WatchHeartRateCollector( } } + private fun scheduleSampleWatchdog(context: Context) { + if (sampleWatchdogRunnable != null) { + return + } + val appContext = context.applicationContext + sampleWatchdogRunnable = Runnable { + sampleWatchdogRunnable = null + checkSampleFreshness(appContext) + }.also { runnable -> + mainHandler.postDelayed(runnable, SAMPLE_WATCHDOG_INTERVAL_MS) + } + } + + private fun cancelSampleWatchdog() { + sampleWatchdogRunnable?.let { mainHandler.removeCallbacks(it) } + sampleWatchdogRunnable = null + } + + private fun scheduleDistanceRetry(context: Context) { + if (distanceRetryRunnable != null) { + return + } + val appContext = context.applicationContext + distanceRetryRunnable = Runnable { + distanceRetryRunnable = null + retryDistanceMetrics(appContext) + }.also { runnable -> + mainHandler.postDelayed(runnable, DISTANCE_RETRY_DELAY_MS) + } + } + + private fun cancelDistanceRetry() { + distanceRetryRunnable?.let { mainHandler.removeCallbacks(it) } + distanceRetryRunnable = null + } + + private fun checkSampleFreshness(context: Context) { + if (!shouldAggregate || sessionId.isNullOrBlank()) { + return + } + val latestSampleAt = latestSampleAtEpochMs + val elapsedMs = System.currentTimeMillis() - latestSampleAt + if ( + latestSampleAt > 0L && + elapsedMs >= SAMPLE_STALE_TIMEOUT_MS && + !exerciseMetricsStartInFlight + ) { + restartExerciseMetrics(context) + } + if (shouldAggregate && !sessionId.isNullOrBlank()) { + scheduleSampleWatchdog(context) + } + } + private fun flushPendingSample(context: Context, forceNodeRefresh: Boolean) { val sample = pendingSample ?: return pendingSample = null @@ -651,3 +785,24 @@ internal class WatchHeartRateCollector( cachedReachableNodesAtEpochMs = System.currentTimeMillis() } } + +internal object WatchExerciseMetricsRetryPolicy { + fun isDistanceSecurityFailure( + dataTypes: Set>, + error: Throwable, + ): Boolean = DataType.DISTANCE in dataTypes && error.hasCause() + + fun canRetryDistance(securityFailureCount: Int): Boolean = + securityFailureCount in 1..MAX_DISTANCE_SECURITY_RETRIES + + private inline fun Throwable.hasCause(): Boolean { + var current: Throwable? = this + while (current != null) { + if (current is T) { + return true + } + current = current.cause + } + return false + } +} diff --git a/watch_app/android/app/src/test/kotlin/com/gametime/watch/bridge/WatchExerciseMetricsRetryPolicyTest.kt b/watch_app/android/app/src/test/kotlin/com/gametime/watch/bridge/WatchExerciseMetricsRetryPolicyTest.kt new file mode 100644 index 0000000..1977cbd --- /dev/null +++ b/watch_app/android/app/src/test/kotlin/com/gametime/watch/bridge/WatchExerciseMetricsRetryPolicyTest.kt @@ -0,0 +1,58 @@ +package com.gametime.watch.bridge + +import androidx.health.services.client.data.DataType +import java.util.concurrent.ExecutionException +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WatchExerciseMetricsRetryPolicyTest { + @Test + fun distanceSecurityFailureMatchesWrappedSecurityException() { + val error = ExecutionException( + SecurityException("Missing permissions: [android.permission.ACCESS_FINE_LOCATION]"), + ) + + assertTrue( + WatchExerciseMetricsRetryPolicy.isDistanceSecurityFailure( + setOf(DataType.DISTANCE, DataType.CALORIES, DataType.HEART_RATE_BPM), + error, + ), + ) + } + + @Test + fun nonDistanceSecurityFailureDoesNotScheduleDistanceRetry() { + val error = ExecutionException( + SecurityException("Missing permissions: [android.permission.ACCESS_FINE_LOCATION]"), + ) + + assertFalse( + WatchExerciseMetricsRetryPolicy.isDistanceSecurityFailure( + setOf(DataType.CALORIES, DataType.HEART_RATE_BPM), + error, + ), + ) + } + + @Test + fun distanceNonSecurityFailureDoesNotScheduleDistanceRetry() { + val error = ExecutionException(IllegalStateException("Health Services busy")) + + assertFalse( + WatchExerciseMetricsRetryPolicy.isDistanceSecurityFailure( + setOf(DataType.DISTANCE, DataType.CALORIES, DataType.HEART_RATE_BPM), + error, + ), + ) + } + + @Test + fun distanceSecurityRetriesAreBounded() { + assertFalse(WatchExerciseMetricsRetryPolicy.canRetryDistance(0)) + assertTrue(WatchExerciseMetricsRetryPolicy.canRetryDistance(1)) + assertTrue(WatchExerciseMetricsRetryPolicy.canRetryDistance(2)) + assertTrue(WatchExerciseMetricsRetryPolicy.canRetryDistance(3)) + assertFalse(WatchExerciseMetricsRetryPolicy.canRetryDistance(4)) + } +}