From 1cc721055d8170f0d6b8a759b5c2858b7cc01aea Mon Sep 17 00:00:00 2001 From: Aman Khosa Date: Fri, 23 Feb 2024 22:54:56 +0000 Subject: [PATCH] Prevent crash when starting an exercise If an exercise is started with incorrect permissions or location is disabled and it is being requested, WHS throws an exception, this was not being handled in HealthServicesRepository which resulted in a crash when calling startExercise. This change catches the error and displays it in an error screen. Implemented in ExerciseScreen to avoid having to reimplement the error catching logic since starting an exercise is called from multiple places. Prevents crashes like the one seen in #212. --- .../data/HealthServicesRepository.kt | 40 ++++++--- .../presentation/ExerciseSampleApp.kt | 6 +- .../presentation/exercise/ExerciseScreen.kt | 81 +++++++++++++++++-- .../exercise/ExerciseScreenState.kt | 6 ++ .../exercise/ExerciseViewModel.kt | 2 +- .../service/ExerciseLogger.kt | 9 ++- .../service/ExerciseState.kt | 3 +- .../app/src/main/res/values/strings.xml | 3 + 8 files changed, 126 insertions(+), 24 deletions(-) diff --git a/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/data/HealthServicesRepository.kt b/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/data/HealthServicesRepository.kt index 4def12c6..f748856e 100644 --- a/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/data/HealthServicesRepository.kt +++ b/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/data/HealthServicesRepository.kt @@ -20,38 +20,45 @@ package com.example.exercisesamplecompose.data import android.content.Context import androidx.health.services.client.data.LocationAvailability import com.example.exercisesamplecompose.di.bindService +import com.example.exercisesamplecompose.service.ExerciseLogger import com.example.exercisesamplecompose.service.ExerciseService import com.example.exercisesamplecompose.service.ExerciseServiceState import dagger.hilt.android.ActivityRetainedLifecycle import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.scopes.ActivityRetainedScoped +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject @ActivityRetainedScoped class HealthServicesRepository @Inject constructor( @ApplicationContext private val applicationContext: Context, val exerciseClientManager: ExerciseClientManager, + val logger: ExerciseLogger, val coroutineScope: CoroutineScope, val lifecycle: ActivityRetainedLifecycle ) { private val binderConnection = lifecycle.bindService(applicationContext) - val serviceState: StateFlow = - binderConnection.flowWhenConnected(ExerciseService.LocalBinder::exerciseServiceState).map { - ServiceState.Connected(it) - }.stateIn( - coroutineScope, - started = SharingStarted.Eagerly, - initialValue = ServiceState.Disconnected - ) + private val exerciseServiceStateUpdates: Flow = binderConnection.flowWhenConnected(ExerciseService.LocalBinder::exerciseServiceState) + + private var errorState: MutableStateFlow = MutableStateFlow(null) + + val serviceState: StateFlow = exerciseServiceStateUpdates.combine(errorState) { exerciseServiceState, errorString -> + ServiceState.Connected(exerciseServiceState.copy(error = errorString)) + }.stateIn( + coroutineScope, + started = SharingStarted.Eagerly, + initialValue = ServiceState.Disconnected + ) suspend fun hasExerciseCapability(): Boolean = getExerciseCapabilities() != null @@ -71,7 +78,15 @@ class HealthServicesRepository @Inject constructor( } } - fun startExercise() = serviceCall { startExercise() } + fun startExercise() = serviceCall { + try { + errorState.value = null + startExercise() + } catch (e: Exception) { + errorState.value = e.message + logger.error("Error starting exercise", e.fillInStackTrace()) + } + } fun pauseExercise() = serviceCall { pauseExercise() } fun endExercise() = serviceCall { endExercise() } fun resumeExercise() = serviceCall { resumeExercise() } @@ -80,7 +95,8 @@ class HealthServicesRepository @Inject constructor( /** Store exercise values in the service state. While the service is connected, * the values will persist.**/ sealed class ServiceState { - object Disconnected : ServiceState() + data object Disconnected : ServiceState() + data class Connected( val exerciseServiceState: ExerciseServiceState, ) : ServiceState() { diff --git a/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/ExerciseSampleApp.kt b/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/ExerciseSampleApp.kt index 539b6690..2041a9c8 100644 --- a/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/ExerciseSampleApp.kt +++ b/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/ExerciseSampleApp.kt @@ -98,7 +98,11 @@ fun ExerciseSampleApp( columnState = columnState, onSummary = { navController.navigateToTopLevel(Summary, Summary.buildRoute(it)) - } + }, + onRestart = { + navController.navigateToTopLevel(PreparingExercise) + }, + onFinishActivity = onFinishActivity ) } } diff --git a/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/exercise/ExerciseScreen.kt b/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/exercise/ExerciseScreen.kt index 2512bbff..298fcda5 100644 --- a/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/exercise/ExerciseScreen.kt +++ b/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/exercise/ExerciseScreen.kt @@ -36,7 +36,9 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.compose.material.Icon import androidx.wear.compose.material.Text +import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices import com.example.exercisesamplecompose.R +import com.example.exercisesamplecompose.data.ServiceState import com.example.exercisesamplecompose.presentation.component.CaloriesText import com.example.exercisesamplecompose.presentation.component.DistanceText import com.example.exercisesamplecompose.presentation.component.HRText @@ -46,10 +48,13 @@ import com.example.exercisesamplecompose.presentation.component.StartButton import com.example.exercisesamplecompose.presentation.component.StopButton import com.example.exercisesamplecompose.presentation.component.formatElapsedTime import com.example.exercisesamplecompose.presentation.summary.SummaryScreenState +import com.example.exercisesamplecompose.presentation.theme.ThemePreview +import com.example.exercisesamplecompose.service.ExerciseServiceState import com.google.android.horologist.annotations.ExperimentalHorologistApi import com.google.android.horologist.compose.ambient.AmbientState import com.google.android.horologist.compose.layout.ScalingLazyColumn import com.google.android.horologist.compose.layout.ScalingLazyColumnState +import com.google.android.horologist.compose.material.AlertDialog import com.google.android.horologist.health.composables.ActiveDurationText @Composable @@ -57,7 +62,9 @@ fun ExerciseRoute( ambientState: AmbientState, columnState: ScalingLazyColumnState, modifier: Modifier = Modifier, - onSummary: (SummaryScreenState) -> Unit + onSummary: (SummaryScreenState) -> Unit, + onRestart: () -> Unit, + onFinishActivity: () -> Unit, ) { val viewModel = hiltViewModel() val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -68,7 +75,13 @@ fun ExerciseRoute( } } - if (ambientState is AmbientState.Interactive) { + if (uiState.error != null) { + ErrorStartingExerciseScreen( + onRestart = onRestart, + onFinishActivity = onFinishActivity, + uiState = uiState + ) + } else if (ambientState is AmbientState.Interactive) { ExerciseScreen( onPauseClick = { viewModel.pauseExercise() }, onEndClick = { viewModel.endExercise() }, @@ -81,6 +94,24 @@ fun ExerciseRoute( } } +/** + * Shows an error that occured when starting an exercise + */ +@Composable +fun ErrorStartingExerciseScreen( + onRestart: () -> Unit, + onFinishActivity: () -> Unit, + uiState: ExerciseScreenState +) { + AlertDialog( + title = stringResource(id = R.string.error_starting_exercise), + message = "${uiState.error ?: stringResource(id = R.string.unknown_error)}. ${stringResource(id = R.string.try_again)}", + onCancel = onFinishActivity, + onOk = onRestart, + showDialog = true, + ) +} + /** * Shows while an exercise is in progress */ @@ -220,10 +251,44 @@ private fun DurationRow(uiState: ExerciseScreenState) { } } +@WearPreviewDevices +@Composable +fun ExerciseScreenPreview() { + ThemePreview { + ExerciseScreen( + onPauseClick = {}, + onEndClick = {}, + onResumeClick = {}, + onStartClick = {}, + uiState = ExerciseScreenState( + hasExerciseCapabilities = true, + isTrackingAnotherExercise = false, + serviceState = ServiceState.Connected( + ExerciseServiceState() + ), + exerciseState = ExerciseServiceState() + ), + columnState = ScalingLazyColumnState(), + modifier = Modifier + ) + } +} - - - - - - +@WearPreviewDevices +@Composable +fun ErrorStartingExerciseScreenPreview() { + ThemePreview { + ErrorStartingExerciseScreen( + onRestart = {}, + onFinishActivity = {}, + uiState = ExerciseScreenState( + hasExerciseCapabilities = true, + isTrackingAnotherExercise = false, + serviceState = ServiceState.Connected( + ExerciseServiceState() + ), + exerciseState = ExerciseServiceState() + ) + ) + } +} diff --git a/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/exercise/ExerciseScreenState.kt b/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/exercise/ExerciseScreenState.kt index 51a7510b..8d051d21 100644 --- a/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/exercise/ExerciseScreenState.kt +++ b/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/exercise/ExerciseScreenState.kt @@ -28,4 +28,10 @@ data class ExerciseScreenState( val isPaused: Boolean get() = exerciseState?.exerciseState?.isPaused == true + + val error: String? + get() = when(serviceState) { + is ServiceState.Connected -> serviceState.exerciseServiceState.error + else -> null + } } \ No newline at end of file diff --git a/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/exercise/ExerciseViewModel.kt b/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/exercise/ExerciseViewModel.kt index 741eb11e..d82be641 100644 --- a/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/exercise/ExerciseViewModel.kt +++ b/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/exercise/ExerciseViewModel.kt @@ -20,11 +20,11 @@ import androidx.lifecycle.viewModelScope import com.example.exercisesamplecompose.data.HealthServicesRepository import com.example.exercisesamplecompose.data.ServiceState import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import javax.inject.Inject @HiltViewModel class ExerciseViewModel @Inject constructor( diff --git a/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/service/ExerciseLogger.kt b/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/service/ExerciseLogger.kt index def25bb3..025fbcf1 100644 --- a/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/service/ExerciseLogger.kt +++ b/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/service/ExerciseLogger.kt @@ -2,12 +2,19 @@ package com.example.exercisesamplecompose.service import android.util.Log +private const val TAG = "ExerciseSample" + interface ExerciseLogger { + fun error(message: String, throwable: Throwable? = null) fun log(message: String) } class AndroidLogExerciseLogger : ExerciseLogger { + override fun error(message: String, throwable: Throwable?) { + Log.e(TAG, message, throwable) + } + override fun log(message: String) { - Log.i("ExerciseSample", message) + Log.i(TAG, message) } } \ No newline at end of file diff --git a/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/service/ExerciseState.kt b/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/service/ExerciseState.kt index 9f532d3b..a5a137d8 100644 --- a/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/service/ExerciseState.kt +++ b/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/service/ExerciseState.kt @@ -30,5 +30,6 @@ data class ExerciseServiceState( val exerciseMetrics: ExerciseMetrics = ExerciseMetrics(), val exerciseLaps: Int = 0, val activeDurationCheckpoint: ActiveDurationCheckpoint? = null, - val locationAvailability: LocationAvailability = LocationAvailability.UNKNOWN + val locationAvailability: LocationAvailability = LocationAvailability.UNKNOWN, + val error: String? = null, ) \ No newline at end of file diff --git a/health-services/ExerciseSampleCompose/app/src/main/res/values/strings.xml b/health-services/ExerciseSampleCompose/app/src/main/res/values/strings.xml index d6b89a8c..1c1798e5 100644 --- a/health-services/ExerciseSampleCompose/app/src/main/res/values/strings.xml +++ b/health-services/ExerciseSampleCompose/app/src/main/res/values/strings.xml @@ -21,6 +21,9 @@ Start Starting up Exercise not available on this device + Error starting exercise + Unknown error + Would you like to try again? GPS Acquiring