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
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ class EventDataTest {
assertThat(EventData.deserializeByte(it.serializeByte())).isEqualTo(it)
assertThat(EventData.deserialize(it.serialize())).isEqualTo(it)
}
EventData.WatchFacePushStatus(supported = true, installedFace = "wfs").let {
assertThat(EventData.deserializeByte(it.serializeByte())).isEqualTo(it)
assertThat(EventData.deserialize(it.serialize())).isEqualTo(it)
}
EventData.Status(
dataset = 0, externalStatus = "st", iobSum = "1", iobDetail = "2", cob = "3", currentBasal = "4",
battery = "5", rigBattery = "6", openApsStatus = 7L, bgi = "8", batteryLevel = 9, patientName = "p",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
package app.aaps.core.interfaces.rx.events

import app.aaps.core.interfaces.rx.weardata.CwfData
import app.aaps.core.interfaces.rx.weardata.EventData

/**
* Fired to update the GUI on the wearable device.
*
* @param customWatchfaceData Data for a custom watchface, or null if not applicable.
* @param exportFile If true, the data should be exported to a file.
* @param watchFacePushStatus What the watch reported about Watch Face Push, or null if this event
* carries something else.
*/
data class EventWearUpdateGui(val customWatchfaceData: CwfData? = null, val exportFile: Boolean = false) : Event()
data class EventWearUpdateGui(
val customWatchfaceData: CwfData? = null,
val exportFile: Boolean = false,
val watchFacePushStatus: EventData.WatchFacePushStatus? = null
) : Event()
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ sealed class EventData : Event() {
@Serializable
data class ActionPong(val timeStamp: Long, val apiLevel: Int) : EventData()

/**
* What the watch did with `StringKey.WearPushedWatchface`. Sent after the startup sync, after
* every install, and in reply to [Preferences], so the phone shows the face choice only on a
* watch that has Watch Face Push (Wear OS 6+) and can tell when the watch still holds the other
* face. [installedFace] is a `PushedWatchfaceId` value, or null when no face of ours is installed.
*/
@Serializable
data class WatchFacePushStatus(val supported: Boolean, val installedFace: String? = null) : EventData()

@Serializable
data class WearException(
val timeStamp: Long,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import app.aaps.core.interfaces.rx.events.Event
import app.aaps.core.interfaces.rx.events.EventMobileToWear
import app.aaps.core.interfaces.rx.events.EventWearUpdateGui
import app.aaps.core.interfaces.rx.weardata.CwfData
import app.aaps.core.interfaces.rx.weardata.CwfFile
import app.aaps.core.interfaces.rx.weardata.CwfMetadataKey
import app.aaps.core.interfaces.rx.weardata.EventData
import app.aaps.core.interfaces.utils.DateUtil
Expand Down Expand Up @@ -53,6 +54,7 @@ internal class WearViewModelTest {
private val savedCustomWatchfaceFlow = MutableStateFlow<CwfData?>(null)
private val eventWearUpdateGuiFlow = MutableSharedFlow<EventWearUpdateGui>()
private val pushedWatchfaceFlow = MutableStateFlow(PushedWatchfaceId.CWF)
private val watchFacePushStatusFlow = MutableStateFlow<EventData.WatchFacePushStatus?>(null)

private lateinit var sut: WearViewModel

Expand All @@ -62,6 +64,7 @@ internal class WearViewModelTest {
Dispatchers.setMain(UnconfinedTestDispatcher())
whenever(wearPlugin.connectedDevice).thenReturn(connectedDeviceFlow)
whenever(wearPlugin.savedCustomWatchface).thenReturn(savedCustomWatchfaceFlow)
whenever(wearPlugin.watchFacePushStatus).thenReturn(watchFacePushStatusFlow)
whenever(rxBus.toFlow(EventWearUpdateGui::class)).thenReturn(eventWearUpdateGuiFlow)
whenever(preferences.observe(StringKey.WearPushedWatchface)).thenReturn(pushedWatchfaceFlow)
whenever(rh.gs(SyncStrings.no_watch_connected)).thenReturn("No watch connected")
Expand Down Expand Up @@ -131,13 +134,61 @@ internal class WearViewModelTest {
}

@Test
fun `the custom watchface hint follows the pushed watchface setting`() {
// Custom watchface installed on the watch: nothing to warn about
assertThat(sut.uiState.value.customWatchfaceInstalled).isTrue()
fun `the custom watchface warning follows the pushed watchface setting`() {
// Custom watchface chosen: nothing to warn about
assertThat(sut.uiState.value.customWatchfaceSelected).isTrue()

// The complications face installed instead: a loaded zip would not be shown
// The complications face chosen instead: a loaded zip would not be shown
pushedWatchfaceFlow.value = PushedWatchfaceId.WFS
assertThat(sut.uiState.value.customWatchfaceInstalled).isFalse()
assertThat(sut.uiState.value.customWatchfaceSelected).isFalse()
}

@Test
fun `the face choice appears only once the watch reports Watch Face Push`() {
// Nothing reported yet, or an older wear app that never reports: no choice
assertThat(sut.uiState.value.watchFacePushSupported).isFalse()
assertThat(sut.uiState.value.installedWatchface).isNull()

watchFacePushStatusFlow.value = EventData.WatchFacePushStatus(supported = true, installedFace = PushedWatchfaceId.WFS)
assertThat(sut.uiState.value.watchFacePushSupported).isTrue()
assertThat(sut.uiState.value.installedWatchface).isEqualTo(PushedWatchfaceId.WFS)

// A watch below Wear OS 6 reports, and the choice stays away
watchFacePushStatusFlow.value = EventData.WatchFacePushStatus(supported = false)
assertThat(sut.uiState.value.watchFacePushSupported).isFalse()

// Disconnected: the plugin clears the report, the next watch speaks for itself
watchFacePushStatusFlow.value = null
assertThat(sut.uiState.value.watchFacePushSupported).isFalse()
assertThat(sut.uiState.value.installedWatchface).isNull()
}

@Test
fun `sending a zip while the complications face is on the wrist says so once`() {
val zip = CwfFile(
cwfData = CwfData(json = "{}", metadata = mutableMapOf(CwfMetadataKey.CWF_NAME to "Analog G-Watch"), resData = mutableMapOf()),
zipByteArray = ByteArray(0)
)
watchFacePushStatusFlow.value = EventData.WatchFacePushStatus(supported = true)
pushedWatchfaceFlow.value = PushedWatchfaceId.WFS

sut.selectWatchface(zip)
assertThat(sut.uiState.value.customWatchfaceNotShown).isEqualTo("Analog G-Watch")

sut.dismissCustomWatchfaceNotShown()
assertThat(sut.uiState.value.customWatchfaceNotShown).isNull()
}

@Test
fun `sending a zip while the custom face is on the wrist says nothing`() {
val zip = CwfFile(
cwfData = CwfData(json = "{}", metadata = mutableMapOf(CwfMetadataKey.CWF_NAME to "Analog G-Watch"), resData = mutableMapOf()),
zipByteArray = ByteArray(0)
)
watchFacePushStatusFlow.value = EventData.WatchFacePushStatus(supported = true)

sut.selectWatchface(zip)
assertThat(sut.uiState.value.customWatchfaceNotShown).isNull()
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,17 @@ class WearPlugin(
private val _savedCustomWatchface = MutableStateFlow<CwfData?>(null)
val savedCustomWatchface: StateFlow<CwfData?> = _savedCustomWatchface.asStateFlow()

/**
* What the watch last said about Watch Face Push: whether it has it, and which face it holds.
* Null until the watch reports, and again when it disconnects - a fresh watch must speak for
* itself, since the answer differs from one watch to the next.
*/
private val _watchFacePushStatus = MutableStateFlow<EventData.WatchFacePushStatus?>(null)
val watchFacePushStatus: StateFlow<EventData.WatchFacePushStatus?> = _watchFacePushStatus.asStateFlow()

fun updateConnectedDevice(deviceName: String?) {
_connectedDevice.value = deviceName
if (deviceName == null) _watchFacePushStatus.value = null
}

fun updateSavedCustomWatchface(cwfData: CwfData?) {
Expand Down Expand Up @@ -203,6 +212,7 @@ class WearPlugin(
checkCustomWatchfacePreferences()
}
}
event.watchFacePushStatus?.let { _watchFacePushStatus.value = it }
}
}
rxBus.toFlow(EventMobileToWear::class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import app.aaps.core.ui.compose.AapsSpacing
import app.aaps.core.ui.compose.LocalSnackbarHostState
import app.aaps.core.ui.compose.ToolbarConfig
import app.aaps.core.ui.compose.dialogs.OkCancelDialog
import app.aaps.core.ui.compose.dialogs.OkDialog
import app.aaps.plugins.sync.R

@Composable
Expand Down Expand Up @@ -176,6 +177,7 @@ internal fun WearScreen(
context.startActivity(Intent(Intent.ACTION_VIEW, moreWatchfacesUrl.toUri()))
},
onSelectPushedWatchface = { viewModel.selectPushedWatchface(it) },
onDismissCustomWatchfaceNotShown = { viewModel.dismissCustomWatchfaceNotShown() },
modifier = modifier
)
}
Expand All @@ -199,16 +201,17 @@ internal fun WearMainContent(
onExportTemplate: () -> Unit,
onMoreWatchfaces: () -> Unit,
onSelectPushedWatchface: (String) -> Unit,
onDismissCustomWatchfaceNotShown: () -> Unit,
modifier: Modifier = Modifier
) {
// The face the wearer tapped, waiting for their confirmation; null while no dialog is open.
// Confirmed first because the watch replaces its installed face at once and that resets the
// choices made in the watch face editor.
// Confirmed first because the watch replaces its installed face at once.
var pendingWatchface by remember { mutableStateOf<String?>(null) }
pendingWatchface?.let { face ->
val current = if (uiState.customWatchfaceSelected) PushedWatchfaceId.CWF else PushedWatchfaceId.WFS
OkCancelDialog(
title = stringResource(SyncStrings.wear_pushed_watchface_confirm_title),
message = stringResource(SyncStrings.wear_pushed_watchface_confirm_message, pushedWatchfaceLabel(face)),
message = stringResource(SyncStrings.wear_pushed_watchface_confirm_message, pushedWatchfaceLabel(face), pushedWatchfaceLabel(current)),
onConfirm = {
onSelectPushedWatchface(face)
pendingWatchface = null
Expand All @@ -217,6 +220,16 @@ internal fun WearMainContent(
)
}

// A zip was just sent while the complications face is the one on the wrist: it is stored on
// the watch, but nothing shows it until the custom face is selected. Said once, at that moment.
uiState.customWatchfaceNotShown?.let { name ->
OkDialog(
title = pushedWatchfaceLabel(PushedWatchfaceId.CWF),
message = stringResource(SyncStrings.wear_custom_watchface_not_shown, name),
onDismiss = onDismissCustomWatchfaceNotShown
)
}

Column(
modifier = modifier
.fillMaxSize()
Expand Down Expand Up @@ -249,9 +262,9 @@ internal fun WearMainContent(
}
}

// Watchface Card (visible only when connected): which face the watch installs, then the
// controls of that face
if (uiState.isDeviceConnected) {
// Pushed Watchface Card: only on a watch that reported Watch Face Push (Wear OS 6+). Below
// that the pushed faces cannot exist, and a choice that does nothing would only mislead.
if (uiState.isDeviceConnected && uiState.watchFacePushSupported) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
Expand All @@ -270,57 +283,80 @@ internal fun WearMainContent(

// Watch Face Push gives the app one slot, so this is a choice between the two
// embedded faces, not two switches. The tap asks first, see pendingWatchface.
// While the watch still holds the other face - the seconds an install takes,
// or longer after a reinstall until the preferences reach it - the chosen row
// says so, quietly: it is progress, not a fault.
val selectedId = if (uiState.customWatchfaceSelected) PushedWatchfaceId.CWF else PushedWatchfaceId.WFS
val installing = uiState.installedWatchface != null && uiState.installedWatchface != selectedId
Column(modifier = Modifier.selectableGroup()) {
listOf(PushedWatchfaceId.CWF, PushedWatchfaceId.WFS).forEach { face ->
val selected = (face == PushedWatchfaceId.CWF) == uiState.customWatchfaceInstalled
val selected = face == selectedId
WatchfaceChoiceRow(
label = pushedWatchfaceLabel(face),
selected = selected,
hint = if (selected && installing) stringResource(SyncStrings.wear_pushed_watchface_installing) else null,
onSelect = { if (!selected) pendingWatchface = face }
)
}
}

if (uiState.customWatchfaceInstalled) {
Text(
text = stringResource(SyncStrings.wear_custom_watchface, uiState.watchfaceName),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(horizontal = AapsSpacing.small)
// What the wrist shows with the complications face chosen. The custom face
// needs no picture here: its own preview is in its own card below.
if (!uiState.customWatchfaceSelected) {
Spacer(modifier = Modifier.height(AapsSpacing.small))
Image(
painter = painterResource(R.drawable.wfs_watchface_preview),
contentDescription = pushedWatchfaceLabel(PushedWatchfaceId.WFS),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AapsSpacing.extraLarge),
contentScale = ContentScale.FillWidth
)
}
}
}
}

// Row 1: Load + Info
ButtonRow(
button1 = ButtonDef(Icons.Default.Upload, stringResource(SyncStrings.wear_load_watchface), onLoadWatchface),
button2 = if (uiState.hasCustomWatchface)
ButtonDef(Icons.Default.Info, stringResource(SyncStrings.wear_infos_watchface), onInfosWatchface)
else null
)
// Custom Watchface Card (visible only when connected), whatever face is chosen above: a
// watch below Wear OS 6 runs the code-based face, a watch like the Galaxy Watch 5 runs it
// beside the pushed faces, and a zip can be loaded before switching
if (uiState.isDeviceConnected) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(AapsSpacing.large),
verticalArrangement = Arrangement.spacedBy(AapsSpacing.medium)
) {
Text(
text = stringResource(SyncStrings.wear_custom_watchface, uiState.watchfaceName),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(horizontal = AapsSpacing.small)
)

// Row 2: More Watchfaces + Export
ButtonRow(
button1 = ButtonDef(Icons.Default.Public, stringResource(InterfacesStrings.wear_more_watchfaces), onMoreWatchfaces),
button2 = ButtonDef(Icons.Default.Download, stringResource(SyncStrings.wear_export_watchface), onExportTemplate)
)
// Row 1: Load + Info
ButtonRow(
button1 = ButtonDef(Icons.Default.Upload, stringResource(SyncStrings.wear_load_watchface), onLoadWatchface),
button2 = if (uiState.hasCustomWatchface)
ButtonDef(Icons.Default.Info, stringResource(SyncStrings.wear_infos_watchface), onInfosWatchface)
else null
)

// Watchface preview image
uiState.watchfaceImage?.let { image ->
Spacer(modifier = Modifier.height(AapsSpacing.small))
Image(
bitmap = image,
contentDescription = uiState.watchfaceName,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AapsSpacing.extraLarge),
contentScale = ContentScale.FillWidth
)
}
} else {
// The complications face has nothing to load or export: its layout is fixed
// and the wearer edits the slots on the watch. Only its picture is shown.
// Row 2: More Watchfaces + Export
ButtonRow(
button1 = ButtonDef(Icons.Default.Public, stringResource(InterfacesStrings.wear_more_watchfaces), onMoreWatchfaces),
button2 = ButtonDef(Icons.Default.Download, stringResource(SyncStrings.wear_export_watchface), onExportTemplate)
)

// Watchface preview image
uiState.watchfaceImage?.let { image ->
Spacer(modifier = Modifier.height(AapsSpacing.small))
Image(
painter = painterResource(R.drawable.wfs_watchface_preview),
contentDescription = pushedWatchfaceLabel(PushedWatchfaceId.WFS),
bitmap = image,
contentDescription = uiState.watchfaceName,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AapsSpacing.extraLarge),
Expand All @@ -338,10 +374,12 @@ internal fun WearMainContent(
private fun pushedWatchfaceLabel(face: String): String =
stringResource(if (face == PushedWatchfaceId.CWF) KeysStrings.wear_pushed_watchface_cwf else KeysStrings.wear_pushed_watchface_wfs)

/** One radio row; [hint] is a quiet note after the label, for a state that will pass by itself */
@Composable
private fun WatchfaceChoiceRow(
label: String,
selected: Boolean,
hint: String?,
onSelect: () -> Unit
) {
Row(
Expand All @@ -357,6 +395,14 @@ private fun WatchfaceChoiceRow(
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = AapsSpacing.medium)
)
if (hint != null) {
Text(
text = hint,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = AapsSpacing.small)
)
}
}
}

Expand Down
Loading
Loading