mirror of
https://github.com/usetrmnl/trmnl-android.git
synced 2026-04-29 13:35:26 -07:00
fix: address kotlin compiler warnings
- Move @Inject/@AssistedInject to class level for single-constructor classes - Remove explicit class key from @ActivityKey (redundant) - Add -Xannotation-default-target=param-property compiler arg to suppress annotation target warnings for @Json and @ApplicationContext parameters - Rename writeTo parameter to match supertype Serializer<T> signature Fixes warnings from KT-73255 and other Metro/Kotlin best practices
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user