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 8d98035..48a4375 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 @@ -2,6 +2,8 @@ package com.gametime.watch.bridge import android.content.Context import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.location.LocationManager import android.os.Handler import android.os.Looper import android.util.Log @@ -315,11 +317,12 @@ internal class WatchHeartRateCollector( val requestedTypeNames = ( executionContext["healthServicesExerciseTypeStrategy"] as? List<*> )?.filterIsInstance().orEmpty() - val config = exerciseConfigFromCapabilities( + val configs = exerciseConfigsFromCapabilities( capabilities, requestedTypeNames, + canUseGps = canStartGpsExercise(context), ) - if (config == null) { + if (configs.isEmpty()) { exerciseMetricsStartInFlight = false Log.w(TAG, "no exercise type supports usable live metrics sessionId=$sessionId") if (shouldAggregate) { @@ -331,29 +334,11 @@ internal class WatchHeartRateCollector( exerciseMetricsStartInFlight = false return@addListener } - exerciseHeartRateSupported = DataType.HEART_RATE_BPM in config.dataTypes - exerciseClient.setUpdateCallback(context.mainExecutor, exerciseCallback) - val startFuture = exerciseClient.startExerciseAsync(config) - startFuture.addListener( - { - exerciseMetricsStartInFlight = false - try { - startFuture.get() - exerciseMetricsStarted = true - Log.d( - TAG, - "exercise metrics started sessionId=$sessionId type=${config.exerciseType} dataTypes=${config.dataTypes}", - ) - } catch (error: Exception) { - Log.w(TAG, "exercise metrics start failed", error) - clearExerciseCallback(exerciseClient) - exerciseHeartRateSupported = false - if (shouldAggregate) { - startMeasureHeartRateFallback(context) - } - } - }, - context.mainExecutor, + startExerciseMetricsConfig( + context, + exerciseClient, + configs, + index = 0, ) } catch (error: Exception) { exerciseMetricsStartInFlight = false @@ -364,73 +349,144 @@ internal class WatchHeartRateCollector( ) } - private fun exerciseConfigFromCapabilities( + private fun startExerciseMetricsConfig( + context: Context, + exerciseClient: ExerciseClient, + configs: List, + index: Int, + ) { + if (!shouldAggregate) { + exerciseMetricsStartInFlight = false + return + } + val config = configs.getOrNull(index) + if (config == null) { + exerciseMetricsStartInFlight = false + clearExerciseCallback(exerciseClient) + exerciseHeartRateSupported = false + Log.w(TAG, "all exercise metric configs failed sessionId=$sessionId") + if (shouldAggregate) { + startMeasureHeartRateFallback(context) + } + return + } + exerciseHeartRateSupported = DataType.HEART_RATE_BPM in config.dataTypes + exerciseClient.setUpdateCallback(context.mainExecutor, exerciseCallback) + val startFuture = exerciseClient.startExerciseAsync(config) + startFuture.addListener( + { + try { + startFuture.get() + exerciseMetricsStartInFlight = false + exerciseMetricsStarted = true + Log.d( + TAG, + "exercise metrics started sessionId=$sessionId type=${config.exerciseType} dataTypes=${config.dataTypes}", + ) + } catch (error: Exception) { + Log.w( + TAG, + "exercise metrics start failed type=${config.exerciseType} dataTypes=${config.dataTypes}", + error, + ) + clearExerciseCallback(exerciseClient) + exerciseHeartRateSupported = false + startExerciseMetricsConfig( + context, + exerciseClient, + configs, + index + 1, + ) + } + }, + context.mainExecutor, + ) + } + + private fun exerciseConfigsFromCapabilities( capabilities: androidx.health.services.client.data.ExerciseCapabilities, requestedTypeNames: List, - ): ExerciseConfig? { + canUseGps: Boolean, + ): List { val requestedTypes = exerciseTypesFromNames(requestedTypeNames) - var distanceOnlyConfig: ExerciseConfig? = null - var caloriesOnlyConfig: ExerciseConfig? = null - var heartRateOnlyConfig: ExerciseConfig? = null + val distanceCaloriesConfigs = mutableListOf() + val caloriesConfigs = mutableListOf() + val heartRateConfigs = mutableListOf() for (exerciseType in requestedTypes) { if (exerciseType !in capabilities.supportedExerciseTypes) { continue } val supported = capabilities.getExerciseTypeCapabilities(exerciseType) .supportedDataTypes - val dataTypes = mutableSetOf>() - if (DataType.DISTANCE !in supported) { - if (DataType.CALORIES in supported) { - dataTypes.add(DataType.CALORIES) - } - if (DataType.HEART_RATE_BPM in supported) { + val supportsHeartRate = DataType.HEART_RATE_BPM in supported + if ( + canUseGps && + DataType.DISTANCE in supported && + DataType.CALORIES in supported + ) { + val dataTypes = mutableSetOf>( + DataType.DISTANCE, + DataType.CALORIES, + ) + if (supportsHeartRate) { dataTypes.add(DataType.HEART_RATE_BPM) } - if (dataTypes.isEmpty()) { - Log.w( - TAG, - "exercise type lacks usable metrics type=$exerciseType supported=$supported", - ) - continue - } - if ( - DataType.CALORIES in dataTypes && - caloriesOnlyConfig == null - ) { - caloriesOnlyConfig = ExerciseConfig.builder(exerciseType) + distanceCaloriesConfigs.add( + ExerciseConfig.builder(exerciseType) .setDataTypes(dataTypes) .setIsAutoPauseAndResumeEnabled(false) - .setIsGpsEnabled(false) - .build() - } else if (heartRateOnlyConfig == null) { - heartRateOnlyConfig = ExerciseConfig.builder(exerciseType) - .setDataTypes(dataTypes) - .setIsAutoPauseAndResumeEnabled(false) - .setIsGpsEnabled(false) - .build() - } - continue + .setIsGpsEnabled(true) + .build(), + ) } - 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) - } - val config = ExerciseConfig.builder(exerciseType) - .setDataTypes(dataTypes) - .setIsAutoPauseAndResumeEnabled(false) - .setIsGpsEnabled(true) - .build() - if (DataType.CALORIES in dataTypes) { - return config - } - if (distanceOnlyConfig == null) { - distanceOnlyConfig = config + val dataTypes = mutableSetOf>( + 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 caloriesOnlyConfig ?: distanceOnlyConfig ?: heartRateOnlyConfig + return distanceCaloriesConfigs + caloriesConfigs + heartRateConfigs + } + + private fun canStartGpsExercise(context: Context): Boolean { + if ( + context.checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) != + PackageManager.PERMISSION_GRANTED + ) { + return false + } + return try { + val locationManager = context.getSystemService(LocationManager::class.java) + ?: return false + locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || + locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + } catch (error: RuntimeException) { + Log.w(TAG, "location availability check failed", error) + false + } } private fun exerciseTypesFromNames(names: List): List {