diff --git a/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/rx/weardata/EventDataTest.kt b/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/rx/weardata/EventDataTest.kt index 4cb148c32287..157796184f28 100644 --- a/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/rx/weardata/EventDataTest.kt +++ b/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/rx/weardata/EventDataTest.kt @@ -161,6 +161,10 @@ class EventDataTest { assertThat(EventData.deserializeByte(it.serializeByte())).isEqualTo(it) assertThat(EventData.deserialize(it.serialize())).isEqualTo(it) } + EventData.WatchFacePushStatus(supported = true, installedFace = "wfs").let { + assertThat(EventData.deserializeByte(it.serializeByte())).isEqualTo(it) + assertThat(EventData.deserialize(it.serialize())).isEqualTo(it) + } EventData.Status( dataset = 0, externalStatus = "st", iobSum = "1", iobDetail = "2", cob = "3", currentBasal = "4", battery = "5", rigBattery = "6", openApsStatus = 7L, bgi = "8", batteryLevel = 9, patientName = "p", diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventWearUpdateGui.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventWearUpdateGui.kt index d2fe195a9952..7efbb71cd25b 100644 --- a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventWearUpdateGui.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventWearUpdateGui.kt @@ -1,11 +1,18 @@ package app.aaps.core.interfaces.rx.events import app.aaps.core.interfaces.rx.weardata.CwfData +import app.aaps.core.interfaces.rx.weardata.EventData /** * Fired to update the GUI on the wearable device. * * @param customWatchfaceData Data for a custom watchface, or null if not applicable. * @param exportFile If true, the data should be exported to a file. + * @param watchFacePushStatus What the watch reported about Watch Face Push, or null if this event + * carries something else. */ -data class EventWearUpdateGui(val customWatchfaceData: CwfData? = null, val exportFile: Boolean = false) : Event() \ No newline at end of file +data class EventWearUpdateGui( + val customWatchfaceData: CwfData? = null, + val exportFile: Boolean = false, + val watchFacePushStatus: EventData.WatchFacePushStatus? = null +) : Event() \ No newline at end of file diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/weardata/EventData.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/weardata/EventData.kt index bf8bb0bde377..285179cd4e1c 100644 --- a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/weardata/EventData.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/weardata/EventData.kt @@ -44,6 +44,15 @@ sealed class EventData : Event() { @Serializable data class ActionPong(val timeStamp: Long, val apiLevel: Int) : EventData() + /** + * What the watch did with `StringKey.WearPushedWatchface`. Sent after the startup sync, after + * every install, and in reply to [Preferences], so the phone shows the face choice only on a + * watch that has Watch Face Push (Wear OS 6+) and can tell when the watch still holds the other + * face. [installedFace] is a `PushedWatchfaceId` value, or null when no face of ours is installed. + */ + @Serializable + data class WatchFacePushStatus(val supported: Boolean, val installedFace: String? = null) : EventData() + @Serializable data class WearException( val timeStamp: Long, diff --git a/plugins/sync/src/androidHostTest/kotlin/app/aaps/plugins/sync/wear/compose/WearViewModelTest.kt b/plugins/sync/src/androidHostTest/kotlin/app/aaps/plugins/sync/wear/compose/WearViewModelTest.kt index c2fa93f748a7..62e79877b004 100644 --- a/plugins/sync/src/androidHostTest/kotlin/app/aaps/plugins/sync/wear/compose/WearViewModelTest.kt +++ b/plugins/sync/src/androidHostTest/kotlin/app/aaps/plugins/sync/wear/compose/WearViewModelTest.kt @@ -9,6 +9,7 @@ import app.aaps.core.interfaces.rx.events.Event import app.aaps.core.interfaces.rx.events.EventMobileToWear import app.aaps.core.interfaces.rx.events.EventWearUpdateGui import app.aaps.core.interfaces.rx.weardata.CwfData +import app.aaps.core.interfaces.rx.weardata.CwfFile import app.aaps.core.interfaces.rx.weardata.CwfMetadataKey import app.aaps.core.interfaces.rx.weardata.EventData import app.aaps.core.interfaces.utils.DateUtil @@ -53,6 +54,7 @@ internal class WearViewModelTest { private val savedCustomWatchfaceFlow = MutableStateFlow(null) private val eventWearUpdateGuiFlow = MutableSharedFlow() private val pushedWatchfaceFlow = MutableStateFlow(PushedWatchfaceId.CWF) + private val watchFacePushStatusFlow = MutableStateFlow(null) private lateinit var sut: WearViewModel @@ -62,6 +64,7 @@ internal class WearViewModelTest { Dispatchers.setMain(UnconfinedTestDispatcher()) whenever(wearPlugin.connectedDevice).thenReturn(connectedDeviceFlow) whenever(wearPlugin.savedCustomWatchface).thenReturn(savedCustomWatchfaceFlow) + whenever(wearPlugin.watchFacePushStatus).thenReturn(watchFacePushStatusFlow) whenever(rxBus.toFlow(EventWearUpdateGui::class)).thenReturn(eventWearUpdateGuiFlow) whenever(preferences.observe(StringKey.WearPushedWatchface)).thenReturn(pushedWatchfaceFlow) whenever(rh.gs(SyncStrings.no_watch_connected)).thenReturn("No watch connected") @@ -131,13 +134,61 @@ internal class WearViewModelTest { } @Test - fun `the custom watchface hint follows the pushed watchface setting`() { - // Custom watchface installed on the watch: nothing to warn about - assertThat(sut.uiState.value.customWatchfaceInstalled).isTrue() + fun `the custom watchface warning follows the pushed watchface setting`() { + // Custom watchface chosen: nothing to warn about + assertThat(sut.uiState.value.customWatchfaceSelected).isTrue() - // The complications face installed instead: a loaded zip would not be shown + // The complications face chosen instead: a loaded zip would not be shown pushedWatchfaceFlow.value = PushedWatchfaceId.WFS - assertThat(sut.uiState.value.customWatchfaceInstalled).isFalse() + assertThat(sut.uiState.value.customWatchfaceSelected).isFalse() + } + + @Test + fun `the face choice appears only once the watch reports Watch Face Push`() { + // Nothing reported yet, or an older wear app that never reports: no choice + assertThat(sut.uiState.value.watchFacePushSupported).isFalse() + assertThat(sut.uiState.value.installedWatchface).isNull() + + watchFacePushStatusFlow.value = EventData.WatchFacePushStatus(supported = true, installedFace = PushedWatchfaceId.WFS) + assertThat(sut.uiState.value.watchFacePushSupported).isTrue() + assertThat(sut.uiState.value.installedWatchface).isEqualTo(PushedWatchfaceId.WFS) + + // A watch below Wear OS 6 reports, and the choice stays away + watchFacePushStatusFlow.value = EventData.WatchFacePushStatus(supported = false) + assertThat(sut.uiState.value.watchFacePushSupported).isFalse() + + // Disconnected: the plugin clears the report, the next watch speaks for itself + watchFacePushStatusFlow.value = null + assertThat(sut.uiState.value.watchFacePushSupported).isFalse() + assertThat(sut.uiState.value.installedWatchface).isNull() + } + + @Test + fun `sending a zip while the complications face is on the wrist says so once`() { + val zip = CwfFile( + cwfData = CwfData(json = "{}", metadata = mutableMapOf(CwfMetadataKey.CWF_NAME to "Analog G-Watch"), resData = mutableMapOf()), + zipByteArray = ByteArray(0) + ) + watchFacePushStatusFlow.value = EventData.WatchFacePushStatus(supported = true) + pushedWatchfaceFlow.value = PushedWatchfaceId.WFS + + sut.selectWatchface(zip) + assertThat(sut.uiState.value.customWatchfaceNotShown).isEqualTo("Analog G-Watch") + + sut.dismissCustomWatchfaceNotShown() + assertThat(sut.uiState.value.customWatchfaceNotShown).isNull() + } + + @Test + fun `sending a zip while the custom face is on the wrist says nothing`() { + val zip = CwfFile( + cwfData = CwfData(json = "{}", metadata = mutableMapOf(CwfMetadataKey.CWF_NAME to "Analog G-Watch"), resData = mutableMapOf()), + zipByteArray = ByteArray(0) + ) + watchFacePushStatusFlow.value = EventData.WatchFacePushStatus(supported = true) + + sut.selectWatchface(zip) + assertThat(sut.uiState.value.customWatchfaceNotShown).isNull() } @Test diff --git a/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/WearPlugin.kt b/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/WearPlugin.kt index 89b69a0a1bf8..6e80843cc8ac 100644 --- a/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/WearPlugin.kt +++ b/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/WearPlugin.kt @@ -104,8 +104,17 @@ class WearPlugin( private val _savedCustomWatchface = MutableStateFlow(null) val savedCustomWatchface: StateFlow = _savedCustomWatchface.asStateFlow() + /** + * What the watch last said about Watch Face Push: whether it has it, and which face it holds. + * Null until the watch reports, and again when it disconnects - a fresh watch must speak for + * itself, since the answer differs from one watch to the next. + */ + private val _watchFacePushStatus = MutableStateFlow(null) + val watchFacePushStatus: StateFlow = _watchFacePushStatus.asStateFlow() + fun updateConnectedDevice(deviceName: String?) { _connectedDevice.value = deviceName + if (deviceName == null) _watchFacePushStatus.value = null } fun updateSavedCustomWatchface(cwfData: CwfData?) { @@ -203,6 +212,7 @@ class WearPlugin( checkCustomWatchfacePreferences() } } + event.watchFacePushStatus?.let { _watchFacePushStatus.value = it } } } rxBus.toFlow(EventMobileToWear::class) diff --git a/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/compose/WearScreen.kt b/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/compose/WearScreen.kt index f780b6b910d9..d9297754b55c 100644 --- a/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/compose/WearScreen.kt +++ b/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/compose/WearScreen.kt @@ -67,6 +67,7 @@ import app.aaps.core.ui.compose.AapsSpacing import app.aaps.core.ui.compose.LocalSnackbarHostState import app.aaps.core.ui.compose.ToolbarConfig import app.aaps.core.ui.compose.dialogs.OkCancelDialog +import app.aaps.core.ui.compose.dialogs.OkDialog import app.aaps.plugins.sync.R @Composable @@ -176,6 +177,7 @@ internal fun WearScreen( context.startActivity(Intent(Intent.ACTION_VIEW, moreWatchfacesUrl.toUri())) }, onSelectPushedWatchface = { viewModel.selectPushedWatchface(it) }, + onDismissCustomWatchfaceNotShown = { viewModel.dismissCustomWatchfaceNotShown() }, modifier = modifier ) } @@ -199,16 +201,17 @@ internal fun WearMainContent( onExportTemplate: () -> Unit, onMoreWatchfaces: () -> Unit, onSelectPushedWatchface: (String) -> Unit, + onDismissCustomWatchfaceNotShown: () -> Unit, modifier: Modifier = Modifier ) { // The face the wearer tapped, waiting for their confirmation; null while no dialog is open. - // Confirmed first because the watch replaces its installed face at once and that resets the - // choices made in the watch face editor. + // Confirmed first because the watch replaces its installed face at once. var pendingWatchface by remember { mutableStateOf(null) } pendingWatchface?.let { face -> + val current = if (uiState.customWatchfaceSelected) PushedWatchfaceId.CWF else PushedWatchfaceId.WFS OkCancelDialog( title = stringResource(SyncStrings.wear_pushed_watchface_confirm_title), - message = stringResource(SyncStrings.wear_pushed_watchface_confirm_message, pushedWatchfaceLabel(face)), + message = stringResource(SyncStrings.wear_pushed_watchface_confirm_message, pushedWatchfaceLabel(face), pushedWatchfaceLabel(current)), onConfirm = { onSelectPushedWatchface(face) pendingWatchface = null @@ -217,6 +220,16 @@ internal fun WearMainContent( ) } + // A zip was just sent while the complications face is the one on the wrist: it is stored on + // the watch, but nothing shows it until the custom face is selected. Said once, at that moment. + uiState.customWatchfaceNotShown?.let { name -> + OkDialog( + title = pushedWatchfaceLabel(PushedWatchfaceId.CWF), + message = stringResource(SyncStrings.wear_custom_watchface_not_shown, name), + onDismiss = onDismissCustomWatchfaceNotShown + ) + } + Column( modifier = modifier .fillMaxSize() @@ -249,9 +262,9 @@ internal fun WearMainContent( } } - // Watchface Card (visible only when connected): which face the watch installs, then the - // controls of that face - if (uiState.isDeviceConnected) { + // Pushed Watchface Card: only on a watch that reported Watch Face Push (Wear OS 6+). Below + // that the pushed faces cannot exist, and a choice that does nothing would only mislead. + if (uiState.isDeviceConnected && uiState.watchFacePushSupported) { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) @@ -270,57 +283,80 @@ internal fun WearMainContent( // Watch Face Push gives the app one slot, so this is a choice between the two // embedded faces, not two switches. The tap asks first, see pendingWatchface. + // While the watch still holds the other face - the seconds an install takes, + // or longer after a reinstall until the preferences reach it - the chosen row + // says so, quietly: it is progress, not a fault. + val selectedId = if (uiState.customWatchfaceSelected) PushedWatchfaceId.CWF else PushedWatchfaceId.WFS + val installing = uiState.installedWatchface != null && uiState.installedWatchface != selectedId Column(modifier = Modifier.selectableGroup()) { listOf(PushedWatchfaceId.CWF, PushedWatchfaceId.WFS).forEach { face -> - val selected = (face == PushedWatchfaceId.CWF) == uiState.customWatchfaceInstalled + val selected = face == selectedId WatchfaceChoiceRow( label = pushedWatchfaceLabel(face), selected = selected, + hint = if (selected && installing) stringResource(SyncStrings.wear_pushed_watchface_installing) else null, onSelect = { if (!selected) pendingWatchface = face } ) } } - if (uiState.customWatchfaceInstalled) { - Text( - text = stringResource(SyncStrings.wear_custom_watchface, uiState.watchfaceName), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(horizontal = AapsSpacing.small) + // What the wrist shows with the complications face chosen. The custom face + // needs no picture here: its own preview is in its own card below. + if (!uiState.customWatchfaceSelected) { + Spacer(modifier = Modifier.height(AapsSpacing.small)) + Image( + painter = painterResource(R.drawable.wfs_watchface_preview), + contentDescription = pushedWatchfaceLabel(PushedWatchfaceId.WFS), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AapsSpacing.extraLarge), + contentScale = ContentScale.FillWidth ) + } + } + } + } - // Row 1: Load + Info - ButtonRow( - button1 = ButtonDef(Icons.Default.Upload, stringResource(SyncStrings.wear_load_watchface), onLoadWatchface), - button2 = if (uiState.hasCustomWatchface) - ButtonDef(Icons.Default.Info, stringResource(SyncStrings.wear_infos_watchface), onInfosWatchface) - else null - ) + // Custom Watchface Card (visible only when connected), whatever face is chosen above: a + // watch below Wear OS 6 runs the code-based face, a watch like the Galaxy Watch 5 runs it + // beside the pushed faces, and a zip can be loaded before switching + if (uiState.isDeviceConnected) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(AapsSpacing.large), + verticalArrangement = Arrangement.spacedBy(AapsSpacing.medium) + ) { + Text( + text = stringResource(SyncStrings.wear_custom_watchface, uiState.watchfaceName), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = AapsSpacing.small) + ) - // Row 2: More Watchfaces + Export - ButtonRow( - button1 = ButtonDef(Icons.Default.Public, stringResource(InterfacesStrings.wear_more_watchfaces), onMoreWatchfaces), - button2 = ButtonDef(Icons.Default.Download, stringResource(SyncStrings.wear_export_watchface), onExportTemplate) - ) + // Row 1: Load + Info + ButtonRow( + button1 = ButtonDef(Icons.Default.Upload, stringResource(SyncStrings.wear_load_watchface), onLoadWatchface), + button2 = if (uiState.hasCustomWatchface) + ButtonDef(Icons.Default.Info, stringResource(SyncStrings.wear_infos_watchface), onInfosWatchface) + else null + ) - // Watchface preview image - uiState.watchfaceImage?.let { image -> - Spacer(modifier = Modifier.height(AapsSpacing.small)) - Image( - bitmap = image, - contentDescription = uiState.watchfaceName, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AapsSpacing.extraLarge), - contentScale = ContentScale.FillWidth - ) - } - } else { - // The complications face has nothing to load or export: its layout is fixed - // and the wearer edits the slots on the watch. Only its picture is shown. + // Row 2: More Watchfaces + Export + ButtonRow( + button1 = ButtonDef(Icons.Default.Public, stringResource(InterfacesStrings.wear_more_watchfaces), onMoreWatchfaces), + button2 = ButtonDef(Icons.Default.Download, stringResource(SyncStrings.wear_export_watchface), onExportTemplate) + ) + + // Watchface preview image + uiState.watchfaceImage?.let { image -> Spacer(modifier = Modifier.height(AapsSpacing.small)) Image( - painter = painterResource(R.drawable.wfs_watchface_preview), - contentDescription = pushedWatchfaceLabel(PushedWatchfaceId.WFS), + bitmap = image, + contentDescription = uiState.watchfaceName, modifier = Modifier .fillMaxWidth() .padding(horizontal = AapsSpacing.extraLarge), @@ -338,10 +374,12 @@ internal fun WearMainContent( private fun pushedWatchfaceLabel(face: String): String = stringResource(if (face == PushedWatchfaceId.CWF) KeysStrings.wear_pushed_watchface_cwf else KeysStrings.wear_pushed_watchface_wfs) +/** One radio row; [hint] is a quiet note after the label, for a state that will pass by itself */ @Composable private fun WatchfaceChoiceRow( label: String, selected: Boolean, + hint: String?, onSelect: () -> Unit ) { Row( @@ -357,6 +395,14 @@ private fun WatchfaceChoiceRow( style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(start = AapsSpacing.medium) ) + if (hint != null) { + Text( + text = hint, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = AapsSpacing.small) + ) + } } } diff --git a/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/compose/WearScreenPreviews.kt b/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/compose/WearScreenPreviews.kt index ea1b879d70bc..82d758ac5ac1 100644 --- a/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/compose/WearScreenPreviews.kt +++ b/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/compose/WearScreenPreviews.kt @@ -4,6 +4,7 @@ import android.content.res.Configuration import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview +import app.aaps.core.keys.PushedWatchfaceId @Preview(showBackground = true) @Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @@ -15,7 +16,9 @@ internal fun WearMainContentPreview() { connectedDevice = "Galaxy Watch 5 (a1b2)", isDeviceConnected = true, hasCustomWatchface = true, - watchfaceName = "AAPS V2" + watchfaceName = "AAPS V2", + watchFacePushSupported = true, + installedWatchface = PushedWatchfaceId.CWF ), onResendData = {}, onOpenSettings = {}, @@ -23,7 +26,8 @@ internal fun WearMainContentPreview() { onInfosWatchface = {}, onExportTemplate = {}, onMoreWatchfaces = {}, - onSelectPushedWatchface = {} + onSelectPushedWatchface = {}, + onDismissCustomWatchfaceNotShown = {} ) } } @@ -34,10 +38,16 @@ internal fun WearMainContentPreview() { internal fun WearMainContentComplicationsFacePreview() { MaterialTheme { WearMainContent( + // The complications face chosen while the watch still holds the custom one: the chosen + // row carries the "Installing…" note, and its picture shows under the choice uiState = WearUiState( connectedDevice = "Galaxy Watch 8 (c3d4)", isDeviceConnected = true, - customWatchfaceInstalled = false + hasCustomWatchface = true, + watchfaceName = "Analog G-Watch", + customWatchfaceSelected = false, + watchFacePushSupported = true, + installedWatchface = PushedWatchfaceId.CWF ), onResendData = {}, onOpenSettings = {}, @@ -45,7 +55,8 @@ internal fun WearMainContentComplicationsFacePreview() { onInfosWatchface = {}, onExportTemplate = {}, onMoreWatchfaces = {}, - onSelectPushedWatchface = {} + onSelectPushedWatchface = {}, + onDismissCustomWatchfaceNotShown = {} ) } } @@ -65,7 +76,8 @@ internal fun WearMainContentDisconnectedPreview() { onInfosWatchface = {}, onExportTemplate = {}, onMoreWatchfaces = {}, - onSelectPushedWatchface = {} + onSelectPushedWatchface = {}, + onDismissCustomWatchfaceNotShown = {} ) } } diff --git a/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/compose/WearViewModel.kt b/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/compose/WearViewModel.kt index 40cdf37bdec8..33e6f05ee5df 100644 --- a/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/compose/WearViewModel.kt +++ b/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/compose/WearViewModel.kt @@ -58,10 +58,16 @@ data class WearUiState( val watchfaceName: String = "", val watchfaceImage: ImageBitmap? = null, /** - * Whether the face the watch installs through Watch Face Push is the one that shows the - * custom watchface. When it is not, a loaded zip reaches the watch but stays invisible there. + * Whether the phone's choice of pushed face is the one that shows the custom watchface. When it + * is not, a loaded zip reaches the watch but stays invisible there until the choice changes. */ - val customWatchfaceInstalled: Boolean = true, + val customWatchfaceSelected: Boolean = true, + /** Whether the connected watch reported Watch Face Push (Wear OS 6+); the face choice shows only then */ + val watchFacePushSupported: Boolean = false, + /** The face the watch reported holding, as a `PushedWatchfaceId` value, or null when unknown or none */ + val installedWatchface: String? = null, + /** Name of a custom watchface just sent while the complications face is selected; the screen says so once, then clears it */ + val customWatchfaceNotShown: String? = null, val showInfos: Boolean = false, val cwfInfosState: CwfInfosState? = null, val showImportList: Boolean = false, @@ -148,7 +154,17 @@ class WearViewModel( } viewModelScope.launch { preferences.observe(StringKey.WearPushedWatchface).collect { face -> - _uiState.update { it.copy(customWatchfaceInstalled = face == PushedWatchfaceId.CWF) } + _uiState.update { it.copy(customWatchfaceSelected = face == PushedWatchfaceId.CWF) } + } + } + viewModelScope.launch { + wearPlugin.watchFacePushStatus.collect { status -> + _uiState.update { + it.copy( + watchFacePushSupported = status?.supported == true, + installedWatchface = status?.installedFace + ) + } } } viewModelScope.launch { @@ -307,7 +323,20 @@ class WearViewModel( preferences.put(StringNonKey.WearCwfAuthorVersion, metadata[CwfMetadataKey.CWF_AUTHOR_VERSION] ?: "") preferences.put(StringNonKey.WearCwfFileName, metadata[CwfMetadataKey.CWF_FILENAME] ?: "") rxBus.send(EventMobileToWearWatchface(cwfFile.zipByteArray)) - _uiState.update { it.copy(showImportList = false, importItems = emptyList()) } + _uiState.update { state -> + // Sent and stored either way; but with the complications face on the wrist nothing + // draws it, and the wearer should hear that now rather than wonder later + val hidden = state.watchFacePushSupported && !state.customWatchfaceSelected + state.copy( + showImportList = false, + importItems = emptyList(), + customWatchfaceNotShown = if (hidden) metadata[CwfMetadataKey.CWF_NAME] ?: "" else null + ) + } + } + + fun dismissCustomWatchfaceNotShown() { + _uiState.update { it.copy(customWatchfaceNotShown = null) } } fun hideImportList() { diff --git a/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/wearintegration/DataHandlerMobile.kt b/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/wearintegration/DataHandlerMobile.kt index bed78bcf283d..3b274b49f0ad 100644 --- a/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/wearintegration/DataHandlerMobile.kt +++ b/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/wearintegration/DataHandlerMobile.kt @@ -219,6 +219,8 @@ class DataHandlerMobile( (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).cancel(Constants.NOTIFICATION_ID) } onEvent { resendData(it.from) } + // The watch's word on Watch Face Push, kept by WearPlugin for the wear screen + onEvent { rxBus.send(EventWearUpdateGui(watchFacePushStatus = it)) } onEvent { sendToWear( EventData.ConfirmAction( diff --git a/plugins/sync/src/androidMain/res/values/strings.xml b/plugins/sync/src/androidMain/res/values/strings.xml index 1408e3489c0a..24b300f1a1a4 100644 --- a/plugins/sync/src/androidMain/res/values/strings.xml +++ b/plugins/sync/src/androidMain/res/values/strings.xml @@ -127,8 +127,10 @@ General Settings Custom Watchface Settings Custom Watchface: %1$s + Installing… + The watch shows the Complications watchface. Select Custom watchface to see %1$s on the watch. Change the watchface on the watch? - %1$s will replace the AAPS watchface installed on the watch. Choices made in the watch face editor are reset. Wear OS 6 and newer only. + %1$s will replace the %2$s on the watch. Load Watchface Infos Watchface Export template diff --git a/wear/src/main/kotlin/app/aaps/wear/comm/DataHandlerWear.kt b/wear/src/main/kotlin/app/aaps/wear/comm/DataHandlerWear.kt index 05afb55a8e0b..034c547c00f3 100644 --- a/wear/src/main/kotlin/app/aaps/wear/comm/DataHandlerWear.kt +++ b/wear/src/main/kotlin/app/aaps/wear/comm/DataHandlerWear.kt @@ -240,9 +240,13 @@ class DataHandlerWear( preferences.put(IntKey.OverviewCarbsButtonIncrement2, it.carbsButtonIncrement2) // The Watch Face Push face the wearer chose on the phone. Stored whatever the watch // can do with it, so a watch updated to Wear OS 6 later installs the chosen face on - // its next start; swapped now when the watch can - if (watchFacePushHelper.selectFace(it.pushedWatchface)) - dataStoreScope.launch { watchFacePushHelper.installOrUpdate() } + // its next start; swapped now when the watch can. Either way the phone is told what + // the watch has, which is what lets its screen show the choice only where it applies + val faceChanged = watchFacePushHelper.selectFace(it.pushedWatchface) + dataStoreScope.launch { + if (faceChanged) watchFacePushHelper.installOrUpdate() + else watchFacePushHelper.reportStatus() + } } onEvent { val serialized = it.serialize() diff --git a/wear/src/main/kotlin/app/aaps/wear/comm/DataLayerListenerServiceWear.kt b/wear/src/main/kotlin/app/aaps/wear/comm/DataLayerListenerServiceWear.kt index fcc11d4df14d..2cda8619b9c9 100644 --- a/wear/src/main/kotlin/app/aaps/wear/comm/DataLayerListenerServiceWear.kt +++ b/wear/src/main/kotlin/app/aaps/wear/comm/DataLayerListenerServiceWear.kt @@ -23,6 +23,7 @@ import app.aaps.wear.R import app.aaps.wear.events.EventWearPreferenceChange import app.aaps.wear.heartrate.HeartRateListener import app.aaps.wear.interaction.ConfigurationActivity +import app.aaps.wear.watchfaces.WatchFacePushHelper import app.aaps.wear.wearStepCount.StepCountListener import com.google.android.gms.tasks.Tasks import com.google.android.gms.wearable.CapabilityClient @@ -53,6 +54,7 @@ class DataLayerListenerServiceWear : WearableListenerService() { @Inject lateinit var sp: SP @Inject lateinit var rxBus: RxBus @Inject lateinit var aapsSchedulers: AapsSchedulers + @Inject lateinit var watchFacePushHelper: WatchFacePushHelper private val dataClient by lazy { Wearable.getDataClient(this) } private val messageClient by lazy { Wearable.getMessageClient(this) } @@ -94,6 +96,16 @@ class DataLayerListenerServiceWear : WearableListenerService() { updateHeartRateListener() updateStepsCountListener() + + // A watch with Watch Face Push has no code-based face to ask the phone for its data, so + // nothing would ask until a menu or a tile is opened. The pushed face choice lives on the + // phone, and after a reinstall the watch starts with the default face until the + // preferences arrive. So ask now. Posted behind the capability lookup above, on the same + // thread: that is the first moment a node is known, and the collectors above exist by then. + handler.post { + if (watchFacePushHelper.isSupported()) + rxBus.send(EventWearToMobile(EventData.ActionResendData("DataLayerListenerServiceWear::onCreate"))) + } } override fun onCapabilityChanged(p0: CapabilityInfo) { diff --git a/wear/src/main/kotlin/app/aaps/wear/watchfaces/WatchFacePushHelper.kt b/wear/src/main/kotlin/app/aaps/wear/watchfaces/WatchFacePushHelper.kt index eaf2482b2c58..e4ffec52289d 100644 --- a/wear/src/main/kotlin/app/aaps/wear/watchfaces/WatchFacePushHelper.kt +++ b/wear/src/main/kotlin/app/aaps/wear/watchfaces/WatchFacePushHelper.kt @@ -8,13 +8,19 @@ import androidx.wear.watchfacepush.WatchFacePushManager import androidx.wear.watchfacepush.WatchFacePushManagerFactory import app.aaps.core.interfaces.logging.AAPSLogger import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.rx.bus.RxBus +import app.aaps.core.interfaces.rx.events.EventWearToMobile +import app.aaps.core.interfaces.rx.weardata.EventData import app.aaps.core.interfaces.sharedPreferences.SP import app.aaps.core.keys.PushedWatchfaceId import app.aaps.wear.watchfaces.WatchFacePushHelper.Companion.KEY_FACE_INSTALLED +import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import java.io.File import java.io.IOException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -58,11 +64,18 @@ enum class PushedFace(val id: String) { * The face APKs and their validation tokens are generated at build time (see `EmbedWatchFaceTask` * in the wear build script) and shipped in assets. A token is bound to the exact APK bytes, so both * always travel together. + * + * One instance for the process, and that is not a detail: the install lock below lives in the + * instance. Without the scope every injection site got its own helper with its own lock - the app, + * the data handler, the menu and the data layer service - and two installs ran side by side after + * all, exactly the race the lock exists to stop. Seen on a Galaxy Watch 5. */ +@SingleIn(AppScope::class) @Inject class WatchFacePushHelper( private val context: Context, private val sp: SP, + private val rxBus: RxBus, private val aapsLogger: AAPSLogger ) { @@ -93,12 +106,26 @@ class WatchFacePushHelper( * not: a watch that gets Wear OS 6 later pushes the chosen face on its next start. */ private const val KEY_SELECTED_FACE = "wfpush_selected_face" + + /** + * How long a fresh install waits for the phone's choice before installing the default. + * + * The phone holds the choice, and when it is reachable it answers the resend request that + * the data layer service sends at start - measured 0.6 s after the app came up. Installing + * the default first only to replace it costs a second install and an editor reset for + * nothing. Not reachable: the default goes in after this wait, as it did before. + */ + private const val FIRST_CHOICE_GRACE_MS = 5_000L } /** The face the wearer chose on the phone; [PushedFace.CWF] until a choice arrives - the watchface AAPS users know */ val selectedFace: PushedFace get() = PushedFace.fromId(sp.getString(KEY_SELECTED_FACE, PushedFace.CWF.id)) + /** Whether the phone's choice has ever reached this watch; false after a fresh install */ + private val hasStoredSelection: Boolean + get() = sp.getString(KEY_SELECTED_FACE, "").isNotEmpty() + /** * Records the wearer's choice from the phone. * @@ -170,17 +197,57 @@ class WatchFacePushHelper( * activates the face: selecting it is always the user's choice (picker, or the main menu entry). */ suspend fun syncOnStartup() { - if (!isSupported()) return + if (!isSupported()) { + reportStatus() + return + } + // A fresh install has no choice stored yet. Give the phone a moment to send it, so the + // face that goes in is the chosen one rather than the default followed by a swap. If the + // choice arrives meanwhile, the preferences path installs it and the compare below finds + // it already in place. + if (!hasStoredSelection) { + aapsLogger.debug(LTag.WEAR, "WatchFacePush: no face chosen yet, waiting for the phone") + delay(FIRST_CHOICE_GRACE_MS) + } // A matching token means this exact face was already put in place once — if it is missing // now, the user removed it: respect that val token = embeddedFaceToken(selectedFace) ?: return - if (sp.getString(KEY_SYNCED_FACE, "") == token) return + if (sp.getString(KEY_SYNCED_FACE, "") == token) { + reportStatus() + return + } aapsLogger.debug(LTag.WEAR, "WatchFacePush: embedded face changed, syncing") // Catches all its failures internally — this path runs on app start and must never take - // the app down; on failure the version is not marked synced, so the next start retries + // the app down; on failure the version is not marked synced, so the next start retries. + // Reports to the phone when done, whatever the outcome. installOrUpdate() } + /** + * Tells the phone what this watch can do and which face it holds. + * + * The phone shows the face choice only on a watch that says it has Watch Face Push, and can + * warn when the watch still holds the other face - after a reinstall the watch starts with the + * default and only catches up once the preferences reach it. Sent after the startup sync, after + * every install, and in reply to the preferences, so the phone's picture is never older than + * the last exchange. Never throws: a failed listing reports "supported, face unknown". + */ + suspend fun reportStatus() = withContext(Dispatchers.IO) { + val status = if (!isSupported()) { + EventData.WatchFacePushStatus(supported = false) + } else try { + val packageName = listOwnFaces(createManager()).installedWatchFaceDetails.firstOrNull()?.packageName + EventData.WatchFacePushStatus( + supported = true, + installedFace = PushedFace.entries.firstOrNull { it.packageName(context.packageName) == packageName }?.id + ) + } catch (e: Exception) { + aapsLogger.error(LTag.WEAR, "WatchFacePush: status listing failed", e) + EventData.WatchFacePushStatus(supported = true) + } + rxBus.send(EventWearToMobile(status)) + } + /** * One install at a time. Three callers can want one at the same moment - the startup sync, the * preferences arriving from the phone, and the main menu - and measured on a Galaxy Watch 5 two @@ -204,7 +271,7 @@ class WatchFacePushHelper( */ suspend fun installOrUpdate(activate: Boolean = false): Boolean = withContext(Dispatchers.IO) { if (!isSupported()) return@withContext false - installLock.withLock { + val installed = installLock.withLock { // Read under the lock: a choice that arrived while another install was running is // what this one must install, not what was selected when it was asked for val face = selectedFace @@ -212,6 +279,14 @@ class WatchFacePushHelper( try { val manager = createManager() val token = embeddedFaceToken(face) ?: return@withLock false + // Two callers wanting the same face - the startup sync and the preferences that + // arrived during it - now run one after the other, and the second has nothing to + // do. Not for a menu tap: that is the wearer asking for a face they removed, and + // the token is still stored from before the removal. + if (!activate && sp.getString(KEY_SYNCED_FACE, "") == token) { + aapsLogger.debug(LTag.WEAR, "WatchFacePush: face ${face.id} already in place") + return@withLock true + } context.assets.open(face.assetApk).use { input -> apkFile.outputStream().use { input.copyTo(it) } } @@ -252,6 +327,9 @@ class WatchFacePushHelper( apkFile.delete() } } + // Whatever happened, the phone shows the truth rather than its own wish + reportStatus() + installed } /** Whether the selected face is the currently shown watch face */ diff --git a/wear/src/test/kotlin/app/aaps/wear/watchfaces/WatchFacePushHelperTest.kt b/wear/src/test/kotlin/app/aaps/wear/watchfaces/WatchFacePushHelperTest.kt index 4297a1c37844..07c337dcb27d 100644 --- a/wear/src/test/kotlin/app/aaps/wear/watchfaces/WatchFacePushHelperTest.kt +++ b/wear/src/test/kotlin/app/aaps/wear/watchfaces/WatchFacePushHelperTest.kt @@ -1,6 +1,9 @@ package app.aaps.wear.watchfaces import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.rx.bus.RxBus +import app.aaps.core.interfaces.rx.events.EventWearToMobile +import app.aaps.core.interfaces.rx.weardata.EventData import app.aaps.core.interfaces.sharedPreferences.SP import app.aaps.core.keys.PushedWatchfaceId import app.aaps.wear.AAPSLoggerTest @@ -33,10 +36,11 @@ import org.robolectric.annotation.Config internal class WatchFacePushHelperTest { private val sp: SP = mock() + private val rxBus: RxBus = mock() private val aapsLogger: AAPSLogger = AAPSLoggerTest() private fun sut(): WatchFacePushHelper = - WatchFacePushHelper(RuntimeEnvironment.getApplication(), sp, aapsLogger) + WatchFacePushHelper(RuntimeEnvironment.getApplication(), sp, rxBus, aapsLogger) @Test fun `below API 36 the feature reports unsupported`() { @@ -66,13 +70,20 @@ internal class WatchFacePushHelperTest { verifyNoInteractions(sp) } + @Test + fun `below API 36 the watch still tells the phone that Watch Face Push is missing`() = runTest { + // Without this the phone would show the face choice to a watch that cannot act on it + sut().syncOnStartup() + verify(rxBus).send(EventWearToMobile(EventData.WatchFacePushStatus(supported = false))) + } + /** An SP that hands back the default it is asked for, like the real one */ private fun spWithDefaults(): SP = mock().also { whenever(it.getString(any(), any())).thenAnswer { call -> call.arguments[1] } } private fun sut(sp: SP): WatchFacePushHelper = - WatchFacePushHelper(RuntimeEnvironment.getApplication(), sp, aapsLogger) + WatchFacePushHelper(RuntimeEnvironment.getApplication(), sp, rxBus, aapsLogger) @Test fun `the selected face is the Custom watchface until the phone chooses`() {