diff --git a/README.md b/README.md index 2632e35e..76279a32 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,13 @@ The cross-platform Pebble mobile app is located in `composeApp`. Several features (e.g. bug reporting, google login, memfault, online transcription, github developer connection) will not work without tokens configured in `gradle.properties` (but all core features do work). +### Development build options + +* `fakeWatchEnabled=true` in `gradle.properties` or `local.properties` enables simulated watches in debug settings. + * When enabled, a "Add fake watch" option appears under Settings → Debug. + * The app will use a fake `LibPebble` implementation instead of a real Bluetooth connection when at least one fake watch is configured. + * This is intended for development and testing without a physical watch. + ### Android * Compile on Android with `./gradlew :composeApp:assembleRelease`. * You will need a `google-services.json` in `composeApp/src` to compile on Android (an examples with dummy values is provided in `google-services-dummy.json`). diff --git a/gradle.properties b/gradle.properties index be52985b..b4afb60e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -30,4 +30,5 @@ googleClientId= googleAuthEnabled=true appleAuthEnabled=true githubAuthEnabled=true -cactusProKey= \ No newline at end of file +cactusProKey= +fakeWatchEnabled=false \ No newline at end of file diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/FakeLibPebble.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/FakeLibPebble.kt index e44566c2..641b6679 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/FakeLibPebble.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/FakeLibPebble.kt @@ -928,3 +928,181 @@ fun fakeLockerEntry(): LockerWrapper { sync = true, ) } + +/** + * [FakeLibPebble] configured for a deterministic set of fake watches. + * This is used in development builds to power the app without a real Bluetooth connection. + */ +class ConfiguredFakeLibPebble( + fakeWatches: List, + activeWatch: WatchHardwarePlatform = fakeWatches.firstOrNull() ?: WatchHardwarePlatform.CORE_ASTERIX, +) : LibPebble { + private val delegate = FakeLibPebble() + override fun init() = delegate.init() + override val watches: PebbleDevices = MutableStateFlow( + fakeWatches.mapIndexed { index, hw -> configuredFakeWatch(connected = hw == activeWatch, watchType = hw, index = index) } + ) + override val connectionEvents: Flow = MutableSharedFlow() + override fun watchesDebugState(): String = "" + override val config: StateFlow = MutableStateFlow(LibPebbleConfig()) + override fun updateConfig(config: LibPebbleConfig) = delegate.updateConfig(config) + override suspend fun sendNotification(notification: TimelineNotification, actionHandlers: Map?) = delegate.sendNotification(notification, actionHandlers) + override suspend fun markNotificationRead(itemId: Uuid) = delegate.markNotificationRead(itemId) + override suspend fun sendPing(cookie: UInt) = delegate.sendPing(cookie) + override suspend fun launchApp(uuid: Uuid) = delegate.launchApp(uuid) + override suspend fun stopApp(uuid: Uuid) = delegate.stopApp(uuid) + override fun doStuffAfterPermissionsGranted() = delegate.doStuffAfterPermissionsGranted() + override fun checkForFirmwareUpdates() = delegate.checkForFirmwareUpdates() + override suspend fun updateTimeIfNeeded() = delegate.updateTimeIfNeeded() + override val bluetoothEnabled: StateFlow = MutableStateFlow(BluetoothState.Enabled) + override val isScanningBle: StateFlow = MutableStateFlow(false) + override val isScanningClassic: StateFlow = MutableStateFlow(false) + override fun startBleScan() = delegate.startBleScan() + override fun stopBleScan() = delegate.stopBleScan() + override fun startClassicScan() = delegate.startClassicScan() + override fun stopClassicScan() = delegate.stopClassicScan() + override fun requestLockerSync(): Deferred = delegate.requestLockerSync() + override suspend fun sideloadApp(pbwPath: Path): Boolean = delegate.sideloadApp(pbwPath) + override fun getAllLockerBasicInfo(): Flow> = delegate.getAllLockerBasicInfo() + override fun getAllLockerUuids(): Flow> = delegate.getAllLockerUuids() + override fun getLocker(type: AppType, searchQuery: String?, limit: Int): Flow> = delegate.getLocker(type, searchQuery, limit) + override fun getLockerApp(id: Uuid): Flow = delegate.getLockerApp(id) + override suspend fun setAppOrder(id: Uuid, order: Int) = delegate.setAppOrder(id, order) + override suspend fun waitUntilAppSyncedToWatch(id: Uuid, identifier: PebbleIdentifier, timeout: Duration): Boolean = delegate.waitUntilAppSyncedToWatch(id, identifier, timeout) + override suspend fun removeApp(id: Uuid): Boolean = delegate.removeApp(id) + override suspend fun addAppToLocker(app: LockerEntry) = delegate.addAppToLocker(app) + override suspend fun addAppsToLocker(apps: List) = delegate.addAppsToLocker(apps) + override fun restoreSystemAppOrder() = delegate.restoreSystemAppOrder() + override val activeWatchface: StateFlow = MutableStateFlow(null) + override fun notificationApps(): Flow> = delegate.notificationApps() + override fun notificationAppChannelCounts(packageName: String): Flow> = delegate.notificationAppChannelCounts(packageName) + override fun mostRecentNotificationsFor(pkg: String?, channelId: String?, contactId: String?, limit: Int): Flow> = delegate.mostRecentNotificationsFor(pkg, channelId, contactId, limit) + override fun mostRecentNotificationParticipants(limit: Int): Flow> = delegate.mostRecentNotificationParticipants(limit) + override fun updateNotificationAppMuteState(packageName: String?, muteState: MuteState) = delegate.updateNotificationAppMuteState(packageName, muteState) + override fun updateNotificationAppState(packageName: String, vibePatternName: String?, colorName: String?, iconName: String?) = delegate.updateNotificationAppState(packageName, vibePatternName, colorName, iconName) + override fun notificationRulesForApp(packageName: String): Flow> = delegate.notificationRulesForApp(packageName) + override fun upsertNotificationRule(rule: NotificationRuleEntity) = delegate.upsertNotificationRule(rule) + override fun deleteNotificationRule(rule: NotificationRuleEntity) = delegate.deleteNotificationRule(rule) + override fun updateNotificationChannelMuteState(packageName: String, channelId: String, muteState: MuteState) = delegate.updateNotificationChannelMuteState(packageName, channelId, muteState) + override fun updateNotificationAppAllowDuplicates(packageName: String, allowDuplicates: Boolean) = delegate.updateNotificationAppAllowDuplicates(packageName, allowDuplicates) + override suspend fun getAppIcon(packageName: String): ImageBitmap? = delegate.getAppIcon(packageName) + override val currentCall: MutableStateFlow = MutableStateFlow(null) + override fun calendars(): Flow> = delegate.calendars() + override fun updateCalendarEnabled(calendarId: Int, enabled: Boolean) = delegate.updateCalendarEnabled(calendarId, enabled) + override fun otherPebbleCompanionAppsInstalled(): StateFlow> = delegate.otherPebbleCompanionAppsInstalled() + override suspend fun getAccountToken(appUuid: Uuid): String? = delegate.getAccountToken(appUuid) + override val userFacingErrors: Flow = delegate.userFacingErrors + override fun getContactsWithCounts(searchTerm: String, onlyNotified: Boolean): PagingSource = delegate.getContactsWithCounts(searchTerm, onlyNotified) + override fun getContact(id: String): Flow = delegate.getContact(id) + override fun updateContactState(contactId: String, muteState: MuteState, vibePatternName: String?) = delegate.updateContactState(contactId, muteState, vibePatternName) + override suspend fun getContactImage(lookupKey: String): ImageBitmap? = delegate.getContactImage(lookupKey) + override val analyticsEvents: Flow = delegate.analyticsEvents + override val healthSettings: Flow = delegate.healthSettings + override fun updateHealthSettings(healthSettings: HealthSettings) = delegate.updateHealthSettings(healthSettings) + override suspend fun getHealthDebugStats(): HealthDebugStats = delegate.getHealthDebugStats() + override fun requestHealthData(fullSync: Boolean) = delegate.requestHealthData(fullSync) + override fun sendHealthAveragesToWatch() = delegate.sendHealthAveragesToWatch() + override val healthDataUpdated: SharedFlow = MutableStateFlow(Unit) + override suspend fun getCurrentPosition(maximumAge: Duration?, timeout: Duration?, highAccuracy: Boolean): GeolocationPositionResult = delegate.getCurrentPosition(maximumAge, timeout, highAccuracy) + override suspend fun watchPosition(interval: Duration, highAccuracy: Boolean): Flow = delegate.watchPosition(interval, highAccuracy) + override fun insertOrReplace(pin: TimelinePin) = delegate.insertOrReplace(pin) + override fun delete(pinUuid: Uuid) = delegate.delete(pinUuid) + override fun vibePatterns(): Flow> = delegate.vibePatterns() + override fun addCustomVibePattern(name: String, pattern: List) = delegate.addCustomVibePattern(name, pattern) + override fun deleteCustomPattern(name: String) = delegate.deleteCustomPattern(name) + override val watchPrefs: Flow>> = delegate.watchPrefs + override fun setWatchPref(watchPref: WatchPreference<*>) = delegate.setWatchPref(watchPref) + override fun updateWeatherData(weatherData: List) = delegate.updateWeatherData(weatherData) + override suspend fun getLatestTimestamp(): Long? = delegate.getLatestTimestamp() + override suspend fun getHealthDataAfter(afterTimestamp: Long): List = delegate.getHealthDataAfter(afterTimestamp) + override suspend fun getOverlayEntriesAfter(afterTimestamp: Long, types: List): List = delegate.getOverlayEntriesAfter(afterTimestamp, types) + override suspend fun getHealthDataForRange(start: Long, end: Long) = delegate.getHealthDataForRange(start, end) + override suspend fun getDailyAggregates(start: Long, end: Long) = delegate.getDailyAggregates(start, end) + override suspend fun getTotalHealthData(start: Long, end: Long): HealthAggregates? = delegate.getTotalHealthData(start, end) + override suspend fun getAverageHeartRate(start: Long, end: Long): Double? = delegate.getAverageHeartRate(start, end) + override suspend fun getSleepEntries(start: Long, end: Long) = delegate.getSleepEntries(start, end) + override suspend fun getDailySleepSession(dayStartEpochSec: Long): DailySleep? = delegate.getDailySleepSession(dayStartEpochSec) + override suspend fun getLatestHeartRateReading(): LatestHeartRate? = delegate.getLatestHeartRateReading() + override suspend fun getRestingHeartRate(dayStartEpochSec: Long): Int? = delegate.getRestingHeartRate(dayStartEpochSec) + override suspend fun getHRZoneMinutes(start: Long, end: Long) = delegate.getHRZoneMinutes(start, end) + override suspend fun getActivitySessions(start: Long, end: Long) = delegate.getActivitySessions(start, end) + override suspend fun getTypicalSteps(dayOfWeek: Int) = delegate.getTypicalSteps(dayOfWeek) + override suspend fun getTypicalSleepSeconds() = delegate.getTypicalSleepSeconds() + override suspend fun populateDebugHealthData() = delegate.populateDebugHealthData() +} + +fun configuredFakeWatch( + connected: Boolean = true, + watchType: WatchHardwarePlatform = WatchHardwarePlatform.CORE_ASTERIX, + index: Int = 0, +): PebbleDevice { + val name = fakeWatchDisplayName(watchType) + val fakeIdentifier = "AA:BB:CC:DD:EE:${index.toString(16).padStart(2, '0').uppercase()}".asPebbleBleIdentifier() + return if (connected) { + FakeConnectedDevice( + identifier = fakeIdentifier, + firmwareUpdateAvailable = FirmwareUpdateCheckState(false, null), + firmwareUpdateState = FirmwareUpdater.FirmwareUpdateStatus.NotInProgress.Idle(), + name = name, + nickname = null, + connectionFailureInfo = null, + watchType = watchType, + capabilities = setOf(ProtocolCapsFlag.SupportsBlobDbVersion), + ) + } else { + object : DiscoveredPebbleDevice { + override val identifier = fakeIdentifier + override val name: String = name + override val nickname: String? = null + override val connectionFailureInfo: ConnectionFailureInfo? = null + + override fun connect() { + } + } + } +} + +fun fakeWatchDisplayName(watchType: WatchHardwarePlatform): String = when (watchType) { + WatchHardwarePlatform.UNKNOWN -> "Unknown (Basalt)" + WatchHardwarePlatform.PEBBLE_ONE_EV_1 -> "Pebble One EV1 (Aplite)" + WatchHardwarePlatform.PEBBLE_ONE_EV_2 -> "Pebble One EV2 (Aplite)" + WatchHardwarePlatform.PEBBLE_ONE_EV_2_3 -> "Pebble One EV2.3 (Aplite)" + WatchHardwarePlatform.PEBBLE_ONE_EV_2_4 -> "Pebble One EV2.4 (Aplite)" + WatchHardwarePlatform.PEBBLE_ONE_POINT_FIVE -> "Pebble One V1.5 (Aplite)" + WatchHardwarePlatform.PEBBLE_TWO_POINT_ZERO -> "Pebble Two V2.0 (Aplite)" + WatchHardwarePlatform.PEBBLE_SNOWY_EVT_2 -> "Pebble Time EVT2 (Basalt)" + WatchHardwarePlatform.PEBBLE_SNOWY_DVT -> "Pebble Time DVT (Basalt)" + WatchHardwarePlatform.PEBBLE_BOBBY_SMILES -> "Pebble Time (Basalt)" + WatchHardwarePlatform.PEBBLE_ONE_BIGBOARD_2 -> "Pebble One Bigboard 2 (Aplite)" + WatchHardwarePlatform.PEBBLE_ONE_BIGBOARD -> "Pebble One Bigboard (Aplite)" + WatchHardwarePlatform.PEBBLE_SNOWY_BIGBOARD -> "Pebble Time Bigboard (Basalt)" + WatchHardwarePlatform.PEBBLE_SNOWY_BIGBOARD_2 -> "Pebble Time Bigboard 2 (Basalt)" + WatchHardwarePlatform.PEBBLE_SPALDING_EVT -> "Pebble Time Round EVT (Chalk)" + WatchHardwarePlatform.PEBBLE_SPALDING_PVT -> "Pebble Time Round (Chalk)" + WatchHardwarePlatform.PEBBLE_SPALDING_BIGBOARD -> "Pebble Time Round Bigboard (Chalk)" + WatchHardwarePlatform.PEBBLE_SILK_EVT -> "Pebble 2 EVT (Diorite)" + WatchHardwarePlatform.PEBBLE_SILK -> "Pebble 2 (Diorite)" + WatchHardwarePlatform.CORE_ASTERIX -> "Core Asterix (Flint)" + WatchHardwarePlatform.CORE_OBELIX_EVT -> "Core Obelix EVT (Emery)" + WatchHardwarePlatform.CORE_OBELIX_DVT -> "Core Obelix DVT (Emery)" + WatchHardwarePlatform.CORE_OBELIX_PVT -> "Core Obelix (Emery)" + WatchHardwarePlatform.CORE_GETAFIX_EVT -> "Core Getafix EVT (Gabbro)" + WatchHardwarePlatform.CORE_GETAFIX_DVT -> "Core Getafix DVT (Gabbro)" + WatchHardwarePlatform.CORE_GETAFIX_DVT2 -> "Core Getafix DVT2 (Gabbro)" + WatchHardwarePlatform.PEBBLE_SILK_BIGBOARD -> "Pebble 2 Bigboard (Diorite)" + WatchHardwarePlatform.PEBBLE_SILK_BIGBOARD_2_PLUS -> "Pebble 2 Bigboard 2+ (Diorite)" + WatchHardwarePlatform.PEBBLE_ROBERT_EVT -> "Pebble 2+ EVT (Emery)" + WatchHardwarePlatform.PEBBLE_ROBERT_BIGBOARD -> "Pebble 2+ Bigboard (Emery)" + WatchHardwarePlatform.PEBBLE_ROBERT_BIGBOARD_2 -> "Pebble 2+ Bigboard 2 (Emery)" + WatchHardwarePlatform.CORE_OBELIX_BIGBOARD -> "Core Obelix Bigboard (Emery)" + WatchHardwarePlatform.CORE_OBELIX_BIGBOARD_2 -> "Core Obelix Bigboard 2 (Emery)" +} + +fun isConsumerWatchPlatform(watchType: WatchHardwarePlatform): Boolean = when (watchType) { + WatchHardwarePlatform.PEBBLE_BOBBY_SMILES, + WatchHardwarePlatform.PEBBLE_SPALDING_PVT, + WatchHardwarePlatform.PEBBLE_SILK, + WatchHardwarePlatform.CORE_ASTERIX, + WatchHardwarePlatform.CORE_OBELIX_PVT -> true + else -> false +} diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/metadata/WatchHardwarePlatform.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/metadata/WatchHardwarePlatform.kt index f55123da..b1fad7d0 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/metadata/WatchHardwarePlatform.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/metadata/WatchHardwarePlatform.kt @@ -100,4 +100,4 @@ class WatchHardwarePlatformSerializer { encoder.encodeString(revision) } -} \ No newline at end of file +} diff --git a/pebble/src/commonMain/kotlin/coredevices/pebble/fake/FakeWatchConfigStore.kt b/pebble/src/commonMain/kotlin/coredevices/pebble/fake/FakeWatchConfigStore.kt new file mode 100644 index 00000000..77b097b0 --- /dev/null +++ b/pebble/src/commonMain/kotlin/coredevices/pebble/fake/FakeWatchConfigStore.kt @@ -0,0 +1,70 @@ +package coredevices.pebble.fake + +import com.russhwolf.settings.Settings +import com.russhwolf.settings.get +import com.russhwolf.settings.set +import io.rebble.libpebblecommon.metadata.WatchHardwarePlatform +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +private const val KEY_FAKE_WATCHES = "fake_watch.config.watches" +private const val KEY_FAKE_ACTIVE_WATCH = "fake_watch.config.active" + +class FakeWatchConfigStore(private val settings: Settings) { + private val _fakeWatches = MutableStateFlow(loadFakeWatches()) + val fakeWatches: StateFlow> = _fakeWatches.asStateFlow() + + private val _activeFakeWatch = MutableStateFlow(loadActiveFakeWatch()) + val activeFakeWatch: StateFlow = _activeFakeWatch.asStateFlow() + + fun getFakeWatches(): Set = fakeWatches.value + fun getActiveFakeWatch(): WatchHardwarePlatform? = activeFakeWatch.value + + fun setFakeWatches(watches: Set) { + settings[KEY_FAKE_WATCHES] = watches.joinToString(",") { it.revision } + _fakeWatches.value = watches + } + + fun setActiveFakeWatch(watch: WatchHardwarePlatform?) { + if (watch == null) { + settings.remove(KEY_FAKE_ACTIVE_WATCH) + } else { + settings[KEY_FAKE_ACTIVE_WATCH] = watch.revision + } + _activeFakeWatch.value = watch + } + + fun addFakeWatches(watches: Set) { + if (watches.isEmpty()) return + val updated = _fakeWatches.value + watches + setFakeWatches(updated) + if (_activeFakeWatch.value == null) { + setActiveFakeWatch(watches.first()) + } + } + + fun removeFakeWatch(watch: WatchHardwarePlatform) { + val updated = _fakeWatches.value - watch + setFakeWatches(updated) + if (_activeFakeWatch.value == watch) { + setActiveFakeWatch(updated.firstOrNull()) + } + } + + private fun loadFakeWatches(): Set { + val revisions = settings.getStringOrNull(KEY_FAKE_WATCHES) + ?.split(",") + ?.filter { it.isNotEmpty() } + ?: return emptySet() + return revisions.mapNotNull { revision -> + WatchHardwarePlatform.entries.firstOrNull { it.revision == revision } + }.toSet() + } + + private fun loadActiveFakeWatch(): WatchHardwarePlatform? { + val revision = settings.getStringOrNull(KEY_FAKE_ACTIVE_WATCH) ?: return null + return WatchHardwarePlatform.entries.firstOrNull { it.revision == revision } + } +} diff --git a/pebble/src/commonMain/kotlin/coredevices/pebble/fake/FakeWatchPickerDialog.kt b/pebble/src/commonMain/kotlin/coredevices/pebble/fake/FakeWatchPickerDialog.kt new file mode 100644 index 00000000..070c17bc --- /dev/null +++ b/pebble/src/commonMain/kotlin/coredevices/pebble/fake/FakeWatchPickerDialog.kt @@ -0,0 +1,79 @@ +package coredevices.pebble.fake + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import coredevices.ui.M3Dialog +import io.rebble.libpebblecommon.connection.fakeWatchDisplayName +import io.rebble.libpebblecommon.connection.isConsumerWatchPlatform +import io.rebble.libpebblecommon.metadata.WatchHardwarePlatform + +@Composable +internal fun FakeWatchPickerDialog( + currentWatches: Set, + onAddWatches: (Set) -> Unit, + onDismissRequest: () -> Unit, +) { + val consumerWatchPlatforms = remember { + WatchHardwarePlatform.entries.filter { isConsumerWatchPlatform(it) } + } + val availablePlatforms = remember(currentWatches) { + consumerWatchPlatforms.filter { it !in currentWatches } + } + val selected = remember(currentWatches) { mutableStateOf>(emptySet()) } + + M3Dialog( + onDismissRequest = onDismissRequest, + title = { Text("Add Fake Watches") }, + buttons = { + TextButton(onClick = onDismissRequest) { Text("Cancel") } + TextButton( + onClick = { onAddWatches(selected.value) }, + enabled = selected.value.isNotEmpty(), + ) { Text("Add") } + }, + ) { + if (availablePlatforms.isEmpty()) { + Text("All available watches have been added.") + } else { + LazyColumn(modifier = Modifier.heightIn(max = 400.dp)) { + items(availablePlatforms, key = { it.revision }) { platform -> + val isSelected = platform in selected.value + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + selected.value = selected.value.toggle(platform) + } + .padding(vertical = 4.dp, horizontal = 4.dp), + ) { + Checkbox( + checked = isSelected, + onCheckedChange = { selected.value = selected.value.toggle(platform) }, + ) + Spacer(Modifier.width(8.dp)) + Text(fakeWatchDisplayName(platform), modifier = Modifier.weight(1f)) + } + } + } + } + } +} + +private fun Set.toggle(item: T): Set = if (item in this) this - item else this + item diff --git a/pebble/src/commonMain/kotlin/coredevices/pebble/fake/FakeWatchSettingsItems.kt b/pebble/src/commonMain/kotlin/coredevices/pebble/fake/FakeWatchSettingsItems.kt new file mode 100644 index 00000000..a53d47de --- /dev/null +++ b/pebble/src/commonMain/kotlin/coredevices/pebble/fake/FakeWatchSettingsItems.kt @@ -0,0 +1,86 @@ +package coredevices.pebble.fake + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coredevices.pebble.ui.Section +import coredevices.pebble.ui.SettingsItem +import coredevices.pebble.ui.TopLevelType +import coredevices.pebble.ui.basicSettingsActionItem +import io.rebble.libpebblecommon.connection.fakeWatchDisplayName +import io.rebble.libpebblecommon.metadata.WatchHardwarePlatform + +internal fun fakeWatchItems( + fakeWatchConfig: FakeWatchConfigStore, + fakeWatches: Set, + activeFakeWatch: WatchHardwarePlatform?, + showAddDialog: MutableState, +): List { + val addItem = basicSettingsActionItem( + title = "Add fake watch (requires restart)", + description = "Add a simulated watch for testing. Does not require a real Bluetooth connection.", + topLevelType = TopLevelType.Phone, + section = Section.Debug, + action = { showAddDialog.value = true }, + isDebugSetting = true, + keywords = "fake watch debug", + ) + return buildList { + add(addItem) + fakeWatches.forEach { watch -> + val isActive = watch == activeFakeWatch + val displayName = fakeWatchDisplayName(watch) + add( + SettingsItem( + title = displayName, + topLevelType = TopLevelType.Phone, + section = Section.Debug, + isDebugSetting = true, + item = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .weight(1f) + .clickable { + fakeWatchConfig.setActiveFakeWatch(watch) + } + .padding(vertical = 8.dp, horizontal = 4.dp), + ) { + RadioButton( + selected = isActive, + onClick = { + fakeWatchConfig.setActiveFakeWatch(watch) + }, + ) + Column { + Text(displayName) + Text( + text = if (isActive) "Active (connected)" else "Tap to set as active", + fontSize = 11.sp, + ) + } + } + TextButton(onClick = { fakeWatchConfig.removeFakeWatch(watch) }) { + Text("Remove") + } + } + }, + ) + ) + } + } +} diff --git a/pebble/src/commonMain/kotlin/coredevices/pebble/ui/WatchSettingsScreen.kt b/pebble/src/commonMain/kotlin/coredevices/pebble/ui/WatchSettingsScreen.kt index eeb6220f..25f00415 100644 --- a/pebble/src/commonMain/kotlin/coredevices/pebble/ui/WatchSettingsScreen.kt +++ b/pebble/src/commonMain/kotlin/coredevices/pebble/ui/WatchSettingsScreen.kt @@ -81,6 +81,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember @@ -128,10 +129,14 @@ import coredevices.pebble.ui.SettingsKeys.KEY_ENABLE_FIREBASE_UPLOADS import coredevices.pebble.ui.SettingsKeys.KEY_ENABLE_MEMFAULT_UPLOADS import coredevices.pebble.ui.SettingsKeys.KEY_ENABLE_MIXPANEL_UPLOADS import coredevices.pebble.weather.WeatherFetcher +import coredevices.pebble.fake.FakeWatchConfigStore +import coredevices.pebble.fake.FakeWatchPickerDialog +import coredevices.pebble.fake.fakeWatchItems import coredevices.ui.ConfirmDialog import coredevices.ui.CoreLinearProgressIndicator import coredevices.ui.M3Dialog import coredevices.ui.SignInDialog +import coredevices.util.CommonBuildKonfig import coredevices.util.CoreConfig import coredevices.util.CoreConfigHolder import coredevices.util.PermissionRequester @@ -154,6 +159,7 @@ import io.rebble.libpebblecommon.connection.KnownPebbleDevice import io.rebble.libpebblecommon.database.entity.HRMonitoringInterval import io.rebble.libpebblecommon.database.entity.HealthGender import io.rebble.libpebblecommon.js.PKJSApp +import io.rebble.libpebblecommon.metadata.WatchHardwarePlatform import io.rebble.libpebblecommon.metadata.WatchType import io.rebble.libpebblecommon.packets.ProtocolCapsFlag import kotlinx.coroutines.Dispatchers @@ -324,6 +330,9 @@ fun rememberSettingsItemsState(navBarNav: NavBarNav?, snackbarDisplay: SnackbarD val libPebbleConfig by libPebble.config.collectAsState() val coreConfigHolder: CoreConfigHolder = koinInject() val coreConfig by coreConfigHolder.config.collectAsState() + val fakeWatchConfig: FakeWatchConfigStore = koinInject() + val fakeWatches by fakeWatchConfig.fakeWatches.collectAsState() + val activeFakeWatch by fakeWatchConfig.activeFakeWatch.collectAsState() val themeProvider: ThemeProvider = koinInject() val settings: Settings = koinInject() val currentTheme by themeProvider.theme.collectAsState() @@ -353,6 +362,7 @@ fun rememberSettingsItemsState(navBarNav: NavBarNav?, snackbarDisplay: SnackbarD var debugOptionsEnabled by remember { mutableStateOf(settings.showDebugOptions()) } var pendingSTTModeDialog by remember { mutableStateOf(null) } var showSpokenLanguageDialog by remember { mutableStateOf(false) } + val showAddFakeWatchDialog = remember { mutableStateOf(false) } val recommendedSTTModel = modelManager.getRecommendedSTTModel() val modelDownloadState by modelManager.modelDownloadStatus.collectAsState() if (showSpokenLanguageDialog) { @@ -496,7 +506,8 @@ fun rememberSettingsItemsState(navBarNav: NavBarNav?, snackbarDisplay: SnackbarD experimentalDevices, loggedIn, watchPrefs, - rebbleVoiceAvailable, + fakeWatches, + activeFakeWatch, ) { listOfNotNull( basicSettingsActionItem( @@ -1787,7 +1798,22 @@ fun rememberSettingsItemsState(navBarNav: NavBarNav?, snackbarDisplay: SnackbarD coreConfigHolder.update(coreConfig.copy(interceptPKJSWeather = it)) }, ), - ) + watchPrefs + ) + if (CommonBuildKonfig.FAKE_WATCH_ENABLED) { + fakeWatchItems(fakeWatchConfig, fakeWatches, activeFakeWatch, showAddFakeWatchDialog) + } else { + emptyList() + } + watchPrefs + } + + if (showAddFakeWatchDialog.value) { + FakeWatchPickerDialog( + currentWatches = fakeWatches, + onAddWatches = { selected -> + fakeWatchConfig.addFakeWatches(selected) + showAddFakeWatchDialog.value = false + }, + onDismissRequest = { showAddFakeWatchDialog.value = false }, + ) } return SettingsItemsState( diff --git a/pebble/src/commonMain/kotlin/coredevices/pebble/watchModule.kt b/pebble/src/commonMain/kotlin/coredevices/pebble/watchModule.kt index ef948ab4..a767d81f 100644 --- a/pebble/src/commonMain/kotlin/coredevices/pebble/watchModule.kt +++ b/pebble/src/commonMain/kotlin/coredevices/pebble/watchModule.kt @@ -69,6 +69,8 @@ import io.rebble.libpebblecommon.LibPebbleConfig import io.rebble.libpebblecommon.NotificationConfig import io.rebble.libpebblecommon.WatchConfig import io.rebble.libpebblecommon.connection.ConnectedPebbleDevice +import io.rebble.libpebblecommon.connection.ConfiguredFakeLibPebble +import io.rebble.libpebblecommon.connection.FakeLibPebble import io.rebble.libpebblecommon.connection.HealthDataApi import io.rebble.libpebblecommon.connection.LibPebble import io.rebble.libpebblecommon.connection.LibPebble3 @@ -98,22 +100,42 @@ import org.koin.dsl.module import kotlin.time.Clock import kotlin.time.Duration import kotlin.uuid.Uuid +import coredevices.pebble.fake.FakeWatchConfigStore +import coredevices.util.CommonBuildKonfig +import coredevices.util.CoreConfigHolder val watchModule = module { - single { - Logger.d("watchModule get LibPebble3") - LibPebble3.create( - get(), - get(), - get(), - get(), - flow { emitAll(Firebase.auth.idTokenChanged) } - .map { try { it?.getIdToken(false) } catch (e: Exception) { Logger.w(e) { "Failed to get ID token" }; null } } - .stateIn(GlobalScope, started = SharingStarted.Lazily, initialValue = null), - get(), - get(), - ) - } binds arrayOf(LibPebble3::class, NotificationApps::class, SystemGeolocation::class) + single { + val fakeWatchConfig = get() + val fakeWatches = fakeWatchConfig.getFakeWatches() + if (CommonBuildKonfig.FAKE_WATCH_ENABLED && fakeWatches.isNotEmpty()) { + val active = fakeWatchConfig.getActiveFakeWatch() ?: fakeWatches.first() + Logger.d { "watchModule using FakeLibPebble with ${fakeWatches.size} watches, active: $active" } + ConfiguredFakeLibPebble( + fakeWatches = fakeWatches.toList(), + activeWatch = active, + ) + } else { + Logger.d("watchModule get LibPebble3") + LibPebble3.create( + get(), + get(), + get(), + get(), + flow { emitAll(Firebase.auth.idTokenChanged) } + .map { try { it?.getIdToken(false) } catch (e: Exception) { Logger.w(e) { "Failed to get ID token" }; null } } + .stateIn(GlobalScope, started = SharingStarted.Lazily, initialValue = null), + get(), + get(), + ) + } + } binds arrayOf(LibPebble::class, NotificationApps::class, SystemGeolocation::class) + single { + val libPebble = get() + libPebble as? LibPebble3 + ?: error("LibPebble3 is not available while fake watches are enabled (got ${libPebble::class.simpleName}); request LibPebble, NotificationApps, or SystemGeolocation instead") + } + singleOf(::FakeWatchConfigStore) includes(platformWatchModule) @@ -218,7 +240,7 @@ val watchModule = module { single { CactusTranscription( get(), - lazy { get() }, + lazy { get() }, get(), ) } diff --git a/util/build.gradle.kts b/util/build.gradle.kts index d8c73b68..eb64e90c 100644 --- a/util/build.gradle.kts +++ b/util/build.gradle.kts @@ -190,6 +190,7 @@ val headSha by lazy { }.standardOutput.asText.get().trim() } val enableQa = System.getenv("QA")?.toBoolean() ?: properties.getProperty("QA")?.toBoolean() ?: true +val fakeWatchEnabled = gradleBooleanProp("fakeWatchEnabled", default = false) fun gradleStringPropOrNull(name: String): String? { val local = properties.getProperty(name)?.takeIf { it.isNotEmpty() } @@ -224,5 +225,6 @@ buildkonfig { buildConfigField(FieldSpec.Type.STRING, "CACTUS_LM_MODEL_NAME", "needle-pebble-ft") buildConfigField(FieldSpec.Type.STRING, "CACTUS_STT_WEIGHTS_VERSION", "v1.10") buildConfigField(FieldSpec.Type.STRING, "CACTUS_LM_WEIGHTS_VERSION", "v1.15") + buildConfigField(FieldSpec.Type.BOOLEAN, "FAKE_WATCH_ENABLED", fakeWatchEnabled.toString()) } } diff --git a/util/src/commonMain/kotlin/coredevices/util/CoreConfig.kt b/util/src/commonMain/kotlin/coredevices/util/CoreConfig.kt index a4efc47a..20b5c4c7 100644 --- a/util/src/commonMain/kotlin/coredevices/util/CoreConfig.kt +++ b/util/src/commonMain/kotlin/coredevices/util/CoreConfig.kt @@ -58,6 +58,10 @@ class CoreConfigHolder( _config.value = value } + fun update(transform: (CoreConfig) -> CoreConfig) { + update(transform(config.value)) + } + private val _config: MutableStateFlow = MutableStateFlow(defaultValue()) val config: StateFlow = _config.asStateFlow() }