From c5eb2594d507c4ff024c226156e76e0139757310 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Tue, 17 Jun 2025 14:32:50 -0400 Subject: [PATCH 1/4] [ADDED] Device setup API needed to do the initial setup --- .../trmnl/android/model/TrmnlSetupResponse.kt | 31 +++++++++++++++++++ .../trmnl/android/network/TrmnlApiService.kt | 28 +++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 app/src/main/java/ink/trmnl/android/model/TrmnlSetupResponse.kt diff --git a/app/src/main/java/ink/trmnl/android/model/TrmnlSetupResponse.kt b/app/src/main/java/ink/trmnl/android/model/TrmnlSetupResponse.kt new file mode 100644 index 0000000..28c1f12 --- /dev/null +++ b/app/src/main/java/ink/trmnl/android/model/TrmnlSetupResponse.kt @@ -0,0 +1,31 @@ +package ink.trmnl.android.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Data class representing the response from the TRMNL setup API. + * + * Sample JSON response: + * ```json + * { + * "api_key": "abc1234567890abcdef", + * "friendly_id": "GO87665", + * "image_url": "https://localhost:1234/assets/setup.bmp", + * "message": "Welcome to Terminus!" + * } + * ``` + * + * @property apiKey The API key for the device. + * @property friendlyId A user-friendly identifier for the device. + * @property imageUrl The URL of an image to display during setup. + * @property message A welcome message or setup instructions. + * @see ink.trmnl.android.network.TrmnlApiService.setupNewDevice + */ +@JsonClass(generateAdapter = true) +data class TrmnlSetupResponse( + @Json(name = "api_key") val apiKey: String, + @Json(name = "friendly_id") val friendlyId: String, + @Json(name = "image_url") val imageUrl: String, + @Json(name = "message") val message: String, +) diff --git a/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt b/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt index 7237061..e5808c6 100644 --- a/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt +++ b/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt @@ -2,6 +2,7 @@ package ink.trmnl.android.network import com.slack.eithernet.ApiResult import ink.trmnl.android.data.TrmnlDisplayRepository +import ink.trmnl.android.model.TrmnlSetupResponse import ink.trmnl.android.network.model.TrmnlCurrentImageResponse import ink.trmnl.android.network.model.TrmnlDisplayResponse import retrofit2.http.GET @@ -22,6 +23,8 @@ import retrofit2.http.Url interface TrmnlApiService { companion object { /** + * Path for the TRMNL API endpoint that provides the next image in a playlist. + * * https://docs.usetrmnl.com/go/private-api/fetch-screen-content#auto-advance-content * * @see getNextDisplayData @@ -29,11 +32,20 @@ interface TrmnlApiService { internal const val NEXT_PLAYLIST_SCREEN_API_PATH = "api/display" /** + * Path for the TRMNL API endpoint that provides the current image in a playlist. + * * https://docs.usetrmnl.com/go/private-api/fetch-screen-content#current-screen * * @see getCurrentDisplayData */ internal const val CURRENT_PLAYLIST_SCREEN_API_PATH = "api/current_screen" + + /** + * Path for the TRMNL API endpoint used for new device setup. + * + * https://github.com/usetrmnl/byos_hanami?tab=readme-ov-file#setup-1 + */ + internal const val SETUP_API_PATH = "api/setup" } /** @@ -68,4 +80,20 @@ interface TrmnlApiService { @Url fullApiUrl: String, @Header("access-token") accessToken: String, ): ApiResult + + /** + * Setup a new TRMNL device using it's MAC ID. Using same API with same ID has no effect. + * + * This API is typically used once during the initial setup of a BYOS device. + * See https://github.com/usetrmnl/byos_hanami?tab=readme-ov-file#setup-1 + * + * @param fullApiUrl The complete API URL to call (e.g., "https://your-server.com/api/setup"). + * @param deviceMacId The device's MAC address, sent in the "ID" header. + * @return An [ApiResult] containing [TrmnlSetupResponse] on success. + */ + @GET + suspend fun setupNewDevice( + @Url fullApiUrl: String, + @Header("ID") deviceMacId: String, + ): ApiResult } From fe1083715bab5e989ff8b63e291305aace600465 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Tue, 17 Jun 2025 15:31:53 -0400 Subject: [PATCH 2/4] [ADDED] Device setup required detection --- .../android/data/TrmnlDisplayRepository.kt | 45 ++++++++++++++++++- .../network/model/TrmnlDisplayResponse.kt | 2 +- .../trmnl/android/util/NetworkExtensions.kt | 11 +++++ .../data/TrmnlDisplayRepositoryTest.kt | 8 ++-- 4 files changed, 59 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt index f8ff238..b1d955a 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt @@ -10,6 +10,8 @@ import ink.trmnl.android.model.TrmnlDeviceType import ink.trmnl.android.network.TrmnlApiService import ink.trmnl.android.network.TrmnlApiService.Companion.CURRENT_PLAYLIST_SCREEN_API_PATH import ink.trmnl.android.network.TrmnlApiService.Companion.NEXT_PLAYLIST_SCREEN_API_PATH +import ink.trmnl.android.network.model.TrmnlDisplayResponse +import ink.trmnl.android.util.ERROR_TYPE_DEVICE_SETUP_REQUIRED import ink.trmnl.android.util.HTTP_200 import ink.trmnl.android.util.HTTP_500 import ink.trmnl.android.util.isHttpOk @@ -66,13 +68,18 @@ class TrmnlDisplayRepository } is ApiResult.Success -> { // Map the response to the display info - val response = result.value + val response: TrmnlDisplayResponse = result.value + + if (isDeviceSetupRequired(trmnlDeviceConfig, response)) { + return setupRequiredTrmnlDisplayInfo(trmnlDeviceConfig) + } + val displayInfo = TrmnlDisplayInfo( status = response.status, trmnlDeviceType = trmnlDeviceConfig.type, imageUrl = response.imageUrl ?: "", - imageName = response.imageName ?: "", + imageName = response.imageFileName ?: "", error = response.error, refreshIntervalSeconds = response.refreshRate, httpResponseMetadata = extractHttpResponseMetadata(result), @@ -285,4 +292,38 @@ class TrmnlDisplayRepository timestamp = System.currentTimeMillis(), ) } + + /** + * Right now there is no good known way to determine if a device requires setup. + * The logic here is based on sample responses from the Terminus server API. + * + * See + * - https://discord.com/channels/1281055965508141100/1331360842809348106/1384605617456545904 + * - https://discord.com/channels/1281055965508141100/1384605617456545904/1384613229086511135 + */ + private fun isDeviceSetupRequired( + trmnlDeviceConfig: TrmnlDeviceConfig, + response: TrmnlDisplayResponse, + ): Boolean = + trmnlDeviceConfig.type == TrmnlDeviceType.BYOS && + response.imageFileName?.startsWith("setup", ignoreCase = true) == true && + // This ensures that no screen is generated yet for the device + response.imageFileName.contains("screens", ignoreCase = true).not() + + /** + * Creates a [TrmnlDisplayInfo] indicating that the device requires setup. + * + * This is used when the device is not yet configured and needs to be set up before it can display content. + * @see ERROR_TYPE_DEVICE_SETUP_REQUIRED + * @see [isDeviceSetupRequired] + */ + private fun setupRequiredTrmnlDisplayInfo(trmnlDeviceConfig: TrmnlDeviceConfig): TrmnlDisplayInfo = + TrmnlDisplayInfo( + status = HTTP_500, + trmnlDeviceType = trmnlDeviceConfig.type, + imageUrl = "", + imageName = ERROR_TYPE_DEVICE_SETUP_REQUIRED, + error = "Device setup required", + refreshIntervalSeconds = 0L, + ) } diff --git a/app/src/main/java/ink/trmnl/android/network/model/TrmnlDisplayResponse.kt b/app/src/main/java/ink/trmnl/android/network/model/TrmnlDisplayResponse.kt index a9f3080..9ddb3f5 100644 --- a/app/src/main/java/ink/trmnl/android/network/model/TrmnlDisplayResponse.kt +++ b/app/src/main/java/ink/trmnl/android/network/model/TrmnlDisplayResponse.kt @@ -64,7 +64,7 @@ data class TrmnlDisplayResponse( */ val status: Int = HTTP_NONE, @Json(name = "image_url") val imageUrl: String?, - @Json(name = "filename") val imageName: String?, + @Json(name = "filename") val imageFileName: String?, @Json(name = "update_firmware") val updateFirmware: Boolean?, @Json(name = "firmware_url") val firmwareUrl: String?, @Json(name = "refresh_rate") val refreshRate: Long?, diff --git a/app/src/main/java/ink/trmnl/android/util/NetworkExtensions.kt b/app/src/main/java/ink/trmnl/android/util/NetworkExtensions.kt index 64a8854..0a190bf 100644 --- a/app/src/main/java/ink/trmnl/android/util/NetworkExtensions.kt +++ b/app/src/main/java/ink/trmnl/android/util/NetworkExtensions.kt @@ -1,5 +1,7 @@ package ink.trmnl.android.util +import ink.trmnl.android.data.TrmnlDisplayInfo + /** * 500 Internal Server Error - A generic error message, given when an unexpected * condition was encountered and no more specific message is suitable. @@ -32,3 +34,12 @@ internal fun Int?.isHttpOk(): Boolean = this == HTTP_OK || this == HTTP_200 || t * Extension function to check if the HTTP status code is an error. */ internal fun Int?.isHttpError(): Boolean = this == HTTP_500 || this == null + +/** + * Special error code provided in the [TrmnlDisplayInfo.imageName] as hack to indicate that the device requires setup. + * + * See following for additional context: + * - https://discord.com/channels/1281055965508141100/1331360842809348106/1384605617456545904 + * - https://github.com/usetrmnl/trmnl-android/issues/83 + */ +internal const val ERROR_TYPE_DEVICE_SETUP_REQUIRED = "device_requires_setup" diff --git a/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt b/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt index 037c39d..8e9c8a7 100644 --- a/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt +++ b/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt @@ -81,7 +81,7 @@ class TrmnlDisplayRepositoryTest { TrmnlDisplayResponse( status = 200, imageUrl = "https://test.com/image.png", - imageName = "test-image.png", + imageFileName = "test-image.png", refreshRate = 300L, error = null, updateFirmware = null, @@ -122,7 +122,7 @@ class TrmnlDisplayRepositoryTest { TrmnlDisplayResponse( status = 500, imageUrl = null, - imageName = null, + imageFileName = null, refreshRate = null, error = "Error fetching display", updateFirmware = null, @@ -341,7 +341,7 @@ class TrmnlDisplayRepositoryTest { TrmnlDisplayResponse( status = 200, imageUrl = "https://test.com/image.png", - imageName = "test-image.png", + imageFileName = "test-image.png", refreshRate = 300L, error = null, updateFirmware = null, @@ -385,7 +385,7 @@ class TrmnlDisplayRepositoryTest { TrmnlDisplayResponse( status = 200, imageUrl = "https://test.com/image.png", - imageName = "test-image.png", + imageFileName = "test-image.png", refreshRate = 300L, error = null, updateFirmware = null, From 010c336751b4673cbc3d68f5516611a9f1d6deb8 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Tue, 17 Jun 2025 17:02:23 -0400 Subject: [PATCH 3/4] [ADDED] Device setup flow required for BYOS Hanami servers Fixes #76 Fixes #83 --- .../ink/trmnl/android/data/DeviceSetupInfo.kt | 15 +++ .../trmnl/android/data/TrmnlDisplayInfo.kt | 10 +- .../android/data/TrmnlDisplayRepository.kt | 61 ++++++++-- .../trmnl/android/network/TrmnlApiService.kt | 17 ++- .../network/model/TrmnlDisplayResponse.kt | 11 ++ .../android/ui/settings/AppSettingsScreen.kt | 114 ++++++++++++++++-- .../trmnl/android/util/NetworkExtensions.kt | 2 +- .../android/work/TrmnlImageRefreshWorker.kt | 2 +- .../data/TrmnlDisplayRepositoryTest.kt | 12 +- .../work/TrmnlImageRefreshWorkerTest.kt | 18 +-- 10 files changed, 224 insertions(+), 38 deletions(-) create mode 100644 app/src/main/java/ink/trmnl/android/data/DeviceSetupInfo.kt diff --git a/app/src/main/java/ink/trmnl/android/data/DeviceSetupInfo.kt b/app/src/main/java/ink/trmnl/android/data/DeviceSetupInfo.kt new file mode 100644 index 0000000..afe59a0 --- /dev/null +++ b/app/src/main/java/ink/trmnl/android/data/DeviceSetupInfo.kt @@ -0,0 +1,15 @@ +package ink.trmnl.android.data + +/** + * Represents the setup information for a TRMNL device. + * + * This data class is used to encapsulate the result of setting up a new device, + * including whether the setup was successful, the device's MAC ID, API key, + * and any relevant messages. + */ +data class DeviceSetupInfo( + val success: Boolean, + val deviceMacId: String, + val apiKey: String, + val message: String, +) diff --git a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayInfo.kt b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayInfo.kt index cfdf292..4639259 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayInfo.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayInfo.kt @@ -3,6 +3,7 @@ package ink.trmnl.android.data import androidx.annotation.Keep import ink.trmnl.android.data.AppConfig.DEFAULT_REFRESH_INTERVAL_SEC import ink.trmnl.android.model.TrmnlDeviceType +import ink.trmnl.android.util.ERROR_TYPE_DEVICE_SETUP_REQUIRED import ink.trmnl.android.util.HTTP_200 import ink.trmnl.android.util.HTTP_500 import ink.trmnl.android.util.HTTP_OK @@ -19,7 +20,14 @@ data class TrmnlDisplayInfo constructor( val status: Int, val trmnlDeviceType: TrmnlDeviceType, val imageUrl: String, - val imageName: String, + /** + * The file name of the image to be displayed. + * + * If this is an error type, it indicates a specific error condition. + * For example: + * - [ERROR_TYPE_DEVICE_SETUP_REQUIRED] + */ + val imageFileName: String, val error: String? = null, val refreshIntervalSeconds: Long? = DEFAULT_REFRESH_INTERVAL_SEC, /** diff --git a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt index b1d955a..acec462 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt @@ -2,6 +2,7 @@ package ink.trmnl.android.data import com.slack.eithernet.ApiResult import com.slack.eithernet.InternalEitherNetApi +import com.slack.eithernet.exceptionOrNull import com.squareup.anvil.annotations.optional.SingleIn import ink.trmnl.android.BuildConfig.USE_FAKE_API import ink.trmnl.android.di.AppScope @@ -59,7 +60,7 @@ class TrmnlDisplayRepository deviceMacId = trmnlDeviceConfig.deviceMacId, // TEMP FIX: Use Base64 encoding to avoid relative path issue // See https://github.com/usetrmnl/trmnl-android/issues/76#issuecomment-2980018109 - useBase64 = trmnlDeviceConfig.type == TrmnlDeviceType.BYOS, + // useBase64 = trmnlDeviceConfig.type == TrmnlDeviceType.BYOS, // Disabled for now ) when (result) { @@ -79,7 +80,7 @@ class TrmnlDisplayRepository status = response.status, trmnlDeviceType = trmnlDeviceConfig.type, imageUrl = response.imageUrl ?: "", - imageName = response.imageFileName ?: "", + imageFileName = response.imageFileName ?: "", error = response.error, refreshIntervalSeconds = response.refreshRate, httpResponseMetadata = extractHttpResponseMetadata(result), @@ -139,7 +140,7 @@ class TrmnlDisplayRepository status = response.status, trmnlDeviceType = trmnlDeviceConfig.type, imageUrl = response.imageUrl ?: "", - imageName = response.filename ?: "", + imageFileName = response.filename ?: "", error = response.error, refreshIntervalSeconds = response.refreshRateSec, httpResponseMetadata = extractHttpResponseMetadata(result), @@ -158,6 +159,46 @@ class TrmnlDisplayRepository } } + /** + * Sets up a new device by calling the setup API endpoint. + * + * This is only applicable for BYOS devices, as other device types do not require setup. + * + * @param trmnlDeviceConfig The configuration for the device to be set up. + * @return A [DeviceSetupInfo] object containing the result of the setup operation. + */ + suspend fun setupNewDevice(trmnlDeviceConfig: TrmnlDeviceConfig): DeviceSetupInfo { + if (trmnlDeviceConfig.type != TrmnlDeviceType.BYOS) { + Timber.w("Device setup is only applicable for BYOS devices.") + } + + val result = + apiService.setupNewDevice( + fullApiUrl = constructApiUrl(trmnlDeviceConfig.apiBaseUrl, TrmnlApiService.SETUP_API_PATH), + deviceMacId = requireNotNull(trmnlDeviceConfig.deviceMacId) { "Device MAC ID is required for setup" }, + ) + when (result) { + is ApiResult.Failure -> { + Timber.e("Failed to setup device: ${result.exceptionOrNull()}") + return DeviceSetupInfo( + success = false, + deviceMacId = trmnlDeviceConfig.deviceMacId, + apiKey = "", + message = "Failed to setup device with ID (${trmnlDeviceConfig.deviceMacId}). Reason: $result", + ) + } + is ApiResult.Success -> { + Timber.i("Device setup successful: ${result.value}") + return DeviceSetupInfo( + success = true, + deviceMacId = trmnlDeviceConfig.deviceMacId, + apiKey = result.value.apiKey, + message = result.value.message, + ) + } + } + } + /** * Generates fake display info for debugging purposes without wasting an API request. * @@ -176,7 +217,7 @@ class TrmnlDisplayRepository status = HTTP_200, trmnlDeviceType = TrmnlDeviceType.TRMNL, imageUrl = mockImageUrl, - imageName = "mocked-image-" + mockImageUrl.substringAfterLast('?'), + imageFileName = "mocked-image-" + mockImageUrl.substringAfterLast('?'), error = null, refreshIntervalSeconds = mockRefreshRate, ) @@ -215,7 +256,7 @@ class TrmnlDisplayRepository status = HTTP_500, trmnlDeviceType = trmnlDeviceConfig.type, imageUrl = "", - imageName = "", + imageFileName = "", error = "API failure", refreshIntervalSeconds = 0L, ) @@ -229,7 +270,7 @@ class TrmnlDisplayRepository status = HTTP_500, trmnlDeviceType = trmnlDeviceConfig.type, imageUrl = "", - imageName = "", + imageFileName = "", error = "HTTP failure: ${failure.code}, error: ${failure.error}", refreshIntervalSeconds = 0L, ) @@ -243,7 +284,7 @@ class TrmnlDisplayRepository status = HTTP_500, trmnlDeviceType = trmnlDeviceConfig.type, imageUrl = "", - imageName = "", + imageFileName = "", error = "Network failure: ${failure.error.localizedMessage}", refreshIntervalSeconds = 0L, ) @@ -257,7 +298,7 @@ class TrmnlDisplayRepository status = HTTP_500, trmnlDeviceType = trmnlDeviceConfig.type, imageUrl = "", - imageName = "", + imageFileName = "", error = "Unknown failure: ${failure.error.localizedMessage}", refreshIntervalSeconds = 0L, ) @@ -308,7 +349,7 @@ class TrmnlDisplayRepository trmnlDeviceConfig.type == TrmnlDeviceType.BYOS && response.imageFileName?.startsWith("setup", ignoreCase = true) == true && // This ensures that no screen is generated yet for the device - response.imageFileName.contains("screens", ignoreCase = true).not() + response.imageUrl?.contains("screens", ignoreCase = true) == false /** * Creates a [TrmnlDisplayInfo] indicating that the device requires setup. @@ -322,7 +363,7 @@ class TrmnlDisplayRepository status = HTTP_500, trmnlDeviceType = trmnlDeviceConfig.type, imageUrl = "", - imageName = ERROR_TYPE_DEVICE_SETUP_REQUIRED, + imageFileName = ERROR_TYPE_DEVICE_SETUP_REQUIRED, error = "Device setup required", refreshIntervalSeconds = 0L, ) diff --git a/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt b/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt index e5808c6..f3e7bc3 100644 --- a/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt +++ b/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt @@ -10,7 +10,7 @@ import retrofit2.http.Header import retrofit2.http.Url /** - * API service interface for TRMNL. + * API service interface for TRMNL or BYOS servers. * * This interface defines the endpoints for the TRMNL API. * @@ -25,7 +25,8 @@ interface TrmnlApiService { /** * Path for the TRMNL API endpoint that provides the next image in a playlist. * - * https://docs.usetrmnl.com/go/private-api/fetch-screen-content#auto-advance-content + * - https://docs.usetrmnl.com/go/private-api/fetch-screen-content#auto-advance-content + * - https://github.com/usetrmnl/byos_hanami?tab=readme-ov-file#display * * @see getNextDisplayData */ @@ -44,8 +45,17 @@ interface TrmnlApiService { * Path for the TRMNL API endpoint used for new device setup. * * https://github.com/usetrmnl/byos_hanami?tab=readme-ov-file#setup-1 + * + * @see setupNewDevice */ - internal const val SETUP_API_PATH = "api/setup" + internal const val SETUP_API_PATH = "api/setup/" + + /** + * Default content type for API requests. + * + * This is used when setting up a new device or making other API calls that require a content type header. + */ + private const val DEFAULT_CONTENT_TYPE = "application/json" } /** @@ -95,5 +105,6 @@ interface TrmnlApiService { suspend fun setupNewDevice( @Url fullApiUrl: String, @Header("ID") deviceMacId: String, + @Header("Content-Type") contentType: String = DEFAULT_CONTENT_TYPE, ): ApiResult } diff --git a/app/src/main/java/ink/trmnl/android/network/model/TrmnlDisplayResponse.kt b/app/src/main/java/ink/trmnl/android/network/model/TrmnlDisplayResponse.kt index 9ddb3f5..f2214ad 100644 --- a/app/src/main/java/ink/trmnl/android/network/model/TrmnlDisplayResponse.kt +++ b/app/src/main/java/ink/trmnl/android/network/model/TrmnlDisplayResponse.kt @@ -49,6 +49,17 @@ import ink.trmnl.android.util.HTTP_NONE * "update_firmware": false * } * ``` + * + * Sample 4404 response from BYOS Hanami server: + * ```json + * { + * "type": "/problem_details#device_id", + * "title": "Not Found", + * "status": 404, + * "detail": "Invalid device ID.", + * "instance": "/api/display" + * } + * ``` */ @JsonClass(generateAdapter = true) data class TrmnlDisplayResponse( diff --git a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt index a9bd4a0..f15fcc3 100644 --- a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt +++ b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -85,6 +86,7 @@ import dagger.assisted.AssistedInject import ink.trmnl.android.R import ink.trmnl.android.data.AppConfig.DEFAULT_REFRESH_INTERVAL_SEC import ink.trmnl.android.data.AppConfig.TRMNL_API_SERVER_BASE_URL +import ink.trmnl.android.data.DeviceSetupInfo import ink.trmnl.android.data.RepositoryConfigProvider import ink.trmnl.android.data.TrmnlDeviceConfigDataStore import ink.trmnl.android.data.TrmnlDisplayRepository @@ -99,6 +101,7 @@ import ink.trmnl.android.ui.settings.AppSettingsScreen.ValidationResult.InvalidS import ink.trmnl.android.ui.settings.AppSettingsScreen.ValidationResult.Success import ink.trmnl.android.ui.theme.TrmnlDisplayAppTheme import ink.trmnl.android.util.CoilRequestUtils +import ink.trmnl.android.util.ERROR_TYPE_DEVICE_SETUP_REQUIRED import ink.trmnl.android.util.NextImageRefreshDisplayInfo import ink.trmnl.android.util.isHttpError import ink.trmnl.android.util.isValidMacAddress @@ -140,6 +143,8 @@ data class AppSettingsScreen( val usesFakeApiData: Boolean, val isLoading: Boolean = false, val validationResult: ValidationResult? = null, + val isDeviceSetupLoading: Boolean = false, + val deviceSetupMessage: String? = null, val nextRefreshJobInfo: NextImageRefreshDisplayInfo? = null, val eventSink: (Event) -> Unit, ) : CircuitUiState @@ -161,6 +166,10 @@ data class AppSettingsScreen( data class Failure( val message: String, ) : ValidationResult() + + data class DeviceSetupRequired( + val message: String, + ) : ValidationResult() } /** @@ -213,6 +222,10 @@ data class AppSettingsScreen( * Event triggered when the info icon is clicked. */ data object AppInfoPressed : Event() + + data class SetupDevice( + val deviceMacId: String, + ) : Event() } } @@ -239,6 +252,8 @@ class AppSettingsPresenter var deviceMacId by remember { mutableStateOf("") } var isLoading by remember { mutableStateOf(false) } var validationResult by remember { mutableStateOf(null) } + var isDeviceSetupLoading by remember { mutableStateOf(false) } + var deviceSetupMessage by remember { mutableStateOf(null) } val usesFakeApiData = repositoryConfigProvider.shouldUseFakeData val scope = rememberCoroutineScope() val focusManager = LocalFocusManager.current @@ -273,6 +288,8 @@ class AppSettingsPresenter usesFakeApiData = usesFakeApiData, isLoading = isLoading, validationResult = validationResult, + isDeviceSetupLoading = isDeviceSetupLoading, + deviceSetupMessage = deviceSetupMessage, nextRefreshJobInfo = nextRefreshInfo, eventSink = { event -> when (event) { @@ -280,6 +297,7 @@ class AppSettingsPresenter accessToken = event.token // Clear previous validation when token changes validationResult = null + deviceSetupMessage = null } AppSettingsScreen.Event.ValidateToken -> { @@ -287,6 +305,7 @@ class AppSettingsPresenter focusManager.clearFocus() isLoading = true validationResult = null + deviceSetupMessage = null // First validate server URL if device type is BYOS if (deviceType == TrmnlDeviceType.BYOS) { @@ -333,10 +352,17 @@ class AppSettingsPresenter } if (response.status.isHttpError()) { - // Handle explicit error response - // FIXME - default error message is wrong, can't assume device not found - val errorMessage = response.error ?: "Device not found" - validationResult = Failure(errorMessage) + if (response.imageFileName == ERROR_TYPE_DEVICE_SETUP_REQUIRED) { + // Special case for device setup required + validationResult = + ValidationResult.DeviceSetupRequired( + response.error ?: "Device setup required. Please follow the setup instructions.", + ) + } else { + // Handle explicit error response + val errorMessage = response.error ?: "Unexpected error occurred. Please check required inputs." + validationResult = Failure(errorMessage) + } } else if (response.imageUrl.isNotBlank()) { // Success case - we have an image URL trmnlImageUpdateManager.updateImage(response.imageUrl, response.refreshIntervalSeconds) @@ -392,6 +418,7 @@ class AppSettingsPresenter deviceType = event.type // Clear validation result when device type changes validationResult = null + deviceSetupMessage = null } is AppSettingsScreen.Event.ServerUrlChanged -> { @@ -399,6 +426,7 @@ class AppSettingsPresenter // Clear validation result when server URL changes if (validationResult is InvalidServerUrl) { validationResult = null + deviceSetupMessage = null } } @@ -406,12 +434,42 @@ class AppSettingsPresenter deviceMacId = event.deviceMacId // Clear previous validation when device ID changes validationResult = null + deviceSetupMessage = null } AppSettingsScreen.Event.AppInfoPressed -> { // Navigate to AppInfoScreen navigator.goTo(AppInfoScreen) } + + is AppSettingsScreen.Event.SetupDevice -> { + isDeviceSetupLoading = true + deviceSetupMessage = null + + scope.launch { + // Call the setup API with the provided device ID + val setupResult: DeviceSetupInfo = + displayRepository.setupNewDevice( + TrmnlDeviceConfig( + type = deviceType, + apiBaseUrl = serverBaseUrl.forDevice(deviceType), + apiAccessToken = accessToken, + deviceMacId = event.deviceMacId, + ), + ) + + isDeviceSetupLoading = false + + if (!setupResult.success) { + // Handle error response + deviceSetupMessage = setupResult.message + } else { + deviceSetupMessage = "Device setup successful! You can now use your TRMNL." + // Also prepopulate the access token + accessToken = setupResult.apiKey + } + } + } } }, ) @@ -608,7 +666,7 @@ fun AppSettingsContent( modifier = Modifier.fillMaxWidth(), ) { when (result) { - is ValidationResult.Success -> { + is Success -> { Column( modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -645,7 +703,7 @@ fun AppSettingsContent( } } } - is ValidationResult.InvalidServerUrl -> { + is InvalidServerUrl -> { Column( modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -681,7 +739,7 @@ fun AppSettingsContent( ) } } - is ValidationResult.Failure -> { + is Failure -> { // Error state remains the same Column( modifier = Modifier.padding(16.dp), @@ -700,6 +758,48 @@ fun AppSettingsContent( ) } } + + is ValidationResult.DeviceSetupRequired -> { + Column( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "⚠️ Device Setup Required", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.height(8.dp)) + if (state.deviceSetupMessage != null) { + Text( + text = state.deviceSetupMessage, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.primary, + ) + } else { + FilledTonalButton( + onClick = { + state.eventSink( + AppSettingsScreen.Event.SetupDevice( + deviceMacId = state.deviceMacId, + ), + ) + }, + enabled = !state.isDeviceSetupLoading, + ) { + if (state.isDeviceSetupLoading) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + } else { + Text("Setup Device") + } + } + } + } + } } } } diff --git a/app/src/main/java/ink/trmnl/android/util/NetworkExtensions.kt b/app/src/main/java/ink/trmnl/android/util/NetworkExtensions.kt index 0a190bf..bdb6a1e 100644 --- a/app/src/main/java/ink/trmnl/android/util/NetworkExtensions.kt +++ b/app/src/main/java/ink/trmnl/android/util/NetworkExtensions.kt @@ -36,7 +36,7 @@ internal fun Int?.isHttpOk(): Boolean = this == HTTP_OK || this == HTTP_200 || t internal fun Int?.isHttpError(): Boolean = this == HTTP_500 || this == null /** - * Special error code provided in the [TrmnlDisplayInfo.imageName] as hack to indicate that the device requires setup. + * Special error code provided in the [TrmnlDisplayInfo.imageFileName] as hack to indicate that the device requires setup. * * See following for additional context: * - https://discord.com/channels/1281055965508141100/1331360842809348106/1384605617456545904 diff --git a/app/src/main/java/ink/trmnl/android/work/TrmnlImageRefreshWorker.kt b/app/src/main/java/ink/trmnl/android/work/TrmnlImageRefreshWorker.kt index 79d9051..90a630e 100644 --- a/app/src/main/java/ink/trmnl/android/work/TrmnlImageRefreshWorker.kt +++ b/app/src/main/java/ink/trmnl/android/work/TrmnlImageRefreshWorker.kt @@ -118,7 +118,7 @@ class TrmnlImageRefreshWorker( refreshLogManager.addSuccessLog( trmnlDeviceType = deviceConfig.type, imageUrl = trmnlDisplayInfo.imageUrl, - imageName = trmnlDisplayInfo.imageName, + imageName = trmnlDisplayInfo.imageFileName, refreshIntervalSeconds = trmnlDisplayInfo.refreshIntervalSeconds, imageRefreshWorkType = workTypeValue, httpResponseMetadata = trmnlDisplayInfo.httpResponseMetadata, diff --git a/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt b/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt index 8e9c8a7..54d34a8 100644 --- a/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt +++ b/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt @@ -105,7 +105,7 @@ class TrmnlDisplayRepositoryTest { // Assert assertThat(result.status).isEqualTo(200) assertThat(result.imageUrl).isEqualTo("https://test.com/image.png") - assertThat(result.imageName).isEqualTo("test-image.png") + assertThat(result.imageFileName).isEqualTo("test-image.png") assertThat(result.refreshIntervalSeconds).isEqualTo(300L) assertThat(result.error).isNull() assertThat(result.trmnlDeviceType).isEqualTo(TrmnlDeviceType.TRMNL) @@ -146,7 +146,7 @@ class TrmnlDisplayRepositoryTest { // Assert assertThat(result.status).isEqualTo(500) assertThat(result.imageUrl).isEmpty() - assertThat(result.imageName).isEmpty() + assertThat(result.imageFileName).isEmpty() assertThat(result.refreshIntervalSeconds).isNull() assertThat(result.error).isEqualTo("Error fetching display") assertThat(result.trmnlDeviceType).isEqualTo(TrmnlDeviceType.TRMNL) @@ -184,7 +184,7 @@ class TrmnlDisplayRepositoryTest { // Assert assertThat(result.status).isEqualTo(200) assertThat(result.imageUrl).isEqualTo("https://test.com/current.png") - assertThat(result.imageName).isEqualTo("current-image.png") + assertThat(result.imageFileName).isEqualTo("current-image.png") assertThat(result.refreshIntervalSeconds).isEqualTo(600L) assertThat(result.error).isNull() assertThat(result.trmnlDeviceType).isEqualTo(TrmnlDeviceType.TRMNL) @@ -222,7 +222,7 @@ class TrmnlDisplayRepositoryTest { // Assert assertThat(result.status).isEqualTo(500) assertThat(result.imageUrl).isEmpty() - assertThat(result.imageName).isEmpty() + assertThat(result.imageFileName).isEmpty() assertThat(result.refreshIntervalSeconds).isNull() assertThat(result.error).isEqualTo("Device not found") assertThat(result.trmnlDeviceType).isEqualTo(TrmnlDeviceType.TRMNL) @@ -243,7 +243,7 @@ class TrmnlDisplayRepositoryTest { // Assert assertThat(result.status).isEqualTo(200) assertThat(result.imageUrl).contains("picsum.photos") - assertThat(result.imageName).contains("mocked-image-grayscale&time") + assertThat(result.imageFileName).contains("mocked-image-grayscale&time") assertThat(result.refreshIntervalSeconds).isEqualTo(600L) assertThat(result.error).isNull() @@ -263,7 +263,7 @@ class TrmnlDisplayRepositoryTest { // Assert assertThat(result.status).isEqualTo(200) assertThat(result.imageUrl).contains("picsum.photos") - assertThat(result.imageName).contains("mocked-image-grayscale&time") + assertThat(result.imageFileName).contains("mocked-image-grayscale&time") assertThat(result.refreshIntervalSeconds).isEqualTo(600L) assertThat(result.error).isNull() diff --git a/app/src/test/java/ink/trmnl/android/work/TrmnlImageRefreshWorkerTest.kt b/app/src/test/java/ink/trmnl/android/work/TrmnlImageRefreshWorkerTest.kt index 55f8721..0443975 100644 --- a/app/src/test/java/ink/trmnl/android/work/TrmnlImageRefreshWorkerTest.kt +++ b/app/src/test/java/ink/trmnl/android/work/TrmnlImageRefreshWorkerTest.kt @@ -139,7 +139,7 @@ class TrmnlImageRefreshWorkerTest { status = HTTP_200, trmnlDeviceType = TrmnlDeviceType.TRMNL, imageUrl = validImageUrl, - imageName = "test-image.png", + imageFileName = "test-image.png", refreshIntervalSeconds = validRefreshRate, ) @@ -181,7 +181,7 @@ class TrmnlImageRefreshWorkerTest { status = HTTP_200, trmnlDeviceType = TrmnlDeviceType.BYOD, imageUrl = validImageUrl, - imageName = "test-image.png", + imageFileName = "test-image.png", refreshIntervalSeconds = validRefreshRate, ) @@ -210,7 +210,7 @@ class TrmnlImageRefreshWorkerTest { status = HTTP_200, trmnlDeviceType = TrmnlDeviceType.BYOS, imageUrl = validImageUrl, - imageName = "test-image.png", + imageFileName = "test-image.png", refreshIntervalSeconds = validRefreshRate, ) @@ -243,7 +243,7 @@ class TrmnlImageRefreshWorkerTest { status = HTTP_200, trmnlDeviceType = TrmnlDeviceType.TRMNL, imageUrl = validImageUrl, - imageName = "test-image.png", + imageFileName = "test-image.png", refreshIntervalSeconds = validRefreshRate, ) @@ -270,7 +270,7 @@ class TrmnlImageRefreshWorkerTest { status = HTTP_500, trmnlDeviceType = TrmnlDeviceType.TRMNL, imageUrl = "", - imageName = "", + imageFileName = "", error = "Device not found", refreshIntervalSeconds = null, ) @@ -304,7 +304,7 @@ class TrmnlImageRefreshWorkerTest { status = HTTP_200, trmnlDeviceType = TrmnlDeviceType.TRMNL, imageUrl = "", - imageName = "", + imageFileName = "", refreshIntervalSeconds = validRefreshRate, ) @@ -330,7 +330,7 @@ class TrmnlImageRefreshWorkerTest { status = HTTP_200, trmnlDeviceType = TrmnlDeviceType.TRMNL, imageUrl = validImageUrl, - imageName = "test-image.png", + imageFileName = "test-image.png", refreshIntervalSeconds = newRefreshRate, ) @@ -357,7 +357,7 @@ class TrmnlImageRefreshWorkerTest { status = HTTP_200, trmnlDeviceType = TrmnlDeviceType.TRMNL, imageUrl = validImageUrl, - imageName = "test-image.png", + imageFileName = "test-image.png", refreshIntervalSeconds = validRefreshRate, ) @@ -389,7 +389,7 @@ class TrmnlImageRefreshWorkerTest { status = HTTP_200, trmnlDeviceType = TrmnlDeviceType.TRMNL, imageUrl = validImageUrl, - imageName = "test-image.png", + imageFileName = "test-image.png", refreshIntervalSeconds = validRefreshRate, ) From 6e4d781ec20567ee244389a5c45bafade2995852 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Tue, 17 Jun 2025 17:08:49 -0400 Subject: [PATCH 4/4] [MINOR] Update message to direct user at right direction --- .../java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt index f15fcc3..d1f176c 100644 --- a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt +++ b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt @@ -464,7 +464,7 @@ class AppSettingsPresenter // Handle error response deviceSetupMessage = setupResult.message } else { - deviceSetupMessage = "Device setup successful! You can now use your TRMNL." + deviceSetupMessage = "Device setup successful! Re-validate ID/Token to continue." // Also prepopulate the access token accessToken = setupResult.apiKey }