From b8d1c24b5686f841f4a33028f9b2b3d594793c87 Mon Sep 17 00:00:00 2001 From: BossMG-github Date: Fri, 2 Jan 2026 15:44:40 +0900 Subject: [PATCH 1/2] Fix: Support granular heart rate permission for Android 16 (Baklava) The `BODY_SENSORS` permission is no longer sufficient for heart rate monitoring on Android 16 (API 36+) due to the introduction of granular permissions. Changes included in this commit: - Conditionally request `READ_HEART_RATE` on Baklava (API 36) and above. - Fallback to `BODY_SENSORS` for Android 15 (Vanilla Ice Cream) and below. - Apply spotless formatting to comply with the project style guide. --- .../presentation/component/FormattingUtils.kt | 7 +- .../presentation/dialogs/ExerciseGoalMet.kt | 3 - .../presentation/exercise/ExerciseScreen.kt | 73 +++++------ .../preparing/PreparingExerciseScreen.kt | 119 +++++++++--------- .../preparing/PreparingViewModel.kt | 9 +- .../presentation/ExerciseScreenTest.kt | 2 +- .../PreparingExerciseScreenTest.kt | 31 +++-- 7 files changed, 121 insertions(+), 123 deletions(-) diff --git a/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/component/FormattingUtils.kt b/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/component/FormattingUtils.kt index c729992a..8211b330 100644 --- a/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/component/FormattingUtils.kt +++ b/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/component/FormattingUtils.kt @@ -49,8 +49,11 @@ fun formatElapsedTime( } if (includeSeconds) { val seconds = elapsedDuration.seconds % SECONDS_PER_MINUTE - if (LocalAmbientState.current.isInteractive) append("%02d".format(seconds)) - else append("--") + if (LocalAmbientState.current.isInteractive) { + append("%02d".format(seconds)) + } else { + append("--") + } withStyle(style = MaterialTheme.typography.labelSmall.toSpanStyle()) { append("s") } diff --git a/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/dialogs/ExerciseGoalMet.kt b/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/dialogs/ExerciseGoalMet.kt index 37669452..3b13343b 100644 --- a/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/dialogs/ExerciseGoalMet.kt +++ b/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/dialogs/ExerciseGoalMet.kt @@ -15,12 +15,9 @@ */ package com.example.exercisesamplecompose.presentation.dialogs -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.SportsScore import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.wear.compose.material3.ConfirmationDialog import androidx.wear.compose.material3.Icon 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 fd1b02a5..858eea60 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 @@ -49,7 +49,6 @@ import androidx.wear.compose.material3.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.ambient.ambientBlank import com.example.exercisesamplecompose.presentation.component.CaloriesText import com.example.exercisesamplecompose.presentation.component.DistanceText import com.example.exercisesamplecompose.presentation.component.HRText @@ -159,46 +158,48 @@ fun ExerciseScreen( ) { val coroutineScope = rememberCoroutineScope() val pagerState = rememberPagerState( - initialPage = 1, pageCount = { 2 }) + initialPage = 1, + pageCount = { 2 } + ) - HorizontalPagerScaffold(pagerState = pagerState) { - HorizontalPager( - state = pagerState - ) { page -> - ScreenScaffold { - if (page == 0) { - ExerciseControlButtons( - uiState = uiState, - onStartClick = onStartClick, - onEndClick = onEndClick, - onResumeClick = { - onResumeClick() - coroutineScope.launch { - pagerState.animateScrollToPage(1) - } - }, - onPauseClick = { - onPauseClick() - coroutineScope.launch { - pagerState.animateScrollToPage(1) - } - } - ) - } else { - ExerciseMetrics(uiState = uiState) + HorizontalPagerScaffold(pagerState = pagerState) { + HorizontalPager( + state = pagerState + ) { page -> + ScreenScaffold { + if (page == 0) { + ExerciseControlButtons( + uiState = uiState, + onStartClick = onStartClick, + onEndClick = onEndClick, + onResumeClick = { + onResumeClick() + coroutineScope.launch { + pagerState.animateScrollToPage(1) + } + }, + onPauseClick = { + onPauseClick() + coroutineScope.launch { + pagerState.animateScrollToPage(1) + } } - } + ) + } else { + ExerciseMetrics(uiState = uiState) } - - // If we meet an exercise goal, show our exercise met dialog. - // This approach is for the sample, and doesn't guarantee processing of this event in all cases, - // such as the user exiting the app while this is in-progress. Consider alternatives to exposing - // state in a production app. - uiState.exerciseState?.exerciseGoal?.let { - Log.d("ExerciseGoalMet", "Showing exercise goal met dialog") - ExerciseGoalMet(it.isNotEmpty()) } } + + // If we meet an exercise goal, show our exercise met dialog. + // This approach is for the sample, and doesn't guarantee processing of this event in all cases, + // such as the user exiting the app while this is in-progress. Consider alternatives to exposing + // state in a production app. + uiState.exerciseState?.exerciseGoal?.let { + Log.d("ExerciseGoalMet", "Showing exercise goal met dialog") + ExerciseGoalMet(it.isNotEmpty()) + } + } } @Composable diff --git a/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/preparing/PreparingExerciseScreen.kt b/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/preparing/PreparingExerciseScreen.kt index f1c7e233..84dca000 100644 --- a/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/preparing/PreparingExerciseScreen.kt +++ b/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/preparing/PreparingExerciseScreen.kt @@ -20,9 +20,7 @@ import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -47,7 +45,6 @@ import androidx.health.services.client.data.LocationAvailability import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.compose.foundation.CurvedLayout -import androidx.wear.compose.foundation.CurvedScope import androidx.wear.compose.foundation.lazy.TransformingLazyColumn import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState import androidx.wear.compose.material3.CompactButton @@ -55,9 +52,7 @@ import androidx.wear.compose.material3.FilledIconButton import androidx.wear.compose.material3.Icon import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.Text -import androidx.wear.compose.material3.TimeText import androidx.wear.compose.material3.curvedText -import androidx.wear.compose.material3.timeTextCurvedText import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices import com.example.exercisesamplecompose.R import com.example.exercisesamplecompose.data.ServiceState @@ -144,71 +139,71 @@ fun PreparingExerciseScreen( first = ColumnItemType.BodyText, last = ColumnItemType.Button ) - ScreenScaffold( - scrollState = columnState, - timeText = {}, - contentPadding = contentPadding, - modifier = Modifier - .ambientGray(ambientState) - ) { contentPadding -> - LocationStatusText( - updatePrepareLocationStatus( - locationAvailability = location ?: LocationAvailability.UNAVAILABLE - ) + ScreenScaffold( + scrollState = columnState, + timeText = {}, + contentPadding = contentPadding, + modifier = Modifier + .ambientGray(ambientState) + ) { contentPadding -> + LocationStatusText( + updatePrepareLocationStatus( + locationAvailability = location ?: LocationAvailability.UNAVAILABLE ) - TransformingLazyColumn( - state = columnState, - contentPadding = contentPadding - ) { - item { - Text( - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - text = stringResource(id = R.string.preparing_exercise), - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = 0.15f * LocalConfiguration.current.screenWidthDp.dp - ) - ) - } - item { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 6.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - FilledIconButton( - onClick = onStart, - enabled = uiState is PreparingScreenState.Preparing - ) { - Icon( - Icons.Default.PlayArrow, - contentDescription = stringResource(id = R.string.start) - ) - } - } - } - item { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 6.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally + ) + TransformingLazyColumn( + state = columnState, + contentPadding = contentPadding + ) { + item { + Text( + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + text = stringResource(id = R.string.preparing_exercise), + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = 0.15f * LocalConfiguration.current.screenWidthDp.dp + ) + ) + } + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + FilledIconButton( + onClick = onStart, + enabled = uiState is PreparingScreenState.Preparing ) { - CompactButton( - label = { Text(stringResource(id = R.string.goal)) }, - onClick = onGoals + Icon( + Icons.Default.PlayArrow, + contentDescription = stringResource(id = R.string.start) ) } } } + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + CompactButton( + label = { Text(stringResource(id = R.string.goal)) }, + onClick = onGoals + ) + } + } } } +} /**Return [LocationAvailability] value code as a string**/ diff --git a/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/preparing/PreparingViewModel.kt b/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/preparing/PreparingViewModel.kt index cd54a7df..ff24b198 100644 --- a/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/preparing/PreparingViewModel.kt +++ b/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/preparing/PreparingViewModel.kt @@ -84,13 +84,16 @@ constructor( companion object { val permissions = buildList { - add(Manifest.permission.BODY_SENSORS) add(Manifest.permission.ACCESS_FINE_LOCATION) add(Manifest.permission.ACTIVITY_RECOGNITION) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { add(Manifest.permission.POST_NOTIFICATIONS) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { add(HealthPermissions.READ_HEART_RATE) + } else { + add(Manifest.permission.BODY_SENSORS) + } } } } diff --git a/health-services/ExerciseSampleCompose/app/src/test/java/com/example/exercisesamplecompose/presentation/ExerciseScreenTest.kt b/health-services/ExerciseSampleCompose/app/src/test/java/com/example/exercisesamplecompose/presentation/ExerciseScreenTest.kt index ef6a10c5..b655e024 100644 --- a/health-services/ExerciseSampleCompose/app/src/test/java/com/example/exercisesamplecompose/presentation/ExerciseScreenTest.kt +++ b/health-services/ExerciseSampleCompose/app/src/test/java/com/example/exercisesamplecompose/presentation/ExerciseScreenTest.kt @@ -69,7 +69,7 @@ class ExerciseScreenTest( ExerciseServiceState() ), exerciseState = ExerciseServiceState() - ), + ) ) } } diff --git a/health-services/ExerciseSampleCompose/app/src/test/java/com/example/exercisesamplecompose/presentation/PreparingExerciseScreenTest.kt b/health-services/ExerciseSampleCompose/app/src/test/java/com/example/exercisesamplecompose/presentation/PreparingExerciseScreenTest.kt index b79d661a..1d55fb85 100644 --- a/health-services/ExerciseSampleCompose/app/src/test/java/com/example/exercisesamplecompose/presentation/PreparingExerciseScreenTest.kt +++ b/health-services/ExerciseSampleCompose/app/src/test/java/com/example/exercisesamplecompose/presentation/PreparingExerciseScreenTest.kt @@ -47,7 +47,7 @@ class PreparingExerciseScreenTest( }_${device.id}$suffix.png" @Composable - override fun TestScaffold(content: @Composable () -> Unit) { + override fun TestScaffold(content: @Composable () -> Unit) { CorrectLayout { AppScaffold(timeText = {}) { content() @@ -55,25 +55,24 @@ class PreparingExerciseScreenTest( } } - @Test fun preparing() = runTest { AppScaffold { - PreparingExerciseScreen( - uiState = - PreparingScreenState.Preparing( - serviceState = - ServiceState.Connected( - ExerciseServiceState() - ), - isTrackingInAnotherApp = false, - requiredPermissions = PreparingViewModel.permissions, - hasExerciseCapabilities = true - ), - ambientState = AmbientState.Interactive - ) - } + PreparingExerciseScreen( + uiState = + PreparingScreenState.Preparing( + serviceState = + ServiceState.Connected( + ExerciseServiceState() + ), + isTrackingInAnotherApp = false, + requiredPermissions = PreparingViewModel.permissions, + hasExerciseCapabilities = true + ), + ambientState = AmbientState.Interactive + ) + } } @Test From 58b1b294d4b38f81b2678d4fad2a12f031d83386 Mon Sep 17 00:00:00 2001 From: MinGyu Son <128052197+M1n9yu23@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:44:02 +0900 Subject: [PATCH 2/2] fix: merge conflict resolution in ExerciseScreen and PreparingExerciseScreen --- .../presentation/exercise/ExerciseScreen.kt | 26 ++--- .../preparing/PreparingExerciseScreen.kt | 98 ++++++++++--------- 2 files changed, 61 insertions(+), 63 deletions(-) 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 ad092f99..80ef27e8 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 @@ -159,9 +159,7 @@ fun ExerciseScreen( ) { val coroutineScope = rememberCoroutineScope() val pagerState = rememberPagerState( - initialPage = 1, - pageCount = { 2 } - ) + initialPage = 1, pageCount = { 2 }) HorizontalPagerScaffold(pagerState = pagerState) { HorizontalPager( @@ -189,22 +187,18 @@ fun ExerciseScreen( } else { ExerciseMetrics(uiState = uiState) } - ) - } else { - ExerciseMetrics(uiState = uiState) + } } - } - } - // If we meet an exercise goal, show our exercise met dialog. - // This approach is for the sample, and doesn't guarantee processing of this event in all cases, - // such as the user exiting the app while this is in-progress. Consider alternatives to exposing - // state in a production app. - uiState.exerciseState?.exerciseGoal?.let { - Log.d("ExerciseGoalMet", "Showing exercise goal met dialog") - ExerciseGoalMet(it.isNotEmpty()) + // If we meet an exercise goal, show our exercise met dialog. + // This approach is for the sample, and doesn't guarantee processing of this event in all cases, + // such as the user exiting the app while this is in-progress. Consider alternatives to exposing + // state in a production app. + uiState.exerciseState?.exerciseGoal?.let { + Log.d("ExerciseGoalMet", "Showing exercise goal met dialog") + ExerciseGoalMet(it.isNotEmpty()) + } } - } } @Composable diff --git a/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/preparing/PreparingExerciseScreen.kt b/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/preparing/PreparingExerciseScreen.kt index 369f8417..e3440a92 100644 --- a/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/preparing/PreparingExerciseScreen.kt +++ b/health-services/ExerciseSampleCompose/app/src/main/java/com/example/exercisesamplecompose/presentation/preparing/PreparingExerciseScreen.kt @@ -20,7 +20,9 @@ import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -45,6 +47,7 @@ import androidx.health.services.client.data.LocationAvailability import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.compose.foundation.CurvedLayout +import androidx.wear.compose.foundation.CurvedScope import androidx.wear.compose.foundation.lazy.TransformingLazyColumn import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState import androidx.wear.compose.material3.CompactButton @@ -52,7 +55,9 @@ import androidx.wear.compose.material3.FilledIconButton import androidx.wear.compose.material3.Icon import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.Text +import androidx.wear.compose.material3.TimeText import androidx.wear.compose.material3.curvedText +import androidx.wear.compose.material3.timeTextCurvedText import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices import com.example.exercisesamplecompose.R import com.example.exercisesamplecompose.data.ServiceState @@ -151,60 +156,59 @@ fun PreparingExerciseScreen( locationAvailability = location ?: LocationAvailability.UNAVAILABLE ) ) - ) - TransformingLazyColumn( - state = columnState, - contentPadding = contentPadding - ) { - item { - Text( - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - text = stringResource(id = R.string.preparing_exercise), - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = 0.15f * LocalConfiguration.current.screenWidthDp.dp - ) - ) - } - item { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 6.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - FilledIconButton( - onClick = onStart, - enabled = uiState is PreparingScreenState.Preparing + TransformingLazyColumn( + state = columnState, + contentPadding = contentPadding + ) { + item { + Text( + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + text = stringResource(id = R.string.preparing_exercise), + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = 0.15f * LocalConfiguration.current.screenWidthDp.dp + ) + ) + } + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { - Icon( - Icons.Default.PlayArrow, - contentDescription = stringResource(id = R.string.start) - ) + FilledIconButton( + onClick = onStart, + enabled = uiState is PreparingScreenState.Preparing + ) { + Icon( + Icons.Default.PlayArrow, + contentDescription = stringResource(id = R.string.start) + ) + } } } - } - item { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 6.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - CompactButton( - label = { Text(stringResource(id = R.string.goal)) }, - onClick = onGoals - ) + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + CompactButton( + label = { Text(stringResource(id = R.string.goal)) }, + onClick = onGoals + ) + } } } } } -} /**Return [LocationAvailability] value code as a string**/