From dd66d848a4bff2dc46722f67b79cb12c7da92283 Mon Sep 17 00:00:00 2001 From: olorinmaia Date: Mon, 14 Sep 2026 15:37:18 +0200 Subject: [PATCH 1/2] Wear: let the watch tell the phone what it did with the pushed face MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The face choice on the phone applies only to a watch with Watch Face Push (Wear OS 6+), but the phone showed it to every watch, and hid the Custom watchface controls for the complications face - a dead end on a watch that runs the code-based face, or for loading a zip before switching. The watch now reports WatchFacePushStatus(supported, installedFace) after the startup sync, after every install and in reply to the preferences. The phone shows the choice only when the watch said it is supported, in a card of its own with the complications face's picture when that face is selected. The Custom watchface card is always there. While the watch still holds the other face, the chosen row says "Installing…" until the watch reports the swap. A custom watchface sent while the complications face is selected is stored on the watch but not shown, and a dialog says so at that moment. A reinstalled wear app started with the default face and kept it until something asked the phone for the preferences, which on a watch with only the pushed face nothing did. The data layer service asks once at start, as soon as a node is known, so the phone's choice wins within seconds of connecting. The confirmation before a switch names both faces and drops the notes about Wear OS 6 and editor settings. --- .../interfaces/rx/weardata/EventDataTest.kt | 4 + .../rx/events/EventWearUpdateGui.kt | 9 +- .../core/interfaces/rx/weardata/EventData.kt | 9 ++ .../sync/wear/compose/WearViewModelTest.kt | 61 ++++++++- .../app/aaps/plugins/sync/wear/WearPlugin.kt | 10 ++ .../plugins/sync/wear/compose/WearScreen.kt | 128 ++++++++++++------ .../sync/wear/compose/WearScreenPreviews.kt | 22 ++- .../sync/wear/compose/WearViewModel.kt | 39 +++++- .../wear/wearintegration/DataHandlerMobile.kt | 2 + .../src/androidMain/res/values/strings.xml | 4 +- .../app/aaps/wear/comm/DataHandlerWear.kt | 10 +- .../wear/comm/DataLayerListenerServiceWear.kt | 12 ++ .../wear/watchfaces/WatchFacePushHelper.kt | 47 ++++++- .../watchfaces/WatchFacePushHelperTest.kt | 15 +- 14 files changed, 305 insertions(+), 67 deletions(-) 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..8f23bbd1cfcf 100644 --- a/wear/src/main/kotlin/app/aaps/wear/watchfaces/WatchFacePushHelper.kt +++ b/wear/src/main/kotlin/app/aaps/wear/watchfaces/WatchFacePushHelper.kt @@ -8,6 +8,9 @@ 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 @@ -63,6 +66,7 @@ enum class PushedFace(val id: String) { class WatchFacePushHelper( private val context: Context, private val sp: SP, + private val rxBus: RxBus, private val aapsLogger: AAPSLogger ) { @@ -170,17 +174,49 @@ 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 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 +240,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 @@ -252,6 +288,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`() { From 0c14d671b7f71d33edb4f8a723f5548d5c30bf00 Mon Sep 17 00:00:00 2001 From: olorinmaia Date: Mon, 14 Sep 2026 22:00:11 +0200 Subject: [PATCH 2/2] Wear: one WatchFacePushHelper per process, so the install lock holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lock that serialises installs lives in the helper instance, and the helper had no scope, so every injection site got its own instance and its own lock: the app, the data handler, the menu and the data layer service. After a reinstall the startup sync and the preferences from the phone still installed side by side, both found the slot empty, and the second failed with "limit reached" while the phone said "Installing…" for good. The helper is now a singleton. Two smaller things on the same path. A fresh install waits a few seconds for the phone's choice before installing the default, since the phone answers the resend request well within that time - measured 0.6 s - and the default would only be swapped out again. And an install whose token is already the synced one returns at once, so two callers wanting the same face no longer install it twice; a menu tap still installs, because that is the wearer asking for a face they removed. --- .../wear/watchfaces/WatchFacePushHelper.kt | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) 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 8f23bbd1cfcf..e4ffec52289d 100644 --- a/wear/src/main/kotlin/app/aaps/wear/watchfaces/WatchFacePushHelper.kt +++ b/wear/src/main/kotlin/app/aaps/wear/watchfaces/WatchFacePushHelper.kt @@ -14,10 +14,13 @@ 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 @@ -61,7 +64,13 @@ 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, @@ -97,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. * @@ -178,6 +201,14 @@ class WatchFacePushHelper( 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 @@ -248,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) } }