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 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 16:58:07 +02:00
parent 89558808af
commit e7ff1c2a19
5 changed files with 223 additions and 5 deletions

View File

@ -18,6 +18,9 @@ final class HttpApiClient {
defaultValue: '', defaultValue: '',
); );
static const androidEmulatorDefaultBaseUrl = 'http://10.0.2.2:8090';
static const localDefaultBaseUrl = 'http://localhost:8080';
static String get defaultBaseUrl => static String get defaultBaseUrl =>
defaultBaseUrlFor(isAndroid: Platform.isAndroid); defaultBaseUrlFor(isAndroid: Platform.isAndroid);
@ -30,9 +33,9 @@ final class HttpApiClient {
return configured; return configured;
} }
if (isAndroid) { if (isAndroid) {
return 'http://10.0.2.2:8080'; return androidEmulatorDefaultBaseUrl;
} }
return 'http://localhost:8080'; return localDefaultBaseUrl;
} }
final Uri baseUrl; final Uri baseUrl;

View File

@ -18,7 +18,7 @@ void main() {
test('defaultBaseUrl uses Android emulator host without env override', () { test('defaultBaseUrl uses Android emulator host without env override', () {
expect( expect(
HttpApiClient.defaultBaseUrlFor(isAndroid: true), HttpApiClient.defaultBaseUrlFor(isAndroid: true),
'http://10.0.2.2:8080', 'http://10.0.2.2:8090',
); );
}); });
@ -26,9 +26,9 @@ void main() {
expect( expect(
HttpApiClient.defaultBaseUrlFor( HttpApiClient.defaultBaseUrlFor(
isAndroid: true, 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',
); );
}); });

View File

@ -80,4 +80,6 @@ dependencies {
implementation("androidx.wear:wear-ongoing:1.0.0") implementation("androidx.wear:wear-ongoing:1.0.0")
implementation("com.google.guava:guava:33.6.0-android") implementation("com.google.guava:guava:33.6.0-android")
implementation("com.google.android.gms:play-services-wearable:19.0.0") implementation("com.google.android.gms:play-services-wearable:19.0.0")
testImplementation(kotlin("test"))
} }

View File

@ -27,6 +27,8 @@ import org.json.JSONObject
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import kotlin.math.roundToInt import kotlin.math.roundToInt
private const val MAX_DISTANCE_SECURITY_RETRIES = 3
internal class WatchHeartRateCollector( internal class WatchHeartRateCollector(
private val phoneCapability: String, private val phoneCapability: String,
private val sensorSummaryPath: String, private val sensorSummaryPath: String,
@ -36,12 +38,17 @@ internal class WatchHeartRateCollector(
private companion object { private companion object {
const val TAG = "GTWatchHeartRate" const val TAG = "GTWatchHeartRate"
const val SAMPLE_FLUSH_INTERVAL_MS = 1500L 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 const val NODE_CACHE_TTL_MS = 10000L
} }
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private var pendingSample: Map<String, Any?>? = null private var pendingSample: Map<String, Any?>? = null
private var sampleFlushRunnable: Runnable? = null private var sampleFlushRunnable: Runnable? = null
private var sampleWatchdogRunnable: Runnable? = null
private var distanceRetryRunnable: Runnable? = null
private var cachedReachableNodes: List<Node> = emptyList() private var cachedReachableNodes: List<Node> = emptyList()
private var cachedReachableNodesAtEpochMs = 0L private var cachedReachableNodesAtEpochMs = 0L
private var nodeLookupInFlight = false private var nodeLookupInFlight = false
@ -59,7 +66,10 @@ internal class WatchHeartRateCollector(
private var exerciseMetricsStartInFlight = false private var exerciseMetricsStartInFlight = false
private var exerciseHeartRateSupported = false private var exerciseHeartRateSupported = false
private var exerciseHeartRateObserved = false private var exerciseHeartRateObserved = false
private var distanceSecurityFailureCount = 0
private var shouldRetryDistanceAfterSecurityFailure = false
private var shouldAggregate = false private var shouldAggregate = false
private var latestSampleAtEpochMs = 0L
private var appContext: Context? = null private var appContext: Context? = null
private val measureCallback = object : MeasureCallback { private val measureCallback = object : MeasureCallback {
@ -179,6 +189,10 @@ internal class WatchHeartRateCollector(
return return
} }
appContext = context.applicationContext appContext = context.applicationContext
if (latestSampleAtEpochMs == 0L) {
latestSampleAtEpochMs = System.currentTimeMillis()
}
scheduleSampleWatchdog(context)
startMeasureHeartRateFallback(context) startMeasureHeartRateFallback(context)
startExerciseMetrics(context) startExerciseMetrics(context)
} }
@ -186,6 +200,8 @@ internal class WatchHeartRateCollector(
fun pause(context: Context) { fun pause(context: Context) {
shouldAggregate = false shouldAggregate = false
flushPendingSample(context, forceNodeRefresh = false) flushPendingSample(context, forceNodeRefresh = false)
cancelSampleWatchdog()
cancelDistanceRetry()
unregister(context) unregister(context)
stopExerciseMetrics(context) stopExerciseMetrics(context)
} }
@ -223,6 +239,7 @@ internal class WatchHeartRateCollector(
val context = appContext ?: return val context = appContext ?: return
sampleSequence += 1 sampleSequence += 1
val capturedAt = System.currentTimeMillis() val capturedAt = System.currentTimeMillis()
latestSampleAtEpochMs = capturedAt
val sample = mapOf( val sample = mapOf(
"schemaVersion" to 4, "schemaVersion" to 4,
"sampleId" to "$activeSessionId-$capturedAt-$sampleSequence", "sampleId" to "$activeSessionId-$capturedAt-$sampleSequence",
@ -379,11 +396,26 @@ internal class WatchHeartRateCollector(
startFuture.get() startFuture.get()
exerciseMetricsStartInFlight = false exerciseMetricsStartInFlight = false
exerciseMetricsStarted = true exerciseMetricsStarted = true
onExerciseMetricsStarted(context, config)
Log.d( Log.d(
TAG, TAG,
"exercise metrics started sessionId=$sessionId type=${config.exerciseType} dataTypes=${config.dataTypes}", "exercise metrics started sessionId=$sessionId type=${config.exerciseType} dataTypes=${config.dataTypes}",
) )
} catch (error: Exception) { } 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( Log.w(
TAG, TAG,
"exercise metrics start failed type=${config.exerciseType} dataTypes=${config.dataTypes}", "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( private fun exerciseConfigsFromCapabilities(
capabilities: androidx.health.services.client.data.ExerciseCapabilities, capabilities: androidx.health.services.client.data.ExerciseCapabilities,
requestedTypeNames: List<String>, requestedTypeNames: List<String>,
@ -528,6 +572,37 @@ internal class WatchHeartRateCollector(
exerciseHeartRateSupported = false 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) { private fun clearExerciseCallback(exerciseClient: ExerciseClient) {
try { try {
exerciseClient.clearUpdateCallbackAsync(exerciseCallback) exerciseClient.clearUpdateCallbackAsync(exerciseCallback)
@ -548,12 +623,17 @@ internal class WatchHeartRateCollector(
distanceMeters = null distanceMeters = null
caloriesKcal = null caloriesKcal = null
sampleSequence = 0 sampleSequence = 0
latestSampleAtEpochMs = 0L
executionContext = emptyMap() executionContext = emptyMap()
shouldAggregate = false shouldAggregate = false
cancelSampleWatchdog()
cancelDistanceRetry()
exerciseMetricsStarted = false exerciseMetricsStarted = false
exerciseMetricsStartInFlight = false exerciseMetricsStartInFlight = false
exerciseHeartRateSupported = false exerciseHeartRateSupported = false
exerciseHeartRateObserved = false exerciseHeartRateObserved = false
distanceSecurityFailureCount = 0
shouldRetryDistanceAfterSecurityFailure = false
} }
private fun startMeasureHeartRateFallback(context: Context) { 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) { private fun flushPendingSample(context: Context, forceNodeRefresh: Boolean) {
val sample = pendingSample ?: return val sample = pendingSample ?: return
pendingSample = null pendingSample = null
@ -651,3 +785,24 @@ internal class WatchHeartRateCollector(
cachedReachableNodesAtEpochMs = System.currentTimeMillis() cachedReachableNodesAtEpochMs = System.currentTimeMillis()
} }
} }
internal object WatchExerciseMetricsRetryPolicy {
fun isDistanceSecurityFailure(
dataTypes: Set<androidx.health.services.client.data.DataType<*, *>>,
error: Throwable,
): Boolean = DataType.DISTANCE in dataTypes && error.hasCause<SecurityException>()
fun canRetryDistance(securityFailureCount: Int): Boolean =
securityFailureCount in 1..MAX_DISTANCE_SECURITY_RETRIES
private inline fun <reified T : Throwable> Throwable.hasCause(): Boolean {
var current: Throwable? = this
while (current != null) {
if (current is T) {
return true
}
current = current.cause
}
return false
}
}

View File

@ -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))
}
}