Skip to content

Commit

Permalink
Prevent crash when starting an exercise
Browse files Browse the repository at this point in the history
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 android#212.
  • Loading branch information
Aman Khosa committed Feb 26, 2024
1 parent 7bd3121 commit 1cc7210
Show file tree
Hide file tree
Showing 8 changed files with 126 additions and 24 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExerciseService.LocalBinder, ExerciseService>(applicationContext)

val serviceState: StateFlow<ServiceState> =
binderConnection.flowWhenConnected(ExerciseService.LocalBinder::exerciseServiceState).map {
ServiceState.Connected(it)
}.stateIn(
coroutineScope,
started = SharingStarted.Eagerly,
initialValue = ServiceState.Disconnected
)
private val exerciseServiceStateUpdates: Flow<ExerciseServiceState> = binderConnection.flowWhenConnected(ExerciseService.LocalBinder::exerciseServiceState)

private var errorState: MutableStateFlow<String?> = MutableStateFlow(null)

val serviceState: StateFlow<ServiceState> = 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

Expand All @@ -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() }
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,11 @@ fun ExerciseSampleApp(
columnState = columnState,
onSummary = {
navController.navigateToTopLevel(Summary, Summary.buildRoute(it))
}
},
onRestart = {
navController.navigateToTopLevel(PreparingExercise)
},
onFinishActivity = onFinishActivity
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -46,18 +48,23 @@ 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
fun ExerciseRoute(
ambientState: AmbientState,
columnState: ScalingLazyColumnState,
modifier: Modifier = Modifier,
onSummary: (SummaryScreenState) -> Unit
onSummary: (SummaryScreenState) -> Unit,
onRestart: () -> Unit,
onFinishActivity: () -> Unit,
) {
val viewModel = hiltViewModel<ExerciseViewModel>()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Expand All @@ -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() },
Expand All @@ -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
*/
Expand Down Expand Up @@ -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()
)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
<string name="start">Start</string>
<string name="starting_up">Starting up</string>
<string name="not_avail">Exercise not available on this device</string>
<string name="error_starting_exercise">Error starting exercise</string>
<string name="unknown_error">Unknown error</string>
<string name="try_again">Would you like to try again?</string>

<!-- GPS -->
<string name="GPS_acquiring">GPS Acquiring</string>
Expand Down

0 comments on commit 1cc7210

Please sign in to comment.