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 000000000000..63b0a9ca659d Binary files /dev/null and b/plugins/sync/src/androidMain/res/drawable-nodpi/wfs_watchface_preview.png differ 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/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. --> + { 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/complications/CwfImageComplication.kt b/wear/src/main/kotlin/app/aaps/wear/complications/CwfImageComplication.kt index b136bcac1bdb..a242d878fb1b 100644 --- a/wear/src/main/kotlin/app/aaps/wear/complications/CwfImageComplication.kt +++ b/wear/src/main/kotlin/app/aaps/wear/complications/CwfImageComplication.kt @@ -5,13 +5,14 @@ import app.aaps.wear.complications.cwf.CwfFaceComplication import app.aaps.wear.R /** - * The whole Custom watch face as one image, offered under its own name. + * The whole Custom watch face as one image, under its own name. * * Identical in behaviour to [CwfFaceComplication], which the watch face AAPS pushes uses. This one - * exists so that any *other* watch face with an image slot can pick "Custom Watchface image" from the - * complication list and get the same picture. Separate rather than shared because a data source is - * addressed by its class name: dropping it would silently empty the slot for anyone who had already - * chosen it. + * used to be offered in the complication chooser, so that any *other* watch face with an image slot + * could pick "Custom Watchface" and get the same picture. It is hidden from the chooser now, like its + * two siblings: AAPS ships the face that shows the picture, and three near-identical entries there + * looked like a mistake. Kept rather than dropped because a data source is addressed by its class + * name: a slot that already holds it keeps working, and dropping the class would silently empty it. */ class CwfImageComplication : CwfFaceComplication() { 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/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/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) + } +} 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