Merge pull request #228 from usetrmnl/feature/byod-override-display-model

feat: Add BYOD display model override with persistent preferences
This commit is contained in:
Hossain Khan
2025-12-06 14:43:08 -05:00
committed by GitHub
5 changed files with 400 additions and 25 deletions
@@ -14,9 +14,11 @@ 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
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
@@ -54,11 +56,22 @@ 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)
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<Map<String, DeviceModelSelection>>(deviceModelPreferencesType)
/**
* Gets the device type as a Flow
*/
@@ -106,6 +119,26 @@ class TrmnlDeviceConfigDataStore
preferences[DEVICE_MAC_ID_KEY]
}
/**
* 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<Map<String, DeviceModelSelection>> =
context.deviceConfigStore.data
.map { preferences ->
val json = preferences[DEVICE_MODEL_PREFERENCES_KEY]
if (json != null) {
try {
deviceModelPreferencesAdapter.fromJson(json) ?: emptyMap()
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to parse device model preferences")
emptyMap()
}
} else {
emptyMap()
}
}.distinctUntilChanged()
/**
* Gets the complete device config as a Flow
*/
@@ -231,6 +264,56 @@ 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")
* @param modelLabel The model label (e.g., "Amazon Kindle 2024")
*/
suspend fun saveDeviceModelForType(
deviceType: TrmnlDeviceType,
modelName: String,
modelLabel: String,
) {
try {
context.deviceConfigStore.edit { preferences ->
// Get current map
val currentJson = preferences[DEVICE_MODEL_PREFERENCES_KEY]
val currentMap =
if (currentJson != null) {
try {
deviceModelPreferencesAdapter.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] = DeviceModelSelection(modelName, modelLabel)
// Save back to preferences
preferences[DEVICE_MODEL_PREFERENCES_KEY] = deviceModelPreferencesAdapter.toJson(currentMap)
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")
}
}
/**
* Gets the selected device model selection for a specific device type.
*
* @param deviceType The device type to query
* @return The DeviceModelSelection if set, null otherwise
*/
suspend fun getDeviceModelForType(deviceType: TrmnlDeviceType): DeviceModelSelection? =
deviceModelPreferencesFlow.first()[deviceType.name]
/**
* Checks if a token is already set
*/
@@ -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,
)
@@ -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<DeviceModelSelectorScreen.State> {
/**
@@ -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
}
}
@@ -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
@@ -76,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
@@ -92,9 +94,11 @@ 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
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 +121,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
@@ -146,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
@@ -233,6 +239,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()
}
}
@@ -272,6 +283,37 @@ 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<DeviceModelSelection?>(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<DeviceModelSelectorScreen.Result>(navigator) { result ->
// 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}",
)
}
}
// Load saved token if available
LaunchedEffect(Unit) {
deviceConfigStore.deviceConfigFlow.filterNotNull().collect {
@@ -305,6 +347,7 @@ class AppSettingsPresenter
isDeviceSetupLoading = isDeviceSetupLoading,
deviceSetupMessage = deviceSetupMessage,
nextRefreshJobInfo = nextRefreshInfo,
savedDeviceModel = savedDeviceModel,
eventSink = { event ->
when (event) {
is AppSettingsScreen.Event.AccessTokenChanged -> {
@@ -469,6 +512,13 @@ class AppSettingsPresenter
navigator.goTo(AppInfoScreen)
}
AppSettingsScreen.Event.OverrideDisplayModelPressed -> {
// Navigate to DeviceModelSelectorScreen using answering navigator
// 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 -> {
isDeviceSetupLoading = true
deviceSetupMessage = null
@@ -634,10 +684,12 @@ 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)) },
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,
@@ -850,10 +902,12 @@ private fun DeviceTypeSelectorConfig(
serverUrl: String = "",
deviceId: String = "",
isByodMasterDevice: Boolean = true,
savedDeviceModel: DeviceModelSelection? = null,
onTypeSelected: (TrmnlDeviceType) -> Unit,
onServerUrlChanged: (String) -> Unit,
onDeviceIdChanged: (String) -> Unit,
onByodMasterDeviceChanged: (Boolean) -> Unit = {},
onOverrideDisplayModelPressed: () -> Unit = {},
isServerUrlError: Boolean = false,
serverUrlError: String? = null,
isDeviceMacIdError: Boolean = false,
@@ -968,28 +1022,69 @@ 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,
)
}
}
// 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(),
) {
Text("Override Display Model")
}
}
}
@@ -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)
}
@@ -461,4 +462,168 @@ 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"
val modelLabel = "Amazon Kindle 2024"
// Act
deviceConfigDataStore.saveDeviceModelForType(deviceType, modelName, modelLabel)
// Assert
val preferences = deviceConfigDataStore.deviceModelPreferencesFlow.first()
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",
"Amazon Kindle 2024",
)
// Act - Update to different model
deviceConfigDataStore.saveDeviceModelForType(
TrmnlDeviceType.BYOD,
"boox_tab_ultra_c_pro",
"Boox Tab Ultra C Pro",
)
// Assert
val preferences = deviceConfigDataStore.deviceModelPreferencesFlow.first()
assertThat(preferences["BYOD"]?.name).isEqualTo("boox_tab_ultra_c_pro")
assertThat(preferences["BYOD"]?.label).isEqualTo("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",
"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["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)
}
@Test
fun `getDeviceModelForType returns null when no model saved`() =
runTest {
// Act
val modelSelection = deviceConfigDataStore.getDeviceModelForType(TrmnlDeviceType.BYOD)
// Assert
assertThat(modelSelection).isNull()
}
@Test
fun `getDeviceModelForType returns correct model selection when saved`() =
runTest {
// Arrange
val deviceType = TrmnlDeviceType.BYOD
val expectedModelName = "amazon_kindle_2024"
val expectedModelLabel = "Amazon Kindle 2024"
deviceConfigDataStore.saveDeviceModelForType(deviceType, expectedModelName, expectedModelLabel)
// Act
val modelSelection = deviceConfigDataStore.getDeviceModelForType(deviceType)
// Assert
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",
"Amazon Kindle 2024",
)
// Act - Query for BYOS which has no saved model
val modelSelection = deviceConfigDataStore.getDeviceModelForType(TrmnlDeviceType.BYOS)
// Assert
assertThat(modelSelection).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",
"Amazon Kindle 2024",
)
// Assert - Flow emits updated map
val updatedPreferences = deviceConfigDataStore.deviceModelPreferencesFlow.first()
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",
"Amazon Kindle 2024",
)
deviceConfigDataStore.saveDeviceModelForType(
TrmnlDeviceType.BYOS,
"boox_tab_ultra_c_pro",
"Boox Tab Ultra C Pro",
)
// Act
deviceConfigDataStore.clearAll()
// Assert
val preferences = deviceConfigDataStore.deviceModelPreferencesFlow.first()
assertThat(preferences).isEmpty()
}
}