From 36ed46ce7e817471f9ff80dd9ba9b7a7e1e633b6 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 31 Jan 2026 09:42:14 -0500 Subject: [PATCH 1/9] feat: Add BYOD user API token validation with /api/me endpoint - Add TrmnlUserResponse model with user profile fields (id, name, email, etc.) - Add /api/me endpoint to TrmnlUserApiService with Bearer authentication - Update TrmnlDeviceConfig to include userApiToken field - Add userApiToken persistence to TrmnlDeviceConfigDataStore - Implement validateUserApiToken() in TrmnlDisplayRepository - Add Dagger provider for TrmnlUserApiService in NetworkModule - Add user token input field to AppSettingsScreen (visible only for BYOD devices) - Implement token validation UI with success/error states - Update SaveAndContinue to persist user token in device config - Fix TrmnlDisplayRepositoryTest to include userApiService mock This enables BYOD users to provide and validate their user-level API token separately from the device-level access token. --- app/build.gradle.kts | 2 +- .../data/TrmnlDeviceConfigDataStore.kt | 25 +++ .../android/data/TrmnlDisplayRepository.kt | 57 +++++ .../ink/trmnl/android/di/NetworkModule.kt | 5 + .../trmnl/android/model/TrmnlDeviceConfig.kt | 7 + .../android/network/TrmnlUserApiService.kt | 29 +++ .../network/model/TrmnlUserResponse.kt | 60 ++++++ .../android/ui/settings/AppSettingsScreen.kt | 200 +++++++++++++++++- .../data/TrmnlDisplayRepositoryTest.kt | 4 + 9 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/ink/trmnl/android/network/model/TrmnlUserResponse.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 55f6b9c..1bbe774 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -102,7 +102,7 @@ android { // Allow developers to configure this value for debug builds // Use fake API response for local development and testing purposes. // ℹ️ To override during local development, change the value in `RepositoryConfigProvider` - buildConfigField("Boolean", "USE_FAKE_API", "true") + buildConfigField("Boolean", "USE_FAKE_API", "false") signingConfig = signingConfigs.getByName("debug") } diff --git a/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt b/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt index 62d2779..ac216b3 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt @@ -56,6 +56,7 @@ class TrmnlDeviceConfigDataStore private val CONFIG_JSON_KEY = stringPreferencesKey("config_json") private val DEVICE_MAC_ID_KEY = stringPreferencesKey("device_mac_id") private val IS_MASTER_DEVICE_KEY = stringPreferencesKey("is_master_device") + private val USER_API_TOKEN_KEY = stringPreferencesKey("user_api_token") private val DEVICE_MODEL_PREFERENCES_KEY = stringPreferencesKey("device_model_preferences") } @@ -168,6 +169,7 @@ class TrmnlDeviceConfigDataStore val refreshRate = preferences[REFRESH_RATE_SEC_KEY] ?: DEFAULT_REFRESH_INTERVAL_SEC val deviceMacId = preferences[DEVICE_MAC_ID_KEY] val isMasterDevice = preferences[IS_MASTER_DEVICE_KEY]?.toBoolean() + val userApiToken = preferences[USER_API_TOKEN_KEY] if (token != null) { TrmnlDeviceConfig( @@ -177,6 +179,7 @@ class TrmnlDeviceConfigDataStore deviceMacId = deviceMacId, refreshRateSecs = refreshRate, isMasterDevice = isMasterDevice, + userApiToken = userApiToken, ) } else { null @@ -209,6 +212,11 @@ class TrmnlDeviceConfigDataStore config.isMasterDevice?.let { isMaster -> preferences[IS_MASTER_DEVICE_KEY] = isMaster.toString() } ?: preferences.remove(IS_MASTER_DEVICE_KEY) + + // Save userApiToken if available + config.userApiToken?.let { userToken -> + preferences[USER_API_TOKEN_KEY] = userToken + } ?: preferences.remove(USER_API_TOKEN_KEY) } } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to save device config") @@ -224,6 +232,23 @@ class TrmnlDeviceConfigDataStore } } + /** + * Saves the user-level API token (Account API key) + */ + suspend fun saveUserApiToken(token: String) { + context.deviceConfigStore.edit { preferences -> + preferences[USER_API_TOKEN_KEY] = token + } + } + + /** + * Gets the user-level API token + */ + suspend fun getUserApiToken(): String? = + context.deviceConfigStore.data + .map { preferences -> preferences[USER_API_TOKEN_KEY] } + .first() + /** * Saves the access token */ 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 c80aabb..1d3d061 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt @@ -14,8 +14,11 @@ 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.MODELS_API_PATH import ink.trmnl.android.network.TrmnlApiService.Companion.NEXT_PLAYLIST_SCREEN_API_PATH +import ink.trmnl.android.network.TrmnlUserApiService +import ink.trmnl.android.network.TrmnlUserApiService.Companion.USER_INFO_API_PATH import ink.trmnl.android.network.model.TrmnlDeviceModel import ink.trmnl.android.network.model.TrmnlDisplayResponse +import ink.trmnl.android.network.model.TrmnlUser import ink.trmnl.android.network.util.constructApiUrl import ink.trmnl.android.network.util.extractHttpResponseMetadata import ink.trmnl.android.network.util.extractHttpResponseMetadataFromFailure @@ -38,6 +41,7 @@ class TrmnlDisplayRepository @Inject constructor( private val apiService: TrmnlApiService, + private val userApiService: TrmnlUserApiService, private val imageMetadataStore: ImageMetadataStore, private val repositoryConfigProvider: RepositoryConfigProvider, ) { @@ -312,4 +316,57 @@ class TrmnlDisplayRepository mimeType = mimeType, kind = kind, ) + + /** + * Validates a user API token by calling the /api/me endpoint. + * + * This method is used to verify user-level (account) API tokens before saving them. + * On success, returns the user's information. + * + * @param apiBaseUrl The base URL of the TRMNL API server + * @param userApiToken The user API token to validate (should start with "user_") + * @return A Result containing TrmnlUser on success or an exception on failure + */ + suspend fun validateUserApiToken( + apiBaseUrl: String, + userApiToken: String, + ): Result { + Timber.i("Validating user API token") + + if (repositoryConfigProvider.shouldUseFakeData) { + // Return fake user data in debug mode + return Result.success( + TrmnlUser( + id = 42, + name = "Test User", + email = "test@example.com", + firstName = "Test", + lastName = "User", + locale = "en", + timeZone = "Eastern Time (US & Canada)", + timeZoneIana = "America/New_York", + utcOffset = -14400, + apiKey = "user_test123", + ), + ) + } + + 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) + } + } + } } diff --git a/app/src/main/java/ink/trmnl/android/di/NetworkModule.kt b/app/src/main/java/ink/trmnl/android/di/NetworkModule.kt index fab9923..c5c13d0 100644 --- a/app/src/main/java/ink/trmnl/android/di/NetworkModule.kt +++ b/app/src/main/java/ink/trmnl/android/di/NetworkModule.kt @@ -12,6 +12,7 @@ import dagger.Module import dagger.Provides import ink.trmnl.android.BuildConfig import ink.trmnl.android.network.TrmnlApiService +import ink.trmnl.android.network.TrmnlUserApiService import okhttp3.Cache import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor @@ -100,4 +101,8 @@ object NetworkModule { @Provides @SingleIn(AppScope::class) fun provideTrmnlApiService(retrofit: Retrofit): TrmnlApiService = retrofit.create(TrmnlApiService::class.java) + + @Provides + @SingleIn(AppScope::class) + fun provideTrmnlUserApiService(retrofit: Retrofit): TrmnlUserApiService = retrofit.create(TrmnlUserApiService::class.java) } diff --git a/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt b/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt index 33926a1..65a098f 100644 --- a/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt +++ b/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt @@ -33,4 +33,11 @@ data class TrmnlDeviceConfig constructor( * See https://github.com/usetrmnl/trmnl-android/issues/190 */ val isMasterDevice: Boolean? = null, + /** + * User-level API token (Account API key) for user-level endpoints. + * Required for BYOD devices to access user-level API endpoints like /api/me and /api/devices. + * + * This is separate from [apiAccessToken] which is the device-level API key. + */ + val userApiToken: String? = null, ) diff --git a/app/src/main/java/ink/trmnl/android/network/TrmnlUserApiService.kt b/app/src/main/java/ink/trmnl/android/network/TrmnlUserApiService.kt index 34afc94..c272a68 100644 --- a/app/src/main/java/ink/trmnl/android/network/TrmnlUserApiService.kt +++ b/app/src/main/java/ink/trmnl/android/network/TrmnlUserApiService.kt @@ -3,6 +3,7 @@ package ink.trmnl.android.network import com.slack.eithernet.ApiResult import ink.trmnl.android.network.model.TrmnlDeviceResponse import ink.trmnl.android.network.model.TrmnlDeviceUpdateRequest +import ink.trmnl.android.network.model.TrmnlUserResponse import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Header @@ -22,6 +23,17 @@ import retrofit2.http.Url */ interface TrmnlUserApiService { companion object { + /** + * Path for the TRMNL API endpoint to get the authenticated user's information. + * + * **Authentication:** Requires Bearer token (user-level Account API key) + * + * See: https://trmnl.com/api-docs/index.html#/Users/get_api_me + * + * @see getUserInfo + */ + internal const val USER_INFO_API_PATH = "api/me" + /** * Path template for the TRMNL API endpoint to get or update a specific device. * @@ -37,6 +49,23 @@ interface TrmnlUserApiService { internal const val DEVICE_API_PATH = "api/devices/{id}" } + /** + * Retrieve the authenticated user's information using [USER_INFO_API_PATH]. + * + * This endpoint is used to validate the user's API token and retrieve their profile information. + * + * **Authentication:** Requires Bearer token with user-level Account API key + * + * @param fullApiUrl The complete API URL to call (e.g., "https://usetrmnl.com/api/me") + * @param accessToken The bearer authentication token (format: "Bearer your_api_key") + * @return An [ApiResult] containing [TrmnlUserResponse] with the user's information + */ + @GET + suspend fun getUserInfo( + @Url fullApiUrl: String, + @Header("Authorization") accessToken: String, + ): ApiResult + /** * Retrieve device data for a specific device using [DEVICE_API_PATH]. * diff --git a/app/src/main/java/ink/trmnl/android/network/model/TrmnlUserResponse.kt b/app/src/main/java/ink/trmnl/android/network/model/TrmnlUserResponse.kt new file mode 100644 index 0000000..d5c1fb2 --- /dev/null +++ b/app/src/main/java/ink/trmnl/android/network/model/TrmnlUserResponse.kt @@ -0,0 +1,60 @@ +package ink.trmnl.android.network.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Response wrapper for the TRMNL /api/me endpoint. + * + * This response provides information about the authenticated user. + * + * See: https://trmnl.com/api-docs/index.html#/Users/get_api_me + * + * @property data The user data + */ +@JsonClass(generateAdapter = true) +data class TrmnlUserResponse( + @Json(name = "data") + val data: TrmnlUser, +) + +/** + * Represents a TRMNL user's information. + * + * Contains details about the authenticated user including their profile information, + * timezone settings, and API key. + * + * @property id The unique identifier for the user + * @property name The user's full name + * @property email The user's email address + * @property firstName The user's first name + * @property lastName The user's last name + * @property locale The user's locale (e.g., "en") + * @property timeZone The user's timezone in human-readable format (e.g., "Eastern Time (US & Canada)") + * @property timeZoneIana The user's timezone in IANA format (e.g., "America/New_York") + * @property utcOffset The user's UTC offset in seconds + * @property apiKey The user's API key + */ +@JsonClass(generateAdapter = true) +data class TrmnlUser( + @Json(name = "id") + val id: Int, + @Json(name = "name") + val name: String, + @Json(name = "email") + val email: String, + @Json(name = "first_name") + val firstName: String, + @Json(name = "last_name") + val lastName: String, + @Json(name = "locale") + val locale: String, + @Json(name = "time_zone") + val timeZone: String, + @Json(name = "time_zone_iana") + val timeZoneIana: String, + @Json(name = "utc_offset") + val utcOffset: Int, + @Json(name = "api_key") + val apiKey: String, +) 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 4d1516a..428de08 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 @@ -128,8 +128,10 @@ import java.time.format.DateTimeFormatter * This screen allows users to: * - Configure API authentication (access token or device ID) * - Set custom server URLs for BYOS installations + * - Set user access tokens for BYOD devices * - Configure refresh intervals and behavior * - Manage display preferences + * - Validate settings before saving */ @Parcelize data class AppSettingsScreen( @@ -141,6 +143,7 @@ data class AppSettingsScreen( val accessToken: String, val deviceMacId: String, val isByodMasterDevice: Boolean, + val userApiToken: String, val usesFakeApiData: Boolean, val isLoading: Boolean = false, val validationResult: ValidationResult? = null, @@ -172,6 +175,15 @@ data class AppSettingsScreen( data class DeviceSetupRequired( val message: String, ) : ValidationResult() + + data class UserTokenSuccess( + val userName: String, + val userEmail: String, + ) : ValidationResult() + + data class InvalidUserToken( + val message: String, + ) : ValidationResult() } /** @@ -185,6 +197,18 @@ data class AppSettingsScreen( val token: String, ) : Event() + /** + * Event triggered when the user API token is changed. + */ + data class UserApiTokenChanged( + val token: String, + ) : Event() + + /** + * Event triggered to validate the current user API token. + */ + data object ValidateUserToken : Event() + /** * Event triggered to validate the current access token. */ @@ -265,6 +289,7 @@ class AppSettingsPresenter var accessToken by remember { mutableStateOf("") } var deviceMacId by remember { mutableStateOf("") } var isByodMasterDevice by remember { mutableStateOf(true) } + var userApiToken by remember { mutableStateOf("") } var isLoading by remember { mutableStateOf(false) } var validationResult by remember { mutableStateOf(null) } var isDeviceSetupLoading by remember { mutableStateOf(false) } @@ -328,6 +353,11 @@ class AppSettingsPresenter if (it.type == TrmnlDeviceType.BYOD) { isByodMasterDevice = it.isMasterDevice ?: true } + + // Load userApiToken for BYOD + if (it.type == TrmnlDeviceType.BYOD) { + userApiToken = it.userApiToken ?: "" + } } } @@ -337,6 +367,7 @@ class AppSettingsPresenter accessToken = accessToken, deviceMacId = deviceMacId, isByodMasterDevice = isByodMasterDevice, + userApiToken = userApiToken, usesFakeApiData = usesFakeApiData, isLoading = isLoading, validationResult = validationResult, @@ -353,6 +384,61 @@ class AppSettingsPresenter deviceSetupMessage = null } + is AppSettingsScreen.Event.UserApiTokenChanged -> { + userApiToken = event.token + // Clear previous validation when user token changes + if (validationResult is ValidationResult.UserTokenSuccess || + validationResult is ValidationResult.InvalidUserToken + ) { + validationResult = null + } + } + + AppSettingsScreen.Event.ValidateUserToken -> { + scope.launch { + focusManager.clearFocus() + isLoading = true + + // Clear previous user token validation + if (validationResult is ValidationResult.UserTokenSuccess || + validationResult is ValidationResult.InvalidUserToken + ) { + validationResult = null + } + + // Validate user API token by calling /api/me + val result = + displayRepository.validateUserApiToken( + apiBaseUrl = serverBaseUrl.forDevice(deviceType), + userApiToken = userApiToken, + ) + + validationResult = + when { + result.isSuccess -> { + val user = result.getOrNull() + if (user != null) { + // Save the user API token + deviceConfigStore.saveUserApiToken(userApiToken) + ValidationResult.UserTokenSuccess( + userName = user.name, + userEmail = user.email, + ) + } else { + ValidationResult.InvalidUserToken("Failed to retrieve user information") + } + } + else -> { + ValidationResult.InvalidUserToken( + result.exceptionOrNull()?.message ?: "Invalid user API token", + ) + } + } + + isLoading = false + } + } + AppSettingsScreen.Event.ValidateToken -> { scope.launch { focusManager.clearFocus() @@ -455,6 +541,8 @@ class AppSettingsPresenter // Normalize the MAC address to standard format if provided in different format deviceMacId = normalizeMacAddress(deviceMacId)?.ifBlank { null }, isMasterDevice = isMaster, + // Save user API token if provided (for BYOD) + userApiToken = userApiToken.ifBlank { null }, ), ) trmnlWorkScheduler.updateRefreshInterval(result.refreshRateSecs) @@ -692,11 +780,71 @@ fun AppSettingsContent( deviceIdError = (state.validationResult as? ValidationResult.InvalidDeviceMacId)?.message, ) + // User API Token field (only for BYOD) + AnimatedVisibility( + visible = state.deviceType == TrmnlDeviceType.BYOD, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column { + Spacer(modifier = Modifier.height(16.dp)) + + var userTokenVisible by remember { mutableStateOf(false) } + + OutlinedTextField( + value = state.userApiToken, + onValueChange = { state.eventSink(AppSettingsScreen.Event.UserApiTokenChanged(it)) }, + label = { Text("User API Token (Account Key)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = if (userTokenVisible) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = + KeyboardActions( + onDone = { + state.eventSink(AppSettingsScreen.Event.ValidateUserToken) + }, + ), + supportingText = { + Text("Required for device management APIs. Get this from your TRMNL user account settings.") + }, + trailingIcon = { + IconButton(onClick = { userTokenVisible = !userTokenVisible }) { + Icon( + painter = + painterResource( + if (userTokenVisible) R.drawable.visibility_off_24dp else R.drawable.visibility_24dp, + ), + contentDescription = if (userTokenVisible) "Hide user token" else "Show user token", + ) + } + }, + isError = state.validationResult is ValidationResult.InvalidUserToken, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = { state.eventSink(AppSettingsScreen.Event.ValidateUserToken) }, + enabled = state.userApiToken.isNotBlank() && !state.isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Validate User Token") + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + // Password field with toggle visibility button OutlinedTextField( value = state.accessToken, onValueChange = { state.eventSink(AppSettingsScreen.Event.AccessTokenChanged(it)) }, - label = { Text("Access Token") }, + label = { Text("Device Access Token") }, modifier = Modifier.fillMaxWidth(), singleLine = true, visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), @@ -835,6 +983,47 @@ fun AppSettingsContent( ) } } + is ValidationResult.UserTokenSuccess -> { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "✅ User Token Valid", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Welcome, ${result.userName}!", + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + ) + Text( + result.userEmail, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + is ValidationResult.InvalidUserToken -> { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "❌ Invalid User Token", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + result.message, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error, + ) + } + } is ValidationResult.DeviceSetupRequired -> { Column( @@ -1274,6 +1463,7 @@ private fun PreviewAppSettingsContentInitial() { accessToken = "", deviceMacId = "aa:bb:cc:dd:ee:ff", isByodMasterDevice = true, + userApiToken = "", usesFakeApiData = true, isLoading = false, validationResult = null, @@ -1296,6 +1486,7 @@ private fun PreviewAppSettingsContentLoading() { accessToken = "some-token", deviceMacId = "aa:bb:cc:dd:ee:ff", isByodMasterDevice = true, + userApiToken = "", usesFakeApiData = false, isLoading = true, validationResult = null, @@ -1318,6 +1509,7 @@ private fun PreviewAppSettingsContentSuccess() { accessToken = "valid-token-123", deviceMacId = "aa:bb:cc:dd:ee:ff", isByodMasterDevice = true, + userApiToken = "", usesFakeApiData = false, isLoading = false, validationResult = @@ -1344,6 +1536,7 @@ private fun PreviewAppSettingsContentFailure() { accessToken = "invalid-token", deviceMacId = "aa:bb:cc:dd:ee:ff", isByodMasterDevice = true, + userApiToken = "", usesFakeApiData = false, isLoading = false, validationResult = @@ -1373,6 +1566,7 @@ private fun PreviewAppSettingsContentWithWork() { accessToken = "valid-token-123", deviceMacId = "aa:bb:cc:dd:ee:ff", isByodMasterDevice = true, + userApiToken = "", usesFakeApiData = false, isLoading = false, validationResult = null, // Can also be Success state @@ -1405,6 +1599,7 @@ private fun PreviewWorkScheduleStatusCardScheduled() { accessToken = "some-token", deviceMacId = "AA:BB:CC:DD:EE:FF", isByodMasterDevice = true, + userApiToken = "", usesFakeApiData = false, nextRefreshJobInfo = NextImageRefreshDisplayInfo( @@ -1431,6 +1626,7 @@ private fun PreviewWorkScheduleStatusCardNoWork() { accessToken = "some-token", deviceMacId = "aa:bb:cc:dd:ee:ff", isByodMasterDevice = true, + userApiToken = "", usesFakeApiData = false, nextRefreshJobInfo = null, eventSink = {}, @@ -1459,6 +1655,7 @@ private fun PreviewAppSettingsContentByod() { accessToken = "byod-access-token-here", deviceMacId = "", isByodMasterDevice = false, + userApiToken = "user_test123", usesFakeApiData = false, isLoading = false, validationResult = null, @@ -1482,6 +1679,7 @@ private fun PreviewAppSettingsContentByos() { accessToken = "byos-access-token-here", deviceMacId = "AA:BB:CC:DD:EE:FF", isByodMasterDevice = true, + userApiToken = "", usesFakeApiData = false, isLoading = false, validationResult = null, 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 49b4664..fcd71b0 100644 --- a/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt +++ b/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt @@ -5,6 +5,7 @@ import com.slack.eithernet.ApiResult import ink.trmnl.android.model.TrmnlDeviceConfig import ink.trmnl.android.model.TrmnlDeviceType import ink.trmnl.android.network.TrmnlApiService +import ink.trmnl.android.network.TrmnlUserApiService import ink.trmnl.android.network.model.TrmnlCurrentImageResponse import ink.trmnl.android.network.model.TrmnlDisplayResponse import ink.trmnl.android.network.util.constructApiUrl @@ -28,6 +29,7 @@ import org.junit.Test class TrmnlDisplayRepositoryTest { private lateinit var repository: TrmnlDisplayRepository private lateinit var apiService: TrmnlApiService + private lateinit var userApiService: TrmnlUserApiService private lateinit var imageMetadataStore: ImageMetadataStore private lateinit var repositoryConfigProvider: RepositoryConfigProvider private lateinit var deviceConfigDataStore: TrmnlDeviceConfigDataStore @@ -59,6 +61,7 @@ class TrmnlDisplayRepositoryTest { @Before fun setup() { apiService = mockk() + userApiService = mockk() repositoryConfigProvider = mockk() deviceConfigDataStore = mockk() imageMetadataStore = mockk(relaxed = true) @@ -68,6 +71,7 @@ class TrmnlDisplayRepositoryTest { repository = TrmnlDisplayRepository( apiService = apiService, + userApiService = userApiService, imageMetadataStore = imageMetadataStore, repositoryConfigProvider = repositoryConfigProvider, ) From ac554cb1a5312afa65be6c9fceb827f53369504a Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 31 Jan 2026 09:44:46 -0500 Subject: [PATCH 2/9] refactor: Remove unused apiKey field from TrmnlUser model The apiKey property is not needed for the current implementation. --- .../main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt | 1 - .../java/ink/trmnl/android/network/model/TrmnlUserResponse.kt | 3 --- 2 files changed, 4 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 1d3d061..4cb303f 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt @@ -346,7 +346,6 @@ class TrmnlDisplayRepository timeZone = "Eastern Time (US & Canada)", timeZoneIana = "America/New_York", utcOffset = -14400, - apiKey = "user_test123", ), ) } diff --git a/app/src/main/java/ink/trmnl/android/network/model/TrmnlUserResponse.kt b/app/src/main/java/ink/trmnl/android/network/model/TrmnlUserResponse.kt index d5c1fb2..34650c7 100644 --- a/app/src/main/java/ink/trmnl/android/network/model/TrmnlUserResponse.kt +++ b/app/src/main/java/ink/trmnl/android/network/model/TrmnlUserResponse.kt @@ -33,7 +33,6 @@ data class TrmnlUserResponse( * @property timeZone The user's timezone in human-readable format (e.g., "Eastern Time (US & Canada)") * @property timeZoneIana The user's timezone in IANA format (e.g., "America/New_York") * @property utcOffset The user's UTC offset in seconds - * @property apiKey The user's API key */ @JsonClass(generateAdapter = true) data class TrmnlUser( @@ -55,6 +54,4 @@ data class TrmnlUser( val timeZoneIana: String, @Json(name = "utc_offset") val utcOffset: Int, - @Json(name = "api_key") - val apiKey: String, ) From 6387640763d0654de16ae9023bfd578485868a12 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 31 Jan 2026 10:02:17 -0500 Subject: [PATCH 3/9] fix: Address PR review feedback - Revert USE_FAKE_API back to true for debug builds (unintended change) - Remove auto-save on token validation - now only saves on 'Save and Continue' - Combine duplicate BYOD checks into single block for better readability - Improve error handling with Timber logging for unexpected null user - Add documentation explaining optional user token validation Addresses feedback from PR #243 --- app/build.gradle.kts | 2 +- .../android/ui/settings/AppSettingsScreen.kt | 24 ++++++++++++------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1bbe774..55f6b9c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -102,7 +102,7 @@ android { // Allow developers to configure this value for debug builds // Use fake API response for local development and testing purposes. // ℹ️ To override during local development, change the value in `RepositoryConfigProvider` - buildConfigField("Boolean", "USE_FAKE_API", "false") + buildConfigField("Boolean", "USE_FAKE_API", "true") signingConfig = signingConfigs.getByName("debug") } 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 428de08..0c09ec2 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 @@ -349,13 +349,9 @@ class AppSettingsPresenter } } - // Load isMasterDevice setting for BYOD (default to true if not set) + // Load BYOD-specific settings if (it.type == TrmnlDeviceType.BYOD) { isByodMasterDevice = it.isMasterDevice ?: true - } - - // Load userApiToken for BYOD - if (it.type == TrmnlDeviceType.BYOD) { userApiToken = it.userApiToken ?: "" } } @@ -418,14 +414,21 @@ class AppSettingsPresenter result.isSuccess -> { val user = result.getOrNull() if (user != null) { - // Save the user API token - deviceConfigStore.saveUserApiToken(userApiToken) + // Token is valid - user will save it via "Save and Continue" ValidationResult.UserTokenSuccess( userName = user.name, userEmail = user.email, ) } else { - ValidationResult.InvalidUserToken("Failed to retrieve user information") + Timber.e( + "validateUserApiToken succeeded but returned null user. " + + "apiBaseUrl=%s, deviceType=%s", + serverBaseUrl.forDevice(deviceType), + deviceType, + ) + ValidationResult.InvalidUserToken( + "Unexpected error: API returned success but no user data was received", + ) } } else -> { @@ -541,7 +544,10 @@ class AppSettingsPresenter // Normalize the MAC address to standard format if provided in different format deviceMacId = normalizeMacAddress(deviceMacId)?.ifBlank { null }, isMasterDevice = isMaster, - // Save user API token if provided (for BYOD) + // Save user API token if provided (for BYOD). + // Note: user token validation is optional and may be skipped by the user. + // We still persist the token here; any invalid or expired token will be + // detected and surfaced via downstream API error handling. userApiToken = userApiToken.ifBlank { null }, ), ) From 0a3b509bd766b5f1eda86d3c6b6cbe8fcbef5786 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 31 Jan 2026 10:06:09 -0500 Subject: [PATCH 4/9] [MINOR] Updated note properly --- app/build.gradle.kts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 55f6b9c..c5b0741 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -101,7 +101,8 @@ android { debug { // Allow developers to configure this value for debug builds // Use fake API response for local development and testing purposes. - // ℹ️ To override during local development, change the value in `RepositoryConfigProvider` + // ℹ️ To override during local development, change this value to `"false"` + // or, you can change the value in the `RepositoryConfigProvider` buildConfigField("Boolean", "USE_FAKE_API", "true") signingConfig = signingConfigs.getByName("debug") From 8bb97f3f1539f1a691a438f2b6308811309ce293 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 31 Jan 2026 10:08:28 -0500 Subject: [PATCH 5/9] Update app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../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 0c09ec2..c8ff227 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 @@ -816,7 +816,7 @@ fun AppSettingsContent( }, ), supportingText = { - Text("Required for device management APIs. Get this from your TRMNL user account settings.") + Text("Optional: Required for device management features like battery reporting. Get this from your TRMNL user account settings.") }, trailingIcon = { IconButton(onClick = { userTokenVisible = !userTokenVisible }) { From 8487441dbf360a277713662373a6d8e78daf9164 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 31 Jan 2026 10:08:41 -0500 Subject: [PATCH 6/9] Update app/src/main/java/ink/trmnl/android/network/model/TrmnlUserResponse.kt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../java/ink/trmnl/android/network/model/TrmnlUserResponse.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/ink/trmnl/android/network/model/TrmnlUserResponse.kt b/app/src/main/java/ink/trmnl/android/network/model/TrmnlUserResponse.kt index 34650c7..c8d28fb 100644 --- a/app/src/main/java/ink/trmnl/android/network/model/TrmnlUserResponse.kt +++ b/app/src/main/java/ink/trmnl/android/network/model/TrmnlUserResponse.kt @@ -21,8 +21,8 @@ data class TrmnlUserResponse( /** * Represents a TRMNL user's information. * - * Contains details about the authenticated user including their profile information, - * timezone settings, and API key. + * Contains details about the authenticated user including their profile information + * and timezone settings. * * @property id The unique identifier for the user * @property name The user's full name From 5d0e5037fddd4a57f203e0bf51e99bc132ddd0e8 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 31 Jan 2026 10:09:23 -0500 Subject: [PATCH 7/9] [FIXED] Formatting --- .../java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 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 c8ff227..5127ea0 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 @@ -816,7 +816,9 @@ fun AppSettingsContent( }, ), supportingText = { - Text("Optional: Required for device management features like battery reporting. Get this from your TRMNL user account settings.") + Text( + "Optional: Required for device management features like battery reporting. Get this from your TRMNL user account settings.", + ) }, trailingIcon = { IconButton(onClick = { userTokenVisible = !userTokenVisible }) { From af89f635ea407be795e2bb0f7fa7236d06f56d8f Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 31 Jan 2026 10:21:26 -0500 Subject: [PATCH 8/9] feat: Add visual feedback to user token validation button - Success state: Green button with check_circle icon - Error state: Red button with error icon - Button state resets when user edits token input - Uses Material Design color scheme for consistency --- .../android/ui/settings/AppSettingsScreen.kt | 36 +++++++++++++++++++ .../main/res/drawable/check_circle_24dp.xml | 9 +++++ app/src/main/res/drawable/error_24dp.xml | 9 +++++ .../res/drawable/outline_check_circle_24.xml | 5 +++ .../res/drawable/outline_check_small_24.xml | 5 +++ 5 files changed, 64 insertions(+) create mode 100644 app/src/main/res/drawable/check_circle_24dp.xml create mode 100644 app/src/main/res/drawable/error_24dp.xml create mode 100644 app/src/main/res/drawable/outline_check_circle_24.xml create mode 100644 app/src/main/res/drawable/outline_check_small_24.xml 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 5127ea0..2e5a1f2 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 @@ -836,12 +836,48 @@ fun AppSettingsContent( Spacer(modifier = Modifier.height(8.dp)) + // Determine button state based on validation result + val isValidationSuccess = state.validationResult is ValidationResult.UserTokenSuccess + val isValidationError = state.validationResult is ValidationResult.InvalidUserToken + Button( onClick = { state.eventSink(AppSettingsScreen.Event.ValidateUserToken) }, enabled = state.userApiToken.isNotBlank() && !state.isLoading, modifier = Modifier.fillMaxWidth(), + colors = + when { + isValidationSuccess -> + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + isValidationError -> + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ) + else -> ButtonDefaults.buttonColors() + }, ) { Text("Validate User Token") + when { + isValidationSuccess -> { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(R.drawable.check_circle_24dp), + contentDescription = "Validation successful", + modifier = Modifier.size(20.dp), + ) + } + isValidationError -> { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(R.drawable.error_24dp), + contentDescription = "Validation failed", + modifier = Modifier.size(20.dp), + ) + } + } } } } diff --git a/app/src/main/res/drawable/check_circle_24dp.xml b/app/src/main/res/drawable/check_circle_24dp.xml new file mode 100644 index 0000000..764a139 --- /dev/null +++ b/app/src/main/res/drawable/check_circle_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/error_24dp.xml b/app/src/main/res/drawable/error_24dp.xml new file mode 100644 index 0000000..7045625 --- /dev/null +++ b/app/src/main/res/drawable/error_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/outline_check_circle_24.xml b/app/src/main/res/drawable/outline_check_circle_24.xml new file mode 100644 index 0000000..7f747c6 --- /dev/null +++ b/app/src/main/res/drawable/outline_check_circle_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/outline_check_small_24.xml b/app/src/main/res/drawable/outline_check_small_24.xml new file mode 100644 index 0000000..bcd72e2 --- /dev/null +++ b/app/src/main/res/drawable/outline_check_small_24.xml @@ -0,0 +1,5 @@ + + + + + From 9ffe6346c358cf0661950e0d5724b626603b6164 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 31 Jan 2026 10:26:29 -0500 Subject: [PATCH 9/9] [UPDATE] Messaging --- .../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 2e5a1f2..9e04bc3 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 @@ -817,7 +817,7 @@ fun AppSettingsContent( ), supportingText = { Text( - "Optional: Required for device management features like battery reporting. Get this from your TRMNL user account settings.", + "Optional: This token is needed for device management features like battery reporting. Get this from your TRMNL user account settings.", ) }, trailingIcon = {