mirror of
https://github.com/usetrmnl/trmnl-android.git
synced 2026-04-29 13:35:26 -07:00
Merge pull request #243 from usetrmnl/feature/byod-user-api-token-validation
Add BYOD User API Token Validation with /api/me Endpoint
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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<TrmnlUser> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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<TrmnlUserResponse, Unit>
|
||||
|
||||
/**
|
||||
* Retrieve device data for a specific device using [DEVICE_API_PATH].
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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<ValidationResult?>(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,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="m424,664 l282,-282 -56,-56 -226,226 -114,-114 -56,56 170,170ZM480,880q-83,0 -156,-31.5T197,763q-54,-54 -85.5,-127T80,480q0,-83 31.5,-156T197,197q54,-54 127,-85.5T480,80q83,0 156,31.5T763,197q54,54 85.5,127T880,480q0,83 -31.5,156T763,763q-54,54 -127,85.5T480,880ZM480,800q134,0 227,-93t93,-227q0,-134 -93,-227t-227,-93q-134,0 -227,93t-93,227q0,134 93,227t227,93ZM480,480Z"
|
||||
android:fillColor="#999999"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="M480,680q17,0 28.5,-11.5T520,640q0,-17 -11.5,-28.5T480,600q-17,0 -28.5,11.5T440,640q0,17 11.5,28.5T480,680ZM440,520h80v-240h-80v240ZM480,880q-83,0 -156,-31.5T197,763q-54,-54 -85.5,-127T80,480q0,-83 31.5,-156T197,197q54,-54 127,-85.5T480,80q83,0 156,31.5T763,197q54,54 85.5,127T880,480q0,83 -31.5,156T763,763q-54,54 -127,85.5T480,880ZM480,800q134,0 227,-93t93,-227q0,-134 -93,-227t-227,-93q-134,0 -227,93t-93,227q0,134 93,227t227,93ZM480,480Z"
|
||||
android:fillColor="#999999"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M424,664L706,382L650,326L424,552L310,438L254,494L424,664ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
|
||||
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M400,656L240,496L296,440L400,544L664,280L720,336L400,656Z"/>
|
||||
|
||||
</vector>
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user