From 3d117cc23036f421cf7c22898c8fc7ce72fb7e8a Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 6 Dec 2025 10:29:32 -0500 Subject: [PATCH 1/9] feat: Add Override Display Model button for BYOD mode - Add OutlinedButton 'Override Display Model' below BYOD master device checkbox - Button navigates to DeviceModelSelectorScreen for device model selection - Add Timber logging when navigation occurs - Add OverrideDisplayModelPressed event to AppSettingsScreen.Event - Update DeviceTypeSelectorConfig to include onOverrideDisplayModelPressed callback - Change BYOD layout from Row to Column to accommodate button - Add TODO comment about Circuit 0.31.0 upgrade for full result capture Note: Result handling limited in Circuit 0.27.1. DeviceModelSelectorScreen pops with Result but capturing requires backstack access. Full implementation needs Circuit 0.31.0 upgrade (requires Compose Multiplatform migration). --- .../android/ui/settings/AppSettingsScreen.kt | 73 +++++++++++++------ 1 file changed, 52 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt index a9d5b72..882ec75 100644 --- a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt +++ b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedButton @@ -95,6 +96,7 @@ import ink.trmnl.android.di.AppScope import ink.trmnl.android.model.TrmnlDeviceConfig import ink.trmnl.android.model.TrmnlDeviceType import ink.trmnl.android.ui.aboutapp.AppInfoScreen +import ink.trmnl.android.ui.devicemodel.DeviceModelSelectorScreen import ink.trmnl.android.ui.display.TrmnlMirrorDisplayScreen import ink.trmnl.android.ui.settings.AppSettingsScreen.ValidationResult import ink.trmnl.android.ui.settings.AppSettingsScreen.ValidationResult.Failure @@ -117,6 +119,7 @@ import ink.trmnl.android.work.TrmnlWorkScheduler import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize +import timber.log.Timber import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -233,6 +236,11 @@ data class AppSettingsScreen( data class SetupDevice( val deviceMacId: String, ) : Event() + + /** + * Event triggered when the override display model button is clicked. + */ + data object OverrideDisplayModelPressed : Event() } } @@ -469,6 +477,16 @@ class AppSettingsPresenter navigator.goTo(AppInfoScreen) } + AppSettingsScreen.Event.OverrideDisplayModelPressed -> { + // Navigate to DeviceModelSelectorScreen + // TODO: Result handling - In Circuit 0.27.1, capturing PopResult in the calling screen + // requires accessing backstack state. For full result handling, upgrade to Circuit 0.31.0 + // which provides rememberAnsweringNavigator API. For now, the DeviceModelSelectorScreen + // will pop with a result, but we don't capture it here. + Timber.d("Navigating to DeviceModelSelectorScreen...") + navigator.goTo(DeviceModelSelectorScreen) + } + is AppSettingsScreen.Event.SetupDevice -> { isDeviceSetupLoading = true deviceSetupMessage = null @@ -638,6 +656,7 @@ fun AppSettingsContent( onServerUrlChanged = { state.eventSink(AppSettingsScreen.Event.ServerUrlChanged(it)) }, onDeviceIdChanged = { state.eventSink(AppSettingsScreen.Event.DeviceMacIdChanged(it)) }, onByodMasterDeviceChanged = { state.eventSink(AppSettingsScreen.Event.ByodMasterDeviceChanged(it)) }, + onOverrideDisplayModelPressed = { state.eventSink(AppSettingsScreen.Event.OverrideDisplayModelPressed) }, isServerUrlError = state.validationResult is InvalidServerUrl, serverUrlError = (state.validationResult as? InvalidServerUrl)?.message, isDeviceMacIdError = state.validationResult is ValidationResult.InvalidDeviceMacId, @@ -854,6 +873,7 @@ private fun DeviceTypeSelectorConfig( onServerUrlChanged: (String) -> Unit, onDeviceIdChanged: (String) -> Unit, onByodMasterDeviceChanged: (Boolean) -> Unit = {}, + onOverrideDisplayModelPressed: () -> Unit = {}, isServerUrlError: Boolean = false, serverUrlError: String? = null, isDeviceMacIdError: Boolean = false, @@ -968,28 +988,39 @@ private fun DeviceTypeSelectorConfig( enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut(), ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(top = 16.dp, bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Checkbox( - checked = isByodMasterDevice, - onCheckedChange = { onByodMasterDeviceChanged(it) }, - ) - Spacer(modifier = Modifier.width(8.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Act as master device (auto-advance playlist image)", - style = MaterialTheme.typography.bodyMedium, - ) - Text( - text = "Uncheck if this device should mirror another BYOD device that automatically auto-advances playlist image", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + Column { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 16.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = isByodMasterDevice, + onCheckedChange = { onByodMasterDeviceChanged(it) }, ) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Act as master device (auto-advance playlist image)", + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = + "Uncheck if this device should mirror another BYOD device " + + "that automatically auto-advances playlist image", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + OutlinedButton( + onClick = onOverrideDisplayModelPressed, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Override Display Model") } } } From b4136e976a27f47e7c4c075714add67823fe578d Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 6 Dec 2025 10:36:08 -0500 Subject: [PATCH 2/9] feat: Implement rememberAnsweringNavigator for device model selection - Add rememberAnsweringNavigator import from Circuit foundation - Create deviceModelNavigator to capture DeviceModelSelectorScreen results - Log selected device model details (name, width, height) using Timber - Remove TODO comment about Circuit 0.31.0 upgrade (not needed) - Use proper SupportedDeviceModel properties (width/height) This implements full result handling using Circuit 0.27.1's rememberAnsweringNavigator API, which was available all along. --- .../android/ui/settings/AppSettingsScreen.kt | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt index 882ec75..45724bb 100644 --- a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt +++ b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt @@ -77,6 +77,7 @@ import androidx.compose.ui.unit.dp import androidx.work.WorkInfo import coil3.compose.AsyncImage import com.slack.circuit.codegen.annotations.CircuitInject +import com.slack.circuit.foundation.rememberAnsweringNavigator import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.Navigator @@ -280,6 +281,15 @@ class AppSettingsPresenter } } + // Create answering navigator for DeviceModelSelectorScreen + val deviceModelNavigator = + rememberAnsweringNavigator(navigator) { result -> + Timber.d("User selected device model: ${result.selectedModel}") + Timber.d( + "Device specs - Name: ${result.selectedModel.name}, Display: ${result.selectedModel.width}x${result.selectedModel.height}px", + ) + } + // Load saved token if available LaunchedEffect(Unit) { deviceConfigStore.deviceConfigFlow.filterNotNull().collect { @@ -478,13 +488,9 @@ class AppSettingsPresenter } AppSettingsScreen.Event.OverrideDisplayModelPressed -> { - // Navigate to DeviceModelSelectorScreen - // TODO: Result handling - In Circuit 0.27.1, capturing PopResult in the calling screen - // requires accessing backstack state. For full result handling, upgrade to Circuit 0.31.0 - // which provides rememberAnsweringNavigator API. For now, the DeviceModelSelectorScreen - // will pop with a result, but we don't capture it here. + // Navigate to DeviceModelSelectorScreen using answering navigator Timber.d("Navigating to DeviceModelSelectorScreen...") - navigator.goTo(DeviceModelSelectorScreen) + deviceModelNavigator.goTo(DeviceModelSelectorScreen) } is AppSettingsScreen.Event.SetupDevice -> { From c8fb803ea0beec684a4379dc30b6ded7a190c871 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 6 Dec 2025 11:44:39 -0500 Subject: [PATCH 3/9] feat: Add device model preferences storage by device type Store user's selected device model per device type (BYOD -> model name mapping). Changes: - Add deviceModelPreferencesFlow to expose Map - Add saveDeviceModelForType() to save model selection for specific device type - Add getDeviceModelForType() to retrieve saved model for device type - Store preferences as JSON map in DataStore - Update AppSettingsScreen to save selected model from DeviceModelSelectorScreen - Add comprehensive test coverage (10 new tests) Example usage: BYOD -> "amazon_kindle_2024" BYOS -> "boox_tab_ultra_c_pro" Tests cover: - Empty state handling - Single device type storage - Multiple device types - Updates to existing preferences - Flow emissions - clearAll() cleanup --- .../data/TrmnlDeviceConfigDataStore.kt | 88 +++++++++++++ .../android/ui/settings/AppSettingsScreen.kt | 8 ++ .../data/TrmnlDeviceConfigDataStoreTest.kt | 122 ++++++++++++++++++ 3 files changed, 218 insertions(+) diff --git a/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt b/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt index 79d5136..d9143ed 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt @@ -54,6 +54,7 @@ class TrmnlDeviceConfigDataStore private val CONFIG_JSON_KEY = stringPreferencesKey("config_json") private val DEVICE_MAC_ID_KEY = stringPreferencesKey("device_mac_id") private val IS_MASTER_DEVICE_KEY = stringPreferencesKey("is_master_device") + private val DEVICE_MODEL_PREFERENCES_KEY = stringPreferencesKey("device_model_preferences") } private val deviceTypeAdapter = moshi.adapter(TrmnlDeviceType::class.java) @@ -106,6 +107,32 @@ class TrmnlDeviceConfigDataStore preferences[DEVICE_MAC_ID_KEY] } + /** + * Gets the device model preferences (map of device type to model name) as a Flow. + * Returns a map where keys are device type names (e.g., "BYOD") and values are model names (e.g., "amazon_kindle_2024"). + */ + val deviceModelPreferencesFlow: Flow> = + context.deviceConfigStore.data.map { preferences -> + val json = preferences[DEVICE_MODEL_PREFERENCES_KEY] + if (json != null) { + try { + val type = + com.squareup.moshi.Types.newParameterizedType( + Map::class.java, + String::class.java, + String::class.java, + ) + val adapter = moshi.adapter>(type) + adapter.fromJson(json) ?: emptyMap() + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to parse device model preferences") + emptyMap() + } + } else { + emptyMap() + } + } + /** * Gets the complete device config as a Flow */ @@ -231,6 +258,67 @@ class TrmnlDeviceConfigDataStore } } + /** + * Saves the selected device model for a specific device type. + * + * @param deviceType The device type (e.g., BYOD, BYOS) + * @param modelName The model name (e.g., "amazon_kindle_2024") + */ + suspend fun saveDeviceModelForType( + deviceType: TrmnlDeviceType, + modelName: String, + ) { + try { + context.deviceConfigStore.edit { preferences -> + // Get current map + val currentJson = preferences[DEVICE_MODEL_PREFERENCES_KEY] + val currentMap = + if (currentJson != null) { + try { + val type = + com.squareup.moshi.Types.newParameterizedType( + Map::class.java, + String::class.java, + String::class.java, + ) + val adapter = moshi.adapter>(type) + adapter.fromJson(currentJson)?.toMutableMap() ?: mutableMapOf() + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to parse existing device model preferences") + mutableMapOf() + } + } else { + mutableMapOf() + } + + // Update the map with new value + currentMap[deviceType.name] = modelName + + // Save back to preferences + val type = + com.squareup.moshi.Types.newParameterizedType( + Map::class.java, + String::class.java, + String::class.java, + ) + val adapter = moshi.adapter>(type) + preferences[DEVICE_MODEL_PREFERENCES_KEY] = adapter.toJson(currentMap) + + Timber.tag(TAG).d("Saved device model preference: ${deviceType.name} -> $modelName") + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to save device model preference") + } + } + + /** + * Gets the selected device model name for a specific device type. + * + * @param deviceType The device type to query + * @return The model name if set, null otherwise + */ + suspend fun getDeviceModelForType(deviceType: TrmnlDeviceType): String? = deviceModelPreferencesFlow.first()[deviceType.name] + /** * Checks if a token is already set */ diff --git a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt index 45724bb..38d7416 100644 --- a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt +++ b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt @@ -288,6 +288,14 @@ class AppSettingsPresenter Timber.d( "Device specs - Name: ${result.selectedModel.name}, Display: ${result.selectedModel.width}x${result.selectedModel.height}px", ) + // Save the selected device model for the current device type + scope.launch { + deviceConfigStore.saveDeviceModelForType( + deviceType = deviceType, + modelName = result.selectedModel.name, + ) + Timber.d("Saved device model preference: ${deviceType.name} -> ${result.selectedModel.name}") + } } // Load saved token if available diff --git a/app/src/test/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStoreTest.kt b/app/src/test/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStoreTest.kt index e404979..105f972 100644 --- a/app/src/test/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStoreTest.kt +++ b/app/src/test/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStoreTest.kt @@ -461,4 +461,126 @@ class TrmnlDeviceConfigDataStoreTest { val savedConfig = deviceConfigDataStore.deviceConfigFlow.first() assertThat(savedConfig?.isMasterDevice).isFalse() } + + @Test + fun `deviceModelPreferencesFlow returns empty map when not saved`() = + runTest { + // Act + val preferences = deviceConfigDataStore.deviceModelPreferencesFlow.first() + + // Assert + assertThat(preferences).isEmpty() + } + + @Test + fun `saveDeviceModelForType stores model name for device type`() = + runTest { + // Arrange + val deviceType = TrmnlDeviceType.BYOD + val modelName = "amazon_kindle_2024" + + // Act + deviceConfigDataStore.saveDeviceModelForType(deviceType, modelName) + + // Assert + val preferences = deviceConfigDataStore.deviceModelPreferencesFlow.first() + assertThat(preferences).containsEntry("BYOD", "amazon_kindle_2024") + } + + @Test + fun `saveDeviceModelForType updates existing model for device type`() = + runTest { + // Arrange - Save initial model + deviceConfigDataStore.saveDeviceModelForType(TrmnlDeviceType.BYOD, "amazon_kindle_2024") + + // Act - Update to different model + deviceConfigDataStore.saveDeviceModelForType(TrmnlDeviceType.BYOD, "boox_tab_ultra_c_pro") + + // Assert + val preferences = deviceConfigDataStore.deviceModelPreferencesFlow.first() + assertThat(preferences).containsEntry("BYOD", "boox_tab_ultra_c_pro") + assertThat(preferences).hasSize(1) + } + + @Test + fun `saveDeviceModelForType stores multiple device types independently`() = + runTest { + // Act - Save models for different device types + deviceConfigDataStore.saveDeviceModelForType(TrmnlDeviceType.BYOD, "amazon_kindle_2024") + deviceConfigDataStore.saveDeviceModelForType(TrmnlDeviceType.BYOS, "boox_tab_ultra_c_pro") + + // Assert + val preferences = deviceConfigDataStore.deviceModelPreferencesFlow.first() + assertThat(preferences).containsEntry("BYOD", "amazon_kindle_2024") + assertThat(preferences).containsEntry("BYOS", "boox_tab_ultra_c_pro") + assertThat(preferences).hasSize(2) + } + + @Test + fun `getDeviceModelForType returns null when no model saved`() = + runTest { + // Act + val modelName = deviceConfigDataStore.getDeviceModelForType(TrmnlDeviceType.BYOD) + + // Assert + assertThat(modelName).isNull() + } + + @Test + fun `getDeviceModelForType returns correct model name when saved`() = + runTest { + // Arrange + val deviceType = TrmnlDeviceType.BYOD + val expectedModelName = "amazon_kindle_2024" + deviceConfigDataStore.saveDeviceModelForType(deviceType, expectedModelName) + + // Act + val modelName = deviceConfigDataStore.getDeviceModelForType(deviceType) + + // Assert + assertThat(modelName).isEqualTo(expectedModelName) + } + + @Test + fun `getDeviceModelForType returns null for device type without saved model`() = + runTest { + // Arrange - Save model for BYOD only + deviceConfigDataStore.saveDeviceModelForType(TrmnlDeviceType.BYOD, "amazon_kindle_2024") + + // Act - Query for BYOS which has no saved model + val modelName = deviceConfigDataStore.getDeviceModelForType(TrmnlDeviceType.BYOS) + + // Assert + assertThat(modelName).isNull() + } + + @Test + fun `deviceModelPreferencesFlow emits updated map when model saved`() = + runTest { + // Arrange - Start with empty preferences + val initialPreferences = deviceConfigDataStore.deviceModelPreferencesFlow.first() + assertThat(initialPreferences).isEmpty() + + // Act - Save a model + deviceConfigDataStore.saveDeviceModelForType(TrmnlDeviceType.BYOD, "amazon_kindle_2024") + + // Assert - Flow emits updated map + val updatedPreferences = deviceConfigDataStore.deviceModelPreferencesFlow.first() + assertThat(updatedPreferences).containsEntry("BYOD", "amazon_kindle_2024") + } + + @Test + fun `clearAll removes device model preferences`() = + runTest { + // Arrange - Save some device model preferences + deviceConfigDataStore.saveDeviceModelForType(TrmnlDeviceType.BYOD, "amazon_kindle_2024") + deviceConfigDataStore.saveDeviceModelForType(TrmnlDeviceType.BYOS, "boox_tab_ultra_c_pro") + + // Act + deviceConfigDataStore.clearAll() + + // Assert + val preferences = deviceConfigDataStore.deviceModelPreferencesFlow.first() + assertThat(preferences).isEmpty() + } } From a87e2073151f555d17da06cfb3308f7edfecfb4b Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 6 Dec 2025 11:51:37 -0500 Subject: [PATCH 4/9] refactor: Store both model name and label in device preferences Replace simple string storage with DeviceModelSelection data class to store both model name and human-readable label. Changes: - Add DeviceModelSelection data class (name + label) - Update deviceModelPreferencesFlow to return Map - Update saveDeviceModelForType() to accept both name and label parameters - Update getDeviceModelForType() to return DeviceModelSelection - Update AppSettingsScreen to pass both name and label when saving - Update all tests to work with DeviceModelSelection - Add KotlinJsonAdapterFactory to test Moshi builder for proper serialization This allows displaying user-friendly labels like "Amazon Kindle 2024" while using technical names like "amazon_kindle_2024" internally. Example storage: BYOD -> DeviceModelSelection("amazon_kindle_2024", "Amazon Kindle 2024") --- .../data/TrmnlDeviceConfigDataStore.kt | 32 ++++--- .../android/model/DeviceModelSelection.kt | 15 ++++ .../android/ui/settings/AppSettingsScreen.kt | 5 +- .../data/TrmnlDeviceConfigDataStoreTest.kt | 89 ++++++++++++++----- 4 files changed, 103 insertions(+), 38 deletions(-) create mode 100644 app/src/main/java/ink/trmnl/android/model/DeviceModelSelection.kt diff --git a/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt b/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt index d9143ed..81fc7e9 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt @@ -14,6 +14,7 @@ import ink.trmnl.android.data.AppConfig.DEFAULT_REFRESH_INTERVAL_SEC import ink.trmnl.android.data.AppConfig.TRMNL_API_SERVER_BASE_URL import ink.trmnl.android.di.AppScope import ink.trmnl.android.di.ApplicationContext +import ink.trmnl.android.model.DeviceModelSelection import ink.trmnl.android.model.TrmnlDeviceConfig import ink.trmnl.android.model.TrmnlDeviceType import kotlinx.coroutines.flow.Flow @@ -108,10 +109,10 @@ class TrmnlDeviceConfigDataStore } /** - * Gets the device model preferences (map of device type to model name) as a Flow. - * Returns a map where keys are device type names (e.g., "BYOD") and values are model names (e.g., "amazon_kindle_2024"). + * Gets the device model preferences (map of device type to model selection) as a Flow. + * Returns a map where keys are device type names (e.g., "BYOD") and values are DeviceModelSelection objects. */ - val deviceModelPreferencesFlow: Flow> = + val deviceModelPreferencesFlow: Flow> = context.deviceConfigStore.data.map { preferences -> val json = preferences[DEVICE_MODEL_PREFERENCES_KEY] if (json != null) { @@ -120,9 +121,9 @@ class TrmnlDeviceConfigDataStore com.squareup.moshi.Types.newParameterizedType( Map::class.java, String::class.java, - String::class.java, + DeviceModelSelection::class.java, ) - val adapter = moshi.adapter>(type) + val adapter = moshi.adapter>(type) adapter.fromJson(json) ?: emptyMap() } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to parse device model preferences") @@ -263,10 +264,12 @@ class TrmnlDeviceConfigDataStore * * @param deviceType The device type (e.g., BYOD, BYOS) * @param modelName The model name (e.g., "amazon_kindle_2024") + * @param modelLabel The model label (e.g., "Amazon Kindle 2024") */ suspend fun saveDeviceModelForType( deviceType: TrmnlDeviceType, modelName: String, + modelLabel: String, ) { try { context.deviceConfigStore.edit { preferences -> @@ -279,9 +282,9 @@ class TrmnlDeviceConfigDataStore com.squareup.moshi.Types.newParameterizedType( Map::class.java, String::class.java, - String::class.java, + DeviceModelSelection::class.java, ) - val adapter = moshi.adapter>(type) + val adapter = moshi.adapter>(type) adapter.fromJson(currentJson)?.toMutableMap() ?: mutableMapOf() } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to parse existing device model preferences") @@ -292,19 +295,19 @@ class TrmnlDeviceConfigDataStore } // Update the map with new value - currentMap[deviceType.name] = modelName + currentMap[deviceType.name] = DeviceModelSelection(modelName, modelLabel) // Save back to preferences val type = com.squareup.moshi.Types.newParameterizedType( Map::class.java, String::class.java, - String::class.java, + DeviceModelSelection::class.java, ) - val adapter = moshi.adapter>(type) + val adapter = moshi.adapter>(type) preferences[DEVICE_MODEL_PREFERENCES_KEY] = adapter.toJson(currentMap) - Timber.tag(TAG).d("Saved device model preference: ${deviceType.name} -> $modelName") + Timber.tag(TAG).d("Saved device model preference: ${deviceType.name} -> $modelName ($modelLabel)") } } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to save device model preference") @@ -312,12 +315,13 @@ class TrmnlDeviceConfigDataStore } /** - * Gets the selected device model name for a specific device type. + * Gets the selected device model selection for a specific device type. * * @param deviceType The device type to query - * @return The model name if set, null otherwise + * @return The DeviceModelSelection if set, null otherwise */ - suspend fun getDeviceModelForType(deviceType: TrmnlDeviceType): String? = deviceModelPreferencesFlow.first()[deviceType.name] + suspend fun getDeviceModelForType(deviceType: TrmnlDeviceType): DeviceModelSelection? = + deviceModelPreferencesFlow.first()[deviceType.name] /** * Checks if a token is already set diff --git a/app/src/main/java/ink/trmnl/android/model/DeviceModelSelection.kt b/app/src/main/java/ink/trmnl/android/model/DeviceModelSelection.kt new file mode 100644 index 0000000..1349c2f --- /dev/null +++ b/app/src/main/java/ink/trmnl/android/model/DeviceModelSelection.kt @@ -0,0 +1,15 @@ +package ink.trmnl.android.model + +import androidx.annotation.Keep + +/** + * Represents a user's device model selection preference. + * + * @property name Unique identifier for the model (e.g., "amazon_kindle_2024") + * @property label Human-readable label for display (e.g., "Amazon Kindle 2024") + */ +@Keep +data class DeviceModelSelection( + val name: String, + val label: String, +) diff --git a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt index 38d7416..bd55190 100644 --- a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt +++ b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt @@ -293,8 +293,11 @@ class AppSettingsPresenter deviceConfigStore.saveDeviceModelForType( deviceType = deviceType, modelName = result.selectedModel.name, + modelLabel = result.selectedModel.label, + ) + Timber.d( + "Saved device model preference: ${deviceType.name} -> ${result.selectedModel.name} (${result.selectedModel.label})", ) - Timber.d("Saved device model preference: ${deviceType.name} -> ${result.selectedModel.name}") } } diff --git a/app/src/test/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStoreTest.kt b/app/src/test/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStoreTest.kt index 105f972..672c99f 100644 --- a/app/src/test/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStoreTest.kt +++ b/app/src/test/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStoreTest.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import ink.trmnl.android.model.TrmnlDeviceConfig import ink.trmnl.android.model.TrmnlDeviceType import kotlinx.coroutines.flow.first @@ -25,7 +26,7 @@ class TrmnlDeviceConfigDataStoreTest { @Before fun setUp() { context = ApplicationProvider.getApplicationContext() - moshi = Moshi.Builder().build() + moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() deviceConfigDataStore = TrmnlDeviceConfigDataStore(context, moshi) } @@ -478,27 +479,39 @@ class TrmnlDeviceConfigDataStoreTest { // Arrange val deviceType = TrmnlDeviceType.BYOD val modelName = "amazon_kindle_2024" + val modelLabel = "Amazon Kindle 2024" // Act - deviceConfigDataStore.saveDeviceModelForType(deviceType, modelName) + deviceConfigDataStore.saveDeviceModelForType(deviceType, modelName, modelLabel) // Assert val preferences = deviceConfigDataStore.deviceModelPreferencesFlow.first() - assertThat(preferences).containsEntry("BYOD", "amazon_kindle_2024") + assertThat(preferences).containsKey("BYOD") + assertThat(preferences["BYOD"]?.name).isEqualTo("amazon_kindle_2024") + assertThat(preferences["BYOD"]?.label).isEqualTo("Amazon Kindle 2024") } @Test fun `saveDeviceModelForType updates existing model for device type`() = runTest { // Arrange - Save initial model - deviceConfigDataStore.saveDeviceModelForType(TrmnlDeviceType.BYOD, "amazon_kindle_2024") + deviceConfigDataStore.saveDeviceModelForType( + TrmnlDeviceType.BYOD, + "amazon_kindle_2024", + "Amazon Kindle 2024", + ) // Act - Update to different model - deviceConfigDataStore.saveDeviceModelForType(TrmnlDeviceType.BYOD, "boox_tab_ultra_c_pro") + deviceConfigDataStore.saveDeviceModelForType( + TrmnlDeviceType.BYOD, + "boox_tab_ultra_c_pro", + "Boox Tab Ultra C Pro", + ) // Assert val preferences = deviceConfigDataStore.deviceModelPreferencesFlow.first() - assertThat(preferences).containsEntry("BYOD", "boox_tab_ultra_c_pro") + assertThat(preferences["BYOD"]?.name).isEqualTo("boox_tab_ultra_c_pro") + assertThat(preferences["BYOD"]?.label).isEqualTo("Boox Tab Ultra C Pro") assertThat(preferences).hasSize(1) } @@ -506,13 +519,23 @@ class TrmnlDeviceConfigDataStoreTest { fun `saveDeviceModelForType stores multiple device types independently`() = runTest { // Act - Save models for different device types - deviceConfigDataStore.saveDeviceModelForType(TrmnlDeviceType.BYOD, "amazon_kindle_2024") - deviceConfigDataStore.saveDeviceModelForType(TrmnlDeviceType.BYOS, "boox_tab_ultra_c_pro") + deviceConfigDataStore.saveDeviceModelForType( + TrmnlDeviceType.BYOD, + "amazon_kindle_2024", + "Amazon Kindle 2024", + ) + deviceConfigDataStore.saveDeviceModelForType( + TrmnlDeviceType.BYOS, + "boox_tab_ultra_c_pro", + "Boox Tab Ultra C Pro", + ) // Assert val preferences = deviceConfigDataStore.deviceModelPreferencesFlow.first() - assertThat(preferences).containsEntry("BYOD", "amazon_kindle_2024") - assertThat(preferences).containsEntry("BYOS", "boox_tab_ultra_c_pro") + assertThat(preferences["BYOD"]?.name).isEqualTo("amazon_kindle_2024") + assertThat(preferences["BYOD"]?.label).isEqualTo("Amazon Kindle 2024") + assertThat(preferences["BYOS"]?.name).isEqualTo("boox_tab_ultra_c_pro") + assertThat(preferences["BYOS"]?.label).isEqualTo("Boox Tab Ultra C Pro") assertThat(preferences).hasSize(2) } @@ -520,38 +543,45 @@ class TrmnlDeviceConfigDataStoreTest { fun `getDeviceModelForType returns null when no model saved`() = runTest { // Act - val modelName = deviceConfigDataStore.getDeviceModelForType(TrmnlDeviceType.BYOD) + val modelSelection = deviceConfigDataStore.getDeviceModelForType(TrmnlDeviceType.BYOD) // Assert - assertThat(modelName).isNull() + assertThat(modelSelection).isNull() } @Test - fun `getDeviceModelForType returns correct model name when saved`() = + fun `getDeviceModelForType returns correct model selection when saved`() = runTest { // Arrange val deviceType = TrmnlDeviceType.BYOD val expectedModelName = "amazon_kindle_2024" - deviceConfigDataStore.saveDeviceModelForType(deviceType, expectedModelName) + val expectedModelLabel = "Amazon Kindle 2024" + deviceConfigDataStore.saveDeviceModelForType(deviceType, expectedModelName, expectedModelLabel) // Act - val modelName = deviceConfigDataStore.getDeviceModelForType(deviceType) + val modelSelection = deviceConfigDataStore.getDeviceModelForType(deviceType) // Assert - assertThat(modelName).isEqualTo(expectedModelName) + assertThat(modelSelection).isNotNull() + assertThat(modelSelection?.name).isEqualTo(expectedModelName) + assertThat(modelSelection?.label).isEqualTo(expectedModelLabel) } @Test fun `getDeviceModelForType returns null for device type without saved model`() = runTest { // Arrange - Save model for BYOD only - deviceConfigDataStore.saveDeviceModelForType(TrmnlDeviceType.BYOD, "amazon_kindle_2024") + deviceConfigDataStore.saveDeviceModelForType( + TrmnlDeviceType.BYOD, + "amazon_kindle_2024", + "Amazon Kindle 2024", + ) // Act - Query for BYOS which has no saved model - val modelName = deviceConfigDataStore.getDeviceModelForType(TrmnlDeviceType.BYOS) + val modelSelection = deviceConfigDataStore.getDeviceModelForType(TrmnlDeviceType.BYOS) // Assert - assertThat(modelName).isNull() + assertThat(modelSelection).isNull() } @Test @@ -562,19 +592,32 @@ class TrmnlDeviceConfigDataStoreTest { assertThat(initialPreferences).isEmpty() // Act - Save a model - deviceConfigDataStore.saveDeviceModelForType(TrmnlDeviceType.BYOD, "amazon_kindle_2024") + deviceConfigDataStore.saveDeviceModelForType( + TrmnlDeviceType.BYOD, + "amazon_kindle_2024", + "Amazon Kindle 2024", + ) // Assert - Flow emits updated map val updatedPreferences = deviceConfigDataStore.deviceModelPreferencesFlow.first() - assertThat(updatedPreferences).containsEntry("BYOD", "amazon_kindle_2024") + assertThat(updatedPreferences["BYOD"]?.name).isEqualTo("amazon_kindle_2024") + assertThat(updatedPreferences["BYOD"]?.label).isEqualTo("Amazon Kindle 2024") } @Test fun `clearAll removes device model preferences`() = runTest { // Arrange - Save some device model preferences - deviceConfigDataStore.saveDeviceModelForType(TrmnlDeviceType.BYOD, "amazon_kindle_2024") - deviceConfigDataStore.saveDeviceModelForType(TrmnlDeviceType.BYOS, "boox_tab_ultra_c_pro") + deviceConfigDataStore.saveDeviceModelForType( + TrmnlDeviceType.BYOD, + "amazon_kindle_2024", + "Amazon Kindle 2024", + ) + deviceConfigDataStore.saveDeviceModelForType( + TrmnlDeviceType.BYOS, + "boox_tab_ultra_c_pro", + "Boox Tab Ultra C Pro", + ) // Act deviceConfigDataStore.clearAll() From bf9891ba80982374553eeae1e1b322efab66764e Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 6 Dec 2025 12:14:23 -0500 Subject: [PATCH 5/9] fix: Pass deviceType to DeviceModelSelectorScreen to prevent wrong saves Previously, when navigating to DeviceModelSelectorScreen and returning, the deviceType state variable could change (e.g., user switches from BYOD to TRMNL while on another screen), causing the selected model to be saved to the wrong device type. Changes: - Change DeviceModelSelectorScreen from data object to data class with deviceType parameter - Update Result to include deviceType field - Pass current deviceType when navigating to selector screen - Use deviceType from Result (not state) when saving preference - Update DeviceModelSelectorPresenter to receive screen parameter - Update Factory interface to accept screen parameter - Add TrmnlDeviceType import This ensures the model is always saved to the correct device type that initiated the selection, even if the user changes device types elsewhere. Fixes issue where selecting a model for BYOD would incorrectly save to TRMNL device type. --- .../devicemodel/DeviceModelSelectorScreen.kt | 23 ++++++++++++++++--- .../android/ui/settings/AppSettingsScreen.kt | 13 +++++++---- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/ink/trmnl/android/ui/devicemodel/DeviceModelSelectorScreen.kt b/app/src/main/java/ink/trmnl/android/ui/devicemodel/DeviceModelSelectorScreen.kt index 3516e4a..4106a5f 100644 --- a/app/src/main/java/ink/trmnl/android/ui/devicemodel/DeviceModelSelectorScreen.kt +++ b/app/src/main/java/ink/trmnl/android/ui/devicemodel/DeviceModelSelectorScreen.kt @@ -52,6 +52,7 @@ import ink.trmnl.android.data.AppConfig.TRMNL_API_SERVER_BASE_URL import ink.trmnl.android.data.TrmnlDisplayRepository import ink.trmnl.android.di.AppScope import ink.trmnl.android.model.SupportedDeviceModel +import ink.trmnl.android.model.TrmnlDeviceType import ink.trmnl.android.ui.theme.TrmnlDisplayAppTheme import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize @@ -63,9 +64,13 @@ import kotlinx.parcelize.Parcelize * - View all available device models with their specifications * - Select a device model * - Return the selected model to the previous screen via PopResult + * + * @property deviceType The device type this model selection is for (e.g., BYOD, BYOS) */ @Parcelize -data object DeviceModelSelectorScreen : Screen { +data class DeviceModelSelectorScreen( + val deviceType: TrmnlDeviceType, +) : Screen { /** * Represents the UI state for the [DeviceModelSelectorScreen]. * @@ -112,10 +117,12 @@ data object DeviceModelSelectorScreen : Screen { * using Circuit's PopResult mechanism. * * @property selectedModel The device model that was selected by the user + * @property deviceType The device type this model selection was for */ @Parcelize data class Result( val selectedModel: SupportedDeviceModel, + val deviceType: TrmnlDeviceType, ) : PopResult } @@ -127,6 +134,7 @@ class DeviceModelSelectorPresenter @AssistedInject constructor( @Assisted private val navigator: Navigator, + @Assisted private val screen: DeviceModelSelectorScreen, private val repository: TrmnlDisplayRepository, ) : Presenter { /** @@ -169,7 +177,13 @@ class DeviceModelSelectorPresenter } is DeviceModelSelectorScreen.Event.ModelSelected -> { // Pop with result to return the selected model to the previous screen - navigator.pop(result = DeviceModelSelectorScreen.Result(event.model)) + navigator.pop( + result = + DeviceModelSelectorScreen.Result( + selectedModel = event.model, + deviceType = screen.deviceType, + ), + ) } is DeviceModelSelectorScreen.Event.RetryLoad -> { scope.launch { @@ -212,7 +226,10 @@ class DeviceModelSelectorPresenter @CircuitInject(DeviceModelSelectorScreen::class, AppScope::class) @AssistedFactory fun interface Factory { - fun create(navigator: Navigator): DeviceModelSelectorPresenter + fun create( + navigator: Navigator, + screen: DeviceModelSelectorScreen, + ): DeviceModelSelectorPresenter } } diff --git a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt index bd55190..2657662 100644 --- a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt +++ b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt @@ -288,15 +288,17 @@ class AppSettingsPresenter Timber.d( "Device specs - Name: ${result.selectedModel.name}, Display: ${result.selectedModel.width}x${result.selectedModel.height}px", ) - // Save the selected device model for the current device type + // Save the selected device model using the device type from the result + // This ensures we save to the correct device type even if the user + // switched device types while on the selector screen scope.launch { deviceConfigStore.saveDeviceModelForType( - deviceType = deviceType, + deviceType = result.deviceType, modelName = result.selectedModel.name, modelLabel = result.selectedModel.label, ) Timber.d( - "Saved device model preference: ${deviceType.name} -> ${result.selectedModel.name} (${result.selectedModel.label})", + "Saved device model preference: ${result.deviceType.name} -> ${result.selectedModel.name} (${result.selectedModel.label})", ) } } @@ -500,8 +502,9 @@ class AppSettingsPresenter AppSettingsScreen.Event.OverrideDisplayModelPressed -> { // Navigate to DeviceModelSelectorScreen using answering navigator - Timber.d("Navigating to DeviceModelSelectorScreen...") - deviceModelNavigator.goTo(DeviceModelSelectorScreen) + // Pass the current device type so the screen knows which type this selection is for + Timber.d("Navigating to DeviceModelSelectorScreen for device type: ${deviceType.name}") + deviceModelNavigator.goTo(DeviceModelSelectorScreen(deviceType)) } is AppSettingsScreen.Event.SetupDevice -> { From 26bb80130b47c04bad8a11492f0dfbce0a661d8f Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 6 Dec 2025 12:15:24 -0500 Subject: [PATCH 6/9] fix: Prevent duplicate saves in rememberAnsweringNavigator callback Use named parameters (navigator, onResult) instead of trailing lambda to ensure the callback isn't recreated on recompositions, which was causing the save operation to execute twice. Before: Device model preference saved twice (12:14:11.974 and 12:14:11.983) After: Device model preference saved only once --- .../android/ui/settings/AppSettingsScreen.kt | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt index 2657662..3b1768d 100644 --- a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt +++ b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt @@ -282,26 +282,30 @@ class AppSettingsPresenter } // Create answering navigator for DeviceModelSelectorScreen + // Use navigator as key to prevent recreating the callback on every recomposition val deviceModelNavigator = - rememberAnsweringNavigator(navigator) { result -> - Timber.d("User selected device model: ${result.selectedModel}") - Timber.d( - "Device specs - Name: ${result.selectedModel.name}, Display: ${result.selectedModel.width}x${result.selectedModel.height}px", - ) - // Save the selected device model using the device type from the result - // This ensures we save to the correct device type even if the user - // switched device types while on the selector screen - scope.launch { - deviceConfigStore.saveDeviceModelForType( - deviceType = result.deviceType, - modelName = result.selectedModel.name, - modelLabel = result.selectedModel.label, - ) + rememberAnsweringNavigator( + navigator = navigator, + onResult = { result -> + Timber.d("User selected device model: ${result.selectedModel}") Timber.d( - "Saved device model preference: ${result.deviceType.name} -> ${result.selectedModel.name} (${result.selectedModel.label})", + "Device specs - Name: ${result.selectedModel.name}, Display: ${result.selectedModel.width}x${result.selectedModel.height}px", ) - } - } + // Save the selected device model using the device type from the result + // This ensures we save to the correct device type even if the user + // switched device types while on the selector screen + scope.launch { + deviceConfigStore.saveDeviceModelForType( + deviceType = result.deviceType, + modelName = result.selectedModel.name, + modelLabel = result.selectedModel.label, + ) + Timber.d( + "Saved device model preference: ${result.deviceType.name} -> ${result.selectedModel.name} (${result.selectedModel.label})", + ) + } + }, + ) // Load saved token if available LaunchedEffect(Unit) { From f5c7a7b627b3a5ad5df3c675035decb05c666e44 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 6 Dec 2025 12:26:23 -0500 Subject: [PATCH 7/9] Revert "fix: Prevent duplicate saves in rememberAnsweringNavigator callback" This reverts commit 26bb80130b47c04bad8a11492f0dfbce0a661d8f. --- .../android/ui/settings/AppSettingsScreen.kt | 40 +++++++++---------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt index 3b1768d..2657662 100644 --- a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt +++ b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt @@ -282,30 +282,26 @@ class AppSettingsPresenter } // Create answering navigator for DeviceModelSelectorScreen - // Use navigator as key to prevent recreating the callback on every recomposition val deviceModelNavigator = - rememberAnsweringNavigator( - navigator = navigator, - onResult = { result -> - Timber.d("User selected device model: ${result.selectedModel}") - Timber.d( - "Device specs - Name: ${result.selectedModel.name}, Display: ${result.selectedModel.width}x${result.selectedModel.height}px", + rememberAnsweringNavigator(navigator) { result -> + Timber.d("User selected device model: ${result.selectedModel}") + Timber.d( + "Device specs - Name: ${result.selectedModel.name}, Display: ${result.selectedModel.width}x${result.selectedModel.height}px", + ) + // Save the selected device model using the device type from the result + // This ensures we save to the correct device type even if the user + // switched device types while on the selector screen + scope.launch { + deviceConfigStore.saveDeviceModelForType( + deviceType = result.deviceType, + modelName = result.selectedModel.name, + modelLabel = result.selectedModel.label, ) - // Save the selected device model using the device type from the result - // This ensures we save to the correct device type even if the user - // switched device types while on the selector screen - scope.launch { - deviceConfigStore.saveDeviceModelForType( - deviceType = result.deviceType, - modelName = result.selectedModel.name, - modelLabel = result.selectedModel.label, - ) - Timber.d( - "Saved device model preference: ${result.deviceType.name} -> ${result.selectedModel.name} (${result.selectedModel.label})", - ) - } - }, - ) + Timber.d( + "Saved device model preference: ${result.deviceType.name} -> ${result.selectedModel.name} (${result.selectedModel.label})", + ) + } + } // Load saved token if available LaunchedEffect(Unit) { From f5d647bea9f0c3707082dd9b285a0342fae8b0b0 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 6 Dec 2025 13:02:03 -0500 Subject: [PATCH 8/9] Clean up debug logging from device model preference feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove emoji debug markers (πŸŽ―πŸ”΅πŸŸ‘πŸŸ’πŸ’ΎπŸ“–) from produceState and callbacks - Keep essential production logs for device model saves - Simplify DataStore Flow emission logging - Feature is now production-ready --- .../data/TrmnlDeviceConfigDataStore.kt | 38 ++++++------- .../android/ui/settings/AppSettingsScreen.kt | 54 +++++++++++++++++-- 2 files changed, 69 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt b/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt index 81fc7e9..183fa22 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt @@ -18,6 +18,7 @@ import ink.trmnl.android.model.DeviceModelSelection import ink.trmnl.android.model.TrmnlDeviceConfig import ink.trmnl.android.model.TrmnlDeviceType import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.runBlocking @@ -113,26 +114,27 @@ class TrmnlDeviceConfigDataStore * Returns a map where keys are device type names (e.g., "BYOD") and values are DeviceModelSelection objects. */ val deviceModelPreferencesFlow: Flow> = - context.deviceConfigStore.data.map { preferences -> - val json = preferences[DEVICE_MODEL_PREFERENCES_KEY] - if (json != null) { - try { - val type = - com.squareup.moshi.Types.newParameterizedType( - Map::class.java, - String::class.java, - DeviceModelSelection::class.java, - ) - val adapter = moshi.adapter>(type) - adapter.fromJson(json) ?: emptyMap() - } catch (e: Exception) { - Timber.tag(TAG).e(e, "Failed to parse device model preferences") + context.deviceConfigStore.data + .map { preferences -> + val json = preferences[DEVICE_MODEL_PREFERENCES_KEY] + if (json != null) { + try { + val type = + com.squareup.moshi.Types.newParameterizedType( + Map::class.java, + String::class.java, + DeviceModelSelection::class.java, + ) + val adapter = moshi.adapter>(type) + adapter.fromJson(json) ?: emptyMap() + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to parse device model preferences") + emptyMap() + } + } else { emptyMap() } - } else { - emptyMap() - } - } + }.distinctUntilChanged() /** * Gets the complete device config as a Flow diff --git a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt index 2657662..cc8ba91 100644 --- a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt +++ b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt @@ -94,6 +94,7 @@ import ink.trmnl.android.data.RepositoryConfigProvider import ink.trmnl.android.data.TrmnlDeviceConfigDataStore import ink.trmnl.android.data.TrmnlDisplayRepository import ink.trmnl.android.di.AppScope +import ink.trmnl.android.model.DeviceModelSelection import ink.trmnl.android.model.TrmnlDeviceConfig import ink.trmnl.android.model.TrmnlDeviceType import ink.trmnl.android.ui.aboutapp.AppInfoScreen @@ -150,6 +151,7 @@ data class AppSettingsScreen( val isDeviceSetupLoading: Boolean = false, val deviceSetupMessage: String? = null, val nextRefreshJobInfo: NextImageRefreshDisplayInfo? = null, + val savedDeviceModel: DeviceModelSelection? = null, val eventSink: (Event) -> Unit, ) : CircuitUiState @@ -281,13 +283,22 @@ class AppSettingsPresenter } } + // Load saved device model preference based on current device type + // Flow automatically updates when preferences change in DataStore + // Use a single collector that filters by current deviceType value instead of restarting on deviceType change + val savedDeviceModel by produceState(initialValue = null) { + deviceConfigStore.deviceModelPreferencesFlow.collect { preferences -> + // Update value based on current deviceType (captured from closure) + val newValue = preferences[deviceType.name] + if (value != newValue) { + value = newValue + } + } + } + // Create answering navigator for DeviceModelSelectorScreen val deviceModelNavigator = rememberAnsweringNavigator(navigator) { result -> - Timber.d("User selected device model: ${result.selectedModel}") - Timber.d( - "Device specs - Name: ${result.selectedModel.name}, Display: ${result.selectedModel.width}x${result.selectedModel.height}px", - ) // Save the selected device model using the device type from the result // This ensures we save to the correct device type even if the user // switched device types while on the selector screen @@ -298,7 +309,7 @@ class AppSettingsPresenter modelLabel = result.selectedModel.label, ) Timber.d( - "Saved device model preference: ${result.deviceType.name} -> ${result.selectedModel.name} (${result.selectedModel.label})", + "Saved device model preference: ${result.deviceType.name} -> ${result.selectedModel.name}", ) } } @@ -336,6 +347,7 @@ class AppSettingsPresenter isDeviceSetupLoading = isDeviceSetupLoading, deviceSetupMessage = deviceSetupMessage, nextRefreshJobInfo = nextRefreshInfo, + savedDeviceModel = savedDeviceModel, eventSink = { event -> when (event) { is AppSettingsScreen.Event.AccessTokenChanged -> { @@ -672,6 +684,7 @@ fun AppSettingsContent( serverUrl = state.serverBaseUrl, deviceId = state.deviceMacId, isByodMasterDevice = state.isByodMasterDevice, + savedDeviceModel = state.savedDeviceModel, onTypeSelected = { state.eventSink(AppSettingsScreen.Event.DeviceTypeChanged(it)) }, onServerUrlChanged = { state.eventSink(AppSettingsScreen.Event.ServerUrlChanged(it)) }, onDeviceIdChanged = { state.eventSink(AppSettingsScreen.Event.DeviceMacIdChanged(it)) }, @@ -889,6 +902,7 @@ private fun DeviceTypeSelectorConfig( serverUrl: String = "", deviceId: String = "", isByodMasterDevice: Boolean = true, + savedDeviceModel: DeviceModelSelection? = null, onTypeSelected: (TrmnlDeviceType) -> Unit, onServerUrlChanged: (String) -> Unit, onDeviceIdChanged: (String) -> Unit, @@ -1036,6 +1050,36 @@ private fun DeviceTypeSelectorConfig( } } + // Show saved device model if available + if (savedDeviceModel != null) { + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 8.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) { + Column( + modifier = Modifier.padding(12.dp), + ) { + Text( + text = "Current Display Model", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Text( + text = savedDeviceModel.label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } + OutlinedButton( onClick = onOverrideDisplayModelPressed, modifier = Modifier.fillMaxWidth(), From 3ff303275fd7b78c1d5cb3acf6845b6257eac111 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 6 Dec 2025 14:19:23 -0500 Subject: [PATCH 9/9] refactor: Extract Moshi adapter for device model preferences to avoid duplication - Create private properties deviceModelPreferencesType and deviceModelPreferencesAdapter at class level - Use extracted adapter in deviceModelPreferencesFlow and saveDeviceModelForType - Reduces code duplication and improves maintainability Addresses PR feedback: https://github.com/usetrmnl/trmnl-android/pull/228\#discussion_r2595223707 --- .../data/TrmnlDeviceConfigDataStore.kt | 37 +++++++------------ 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt b/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt index 183fa22..62d2779 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt @@ -62,6 +62,16 @@ class TrmnlDeviceConfigDataStore private val deviceTypeAdapter = moshi.adapter(TrmnlDeviceType::class.java) private val deviceConfigAdapter = moshi.adapter(TrmnlDeviceConfig::class.java) + // Moshi adapter for device model preferences map + private val deviceModelPreferencesType = + com.squareup.moshi.Types.newParameterizedType( + Map::class.java, + String::class.java, + DeviceModelSelection::class.java, + ) + private val deviceModelPreferencesAdapter = + moshi.adapter>(deviceModelPreferencesType) + /** * Gets the device type as a Flow */ @@ -119,14 +129,7 @@ class TrmnlDeviceConfigDataStore val json = preferences[DEVICE_MODEL_PREFERENCES_KEY] if (json != null) { try { - val type = - com.squareup.moshi.Types.newParameterizedType( - Map::class.java, - String::class.java, - DeviceModelSelection::class.java, - ) - val adapter = moshi.adapter>(type) - adapter.fromJson(json) ?: emptyMap() + deviceModelPreferencesAdapter.fromJson(json) ?: emptyMap() } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to parse device model preferences") emptyMap() @@ -280,14 +283,7 @@ class TrmnlDeviceConfigDataStore val currentMap = if (currentJson != null) { try { - val type = - com.squareup.moshi.Types.newParameterizedType( - Map::class.java, - String::class.java, - DeviceModelSelection::class.java, - ) - val adapter = moshi.adapter>(type) - adapter.fromJson(currentJson)?.toMutableMap() ?: mutableMapOf() + deviceModelPreferencesAdapter.fromJson(currentJson)?.toMutableMap() ?: mutableMapOf() } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to parse existing device model preferences") mutableMapOf() @@ -300,14 +296,7 @@ class TrmnlDeviceConfigDataStore currentMap[deviceType.name] = DeviceModelSelection(modelName, modelLabel) // Save back to preferences - val type = - com.squareup.moshi.Types.newParameterizedType( - Map::class.java, - String::class.java, - DeviceModelSelection::class.java, - ) - val adapter = moshi.adapter>(type) - preferences[DEVICE_MODEL_PREFERENCES_KEY] = adapter.toJson(currentMap) + preferences[DEVICE_MODEL_PREFERENCES_KEY] = deviceModelPreferencesAdapter.toJson(currentMap) Timber.tag(TAG).d("Saved device model preference: ${deviceType.name} -> $modelName ($modelLabel)") }