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:
@ -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;
|
||||||
|
|||||||
@ -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',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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"))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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