diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ff0ddea..dc04865 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -120,6 +120,7 @@ android { kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) + freeCompilerArgs.addAll(listOf("-Xannotation-default-target=param-property")) } } diff --git a/app/src/main/java/ink/trmnl/android/MainActivity.kt b/app/src/main/java/ink/trmnl/android/MainActivity.kt index 6a72d27..cc85740 100644 --- a/app/src/main/java/ink/trmnl/android/MainActivity.kt +++ b/app/src/main/java/ink/trmnl/android/MainActivity.kt @@ -40,100 +40,99 @@ import timber.log.Timber * It can function as either a mirror for existing TRMNL devices or as a * standalone TRMNL display connected directly to BYOS servers. */ +@Inject @ContributesIntoMap(AppScope::class, binding = binding()) -@ActivityKey(MainActivity::class) -class MainActivity - @Inject - constructor( - @ApplicationContext private val context: Context, - private val circuit: Circuit, - private val trmnlImageUpdateManager: TrmnlImageUpdateManager, - ) : ComponentActivity() { - @OptIn(ExperimentalSharedTransitionApi::class) - override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge() - super.onCreate(savedInstanceState) +@ActivityKey +class MainActivity( + @ApplicationContext private val context: Context, + private val circuit: Circuit, + private val trmnlImageUpdateManager: TrmnlImageUpdateManager, +) : ComponentActivity() { + @OptIn(ExperimentalSharedTransitionApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) - // Setup listener for TRMNL display image updates - listenForWorkUpdates() + // Setup listener for TRMNL display image updates + listenForWorkUpdates() - setContent { - TrmnlDisplayAppTheme { - // See https://slackhq.github.io/circuit/navigation/ - val backStack = rememberSaveableBackStack(root = TrmnlMirrorDisplayScreen) - val navigator = rememberCircuitNavigator(backStack) + setContent { + TrmnlDisplayAppTheme { + // See https://slackhq.github.io/circuit/navigation/ + val backStack = rememberSaveableBackStack(root = TrmnlMirrorDisplayScreen) + val navigator = rememberCircuitNavigator(backStack) - // See https://slackhq.github.io/circuit/circuit-content/ - CircuitCompositionLocals(circuit) { - // See https://slackhq.github.io/circuit/shared-elements/ - SharedElementTransitionLayout { - // See https://slackhq.github.io/circuit/overlays/ - ContentWithOverlays { - NavigableCircuitContent( - navigator = navigator, - backStack = backStack, - decoratorFactory = - remember(navigator) { - GestureNavigationDecorationFactory(onBackInvoked = navigator::pop) - }, - ) - } + // See https://slackhq.github.io/circuit/circuit-content/ + CircuitCompositionLocals(circuit) { + // See https://slackhq.github.io/circuit/shared-elements/ + SharedElementTransitionLayout { + // See https://slackhq.github.io/circuit/overlays/ + ContentWithOverlays { + NavigableCircuitContent( + navigator = navigator, + backStack = backStack, + decoratorFactory = + remember(navigator) { + GestureNavigationDecorationFactory(onBackInvoked = navigator::pop) + }, + ) } } } } } + } - /** - * Sets up observers for WorkManager work updates. - * - * This function: - * 1. Listens for periodic image refresh work results - * 2. Listens for one-time image refresh work results - * 3. Updates the application with new images when available - * 4. Logs work status and errors - * - * 📚 See following sequence diagram for flow: - * - https://github.com/usetrmnl/trmnl-android/blob/main/CONTRIBUTING.md#trmnl-app-image-loading-flow - */ - private fun listenForWorkUpdates() { - val workManager = WorkManager.getInstance(context) + /** + * Sets up observers for WorkManager work updates. + * + * This function: + * 1. Listens for periodic image refresh work results + * 2. Listens for one-time image refresh work results + * 3. Updates the application with new images when available + * 4. Logs work status and errors + * + * 📚 See following sequence diagram for flow: + * - https://github.com/usetrmnl/trmnl-android/blob/main/CONTRIBUTING.md#trmnl-app-image-loading-flow + */ + private fun listenForWorkUpdates() { + val workManager = WorkManager.getInstance(context) - workManager - .getWorkInfosLiveData( - WorkQuery.fromUniqueWorkNames(IMAGE_REFRESH_PERIODIC_WORK_NAME, IMAGE_REFRESH_ONETIME_WORK_NAME), - ).observe(this) { workInfos -> - // ⚠️ DEV NOTE: On app launch, previously ran work info is broadcasted here, - // so it may result in inconsistent behavior where it remembers last result. - workInfos.forEach { workInfo -> - when (workInfo.state) { - WorkInfo.State.SUCCEEDED -> { - Timber.d("${workInfo.tags} work ${workInfo.state.name.lowercase()}: $workInfo") - val newImageUrl = - workInfo.outputData.getString( - TrmnlImageRefreshWorker.KEY_NEW_IMAGE_URL, - ) + workManager + .getWorkInfosLiveData( + WorkQuery.fromUniqueWorkNames(IMAGE_REFRESH_PERIODIC_WORK_NAME, IMAGE_REFRESH_ONETIME_WORK_NAME), + ).observe(this) { workInfos -> + // ⚠️ DEV NOTE: On app launch, previously ran work info is broadcasted here, + // so it may result in inconsistent behavior where it remembers last result. + workInfos.forEach { workInfo -> + when (workInfo.state) { + WorkInfo.State.SUCCEEDED -> { + Timber.d("${workInfo.tags} work ${workInfo.state.name.lowercase()}: $workInfo") + val newImageUrl = + workInfo.outputData.getString( + TrmnlImageRefreshWorker.KEY_NEW_IMAGE_URL, + ) - if (newImageUrl != null) { - Timber.i("New image URL from ${workInfo.tags}: $newImageUrl") - trmnlImageUpdateManager.updateImage(newImageUrl) - } - } - WorkInfo.State.FAILED -> { - val error = workInfo.outputData.getString(TrmnlImageRefreshWorker.KEY_ERROR_MESSAGE) - Timber.e("${workInfo.tags} work failed: $error") - trmnlImageUpdateManager.updateImage(imageUrl = "", errorMessage = error) - } - else -> { - Timber.d("${workInfo.tags} work state updated: ${workInfo.state}") + if (newImageUrl != null) { + Timber.i("New image URL from ${workInfo.tags}: $newImageUrl") + trmnlImageUpdateManager.updateImage(newImageUrl) } } - // Even though pruning is not recommended to do frequently, - // we need this to avoid getting stale completed work info - // See https://github.com/hossain-khan/android-trmnl-display/pull/98#issuecomment-2825920626 - // See https://github.com/hossain-khan/android-trmnl-display/pull/63#issuecomment-2817278344 - workManager.pruneWork() + WorkInfo.State.FAILED -> { + val error = workInfo.outputData.getString(TrmnlImageRefreshWorker.KEY_ERROR_MESSAGE) + Timber.e("${workInfo.tags} work failed: $error") + trmnlImageUpdateManager.updateImage(imageUrl = "", errorMessage = error) + } + else -> { + Timber.d("${workInfo.tags} work state updated: ${workInfo.state}") + } } + // Even though pruning is not recommended to do frequently, + // we need this to avoid getting stale completed work info + // See https://github.com/hossain-khan/android-trmnl-display/pull/98#issuecomment-2825920626 + // See https://github.com/hossain-khan/android-trmnl-display/pull/63#issuecomment-2817278344 + workManager.pruneWork() } - } + } } +} diff --git a/app/src/main/java/ink/trmnl/android/data/ImageMetadataStore.kt b/app/src/main/java/ink/trmnl/android/data/ImageMetadataStore.kt index d62110d..3da95db 100644 --- a/app/src/main/java/ink/trmnl/android/data/ImageMetadataStore.kt +++ b/app/src/main/java/ink/trmnl/android/data/ImageMetadataStore.kt @@ -27,113 +27,112 @@ private val Context.imageDataStore: DataStore by preferencesDataSto * * @see ImageMetadata */ -class ImageMetadataStore - @Inject - constructor( - @ApplicationContext private val context: Context, +@Inject +class ImageMetadataStore( + @ApplicationContext private val context: Context, +) { + companion object { + private val IMAGE_URL_KEY = stringPreferencesKey("last_image_url") + private val TIMESTAMP_KEY = longPreferencesKey("last_image_timestamp") + private val REFRESH_RATE_KEY = longPreferencesKey("last_refresh_rate") + private val HTTP_STATUS_CODE_KEY = intPreferencesKey("last_http_status_code") + } + + /** + * Get the image metadata as a Flow + */ + val imageMetadataFlow: Flow = + context.imageDataStore.data.map { preferences -> + val imageUrl = preferences[IMAGE_URL_KEY] ?: return@map null + val timestamp = preferences[TIMESTAMP_KEY] ?: Instant.now().toEpochMilli() + val refreshRate = preferences[REFRESH_RATE_KEY] + val httpStatusCode = preferences[HTTP_STATUS_CODE_KEY] + + ImageMetadata( + url = imageUrl, + refreshIntervalSecs = refreshRate, + errorMessage = null, + httpStatusCode = httpStatusCode, + timestamp = timestamp, + ) + } + + /** + * Save new image metadata + */ + suspend fun saveImageMetadata( + imageUrl: String, + refreshIntervalSec: Long? = null, + httpStatusCode: Int? = null, ) { - companion object { - private val IMAGE_URL_KEY = stringPreferencesKey("last_image_url") - private val TIMESTAMP_KEY = longPreferencesKey("last_image_timestamp") - private val REFRESH_RATE_KEY = longPreferencesKey("last_refresh_rate") - private val HTTP_STATUS_CODE_KEY = intPreferencesKey("last_http_status_code") - } + Timber.d("Saving image metadata: url=$imageUrl, refreshIntervalSec=$refreshIntervalSec, httpStatusCode=$httpStatusCode") + context.imageDataStore.edit { preferences -> + preferences[IMAGE_URL_KEY] = imageUrl + preferences[TIMESTAMP_KEY] = Instant.now().toEpochMilli() + refreshIntervalSec?.let { preferences[REFRESH_RATE_KEY] = it } - /** - * Get the image metadata as a Flow - */ - val imageMetadataFlow: Flow = - context.imageDataStore.data.map { preferences -> - val imageUrl = preferences[IMAGE_URL_KEY] ?: return@map null - val timestamp = preferences[TIMESTAMP_KEY] ?: Instant.now().toEpochMilli() - val refreshRate = preferences[REFRESH_RATE_KEY] - val httpStatusCode = preferences[HTTP_STATUS_CODE_KEY] - - ImageMetadata( - url = imageUrl, - refreshIntervalSecs = refreshRate, - errorMessage = null, - httpStatusCode = httpStatusCode, - timestamp = timestamp, - ) - } - - /** - * Save new image metadata - */ - suspend fun saveImageMetadata( - imageUrl: String, - refreshIntervalSec: Long? = null, - httpStatusCode: Int? = null, - ) { - Timber.d("Saving image metadata: url=$imageUrl, refreshIntervalSec=$refreshIntervalSec, httpStatusCode=$httpStatusCode") - context.imageDataStore.edit { preferences -> - preferences[IMAGE_URL_KEY] = imageUrl - preferences[TIMESTAMP_KEY] = Instant.now().toEpochMilli() - refreshIntervalSec?.let { preferences[REFRESH_RATE_KEY] = it } - - // Save or clear HTTP status code - if (httpStatusCode != null) { - preferences[HTTP_STATUS_CODE_KEY] = httpStatusCode - } else { - preferences.remove(HTTP_STATUS_CODE_KEY) - } - } - } - - /** - * Checks if the stored image URL is still valid based on refresh rate - * @return Flow of Boolean indicating if a valid, non-expired image URL exists - */ - val hasValidImageUrlFlow: Flow = - context.imageDataStore.data.map { preferences -> - val url = preferences[IMAGE_URL_KEY] ?: return@map false - val timestamp = preferences[TIMESTAMP_KEY] ?: return@map false - val refreshRate = preferences[REFRESH_RATE_KEY] ?: return@map false - - // Calculate if the image is expired based on timestamp + refresh rate - val expirationTime = timestamp + (refreshRate * 1000) // Convert seconds to milliseconds - val currentTime = Instant.now().toEpochMilli() - - // Image is valid if current time is before expiration - url.isNotEmpty() && currentTime < expirationTime - } - - /** - * Checks synchronously if a valid, non-expired image URL exists - * @return true if valid image URL exists and is not expired - */ - fun hasValidImageUrlSync(): Boolean { - return runBlocking { - return@runBlocking hasValidImageUrlFlow.first() - } - } - - /** - * Returns the amount of time in milliseconds until the current image expires - * @return Positive value if image is still valid, negative if already expired, null if no valid image - */ - val timeUntilExpirationFlow: Flow = - context.imageDataStore.data.map { preferences -> - val timestamp = preferences[TIMESTAMP_KEY] ?: return@map null - val refreshRate = preferences[REFRESH_RATE_KEY] ?: return@map null - - // Calculate time until expiration - val expirationTime = timestamp + (refreshRate * 1000) // Convert seconds to milliseconds - val currentTime = Instant.now().toEpochMilli() - - expirationTime - currentTime - } - - /** - * Clear stored image metadata - */ - suspend fun clearImageMetadata() { - context.imageDataStore.edit { preferences -> - preferences.remove(IMAGE_URL_KEY) - preferences.remove(TIMESTAMP_KEY) - preferences.remove(REFRESH_RATE_KEY) + // Save or clear HTTP status code + if (httpStatusCode != null) { + preferences[HTTP_STATUS_CODE_KEY] = httpStatusCode + } else { preferences.remove(HTTP_STATUS_CODE_KEY) } } } + + /** + * Checks if the stored image URL is still valid based on refresh rate + * @return Flow of Boolean indicating if a valid, non-expired image URL exists + */ + val hasValidImageUrlFlow: Flow = + context.imageDataStore.data.map { preferences -> + val url = preferences[IMAGE_URL_KEY] ?: return@map false + val timestamp = preferences[TIMESTAMP_KEY] ?: return@map false + val refreshRate = preferences[REFRESH_RATE_KEY] ?: return@map false + + // Calculate if the image is expired based on timestamp + refresh rate + val expirationTime = timestamp + (refreshRate * 1000) // Convert seconds to milliseconds + val currentTime = Instant.now().toEpochMilli() + + // Image is valid if current time is before expiration + url.isNotEmpty() && currentTime < expirationTime + } + + /** + * Checks synchronously if a valid, non-expired image URL exists + * @return true if valid image URL exists and is not expired + */ + fun hasValidImageUrlSync(): Boolean { + return runBlocking { + return@runBlocking hasValidImageUrlFlow.first() + } + } + + /** + * Returns the amount of time in milliseconds until the current image expires + * @return Positive value if image is still valid, negative if already expired, null if no valid image + */ + val timeUntilExpirationFlow: Flow = + context.imageDataStore.data.map { preferences -> + val timestamp = preferences[TIMESTAMP_KEY] ?: return@map null + val refreshRate = preferences[REFRESH_RATE_KEY] ?: return@map null + + // Calculate time until expiration + val expirationTime = timestamp + (refreshRate * 1000) // Convert seconds to milliseconds + val currentTime = Instant.now().toEpochMilli() + + expirationTime - currentTime + } + + /** + * Clear stored image metadata + */ + suspend fun clearImageMetadata() { + context.imageDataStore.edit { preferences -> + preferences.remove(IMAGE_URL_KEY) + preferences.remove(TIMESTAMP_KEY) + preferences.remove(REFRESH_RATE_KEY) + preferences.remove(HTTP_STATUS_CODE_KEY) + } + } +} 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 024168a..cd5e290 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt @@ -82,437 +82,436 @@ private val Context.deviceConfigStore: DataStore by preferencesData * @see TrmnlDeviceConfig for the device configuration data model * @see DeviceModelSelection for device model selection data */ +@Inject @SingleIn(AppScope::class) -class TrmnlDeviceConfigDataStore - @Inject - constructor( - @ApplicationContext private val context: Context, - private val moshi: Moshi, - ) { - companion object { - private const val TAG = "DeviceConfigStore" +class TrmnlDeviceConfigDataStore( + @ApplicationContext private val context: Context, + private val moshi: Moshi, +) { + companion object { + private const val TAG = "DeviceConfigStore" - private val DEVICE_TYPE_KEY = stringPreferencesKey("device_type") - private val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token") - private val API_BASE_URL_KEY = stringPreferencesKey("api_base_url") - private val REFRESH_RATE_SEC_KEY = longPreferencesKey("refresh_rate_seconds") - 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 DEVICE_TYPE_KEY = stringPreferencesKey("device_type") + private val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token") + private val API_BASE_URL_KEY = stringPreferencesKey("api_base_url") + private val REFRESH_RATE_SEC_KEY = longPreferencesKey("refresh_rate_seconds") + 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) + private val deviceTypeAdapter = moshi.adapter(TrmnlDeviceType::class.java) + private val deviceConfigAdapter = moshi.adapter(TrmnlDeviceConfig::class.java) - /** - * 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" + /** + * 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 = - Types.newParameterizedType( - Map::class.java, - String::class.java, - DeviceModelSelection::class.java, - ) - private val deviceModelPreferencesAdapter = - moshi.adapter>(deviceModelPreferencesType) + /** + * 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 = + Types.newParameterizedType( + Map::class.java, + String::class.java, + DeviceModelSelection::class.java, + ) + private val deviceModelPreferencesAdapter = + moshi.adapter>(deviceModelPreferencesType) - /** - * Gets the device type as a Flow - */ - val deviceTypeFlow: Flow = - context.deviceConfigStore.data.map { preferences -> - preferences[DEVICE_TYPE_KEY]?.let { - try { - deviceTypeAdapter.fromJson(it) - } catch (e: Exception) { - Timber.tag(TAG).e(e, "Failed to parse device type") - TrmnlDeviceType.TRMNL - } + /** + * Gets the device type as a Flow + */ + val deviceTypeFlow: Flow = + context.deviceConfigStore.data.map { preferences -> + preferences[DEVICE_TYPE_KEY]?.let { + try { + deviceTypeAdapter.fromJson(it) + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to parse device type") + TrmnlDeviceType.TRMNL } } + } - /** - * Gets the access token as a Flow - */ - val accessTokenFlow: Flow = - context.deviceConfigStore.data.map { preferences -> - preferences[ACCESS_TOKEN_KEY] - } + /** + * Gets the access token as a Flow + */ + val accessTokenFlow: Flow = + context.deviceConfigStore.data.map { preferences -> + preferences[ACCESS_TOKEN_KEY] + } - /** - * Gets the server base URL as a Flow - */ - val serverUrlFlow: Flow = - context.deviceConfigStore.data.map { preferences -> - preferences[API_BASE_URL_KEY] ?: TRMNL_API_SERVER_BASE_URL - } + /** + * Gets the server base URL as a Flow + */ + val serverUrlFlow: Flow = + context.deviceConfigStore.data.map { preferences -> + preferences[API_BASE_URL_KEY] ?: TRMNL_API_SERVER_BASE_URL + } - /** - * Gets the refresh rate in seconds as a Flow - */ - val refreshRateSecondsFlow: Flow = - context.deviceConfigStore.data.map { preferences -> - preferences[REFRESH_RATE_SEC_KEY] - } + /** + * Gets the refresh rate in seconds as a Flow + */ + val refreshRateSecondsFlow: Flow = + context.deviceConfigStore.data.map { preferences -> + preferences[REFRESH_RATE_SEC_KEY] + } - /** - * Gets the device MAC address as a Flow - */ - val deviceMacIdFlow: Flow = - context.deviceConfigStore.data.map { preferences -> - preferences[DEVICE_MAC_ID_KEY] - } + /** + * Gets the device MAC address as a Flow + */ + val deviceMacIdFlow: Flow = + context.deviceConfigStore.data.map { preferences -> + 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> = - 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 { + /** + * 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> = + 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() } - }.distinctUntilChanged() - - /** - * 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) - * - * **Domain Migration:** - * - Automatically migrates `usetrmnl.com` to `trmnl.com` for TRMNL device types - * - See: https://github.com/usetrmnl/trmnl-android/issues/240 - * - * 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 { - val config: TrmnlDeviceConfig? = deviceConfigAdapter.fromJson(configJson) - Timber.tag(TAG).d( - "Loading device config (JSON): type=${config?.type}", - ) - config - } catch (e: Exception) { - Timber.tag(TAG).e(e, "Failed to parse device config") - null - } - } else { - // Legacy migration path - build config from individual preferences - val type = - preferences[DEVICE_TYPE_KEY]?.let { - try { - deviceTypeAdapter.fromJson(it) - } catch (e: Exception) { - TrmnlDeviceType.TRMNL - } - } ?: TrmnlDeviceType.TRMNL - - val token = preferences[ACCESS_TOKEN_KEY] - val url = preferences[API_BASE_URL_KEY] ?: TRMNL_API_SERVER_BASE_URL - val refreshRate = preferences[REFRESH_RATE_SEC_KEY] ?: DEFAULT_REFRESH_INTERVAL_SEC - val deviceMacId = preferences[DEVICE_MAC_ID_KEY] - val isMasterDevice = preferences[IS_MASTER_DEVICE_KEY]?.toBoolean() - - Timber.tag(TAG).d( - "Loading device config (legacy): type=$type, deviceApiToken=${token.obfuscated()}", - ) - - if (token != null) { - TrmnlDeviceConfig( - type = type, - apiBaseUrl = url, - apiAccessToken = token, - deviceMacId = deviceMacId, - refreshRateSecs = refreshRate, - isMasterDevice = isMasterDevice, - ) - } else { - null - } - } - }.map { config -> - // Migrate usetrmnl.com -> trmnl.com for TRMNL device types - // See: https://github.com/usetrmnl/trmnl-android/issues/240 - if (config != null && - config.type == TrmnlDeviceType.TRMNL && - config.apiBaseUrl.contains(AppConfig.LEGACY_TRMNL_DOMAIN, ignoreCase = true) - ) { - val newUrl = config.apiBaseUrl.replace(AppConfig.LEGACY_TRMNL_DOMAIN, AppConfig.TRMNL_DOMAIN, ignoreCase = true) - Timber.tag(TAG).i( - "Migrating API base URL from ${config.apiBaseUrl} to $newUrl for TRMNL device", - ) - val migratedConfig = config.copy(apiBaseUrl = newUrl) - // Persist the migrated config back to DataStore (one-time migration) - // Note: Using runBlocking here as Flow.map doesn't support suspend operations. - // This is acceptable because: (1) it's a one-time migration per user, - // (2) DataStore writes are fast, and (3) distinctUntilChanged() below - // prevents duplicate downstream emissions from the save triggering re-collection. - runBlocking { - saveDeviceConfig(migratedConfig) - } - migratedConfig - } else { - config - } - }.distinctUntilChanged() - - /** - * 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}", - ) - val configJson = deviceConfigAdapter.toJson(config) - context.deviceConfigStore.edit { preferences -> - // Save as JSON for future use - preferences[CONFIG_JSON_KEY] = configJson - - // Also save individual fields for backward compatibility - preferences[DEVICE_TYPE_KEY] = deviceTypeAdapter.toJson(config.type) - preferences[ACCESS_TOKEN_KEY] = config.apiAccessToken - preferences[API_BASE_URL_KEY] = config.apiBaseUrl - preferences[REFRESH_RATE_SEC_KEY] = config.refreshRateSecs - - // Save device ID if available - config.deviceMacId?.let { deviceMacId -> - preferences[DEVICE_MAC_ID_KEY] = deviceMacId - } - - // Save isMasterDevice if available - config.isMasterDevice?.let { isMaster -> - preferences[IS_MASTER_DEVICE_KEY] = isMaster.toString() - } ?: preferences.remove(IS_MASTER_DEVICE_KEY) - } - Timber.tag(TAG).d("Device config saved successfully") - } catch (e: Exception) { - Timber.tag(TAG).e(e, "Failed to save device config") - } - } - - /** - * Saves the device type - */ - suspend fun saveDeviceType(type: TrmnlDeviceType) { - context.deviceConfigStore.edit { preferences -> - preferences[DEVICE_TYPE_KEY] = deviceTypeAdapter.toJson(type) - } - } - - /** - * Saves the access token - */ - suspend fun saveAccessToken(token: String) { - context.deviceConfigStore.edit { preferences -> - preferences[ACCESS_TOKEN_KEY] = token.trim() - } - } - - /** - * Saves the server URL - */ - suspend fun saveServerUrl(url: String) { - context.deviceConfigStore.edit { preferences -> - preferences[API_BASE_URL_KEY] = url - } - } - - /** - * Saves the refresh rate in seconds - */ - suspend fun saveRefreshRateSeconds(seconds: Long) { - context.deviceConfigStore.edit { preferences -> - preferences[REFRESH_RATE_SEC_KEY] = seconds - } - } - - /** - * Saves the device ID (MAC address) - */ - suspend fun saveDeviceMacId(deviceMacId: String?) { - context.deviceConfigStore.edit { preferences -> - if (deviceMacId != null) { - preferences[DEVICE_MAC_ID_KEY] = deviceMacId } else { - preferences.remove(DEVICE_MAC_ID_KEY) + emptyMap() } - } - } + }.distinctUntilChanged() - /** - * 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) { + /** + * 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) + * + * **Domain Migration:** + * - Automatically migrates `usetrmnl.com` to `trmnl.com` for TRMNL device types + * - See: https://github.com/usetrmnl/trmnl-android/issues/240 + * + * 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 { + val config: TrmnlDeviceConfig? = deviceConfigAdapter.fromJson(configJson) + Timber.tag(TAG).d( + "Loading device config (JSON): type=${config?.type}", + ) + config + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to parse device config") + null + } + } else { + // Legacy migration path - build config from individual preferences + val type = + preferences[DEVICE_TYPE_KEY]?.let { try { - deviceModelPreferencesAdapter.fromJson(currentJson)?.toMutableMap() ?: mutableMapOf() + deviceTypeAdapter.fromJson(it) } catch (e: Exception) { - Timber.tag(TAG).e(e, "Failed to parse existing device model preferences") - mutableMapOf() + TrmnlDeviceType.TRMNL } - } else { - mutableMapOf() - } + } ?: TrmnlDeviceType.TRMNL - // Update the map with new value - currentMap[deviceType.name] = DeviceModelSelection(modelName, modelLabel) + val token = preferences[ACCESS_TOKEN_KEY] + val url = preferences[API_BASE_URL_KEY] ?: TRMNL_API_SERVER_BASE_URL + val refreshRate = preferences[REFRESH_RATE_SEC_KEY] ?: DEFAULT_REFRESH_INTERVAL_SEC + val deviceMacId = preferences[DEVICE_MAC_ID_KEY] + val isMasterDevice = preferences[IS_MASTER_DEVICE_KEY]?.toBoolean() - // Save back to preferences - preferences[DEVICE_MODEL_PREFERENCES_KEY] = deviceModelPreferencesAdapter.toJson(currentMap) + Timber.tag(TAG).d( + "Loading device config (legacy): type=$type, deviceApiToken=${token.obfuscated()}", + ) - Timber.tag(TAG).d("Saved device model preference: ${deviceType.name} -> $modelName ($modelLabel)") + if (token != null) { + TrmnlDeviceConfig( + type = type, + apiBaseUrl = url, + apiAccessToken = token, + deviceMacId = deviceMacId, + refreshRateSecs = refreshRate, + isMasterDevice = isMasterDevice, + ) + } else { + null + } } - } catch (e: Exception) { - Timber.tag(TAG).e(e, "Failed to save device model preference") - } - } + }.map { config -> + // Migrate usetrmnl.com -> trmnl.com for TRMNL device types + // See: https://github.com/usetrmnl/trmnl-android/issues/240 + if (config != null && + config.type == TrmnlDeviceType.TRMNL && + config.apiBaseUrl.contains(AppConfig.LEGACY_TRMNL_DOMAIN, ignoreCase = true) + ) { + val newUrl = config.apiBaseUrl.replace(AppConfig.LEGACY_TRMNL_DOMAIN, AppConfig.TRMNL_DOMAIN, ignoreCase = true) + Timber.tag(TAG).i( + "Migrating API base URL from ${config.apiBaseUrl} to $newUrl for TRMNL device", + ) + val migratedConfig = config.copy(apiBaseUrl = newUrl) + // Persist the migrated config back to DataStore (one-time migration) + // Note: Using runBlocking here as Flow.map doesn't support suspend operations. + // This is acceptable because: (1) it's a one-time migration per user, + // (2) DataStore writes are fast, and (3) distinctUntilChanged() below + // prevents duplicate downstream emissions from the save triggering re-collection. + runBlocking { + saveDeviceConfig(migratedConfig) + } + migratedConfig + } else { + config + } + }.distinctUntilChanged() - /** - * 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 - */ - val hasTokenFlow: Flow = - accessTokenFlow.map { token -> - !token.isNullOrBlank() - } - - /** - * Checks synchronously if token is already set - */ - fun hasTokenSync(): Boolean { - return runBlocking { - return@runBlocking hasTokenFlow.first() - } - } - - /** - * Gets the device config synchronously (blocking) - */ - fun getDeviceConfigSync(): TrmnlDeviceConfig? { - return runBlocking { - return@runBlocking deviceConfigFlow.first() - } - } - - /** - * Validates if the provided URL is properly formatted - */ - fun isValidServerUrl(url: String): Boolean = - try { - val uri = Uri.parse(url) - uri != null && (uri.scheme == "http" || uri.scheme == "https") && !uri.host.isNullOrEmpty() - } catch (e: Exception) { - false - } - - /** - * Checks if refresh rate needs to be updated - */ - suspend fun shouldUpdateRefreshRate(newRefreshRateSec: Long): Boolean { - val currentRefreshRate = refreshRateSecondsFlow.first() - return currentRefreshRate != null && newRefreshRateSec != currentRefreshRate - } - - /** - * Clears all stored preferences - */ - suspend fun clearAll() { + /** + * 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}", + ) + val configJson = deviceConfigAdapter.toJson(config) context.deviceConfigStore.edit { preferences -> - preferences.clear() + // Save as JSON for future use + preferences[CONFIG_JSON_KEY] = configJson + + // Also save individual fields for backward compatibility + preferences[DEVICE_TYPE_KEY] = deviceTypeAdapter.toJson(config.type) + preferences[ACCESS_TOKEN_KEY] = config.apiAccessToken + preferences[API_BASE_URL_KEY] = config.apiBaseUrl + preferences[REFRESH_RATE_SEC_KEY] = config.refreshRateSecs + + // Save device ID if available + config.deviceMacId?.let { deviceMacId -> + preferences[DEVICE_MAC_ID_KEY] = deviceMacId + } + + // Save isMasterDevice if available + config.isMasterDevice?.let { isMaster -> + preferences[IS_MASTER_DEVICE_KEY] = isMaster.toString() + } ?: preferences.remove(IS_MASTER_DEVICE_KEY) + } + Timber.tag(TAG).d("Device config saved successfully") + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to save device config") + } + } + + /** + * Saves the device type + */ + suspend fun saveDeviceType(type: TrmnlDeviceType) { + context.deviceConfigStore.edit { preferences -> + preferences[DEVICE_TYPE_KEY] = deviceTypeAdapter.toJson(type) + } + } + + /** + * Saves the access token + */ + suspend fun saveAccessToken(token: String) { + context.deviceConfigStore.edit { preferences -> + preferences[ACCESS_TOKEN_KEY] = token.trim() + } + } + + /** + * Saves the server URL + */ + suspend fun saveServerUrl(url: String) { + context.deviceConfigStore.edit { preferences -> + preferences[API_BASE_URL_KEY] = url + } + } + + /** + * Saves the refresh rate in seconds + */ + suspend fun saveRefreshRateSeconds(seconds: Long) { + context.deviceConfigStore.edit { preferences -> + preferences[REFRESH_RATE_SEC_KEY] = seconds + } + } + + /** + * Saves the device ID (MAC address) + */ + suspend fun saveDeviceMacId(deviceMacId: String?) { + context.deviceConfigStore.edit { preferences -> + if (deviceMacId != null) { + preferences[DEVICE_MAC_ID_KEY] = deviceMacId + } else { + preferences.remove(DEVICE_MAC_ID_KEY) } } } + + /** + * 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 + */ + val hasTokenFlow: Flow = + accessTokenFlow.map { token -> + !token.isNullOrBlank() + } + + /** + * Checks synchronously if token is already set + */ + fun hasTokenSync(): Boolean { + return runBlocking { + return@runBlocking hasTokenFlow.first() + } + } + + /** + * Gets the device config synchronously (blocking) + */ + fun getDeviceConfigSync(): TrmnlDeviceConfig? { + return runBlocking { + return@runBlocking deviceConfigFlow.first() + } + } + + /** + * Validates if the provided URL is properly formatted + */ + fun isValidServerUrl(url: String): Boolean = + try { + val uri = Uri.parse(url) + uri != null && (uri.scheme == "http" || uri.scheme == "https") && !uri.host.isNullOrEmpty() + } catch (e: Exception) { + false + } + + /** + * Checks if refresh rate needs to be updated + */ + suspend fun shouldUpdateRefreshRate(newRefreshRateSec: Long): Boolean { + val currentRefreshRate = refreshRateSecondsFlow.first() + return currentRefreshRate != null && newRefreshRateSec != currentRefreshRate + } + + /** + * Clears all stored preferences + */ + suspend fun clearAll() { + context.deviceConfigStore.edit { preferences -> + preferences.clear() + } + } +} diff --git a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt index 9823305..dd985b6 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt @@ -25,284 +25,283 @@ import timber.log.Timber /** * Repository class responsible for fetching and mapping display data. */ +@Inject @SingleIn(AppScope::class) -class TrmnlDisplayRepository - @Inject - constructor( - private val apiService: TrmnlApiService, - private val imageMetadataStore: ImageMetadataStore, - private val androidDeviceInfoProvider: AndroidDeviceInfoProvider, - ) { - /** - * Fetches display data for next plugin from the server using the provided access token. - * - * @param trmnlDeviceConfig Device configuration containing the access token and other settings. - * @return A [TrmnlDisplayInfo] object containing the display data. - */ - suspend fun getNextDisplayData(trmnlDeviceConfig: TrmnlDeviceConfig): TrmnlDisplayInfo { - Timber.i("Fetching next playlist item display data from server for device: ${trmnlDeviceConfig.type}") +class TrmnlDisplayRepository( + private val apiService: TrmnlApiService, + private val imageMetadataStore: ImageMetadataStore, + private val androidDeviceInfoProvider: AndroidDeviceInfoProvider, +) { + /** + * Fetches display data for next plugin from the server using the provided access token. + * + * @param trmnlDeviceConfig Device configuration containing the access token and other settings. + * @return A [TrmnlDisplayInfo] object containing the display data. + */ + suspend fun getNextDisplayData(trmnlDeviceConfig: TrmnlDeviceConfig): TrmnlDisplayInfo { + Timber.i("Fetching next playlist item display data from server for device: ${trmnlDeviceConfig.type}") - val result = - apiService - .getNextDisplayData( - fullApiUrl = constructApiUrl(trmnlDeviceConfig.apiBaseUrl, NEXT_PLAYLIST_SCREEN_API_PATH), - accessToken = trmnlDeviceConfig.apiAccessToken, - // Send device MAC ID if available (used for BYOS service) - deviceMacId = trmnlDeviceConfig.deviceMacId, - // TEMP FIX: Use Base64 encoding to avoid relative path issue - // See https://github.com/usetrmnl/trmnl-android/issues/76#issuecomment-2980018109 - // useBase64 = trmnlDeviceConfig.type == TrmnlDeviceType.BYOS, // Disabled for now - rssi = - if (trmnlDeviceConfig.type == TrmnlDeviceType.BYOD) { - // Send WiFi signal strength (RSSI) if available for BYOD devices only - androidDeviceInfoProvider.getWifiSignalStrength() - } else { - null - }, - percentCharged = - if (trmnlDeviceConfig.type == TrmnlDeviceType.BYOD) { - // Send battery percentage if available for BYOD devices only - // See following for context: - // https://github.com/usetrmnl/trmnl-android/issues/252 - // https://github.com/usetrmnl/trmnl-android/issues/239 - // https://discord.com/channels/1281055965508141100/1466030731770855434/1469103763846463620 - androidDeviceInfoProvider.getBatteryLevel() - } else { - null - }, - ) - - when (result) { - is ApiResult.Failure -> { - return failedTrmnlDisplayInfo(trmnlDeviceConfig, result) - } - is ApiResult.Success -> { - // Map the response to the display info - val response: TrmnlDisplayResponse = result.value - - if (isDeviceSetupRequired(trmnlDeviceConfig, response)) { - return TrmnlDisplayInfo.setupRequired() - } - - val displayInfo = - TrmnlDisplayInfo( - status = response.status, - trmnlDeviceType = trmnlDeviceConfig.type, - imageUrl = response.imageUrl ?: "", - imageFileName = response.imageFileName ?: "", - error = response.error, - refreshIntervalSeconds = response.refreshRate, - httpResponseMetadata = extractHttpResponseMetadata(result), - ) - - // If response was successful and has an image URL, save to data store - if (response.status.isHttpOk() && displayInfo.imageUrl.isNotEmpty()) { - imageMetadataStore.saveImageMetadata( - displayInfo.imageUrl, - displayInfo.refreshIntervalSeconds, - ) - } - - return displayInfo - } - } - } - - /** - * Fetches the current display data from the server using the provided access token. - * - * ⚠️ NOTE: This API is not available on BYOS servers. - * See https://discord.com/channels/1281055965508141100/1331360842809348106/1382863253880963124 - * - * @param trmnlDeviceConfig Device configuration containing the access token and other settings. - * @return A [TrmnlDisplayInfo] object containing the current display data. - */ - suspend fun getCurrentDisplayData(trmnlDeviceConfig: TrmnlDeviceConfig): TrmnlDisplayInfo { - Timber.i("Fetching current display data from server for device: ${trmnlDeviceConfig.type}") - - if (trmnlDeviceConfig.type == TrmnlDeviceType.BYOS) { - Timber.w("Current display image data API is not available for BYOS service.") - } - - val result = - apiService - .getCurrentDisplayData( - fullApiUrl = constructApiUrl(trmnlDeviceConfig.apiBaseUrl, CURRENT_PLAYLIST_SCREEN_API_PATH), - accessToken = trmnlDeviceConfig.apiAccessToken, - ) - - when (result) { - is ApiResult.Failure -> { - return failedTrmnlDisplayInfo(trmnlDeviceConfig, result) - } - is ApiResult.Success -> { - // Map the response to the display info - val response = result.value - val displayInfo = - TrmnlDisplayInfo( - status = response.status, - trmnlDeviceType = trmnlDeviceConfig.type, - imageUrl = response.imageUrl ?: "", - imageFileName = response.filename ?: "", - error = response.error, - refreshIntervalSeconds = response.refreshRateSec, - httpResponseMetadata = extractHttpResponseMetadata(result), - ) - - // If response was successful and has an image URL, save to data store - if (response.status.isHttpOk() && displayInfo.imageUrl.isNotEmpty()) { - imageMetadataStore.saveImageMetadata( - displayInfo.imageUrl, - displayInfo.refreshIntervalSeconds, - ) - } - - return displayInfo - } - } - } - - /** - * Sets up a new device by calling the setup API endpoint. - * - * This is only applicable for BYOS devices, as other device types do not require setup. - * - * @param trmnlDeviceConfig The configuration for the device to be set up. - * @return A [DeviceSetupInfo] object containing the result of the setup operation. - */ - suspend fun setupNewDevice(trmnlDeviceConfig: TrmnlDeviceConfig): DeviceSetupInfo { - if (trmnlDeviceConfig.type != TrmnlDeviceType.BYOS) { - Timber.w("Device setup is only applicable for BYOS devices.") - } - - val result = - apiService.setupNewDevice( - fullApiUrl = constructApiUrl(trmnlDeviceConfig.apiBaseUrl, TrmnlApiService.SETUP_API_PATH), - deviceMacId = requireNotNull(trmnlDeviceConfig.deviceMacId) { "Device MAC ID is required for setup" }, - ) - when (result) { - is ApiResult.Failure -> { - Timber.e("Failed to setup device: ${result.exceptionOrNull()}") - return DeviceSetupInfo( - success = false, - deviceMacId = trmnlDeviceConfig.deviceMacId, - apiKey = "", - message = "Failed to setup device with ID (${trmnlDeviceConfig.deviceMacId}). Reason: $result", - ) - } - is ApiResult.Success -> { - Timber.i("Device setup successful: ${result.value}") - return DeviceSetupInfo( - success = true, - deviceMacId = trmnlDeviceConfig.deviceMacId, - apiKey = result.value.apiKey, - message = result.value.message, - ) - } - } - } - - /** - * Converts an API failure result into a [TrmnlDisplayInfo] object with appropriate error details. - * - * This function handles different types of API failures and maps them to a standardized - * [TrmnlDisplayInfo] object. The returned object contains error information and default values - * for other fields. - */ - private fun failedTrmnlDisplayInfo( - trmnlDeviceConfig: TrmnlDeviceConfig, - failure: ApiResult.Failure, - ): TrmnlDisplayInfo = - TrmnlDisplayInfo( - status = (failure as? ApiResult.Failure.HttpFailure)?.code ?: HTTP_500, - trmnlDeviceType = trmnlDeviceConfig.type, - imageUrl = "", - imageFileName = "", - error = - when (failure) { - is ApiResult.Failure.ApiFailure -> "API request failed to process response" - is ApiResult.Failure.HttpFailure -> "HTTP failure: ${failure.code}, error: ${failure.error}" - is ApiResult.Failure.NetworkFailure -> "Network failure: ${failure.error.localizedMessage}" - is ApiResult.Failure.UnknownFailure -> "Unknown failure: ${failure.error.localizedMessage}" - }, - refreshIntervalSeconds = 0L, - httpResponseMetadata = extractHttpResponseMetadataFromFailure(failure), - ) - - /** - * Right now there is no good known way to determine if a device requires setup. - * The logic here is based on sample responses from the Terminus server API. - * - * See - * - https://discord.com/channels/1281055965508141100/1331360842809348106/1384605617456545904 - * - https://discord.com/channels/1281055965508141100/1384605617456545904/1384613229086511135 - */ - private fun isDeviceSetupRequired( - trmnlDeviceConfig: TrmnlDeviceConfig, - response: TrmnlDisplayResponse, - ): Boolean = - (trmnlDeviceConfig.type == TrmnlDeviceType.BYOS) && - (response.imageFileName?.startsWith("setup", ignoreCase = true) == true) && - // This ensures that no screen is generated yet for the device - // Example (when device is not set up): - // -- "filename": "setup" - // -- "image_url": "/assets/setup-A2B2C2.svg" - // Example (when device is set up): - // -- "filename": "setup.png" - // -- "image_url": "https://my-trmnl-hub.com/assets/screens/ABCDEF123/setup.png", - (response.imageUrl?.contains("screens", ignoreCase = true) == false) - - /** - * Fetches the list of available device models from the TRMNL API. - * - * This provides information about all supported device models including - * display specifications, supported palettes, and device characteristics. - * - * The API response is converted to simplified [SupportedDeviceModel] DTOs containing - * only the essential information needed for device selection. - * - * @param serverBaseUrl The base URL of the server to fetch models from (defaults to TRMNL API). - * @return A list of [SupportedDeviceModel] objects, or an empty list on failure. - */ - suspend fun getDeviceModels(serverBaseUrl: String): List { - Timber.i("Fetching device models from server: $serverBaseUrl") - - val result = - apiService.getDeviceModels( - fullApiUrl = constructApiUrl(serverBaseUrl, MODELS_API_PATH), + val result = + apiService + .getNextDisplayData( + fullApiUrl = constructApiUrl(trmnlDeviceConfig.apiBaseUrl, NEXT_PLAYLIST_SCREEN_API_PATH), + accessToken = trmnlDeviceConfig.apiAccessToken, + // Send device MAC ID if available (used for BYOS service) + deviceMacId = trmnlDeviceConfig.deviceMacId, + // TEMP FIX: Use Base64 encoding to avoid relative path issue + // See https://github.com/usetrmnl/trmnl-android/issues/76#issuecomment-2980018109 + // useBase64 = trmnlDeviceConfig.type == TrmnlDeviceType.BYOS, // Disabled for now + rssi = + if (trmnlDeviceConfig.type == TrmnlDeviceType.BYOD) { + // Send WiFi signal strength (RSSI) if available for BYOD devices only + androidDeviceInfoProvider.getWifiSignalStrength() + } else { + null + }, + percentCharged = + if (trmnlDeviceConfig.type == TrmnlDeviceType.BYOD) { + // Send battery percentage if available for BYOD devices only + // See following for context: + // https://github.com/usetrmnl/trmnl-android/issues/252 + // https://github.com/usetrmnl/trmnl-android/issues/239 + // https://discord.com/channels/1281055965508141100/1466030731770855434/1469103763846463620 + androidDeviceInfoProvider.getBatteryLevel() + } else { + null + }, ) - return when (result) { - is ApiResult.Failure -> { - Timber.e("Failed to fetch device models: ${result.exceptionOrNull()}") - emptyList() + when (result) { + is ApiResult.Failure -> { + return failedTrmnlDisplayInfo(trmnlDeviceConfig, result) + } + is ApiResult.Success -> { + // Map the response to the display info + val response: TrmnlDisplayResponse = result.value + + if (isDeviceSetupRequired(trmnlDeviceConfig, response)) { + return TrmnlDisplayInfo.setupRequired() } - is ApiResult.Success -> { - Timber.i("Successfully fetched ${result.value.data.size} device models") - // Convert API models to simplified SupportedDeviceModel DTOs - result.value.data.map { it.toSupportedDeviceModel() } + + val displayInfo = + TrmnlDisplayInfo( + status = response.status, + trmnlDeviceType = trmnlDeviceConfig.type, + imageUrl = response.imageUrl ?: "", + imageFileName = response.imageFileName ?: "", + error = response.error, + refreshIntervalSeconds = response.refreshRate, + httpResponseMetadata = extractHttpResponseMetadata(result), + ) + + // If response was successful and has an image URL, save to data store + if (response.status.isHttpOk() && displayInfo.imageUrl.isNotEmpty()) { + imageMetadataStore.saveImageMetadata( + displayInfo.imageUrl, + displayInfo.refreshIntervalSeconds, + ) } + + return displayInfo } } - - /** - * Converts a [TrmnlDeviceModel] API response to a simplified [SupportedDeviceModel] DTO. - * - * This extension function extracts only the essential device information needed - * for UI purposes, making it Parcelable for navigation results. - */ - private fun TrmnlDeviceModel.toSupportedDeviceModel() = - SupportedDeviceModel( - name = name, - label = label, - description = description, - width = width, - height = height, - colors = colors, - bitDepth = bitDepth, - scaleFactor = scaleFactor, - rotation = rotation, - mimeType = mimeType, - kind = kind, - ) } + + /** + * Fetches the current display data from the server using the provided access token. + * + * ⚠️ NOTE: This API is not available on BYOS servers. + * See https://discord.com/channels/1281055965508141100/1331360842809348106/1382863253880963124 + * + * @param trmnlDeviceConfig Device configuration containing the access token and other settings. + * @return A [TrmnlDisplayInfo] object containing the current display data. + */ + suspend fun getCurrentDisplayData(trmnlDeviceConfig: TrmnlDeviceConfig): TrmnlDisplayInfo { + Timber.i("Fetching current display data from server for device: ${trmnlDeviceConfig.type}") + + if (trmnlDeviceConfig.type == TrmnlDeviceType.BYOS) { + Timber.w("Current display image data API is not available for BYOS service.") + } + + val result = + apiService + .getCurrentDisplayData( + fullApiUrl = constructApiUrl(trmnlDeviceConfig.apiBaseUrl, CURRENT_PLAYLIST_SCREEN_API_PATH), + accessToken = trmnlDeviceConfig.apiAccessToken, + ) + + when (result) { + is ApiResult.Failure -> { + return failedTrmnlDisplayInfo(trmnlDeviceConfig, result) + } + is ApiResult.Success -> { + // Map the response to the display info + val response = result.value + val displayInfo = + TrmnlDisplayInfo( + status = response.status, + trmnlDeviceType = trmnlDeviceConfig.type, + imageUrl = response.imageUrl ?: "", + imageFileName = response.filename ?: "", + error = response.error, + refreshIntervalSeconds = response.refreshRateSec, + httpResponseMetadata = extractHttpResponseMetadata(result), + ) + + // If response was successful and has an image URL, save to data store + if (response.status.isHttpOk() && displayInfo.imageUrl.isNotEmpty()) { + imageMetadataStore.saveImageMetadata( + displayInfo.imageUrl, + displayInfo.refreshIntervalSeconds, + ) + } + + return displayInfo + } + } + } + + /** + * Sets up a new device by calling the setup API endpoint. + * + * This is only applicable for BYOS devices, as other device types do not require setup. + * + * @param trmnlDeviceConfig The configuration for the device to be set up. + * @return A [DeviceSetupInfo] object containing the result of the setup operation. + */ + suspend fun setupNewDevice(trmnlDeviceConfig: TrmnlDeviceConfig): DeviceSetupInfo { + if (trmnlDeviceConfig.type != TrmnlDeviceType.BYOS) { + Timber.w("Device setup is only applicable for BYOS devices.") + } + + val result = + apiService.setupNewDevice( + fullApiUrl = constructApiUrl(trmnlDeviceConfig.apiBaseUrl, TrmnlApiService.SETUP_API_PATH), + deviceMacId = requireNotNull(trmnlDeviceConfig.deviceMacId) { "Device MAC ID is required for setup" }, + ) + when (result) { + is ApiResult.Failure -> { + Timber.e("Failed to setup device: ${result.exceptionOrNull()}") + return DeviceSetupInfo( + success = false, + deviceMacId = trmnlDeviceConfig.deviceMacId, + apiKey = "", + message = "Failed to setup device with ID (${trmnlDeviceConfig.deviceMacId}). Reason: $result", + ) + } + is ApiResult.Success -> { + Timber.i("Device setup successful: ${result.value}") + return DeviceSetupInfo( + success = true, + deviceMacId = trmnlDeviceConfig.deviceMacId, + apiKey = result.value.apiKey, + message = result.value.message, + ) + } + } + } + + /** + * Converts an API failure result into a [TrmnlDisplayInfo] object with appropriate error details. + * + * This function handles different types of API failures and maps them to a standardized + * [TrmnlDisplayInfo] object. The returned object contains error information and default values + * for other fields. + */ + private fun failedTrmnlDisplayInfo( + trmnlDeviceConfig: TrmnlDeviceConfig, + failure: ApiResult.Failure, + ): TrmnlDisplayInfo = + TrmnlDisplayInfo( + status = (failure as? ApiResult.Failure.HttpFailure)?.code ?: HTTP_500, + trmnlDeviceType = trmnlDeviceConfig.type, + imageUrl = "", + imageFileName = "", + error = + when (failure) { + is ApiResult.Failure.ApiFailure -> "API request failed to process response" + is ApiResult.Failure.HttpFailure -> "HTTP failure: ${failure.code}, error: ${failure.error}" + is ApiResult.Failure.NetworkFailure -> "Network failure: ${failure.error.localizedMessage}" + is ApiResult.Failure.UnknownFailure -> "Unknown failure: ${failure.error.localizedMessage}" + }, + refreshIntervalSeconds = 0L, + httpResponseMetadata = extractHttpResponseMetadataFromFailure(failure), + ) + + /** + * Right now there is no good known way to determine if a device requires setup. + * The logic here is based on sample responses from the Terminus server API. + * + * See + * - https://discord.com/channels/1281055965508141100/1331360842809348106/1384605617456545904 + * - https://discord.com/channels/1281055965508141100/1384605617456545904/1384613229086511135 + */ + private fun isDeviceSetupRequired( + trmnlDeviceConfig: TrmnlDeviceConfig, + response: TrmnlDisplayResponse, + ): Boolean = + (trmnlDeviceConfig.type == TrmnlDeviceType.BYOS) && + (response.imageFileName?.startsWith("setup", ignoreCase = true) == true) && + // This ensures that no screen is generated yet for the device + // Example (when device is not set up): + // -- "filename": "setup" + // -- "image_url": "/assets/setup-A2B2C2.svg" + // Example (when device is set up): + // -- "filename": "setup.png" + // -- "image_url": "https://my-trmnl-hub.com/assets/screens/ABCDEF123/setup.png", + (response.imageUrl?.contains("screens", ignoreCase = true) == false) + + /** + * Fetches the list of available device models from the TRMNL API. + * + * This provides information about all supported device models including + * display specifications, supported palettes, and device characteristics. + * + * The API response is converted to simplified [SupportedDeviceModel] DTOs containing + * only the essential information needed for device selection. + * + * @param serverBaseUrl The base URL of the server to fetch models from (defaults to TRMNL API). + * @return A list of [SupportedDeviceModel] objects, or an empty list on failure. + */ + suspend fun getDeviceModels(serverBaseUrl: String): List { + Timber.i("Fetching device models from server: $serverBaseUrl") + + val result = + apiService.getDeviceModels( + fullApiUrl = constructApiUrl(serverBaseUrl, MODELS_API_PATH), + ) + + return when (result) { + is ApiResult.Failure -> { + Timber.e("Failed to fetch device models: ${result.exceptionOrNull()}") + emptyList() + } + is ApiResult.Success -> { + Timber.i("Successfully fetched ${result.value.data.size} device models") + // Convert API models to simplified SupportedDeviceModel DTOs + result.value.data.map { it.toSupportedDeviceModel() } + } + } + } + + /** + * Converts a [TrmnlDeviceModel] API response to a simplified [SupportedDeviceModel] DTO. + * + * This extension function extracts only the essential device information needed + * for UI purposes, making it Parcelable for navigation results. + */ + private fun TrmnlDeviceModel.toSupportedDeviceModel() = + SupportedDeviceModel( + name = name, + label = label, + description = description, + width = width, + height = height, + colors = colors, + bitDepth = bitDepth, + scaleFactor = scaleFactor, + rotation = rotation, + mimeType = mimeType, + kind = kind, + ) +} diff --git a/app/src/main/java/ink/trmnl/android/data/TrmnlUserRepository.kt b/app/src/main/java/ink/trmnl/android/data/TrmnlUserRepository.kt index ff74cf1..cbd8cf0 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlUserRepository.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlUserRepository.kt @@ -28,76 +28,75 @@ import timber.log.Timber * - https://github.com/usetrmnl/trmnl-android/pull/253 * - https://discord.com/channels/1281055965508141100/1466030731770855434/1469103763846463620 */ +@Inject @SingleIn(AppScope::class) -class TrmnlUserRepository - @Inject - constructor( - private val userApiService: TrmnlUserApiService, - private val androidDeviceInfoProvider: AndroidDeviceInfoProvider, - ) { - /** - * Validates a user API token by calling the /api/me endpoint. - * - * This method is used to verify user-level (account) API tokens before saving them. - * On success, returns the user's information. - * - * @param apiBaseUrl The base URL of the TRMNL API server - * @param userApiToken The user API token to validate (should start with "user_") - * @return A Result containing TrmnlUser on success or an exception on failure - */ - suspend fun validateUserApiToken( - apiBaseUrl: String, - userApiToken: String, - ): Result { - Timber.i("Validating user API token") +class TrmnlUserRepository( + private val userApiService: TrmnlUserApiService, + private val androidDeviceInfoProvider: AndroidDeviceInfoProvider, +) { + /** + * Validates a user API token by calling the /api/me endpoint. + * + * This method is used to verify user-level (account) API tokens before saving them. + * On success, returns the user's information. + * + * @param apiBaseUrl The base URL of the TRMNL API server + * @param userApiToken The user API token to validate (should start with "user_") + * @return A Result containing TrmnlUser on success or an exception on failure + */ + suspend fun validateUserApiToken( + apiBaseUrl: String, + userApiToken: String, + ): Result { + Timber.i("Validating user API token") - val result = - userApiService.getUserInfo( - fullApiUrl = constructApiUrl(apiBaseUrl, USER_INFO_API_PATH), - accessToken = "Bearer $userApiToken", - ) + val result = + userApiService.getUserInfo( + fullApiUrl = constructApiUrl(apiBaseUrl, USER_INFO_API_PATH), + accessToken = "Bearer $userApiToken", + ) - return when (result) { - is ApiResult.Failure -> { - val exception = result.exceptionOrNull() - Timber.e(exception, "User API token validation failed") - Result.failure(exception ?: Exception("Failed to validate user token")) - } - is ApiResult.Success -> { - Timber.i("User API token validated successfully: ${result.value.data.email}") - Result.success(result.value.data) - } + return when (result) { + is ApiResult.Failure -> { + val exception = result.exceptionOrNull() + Timber.e(exception, "User API token validation failed") + Result.failure(exception ?: Exception("Failed to validate user token")) + } + is ApiResult.Success -> { + Timber.i("User API token validated successfully: ${result.value.data.email}") + Result.success(result.value.data) } } + } - /** - * Fetches the device ID from the TRMNL API using the device API token. - * - * **DEPRECATED:** Device ID fetching is no longer needed. Battery reporting now uses - * the Percent-Charged header in /api/display call, which only requires device-level - * authentication (Access-Token), not user-level authentication or device ID. - * - * This method will be removed in a future version. - * - * This method calls the /api/devices/me endpoint with device-level authentication - * to retrieve device information including the device ID, which is needed for - * user-level API calls to /api/devices/{id}. - * - * **Note:** This endpoint doesn't exist on the server yet, so this method - * returns a mocked response until the server endpoint is implemented. - * - * @param config Device configuration containing the device API token - * @return A Result containing the device ID on success or an exception on failure - */ - @Deprecated("Device ID no longer needed for battery reporting. Use Percent-Charged header instead.") - suspend fun getDeviceIdFromApi(config: TrmnlDeviceConfig): Result { - Timber.w("getDeviceIdFromApi is deprecated. Device ID is no longer needed for battery reporting.") - return Result.failure( - UnsupportedOperationException( - "Device ID fetching is deprecated. Battery reporting now uses Percent-Charged header " + - "in /api/display, which doesn't require device ID or user API token.", - ), - ) + /** + * Fetches the device ID from the TRMNL API using the device API token. + * + * **DEPRECATED:** Device ID fetching is no longer needed. Battery reporting now uses + * the Percent-Charged header in /api/display call, which only requires device-level + * authentication (Access-Token), not user-level authentication or device ID. + * + * This method will be removed in a future version. + * + * This method calls the /api/devices/me endpoint with device-level authentication + * to retrieve device information including the device ID, which is needed for + * user-level API calls to /api/devices/{id}. + * + * **Note:** This endpoint doesn't exist on the server yet, so this method + * returns a mocked response until the server endpoint is implemented. + * + * @param config Device configuration containing the device API token + * @return A Result containing the device ID on success or an exception on failure + */ + @Deprecated("Device ID no longer needed for battery reporting. Use Percent-Charged header instead.") + suspend fun getDeviceIdFromApi(config: TrmnlDeviceConfig): Result { + Timber.w("getDeviceIdFromApi is deprecated. Device ID is no longer needed for battery reporting.") + return Result.failure( + UnsupportedOperationException( + "Device ID fetching is deprecated. Battery reporting now uses Percent-Charged header " + + "in /api/display, which doesn't require device ID or user API token.", + ), + ) /* DISABLED - Device ID no longer needed for battery reporting Timber.i("Fetching device ID from API for device type: ${config.type}") @@ -144,26 +143,26 @@ class TrmnlUserRepository * } */ */ - } + } - /** - * Reports the device's battery status to the TRMNL API for BYOD devices. - * - * **DEPRECATED:** Battery reporting now uses the Percent-Charged header in /api/display call. - * This method is disabled and will be removed in a future version. - * - * Battery percentage is now automatically sent via the Percent-Charged header parameter - * when calling getNextDisplayData() for BYOD devices, eliminating the need for a separate - * API call and user-level authentication. - * - * @param config Device configuration containing device type, device ID, and user API token - * @see TrmnlDisplayRepository.getNextDisplayData - */ - @Deprecated("Battery reporting now uses Percent-Charged header. This method is no longer needed.") - suspend fun reportDeviceBatteryStatus(config: TrmnlDeviceConfig) { - // DEPRECATED: Battery reporting now happens via Percent-Charged header in /api/display - Timber.d("Battery reporting via separate API call is deprecated. Battery is now sent via Percent-Charged header.") - return + /** + * Reports the device's battery status to the TRMNL API for BYOD devices. + * + * **DEPRECATED:** Battery reporting now uses the Percent-Charged header in /api/display call. + * This method is disabled and will be removed in a future version. + * + * Battery percentage is now automatically sent via the Percent-Charged header parameter + * when calling getNextDisplayData() for BYOD devices, eliminating the need for a separate + * API call and user-level authentication. + * + * @param config Device configuration containing device type, device ID, and user API token + * @see TrmnlDisplayRepository.getNextDisplayData + */ + @Deprecated("Battery reporting now uses Percent-Charged header. This method is no longer needed.") + suspend fun reportDeviceBatteryStatus(config: TrmnlDeviceConfig) { + // DEPRECATED: Battery reporting now happens via Percent-Charged header in /api/display + Timber.d("Battery reporting via separate API call is deprecated. Battery is now sent via Percent-Charged header.") + return /* DISABLED - Battery now reported via Percent-Charged header // Only report battery for BYOD devices with required configuration @@ -199,32 +198,32 @@ class TrmnlUserRepository Timber.e(e, "Unexpected error during battery reporting") } */ - } + } - /** - * Reports the device's battery status to the TRMNL API. - * - * **DEPRECATED:** This method is no longer used. Battery reporting now uses the - * Percent-Charged header in /api/display call instead of PATCH /api/devices/{id}. - * - * This method will be removed in a future version. - * - * @param config Device configuration containing the device ID and user API token - * @param batteryPercent The current battery percentage (0-100) - * @return A Result containing Unit on success or an exception on failure - */ - @Deprecated("Battery reporting now uses Percent-Charged header. This method is no longer needed.") - private suspend fun reportBatteryStatus( - config: TrmnlDeviceConfig, - batteryPercent: Int, - ): Result { - // DEPRECATED: This method is no longer used - Timber.d("reportBatteryStatus is deprecated and disabled") - return Result.failure( - UnsupportedOperationException( - "Battery reporting via PATCH /api/devices/{id} is deprecated. Use Percent-Charged header instead.", - ), - ) + /** + * Reports the device's battery status to the TRMNL API. + * + * **DEPRECATED:** This method is no longer used. Battery reporting now uses the + * Percent-Charged header in /api/display call instead of PATCH /api/devices/{id}. + * + * This method will be removed in a future version. + * + * @param config Device configuration containing the device ID and user API token + * @param batteryPercent The current battery percentage (0-100) + * @return A Result containing Unit on success or an exception on failure + */ + @Deprecated("Battery reporting now uses Percent-Charged header. This method is no longer needed.") + private suspend fun reportBatteryStatus( + config: TrmnlDeviceConfig, + batteryPercent: Int, + ): Result { + // DEPRECATED: This method is no longer used + Timber.d("reportBatteryStatus is deprecated and disabled") + return Result.failure( + UnsupportedOperationException( + "Battery reporting via PATCH /api/devices/{id} is deprecated. Use Percent-Charged header instead.", + ), + ) /* DISABLED - Battery now reported via Percent-Charged header val deviceId = config.deviceId @@ -265,5 +264,5 @@ class TrmnlUserRepository } } */ - } } +} diff --git a/app/src/main/java/ink/trmnl/android/data/log/RefreshLogExporter.kt b/app/src/main/java/ink/trmnl/android/data/log/RefreshLogExporter.kt index 964f0ac..10b448a 100644 --- a/app/src/main/java/ink/trmnl/android/data/log/RefreshLogExporter.kt +++ b/app/src/main/java/ink/trmnl/android/data/log/RefreshLogExporter.kt @@ -18,79 +18,78 @@ import java.util.Locale /** * Handles exporting refresh logs to a JSON file and sharing it via Android's share intent. */ -class RefreshLogExporter - @Inject - constructor( - @ApplicationContext private val context: Context, - private val moshi: Moshi, - ) { - /** - * Exports the logs to a JSON file and shares it via Android's share intent. - * - * @param context Android context used to create files and launch the share intent - * @param logs List of refresh logs to export - */ - internal suspend fun exportLogsAndShare(logs: List) { - withContext(Dispatchers.IO) { - try { - // Create timestamp for filename - val timestamp = - SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format( - Instant.now().toEpochMilli(), - ) - val filename = "trmnl_refresh_logs_$timestamp.json" +@Inject +class RefreshLogExporter( + @ApplicationContext private val context: Context, + private val moshi: Moshi, +) { + /** + * Exports the logs to a JSON file and shares it via Android's share intent. + * + * @param context Android context used to create files and launch the share intent + * @param logs List of refresh logs to export + */ + internal suspend fun exportLogsAndShare(logs: List) { + withContext(Dispatchers.IO) { + try { + // Create timestamp for filename + val timestamp = + SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format( + Instant.now().toEpochMilli(), + ) + val filename = "trmnl_refresh_logs_$timestamp.json" - // Create cache directory if it doesn't exist - val cacheDir = File(context.cacheDir, "logs") - if (!cacheDir.exists()) { - cacheDir.mkdirs() + // Create cache directory if it doesn't exist + val cacheDir = File(context.cacheDir, "logs") + if (!cacheDir.exists()) { + cacheDir.mkdirs() + } + + // Create the JSON file in the cache directory + val file = File(cacheDir, filename) + + // create json content with logs + val adapter = moshi.adapter(TrmnlRefreshLogs::class.java) + val jsonContent = adapter.toJson(TrmnlRefreshLogs(logs)) + + // Write JSON to file + file.writeText(jsonContent) + + // Get content URI via FileProvider + val fileUri = + FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file, + ) + + // Create share intent + val shareIntent = + Intent(Intent.ACTION_SEND).apply { + type = "application/json" + putExtra(Intent.EXTRA_STREAM, fileUri) + putExtra(Intent.EXTRA_SUBJECT, "Share TRMNL Display Image Refresh Logs") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - // Create the JSON file in the cache directory - val file = File(cacheDir, filename) + // Launch the share dialog + val chooserIntent = Intent.createChooser(shareIntent, "TRMNL Image Refresh Logs") + chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(chooserIntent) + } catch (e: Exception) { + Timber.e(e, "Error exporting logs") - // create json content with logs - val adapter = moshi.adapter(TrmnlRefreshLogs::class.java) - val jsonContent = adapter.toJson(TrmnlRefreshLogs(logs)) - - // Write JSON to file - file.writeText(jsonContent) - - // Get content URI via FileProvider - val fileUri = - FileProvider.getUriForFile( + // Show error toast with the exception message on the main thread + withContext(Dispatchers.Main) { + val errorMessage = e.localizedMessage ?: "Unknown error while exporting logs" + Toast + .makeText( context, - "${context.packageName}.fileprovider", - file, - ) - - // Create share intent - val shareIntent = - Intent(Intent.ACTION_SEND).apply { - type = "application/json" - putExtra(Intent.EXTRA_STREAM, fileUri) - putExtra(Intent.EXTRA_SUBJECT, "Share TRMNL Display Image Refresh Logs") - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - - // Launch the share dialog - val chooserIntent = Intent.createChooser(shareIntent, "TRMNL Image Refresh Logs") - chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(chooserIntent) - } catch (e: Exception) { - Timber.e(e, "Error exporting logs") - - // Show error toast with the exception message on the main thread - withContext(Dispatchers.Main) { - val errorMessage = e.localizedMessage ?: "Unknown error while exporting logs" - Toast - .makeText( - context, - "Failed to export logs: $errorMessage", - Toast.LENGTH_LONG, - ).show() - } + "Failed to export logs: $errorMessage", + Toast.LENGTH_LONG, + ).show() } } } } +} diff --git a/app/src/main/java/ink/trmnl/android/data/log/TrmnlRefreshLogManager.kt b/app/src/main/java/ink/trmnl/android/data/log/TrmnlRefreshLogManager.kt index 87e1d29..a6539df 100644 --- a/app/src/main/java/ink/trmnl/android/data/log/TrmnlRefreshLogManager.kt +++ b/app/src/main/java/ink/trmnl/android/data/log/TrmnlRefreshLogManager.kt @@ -19,81 +19,80 @@ import timber.log.Timber * Provides functionality to add successful and failed refresh logs, * access log data through Flow, and clear logs when needed. */ +@Inject @SingleIn(AppScope::class) -class TrmnlRefreshLogManager - @Inject - constructor( - @ApplicationContext private val context: Context, - private val dataStore: DataStore, +class TrmnlRefreshLogManager( + @ApplicationContext private val context: Context, + private val dataStore: DataStore, +) { + /** + * Flow of terminal refresh logs ordered from newest to oldest. + * Handles errors by emitting an empty log list and logging the exception. + */ + val logsFlow: Flow> = + dataStore.data + .catch { e -> + Timber.e(e, "Error reading logs") + emit(TrmnlRefreshLogs()) + }.map { it.logs } + + /** + * Records a successful image refresh operation. + */ + suspend fun addSuccessLog( + trmnlDeviceType: TrmnlDeviceType, + imageUrl: String, + imageName: String, + refreshIntervalSeconds: Long?, + imageRefreshWorkType: String?, + httpResponseMetadata: HttpResponseMetadata? = null, ) { - /** - * Flow of terminal refresh logs ordered from newest to oldest. - * Handles errors by emitting an empty log list and logging the exception. - */ - val logsFlow: Flow> = - dataStore.data - .catch { e -> - Timber.e(e, "Error reading logs") - emit(TrmnlRefreshLogs()) - }.map { it.logs } + addLog( + TrmnlRefreshLog.createSuccess( + trmnlDeviceType = trmnlDeviceType, + imageUrl = imageUrl, + imageName = imageName, + refreshIntervalSeconds = refreshIntervalSeconds, + imageRefreshWorkType = imageRefreshWorkType, + httpResponseMetadata = httpResponseMetadata, + ), + ) + } - /** - * Records a successful image refresh operation. - */ - suspend fun addSuccessLog( - trmnlDeviceType: TrmnlDeviceType, - imageUrl: String, - imageName: String, - refreshIntervalSeconds: Long?, - imageRefreshWorkType: String?, - httpResponseMetadata: HttpResponseMetadata? = null, - ) { - addLog( - TrmnlRefreshLog.createSuccess( - trmnlDeviceType = trmnlDeviceType, - imageUrl = imageUrl, - imageName = imageName, - refreshIntervalSeconds = refreshIntervalSeconds, - imageRefreshWorkType = imageRefreshWorkType, - httpResponseMetadata = httpResponseMetadata, - ), - ) - } + /** + * Records a failed image refresh operation. + */ + suspend fun addFailureLog( + error: String, + httpResponseMetadata: HttpResponseMetadata? = null, + ) { + addLog(TrmnlRefreshLog.createFailure(error, httpResponseMetadata)) + } - /** - * Records a failed image refresh operation. - */ - suspend fun addFailureLog( - error: String, - httpResponseMetadata: HttpResponseMetadata? = null, - ) { - addLog(TrmnlRefreshLog.createFailure(error, httpResponseMetadata)) - } - - /** - * Adds a log entry to the beginning of the log list and trims older entries if necessary. - * Maintains a maximum number of log entries defined by [MAX_LOG_ENTRIES]. - */ - internal suspend fun addLog(log: TrmnlRefreshLog) { - dataStore.updateData { currentLogs -> - val updatedLogs = - currentLogs.logs.toMutableList().apply { - add(0, log) // Add to the beginning for descending order - if (size > MAX_LOG_ENTRIES) { - // Keep only the most recent logs - removeAll(subList(MAX_LOG_ENTRIES, size)) - } + /** + * Adds a log entry to the beginning of the log list and trims older entries if necessary. + * Maintains a maximum number of log entries defined by [MAX_LOG_ENTRIES]. + */ + internal suspend fun addLog(log: TrmnlRefreshLog) { + dataStore.updateData { currentLogs -> + val updatedLogs = + currentLogs.logs.toMutableList().apply { + add(0, log) // Add to the beginning for descending order + if (size > MAX_LOG_ENTRIES) { + // Keep only the most recent logs + removeAll(subList(MAX_LOG_ENTRIES, size)) } - TrmnlRefreshLogs(updatedLogs) - } - } - - /** - * Removes all logs from storage. - */ - suspend fun clearLogs() { - dataStore.updateData { - TrmnlRefreshLogs(emptyList()) - } + } + TrmnlRefreshLogs(updatedLogs) } } + + /** + * Removes all logs from storage. + */ + suspend fun clearLogs() { + dataStore.updateData { + TrmnlRefreshLogs(emptyList()) + } + } +} diff --git a/app/src/main/java/ink/trmnl/android/data/log/TrmnlRefreshLogSerializer.kt b/app/src/main/java/ink/trmnl/android/data/log/TrmnlRefreshLogSerializer.kt index 495998c..b3e7ac4 100644 --- a/app/src/main/java/ink/trmnl/android/data/log/TrmnlRefreshLogSerializer.kt +++ b/app/src/main/java/ink/trmnl/android/data/log/TrmnlRefreshLogSerializer.kt @@ -30,12 +30,12 @@ object TrmnlRefreshLogSerializer : Serializer { } override suspend fun writeTo( - refreshLogs: TrmnlRefreshLogs, + t: TrmnlRefreshLogs, output: OutputStream, ) { withContext(Dispatchers.IO) { try { - val jsonString = adapter.toJson(refreshLogs) + val jsonString = adapter.toJson(t) output.write(jsonString.toByteArray()) } catch (e: Exception) { Timber.e(e, "Error writing activity logs") diff --git a/app/src/main/java/ink/trmnl/android/ui/aboutapp/AppInfoScreen.kt b/app/src/main/java/ink/trmnl/android/ui/aboutapp/AppInfoScreen.kt index 286a1f5..20f4eb9 100644 --- a/app/src/main/java/ink/trmnl/android/ui/aboutapp/AppInfoScreen.kt +++ b/app/src/main/java/ink/trmnl/android/ui/aboutapp/AppInfoScreen.kt @@ -72,41 +72,40 @@ data object AppInfoScreen : Screen { } } -class AppInfoPresenter - @AssistedInject - constructor( - @Assisted private val navigator: Navigator, - ) : Presenter { - @Composable - override fun present(): AppInfoScreen.State { - val uriHandler = LocalUriHandler.current - val appVersion = BuildConfig.VERSION_NAME - val buildType = BuildConfig.BUILD_TYPE +@AssistedInject +class AppInfoPresenter( + @Assisted private val navigator: Navigator, +) : Presenter { + @Composable + override fun present(): AppInfoScreen.State { + val uriHandler = LocalUriHandler.current + val appVersion = BuildConfig.VERSION_NAME + val buildType = BuildConfig.BUILD_TYPE - return State( - appVersion = appVersion, - buildType = buildType, - eventSink = { event -> - when (event) { - Event.BackPressed -> navigator.pop() - Event.OpenGithub -> { - uriHandler.openUri(TRMNL_ANDROID_APP_GITHUB_URL) - } - Event.OpenTrmnlSite -> { - uriHandler.openUri(TRMNL_SITE_URL) - } + return State( + appVersion = appVersion, + buildType = buildType, + eventSink = { event -> + when (event) { + Event.BackPressed -> navigator.pop() + Event.OpenGithub -> { + uriHandler.openUri(TRMNL_ANDROID_APP_GITHUB_URL) } - }, - ) - } - - @CircuitInject(AppInfoScreen::class, AppScope::class) - @AssistedFactory - fun interface Factory { - fun create(navigator: Navigator): AppInfoPresenter - } + Event.OpenTrmnlSite -> { + uriHandler.openUri(TRMNL_SITE_URL) + } + } + }, + ) } + @CircuitInject(AppInfoScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(navigator: Navigator): AppInfoPresenter + } +} + @CircuitInject(AppInfoScreen::class, AppScope::class) @OptIn(ExperimentalMaterial3Api::class) @Composable 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 6a8742f..311e641 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 @@ -141,109 +141,108 @@ data class DeviceModelSelectorScreen( * Presenter for the DeviceModelSelectorScreen. * Manages the screen's state and handles events from the UI. */ -class DeviceModelSelectorPresenter - @AssistedInject - constructor( - @Assisted private val navigator: Navigator, - @Assisted private val screen: DeviceModelSelectorScreen, - private val repository: TrmnlDisplayRepository, - ) : Presenter { - /** - * Creates and returns the state for the DeviceModelSelectorScreen. - * Fetches device models from the repository and handles user interactions. - * - * @return The current UI state. - */ - @Composable - override fun present(): DeviceModelSelectorScreen.State { - var models by remember { mutableStateOf>(emptyList()) } - var isLoading by remember { mutableStateOf(true) } - var errorMessage by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() +@AssistedInject +class DeviceModelSelectorPresenter( + @Assisted private val navigator: Navigator, + @Assisted private val screen: DeviceModelSelectorScreen, + private val repository: TrmnlDisplayRepository, +) : Presenter { + /** + * Creates and returns the state for the DeviceModelSelectorScreen. + * Fetches device models from the repository and handles user interactions. + * + * @return The current UI state. + */ + @Composable + override fun present(): DeviceModelSelectorScreen.State { + var models by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() - // Load models on first composition - LaunchedEffect(Unit) { - loadModels( - onModelsLoaded = { loadedModels -> - models = loadedModels - isLoading = false - errorMessage = null - }, - onError = { error -> - models = emptyList() - isLoading = false - errorMessage = error - }, - ) - } - - return DeviceModelSelectorScreen.State( - models = models, - isLoading = isLoading, - errorMessage = errorMessage, - eventSink = { event -> - when (event) { - is DeviceModelSelectorScreen.Event.BackPressed -> { - navigator.pop() - } - is DeviceModelSelectorScreen.Event.ModelSelected -> { - // Pop with result to return the selected model to the previous screen - navigator.pop( - result = - DeviceModelSelectorScreen.Result( - selectedModel = event.model, - deviceType = screen.deviceType, - ), - ) - } - is DeviceModelSelectorScreen.Event.RetryLoad -> { - scope.launch { - isLoading = true - errorMessage = null - loadModels( - onModelsLoaded = { loadedModels -> - models = loadedModels - isLoading = false - errorMessage = null - }, - onError = { error -> - models = emptyList() - isLoading = false - errorMessage = error - }, - ) - } - } - } + // Load models on first composition + LaunchedEffect(Unit) { + loadModels( + onModelsLoaded = { loadedModels -> + models = loadedModels + isLoading = false + errorMessage = null + }, + onError = { error -> + models = emptyList() + isLoading = false + errorMessage = error }, ) } - private suspend fun loadModels( - onModelsLoaded: (List) -> Unit, - onError: (String) -> Unit, - ) { - val loadedModels = repository.getDeviceModels(TRMNL_API_SERVER_BASE_URL) - if (loadedModels.isEmpty()) { - onError("Failed to load device models. Please try again.") - } else { - onModelsLoaded(loadedModels) - } - } + return DeviceModelSelectorScreen.State( + models = models, + isLoading = isLoading, + errorMessage = errorMessage, + eventSink = { event -> + when (event) { + is DeviceModelSelectorScreen.Event.BackPressed -> { + navigator.pop() + } + is DeviceModelSelectorScreen.Event.ModelSelected -> { + // Pop with result to return the selected model to the previous screen + navigator.pop( + result = + DeviceModelSelectorScreen.Result( + selectedModel = event.model, + deviceType = screen.deviceType, + ), + ) + } + is DeviceModelSelectorScreen.Event.RetryLoad -> { + scope.launch { + isLoading = true + errorMessage = null + loadModels( + onModelsLoaded = { loadedModels -> + models = loadedModels + isLoading = false + errorMessage = null + }, + onError = { error -> + models = emptyList() + isLoading = false + errorMessage = error + }, + ) + } + } + } + }, + ) + } - /** - * Factory interface for creating DeviceModelSelectorPresenter instances. - */ - @CircuitInject(DeviceModelSelectorScreen::class, AppScope::class) - @AssistedFactory - fun interface Factory { - fun create( - navigator: Navigator, - screen: DeviceModelSelectorScreen, - ): DeviceModelSelectorPresenter + private suspend fun loadModels( + onModelsLoaded: (List) -> Unit, + onError: (String) -> Unit, + ) { + val loadedModels = repository.getDeviceModels(TRMNL_API_SERVER_BASE_URL) + if (loadedModels.isEmpty()) { + onError("Failed to load device models. Please try again.") + } else { + onModelsLoaded(loadedModels) } } + /** + * Factory interface for creating DeviceModelSelectorPresenter instances. + */ + @CircuitInject(DeviceModelSelectorScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create( + navigator: Navigator, + screen: DeviceModelSelectorScreen, + ): DeviceModelSelectorPresenter + } +} + /** * Main composable function for rendering the DeviceModelSelectorScreen. * Sets up the screen's structure including toolbar, model list, and loading/error states. diff --git a/app/src/main/java/ink/trmnl/android/ui/display/TrmnlMirrorDisplayScreen.kt b/app/src/main/java/ink/trmnl/android/ui/display/TrmnlMirrorDisplayScreen.kt index d69f454..fbce972 100644 --- a/app/src/main/java/ink/trmnl/android/ui/display/TrmnlMirrorDisplayScreen.kt +++ b/app/src/main/java/ink/trmnl/android/ui/display/TrmnlMirrorDisplayScreen.kt @@ -133,217 +133,216 @@ data object TrmnlMirrorDisplayScreen : Screen { } } -class TrmnlMirrorDisplayPresenter - @AssistedInject - constructor( - @Assisted private val navigator: Navigator, - private val trmnlDeviceConfigDataStore: TrmnlDeviceConfigDataStore, - private val trmnlWorkScheduler: TrmnlWorkScheduler, - private val imageMetadataStore: ImageMetadataStore, - private val trmnlImageUpdateManager: TrmnlImageUpdateManager, - private val rateLimitInterceptor: RateLimitInterceptor, - ) : Presenter { - @Composable - override fun present(): TrmnlMirrorDisplayScreen.State { - var imageUrl by remember { mutableStateOf(null) } - var overlayControlsVisible by remember { mutableStateOf(false) } - var isLoading by remember { mutableStateOf(true) } - var nextRefreshTime by remember { mutableStateOf("No scheduled work found. Please set API token.") } - var error by remember { mutableStateOf(null) } - var saveImageResult by remember { mutableStateOf(null) } - var rateLimitMessage by remember { mutableStateOf(null) } - var retryInfo by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() - val context = LocalContext.current +@AssistedInject +class TrmnlMirrorDisplayPresenter( + @Assisted private val navigator: Navigator, + private val trmnlDeviceConfigDataStore: TrmnlDeviceConfigDataStore, + private val trmnlWorkScheduler: TrmnlWorkScheduler, + private val imageMetadataStore: ImageMetadataStore, + private val trmnlImageUpdateManager: TrmnlImageUpdateManager, + private val rateLimitInterceptor: RateLimitInterceptor, +) : Presenter { + @Composable + override fun present(): TrmnlMirrorDisplayScreen.State { + var imageUrl by remember { mutableStateOf(null) } + var overlayControlsVisible by remember { mutableStateOf(false) } + var isLoading by remember { mutableStateOf(true) } + var nextRefreshTime by remember { mutableStateOf("No scheduled work found. Please set API token.") } + var error by remember { mutableStateOf(null) } + var saveImageResult by remember { mutableStateOf(null) } + var rateLimitMessage by remember { mutableStateOf(null) } + var retryInfo by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + val context = LocalContext.current - // Collect retry events from RateLimitInterceptor for UI feedback - LaunchedEffect(Unit) { - rateLimitInterceptor.retryEvents.collect { event -> - val reasonText = - if (event.reason == ink.trmnl.android.network.RateLimitInterceptor.REASON_RETRY_AFTER_HEADER) { - "Server requested" - } else { - "Rate limited" - } - retryInfo = - TrmnlMirrorDisplayScreen.RetryInfo( - attempt = event.attempt, - maxRetries = event.maxRetries, - delaySeconds = (event.delayMs / 1000).toInt(), - reason = reasonText, - ) - Timber.d("Retry info updated: attempt ${event.attempt}/${event.maxRetries}, delay ${event.delayMs}ms") - } - } - - // Monitor image metadata for rate limit status (HTTP 429) - // Note: With the new RateLimitInterceptor, this flow may not receive HTTP 429 - // since retries are handled transparently at the network layer - LaunchedEffect(Unit) { - imageMetadataStore.imageMetadataFlow.collect { metadata -> - if (metadata?.httpStatusCode == HTTP_429) { - rateLimitMessage = "Showing cached image - rate limited by API server. Retrying in background..." - Timber.d("Rate limit detected (HTTP 429), showing cached image with notification") + // Collect retry events from RateLimitInterceptor for UI feedback + LaunchedEffect(Unit) { + rateLimitInterceptor.retryEvents.collect { event -> + val reasonText = + if (event.reason == ink.trmnl.android.network.RateLimitInterceptor.REASON_RETRY_AFTER_HEADER) { + "Server requested" } else { - rateLimitMessage = null + "Rate limited" } - } + retryInfo = + TrmnlMirrorDisplayScreen.RetryInfo( + attempt = event.attempt, + maxRetries = event.maxRetries, + delaySeconds = (event.delayMs / 1000).toInt(), + reason = reasonText, + ) + Timber.d("Retry info updated: attempt ${event.attempt}/${event.maxRetries}, delay ${event.delayMs}ms") } + } - // Collect updates from the image update manager to get the latest image URL - // Latest image URL is received from WorkManager work requests. - LaunchedEffect(Unit) { - trmnlImageUpdateManager.imageUpdateFlow.collect { imageMetadata -> - Timber.d("Received new image URL from TRMNL Image Update Manager: $imageMetadata") - if (imageMetadata != null && imageMetadata.errorMessage == null) { - imageUrl = imageMetadata.url - isLoading = false - error = null - retryInfo = null // Clear retry info on successful load - } else { - Timber.w("Failed to get cached image URL from TRMNL Image Update Manager `imageUpdateFlow`") - // Keep showing loading state until we have a valid response from the server - // Only set error state if we have a non-null imageMetadata with an error - if (imageMetadata != null) { - isLoading = false - error = imageMetadata.errorMessage ?: "An unknown error occurred." - } - } - } - } - - // Auto-hide timer for overlay controls - LaunchedEffect(overlayControlsVisible) { - if (overlayControlsVisible) { - delay(AUTO_HIDE_APP_CONFIG_WINDOW_MS) - overlayControlsVisible = false - } - } - - LaunchedEffect(overlayControlsVisible) { - trmnlWorkScheduler.getScheduledWorkInfo().collect { workInfo -> - workInfo?.nextRunTime()?.let { - nextRefreshTime = it.timeUntilNextRefresh - } ?: "No scheduled work found. Please set API token." - } - } - - // Initialize by checking token and starting one-time work if needed - LaunchedEffect(Unit) { - val token = trmnlDeviceConfigDataStore.accessTokenFlow.firstOrNull() - if (token.isNullOrBlank()) { - Timber.d("No access token found, navigating to configuration screen") - navigator.goTo(AppSettingsScreen(returnToMirrorAfterSave = true)) - return@LaunchedEffect - } - - // Check if we have a cached image - val hasValidImage = imageMetadataStore.hasValidImageUrlFlow.firstOrNull() ?: false - if (hasValidImage) { - // Initial loading state will be updated when imageUpdateFlow emits - Timber.d("Valid cached image URL exists in ImageMetadataStore") - trmnlImageUpdateManager.initialize() + // Monitor image metadata for rate limit status (HTTP 429) + // Note: With the new RateLimitInterceptor, this flow may not receive HTTP 429 + // since retries are handled transparently at the network layer + LaunchedEffect(Unit) { + imageMetadataStore.imageMetadataFlow.collect { metadata -> + if (metadata?.httpStatusCode == HTTP_429) { + rateLimitMessage = "Showing cached image - rate limited by API server. Retrying in background..." + Timber.d("Rate limit detected (HTTP 429), showing cached image with notification") } else { - Timber.d("No valid cached image, starting one-time refresh work") - // Always keep in loading state until we get a valid result - isLoading = true - error = null - // No valid image, start a refresh work - trmnlWorkScheduler.startOneTimeImageRefreshWork() + rateLimitMessage = null } } + } - return TrmnlMirrorDisplayScreen.State( - imageUrl = imageUrl, - overlayControlsVisible = overlayControlsVisible, - nextImageRefreshIn = nextRefreshTime, - isLoading = isLoading, - errorMessage = error, - saveImageResult = saveImageResult, - rateLimitMessage = rateLimitMessage, - retryInfo = retryInfo, - eventSink = { event -> - when (event) { - TrmnlMirrorDisplayScreen.Event.RefreshCurrentPlaylistItemRequested -> { - overlayControlsVisible = false - // Simply trigger the worker for refresh - scope.launch { - // Clear the image URL so that when the image is refreshed - // with old image URL it will load the image. - imageUrl = null - isLoading = true - error = null + // Collect updates from the image update manager to get the latest image URL + // Latest image URL is received from WorkManager work requests. + LaunchedEffect(Unit) { + trmnlImageUpdateManager.imageUpdateFlow.collect { imageMetadata -> + Timber.d("Received new image URL from TRMNL Image Update Manager: $imageMetadata") + if (imageMetadata != null && imageMetadata.errorMessage == null) { + imageUrl = imageMetadata.url + isLoading = false + error = null + retryInfo = null // Clear retry info on successful load + } else { + Timber.w("Failed to get cached image URL from TRMNL Image Update Manager `imageUpdateFlow`") + // Keep showing loading state until we have a valid response from the server + // Only set error state if we have a non-null imageMetadata with an error + if (imageMetadata != null) { + isLoading = false + error = imageMetadata.errorMessage ?: "An unknown error occurred." + } + } + } + } - if (trmnlDeviceConfigDataStore.hasTokenSync()) { - Timber.d("Manually refreshing current image via WorkManager") - trmnlWorkScheduler.startOneTimeImageRefreshWork() - } else { - error = "No access token found" - isLoading = false - Timber.w("Refresh failed: No access token found") - } - } - } - TrmnlMirrorDisplayScreen.Event.ConfigureRequested -> { - navigator.goTo(AppSettingsScreen(returnToMirrorAfterSave = true)) - } - TrmnlMirrorDisplayScreen.Event.BackPressed -> { - navigator.pop() - } - TrmnlMirrorDisplayScreen.Event.ViewLogsRequested -> { - navigator.goTo(DisplayRefreshLogScreen) - } + // Auto-hide timer for overlay controls + LaunchedEffect(overlayControlsVisible) { + if (overlayControlsVisible) { + delay(AUTO_HIDE_APP_CONFIG_WINDOW_MS) + overlayControlsVisible = false + } + } - TrmnlMirrorDisplayScreen.Event.ToggleOverlayControls -> { - overlayControlsVisible = !overlayControlsVisible - } + LaunchedEffect(overlayControlsVisible) { + trmnlWorkScheduler.getScheduledWorkInfo().collect { workInfo -> + workInfo?.nextRunTime()?.let { + nextRefreshTime = it.timeUntilNextRefresh + } ?: "No scheduled work found. Please set API token." + } + } - TrmnlMirrorDisplayScreen.Event.LoadNextPlaylistItemImage -> { + // Initialize by checking token and starting one-time work if needed + LaunchedEffect(Unit) { + val token = trmnlDeviceConfigDataStore.accessTokenFlow.firstOrNull() + if (token.isNullOrBlank()) { + Timber.d("No access token found, navigating to configuration screen") + navigator.goTo(AppSettingsScreen(returnToMirrorAfterSave = true)) + return@LaunchedEffect + } + + // Check if we have a cached image + val hasValidImage = imageMetadataStore.hasValidImageUrlFlow.firstOrNull() ?: false + if (hasValidImage) { + // Initial loading state will be updated when imageUpdateFlow emits + Timber.d("Valid cached image URL exists in ImageMetadataStore") + trmnlImageUpdateManager.initialize() + } else { + Timber.d("No valid cached image, starting one-time refresh work") + // Always keep in loading state until we get a valid result + isLoading = true + error = null + // No valid image, start a refresh work + trmnlWorkScheduler.startOneTimeImageRefreshWork() + } + } + + return TrmnlMirrorDisplayScreen.State( + imageUrl = imageUrl, + overlayControlsVisible = overlayControlsVisible, + nextImageRefreshIn = nextRefreshTime, + isLoading = isLoading, + errorMessage = error, + saveImageResult = saveImageResult, + rateLimitMessage = rateLimitMessage, + retryInfo = retryInfo, + eventSink = { event -> + when (event) { + TrmnlMirrorDisplayScreen.Event.RefreshCurrentPlaylistItemRequested -> { + overlayControlsVisible = false + // Simply trigger the worker for refresh + scope.launch { + // Clear the image URL so that when the image is refreshed + // with old image URL it will load the image. imageUrl = null isLoading = true error = null if (trmnlDeviceConfigDataStore.hasTokenSync()) { - Timber.d("Manually refreshing next playlist item image via WorkManager") - trmnlWorkScheduler.startOneTimeImageRefreshWork(loadNextPlaylistImage = true) + Timber.d("Manually refreshing current image via WorkManager") + trmnlWorkScheduler.startOneTimeImageRefreshWork() } else { error = "No access token found" isLoading = false Timber.w("Refresh failed: No access token found") } } + } + TrmnlMirrorDisplayScreen.Event.ConfigureRequested -> { + navigator.goTo(AppSettingsScreen(returnToMirrorAfterSave = true)) + } + TrmnlMirrorDisplayScreen.Event.BackPressed -> { + navigator.pop() + } + TrmnlMirrorDisplayScreen.Event.ViewLogsRequested -> { + navigator.goTo(DisplayRefreshLogScreen) + } - is TrmnlMirrorDisplayScreen.Event.ImageLoadingError -> { - error = event.message + TrmnlMirrorDisplayScreen.Event.ToggleOverlayControls -> { + overlayControlsVisible = !overlayControlsVisible + } + + TrmnlMirrorDisplayScreen.Event.LoadNextPlaylistItemImage -> { + imageUrl = null + isLoading = true + error = null + + if (trmnlDeviceConfigDataStore.hasTokenSync()) { + Timber.d("Manually refreshing next playlist item image via WorkManager") + trmnlWorkScheduler.startOneTimeImageRefreshWork(loadNextPlaylistImage = true) + } else { + error = "No access token found" isLoading = false - } - - TrmnlMirrorDisplayScreen.Event.SaveImageRequested -> { - scope.launch { - saveImageResult = null - val savedUri = ImageSaver.saveImageToDownloads(context, imageUrl) - saveImageResult = - if (savedUri != null) { - Timber.d("Image saved successfully to: $savedUri") - TrmnlMirrorDisplayScreen.SaveImageResult.Success - } else { - Timber.w("Failed to save image") - TrmnlMirrorDisplayScreen.SaveImageResult.Error("Failed to save image") - } - } + Timber.w("Refresh failed: No access token found") } } - }, - ) - } - @CircuitInject(TrmnlMirrorDisplayScreen::class, AppScope::class) - @AssistedFactory - fun interface Factory { - fun create(navigator: Navigator): TrmnlMirrorDisplayPresenter - } + is TrmnlMirrorDisplayScreen.Event.ImageLoadingError -> { + error = event.message + isLoading = false + } + + TrmnlMirrorDisplayScreen.Event.SaveImageRequested -> { + scope.launch { + saveImageResult = null + val savedUri = ImageSaver.saveImageToDownloads(context, imageUrl) + saveImageResult = + if (savedUri != null) { + Timber.d("Image saved successfully to: $savedUri") + TrmnlMirrorDisplayScreen.SaveImageResult.Success + } else { + Timber.w("Failed to save image") + TrmnlMirrorDisplayScreen.SaveImageResult.Error("Failed to save image") + } + } + } + } + }, + ) } + @CircuitInject(TrmnlMirrorDisplayScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(navigator: Navigator): TrmnlMirrorDisplayPresenter + } +} + @CircuitInject(TrmnlMirrorDisplayScreen::class, AppScope::class) @Composable fun TrmnlMirrorDisplayContent( diff --git a/app/src/main/java/ink/trmnl/android/ui/refreshlog/DisplayRefreshLogScreen.kt b/app/src/main/java/ink/trmnl/android/ui/refreshlog/DisplayRefreshLogScreen.kt index ec224f8..26094f7 100644 --- a/app/src/main/java/ink/trmnl/android/ui/refreshlog/DisplayRefreshLogScreen.kt +++ b/app/src/main/java/ink/trmnl/android/ui/refreshlog/DisplayRefreshLogScreen.kt @@ -139,83 +139,82 @@ data object DisplayRefreshLogScreen : Screen { * Presenter for the DisplayRefreshLogScreen. * Manages the screen's state and handles events from the UI. */ -class DisplayRefreshLogPresenter - @AssistedInject - constructor( - @Assisted private val navigator: Navigator, - private val refreshLogManager: TrmnlRefreshLogManager, - private val refreshLogExporter: RefreshLogExporter, - private val trmnlWorkScheduler: TrmnlWorkScheduler, - ) : Presenter { - /** - * Creates and returns the state for the DisplayRefreshLogScreen. - * Collects logs from the log manager and sets up event handling. - * - * @return The current UI state. - */ - @Composable - override fun present(): DisplayRefreshLogScreen.State { - val logs by refreshLogManager.logsFlow.collectAsState(initial = emptyList()) - val scope = rememberCoroutineScope() +@AssistedInject +class DisplayRefreshLogPresenter( + @Assisted private val navigator: Navigator, + private val refreshLogManager: TrmnlRefreshLogManager, + private val refreshLogExporter: RefreshLogExporter, + private val trmnlWorkScheduler: TrmnlWorkScheduler, +) : Presenter { + /** + * Creates and returns the state for the DisplayRefreshLogScreen. + * Collects logs from the log manager and sets up event handling. + * + * @return The current UI state. + */ + @Composable + override fun present(): DisplayRefreshLogScreen.State { + val logs by refreshLogManager.logsFlow.collectAsState(initial = emptyList()) + val scope = rememberCoroutineScope() - return DisplayRefreshLogScreen.State( - logs = logs, - eventSink = { event -> - when (event) { - DisplayRefreshLogScreen.Event.BackPressed -> navigator.pop() - DisplayRefreshLogScreen.Event.ClearLogs -> { - scope.launch { - refreshLogManager.clearLogs() - } - } - - DisplayRefreshLogScreen.Event.AddFailLog -> { - scope.launch { - refreshLogManager.addLog( - TrmnlRefreshLog.createFailure( - error = "Test failure", - HttpResponseMetadata.empty(), - ), - ) - } - } - DisplayRefreshLogScreen.Event.AddSuccessLog -> { - scope.launch { - refreshLogManager.addLog( - TrmnlRefreshLog.createSuccess( - trmnlDeviceType = TrmnlDeviceType.TRMNL, - imageUrl = "https://debug.example.com/image.png", - imageName = "test-image.png", - refreshIntervalSeconds = 300L, - imageRefreshWorkType = RefreshWorkType.ONE_TIME.name, - ), - ) - } - } - - DisplayRefreshLogScreen.Event.StartRefreshWorker -> { - trmnlWorkScheduler.startOneTimeImageRefreshWork() - } - DisplayRefreshLogScreen.Event.ExportLogs -> { - scope.launch { - refreshLogExporter.exportLogsAndShare(logs) - } + return DisplayRefreshLogScreen.State( + logs = logs, + eventSink = { event -> + when (event) { + DisplayRefreshLogScreen.Event.BackPressed -> navigator.pop() + DisplayRefreshLogScreen.Event.ClearLogs -> { + scope.launch { + refreshLogManager.clearLogs() } } - }, - ) - } - /** - * Factory interface for creating DisplayRefreshLogPresenter instances. - */ - @CircuitInject(DisplayRefreshLogScreen::class, AppScope::class) - @AssistedFactory - fun interface Factory { - fun create(navigator: Navigator): DisplayRefreshLogPresenter - } + DisplayRefreshLogScreen.Event.AddFailLog -> { + scope.launch { + refreshLogManager.addLog( + TrmnlRefreshLog.createFailure( + error = "Test failure", + HttpResponseMetadata.empty(), + ), + ) + } + } + DisplayRefreshLogScreen.Event.AddSuccessLog -> { + scope.launch { + refreshLogManager.addLog( + TrmnlRefreshLog.createSuccess( + trmnlDeviceType = TrmnlDeviceType.TRMNL, + imageUrl = "https://debug.example.com/image.png", + imageName = "test-image.png", + refreshIntervalSeconds = 300L, + imageRefreshWorkType = RefreshWorkType.ONE_TIME.name, + ), + ) + } + } + + DisplayRefreshLogScreen.Event.StartRefreshWorker -> { + trmnlWorkScheduler.startOneTimeImageRefreshWork() + } + DisplayRefreshLogScreen.Event.ExportLogs -> { + scope.launch { + refreshLogExporter.exportLogsAndShare(logs) + } + } + } + }, + ) } + /** + * Factory interface for creating DisplayRefreshLogPresenter instances. + */ + @CircuitInject(DisplayRefreshLogScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(navigator: Navigator): DisplayRefreshLogPresenter + } +} + /** * Main composable function for rendering the DisplayRefreshLogScreen. * Sets up the screen's structure including toolbar, log list, and debug controls. 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 367c7ea..20dfd9b 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 @@ -260,331 +260,330 @@ data class AppSettingsScreen( * Presenter for the [AppSettingsScreen]. * Manages the screen's state and handles events from the UI. */ -class AppSettingsPresenter - @AssistedInject - constructor( - @Assisted private val navigator: Navigator, - @Assisted private val screen: AppSettingsScreen, - private val displayRepository: TrmnlDisplayRepository, - private val deviceConfigStore: TrmnlDeviceConfigDataStore, - private val trmnlWorkScheduler: TrmnlWorkScheduler, - private val trmnlImageUpdateManager: TrmnlImageUpdateManager, - ) : Presenter { - @Composable - override fun present(): AppSettingsScreen.State { - var deviceType by remember { mutableStateOf(TrmnlDeviceType.TRMNL) } - var serverBaseUrl by remember { mutableStateOf("") } - var accessToken by remember { mutableStateOf("") } - var deviceMacId by remember { mutableStateOf("") } - var isByodMasterDevice by remember { mutableStateOf(true) } - var isLoading by remember { mutableStateOf(false) } - var validationResult by remember { mutableStateOf(null) } - var isDeviceSetupLoading by remember { mutableStateOf(false) } - var deviceSetupMessage by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() - val focusManager = LocalFocusManager.current +@AssistedInject +class AppSettingsPresenter( + @Assisted private val navigator: Navigator, + @Assisted private val screen: AppSettingsScreen, + private val displayRepository: TrmnlDisplayRepository, + private val deviceConfigStore: TrmnlDeviceConfigDataStore, + private val trmnlWorkScheduler: TrmnlWorkScheduler, + private val trmnlImageUpdateManager: TrmnlImageUpdateManager, +) : Presenter { + @Composable + override fun present(): AppSettingsScreen.State { + var deviceType by remember { mutableStateOf(TrmnlDeviceType.TRMNL) } + var serverBaseUrl by remember { mutableStateOf("") } + var accessToken by remember { mutableStateOf("") } + var deviceMacId by remember { mutableStateOf("") } + var isByodMasterDevice by remember { mutableStateOf(true) } + var isLoading by remember { mutableStateOf(false) } + var validationResult by remember { mutableStateOf(null) } + var isDeviceSetupLoading by remember { mutableStateOf(false) } + var deviceSetupMessage by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current - val nextRefreshInfo by produceState(null) { - trmnlWorkScheduler.getScheduledWorkInfo().collect { workInfo -> - value = workInfo?.nextRunTime() + val nextRefreshInfo by produceState(null) { + trmnlWorkScheduler.getScheduledWorkInfo().collect { workInfo -> + value = workInfo?.nextRunTime() + } + } + + // 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 -> + // 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 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 + // Load saved token if available + LaunchedEffect(Unit) { + deviceConfigStore.deviceConfigFlow.filterNotNull().collect { + deviceType = it.type + accessToken = it.apiAccessToken + + if (it.type == TrmnlDeviceType.BYOS) { + // On initial load, prefill only if the device type is BYOS + serverBaseUrl = it.apiBaseUrl + it.deviceMacId?.let { savedDeviceId -> + deviceMacId = savedDeviceId } } + + // Load BYOD-specific settings + if (it.type == TrmnlDeviceType.BYOD) { + isByodMasterDevice = it.isMasterDevice ?: true + } } + } - // Create answering navigator for DeviceModelSelectorScreen - val deviceModelNavigator = - rememberAnsweringNavigator(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 { - deviceType = it.type - accessToken = it.apiAccessToken - - if (it.type == TrmnlDeviceType.BYOS) { - // On initial load, prefill only if the device type is BYOS - serverBaseUrl = it.apiBaseUrl - it.deviceMacId?.let { savedDeviceId -> - deviceMacId = savedDeviceId - } + return AppSettingsScreen.State( + deviceType = deviceType, + serverBaseUrl = serverBaseUrl, + accessToken = accessToken, + deviceMacId = deviceMacId, + isByodMasterDevice = isByodMasterDevice, + isLoading = isLoading, + validationResult = validationResult, + isDeviceSetupLoading = isDeviceSetupLoading, + deviceSetupMessage = deviceSetupMessage, + nextRefreshJobInfo = nextRefreshInfo, + savedDeviceModel = savedDeviceModel, + eventSink = { event -> + when (event) { + is AppSettingsScreen.Event.AccessTokenChanged -> { + accessToken = event.token + // Clear previous validation when token changes + validationResult = null + deviceSetupMessage = null } - // Load BYOD-specific settings - if (it.type == TrmnlDeviceType.BYOD) { - isByodMasterDevice = it.isMasterDevice ?: true - } - } - } - - return AppSettingsScreen.State( - deviceType = deviceType, - serverBaseUrl = serverBaseUrl, - accessToken = accessToken, - deviceMacId = deviceMacId, - isByodMasterDevice = isByodMasterDevice, - isLoading = isLoading, - validationResult = validationResult, - isDeviceSetupLoading = isDeviceSetupLoading, - deviceSetupMessage = deviceSetupMessage, - nextRefreshJobInfo = nextRefreshInfo, - savedDeviceModel = savedDeviceModel, - eventSink = { event -> - when (event) { - is AppSettingsScreen.Event.AccessTokenChanged -> { - accessToken = event.token - // Clear previous validation when token changes + AppSettingsScreen.Event.ValidateToken -> { + scope.launch { + focusManager.clearFocus() + isLoading = true validationResult = null deviceSetupMessage = null - } - AppSettingsScreen.Event.ValidateToken -> { - scope.launch { - focusManager.clearFocus() - isLoading = true - validationResult = null - deviceSetupMessage = null + // First validate server URL if device type is BYOS + if (deviceType == TrmnlDeviceType.BYOS) { + if (!isValidUrl(serverBaseUrl)) { + isLoading = false + validationResult = InvalidServerUrl("Please enter a valid HTTPS URL (e.g. https://my-terminus.com)") + return@launch + } - // First validate server URL if device type is BYOS - if (deviceType == TrmnlDeviceType.BYOS) { - if (!isValidUrl(serverBaseUrl)) { - isLoading = false - validationResult = InvalidServerUrl("Please enter a valid HTTPS URL (e.g. https://my-terminus.com)") - return@launch + // If device ID is provided, validate MAC address format (only for BYOS) + if (deviceMacId.isNotBlank() && !isValidMacAddress(deviceMacId)) { + isLoading = false + validationResult = + ValidationResult.InvalidDeviceMacId( + "Please enter a valid MAC address format:\n" + + "• XX:XX:XX:XX:XX:XX\n" + + "• XX-XX-XX-XX-XX-XX\n" + + "• XXXXXXXXXXXX\n" + + "where X is a hexadecimal digit (0-9, A-F)", + ) + return@launch + } + } + + // Device configuration for API calls + val deviceConfig = + TrmnlDeviceConfig( + type = deviceType, + apiBaseUrl = serverBaseUrl.forDevice(deviceType), + apiAccessToken = accessToken, + deviceMacId = deviceMacId.ifBlank { null }, + ) + // For TRMNL device type, use getCurrentDisplayData + // For all other device types, use getNextDisplayData + // See https://discord.com/channels/1281055965508141100/1331360842809348106/1382865608236077086 + val response = + when (deviceType) { + TrmnlDeviceType.TRMNL -> { + displayRepository.getCurrentDisplayData(deviceConfig) } - - // If device ID is provided, validate MAC address format (only for BYOS) - if (deviceMacId.isNotBlank() && !isValidMacAddress(deviceMacId)) { - isLoading = false - validationResult = - ValidationResult.InvalidDeviceMacId( - "Please enter a valid MAC address format:\n" + - "• XX:XX:XX:XX:XX:XX\n" + - "• XX-XX-XX-XX-XX-XX\n" + - "• XXXXXXXXXXXX\n" + - "where X is a hexadecimal digit (0-9, A-F)", - ) - return@launch + else -> { + displayRepository.getNextDisplayData(deviceConfig) } } - // Device configuration for API calls - val deviceConfig = + if (response.status.isHttpError()) { + if (response.imageFileName == ERROR_TYPE_DEVICE_SETUP_REQUIRED) { + // Special case for device setup required + validationResult = + ValidationResult.DeviceSetupRequired( + response.error ?: "Device setup required. Please follow the setup instructions.", + ) + } else { + // Handle explicit error response + val errorMessage = response.error ?: "Unexpected error occurred. Please check required inputs." + validationResult = Failure(errorMessage) + } + } else if (response.imageUrl.isNotBlank()) { + // Success case - we have an image URL + trmnlImageUpdateManager.updateImage(response.imageUrl, response.refreshIntervalSeconds) + validationResult = + Success( + response.imageUrl, + response.refreshIntervalSeconds ?: DEFAULT_REFRESH_INTERVAL_SEC, + ) + } else { + // No error but also no image URL + val errorMessage = response.error ?: "" + validationResult = Failure("$errorMessage No image URL received.") + } + isLoading = false + } + } + + AppSettingsScreen.Event.SaveAndContinue -> { + // Only save if validation was successful + val result = validationResult + if (result is Success) { + scope.launch { + // Determine isMasterDevice based on device type + val isMaster = + when (deviceType) { + TrmnlDeviceType.BYOD -> isByodMasterDevice + TrmnlDeviceType.BYOS -> true + TrmnlDeviceType.TRMNL -> false + } + + deviceConfigStore.saveDeviceConfig( TrmnlDeviceConfig( type = deviceType, apiBaseUrl = serverBaseUrl.forDevice(deviceType), apiAccessToken = accessToken, - deviceMacId = deviceMacId.ifBlank { null }, - ) - // For TRMNL device type, use getCurrentDisplayData - // For all other device types, use getNextDisplayData - // See https://discord.com/channels/1281055965508141100/1331360842809348106/1382865608236077086 - val response = - when (deviceType) { - TrmnlDeviceType.TRMNL -> { - displayRepository.getCurrentDisplayData(deviceConfig) - } - else -> { - displayRepository.getNextDisplayData(deviceConfig) - } - } + refreshRateSecs = result.refreshRateSecs, + // Normalize the MAC address to standard format if provided in different format + deviceMacId = normalizeMacAddress(deviceMacId)?.ifBlank { null }, + isMasterDevice = isMaster, + ), + ) + trmnlWorkScheduler.updateRefreshInterval(result.refreshRateSecs) - if (response.status.isHttpError()) { - if (response.imageFileName == ERROR_TYPE_DEVICE_SETUP_REQUIRED) { - // Special case for device setup required - validationResult = - ValidationResult.DeviceSetupRequired( - response.error ?: "Device setup required. Please follow the setup instructions.", - ) - } else { - // Handle explicit error response - val errorMessage = response.error ?: "Unexpected error occurred. Please check required inputs." - validationResult = Failure(errorMessage) - } - } else if (response.imageUrl.isNotBlank()) { - // Success case - we have an image URL - trmnlImageUpdateManager.updateImage(response.imageUrl, response.refreshIntervalSeconds) - validationResult = - Success( - response.imageUrl, - response.refreshIntervalSeconds ?: DEFAULT_REFRESH_INTERVAL_SEC, - ) + if (screen.returnToMirrorAfterSave) { + navigator.goTo(TrmnlMirrorDisplayScreen) } else { - // No error but also no image URL - val errorMessage = response.error ?: "" - validationResult = Failure("$errorMessage No image URL received.") - } - isLoading = false - } - } - - AppSettingsScreen.Event.SaveAndContinue -> { - // Only save if validation was successful - val result = validationResult - if (result is Success) { - scope.launch { - // Determine isMasterDevice based on device type - val isMaster = - when (deviceType) { - TrmnlDeviceType.BYOD -> isByodMasterDevice - TrmnlDeviceType.BYOS -> true - TrmnlDeviceType.TRMNL -> false - } - - deviceConfigStore.saveDeviceConfig( - TrmnlDeviceConfig( - type = deviceType, - apiBaseUrl = serverBaseUrl.forDevice(deviceType), - apiAccessToken = accessToken, - refreshRateSecs = result.refreshRateSecs, - // Normalize the MAC address to standard format if provided in different format - deviceMacId = normalizeMacAddress(deviceMacId)?.ifBlank { null }, - isMasterDevice = isMaster, - ), - ) - trmnlWorkScheduler.updateRefreshInterval(result.refreshRateSecs) - - if (screen.returnToMirrorAfterSave) { - navigator.goTo(TrmnlMirrorDisplayScreen) - } else { - navigator.pop() - } - } - } - } - - AppSettingsScreen.Event.BackPressed -> { - navigator.pop() - } - - AppSettingsScreen.Event.CancelScheduledWork -> { - trmnlWorkScheduler.cancelPeriodicImageRefreshWork() - } - - is AppSettingsScreen.Event.DeviceTypeChanged -> { - deviceType = event.type - // Clear validation result when device type changes - validationResult = null - deviceSetupMessage = null - } - - is AppSettingsScreen.Event.ServerUrlChanged -> { - serverBaseUrl = event.url - // Clear validation result when server URL changes - if (validationResult is InvalidServerUrl) { - validationResult = null - deviceSetupMessage = null - } - } - - is AppSettingsScreen.Event.DeviceMacIdChanged -> { - deviceMacId = event.deviceMacId - // Clear previous validation when device ID changes - validationResult = null - deviceSetupMessage = null - } - - is AppSettingsScreen.Event.ByodMasterDeviceChanged -> { - isByodMasterDevice = event.isMaster - } - - AppSettingsScreen.Event.AppInfoPressed -> { - // Navigate to AppInfoScreen - navigator.goTo(AppInfoScreen) - } - - AppSettingsScreen.Event.ViewLogsRequested -> { - // Navigate to DisplayRefreshLogScreen - navigator.goTo(DisplayRefreshLogScreen) - } - - 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 - - scope.launch { - // Call the setup API with the provided device ID - val setupResult: DeviceSetupInfo = - displayRepository.setupNewDevice( - TrmnlDeviceConfig( - type = deviceType, - apiBaseUrl = serverBaseUrl.forDevice(deviceType), - apiAccessToken = accessToken, - deviceMacId = event.deviceMacId, - ), - ) - - isDeviceSetupLoading = false - - if (!setupResult.success) { - // Handle error response - deviceSetupMessage = setupResult.message - } else { - deviceSetupMessage = "Device setup successful! Re-validate ID/Token to continue." - // Also prepopulate the access token - accessToken = setupResult.apiKey + navigator.pop() } } } } - }, - ) - } - /** - * Returns the server base URL for the [deviceType] (custom or TRMNL server). - */ - private fun String.forDevice(deviceType: TrmnlDeviceType): String = - if (deviceType == TrmnlDeviceType.BYOS) { - // For BYOS, use the provided custom server URL - this - } else { - // For any other device type, use the default TRMNL API server URL - TRMNL_API_SERVER_BASE_URL - } + AppSettingsScreen.Event.BackPressed -> { + navigator.pop() + } - @CircuitInject(AppSettingsScreen::class, AppScope::class) - @AssistedFactory - fun interface Factory { - fun create( - navigator: Navigator, - screen: AppSettingsScreen, - ): AppSettingsPresenter - } + AppSettingsScreen.Event.CancelScheduledWork -> { + trmnlWorkScheduler.cancelPeriodicImageRefreshWork() + } + + is AppSettingsScreen.Event.DeviceTypeChanged -> { + deviceType = event.type + // Clear validation result when device type changes + validationResult = null + deviceSetupMessage = null + } + + is AppSettingsScreen.Event.ServerUrlChanged -> { + serverBaseUrl = event.url + // Clear validation result when server URL changes + if (validationResult is InvalidServerUrl) { + validationResult = null + deviceSetupMessage = null + } + } + + is AppSettingsScreen.Event.DeviceMacIdChanged -> { + deviceMacId = event.deviceMacId + // Clear previous validation when device ID changes + validationResult = null + deviceSetupMessage = null + } + + is AppSettingsScreen.Event.ByodMasterDeviceChanged -> { + isByodMasterDevice = event.isMaster + } + + AppSettingsScreen.Event.AppInfoPressed -> { + // Navigate to AppInfoScreen + navigator.goTo(AppInfoScreen) + } + + AppSettingsScreen.Event.ViewLogsRequested -> { + // Navigate to DisplayRefreshLogScreen + navigator.goTo(DisplayRefreshLogScreen) + } + + 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 + + scope.launch { + // Call the setup API with the provided device ID + val setupResult: DeviceSetupInfo = + displayRepository.setupNewDevice( + TrmnlDeviceConfig( + type = deviceType, + apiBaseUrl = serverBaseUrl.forDevice(deviceType), + apiAccessToken = accessToken, + deviceMacId = event.deviceMacId, + ), + ) + + isDeviceSetupLoading = false + + if (!setupResult.success) { + // Handle error response + deviceSetupMessage = setupResult.message + } else { + deviceSetupMessage = "Device setup successful! Re-validate ID/Token to continue." + // Also prepopulate the access token + accessToken = setupResult.apiKey + } + } + } + } + }, + ) } + /** + * Returns the server base URL for the [deviceType] (custom or TRMNL server). + */ + private fun String.forDevice(deviceType: TrmnlDeviceType): String = + if (deviceType == TrmnlDeviceType.BYOS) { + // For BYOS, use the provided custom server URL + this + } else { + // For any other device type, use the default TRMNL API server URL + TRMNL_API_SERVER_BASE_URL + } + + @CircuitInject(AppSettingsScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create( + navigator: Navigator, + screen: AppSettingsScreen, + ): AppSettingsPresenter + } +} + /** * Main Composable function for rendering the AppSettingsScreen. * Sets up the screen's structure including form, validation result display, and work schedule status. diff --git a/app/src/main/java/ink/trmnl/android/util/AndroidDeviceInfoProvider.kt b/app/src/main/java/ink/trmnl/android/util/AndroidDeviceInfoProvider.kt index 3b51598..260ace8 100644 --- a/app/src/main/java/ink/trmnl/android/util/AndroidDeviceInfoProvider.kt +++ b/app/src/main/java/ink/trmnl/android/util/AndroidDeviceInfoProvider.kt @@ -15,53 +15,52 @@ import timber.log.Timber * This class provides utility methods to retrieve device-specific information * such as battery level and WiFi signal strength, which can be used for reporting to the TRMNL API. */ +@Inject @SingleIn(AppScope::class) -class AndroidDeviceInfoProvider - @Inject - constructor( - @ApplicationContext private val context: Context, - ) { - /** - * Gets the current battery level of the Android device. - * - * @return Battery percentage (0-100), or null if unable to retrieve - */ - fun getBatteryLevel(): Int? = - try { - val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager - val batteryLevel = - batteryManager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) - Timber.i("Current battery level: $batteryLevel%") - batteryLevel - } catch (e: Exception) { - Timber.e(e, "Failed to get battery level") +class AndroidDeviceInfoProvider( + @ApplicationContext private val context: Context, +) { + /** + * Gets the current battery level of the Android device. + * + * @return Battery percentage (0-100), or null if unable to retrieve + */ + fun getBatteryLevel(): Int? = + try { + val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager + val batteryLevel = + batteryManager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) + Timber.i("Current battery level: $batteryLevel%") + batteryLevel + } catch (e: Exception) { + Timber.e(e, "Failed to get battery level") + null + } + + /** + * Gets the current WiFi signal strength (RSSI) of the Android device. + * + * RSSI (Received Signal Strength Indicator) is measured in dBm and typically + * ranges from -100 (weakest) to 0 (strongest). + * + * @return WiFi signal strength in dBm, or null if unable to retrieve or WiFi is not connected + */ + @Suppress("DEPRECATION") // WifiInfo.rssi is still the standard way to get signal strength + fun getWifiSignalStrength(): Int? = + try { + val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager + val wifiInfo = wifiManager?.connectionInfo + val rssi = wifiInfo?.rssi + + if (rssi != null && rssi != -127) { // -127 (`INVALID_RSSI`) indicates no signal + Timber.i("Current WiFi signal strength (RSSI): $rssi dBm") + rssi + } else { + Timber.d("WiFi not connected or signal unavailable") null } - - /** - * Gets the current WiFi signal strength (RSSI) of the Android device. - * - * RSSI (Received Signal Strength Indicator) is measured in dBm and typically - * ranges from -100 (weakest) to 0 (strongest). - * - * @return WiFi signal strength in dBm, or null if unable to retrieve or WiFi is not connected - */ - @Suppress("DEPRECATION") // WifiInfo.rssi is still the standard way to get signal strength - fun getWifiSignalStrength(): Int? = - try { - val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager - val wifiInfo = wifiManager?.connectionInfo - val rssi = wifiInfo?.rssi - - if (rssi != null && rssi != -127) { // -127 (`INVALID_RSSI`) indicates no signal - Timber.i("Current WiFi signal strength (RSSI): $rssi dBm") - rssi - } else { - Timber.d("WiFi not connected or signal unavailable") - null - } - } catch (e: Exception) { - Timber.e(e, "Failed to get WiFi signal strength") - null - } - } + } catch (e: Exception) { + Timber.e(e, "Failed to get WiFi signal strength") + null + } +} diff --git a/app/src/main/java/ink/trmnl/android/work/TrmnlImageRefreshWorker.kt b/app/src/main/java/ink/trmnl/android/work/TrmnlImageRefreshWorker.kt index 99bc034..95f4a6f 100644 --- a/app/src/main/java/ink/trmnl/android/work/TrmnlImageRefreshWorker.kt +++ b/app/src/main/java/ink/trmnl/android/work/TrmnlImageRefreshWorker.kt @@ -221,29 +221,28 @@ class TrmnlImageRefreshWorker( * @see TrmnlWorkerFactory * @see WorkerModule */ - class Factory - @Inject - constructor( - private val displayRepository: TrmnlDisplayRepository, - private val trmnlDeviceConfigDataStore: TrmnlDeviceConfigDataStore, - private val refreshLogManager: TrmnlRefreshLogManager, - private val trmnlWorkScheduler: TrmnlWorkScheduler, - private val trmnlImageUpdateManager: TrmnlImageUpdateManager, - private val imageMetadataStore: ImageMetadataStore, - ) { - fun create( - appContext: Context, - params: WorkerParameters, - ): TrmnlImageRefreshWorker = - TrmnlImageRefreshWorker( - appContext = appContext, - params = params, - displayRepository = displayRepository, - trmnlDeviceConfigDataStore = trmnlDeviceConfigDataStore, - refreshLogManager = refreshLogManager, - trmnlWorkScheduler = trmnlWorkScheduler, - trmnlImageUpdateManager = trmnlImageUpdateManager, - imageMetadataStore = imageMetadataStore, - ) - } + @Inject + class Factory( + private val displayRepository: TrmnlDisplayRepository, + private val trmnlDeviceConfigDataStore: TrmnlDeviceConfigDataStore, + private val refreshLogManager: TrmnlRefreshLogManager, + private val trmnlWorkScheduler: TrmnlWorkScheduler, + private val trmnlImageUpdateManager: TrmnlImageUpdateManager, + private val imageMetadataStore: ImageMetadataStore, + ) { + fun create( + appContext: Context, + params: WorkerParameters, + ): TrmnlImageRefreshWorker = + TrmnlImageRefreshWorker( + appContext = appContext, + params = params, + displayRepository = displayRepository, + trmnlDeviceConfigDataStore = trmnlDeviceConfigDataStore, + refreshLogManager = refreshLogManager, + trmnlWorkScheduler = trmnlWorkScheduler, + trmnlImageUpdateManager = trmnlImageUpdateManager, + imageMetadataStore = imageMetadataStore, + ) + } } diff --git a/app/src/main/java/ink/trmnl/android/work/TrmnlImageUpdateManager.kt b/app/src/main/java/ink/trmnl/android/work/TrmnlImageUpdateManager.kt index f8eafaf..593c19c 100644 --- a/app/src/main/java/ink/trmnl/android/work/TrmnlImageUpdateManager.kt +++ b/app/src/main/java/ink/trmnl/android/work/TrmnlImageUpdateManager.kt @@ -20,45 +20,44 @@ import timber.log.Timber * 📚 See following sequence diagram for flow: * - https://github.com/usetrmnl/trmnl-android/blob/main/CONTRIBUTING.md#trmnl-app-image-loading-flow */ +@Inject @SingleIn(AppScope::class) -class TrmnlImageUpdateManager - @Inject - constructor( - private val imageMetadataStore: ImageMetadataStore, +class TrmnlImageUpdateManager( + private val imageMetadataStore: ImageMetadataStore, +) { + private val _imageUpdateFlow = MutableStateFlow(null) + val imageUpdateFlow: StateFlow = _imageUpdateFlow.asStateFlow() + + /** + * Updates the image URL and notifies observers through the flow. + * Only accepts updates with newer timestamps than the current image. + */ + fun updateImage( + imageUrl: String, + refreshIntervalSecs: Long? = null, + errorMessage: String? = null, ) { - private val _imageUpdateFlow = MutableStateFlow(null) - val imageUpdateFlow: StateFlow = _imageUpdateFlow.asStateFlow() + val imageMetadata = + ImageMetadata( + url = imageUrl, + refreshIntervalSecs = refreshIntervalSecs, + errorMessage = errorMessage, + ) - /** - * Updates the image URL and notifies observers through the flow. - * Only accepts updates with newer timestamps than the current image. - */ - fun updateImage( - imageUrl: String, - refreshIntervalSecs: Long? = null, - errorMessage: String? = null, - ) { - val imageMetadata = - ImageMetadata( - url = imageUrl, - refreshIntervalSecs = refreshIntervalSecs, - errorMessage = errorMessage, - ) + Timber.d("Updating image URL from TrmnlImageUpdateManager: $imageMetadata") - Timber.d("Updating image URL from TrmnlImageUpdateManager: $imageMetadata") + _imageUpdateFlow.value = imageMetadata + } - _imageUpdateFlow.value = imageMetadata - } - - /** - * Initialize the manager with the last cached image URL if available - */ - suspend fun initialize() { - imageMetadataStore.imageMetadataFlow.collect { metadata -> - if (metadata != null && _imageUpdateFlow.value == null) { - Timber.d("Initializing image URL from ImageMetadataStore cache: ${metadata.url}") - _imageUpdateFlow.value = metadata - } + /** + * Initialize the manager with the last cached image URL if available + */ + suspend fun initialize() { + imageMetadataStore.imageMetadataFlow.collect { metadata -> + if (metadata != null && _imageUpdateFlow.value == null) { + Timber.d("Initializing image URL from ImageMetadataStore cache: ${metadata.url}") + _imageUpdateFlow.value = metadata } } } +} diff --git a/app/src/main/java/ink/trmnl/android/work/TrmnlWorkScheduler.kt b/app/src/main/java/ink/trmnl/android/work/TrmnlWorkScheduler.kt index c6f6734..7079141 100644 --- a/app/src/main/java/ink/trmnl/android/work/TrmnlWorkScheduler.kt +++ b/app/src/main/java/ink/trmnl/android/work/TrmnlWorkScheduler.kt @@ -29,264 +29,263 @@ import java.util.concurrent.TimeUnit * Manages the scheduling and execution of background work using WorkManager. * This includes scheduling periodic image refresh work and handling one-time work requests. */ +@Inject @SingleIn(AppScope::class) -class TrmnlWorkScheduler - @Inject - constructor( - @ApplicationContext private val context: Context, - private val trmnlDeviceConfigDataStore: TrmnlDeviceConfigDataStore, - ) { - companion object { - internal const val IMAGE_REFRESH_PERIODIC_WORK_NAME = "trmnl_image_refresh_work_periodic" - internal const val IMAGE_REFRESH_PERIODIC_WORK_TAG = "trmnl_image_refresh_work_periodic_tag" - internal const val IMAGE_REFRESH_ONETIME_WORK_NAME = "trmnl_image_refresh_work_onetime" - internal const val IMAGE_REFRESH_ONETIME_WORK_TAG = "trmnl_image_refresh_work_onetime_tag" - - /** - * Minimum interval for periodic work in minutes. - * This is the minimum interval required by WorkManager for periodic work. - * - * - https://developer.android.com/reference/androidx/work/PeriodicWorkRequest - * - https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work - */ - private const val WORK_MANAGER_MINIMUM_INTERVAL_MINUTES = 15L - - /** - * When loading current image of the TRMNL, we add this delay before fetching - * the image allowing the server to render the image and save it in cloud. - */ - private const val EXTRA_REFRESH_WAIT_TIME_SEC: Long = 60L // 60 seconds - } +class TrmnlWorkScheduler( + @ApplicationContext private val context: Context, + private val trmnlDeviceConfigDataStore: TrmnlDeviceConfigDataStore, +) { + companion object { + internal const val IMAGE_REFRESH_PERIODIC_WORK_NAME = "trmnl_image_refresh_work_periodic" + internal const val IMAGE_REFRESH_PERIODIC_WORK_TAG = "trmnl_image_refresh_work_periodic_tag" + internal const val IMAGE_REFRESH_ONETIME_WORK_NAME = "trmnl_image_refresh_work_onetime" + internal const val IMAGE_REFRESH_ONETIME_WORK_TAG = "trmnl_image_refresh_work_onetime_tag" /** - * Schedule periodic image refresh work with device-type-specific behavior. + * Minimum interval for periodic work in minutes. + * This is the minimum interval required by WorkManager for periodic work. * - * This method: - * - Adds 60 seconds buffer time to the interval before scheduling - * - Converts the interval to minutes with a minimum of 15 minutes (WorkManager requirement) - * - Requires a valid token to be set, otherwise scheduling is skipped - * - Updates existing scheduled work if already present - * - For BYOS devices: Advances through the playlist automatically (playlist cycling) - * - For TRMNL devices: Mirrors the current display from the official TRMNL device - * - Requires network connectivity - * - Uses exponential backoff for retries - * - * @param intervalSeconds The desired refresh interval in seconds (will be adjusted) + * - https://developer.android.com/reference/androidx/work/PeriodicWorkRequest + * - https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work */ - fun scheduleImageRefreshWork(intervalSeconds: Long) { - // Check if we already have work scheduled - val workInfos = - WorkManager - .getInstance(context) - .getWorkInfosForUniqueWork(IMAGE_REFRESH_PERIODIC_WORK_NAME) - .get() - - val existingWork = workInfos.firstOrNull() - if (existingWork != null) { - Timber.d("Existing work found: ${existingWork.state}") - - // Optional: Get the existing work details - val nextScheduleTimeMillis = existingWork.nextScheduleTimeMillis - val nextScheduleTime = java.time.Instant.ofEpochMilli(nextScheduleTimeMillis) - Timber.d("Next schedule time: $nextScheduleTimeMillis ($nextScheduleTime)") - } else { - Timber.d("No existing work found, will create new work") - } - - // Add extra wait time to interval and convert seconds to minutes - val adjustedIntervalSeconds = intervalSeconds + EXTRA_REFRESH_WAIT_TIME_SEC - val intervalMinutes = (adjustedIntervalSeconds / 60).coerceAtLeast(WORK_MANAGER_MINIMUM_INTERVAL_MINUTES) - - Timber.d("Scheduling work: $intervalSeconds seconds + $EXTRA_REFRESH_WAIT_TIME_SEC seconds → $intervalMinutes minutes") - - if (trmnlDeviceConfigDataStore.hasTokenSync().not()) { - Timber.w("Token not set, skipping image refresh work scheduling") - return - } - - // Determine whether to advance playlist based on device type - // - BYOS devices always advance their own playlist - // - BYOD devices can be configured as master (advance) or slave (mirror) via isMasterDevice setting - // - TRMNL devices always mirror the official TRMNL device (stay on current screen) - // See https://github.com/usetrmnl/trmnl-android/issues/190 - val deviceConfig = trmnlDeviceConfigDataStore.getDeviceConfigSync() - val shouldAdvancePlaylist = - when (deviceConfig?.type) { - ink.trmnl.android.model.TrmnlDeviceType.BYOS -> true // Always auto-advance - ink.trmnl.android.model.TrmnlDeviceType.BYOD -> deviceConfig.isMasterDevice ?: true // Default to master if not set - ink.trmnl.android.model.TrmnlDeviceType.TRMNL -> false // Always mirror - null -> false - } - - Timber.d( - "Device type: ${deviceConfig?.type}, isMasterDevice: ${deviceConfig?.isMasterDevice}, shouldAdvancePlaylist: $shouldAdvancePlaylist", - ) - - val constraints = - Constraints - .Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - - val periodicWorkRequest = - PeriodicWorkRequestBuilder( - repeatInterval = intervalMinutes, - repeatIntervalTimeUnit = TimeUnit.MINUTES, - ).setConstraints(constraints) - .setBackoffCriteria( - // Exponential backoff for retrying failed work - // To avoid overwhelming the server with requests - // Using 60 seconds initial delay (increased from 30s default) - // to give more breathing room for rate-limited requests - BackoffPolicy.EXPONENTIAL, - 60_000L, // 60 seconds initial backoff - TimeUnit.MILLISECONDS, - ).setInputData( - workDataOf( - PARAM_REFRESH_WORK_TYPE to RefreshWorkType.PERIODIC.name, - // For BYOS devices: advance the playlist (use /api/display endpoint) - // For TRMNL devices: mirror the current display (use /api/current_screen endpoint) - // This enables automatic playlist cycling for BYOS while maintaining - // mirror functionality for TRMNL devices. - PARAM_LOAD_NEXT_PLAYLIST_DISPLAY_IMAGE to shouldAdvancePlaylist, - ), - ).addTag(IMAGE_REFRESH_PERIODIC_WORK_TAG) - .build() - - WorkManager.getInstance(context).enqueueUniquePeriodicWork( - uniqueWorkName = IMAGE_REFRESH_PERIODIC_WORK_NAME, - existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, - request = periodicWorkRequest, - ) - } + private const val WORK_MANAGER_MINIMUM_INTERVAL_MINUTES = 15L /** - * Start a one-time image refresh work immediately. - * - * This method: - * - Executes immediately (subject to network constraints) - * - Requires a valid token to be set, otherwise work is skipped - * - Replaces any existing one-time work request - * - Requires network connectivity - * - Uses exponential backoff for retries - * - * @param loadNextPlaylistImage If true, advances to next playlist item using /api/display endpoint. - * If false, reloads current screen using /api/current_screen endpoint. - * Defaults to false. + * When loading current image of the TRMNL, we add this delay before fetching + * the image allowing the server to render the image and save it in cloud. */ - fun startOneTimeImageRefreshWork(loadNextPlaylistImage: Boolean = false) { - Timber.d("Starting one-time image refresh work with loadNextPlaylistImage: $loadNextPlaylistImage") - - if (trmnlDeviceConfigDataStore.hasTokenSync().not()) { - Timber.w("Token not set, skipping one-time image refresh work") - return - } - - val constraints = - Constraints - .Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - - val workRequest = - OneTimeWorkRequestBuilder() - .setConstraints(constraints) - .setBackoffCriteria( - // Exponential backoff for retrying failed work - // Using 60 seconds initial delay (increased from 30s default) - BackoffPolicy.EXPONENTIAL, - 60_000L, // 60 seconds initial backoff - TimeUnit.MILLISECONDS, - ).setInputData( - workDataOf( - PARAM_REFRESH_WORK_TYPE to RefreshWorkType.ONE_TIME.name, - PARAM_LOAD_NEXT_PLAYLIST_DISPLAY_IMAGE to loadNextPlaylistImage, - ), - ).addTag(IMAGE_REFRESH_ONETIME_WORK_TAG) - .build() + private const val EXTRA_REFRESH_WAIT_TIME_SEC: Long = 60L // 60 seconds + } + /** + * Schedule periodic image refresh work with device-type-specific behavior. + * + * This method: + * - Adds 60 seconds buffer time to the interval before scheduling + * - Converts the interval to minutes with a minimum of 15 minutes (WorkManager requirement) + * - Requires a valid token to be set, otherwise scheduling is skipped + * - Updates existing scheduled work if already present + * - For BYOS devices: Advances through the playlist automatically (playlist cycling) + * - For TRMNL devices: Mirrors the current display from the official TRMNL device + * - Requires network connectivity + * - Uses exponential backoff for retries + * + * @param intervalSeconds The desired refresh interval in seconds (will be adjusted) + */ + fun scheduleImageRefreshWork(intervalSeconds: Long) { + // Check if we already have work scheduled + val workInfos = WorkManager .getInstance(context) - .enqueueUniqueWork( - uniqueWorkName = IMAGE_REFRESH_ONETIME_WORK_NAME, - existingWorkPolicy = ExistingWorkPolicy.REPLACE, - request = workRequest, - ) + .getWorkInfosForUniqueWork(IMAGE_REFRESH_PERIODIC_WORK_NAME) + .get() + + val existingWork = workInfos.firstOrNull() + if (existingWork != null) { + Timber.d("Existing work found: ${existingWork.state}") + + // Optional: Get the existing work details + val nextScheduleTimeMillis = existingWork.nextScheduleTimeMillis + val nextScheduleTime = java.time.Instant.ofEpochMilli(nextScheduleTimeMillis) + Timber.d("Next schedule time: $nextScheduleTimeMillis ($nextScheduleTime)") + } else { + Timber.d("No existing work found, will create new work") } - /** - * Cancel the scheduled periodic image refresh work. - * - * Note: This only cancels periodic work, not one-time work requests. - */ - fun cancelPeriodicImageRefreshWork() { - WorkManager.getInstance(context).cancelUniqueWork(IMAGE_REFRESH_PERIODIC_WORK_NAME) + // Add extra wait time to interval and convert seconds to minutes + val adjustedIntervalSeconds = intervalSeconds + EXTRA_REFRESH_WAIT_TIME_SEC + val intervalMinutes = (adjustedIntervalSeconds / 60).coerceAtLeast(WORK_MANAGER_MINIMUM_INTERVAL_MINUTES) + + Timber.d("Scheduling work: $intervalSeconds seconds + $EXTRA_REFRESH_WAIT_TIME_SEC seconds → $intervalMinutes minutes") + + if (trmnlDeviceConfigDataStore.hasTokenSync().not()) { + Timber.w("Token not set, skipping image refresh work scheduling") + return } - /** - * Checks if the image refresh work is already scheduled - * @return Flow of Boolean that emits true if work is scheduled - */ - fun isImageRefreshWorkScheduled(): Flow { - val workQuery = - WorkQuery.Builder - .fromUniqueWorkNames(listOf(IMAGE_REFRESH_PERIODIC_WORK_NAME)) - .addStates(listOf(WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED)) - .build() - - return WorkManager - .getInstance(context) - .getWorkInfosLiveData(workQuery) - .asFlow() - .map { workInfoList -> workInfoList.isNotEmpty() } - } - - /** - * Synchronously checks if image refresh work is scheduled - * @return true if work is scheduled - */ - fun isImageRefreshWorkScheduledSync(): Boolean { - val workInfos: List = - WorkManager - .getInstance(context) - .getWorkInfosForUniqueWork(IMAGE_REFRESH_PERIODIC_WORK_NAME) - .get() - - return workInfos.any { - it.state == WorkInfo.State.RUNNING || - it.state == WorkInfo.State.ENQUEUED || - it.state == WorkInfo.State.BLOCKED + // Determine whether to advance playlist based on device type + // - BYOS devices always advance their own playlist + // - BYOD devices can be configured as master (advance) or slave (mirror) via isMasterDevice setting + // - TRMNL devices always mirror the official TRMNL device (stay on current screen) + // See https://github.com/usetrmnl/trmnl-android/issues/190 + val deviceConfig = trmnlDeviceConfigDataStore.getDeviceConfigSync() + val shouldAdvancePlaylist = + when (deviceConfig?.type) { + ink.trmnl.android.model.TrmnlDeviceType.BYOS -> true // Always auto-advance + ink.trmnl.android.model.TrmnlDeviceType.BYOD -> deviceConfig.isMasterDevice ?: true // Default to master if not set + ink.trmnl.android.model.TrmnlDeviceType.TRMNL -> false // Always mirror + null -> false } + + Timber.d( + "Device type: ${deviceConfig?.type}, isMasterDevice: ${deviceConfig?.isMasterDevice}, shouldAdvancePlaylist: $shouldAdvancePlaylist", + ) + + val constraints = + Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val periodicWorkRequest = + PeriodicWorkRequestBuilder( + repeatInterval = intervalMinutes, + repeatIntervalTimeUnit = TimeUnit.MINUTES, + ).setConstraints(constraints) + .setBackoffCriteria( + // Exponential backoff for retrying failed work + // To avoid overwhelming the server with requests + // Using 60 seconds initial delay (increased from 30s default) + // to give more breathing room for rate-limited requests + BackoffPolicy.EXPONENTIAL, + 60_000L, // 60 seconds initial backoff + TimeUnit.MILLISECONDS, + ).setInputData( + workDataOf( + PARAM_REFRESH_WORK_TYPE to RefreshWorkType.PERIODIC.name, + // For BYOS devices: advance the playlist (use /api/display endpoint) + // For TRMNL devices: mirror the current display (use /api/current_screen endpoint) + // This enables automatic playlist cycling for BYOS while maintaining + // mirror functionality for TRMNL devices. + PARAM_LOAD_NEXT_PLAYLIST_DISPLAY_IMAGE to shouldAdvancePlaylist, + ), + ).addTag(IMAGE_REFRESH_PERIODIC_WORK_TAG) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + uniqueWorkName = IMAGE_REFRESH_PERIODIC_WORK_NAME, + existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, + request = periodicWorkRequest, + ) + } + + /** + * Start a one-time image refresh work immediately. + * + * This method: + * - Executes immediately (subject to network constraints) + * - Requires a valid token to be set, otherwise work is skipped + * - Replaces any existing one-time work request + * - Requires network connectivity + * - Uses exponential backoff for retries + * + * @param loadNextPlaylistImage If true, advances to next playlist item using /api/display endpoint. + * If false, reloads current screen using /api/current_screen endpoint. + * Defaults to false. + */ + fun startOneTimeImageRefreshWork(loadNextPlaylistImage: Boolean = false) { + Timber.d("Starting one-time image refresh work with loadNextPlaylistImage: $loadNextPlaylistImage") + + if (trmnlDeviceConfigDataStore.hasTokenSync().not()) { + Timber.w("Token not set, skipping one-time image refresh work") + return } - /** - * Get the scheduled periodic work info as a Flow to get updates on upcoming refresh job. - * - * @return Flow that emits the current WorkInfo for the periodic work, or null if not scheduled - */ - fun getScheduledWorkInfo(): Flow = + val constraints = + Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val workRequest = + OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setBackoffCriteria( + // Exponential backoff for retrying failed work + // Using 60 seconds initial delay (increased from 30s default) + BackoffPolicy.EXPONENTIAL, + 60_000L, // 60 seconds initial backoff + TimeUnit.MILLISECONDS, + ).setInputData( + workDataOf( + PARAM_REFRESH_WORK_TYPE to RefreshWorkType.ONE_TIME.name, + PARAM_LOAD_NEXT_PLAYLIST_DISPLAY_IMAGE to loadNextPlaylistImage, + ), + ).addTag(IMAGE_REFRESH_ONETIME_WORK_TAG) + .build() + + WorkManager + .getInstance(context) + .enqueueUniqueWork( + uniqueWorkName = IMAGE_REFRESH_ONETIME_WORK_NAME, + existingWorkPolicy = ExistingWorkPolicy.REPLACE, + request = workRequest, + ) + } + + /** + * Cancel the scheduled periodic image refresh work. + * + * Note: This only cancels periodic work, not one-time work requests. + */ + fun cancelPeriodicImageRefreshWork() { + WorkManager.getInstance(context).cancelUniqueWork(IMAGE_REFRESH_PERIODIC_WORK_NAME) + } + + /** + * Checks if the image refresh work is already scheduled + * @return Flow of Boolean that emits true if work is scheduled + */ + fun isImageRefreshWorkScheduled(): Flow { + val workQuery = + WorkQuery.Builder + .fromUniqueWorkNames(listOf(IMAGE_REFRESH_PERIODIC_WORK_NAME)) + .addStates(listOf(WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED)) + .build() + + return WorkManager + .getInstance(context) + .getWorkInfosLiveData(workQuery) + .asFlow() + .map { workInfoList -> workInfoList.isNotEmpty() } + } + + /** + * Synchronously checks if image refresh work is scheduled + * @return true if work is scheduled + */ + fun isImageRefreshWorkScheduledSync(): Boolean { + val workInfos: List = WorkManager .getInstance(context) - .getWorkInfosForUniqueWorkLiveData(IMAGE_REFRESH_PERIODIC_WORK_NAME) - .asFlow() - .map { it.firstOrNull() } + .getWorkInfosForUniqueWork(IMAGE_REFRESH_PERIODIC_WORK_NAME) + .get() - /** - * Update the refresh interval for periodic work. - * - * This method: - * - Saves the new interval to the device config data store - * - Reschedules the periodic work with the new interval - * - * @param newIntervalSeconds The new refresh interval in seconds - */ - suspend fun updateRefreshInterval(newIntervalSeconds: Long) { - Timber.d("Updating refresh interval to $newIntervalSeconds seconds") - - // Save the refresh rate to TokenManager - trmnlDeviceConfigDataStore.saveRefreshRateSeconds(newIntervalSeconds) - - // Reschedule with new interval - scheduleImageRefreshWork(newIntervalSeconds) + return workInfos.any { + it.state == WorkInfo.State.RUNNING || + it.state == WorkInfo.State.ENQUEUED || + it.state == WorkInfo.State.BLOCKED } } + + /** + * Get the scheduled periodic work info as a Flow to get updates on upcoming refresh job. + * + * @return Flow that emits the current WorkInfo for the periodic work, or null if not scheduled + */ + fun getScheduledWorkInfo(): Flow = + WorkManager + .getInstance(context) + .getWorkInfosForUniqueWorkLiveData(IMAGE_REFRESH_PERIODIC_WORK_NAME) + .asFlow() + .map { it.firstOrNull() } + + /** + * Update the refresh interval for periodic work. + * + * This method: + * - Saves the new interval to the device config data store + * - Reschedules the periodic work with the new interval + * + * @param newIntervalSeconds The new refresh interval in seconds + */ + suspend fun updateRefreshInterval(newIntervalSeconds: Long) { + Timber.d("Updating refresh interval to $newIntervalSeconds seconds") + + // Save the refresh rate to TokenManager + trmnlDeviceConfigDataStore.saveRefreshRateSeconds(newIntervalSeconds) + + // Reschedule with new interval + scheduleImageRefreshWork(newIntervalSeconds) + } +} diff --git a/app/src/main/java/ink/trmnl/android/work/TrmnlWorkerFactory.kt b/app/src/main/java/ink/trmnl/android/work/TrmnlWorkerFactory.kt index d0b73b2..9088d95 100644 --- a/app/src/main/java/ink/trmnl/android/work/TrmnlWorkerFactory.kt +++ b/app/src/main/java/ink/trmnl/android/work/TrmnlWorkerFactory.kt @@ -8,20 +8,19 @@ import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn import ink.trmnl.android.di.AppScope +@Inject @SingleIn(AppScope::class) -class TrmnlWorkerFactory - @Inject - constructor( - private val imageRefreshWorkerFactory: TrmnlImageRefreshWorker.Factory, - ) : WorkerFactory() { - override fun createWorker( - appContext: Context, - workerClassName: String, - workerParameters: WorkerParameters, - ): ListenableWorker? = - when (workerClassName) { - TrmnlImageRefreshWorker::class.java.name -> - imageRefreshWorkerFactory.create(appContext, workerParameters) - else -> null - } - } +class TrmnlWorkerFactory( + private val imageRefreshWorkerFactory: TrmnlImageRefreshWorker.Factory, +) : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters, + ): ListenableWorker? = + when (workerClassName) { + TrmnlImageRefreshWorker::class.java.name -> + imageRefreshWorkerFactory.create(appContext, workerParameters) + else -> null + } +}