mirror of
https://github.com/usetrmnl/trmnl-android.git
synced 2026-04-29 13:35:26 -07:00
Merge pull request #84 from usetrmnl/76-byos_hanami-compatibility-part-2
[ADDED] Support for BYOS device setup and display image compatibility
This commit is contained in:
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
@@ -10,6 +11,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
|
||||
@@ -57,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) {
|
||||
@@ -66,13 +69,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 ?: "",
|
||||
imageFileName = response.imageFileName ?: "",
|
||||
error = response.error,
|
||||
refreshIntervalSeconds = response.refreshRate,
|
||||
httpResponseMetadata = extractHttpResponseMetadata(result),
|
||||
@@ -132,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),
|
||||
@@ -151,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.
|
||||
*
|
||||
@@ -169,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,
|
||||
)
|
||||
@@ -208,7 +256,7 @@ class TrmnlDisplayRepository
|
||||
status = HTTP_500,
|
||||
trmnlDeviceType = trmnlDeviceConfig.type,
|
||||
imageUrl = "",
|
||||
imageName = "",
|
||||
imageFileName = "",
|
||||
error = "API failure",
|
||||
refreshIntervalSeconds = 0L,
|
||||
)
|
||||
@@ -222,7 +270,7 @@ class TrmnlDisplayRepository
|
||||
status = HTTP_500,
|
||||
trmnlDeviceType = trmnlDeviceConfig.type,
|
||||
imageUrl = "",
|
||||
imageName = "",
|
||||
imageFileName = "",
|
||||
error = "HTTP failure: ${failure.code}, error: ${failure.error}",
|
||||
refreshIntervalSeconds = 0L,
|
||||
)
|
||||
@@ -236,7 +284,7 @@ class TrmnlDisplayRepository
|
||||
status = HTTP_500,
|
||||
trmnlDeviceType = trmnlDeviceConfig.type,
|
||||
imageUrl = "",
|
||||
imageName = "",
|
||||
imageFileName = "",
|
||||
error = "Network failure: ${failure.error.localizedMessage}",
|
||||
refreshIntervalSeconds = 0L,
|
||||
)
|
||||
@@ -250,7 +298,7 @@ class TrmnlDisplayRepository
|
||||
status = HTTP_500,
|
||||
trmnlDeviceType = trmnlDeviceConfig.type,
|
||||
imageUrl = "",
|
||||
imageName = "",
|
||||
imageFileName = "",
|
||||
error = "Unknown failure: ${failure.error.localizedMessage}",
|
||||
refreshIntervalSeconds = 0L,
|
||||
)
|
||||
@@ -285,4 +333,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.imageUrl?.contains("screens", ignoreCase = true) == false
|
||||
|
||||
/**
|
||||
* 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 = "",
|
||||
imageFileName = ERROR_TYPE_DEVICE_SETUP_REQUIRED,
|
||||
error = "Device setup required",
|
||||
refreshIntervalSeconds = 0L,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -9,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.
|
||||
*
|
||||
@@ -22,18 +23,39 @@ import retrofit2.http.Url
|
||||
interface TrmnlApiService {
|
||||
companion object {
|
||||
/**
|
||||
* https://docs.usetrmnl.com/go/private-api/fetch-screen-content#auto-advance-content
|
||||
* 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://github.com/usetrmnl/byos_hanami?tab=readme-ov-file#display
|
||||
*
|
||||
* @see getNextDisplayData
|
||||
*/
|
||||
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
|
||||
*
|
||||
* @see setupNewDevice
|
||||
*/
|
||||
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"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,4 +90,21 @@ interface TrmnlApiService {
|
||||
@Url fullApiUrl: String,
|
||||
@Header("access-token") accessToken: String,
|
||||
): ApiResult<TrmnlCurrentImageResponse, Unit>
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@Header("Content-Type") contentType: String = DEFAULT_CONTENT_TYPE,
|
||||
): ApiResult<TrmnlSetupResponse, Unit>
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
@@ -64,7 +75,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?,
|
||||
|
||||
@@ -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<ValidationResult?>(null) }
|
||||
var isDeviceSetupLoading by remember { mutableStateOf(false) }
|
||||
var deviceSetupMessage by remember { mutableStateOf<String?>(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! Re-validate ID/Token to continue."
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.imageFileName] 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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -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)
|
||||
@@ -122,7 +122,7 @@ class TrmnlDisplayRepositoryTest {
|
||||
TrmnlDisplayResponse(
|
||||
status = 500,
|
||||
imageUrl = null,
|
||||
imageName = null,
|
||||
imageFileName = null,
|
||||
refreshRate = null,
|
||||
error = "Error fetching display",
|
||||
updateFirmware = null,
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user