mirror of
https://github.com/usetrmnl/trmnl-android.git
synced 2026-04-29 13:35:26 -07:00
Merge pull request #244 from usetrmnl/docs/document-datastore-preference-strategy
Add logging and documentation for DataStore preference strategy
This commit is contained in:
@@ -10,6 +10,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.squareup.anvil.annotations.optional.SingleIn
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
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
|
||||
@@ -38,6 +39,48 @@ private val Context.deviceConfigStore: DataStore<Preferences> by preferencesData
|
||||
* This class provides methods for storing, retrieving, and managing the device configuration
|
||||
* including access token, device type, server URL, and refresh rate settings using
|
||||
* Android's DataStore.
|
||||
*
|
||||
* ## Preference Storage Strategy
|
||||
*
|
||||
* The device configuration uses a **dual-storage approach** for backward compatibility:
|
||||
*
|
||||
* ### 1. Modern Approach (Primary)
|
||||
* - Stores complete `TrmnlDeviceConfig` as JSON in `CONFIG_JSON_KEY`
|
||||
* - Single source of truth, easier to maintain
|
||||
* - Preferred method for reading and writing device config
|
||||
*
|
||||
* ### 2. Legacy Approach (Backward Compatibility)
|
||||
* - Stores individual fields as separate preference keys:
|
||||
* - `DEVICE_TYPE_KEY` - Device type (TRMNL, BYOS, BYOD)
|
||||
* - `ACCESS_TOKEN_KEY` - Device access token
|
||||
* - `API_BASE_URL_KEY` - Server base URL
|
||||
* - `REFRESH_RATE_SEC_KEY` - Refresh rate in seconds
|
||||
* - `DEVICE_MAC_ID_KEY` - Device MAC address
|
||||
* - `IS_MASTER_DEVICE_KEY` - Master device flag (BYOD only)
|
||||
* - `USER_API_TOKEN_KEY` - User-level API token (BYOD only)
|
||||
* - Maintained for users upgrading from older app versions
|
||||
*
|
||||
* ### Loading Priority
|
||||
* When loading config via `deviceConfigFlow`:
|
||||
* 1. Check if `CONFIG_JSON_KEY` exists → load from JSON
|
||||
* 2. Otherwise → use legacy migration path (build config from individual keys)
|
||||
*
|
||||
* ### Saving Behavior
|
||||
* When saving via `saveDeviceConfig()`:
|
||||
* - **Always** saves JSON to `CONFIG_JSON_KEY` (modern)
|
||||
* - **Also** saves individual fields (legacy backward compatibility)
|
||||
* - This ensures both old and new app versions can read the config
|
||||
*
|
||||
* ## Device Model Preferences
|
||||
*
|
||||
* Device model preferences are stored separately from device config:
|
||||
* - Key: `DEVICE_MODEL_PREFERENCES_KEY`
|
||||
* - Value: JSON map of device type → `DeviceModelSelection`
|
||||
* - Example: `{"BYOD": {"name": "amazon_kindle_2024", "label": "Amazon Kindle 2024"}}`
|
||||
* - Allows different device models per device type
|
||||
*
|
||||
* @see TrmnlDeviceConfig for the device configuration data model
|
||||
* @see DeviceModelSelection for device model selection data
|
||||
*/
|
||||
@SingleIn(AppScope::class)
|
||||
class TrmnlDeviceConfigDataStore
|
||||
@@ -63,9 +106,36 @@ 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
|
||||
/**
|
||||
* Obfuscates a token string for logging purposes.
|
||||
* Shows only the first 8 characters followed by "..." for security.
|
||||
*
|
||||
* @return Obfuscated token string, or "null" if the token is null
|
||||
*/
|
||||
private fun String?.obfuscated(): String = this?.take(8)?.plus("...") ?: "null"
|
||||
|
||||
/**
|
||||
* Moshi adapter for device model preferences map.
|
||||
*
|
||||
* Stores a mapping of device type name (String) to DeviceModelSelection.
|
||||
* This allows each device type (TRMNL, BYOS, BYOD) to have its own selected model.
|
||||
*
|
||||
* Example JSON structure:
|
||||
* ```json
|
||||
* {
|
||||
* "BYOD": {
|
||||
* "name": "amazon_kindle_2024",
|
||||
* "label": "Amazon Kindle 2024"
|
||||
* },
|
||||
* "BYOS": {
|
||||
* "name": "waveshare_7in3f",
|
||||
* "label": "Waveshare 7.3\" ACeP"
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
private val deviceModelPreferencesType =
|
||||
com.squareup.moshi.Types.newParameterizedType(
|
||||
Types.newParameterizedType(
|
||||
Map::class.java,
|
||||
String::class.java,
|
||||
DeviceModelSelection::class.java,
|
||||
@@ -142,13 +212,34 @@ class TrmnlDeviceConfigDataStore
|
||||
|
||||
/**
|
||||
* Gets the complete device config as a Flow
|
||||
*
|
||||
* ## Loading Strategy
|
||||
* This Flow uses a dual-path approach to support both modern and legacy storage:
|
||||
*
|
||||
* **Primary Path (Modern):**
|
||||
* - Checks for `CONFIG_JSON_KEY` preference
|
||||
* - If exists, deserializes JSON to `TrmnlDeviceConfig`
|
||||
* - Logs: "Loading device config (JSON): type=..., userApiToken=..."
|
||||
*
|
||||
* **Fallback Path (Legacy Migration):**
|
||||
* - If `CONFIG_JSON_KEY` doesn't exist, builds config from individual preference keys
|
||||
* - Reads: `DEVICE_TYPE_KEY`, `ACCESS_TOKEN_KEY`, `API_BASE_URL_KEY`, etc.
|
||||
* - Logs: "Loading device config (legacy): type=..., userApiToken=..."
|
||||
* - Only returns config if `ACCESS_TOKEN_KEY` exists (required field)
|
||||
*
|
||||
* This approach ensures seamless migration from older app versions while
|
||||
* maintaining forward compatibility with newer storage format.
|
||||
*/
|
||||
val deviceConfigFlow: Flow<TrmnlDeviceConfig?> =
|
||||
context.deviceConfigStore.data.map { preferences ->
|
||||
val configJson = preferences[CONFIG_JSON_KEY]
|
||||
if (configJson != null) {
|
||||
try {
|
||||
deviceConfigAdapter.fromJson(configJson)
|
||||
val config: TrmnlDeviceConfig? = deviceConfigAdapter.fromJson(configJson)
|
||||
Timber.tag(TAG).d(
|
||||
"Loading device config (JSON): type=${config?.type}, userApiToken=${config?.userApiToken.obfuscated()}",
|
||||
)
|
||||
config
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Failed to parse device config")
|
||||
null
|
||||
@@ -171,6 +262,10 @@ class TrmnlDeviceConfigDataStore
|
||||
val isMasterDevice = preferences[IS_MASTER_DEVICE_KEY]?.toBoolean()
|
||||
val userApiToken = preferences[USER_API_TOKEN_KEY]
|
||||
|
||||
Timber.tag(TAG).d(
|
||||
"Loading device config (legacy): type=$type, deviceApiToken=${token.obfuscated()}",
|
||||
)
|
||||
|
||||
if (token != null) {
|
||||
TrmnlDeviceConfig(
|
||||
type = type,
|
||||
@@ -189,9 +284,32 @@ class TrmnlDeviceConfigDataStore
|
||||
|
||||
/**
|
||||
* Saves the complete device configuration
|
||||
*
|
||||
* ## Dual-Storage Approach
|
||||
* This method saves the config in **both** formats for maximum compatibility:
|
||||
*
|
||||
* **Modern Storage:**
|
||||
* - Serializes entire `TrmnlDeviceConfig` to JSON
|
||||
* - Saves to `CONFIG_JSON_KEY` preference
|
||||
* - Single source of truth for modern app versions
|
||||
*
|
||||
* **Legacy Storage:**
|
||||
* - Also saves individual fields to separate preference keys
|
||||
* - Ensures older app versions can still read the config
|
||||
* - Fields: `DEVICE_TYPE_KEY`, `ACCESS_TOKEN_KEY`, `USER_API_TOKEN_KEY`, etc.
|
||||
*
|
||||
* **Null Handling:**
|
||||
* - Optional fields (e.g., `userApiToken`, `isMasterDevice`) use `let` operator
|
||||
* - If null, the preference key is removed with `preferences.remove()`
|
||||
* - This keeps DataStore clean and prevents storing empty strings
|
||||
*
|
||||
* @param config The complete device configuration to save
|
||||
*/
|
||||
suspend fun saveDeviceConfig(config: TrmnlDeviceConfig) {
|
||||
try {
|
||||
Timber.tag(TAG).d(
|
||||
"Saving device config: type=${config.type}, userApiToken=${config.userApiToken.obfuscated()}",
|
||||
)
|
||||
val configJson = deviceConfigAdapter.toJson(config)
|
||||
context.deviceConfigStore.edit { preferences ->
|
||||
// Save as JSON for future use
|
||||
@@ -218,6 +336,7 @@ class TrmnlDeviceConfigDataStore
|
||||
preferences[USER_API_TOKEN_KEY] = userToken
|
||||
} ?: preferences.remove(USER_API_TOKEN_KEY)
|
||||
}
|
||||
Timber.tag(TAG).d("Device config saved successfully")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Failed to save device config")
|
||||
}
|
||||
@@ -236,18 +355,24 @@ class TrmnlDeviceConfigDataStore
|
||||
* Saves the user-level API token (Account API key)
|
||||
*/
|
||||
suspend fun saveUserApiToken(token: String) {
|
||||
Timber.tag(TAG).d("Saving user API token: ${token.obfuscated()}")
|
||||
context.deviceConfigStore.edit { preferences ->
|
||||
preferences[USER_API_TOKEN_KEY] = token
|
||||
}
|
||||
Timber.tag(TAG).d("User API token saved successfully")
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the user-level API token
|
||||
*/
|
||||
suspend fun getUserApiToken(): String? =
|
||||
context.deviceConfigStore.data
|
||||
.map { preferences -> preferences[USER_API_TOKEN_KEY] }
|
||||
.first()
|
||||
suspend fun getUserApiToken(): String? {
|
||||
val token =
|
||||
context.deviceConfigStore.data
|
||||
.map { preferences -> preferences[USER_API_TOKEN_KEY] }
|
||||
.first()
|
||||
Timber.tag(TAG).d("Retrieved user API token: ${token.obfuscated()}")
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the access token
|
||||
|
||||
Reference in New Issue
Block a user