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:
Hossain Khan
2026-01-31 10:28:45 -05:00
committed by GitHub
13 changed files with 457 additions and 3 deletions
+2 -1
View File
@@ -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>
+9
View File
@@ -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,
)