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:
@ -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<String, Any?>? = null
|
||||
private var sampleFlushRunnable: Runnable? = null
|
||||
private var sampleWatchdogRunnable: Runnable? = null
|
||||
private var distanceRetryRunnable: Runnable? = null
|
||||
private var cachedReachableNodes: List<Node> = 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<String>,
|
||||
@ -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<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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user