From c79130d373525d6e8fe92cb49237e0165289ff5b Mon Sep 17 00:00:00 2001 From: olorinmaia Date: Sun, 13 Sep 2026 00:50:37 +0200 Subject: [PATCH 1/3] Wear: setting to choose which Watch Face Format face AAPS installs Watch Face Push gives one app one slot, so the Custom watchface face from #5123 replaced the Watch Face Studio face (AAPS V4). Both are now built and embedded, and the wearer picks one on the phone. Phone: the Wear plugin's main page gets a "Watchface installed on watch" card with two choices, Custom watchface (default) and Complications watchface. Changing it asks for confirmation, because the watch replaces its face at once and the choices made in the watch face editor are reset. With the complications face selected, the CWF buttons are hidden and the face preview is shown instead. The choice travels with the preferences. Watch: WatchFacePushHelper keeps the selected face and updates its slot with the other APK when the choice changes. Measured on a Galaxy Watch 5, the updated slot stays the active face, so the wrist follows the phone without any activation permission. "Open settings on wear" opens the main settings menu when the complications face is selected, since the Display screen only holds settings of the code-based faces. Build: :wear:watchfacepush gets a "face" flavor dimension (wfs, cwf) with per-face templates, previews and strings; the wear app embeds both APKs with their validation tokens. Package suffixes differ on purpose: the runtime never re-applies DefaultProviderPolicy to a slot id it has bound, and the two documents use overlapping slot ids with different providers. Two faults found while testing the swap on a Galaxy Watch 5: - Installs could overlap. The startup sync and the preferences arriving from the phone both saw an empty slot table and both called add; the first won with the face the phone had just switched away from, the second failed with "limit of watch faces reached". Installs now run one at a time, and each reads the selected face under the lock, so the phone's last choice always wins. An update that hits a slot left stale by an uninstall from the watch settings falls back to add. - The picker listed the same face twice after such an uninstall and a reinstall: one slot, one package, two entries with the same picture. MultipleInstancesAllowed, which Watch Face Studio writes as true, is now false for both faces. The face id aapsv4 becomes wfs. Watches that already hold the old face get one config reset on the first sync, as the package name changes. --- .gitignore | 7 +- _docs/Complication_Libraries.md | 9 + .../core/interfaces/rx/weardata/EventData.kt | 8 +- .../src/androidMain/res/values/strings.xml | 4 + .../app/aaps/core/keys/PushedWatchfaceId.kt | 16 ++ .../kotlin/app/aaps/core/keys/StringKey.kt | 17 ++ .../sync/wear/compose/WearViewModelTest.kt | 20 ++ .../app/aaps/plugins/sync/wear/WearPlugin.kt | 3 + .../plugins/sync/wear/compose/WearScreen.kt | 130 ++++++++++-- .../sync/wear/compose/WearScreenPreviews.kt | 28 ++- .../sync/wear/compose/WearViewModel.kt | 17 ++ .../wear/wearintegration/DataHandlerMobile.kt | 3 +- .../drawable-nodpi/wfs_watchface_preview.png | Bin 0 -> 37136 bytes .../src/androidMain/res/values/strings.xml | 2 + wear/build.gradle.kts | 71 ++++--- .../app/aaps/wear/comm/DataHandlerWear.kt | 19 +- .../interaction/menus/MainMenuActivity.kt | 11 +- .../wear/watchfaces/WatchFacePushHelper.kt | 200 +++++++++++++----- ...watchface_aapsv4.png => watchface_wfs.png} | Bin .../app/aaps/wear/comm/DataHandlerWearTest.kt | 13 +- .../watchfaces/WatchFacePushHelperTest.kt | 42 ++++ wear/watchfacepush/build.gradle.kts | 60 ++++-- .../res/drawable-nodpi/preview.png | Bin .../res/drawable-nodpi/preview_circular.png | Bin .../src/{main => cwf}/res/values/strings.xml | 0 .../{main => cwf}/res/xml/watch_face_info.xml | 6 +- .../{ => src/cwf}/template/watchface.xml | 0 .../src/wfs/res/drawable-nodpi/preview.png | Bin 0 -> 37136 bytes .../res/drawable-nodpi/preview_circular.png | Bin 0 -> 37136 bytes .../src/wfs/res/xml/watch_face_info.xml | 11 + .../src/wfs/template/watchface.xml | 1 + 31 files changed, 569 insertions(+), 129 deletions(-) create mode 100644 core/keys/src/commonMain/kotlin/app/aaps/core/keys/PushedWatchfaceId.kt create mode 100644 plugins/sync/src/androidMain/res/drawable-nodpi/wfs_watchface_preview.png rename wear/src/main/res/drawable-nodpi/{watchface_aapsv4.png => watchface_wfs.png} (100%) rename wear/watchfacepush/src/{main => cwf}/res/drawable-nodpi/preview.png (100%) rename wear/watchfacepush/src/{main => cwf}/res/drawable-nodpi/preview_circular.png (100%) rename wear/watchfacepush/src/{main => cwf}/res/values/strings.xml (100%) rename wear/watchfacepush/src/{main => cwf}/res/xml/watch_face_info.xml (71%) rename wear/watchfacepush/{ => src/cwf}/template/watchface.xml (100%) create mode 100644 wear/watchfacepush/src/wfs/res/drawable-nodpi/preview.png create mode 100644 wear/watchfacepush/src/wfs/res/drawable-nodpi/preview_circular.png create mode 100644 wear/watchfacepush/src/wfs/res/xml/watch_face_info.xml create mode 100644 wear/watchfacepush/src/wfs/template/watchface.xml diff --git a/.gitignore b/.gitignore index 5dfda4ef9d19..efad7f6f8bcf 100644 --- a/.gitignore +++ b/.gitignore @@ -29,10 +29,9 @@ wear/aapsclient/* wear/aapsclient2/* wear/aapsclient3/* wear/pumpcontrol/* -wear/watchfacepush/aapsclient/* -wear/watchfacepush/aapsclient2/* -wear/watchfacepush/aapsclient3/* -wear/watchfacepush/pumpcontrol/* +# Android Studio "Build APK" output of :wear:watchfacepush, one folder per variant (fullWfs, aapsclientCwf, ...) +wear/watchfacepush/*Wfs/* +wear/watchfacepush/*Cwf/* .kotlin/* /*.log *.preferences_pb diff --git a/_docs/Complication_Libraries.md b/_docs/Complication_Libraries.md index 599b42495c30..5af3401ad6ec 100644 --- a/_docs/Complication_Libraries.md +++ b/_docs/Complication_Libraries.md @@ -1931,6 +1931,15 @@ WatchFacePush: slots used=1 remaining=0 packages=.watchfacepush.aapsv4 So the quota is **one face per app**. An app that already pushed a face has no room for a second; it can only `updateWatchFace()` the slot it holds, or `removeWatchFace()` to free it. +That is how AAPS ships two faces anyway. `updateWatchFace()` accepts "a completely different watch +face" (its KDoc), so the wear app embeds both faces (`wfs`, the Watch Face Studio layout of +complications, and `cwf`, the picture of the Custom watchface) and fills its one slot with the face +chosen in the phone's wear settings (`StringKey.WearPushedWatchface`). The two faces have different +package suffixes on purpose: the runtime never re-applies `DefaultProviderPolicy` to a slot id it has +already bound, and the faces use overlapping slot ids with different providers. The KDoc also says +the face's own user configuration is reset when the package name changes, so a switch and back +loses the complication choices made in the face editor. + Note `installedWatchFaceDetails` lists only faces added by the calling app, so this count is per-app, not device-wide. 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 056fbfe81904..bf8bb0bde377 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 @@ -399,7 +399,13 @@ sealed class EventData : Event() { val insulinButtonIncrement1: Double, val insulinButtonIncrement2: Double, val carbsButtonIncrement1: Int, - val carbsButtonIncrement2: Int + val carbsButtonIncrement2: Int, + /** + * Which embedded Watch Face Format face the wear app installs through Watch Face Push, as + * a value of `StringKey.WearPushedWatchface`. Defaulted, so a payload from a phone that + * predates the field still decodes and keeps the face that was pushed before. + */ + val pushedWatchface: String = "cwf" ) : EventData() @Serializable diff --git a/core/keys/src/androidMain/res/values/strings.xml b/core/keys/src/androidMain/res/values/strings.xml index a2bda57b6048..d3c277e8f4f0 100644 --- a/core/keys/src/androidMain/res/values/strings.xml +++ b/core/keys/src/androidMain/res/values/strings.xml @@ -158,6 +158,10 @@ Include COB in wizard Include IOB in wizard Authorize custom watchfaces + Watchface installed on watch + Wear OS 6 and newer only. AAPS can install one watchface on the watch: the complications watchface or the custom watchface. Older watches ignore this setting. + Complications watchface + Custom watchface Notify on SMB Send notifications to watch when SMB is delivered. Broadcast data to watch diff --git a/core/keys/src/commonMain/kotlin/app/aaps/core/keys/PushedWatchfaceId.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/PushedWatchfaceId.kt new file mode 100644 index 000000000000..26dc53c712a6 --- /dev/null +++ b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/PushedWatchfaceId.kt @@ -0,0 +1,16 @@ +package app.aaps.core.keys + +/** + * Values of [StringKey.WearPushedWatchface]: the id of the embedded Watch Face Format face the wear + * app installs through Watch Face Push. The id is also the face's package suffix + * (`.watchfacepush.`) and the name of its embedded asset, so the phone and the watch + * must agree on it - which is why it lives here and not in either of them. + */ +object PushedWatchfaceId { + + /** The face designed in Watch Face Studio: a layout of AAPS complications */ + const val WFS = "wfs" + + /** The face that shows the wearer's own Custom watchface zip as a picture */ + const val CWF = "cwf" +} diff --git a/core/keys/src/commonMain/kotlin/app/aaps/core/keys/StringKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/StringKey.kt index 9f45c558c22e..472835eef403 100644 --- a/core/keys/src/commonMain/kotlin/app/aaps/core/keys/StringKey.kt +++ b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/StringKey.kt @@ -170,6 +170,23 @@ enum class StringKey( VirtualPumpType(key = "virtualpump_type", defaultValue = "Generic AAPS", title = KeysStrings.pref_title_virtual_pump_type, preferenceType = PreferenceType.LIST), + /** + * Which embedded Watch Face Format face the wear app installs through Watch Face Push on a + * Wear OS 6+ watch. Watch Face Push gives an app one slot, so this is a choice, not a set. + * Values in [PushedWatchfaceId]. Older watches receive and keep the value but cannot act on it. + */ + WearPushedWatchface( + key = "wear_pushed_watchface", + defaultValue = PushedWatchfaceId.CWF, + title = KeysStrings.pref_title_wear_pushed_watchface, + summary = KeysStrings.pref_summary_wear_pushed_watchface, + preferenceType = PreferenceType.LIST, + entriesRefs = mapOf( + PushedWatchfaceId.WFS to KeysStrings.wear_pushed_watchface_wfs, + PushedWatchfaceId.CWF to KeysStrings.wear_pushed_watchface_cwf + ) + ), + NsClientUrl( key = "nsclientinternal_url", defaultValue = "", 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 a2f40725e380..58a6fc8c11b9 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 @@ -13,6 +13,8 @@ import app.aaps.core.interfaces.rx.weardata.CwfMetadataKey import app.aaps.core.interfaces.rx.weardata.EventData import app.aaps.core.interfaces.utils.DateUtil import app.aaps.core.interfaces.versionChecker.VersionCheckerUtils +import app.aaps.core.keys.PushedWatchfaceId +import app.aaps.core.keys.StringKey import app.aaps.core.keys.interfaces.Preferences import app.aaps.core.keys.interfaces.TextRef import app.aaps.plugins.sync.wear.WearPlugin @@ -50,6 +52,7 @@ internal class WearViewModelTest { private val connectedDeviceFlow = MutableStateFlow(null) private val savedCustomWatchfaceFlow = MutableStateFlow(null) private val eventWearUpdateGuiFlow = MutableSharedFlow() + private val pushedWatchfaceFlow = MutableStateFlow(PushedWatchfaceId.CWF) private lateinit var sut: WearViewModel @@ -60,6 +63,7 @@ internal class WearViewModelTest { whenever(wearPlugin.connectedDevice).thenReturn(connectedDeviceFlow) whenever(wearPlugin.savedCustomWatchface).thenReturn(savedCustomWatchfaceFlow) 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") sut = WearViewModel(wearPlugin, rxBus, rh, dateUtil, preferences, versionCheckerUtils, fileListProvider, aapsLogger) } @@ -126,6 +130,22 @@ internal class WearViewModelTest { assertThat(sut.uiState.value.watchfaceName).isEmpty() } + @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() + + // The complications face installed instead: a loaded zip would not be shown + pushedWatchfaceFlow.value = PushedWatchfaceId.WFS + assertThat(sut.uiState.value.customWatchfaceInstalled).isFalse() + } + + @Test + fun `selectPushedWatchface stores the choice for the plugin to resend`() { + sut.selectPushedWatchface(PushedWatchfaceId.WFS) + verify(preferences).put(StringKey.WearPushedWatchface, PushedWatchfaceId.WFS) + } + @Test fun `requestCustomWatchface sends event when no saved watchface`() { sut.requestCustomWatchface() 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 91dd4a236e64..89b69a0a1bf8 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 @@ -36,6 +36,7 @@ import app.aaps.core.interfaces.scenes.SceneAutomationApi import app.aaps.core.keys.BooleanKey import app.aaps.core.keys.DoubleKey import app.aaps.core.keys.IntKey +import app.aaps.core.keys.StringKey import app.aaps.core.keys.StringNonKey import app.aaps.core.keys.interfaces.Preferences import app.aaps.core.keys.interfaces.TextRef @@ -153,6 +154,8 @@ class WearPlugin( preferences.observe(StringNonKey.WearCwfWatchfaceName).drop(1).map {}, preferences.observe(StringNonKey.WearCwfAuthorVersion).drop(1).map {}, preferences.observe(StringNonKey.WearCwfFileName).drop(1).map {}, + // Which Watch Face Format face the watch installs; the watch swaps its slot on arrival + preferences.observe(StringKey.WearPushedWatchface).drop(1).map {}, ).collectResilient(newScope, aapsLogger, LTag.WEAR) { dataHandlerMobile.resendData("PreferenceChange") checkCustomWatchfacePreferences() 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 cf2a8d91b71d..f780b6b910d9 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 @@ -21,6 +21,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -39,22 +41,33 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.aaps.core.keys.KeysStrings +import app.aaps.core.keys.PushedWatchfaceId +import app.aaps.core.keys.StringKey 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.plugins.sync.R @Composable internal fun WearScreen( @@ -162,6 +175,7 @@ internal fun WearScreen( onMoreWatchfaces = { context.startActivity(Intent(Intent.ACTION_VIEW, moreWatchfacesUrl.toUri())) }, + onSelectPushedWatchface = { viewModel.selectPushedWatchface(it) }, modifier = modifier ) } @@ -184,8 +198,25 @@ internal fun WearMainContent( onInfosWatchface: () -> Unit, onExportTemplate: () -> Unit, onMoreWatchfaces: () -> Unit, + onSelectPushedWatchface: (String) -> 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. + var pendingWatchface by remember { mutableStateOf(null) } + pendingWatchface?.let { face -> + OkCancelDialog( + title = stringResource(SyncStrings.wear_pushed_watchface_confirm_title), + message = stringResource(SyncStrings.wear_pushed_watchface_confirm_message, pushedWatchfaceLabel(face)), + onConfirm = { + onSelectPushedWatchface(face) + pendingWatchface = null + }, + onDismiss = { pendingWatchface = null } + ) + } + Column( modifier = modifier .fillMaxSize() @@ -218,7 +249,8 @@ internal fun WearMainContent( } } - // Custom Watchface Card (visible only when connected) + // Watchface Card (visible only when connected): which face the watch installs, then the + // controls of that face if (uiState.isDeviceConnected) { Card( modifier = Modifier.fillMaxWidth(), @@ -231,31 +263,64 @@ internal fun WearMainContent( verticalArrangement = Arrangement.spacedBy(AapsSpacing.medium) ) { Text( - text = stringResource(SyncStrings.wear_custom_watchface, uiState.watchfaceName), - style = MaterialTheme.typography.bodyLarge, + text = stringResource(StringKey.WearPushedWatchface.title), + style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(horizontal = AapsSpacing.small) ) - // 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 - ) + // 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. + Column(modifier = Modifier.selectableGroup()) { + listOf(PushedWatchfaceId.CWF, PushedWatchfaceId.WFS).forEach { face -> + val selected = (face == PushedWatchfaceId.CWF) == uiState.customWatchfaceInstalled + WatchfaceChoiceRow( + label = pushedWatchfaceLabel(face), + selected = selected, + onSelect = { if (!selected) pendingWatchface = face } + ) + } + } - // 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) - ) + if (uiState.customWatchfaceInstalled) { + Text( + text = stringResource(SyncStrings.wear_custom_watchface, uiState.watchfaceName), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = AapsSpacing.small) + ) + + // 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 + ) + + // 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 -> + // 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. Spacer(modifier = Modifier.height(AapsSpacing.small)) Image( - bitmap = image, - contentDescription = uiState.watchfaceName, + painter = painterResource(R.drawable.wfs_watchface_preview), + contentDescription = pushedWatchfaceLabel(PushedWatchfaceId.WFS), modifier = Modifier .fillMaxWidth() .padding(horizontal = AapsSpacing.extraLarge), @@ -268,6 +333,33 @@ internal fun WearMainContent( } } +/** The label of an embedded face, from the same strings the key's list entries use */ +@Composable +private fun pushedWatchfaceLabel(face: String): String = + stringResource(if (face == PushedWatchfaceId.CWF) KeysStrings.wear_pushed_watchface_cwf else KeysStrings.wear_pushed_watchface_wfs) + +@Composable +private fun WatchfaceChoiceRow( + label: String, + selected: Boolean, + onSelect: () -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .selectable(selected = selected, onClick = onSelect, role = Role.RadioButton) + .padding(vertical = AapsSpacing.small, horizontal = AapsSpacing.small) + ) { + RadioButton(selected = selected, onClick = null) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = AapsSpacing.medium) + ) + } +} + private data class ButtonDef(val icon: ImageVector, val text: String, val onClick: () -> Unit) @Composable 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 ae4283d022ad..ea1b879d70bc 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 @@ -22,7 +22,30 @@ internal fun WearMainContentPreview() { onLoadWatchface = {}, onInfosWatchface = {}, onExportTemplate = {}, - onMoreWatchfaces = {} + onMoreWatchfaces = {}, + onSelectPushedWatchface = {} + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +internal fun WearMainContentComplicationsFacePreview() { + MaterialTheme { + WearMainContent( + uiState = WearUiState( + connectedDevice = "Galaxy Watch 8 (c3d4)", + isDeviceConnected = true, + customWatchfaceInstalled = false + ), + onResendData = {}, + onOpenSettings = {}, + onLoadWatchface = {}, + onInfosWatchface = {}, + onExportTemplate = {}, + onMoreWatchfaces = {}, + onSelectPushedWatchface = {} ) } } @@ -41,7 +64,8 @@ internal fun WearMainContentDisconnectedPreview() { onLoadWatchface = {}, onInfosWatchface = {}, onExportTemplate = {}, - onMoreWatchfaces = {} + onMoreWatchfaces = {}, + onSelectPushedWatchface = {} ) } } 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 8866bc58f8c2..40cdf37bdec8 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 @@ -25,6 +25,8 @@ import app.aaps.core.interfaces.rx.weardata.EventData import app.aaps.core.interfaces.utils.DateUtil import app.aaps.core.interfaces.versionChecker.VersionCheckerUtils import app.aaps.core.keys.BooleanKey +import app.aaps.core.keys.PushedWatchfaceId +import app.aaps.core.keys.StringKey import app.aaps.core.keys.StringNonKey import app.aaps.core.keys.interfaces.Preferences import app.aaps.plugins.sync.wear.WearPlugin @@ -55,6 +57,11 @@ data class WearUiState( val hasCustomWatchface: Boolean = false, 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. + */ + val customWatchfaceInstalled: Boolean = true, val showInfos: Boolean = false, val cwfInfosState: CwfInfosState? = null, val showImportList: Boolean = false, @@ -139,6 +146,11 @@ class WearViewModel( } } } + viewModelScope.launch { + preferences.observe(StringKey.WearPushedWatchface).collect { face -> + _uiState.update { it.copy(customWatchfaceInstalled = face == PushedWatchfaceId.CWF) } + } + } viewModelScope.launch { rxBus.toFlow(EventWearUpdateGui::class).collect { event -> if (event.exportFile) { @@ -170,6 +182,11 @@ class WearViewModel( rxBus.send(EventMobileToWear(EventData.ActionrequestCustomWatchface(true))) } + /** Stores the wearer's choice of pushed face; the plugin observes the key and resends the preferences to the watch */ + fun selectPushedWatchface(face: String) { + preferences.put(StringKey.WearPushedWatchface, face) + } + fun showCwfInfos() { val cwfData = wearPlugin.savedCustomWatchface.value ?: return val metadata = cwfData.metadata 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 6b5248111eaf..bed78bcf283d 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 @@ -1090,7 +1090,8 @@ class DataHandlerMobile( insulinButtonIncrement1 = preferences.get(DoubleKey.OverviewInsulinButtonIncrement1), insulinButtonIncrement2 = preferences.get(DoubleKey.OverviewInsulinButtonIncrement2), carbsButtonIncrement1 = preferences.get(IntKey.OverviewCarbsButtonIncrement1), - carbsButtonIncrement2 = preferences.get(IntKey.OverviewCarbsButtonIncrement2) + carbsButtonIncrement2 = preferences.get(IntKey.OverviewCarbsButtonIncrement2), + pushedWatchface = preferences.get(StringKey.WearPushedWatchface) ) ) // QuickWizard diff --git a/plugins/sync/src/androidMain/res/drawable-nodpi/wfs_watchface_preview.png b/plugins/sync/src/androidMain/res/drawable-nodpi/wfs_watchface_preview.png new file mode 100644 index 0000000000000000000000000000000000000000..63b0a9ca659d2de177e460dd28bd10a8b7b19098 GIT binary patch literal 37136 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 literal 0 HcmV?d00001 diff --git a/plugins/sync/src/androidMain/res/values/strings.xml b/plugins/sync/src/androidMain/res/values/strings.xml index a94d537c7fa6..1408e3489c0a 100644 --- a/plugins/sync/src/androidMain/res/values/strings.xml +++ b/plugins/sync/src/androidMain/res/values/strings.xml @@ -127,6 +127,8 @@ General Settings Custom Watchface Settings Custom Watchface: %1$s + 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. Load Watchface Infos Watchface Export template diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index a966e49b8a09..898bab3217cd 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -123,18 +123,25 @@ allprojects { } /** - * Validates a Watch Face Push face APK (built by :wear:watchfacepush) with Google's offline - * validator and embeds the APK plus its validation token into this variant's assets under - * `watchfacepush/`. The token is a hash over the exact APK bytes, so it must be regenerated on - * every face build — never hardcoded. + * Validates the Watch Face Push face APKs (built by :wear:watchfacepush, one per face) with + * Google's offline validator and embeds each APK plus its validation token into this variant's + * assets under `watchfacepush/`, as `.apk` and `_token.txt`. The token is a hash + * over the exact APK bytes, so it must be regenerated on every face build — never hardcoded. + * + * Both faces are embedded although only one is ever installed: Watch Face Push gives an app one + * slot, and the wear app fills it with the face the wearer selected (see `WatchFacePushHelper`). */ abstract class EmbedWatchFaceTask @Inject constructor( private val execOperations: ExecOperations ) : DefaultTask() { - /** Resolved artifact of :wear:watchfacepush — the variant's APK output directory */ + /** Resolved artifact of :wear:watchfacepush — the APK output directory of the `wfs` face */ @get:InputFiles - abstract val watchFaceApkDir: ConfigurableFileCollection + abstract val wfsApkDir: ConfigurableFileCollection + + /** Resolved artifact of :wear:watchfacepush — the APK output directory of the `cwf` face */ + @get:InputFiles + abstract val cwfApkDir: ConfigurableFileCollection @get:Input abstract val clientPackageName: Property @@ -147,8 +154,16 @@ abstract class EmbedWatchFaceTask @Inject constructor( @TaskAction fun run() { - val watchFaceApk = watchFaceApkDir.asFileTree.files.singleOrNull { it.extension == "apk" } - ?: throw GradleException("Expected exactly one watch face APK in ${watchFaceApkDir.files}") + val assetDir = outputDir.get().asFile.resolve("watchfacepush") + assetDir.deleteRecursively() + assetDir.mkdirs() + embed("wfs", wfsApkDir, assetDir) + embed("cwf", cwfApkDir, assetDir) + } + + private fun embed(face: String, apkDir: ConfigurableFileCollection, assetDir: File) { + val watchFaceApk = apkDir.asFileTree.files.singleOrNull { it.extension == "apk" } + ?: throw GradleException("Expected exactly one $face watch face APK in ${apkDir.files}") val stdout = ByteArrayOutputStream() execOperations.javaexec { classpath = validatorClasspath @@ -161,12 +176,9 @@ abstract class EmbedWatchFaceTask @Inject constructor( } val output = stdout.toString() val token = Regex("generated token: (\\S+)").find(output)?.groupValues?.get(1) - ?: throw GradleException("Watch face validation did not produce a token:\n$output") - val assetDir = outputDir.get().asFile.resolve("watchfacepush") - assetDir.deleteRecursively() - assetDir.mkdirs() - watchFaceApk.copyTo(assetDir.resolve("aapsv4.apk"), overwrite = true) - assetDir.resolve("aapsv4_token.txt").writeText(token) + ?: throw GradleException("Watch face validation of the $face face did not produce a token:\n$output") + watchFaceApk.copyTo(assetDir.resolve("$face.apk"), overwrite = true) + assetDir.resolve("${face}_token.txt").writeText(token) } } @@ -178,22 +190,29 @@ extensions.configure("androidComponents") onVariants { variant -> val flavor = variant.flavorName ?: return@onVariants val flavorCap = flavor.replaceFirstChar { it.uppercase() } - // The face is always embedded from its release build (signed with the debug key in the - // face module) — the wear app's own build type does not change the face APK. Consumed as - // an artifact configuration so the producing tasks are wired in automatically. - val faceApkConfiguration = configurations.create("watchFaceApk${variant.name.replaceFirstChar { it.uppercase() }}") { - isCanBeConsumed = false - isCanBeResolved = true + val variantCap = variant.name.replaceFirstChar { it.uppercase() } + // The faces are always embedded from their release build (signed with the debug key in + // the face module) — the wear app's own build type does not change the face APKs. + // Consumed as artifact configurations so the producing tasks are wired in automatically, + // one per face: the face module names them watchfaceApk. + val faceApkConfigurations = listOf("wfs", "cwf").associateWith { face -> + val faceCap = face.replaceFirstChar { it.uppercase() } + val configuration = configurations.create("watchFaceApk$variantCap$faceCap") { + isCanBeConsumed = false + isCanBeResolved = true + } + dependencies.add( + configuration.name, + dependencies.project(mapOf("path" to ":wear:watchfacepush", "configuration" to "watchfaceApk$flavorCap$faceCap")) + ) + configuration } - dependencies.add( - faceApkConfiguration.name, - dependencies.project(mapOf("path" to ":wear:watchfacepush", "configuration" to "watchfaceApk$flavorCap")) - ) val taskProvider = project.tasks.register( - "embed${variant.name.replaceFirstChar { it.uppercase() }}WatchFace", + "embed${variantCap}WatchFace", EmbedWatchFaceTask::class.java ) { - watchFaceApkDir.from(faceApkConfiguration) + wfsApkDir.from(faceApkConfigurations.getValue("wfs")) + cwfApkDir.from(faceApkConfigurations.getValue("cwf")) clientPackageName.set(variant.applicationId) validatorClasspath.from(watchFacePushValidator) } 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 319dd8c3bd17..05afb55a8e0b 100644 --- a/wear/src/main/kotlin/app/aaps/wear/comm/DataHandlerWear.kt +++ b/wear/src/main/kotlin/app/aaps/wear/comm/DataHandlerWear.kt @@ -56,6 +56,7 @@ import app.aaps.wear.interaction.WatchfaceConfigurationActivity import app.aaps.wear.interaction.actions.AcceptActivity import app.aaps.wear.interaction.actions.ContactingMasterActivity import app.aaps.wear.interaction.actions.ProfileSwitchActivity +import app.aaps.wear.interaction.menus.PreferenceMenuActivity import app.aaps.wear.tile.ActionsTileService import app.aaps.wear.tile.BgGraphTileService import app.aaps.wear.tile.QuickWizardTileService @@ -63,6 +64,8 @@ import app.aaps.wear.tile.RunningModeTileService import app.aaps.wear.tile.SceneTileService import app.aaps.wear.tile.TempTargetTileService import app.aaps.wear.tile.UserActionTileService +import app.aaps.wear.watchfaces.PushedFace +import app.aaps.wear.watchfaces.WatchFacePushHelper import com.google.android.gms.wearable.WearableListenerService import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject @@ -82,7 +85,8 @@ class DataHandlerWear( private val sp: SP, private val preferences: Preferences, private val aapsLogger: AAPSLogger, - private val complicationDataRepository: ComplicationDataRepository + private val complicationDataRepository: ComplicationDataRepository, + private val watchFacePushHelper: WatchFacePushHelper ) { // Coroutine scope for DataStore operations @@ -164,7 +168,13 @@ class DataHandlerWear( } onEvent { handleOpenLoopRequest(it) } onEvent { - context.startActivity(Intent(context, WatchfaceConfigurationActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) + // The Display screen holds the settings of the code-based faces. With the complications + // face installed through Watch Face Push nothing there is the wearer's, so the phone's + // button opens the main settings menu instead, from which every screen is reachable + val target = + if (watchFacePushHelper.isSupported() && watchFacePushHelper.selectedFace == PushedFace.WFS) PreferenceMenuActivity::class.java + else WatchfaceConfigurationActivity::class.java + context.startActivity(Intent(context, target).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) } onEvent { event -> context.startActivity(Intent(context, ProfileSwitchActivity::class.java).apply { @@ -228,6 +238,11 @@ class DataHandlerWear( preferences.put(DoubleKey.OverviewInsulinButtonIncrement2, it.insulinButtonIncrement2) preferences.put(IntKey.OverviewCarbsButtonIncrement1, it.carbsButtonIncrement1) 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() } } onEvent { val serialized = it.serialize() diff --git a/wear/src/main/kotlin/app/aaps/wear/interaction/menus/MainMenuActivity.kt b/wear/src/main/kotlin/app/aaps/wear/interaction/menus/MainMenuActivity.kt index 0653d539c0b6..8f8e6c2b3657 100644 --- a/wear/src/main/kotlin/app/aaps/wear/interaction/menus/MainMenuActivity.kt +++ b/wear/src/main/kotlin/app/aaps/wear/interaction/menus/MainMenuActivity.kt @@ -21,6 +21,7 @@ import app.aaps.wear.interaction.actions.WizardActivity import app.aaps.wear.interaction.activities.BgGraphActivity import app.aaps.wear.interaction.activities.LoopStatusActivity import app.aaps.wear.interaction.utils.MenuListActivity +import app.aaps.wear.watchfaces.PushedFace import app.aaps.wear.watchfaces.WatchFacePushHelper import dev.zacsweers.metro.Inject import kotlinx.coroutines.launch @@ -80,8 +81,14 @@ class MainMenuActivity : MenuListActivity() { // user who removed the face and wants it back before the next app update. While an // install runs the label switches to an inert "Installing…" so the tap is visibly // acknowledged (install can take a few seconds and a silent wait provokes second taps) - if (watchFacePushHelper.isSupported() && !sp.getBoolean(WatchFacePushHelper.KEY_FACE_INSTALLED, false)) - add(MenuItem(R.drawable.watchface_aapsv4, getString(if (installing) R.string.menu_installing_watchface else R.string.menu_install_watchface))) + if (watchFacePushHelper.isSupported() && !sp.getBoolean(WatchFacePushHelper.KEY_FACE_INSTALLED, false)) { + // The entry installs whichever face is selected on the phone, so it shows that face + val icon = when (watchFacePushHelper.selectedFace) { + PushedFace.WFS -> R.drawable.watchface_wfs + PushedFace.CWF -> R.drawable.watchface_custom + } + add(MenuItem(icon, getString(if (installing) R.string.menu_installing_watchface else R.string.menu_install_watchface))) + } } override fun doAction(position: String) { 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 7908af1c13af..eaf2482b2c58 100644 --- a/wear/src/main/kotlin/app/aaps/wear/watchfaces/WatchFacePushHelper.kt +++ b/wear/src/main/kotlin/app/aaps/wear/watchfaces/WatchFacePushHelper.kt @@ -9,21 +9,54 @@ import androidx.wear.watchfacepush.WatchFacePushManagerFactory import app.aaps.core.interfaces.logging.AAPSLogger import app.aaps.core.interfaces.logging.LTag import app.aaps.core.interfaces.sharedPreferences.SP -import app.aaps.wear.BuildConfig +import app.aaps.core.keys.PushedWatchfaceId import app.aaps.wear.watchfaces.WatchFacePushHelper.Companion.KEY_FACE_INSTALLED import dev.zacsweers.metro.Inject import java.io.File import java.io.IOException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext /** - * Installs and updates the embedded "AAPS V4" Watch Face Format face through the Watch Face Push - * API. Only available on Wear OS 6+ (API 36) — the watches that no longer support the code-based - * AAPS watchfaces and therefore have no other way to get an AAPS face. + * The Watch Face Format faces embedded in the wear app, one per `face` flavor of + * `:wear:watchfacepush`. The [id] is shared with the phone through [PushedWatchfaceId]: it names + * the embedded assets and ends the face's package name, which Watch Face Push requires to be + * `.watchfacepush.`. + */ +enum class PushedFace(val id: String) { + + /** The face designed in Watch Face Studio: a layout of AAPS complications */ + WFS(PushedWatchfaceId.WFS), + + /** The face that shows the wearer's own Custom watchface zip as a picture */ + CWF(PushedWatchfaceId.CWF); + + val assetApk: String get() = "watchfacepush/$id.apk" + val assetToken: String get() = "watchfacepush/${id}_token.txt" + fun packageName(appPackage: String): String = "$appPackage.watchfacepush.$id" + + companion object { + + /** The face for a stored id; an unknown id - never written by this app - falls back to [CWF], the default */ + fun fromId(id: String?): PushedFace = entries.firstOrNull { it.id == id } ?: CWF + } +} + +/** + * Installs and updates the embedded Watch Face Format faces through the Watch Face Push API. Only + * available on Wear OS 6+ (API 36) — the watches that no longer support the code-based AAPS + * watchfaces and therefore have no other way to get an AAPS face. + * + * Two faces are embedded (see [PushedFace]) but Watch Face Push gives an app **one** slot, measured + * as `slots used=1 remaining=0` on a Galaxy Watch 4. So the wearer chooses one on the phone, the + * choice arrives with the preferences and is kept in [selectedFace], and this helper makes sure the + * slot holds that face: a change of choice updates the slot with the other APK, which the API allows + * ("a completely different watch face"), at the price of resetting that face's own editor settings. * - * The face APK and its validation token are generated at build time (see `EmbedWatchFaceTask` in - * the wear build script) and shipped in assets. The token is bound to the exact APK bytes, so both + * 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. */ @Inject @@ -35,11 +68,10 @@ class WatchFacePushHelper( companion object { - private const val ASSET_APK = "watchfacepush/aapsv4.apk" - private const val ASSET_TOKEN = "watchfacepush/aapsv4_token.txt" private const val MIN_SDK = 36 const val KEY_FACE_INSTALLED = "wfpush_face_installed" + /** * The face this app last pushed, stored as its validation token. * @@ -51,14 +83,40 @@ class WatchFacePushHelper( * rescue them - it only shows while the face is missing. * * The token is a hash over the exact APK bytes we embed, so comparing it means "push when - * the face really changed, and never otherwise". + * the face really changed, and never otherwise". A change of [selectedFace] changes the + * token too, so the same compare also drives the swap. */ private const val KEY_SYNCED_FACE = "wfpush_synced_face" + + /** + * The [PushedFace.id] the wearer chose on the phone. Stored on every watch, supported or + * 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" } - private val facePackageName get() = "${context.packageName}.watchfacepush.aapsv4" + /** 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)) - /** Defensive: every flavor currently embeds the face, but a build without the asset must report unsupported */ + /** + * Records the wearer's choice from the phone. + * + * @return true when the choice differs from the stored one, so the caller knows a push is due. + * The push is the caller's to start: this runs inside the preferences handler, which must + * stay cheap and Android-free. + */ + fun selectFace(id: String): Boolean { + val face = PushedFace.fromId(id) + if (face == selectedFace) return false + sp.putString(KEY_SELECTED_FACE, face.id) + aapsLogger.debug(LTag.WEAR, "WatchFacePush: selected face ${face.id}") + return true + } + + private val facePackageName get() = selectedFace.packageName(context.packageName) + + /** Defensive: every flavor currently embeds the faces, but a build without the assets must report unsupported */ private val hasEmbeddedFace: Boolean by lazy { try { context.assets.list("watchfacepush")?.isNotEmpty() == true @@ -73,18 +131,18 @@ class WatchFacePushHelper( * Doubles as the identity of the face: the build generates it as a hash over the exact APK * bytes, so two builds share a token only when they carry the same face. */ - private fun embeddedFaceToken(): String? = + private fun embeddedFaceToken(face: PushedFace): String? = try { - context.assets.open(ASSET_TOKEN).use { String(it.readBytes()) }.trim() + context.assets.open(face.assetToken).use { String(it.readBytes()) }.trim() } catch (e: IOException) { - aapsLogger.error(LTag.WEAR, "WatchFacePush: cannot read the embedded face token", e) + aapsLogger.error(LTag.WEAR, "WatchFacePush: cannot read the embedded ${face.id} face token", e) null } fun isSupported(): Boolean = Build.VERSION.SDK_INT >= MIN_SDK && hasEmbeddedFace /** - * Whether the embedded face is currently installed in this app's Watch Face Push slot. + * Whether the selected face is currently installed in this app's Watch Face Push slot. * Also refreshes the [KEY_FACE_INSTALLED] flag used for synchronous menu building. */ suspend fun isFaceInstalled(): Boolean = withContext(Dispatchers.IO) { @@ -105,17 +163,17 @@ class WatchFacePushHelper( } /** - * Called on app start: makes sure the embedded face is available in the watch face picker. + * Called on app start: makes sure the selected face is available in the watch face picker. * Each app install/update pushes the face once — the same contract as the code-based * watchfaces, which always ship with the app. A face the user removed stays removed until a - * reinstall from the main menu or the next app update. Never activates the face: selecting it - * is always the user's choice (picker, or the main menu entry). + * reinstall from the main menu, a change of the selected face, or the next app update. Never + * activates the face: selecting it is always the user's choice (picker, or the main menu entry). */ suspend fun syncOnStartup() { if (!isSupported()) 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() ?: return + val token = embeddedFaceToken(selectedFace) ?: return if (sp.getString(KEY_SYNCED_FACE, "") == token) 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 @@ -124,7 +182,17 @@ class WatchFacePushHelper( } /** - * Installs the embedded face (or updates it if already installed). + * 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 + * of them did run side by side: both saw an empty slot table, both called add, the first won with + * the face the phone had just switched away from, and the second failed with "limit of watch + * faces reached". Serialised, the second one finds the slot and updates it instead. + */ + private val installLock = Mutex() + + /** + * Installs the selected face, or updates this app's slot with it if the slot exists - whether + * it holds an older build of the same face or the other face. * * @param activate also try to make the face the active watch face afterwards. Pass true only * for user-triggered installs (menu tap = the user wants the face on the wrist, including @@ -136,39 +204,57 @@ class WatchFacePushHelper( */ suspend fun installOrUpdate(activate: Boolean = false): Boolean = withContext(Dispatchers.IO) { if (!isSupported()) return@withContext false - val apkFile = File(context.cacheDir, "wfpush_aapsv4.apk") - try { - val manager = createManager() - val token = embeddedFaceToken() ?: return@withContext false - context.assets.open(ASSET_APK).use { input -> - apkFile.outputStream().use { input.copyTo(it) } - } - val existingSlotId = installedFaceSlotId(manager) - ParcelFileDescriptor.open(apkFile, ParcelFileDescriptor.MODE_READ_ONLY).use { fd -> - if (existingSlotId == null) { - manager.addWatchFace(fd, token) - } else { - manager.updateWatchFace(existingSlotId, fd, token) + 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 + val apkFile = File(context.cacheDir, "wfpush_${face.id}.apk") + try { + val manager = createManager() + val token = embeddedFaceToken(face) ?: return@withLock false + context.assets.open(face.assetApk).use { input -> + apkFile.outputStream().use { input.copyTo(it) } } + // Any slot of ours, not only one holding this face: with a single slot per app the + // other face has to give way, and the API updates a slot with a different package. + // Measured on a Galaxy Watch 5: the updated slot stays the active face, so the + // wrist follows the phone's choice by itself, without any activation permission. + val existingSlotId = ownSlotId(manager) + val action = ParcelFileDescriptor.open(apkFile, ParcelFileDescriptor.MODE_READ_ONLY).use { fd -> + if (existingSlotId == null) { + manager.addWatchFace(fd, token) + "installed" + } else try { + manager.updateWatchFace(existingSlotId, fd, token) + "updated" + } catch (e: WatchFacePushManager.UpdateWatchFaceException) { + // A slot the runtime still lists but no longer accepts - left behind by an + // uninstall of the face from the watch settings. Adding is what revives it. + if (e.errorCode != WatchFacePushManager.UpdateWatchFaceException.ERROR_INVALID_SLOT_ID) throw e + aapsLogger.debug(LTag.WEAR, "WatchFacePush: slot $existingSlotId is stale, adding instead") + manager.addWatchFace(fd, token) + "installed over a stale slot" + } + } + if (activate) { + installedFaceSlotId(manager)?.let { slotId -> setActive(manager, slotId) } + } + sp.putString(KEY_SYNCED_FACE, token) + sp.putBoolean(KEY_FACE_INSTALLED, true) + aapsLogger.debug(LTag.WEAR, "WatchFacePush: face ${face.id} $action") + true + } catch (e: Exception) { + // Broad on purpose: covers the typed Add/Update/List exceptions, IO on the embedded + // asset AND the raw RuntimeExceptions the manager surfaces across its IPC + aapsLogger.error(LTag.WEAR, "WatchFacePush: install/update of face ${face.id} failed", e) + false + } finally { + apkFile.delete() } - if (activate) { - installedFaceSlotId(manager)?.let { slotId -> setActive(manager, slotId) } - } - sp.putString(KEY_SYNCED_FACE, token) - sp.putBoolean(KEY_FACE_INSTALLED, true) - aapsLogger.debug(LTag.WEAR, "WatchFacePush: face ${if (existingSlotId == null) "installed" else "updated"}") - true - } catch (e: Exception) { - // Broad on purpose: covers the typed Add/Update/List exceptions, IO on the embedded - // asset AND the raw RuntimeExceptions the manager surfaces across its IPC - aapsLogger.error(LTag.WEAR, "WatchFacePush: install/update failed", e) - false - } finally { - apkFile.delete() } } - /** Whether the embedded face is the currently shown watch face */ + /** Whether the selected face is the currently shown watch face */ suspend fun isFaceActive(): Boolean = withContext(Dispatchers.IO) { if (!isSupported()) return@withContext false try { @@ -190,20 +276,28 @@ class WatchFacePushHelper( private fun createManager(): WatchFacePushManager = WatchFacePushManagerFactory.createWatchFacePushManager(context) - private suspend fun installedFaceSlotId(manager: WatchFacePushManager): String? { + private suspend fun listOwnFaces(manager: WatchFacePushManager): WatchFacePushManager.ListWatchFacesResponse { val response = manager.listWatchFaces() // How many faces one app may push is not stated in the API documentation - only that the - // number is limited - so it is logged here, where the response is already in hand. It decides - // whether AAPS can offer a second face alongside this one, or would have to replace it. + // number is limited - so it is logged here, where the response is already in hand. Measured + // as one on a Galaxy Watch 4, which is why the two embedded faces share this slot. aapsLogger.debug( LTag.WEAR, "WatchFacePush: slots used=${response.installedWatchFaceDetails.size} remaining=${response.remainingSlotCount}" + " packages=${response.installedWatchFaceDetails.joinToString { it.packageName }}" ) - return response.installedWatchFaceDetails + return response + } + + /** The slot holding the selected face, or null */ + private suspend fun installedFaceSlotId(manager: WatchFacePushManager): String? = + listOwnFaces(manager).installedWatchFaceDetails .firstOrNull { it.packageName == facePackageName } ?.slotId - } + + /** Any slot this app holds, whichever face is in it, or null. The list only ever contains our own faces. */ + private suspend fun ownSlotId(manager: WatchFacePushManager): String? = + listOwnFaces(manager).installedWatchFaceDetails.firstOrNull()?.slotId private suspend fun setActive(manager: WatchFacePushManager, slotId: String) { try { diff --git a/wear/src/main/res/drawable-nodpi/watchface_aapsv4.png b/wear/src/main/res/drawable-nodpi/watchface_wfs.png similarity index 100% rename from wear/src/main/res/drawable-nodpi/watchface_aapsv4.png rename to wear/src/main/res/drawable-nodpi/watchface_wfs.png diff --git a/wear/src/test/kotlin/app/aaps/wear/comm/DataHandlerWearTest.kt b/wear/src/test/kotlin/app/aaps/wear/comm/DataHandlerWearTest.kt index 3cea4293ed0d..da915ae4cdeb 100644 --- a/wear/src/test/kotlin/app/aaps/wear/comm/DataHandlerWearTest.kt +++ b/wear/src/test/kotlin/app/aaps/wear/comm/DataHandlerWearTest.kt @@ -5,12 +5,14 @@ import app.aaps.core.interfaces.rx.events.EventWearToMobile import app.aaps.core.interfaces.rx.weardata.EventData import app.aaps.core.keys.DoubleKey import app.aaps.core.keys.IntKey +import app.aaps.core.keys.PushedWatchfaceId import app.aaps.core.keys.interfaces.Preferences import app.aaps.shared.impl.rx.bus.RxBusImpl import app.aaps.wear.AAPSLoggerTest import app.aaps.wear.R import app.aaps.wear.WearTestBase import app.aaps.wear.data.ComplicationDataRepository +import app.aaps.wear.watchfaces.WatchFacePushHelper import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -35,6 +37,7 @@ internal class DataHandlerWearTest : WearTestBase() { @Mock lateinit var preferences: Preferences @Mock lateinit var complicationDataRepository: ComplicationDataRepository + @Mock lateinit var watchFacePushHelper: WatchFacePushHelper private val logger = AAPSLoggerTest() private lateinit var rxBus: RxBus @@ -43,7 +46,7 @@ internal class DataHandlerWearTest : WearTestBase() { @BeforeEach fun setupHandler() { rxBus = RxBusImpl(logger) - sut = DataHandlerWear(context, rxBus, sp, preferences, logger, complicationDataRepository) + sut = DataHandlerWear(context, rxBus, sp, preferences, logger, complicationDataRepository, watchFacePushHelper) } @Test @@ -63,6 +66,14 @@ internal class DataHandlerWearTest : WearTestBase() { verify(preferences, timeout(HANDLER_TIMEOUT_MS)).put(IntKey.OverviewCarbsButtonIncrement2, 10) } + @Test + fun `preferences event hands the chosen pushed watchface to the push helper`() { + // The mock helper reports no change, so no install is started - only the hand-over is checked here. + rxBus.send(EventData.Preferences(0L, false, true, 50, 80, 25.0, 0.5, 1.0, 5, 10, pushedWatchface = PushedWatchfaceId.WFS)) + + verify(watchFacePushHelper, timeout(HANDLER_TIMEOUT_MS)).selectFace(PushedWatchfaceId.WFS) + } + @Test fun `a ping is answered with a pong to the mobile`() { val pong = CompletableDeferred() 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 1ac210f9e6ef..4297a1c37844 100644 --- a/wear/src/test/kotlin/app/aaps/wear/watchfaces/WatchFacePushHelperTest.kt +++ b/wear/src/test/kotlin/app/aaps/wear/watchfaces/WatchFacePushHelperTest.kt @@ -2,13 +2,19 @@ package app.aaps.wear.watchfaces import app.aaps.core.interfaces.logging.AAPSLogger import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.core.keys.PushedWatchfaceId import app.aaps.wear.AAPSLoggerTest import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config @@ -18,6 +24,9 @@ import org.robolectric.annotation.Config * an older watch runs [WatchFacePushHelper.syncOnStartup] on each app start, so on unsupported * devices the helper must be a complete no-op — never binding the Watch Face Push service, never * touching state, and above all never throwing. + * + * The one exception is the face selection: the phone's choice is stored on every watch, so a watch + * that gets Wear OS 6 later installs the chosen face on its next start. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [35]) @@ -56,4 +65,37 @@ internal class WatchFacePushHelperTest { sut().syncOnStartup() verifyNoInteractions(sp) } + + /** 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) + + @Test + fun `the selected face is the Custom watchface until the phone chooses`() { + assertThat(sut(spWithDefaults()).selectedFace).isEqualTo(PushedFace.CWF) + } + + @Test + fun `an unknown face id falls back to the Custom watchface`() { + assertThat(PushedFace.fromId("something-newer")).isEqualTo(PushedFace.CWF) + assertThat(PushedFace.fromId(null)).isEqualTo(PushedFace.CWF) + } + + @Test + fun `selecting the other face is stored below API 36 too and reports the change`() { + val sp = spWithDefaults() + assertThat(sut(sp).selectFace(PushedWatchfaceId.WFS)).isTrue() + verify(sp).putString(any(), eq(PushedWatchfaceId.WFS)) + } + + @Test + fun `selecting the face already selected changes nothing`() { + val sp = spWithDefaults() + assertThat(sut(sp).selectFace(PushedWatchfaceId.CWF)).isFalse() + verify(sp, never()).putString(any(), any()) + } } diff --git a/wear/watchfacepush/build.gradle.kts b/wear/watchfacepush/build.gradle.kts index b81f2b67ed1d..781a9754ae3d 100644 --- a/wear/watchfacepush/build.gradle.kts +++ b/wear/watchfacepush/build.gradle.kts @@ -11,17 +11,28 @@ repositories { } /** - * Code-free Watch Face Format (WFF) face, installed at runtime by the wear app through the + * Code-free Watch Face Format (WFF) faces, installed at runtime by the wear app through the * Watch Face Push API (Wear OS 6+). Watch Face Push requires the face package to be - * `.watchfacepush.`, so every wear flavor gets its own face APK. + * `.watchfacepush.`, so every wear flavor gets its own face APKs. * - * The face source is exported from the Watch Face Studio project (AAPS V4.wfs). To update the - * design: re-export from WFS, then regenerate `template/watchface.xml` from the exported APK's - * `res/raw/watchface.xml`, re-inserting the AAPS_WEAR_APP_ID DefaultProviderPolicy entries. + * Two faces are built, one per `face` flavor, and the wear app embeds both. Watch Face Push gives + * an app one slot, so only one of them is installed at a time: the wearer picks which one in the + * phone's wear settings, and the wear app swaps the slot. + * + * - `wfs`: the face designed in Watch Face Studio (AAPS V4.wfs), a layout of AAPS complications. + * To update the design: re-export from WFS, then regenerate `src/wfs/template/watchface.xml` + * from the exported APK's `res/raw/watchface.xml`, re-inserting the AAPS_WEAR_APP_ID + * DefaultProviderPolicy entries. + * - `cwf`: the document that shows the Custom watchface as a full-screen image complication, so + * the wearer's own zip design reaches a watch whose firmware no longer runs code-based faces. + * Hand-written, see `_docs/CWF_WFF_Prompt.md`. + * + * Each face keeps its own template, preview pictures, `watch_face_info.xml` and strings under + * `src//`; only the manifest and the shape declarations are shared in `src/main/`. */ android { enableKotlin = false - namespace = "info.nightscout.androidaps.watchfacepush.aapsv4" + namespace = "info.nightscout.androidaps.watchfacepush" compileSdk = Versions.compileSdk defaultConfig { @@ -52,34 +63,46 @@ android { } } - flavorDimensions.add("standard") + // "standard" first, so a variant reads as the wear flavor it belongs to: fullWfsRelease. + // The standard flavor sets the client app id, the face flavor appends the face suffix that + // Watch Face Push requires. + flavorDimensions.addAll(listOf("standard", "face")) productFlavors { create("full") { isDefault = true - applicationId = "info.nightscout.androidaps.watchfacepush.aapsv4" + applicationId = "info.nightscout.androidaps" dimension = "standard" resValue("string", "watchface_name", "AAPS") } create("pumpcontrol") { - applicationId = "info.nightscout.aapspumpcontrol.watchfacepush.aapsv4" + applicationId = "info.nightscout.aapspumpcontrol" dimension = "standard" resValue("string", "watchface_name", "Pumpcontrol") } create("aapsclient") { - applicationId = "info.nightscout.aapsclient.watchfacepush.aapsv4" + applicationId = "info.nightscout.aapsclient" dimension = "standard" resValue("string", "watchface_name", "AAPSClient") } create("aapsclient2") { - applicationId = "info.nightscout.aapsclient2.watchfacepush.aapsv4" + applicationId = "info.nightscout.aapsclient2" dimension = "standard" resValue("string", "watchface_name", "AAPSClient2") } create("aapsclient3") { - applicationId = "info.nightscout.aapsclient3.watchfacepush.aapsv4" + applicationId = "info.nightscout.aapsclient3" dimension = "standard" resValue("string", "watchface_name", "AAPSClient3") } + create("wfs") { + dimension = "face" + applicationIdSuffix = ".watchfacepush.wfs" + } + create("cwf") { + isDefault = true + dimension = "face" + applicationIdSuffix = ".watchfacepush.cwf" + } } buildFeatures { resValues = true @@ -106,8 +129,8 @@ configurations.configureEach { /** * Generates `res/raw/watchface.xml` for a variant by substituting the wear app's application id - * into the DefaultProviderPolicy entries of the template, so the face's complication slots default - * to the AAPS complications of the matching wear flavor. + * into the DefaultProviderPolicy entries of the face's template, so the face's complication slots + * default to the AAPS complications of the matching wear flavor. */ abstract class GenerateWatchFaceResTask : DefaultTask() { @@ -133,16 +156,19 @@ abstract class GenerateWatchFaceResTask : DefaultTask() { extensions.configure("androidComponents") { onVariants { variant -> + val face = variant.productFlavors.first { (dimension, _) -> dimension == "face" }.second val taskName = "generate${variant.name.replaceFirstChar { it.uppercase() }}WatchFaceRes" val taskProvider = project.tasks.register(taskName, GenerateWatchFaceResTask::class.java) { - templateFile.set(project.layout.projectDirectory.file("template/watchface.xml")) - providerAppId.set(variant.applicationId.map { appId -> appId.removeSuffix(".watchfacepush.aapsv4") }) + templateFile.set(project.layout.projectDirectory.file("src/$face/template/watchface.xml")) + // The wear app's id is everything before the face suffix + providerAppId.set(variant.applicationId.map { appId -> appId.substringBefore(".watchfacepush.") }) } variant.sources.res?.addGeneratedSourceDirectory(taskProvider, GenerateWatchFaceResTask::outputDir) // Expose the release APK directory as an outgoing artifact so the wear app can consume it // with a real producer→consumer dependency (a hardcoded path would fail Gradle's - // input-file validation before the APK is built) + // input-file validation before the APK is built). One configuration per wear flavor and + // face: watchfaceApkFullWfs, watchfaceApkFullCwf, ... if (variant.buildType == "release") { val outgoing = configurations.create("watchfaceApk${variant.flavorName!!.replaceFirstChar { it.uppercase() }}") { isCanBeConsumed = true diff --git a/wear/watchfacepush/src/main/res/drawable-nodpi/preview.png b/wear/watchfacepush/src/cwf/res/drawable-nodpi/preview.png similarity index 100% rename from wear/watchfacepush/src/main/res/drawable-nodpi/preview.png rename to wear/watchfacepush/src/cwf/res/drawable-nodpi/preview.png diff --git a/wear/watchfacepush/src/main/res/drawable-nodpi/preview_circular.png b/wear/watchfacepush/src/cwf/res/drawable-nodpi/preview_circular.png similarity index 100% rename from wear/watchfacepush/src/main/res/drawable-nodpi/preview_circular.png rename to wear/watchfacepush/src/cwf/res/drawable-nodpi/preview_circular.png diff --git a/wear/watchfacepush/src/main/res/values/strings.xml b/wear/watchfacepush/src/cwf/res/values/strings.xml similarity index 100% rename from wear/watchfacepush/src/main/res/values/strings.xml rename to wear/watchfacepush/src/cwf/res/values/strings.xml diff --git a/wear/watchfacepush/src/main/res/xml/watch_face_info.xml b/wear/watchfacepush/src/cwf/res/xml/watch_face_info.xml similarity index 71% rename from wear/watchfacepush/src/main/res/xml/watch_face_info.xml rename to wear/watchfacepush/src/cwf/res/xml/watch_face_info.xml index de09cede306f..d1997229b5e0 100644 --- a/wear/watchfacepush/src/main/res/xml/watch_face_info.xml +++ b/wear/watchfacepush/src/cwf/res/xml/watch_face_info.xml @@ -1,7 +1,11 @@ - + + + + + + diff --git a/wear/watchfacepush/src/wfs/template/watchface.xml b/wear/watchfacepush/src/wfs/template/watchface.xml new file mode 100644 index 000000000000..174f652214a3 --- /dev/null +++ b/wear/watchfacepush/src/wfs/template/watchface.xml @@ -0,0 +1 @@ + \ No newline at end of file From fecde525cfeb6801f2b678831cd40a56154365f4 Mon Sep 17 00:00:00 2001 From: olorinmaia Date: Sun, 13 Sep 2026 01:36:07 +0200 Subject: [PATCH 2/3] Wear: render the Custom watchface picture only while a face shows it CwfComplicationUpdater ran from app start on every watch: a frame a second while awake and one a minute always, plus three update requests a second, whether or not any face showed the picture. On a Wear OS 5 watch the code-based Custom watchface draws itself and none of that was ever seen; on a Wear OS 6 watch showing any other face, the same. The system asks a data source only for a bound complication, so a request or an activation is the proof that somebody is looking. CwfFaceComplication records both, and the updater renders and requests only while one arrived in the last ten minutes. Demand renews itself while the face is bound, because every tick makes the runtime ask. The runtime's onComplicationDeactivated ends demand at once when the last bound slot lets go, so the loops stop the moment another face is selected; the ten minute timeout stays as the safety net. Measured on a Galaxy Watch 5: a request queued by our own last tick still lands after the deactivation, and the runtime asks before it activates, so once the slot table is known to be empty only an activation counts again. App start knocks once so an already shown face answers. --- .../cwf/CwfComplicationUpdater.kt | 84 ++++++++++++++++- .../complications/cwf/CwfFaceComplication.kt | 94 +++++++++++++++++++ .../wear/complications/cwf/CwfPacingTest.kt | 84 +++++++++++++++++ 3 files changed, 258 insertions(+), 4 deletions(-) diff --git a/wear/src/main/kotlin/app/aaps/wear/complications/cwf/CwfComplicationUpdater.kt b/wear/src/main/kotlin/app/aaps/wear/complications/cwf/CwfComplicationUpdater.kt index a6cdde1b924d..e6f3e4c0e132 100644 --- a/wear/src/main/kotlin/app/aaps/wear/complications/cwf/CwfComplicationUpdater.kt +++ b/wear/src/main/kotlin/app/aaps/wear/complications/cwf/CwfComplicationUpdater.kt @@ -49,6 +49,12 @@ import dev.zacsweers.metro.SingleIn * - **a preference change**, refreshing both halves at once, because a preference can change the * layout itself and the user is looking at the watch when they change one. * + * All of it only while a face is actually showing the picture. The system asks a data source only + * for a bound complication, so a request within the last [DEMAND_TIMEOUT_MS] is the proof that + * somebody is looking; without one the loops idle and nothing is drawn or requested. That is what + * keeps a Wear OS 5 watch running the code-based Custom watchface, or a Wear OS 6 watch showing + * the complications face, from paying for pictures nobody sees. + * * Lives for the life of the process rather than of a service: a data source is bound only for as * long as it takes to answer one request, so nothing inside one can drive a schedule. */ @@ -162,6 +168,34 @@ class CwfComplicationUpdater( /** How long the producer waits when the queue is already full, or the watch is dozing. */ private const val PRODUCER_IDLE_MS = 1_000L + /** + * How long after the last request the picture is still considered wanted. + * + * Everything here used to run from app start on every watch, whether or not any face showed + * the picture: a frame a second while awake and one a minute always, plus three update + * requests a second. On a Wear OS 5 watch the code-based Custom watchface draws itself and + * none of that was ever seen; on a Wear OS 6 watch showing any other face, the same. So the + * loops now run only while a face has asked recently. + * + * While a face is bound, demand renews itself: every tick here makes the runtime ask, once a + * second awake and once a minute dozing, and the sources declare an update period of sixty + * seconds on top, honoured every 1.5 to 6 minutes. Ten minutes is comfortably past all of + * that. Once the face is gone the runtime never asks again, so rendering stops within this + * long - and the moment it is selected again, its first request starts everything at once, + * see [CwfFaceComplication.onDemandResumed]. + */ + private const val DEMAND_TIMEOUT_MS = 10 * 60_000L + + /** How often an idle loop looks whether somebody asked, cheap enough to not matter. */ + private const val IDLE_POLL_MS = 5_000L + + /** + * Whether a request at [lastRequestMs] still counts as demand at [now]. Pure, so the rule + * can be checked without a watch; 0 means nobody has asked yet. + */ + internal fun demandActive(lastRequestMs: Long, now: Long, timeoutMs: Long = DEMAND_TIMEOUT_MS): Boolean = + lastRequestMs > 0 && now - lastRequestMs < timeoutMs + /** * How many ticks in a row may find the queue empty before the rate drops. * @@ -319,6 +353,9 @@ class CwfComplicationUpdater( val ambient = CwfFaceComplication.isAmbient(context) if (ambient != wasAmbient) { wasAmbient = ambient + // Nobody is looking: nothing to refresh and no loop worth restarting. The mode is + // read afresh by every frame anyway, so nothing here is needed later either. + if (!hasDemand()) return aapsLogger.debug(LTag.WEAR, "CwfComplicationUpdater: ambient=$ambient secondsShown=${CwfFaceComplication.showsSeconds()}") // Refreshed here and now rather than through the coalescing loop. That loop sleeps a // second between passes, and the process is being frozen as the watch dozes, so a @@ -408,15 +445,28 @@ class CwfComplicationUpdater( * takes to answer one request, so nothing inside one can prepare anything for later. The process * stays alive because `DataLayerListenerServiceWear` is a foreground service. */ + /** Whether a face has asked for the picture recently enough to be worth rendering for. */ + private fun hasDemand(): Boolean = demandActive(CwfFaceComplication.lastRequestMs, System.currentTimeMillis()) + + /** + * Whether the idle state has been logged since demand last ended. Kept outside the clock loop + * because the loop is restarted by every refresh, and a fresh loop would otherwise either log + * idle again or, as measured, not at all. + */ + @Volatile private var idleAnnounced = false + private fun startProducer() { producerJob?.cancel() producerJob = scope.launch { while (true) { - val wanted = !CwfFaceComplication.isAmbient(context) && CwfFaceComplication.showsSeconds() + // Nothing at all without demand - not even the minute frame that prepareAhead keeps + // ready, and no watch face is ever inflated for a picture nobody has asked for + val demand = hasDemand() + val wanted = demand && !CwfFaceComplication.isAmbient(context) && CwfFaceComplication.showsSeconds() val built = wanted && CwfFaceComplication.prepareAhead(context, aapsLogger) // Nothing to build: wait out the second rather than spin. The queue empties as frames - // are served, so there is work again within a second or so. - if (!built) delay(PRODUCER_IDLE_MS) + // are served, so there is work again within a second or so. Idle, look less often. + if (!built) delay(if (demand) PRODUCER_IDLE_MS else IDLE_POLL_MS) } } } @@ -450,13 +500,25 @@ class CwfComplicationUpdater( // // Bounded, because the wait must never become the fault it is fixing: if the producer // cannot manage a frame in that time, the tick goes ahead and draws its own. - if (!CwfFaceComplication.isAmbient(context) && CwfFaceComplication.showsSeconds()) { + if (hasDemand() && !CwfFaceComplication.isAmbient(context) && CwfFaceComplication.showsSeconds()) { val deadline = System.currentTimeMillis() + PRIME_TIMEOUT_MS while (CwfFaceComplication.preparedFrames() == 0 && System.currentTimeMillis() < deadline) delay(PRIME_POLL_MS) } var announced = 0L while (true) { + // Nobody asked: no requests either, they would only reach a face that is not there. + // The first request of a returning face restarts this loop, so the wait here is + // never what the wearer sees. + if (!hasDemand()) { + announced = 0L + if (!idleAnnounced) { + idleAnnounced = true + aapsLogger.debug(LTag.WEAR, "CwfComplicationUpdater: idle, no face has asked for the picture") + } + delay(IDLE_POLL_MS) + continue + } val interval = if (!CwfFaceComplication.isAmbient(context) && CwfFaceComplication.showsSeconds()) secondsInterval() else MINUTE_INTERVAL_MS @@ -488,6 +550,15 @@ class CwfComplicationUpdater( } fun start() { + // A face that comes back after a quiet spell must not wait for the idle polls: its first + // request restarts both loops, so the second hand runs from the first tick. Restarting is + // what the loops already do on a mode change, so nothing else is needed. + CwfFaceComplication.onDemandResumed = { + aapsLogger.debug(LTag.WEAR, "CwfComplicationUpdater: a face asked for the picture, resuming") + idleAnnounced = false + startProducer() + startClockLoop() + } startProducer() // A newly sent zip: refreshed at once, outside the coalescing queue. @@ -545,6 +616,11 @@ class CwfComplicationUpdater( ?.registerDisplayListener(displayListener, Handler(Looper.getMainLooper())) startClockLoop() + // One knock at start, in case a face already shows the picture: a bound face answers with + // a request, which is the demand the loops wait for. Without one, nothing happens, and + // nothing is drawn - which is exactly right for a watch showing any other face. + requestFace() + requestAmbientFace(force = true) aapsLogger.debug(LTag.WEAR, "CwfComplicationUpdater: started") } } diff --git a/wear/src/main/kotlin/app/aaps/wear/complications/cwf/CwfFaceComplication.kt b/wear/src/main/kotlin/app/aaps/wear/complications/cwf/CwfFaceComplication.kt index 0249848346af..ad3b34edb989 100644 --- a/wear/src/main/kotlin/app/aaps/wear/complications/cwf/CwfFaceComplication.kt +++ b/wear/src/main/kotlin/app/aaps/wear/complications/cwf/CwfFaceComplication.kt @@ -77,6 +77,87 @@ open class CwfFaceComplication : ModernBaseComplicationProviderService() { */ private var warmWatchFace: CustomWatchface? = null + /** + * When a watch face last asked any of the three image sources for the picture; 0 until the + * first request. The system only asks a data source that a shown face has bound, so this is + * the one honest signal that somebody is looking: the code-based Custom watchface draws + * itself and never asks, and neither does any other face. [CwfComplicationUpdater] renders + * nothing while this is stale - see its `demandActive`. + */ + @Volatile internal var lastRequestMs = 0L + private set + + /** Called when a request arrives after a quiet spell, so an idle updater can start at once. */ + @Volatile internal var onDemandResumed: (() -> Unit)? = null + + /** + * Records a request; wakes the updater when it had gone idle. + * + * Not when the runtime has already said that no slot is bound: a request can still land a + * moment after the deactivation, queued by our own last tick, and measured on a Galaxy + * Watch 5 it did, 12 ms later - and revived demand for the whole timeout. With the slot + * table known to be empty, only an activation counts again. + */ + internal fun noteRequest(aapsLogger: AAPSLogger? = null) { + if (bindingsKnown && activeSlots.isEmpty()) { + aapsLogger?.debug(LTag.WEAR, "CwfFaceComplication: request after the runtime let go, not demand") + return + } + noteDemand() + } + + private fun noteDemand() { + val now = System.currentTimeMillis() + val wasIdle = !CwfComplicationUpdater.demandActive(lastRequestMs, now) + lastRequestMs = now + if (wasIdle) onDemandResumed?.invoke() + } + + /** + * The slots currently bound to any of the three sources, keyed by source and the system's + * instance id. Only touched from the main thread, where the runtime delivers both callbacks. + * + * This is the fast way out. The request timestamp above ends demand ten minutes after the + * last ask; the runtime's deactivation tells us the moment the face lets go, so the loops + * can stop right then. The timestamp stays as the safety net for a deactivation that never + * comes. + */ + private val activeSlots = mutableSetOf() + + /** + * Whether the runtime has told us anything about bindings in this process. It does not replay + * activations after a process restart, so until the first callback the set above says nothing + * and requests alone have to carry demand; after it, an empty set means what it says. + */ + @Volatile private var bindingsKnown = false + + /** + * An activation is demand by itself. Measured, the runtime asks for the picture *before* it + * activates the slot, seven seconds before on a Galaxy Watch 5, so with the table empty that + * request is rightly ignored - and if nothing followed the activation, the face would sit on + * that first picture until the runtime's own periodic ask, minutes later. + */ + internal fun noteActivated(slot: String, aapsLogger: AAPSLogger) { + bindingsKnown = true + activeSlots += slot + aapsLogger.debug(LTag.WEAR, "CwfFaceComplication: slot $slot activated, ${activeSlots.size} active") + noteDemand() + } + + internal fun noteDeactivated(slot: String, aapsLogger: AAPSLogger) { + bindingsKnown = true + activeSlots -= slot + aapsLogger.debug(LTag.WEAR, "CwfFaceComplication: slot $slot deactivated, ${activeSlots.size} active") + // The last one gone: nobody is looking any more, so demand ends now rather than when + // the timestamp runs out. Also taken for a slot never seen activated - after a process + // restart the runtime does not replay activations, and a deactivation still means the + // face is going away. + if (activeSlots.isEmpty() && lastRequestMs != 0L) { + lastRequestMs = 0L + aapsLogger.debug(LTag.WEAR, "CwfFaceComplication: no slot left, demand ends") + } + } + /** How long the preview may wait on storage before falling back to the built-in image. */ private const val PREVIEW_READ_TIMEOUT_MS = 500L @@ -392,7 +473,20 @@ open class CwfFaceComplication : ModernBaseComplicationProviderService() { * Overriding the whole request also skips the base class's `DataStore` read, which this path * never used: the pipeline reads the data itself, and only when it has changed. */ + /** Source and instance together: the runtime's instance id is per slot, the source tells the slots apart across the three services. */ + private fun slotKey(complicationInstanceId: Int) = "${javaClass.simpleName}#$complicationInstanceId" + + override fun onComplicationActivated(complicationInstanceId: Int, type: ComplicationType) { + noteActivated(slotKey(complicationInstanceId), aapsLogger) + } + + override fun onComplicationDeactivated(complicationInstanceId: Int) { + noteDeactivated(slotKey(complicationInstanceId), aapsLogger) + } + override fun onComplicationRequest(request: ComplicationRequest, listener: ComplicationRequestListener) { + // Somebody is looking: this is what lets the updater render at all + noteRequest(aapsLogger) // No tap action while the watch is dozing. A slot answers taps wherever it is drawn, so with // one attached the first tap on a sleeping watch was consumed by this complication and opened // AAPS instead of waking the screen - reported on a Galaxy Watch 4, where the tap highlight diff --git a/wear/src/test/kotlin/app/aaps/wear/complications/cwf/CwfPacingTest.kt b/wear/src/test/kotlin/app/aaps/wear/complications/cwf/CwfPacingTest.kt index 74d2010e665a..1a7ca9da5855 100644 --- a/wear/src/test/kotlin/app/aaps/wear/complications/cwf/CwfPacingTest.kt +++ b/wear/src/test/kotlin/app/aaps/wear/complications/cwf/CwfPacingTest.kt @@ -1,5 +1,6 @@ package app.aaps.wear.complications.cwf +import app.aaps.wear.AAPSLoggerTest import com.google.common.truth.Truth.assertThat import org.junit.jupiter.api.Test @@ -98,3 +99,86 @@ class CwfPacingTest { .isEqualTo(1_000) } } + +// ---- whether anybody is looking at all -------------------------------------------------- + +/** + * The rule that keeps the whole renderer idle on a watch showing any other face: a request from a + * bound face is the only proof that the picture is seen, and it goes stale after a while. + */ +class CwfDemandTest { + + @Test + fun `nothing is wanted before the first request`() { + assertThat(CwfComplicationUpdater.demandActive(lastRequestMs = 0, now = 1_000_000)).isFalse() + } + + @Test + fun `a recent request is demand`() { + assertThat(CwfComplicationUpdater.demandActive(lastRequestMs = 1_000_000, now = 1_000_000 + 60_000)).isTrue() + } + + @Test + fun `demand goes stale once the face has stopped asking`() { + // Bound, the runtime asks at least every few minutes; ten minutes of silence means the face is gone + assertThat(CwfComplicationUpdater.demandActive(lastRequestMs = 1_000_000, now = 1_000_000 + 10 * 60_000)).isFalse() + } + + @Test + fun `the window is the caller's to choose`() { + assertThat(CwfComplicationUpdater.demandActive(lastRequestMs = 1_000, now = 3_000, timeoutMs = 1_000)).isFalse() + assertThat(CwfComplicationUpdater.demandActive(lastRequestMs = 1_000, now = 1_500, timeoutMs = 1_000)).isTrue() + } + + // ---- the runtime's own signal, which ends demand at once ---------------------------------- + + private val logger = AAPSLoggerTest() + + // The slot table is process-wide state, so every test here leaves it empty when done. The + // "nothing known yet" state before the first callback cannot be brought back and is not tested. + + @Test + fun `demand ends the moment the last bound slot lets go`() { + CwfFaceComplication.noteActivated("CwfFaceComplication#6", logger) + CwfFaceComplication.noteActivated("CwfAmbientFaceComplication#7", logger) + CwfFaceComplication.noteRequest() + assertThat(CwfFaceComplication.lastRequestMs).isNotEqualTo(0L) + + // One slot gone, the other still shows the picture + CwfFaceComplication.noteDeactivated("CwfFaceComplication#6", logger) + assertThat(CwfFaceComplication.lastRequestMs).isNotEqualTo(0L) + + CwfFaceComplication.noteDeactivated("CwfAmbientFaceComplication#7", logger) + assertThat(CwfFaceComplication.lastRequestMs).isEqualTo(0L) + } + + @Test + fun `an activation is demand by itself`() { + // The runtime asks before it activates, so the activation must not wait for a request + CwfFaceComplication.noteActivated("CwfFaceComplication#6", logger) + assertThat(CwfFaceComplication.lastRequestMs).isNotEqualTo(0L) + + CwfFaceComplication.noteDeactivated("CwfFaceComplication#6", logger) + } + + @Test + fun `a request after the runtime let go does not revive demand`() { + // Measured: a request queued by our own last tick landed 12 ms after the deactivation + CwfFaceComplication.noteActivated("CwfFaceComplication#6", logger) + CwfFaceComplication.noteDeactivated("CwfFaceComplication#6", logger) + assertThat(CwfFaceComplication.lastRequestMs).isEqualTo(0L) + + CwfFaceComplication.noteRequest() + assertThat(CwfFaceComplication.lastRequestMs).isEqualTo(0L) + } + + @Test + fun `a deactivation of an unknown slot leaves a bound one alone`() { + CwfFaceComplication.noteActivated("CwfFaceComplication#6", logger) + CwfFaceComplication.noteDeactivated("CwfFaceComplication#99", logger) + assertThat(CwfFaceComplication.lastRequestMs).isNotEqualTo(0L) + + CwfFaceComplication.noteDeactivated("CwfFaceComplication#6", logger) + assertThat(CwfFaceComplication.lastRequestMs).isEqualTo(0L) + } +} From 7a1e51560d900d35b40edeb06d578ee527ec99cd Mon Sep 17 00:00:00 2001 From: olorinmaia Date: Sun, 13 Sep 2026 14:59:13 +0200 Subject: [PATCH 3/3] Wear: hide the Custom watchface picture sources from the complication chooser Three near-identical "Custom watchface" entries looked like a mistake, and the chooser is the wrong place for them anyway: AAPS ships the Watch Face Format face that shows the picture, and its slots are not customizable. A hidden source can still be named as a default provider, which is how that face binds them, and a slot that already holds one keeps it. --- wear/src/main/AndroidManifest.xml | 21 ++++++++++++++++--- .../complications/CwfImageComplication.kt | 11 +++++----- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index d40382576019..45f561908304 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -265,7 +265,14 @@ + LARGE_IMAGE is the manifest tag for ComplicationType.PHOTO_IMAGE. + + All three picture sources below carry HIDDEN: they are left out of the complication + chooser, where three "Custom watchface" entries looked like a mistake, and where the + chooser is the wrong place anyway - AAPS ships the Watch Face Format face that shows the + picture, and its slots are not customizable. A hidden source can still be named as a + default provider, which is how that face binds them; a slot that already holds one keeps + it, as HIDDEN only stops new choices. --> + + CwfMinuteFrame. Only meaningful beside its twin. --> +