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") 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..4cb303f 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,56 @@ 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, + ), + ) + } + + 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..c8d28fb --- /dev/null +++ b/app/src/main/java/ink/trmnl/android/network/model/TrmnlUserResponse.kt @@ -0,0 +1,57 @@ +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 + * and timezone settings. + * + * @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 + */ +@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, +) 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..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 @@ -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) } @@ -324,9 +349,10 @@ 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 + userApiToken = it.userApiToken ?: "" } } } @@ -337,6 +363,7 @@ class AppSettingsPresenter accessToken = accessToken, deviceMacId = deviceMacId, isByodMasterDevice = isByodMasterDevice, + userApiToken = userApiToken, usesFakeApiData = usesFakeApiData, isLoading = isLoading, validationResult = validationResult, @@ -353,6 +380,68 @@ 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) { + // Token is valid - user will save it via "Save and Continue" + ValidationResult.UserTokenSuccess( + userName = user.name, + userEmail = user.email, + ) + } else { + 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 -> { + ValidationResult.InvalidUserToken( + result.exceptionOrNull()?.message ?: "Invalid user API token", + ) + } + } + + isLoading = false + } + } + AppSettingsScreen.Event.ValidateToken -> { scope.launch { focusManager.clearFocus() @@ -455,6 +544,11 @@ 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). + // 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 }, ), ) trmnlWorkScheduler.updateRefreshInterval(result.refreshRateSecs) @@ -692,11 +786,109 @@ 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( + "Optional: This token is needed for device management features like battery reporting. 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)) + + // 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), + ) + } + } + } + } + } + + 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 +1027,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 +1507,7 @@ private fun PreviewAppSettingsContentInitial() { accessToken = "", deviceMacId = "aa:bb:cc:dd:ee:ff", isByodMasterDevice = true, + userApiToken = "", usesFakeApiData = true, isLoading = false, validationResult = null, @@ -1296,6 +1530,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 +1553,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 +1580,7 @@ private fun PreviewAppSettingsContentFailure() { accessToken = "invalid-token", deviceMacId = "aa:bb:cc:dd:ee:ff", isByodMasterDevice = true, + userApiToken = "", usesFakeApiData = false, isLoading = false, validationResult = @@ -1373,6 +1610,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 +1643,7 @@ private fun PreviewWorkScheduleStatusCardScheduled() { accessToken = "some-token", deviceMacId = "AA:BB:CC:DD:EE:FF", isByodMasterDevice = true, + userApiToken = "", usesFakeApiData = false, nextRefreshJobInfo = NextImageRefreshDisplayInfo( @@ -1431,6 +1670,7 @@ private fun PreviewWorkScheduleStatusCardNoWork() { accessToken = "some-token", deviceMacId = "aa:bb:cc:dd:ee:ff", isByodMasterDevice = true, + userApiToken = "", usesFakeApiData = false, nextRefreshJobInfo = null, eventSink = {}, @@ -1459,6 +1699,7 @@ private fun PreviewAppSettingsContentByod() { accessToken = "byod-access-token-here", deviceMacId = "", isByodMasterDevice = false, + userApiToken = "user_test123", usesFakeApiData = false, isLoading = false, validationResult = null, @@ -1482,6 +1723,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/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 @@ + + + + + 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, )