Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions _docs/Complication_Libraries.md
Original file line number Diff line number Diff line change
Expand Up @@ -1931,6 +1931,15 @@ WatchFacePush: slots used=1 remaining=0 packages=<app>.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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions core/keys/src/androidMain/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@
<string name="pref_title_wear_wizard_cob">Include COB in wizard</string>
<string name="pref_title_wear_wizard_iob">Include IOB in wizard</string>
<string name="pref_title_wear_custom_watchface_authorization">Authorize custom watchfaces</string>
<string name="pref_title_wear_pushed_watchface">Watchface installed on watch</string>
<string name="pref_summary_wear_pushed_watchface">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.</string>
<string name="wear_pushed_watchface_wfs" comment="Entry of a list setting: the built-in watchface made of AAPS complications">Complications watchface</string>
<string name="wear_pushed_watchface_cwf" comment="Entry of a list setting: the watchface loaded from a custom watchface zip file">Custom watchface</string>
<string name="pref_title_wear_notify_on_smb">Notify on SMB</string>
<string name="pref_summary_wear_notify_on_smb">Send notifications to watch when SMB is delivered.</string>
<string name="pref_title_wear_broadcast_data">Broadcast data to watch</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -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
* (`<wear app>.watchfacepush.<id>`) 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"
}
17 changes: 17 additions & 0 deletions core/keys/src/commonMain/kotlin/app/aaps/core/keys/StringKey.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -50,6 +52,7 @@ internal class WearViewModelTest {
private val connectedDeviceFlow = MutableStateFlow<String?>(null)
private val savedCustomWatchfaceFlow = MutableStateFlow<CwfData?>(null)
private val eventWearUpdateGuiFlow = MutableSharedFlow<EventWearUpdateGui>()
private val pushedWatchfaceFlow = MutableStateFlow(PushedWatchfaceId.CWF)

private lateinit var sut: WearViewModel

Expand All @@ -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)
}
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -162,6 +175,7 @@ internal fun WearScreen(
onMoreWatchfaces = {
context.startActivity(Intent(Intent.ACTION_VIEW, moreWatchfacesUrl.toUri()))
},
onSelectPushedWatchface = { viewModel.selectPushedWatchface(it) },
modifier = modifier
)
}
Expand All @@ -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<String?>(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()
Expand Down Expand Up @@ -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(),
Expand All @@ -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),
Expand All @@ -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
Expand Down
Loading
Loading