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 ac216b3..dfe7b97 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt @@ -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 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 = 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