Merge pull request #281 from usetrmnl/fix/kotlin-compiler-warnings

fix: address Kotlin compiler warnings
This commit is contained in:
Hossain Khan
2026-04-03 22:22:56 -04:00
committed by GitHub
19 changed files with 2123 additions and 2139 deletions
+1
View File
@@ -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"))
}
}
@@ -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<Activity>())
@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()
}
}
}
}
}
@@ -27,113 +27,112 @@ private val Context.imageDataStore: DataStore<Preferences> 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<ImageMetadata?> =
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<ImageMetadata?> =
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<Boolean> =
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<Long?> =
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<Boolean> =
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<Long?> =
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)
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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<TrmnlUser> {
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<TrmnlUser> {
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<Int> {
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<Int> {
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<Unit> {
// 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<Unit> {
// 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
}
}
*/
}
}
}
@@ -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<TrmnlRefreshLog>) {
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<TrmnlRefreshLog>) {
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()
}
}
}
}
}
@@ -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<TrmnlRefreshLogs>,
class TrmnlRefreshLogManager(
@ApplicationContext private val context: Context,
private val dataStore: DataStore<TrmnlRefreshLogs>,
) {
/**
* 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<List<TrmnlRefreshLog>> =
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<List<TrmnlRefreshLog>> =
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())
}
}
}
@@ -30,12 +30,12 @@ object TrmnlRefreshLogSerializer : Serializer<TrmnlRefreshLogs> {
}
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")
@@ -72,41 +72,40 @@ data object AppInfoScreen : Screen {
}
}
class AppInfoPresenter
@AssistedInject
constructor(
@Assisted private val navigator: Navigator,
) : Presenter<AppInfoScreen.State> {
@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<AppInfoScreen.State> {
@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
@@ -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<DeviceModelSelectorScreen.State> {
/**
* 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<List<SupportedDeviceModel>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var errorMessage by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
@AssistedInject
class DeviceModelSelectorPresenter(
@Assisted private val navigator: Navigator,
@Assisted private val screen: DeviceModelSelectorScreen,
private val repository: TrmnlDisplayRepository,
) : Presenter<DeviceModelSelectorScreen.State> {
/**
* 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<List<SupportedDeviceModel>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var errorMessage by remember { mutableStateOf<String?>(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<SupportedDeviceModel>) -> 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<SupportedDeviceModel>) -> 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.
@@ -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<TrmnlMirrorDisplayScreen.State> {
@Composable
override fun present(): TrmnlMirrorDisplayScreen.State {
var imageUrl by remember { mutableStateOf<String?>(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<String?>(null) }
var saveImageResult by remember { mutableStateOf<TrmnlMirrorDisplayScreen.SaveImageResult?>(null) }
var rateLimitMessage by remember { mutableStateOf<String?>(null) }
var retryInfo by remember { mutableStateOf<TrmnlMirrorDisplayScreen.RetryInfo?>(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<TrmnlMirrorDisplayScreen.State> {
@Composable
override fun present(): TrmnlMirrorDisplayScreen.State {
var imageUrl by remember { mutableStateOf<String?>(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<String?>(null) }
var saveImageResult by remember { mutableStateOf<TrmnlMirrorDisplayScreen.SaveImageResult?>(null) }
var rateLimitMessage by remember { mutableStateOf<String?>(null) }
var retryInfo by remember { mutableStateOf<TrmnlMirrorDisplayScreen.RetryInfo?>(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(
@@ -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<DisplayRefreshLogScreen.State> {
/**
* 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<DisplayRefreshLogScreen.State> {
/**
* 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.
File diff suppressed because it is too large Load Diff
@@ -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
}
}
@@ -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,
)
}
}
@@ -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<ImageMetadata?>(null)
val imageUpdateFlow: StateFlow<ImageMetadata?> = _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<ImageMetadata?>(null)
val imageUpdateFlow: StateFlow<ImageMetadata?> = _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
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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
}
}