mirror of
https://github.com/usetrmnl/trmnl-android.git
synced 2026-04-29 13:35:26 -07:00
Merge pull request #253 from usetrmnl/feat/migrate-battery-to-percent-charged-header
Migrate battery reporting to Percent-Charged header
This commit is contained in:
@@ -99,8 +99,6 @@ 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_ID_KEY = stringPreferencesKey("device_id")
|
||||
private val DEVICE_MODEL_PREFERENCES_KEY = stringPreferencesKey("device_model_preferences")
|
||||
}
|
||||
|
||||
@@ -238,7 +236,7 @@ class TrmnlDeviceConfigDataStore
|
||||
try {
|
||||
val config: TrmnlDeviceConfig? = deviceConfigAdapter.fromJson(configJson)
|
||||
Timber.tag(TAG).d(
|
||||
"Loading device config (JSON): type=${config?.type}, userApiToken=${config?.userApiToken.obfuscated()}",
|
||||
"Loading device config (JSON): type=${config?.type}",
|
||||
)
|
||||
config
|
||||
} catch (e: Exception) {
|
||||
@@ -261,11 +259,9 @@ 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]
|
||||
val deviceId = preferences[DEVICE_ID_KEY]?.toIntOrNull()
|
||||
|
||||
Timber.tag(TAG).d(
|
||||
"Loading device config (legacy): type=$type, deviceApiToken=${token.obfuscated()}, deviceId=$deviceId",
|
||||
"Loading device config (legacy): type=$type, deviceApiToken=${token.obfuscated()}",
|
||||
)
|
||||
|
||||
if (token != null) {
|
||||
@@ -276,8 +272,6 @@ class TrmnlDeviceConfigDataStore
|
||||
deviceMacId = deviceMacId,
|
||||
refreshRateSecs = refreshRate,
|
||||
isMasterDevice = isMasterDevice,
|
||||
userApiToken = userApiToken,
|
||||
deviceId = deviceId,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -311,7 +305,7 @@ class TrmnlDeviceConfigDataStore
|
||||
suspend fun saveDeviceConfig(config: TrmnlDeviceConfig) {
|
||||
try {
|
||||
Timber.tag(TAG).d(
|
||||
"Saving device config: type=${config.type}, userApiToken=${config.userApiToken.obfuscated()}",
|
||||
"Saving device config: type=${config.type}",
|
||||
)
|
||||
val configJson = deviceConfigAdapter.toJson(config)
|
||||
context.deviceConfigStore.edit { preferences ->
|
||||
@@ -333,16 +327,6 @@ 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)
|
||||
|
||||
// Save deviceId if available
|
||||
config.deviceId?.let { deviceId ->
|
||||
preferences[DEVICE_ID_KEY] = deviceId.toString()
|
||||
} ?: preferences.remove(DEVICE_ID_KEY)
|
||||
}
|
||||
Timber.tag(TAG).d("Device config saved successfully")
|
||||
} catch (e: Exception) {
|
||||
@@ -359,56 +343,6 @@ class TrmnlDeviceConfigDataStore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the user-level API token (Account API key)
|
||||
*/
|
||||
suspend fun saveUserApiToken(token: String) {
|
||||
Timber.tag(TAG).d("Saving user API token: ${token.obfuscated()}")
|
||||
context.deviceConfigStore.edit { preferences ->
|
||||
preferences[USER_API_TOKEN_KEY] = token
|
||||
}
|
||||
Timber.tag(TAG).d("User API token saved successfully")
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the user-level API token
|
||||
*/
|
||||
suspend fun getUserApiToken(): String? {
|
||||
val token =
|
||||
context.deviceConfigStore.data
|
||||
.map { preferences -> preferences[USER_API_TOKEN_KEY] }
|
||||
.first()
|
||||
Timber.tag(TAG).d("Retrieved user API token: ${token.obfuscated()}")
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the device ID (TRMNL device ID from /api/devices/me).
|
||||
*
|
||||
* **Note:** This is only applicable for BYOD device types.
|
||||
*/
|
||||
suspend fun saveDeviceId(deviceId: Int) {
|
||||
Timber.tag(TAG).d("Saving device ID: $deviceId")
|
||||
context.deviceConfigStore.edit { preferences ->
|
||||
preferences[DEVICE_ID_KEY] = deviceId.toString()
|
||||
}
|
||||
Timber.tag(TAG).d("Device ID saved successfully")
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the device ID.
|
||||
*
|
||||
* **Note:** This is only applicable for BYOD device types.
|
||||
*/
|
||||
suspend fun getDeviceId(): Int? {
|
||||
val deviceId =
|
||||
context.deviceConfigStore.data
|
||||
.map { preferences -> preferences[DEVICE_ID_KEY]?.toIntOrNull() }
|
||||
.first()
|
||||
Timber.tag(TAG).d("Retrieved device ID: $deviceId")
|
||||
return deviceId
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the access token
|
||||
*/
|
||||
|
||||
@@ -15,11 +15,8 @@ import ink.trmnl.android.network.TrmnlApiService.Companion.CURRENT_PLAYLIST_SCRE
|
||||
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.DEVICE_API_PATH
|
||||
import ink.trmnl.android.network.TrmnlUserApiService.Companion.USER_INFO_API_PATH
|
||||
import ink.trmnl.android.network.model.TrmnlDevice
|
||||
import ink.trmnl.android.network.model.TrmnlDeviceModel
|
||||
import ink.trmnl.android.network.model.TrmnlDeviceUpdateRequest
|
||||
import ink.trmnl.android.network.model.TrmnlDisplayResponse
|
||||
import ink.trmnl.android.network.model.TrmnlUser
|
||||
import ink.trmnl.android.network.util.constructApiUrl
|
||||
@@ -82,6 +79,17 @@ class TrmnlDisplayRepository
|
||||
} else {
|
||||
null
|
||||
},
|
||||
percentCharged =
|
||||
if (trmnlDeviceConfig.type == TrmnlDeviceType.BYOD) {
|
||||
// Send battery percentage if available for BYOD devices only
|
||||
// See following for context:
|
||||
// https://github.com/usetrmnl/trmnl-android/issues/252
|
||||
// https://github.com/usetrmnl/trmnl-android/issues/239
|
||||
// https://discord.com/channels/1281055965508141100/1466030731770855434/1469103763846463620
|
||||
androidDeviceInfoProvider.getBatteryLevel()
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
when (result) {
|
||||
@@ -384,6 +392,12 @@ class TrmnlDisplayRepository
|
||||
/**
|
||||
* Fetches the device ID from the TRMNL API using the device API token.
|
||||
*
|
||||
* **DEPRECATED:** Device ID fetching is no longer needed. Battery reporting now uses
|
||||
* the Percent-Charged header in /api/display call, which only requires device-level
|
||||
* authentication (Access-Token), not user-level authentication or device ID.
|
||||
*
|
||||
* This method will be removed in a future version.
|
||||
*
|
||||
* This method calls the /api/devices/me endpoint with device-level authentication
|
||||
* to retrieve device information including the device ID, which is needed for
|
||||
* user-level API calls to /api/devices/{id}.
|
||||
@@ -394,7 +408,17 @@ class TrmnlDisplayRepository
|
||||
* @param config Device configuration containing the device API token
|
||||
* @return A Result containing the device ID on success or an exception on failure
|
||||
*/
|
||||
@Deprecated("Device ID no longer needed for battery reporting. Use Percent-Charged header instead.")
|
||||
suspend fun getDeviceIdFromApi(config: TrmnlDeviceConfig): Result<Int> {
|
||||
Timber.w("getDeviceIdFromApi is deprecated. Device ID is no longer needed for battery reporting.")
|
||||
return Result.failure(
|
||||
UnsupportedOperationException(
|
||||
"Device ID fetching is deprecated. Battery reporting now uses Percent-Charged header " +
|
||||
"in /api/display, which doesn't require device ID or user API token.",
|
||||
),
|
||||
)
|
||||
|
||||
/* DISABLED - Device ID no longer needed for battery reporting
|
||||
Timber.i("Fetching device ID from API for device type: ${config.type}")
|
||||
|
||||
// Always use mocked response since the endpoint doesn't exist yet
|
||||
@@ -410,7 +434,7 @@ class TrmnlDisplayRepository
|
||||
sleepModeEnabled = false,
|
||||
sleepStartTime = 1320,
|
||||
sleepEndTime = 480,
|
||||
percentCharged = 100.0,
|
||||
percentCharged = 100,
|
||||
wifiStrength = 100.0,
|
||||
)
|
||||
|
||||
@@ -438,20 +462,29 @@ class TrmnlDisplayRepository
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports the device's battery status to the TRMNL API for BYOD devices.
|
||||
*
|
||||
* This is a convenience method that checks if the device is a BYOD device with the necessary
|
||||
* configuration (deviceId and userApiToken), retrieves the current battery level,
|
||||
* and reports it to the server.
|
||||
* **DEPRECATED:** Battery reporting now uses the Percent-Charged header in /api/display call.
|
||||
* This method is disabled and will be removed in a future version.
|
||||
*
|
||||
* This method should be called after successful image refresh operations.
|
||||
* Battery percentage is now automatically sent via the Percent-Charged header parameter
|
||||
* when calling getNextDisplayData() for BYOD devices, eliminating the need for a separate
|
||||
* API call and user-level authentication.
|
||||
*
|
||||
* @param config Device configuration containing device type, device ID, and user API token
|
||||
* @see getNextDisplayData
|
||||
*/
|
||||
@Deprecated("Battery reporting now uses Percent-Charged header. This method is no longer needed.")
|
||||
suspend fun reportDeviceBatteryStatus(config: TrmnlDeviceConfig) {
|
||||
// DEPRECATED: Battery reporting now happens via Percent-Charged header in /api/display
|
||||
Timber.d("Battery reporting via separate API call is deprecated. Battery is now sent via Percent-Charged header.")
|
||||
return
|
||||
|
||||
/* DISABLED - Battery now reported via Percent-Charged header
|
||||
// Only report battery for BYOD devices with required configuration
|
||||
if (config.type != TrmnlDeviceType.BYOD) {
|
||||
Timber.d("Battery reporting skipped: not a BYOD device (type: ${config.type})")
|
||||
@@ -484,25 +517,35 @@ class TrmnlDisplayRepository
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Unexpected error during battery reporting")
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports the device's battery status to the TRMNL API.
|
||||
*
|
||||
* This method sends a PATCH request to /api/devices/{id} using user-level authentication
|
||||
* to update the device's battery percentage on the server.
|
||||
* **DEPRECATED:** This method is no longer used. Battery reporting now uses the
|
||||
* Percent-Charged header in /api/display call instead of PATCH /api/devices/{id}.
|
||||
*
|
||||
* This suspend function performs network I/O and should be called from a background
|
||||
* coroutine so it does not block or delay display updates.
|
||||
* This method will be removed in a future version.
|
||||
*
|
||||
* @param config Device configuration containing the device ID and user API token
|
||||
* @param batteryPercent The current battery percentage (0-100)
|
||||
* @return A Result containing Unit on success or an exception on failure
|
||||
*/
|
||||
@Deprecated("Battery reporting now uses Percent-Charged header. This method is no longer needed.")
|
||||
private suspend fun reportBatteryStatus(
|
||||
config: TrmnlDeviceConfig,
|
||||
batteryPercent: Int,
|
||||
): Result<Unit> {
|
||||
// DEPRECATED: This method is no longer used
|
||||
Timber.d("reportBatteryStatus is deprecated and disabled")
|
||||
return Result.failure(
|
||||
UnsupportedOperationException(
|
||||
"Battery reporting via PATCH /api/devices/{id} is deprecated. Use Percent-Charged header instead.",
|
||||
),
|
||||
)
|
||||
|
||||
/* DISABLED - Battery now reported via Percent-Charged header
|
||||
val deviceId = config.deviceId
|
||||
val userApiToken = config.userApiToken
|
||||
|
||||
@@ -524,7 +567,7 @@ class TrmnlDisplayRepository
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
val updateRequest = TrmnlDeviceUpdateRequest(percentCharged = batteryPercent.toDouble())
|
||||
val updateRequest = TrmnlDeviceUpdateRequest(percentCharged = batteryPercent)
|
||||
val apiUrl = constructApiUrl(config.apiBaseUrl, DEVICE_API_PATH.replace("{id}", deviceId.toString()))
|
||||
|
||||
val result =
|
||||
@@ -545,5 +588,6 @@ class TrmnlDisplayRepository
|
||||
Result.success(Unit)
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,21 +33,4 @@ 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,
|
||||
/**
|
||||
* TRMNL device ID extracted from /api/devices/me endpoint.
|
||||
* Used for making user-level API calls to /api/devices/{id}.
|
||||
*
|
||||
* This ID is fetched during BYOD device validation and is required for
|
||||
* reporting battery status and other device-specific updates.
|
||||
*
|
||||
* **Note:** This field is only applicable for BYOD device types.
|
||||
*/
|
||||
val deviceId: Int? = null,
|
||||
)
|
||||
|
||||
@@ -94,6 +94,7 @@ interface TrmnlApiService {
|
||||
* @param deviceMacId The device's MAC address (optional)
|
||||
* @param useBase64 Whether to request Base64-encoded image data (optional)
|
||||
* @param rssi WiFi signal strength in dBm (optional, -100 to 0). See https://github.com/usetrmnl/trmnl-firmware/blob/main/src/api-client/display.cpp for additional references.
|
||||
* @param percentCharged Battery percentage (optional, 0.0 to 100.0). Only sent for BYOD devices.
|
||||
*
|
||||
* @see getCurrentDisplayData
|
||||
*/
|
||||
@@ -104,6 +105,7 @@ interface TrmnlApiService {
|
||||
@Header("ID") deviceMacId: String? = null,
|
||||
@Header("BASE64") useBase64: Boolean? = null,
|
||||
@Header("RSSI") rssi: Int? = null,
|
||||
@Header("Percent-Charged") percentCharged: Int? = null,
|
||||
): ApiResult<TrmnlDisplayResponse, Unit>
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,12 @@ import retrofit2.http.Url
|
||||
/**
|
||||
* API service interface for TRMNL user-level (account) API endpoints.
|
||||
*
|
||||
* **DEPRECATED:** This service is no longer needed for battery reporting.
|
||||
* Battery percentage is now sent via the Percent-Charged header in /api/display call,
|
||||
* which only requires device-level authentication (Access-Token).
|
||||
*
|
||||
* This interface is kept for backward compatibility and may be removed in a future version.
|
||||
*
|
||||
* This interface defines endpoints that require user-level authentication via Bearer token
|
||||
* (Account API key), as opposed to device-level authentication.
|
||||
*
|
||||
@@ -21,6 +27,10 @@ import retrofit2.http.Url
|
||||
* - https://docs.trmnl.com/go
|
||||
* - https://trmnl.com/api-docs/index.html (OpenAPI documentation)
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "No longer needed for battery reporting. Battery is now sent via Percent-Charged header.",
|
||||
level = DeprecationLevel.WARNING,
|
||||
)
|
||||
interface TrmnlUserApiService {
|
||||
companion object {
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,11 @@ import com.squareup.moshi.JsonClass
|
||||
/**
|
||||
* Data class representing a request to update a TRMNL device.
|
||||
*
|
||||
* **DEPRECATED:** This model is no longer needed for battery reporting.
|
||||
* Battery percentage is now sent via the Percent-Charged header in /api/display call.
|
||||
*
|
||||
* This class is kept for backward compatibility and may be removed in a future version.
|
||||
*
|
||||
* All fields are optional - only include the fields you want to update.
|
||||
*
|
||||
* Sample JSON request:
|
||||
@@ -24,10 +29,14 @@ import com.squareup.moshi.JsonClass
|
||||
* @property percentCharged The battery percentage charged.
|
||||
* @see ink.trmnl.android.network.TrmnlApiService.updateDevice
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "No longer needed for battery reporting. Battery is now sent via Percent-Charged header.",
|
||||
level = DeprecationLevel.WARNING,
|
||||
)
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class TrmnlDeviceUpdateRequest(
|
||||
@Json(name = "sleep_mode_enabled") val sleepModeEnabled: Boolean? = null,
|
||||
@Json(name = "sleep_start_time") val sleepStartTime: Int? = null,
|
||||
@Json(name = "sleep_end_time") val sleepEndTime: Int? = null,
|
||||
@Json(name = "percent_charged") val percentCharged: Double? = null,
|
||||
@Json(name = "percent_charged") val percentCharged: Int? = null,
|
||||
)
|
||||
|
||||
@@ -143,7 +143,6 @@ 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,
|
||||
@@ -197,18 +196,6 @@ 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.
|
||||
*/
|
||||
@@ -289,7 +276,6 @@ 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) }
|
||||
@@ -352,7 +338,6 @@ class AppSettingsPresenter
|
||||
// Load BYOD-specific settings
|
||||
if (it.type == TrmnlDeviceType.BYOD) {
|
||||
isByodMasterDevice = it.isMasterDevice ?: true
|
||||
userApiToken = it.userApiToken ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -363,7 +348,6 @@ class AppSettingsPresenter
|
||||
accessToken = accessToken,
|
||||
deviceMacId = deviceMacId,
|
||||
isByodMasterDevice = isByodMasterDevice,
|
||||
userApiToken = userApiToken,
|
||||
usesFakeApiData = usesFakeApiData,
|
||||
isLoading = isLoading,
|
||||
validationResult = validationResult,
|
||||
@@ -380,68 +364,6 @@ 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()
|
||||
@@ -513,23 +435,6 @@ class AppSettingsPresenter
|
||||
response.imageUrl,
|
||||
response.refreshIntervalSeconds ?: DEFAULT_REFRESH_INTERVAL_SEC,
|
||||
)
|
||||
|
||||
// For BYOD devices, also fetch and save the device ID
|
||||
if (deviceType == TrmnlDeviceType.BYOD) {
|
||||
val deviceIdResult = displayRepository.getDeviceIdFromApi(deviceConfig)
|
||||
if (deviceIdResult.isSuccess) {
|
||||
val deviceId = deviceIdResult.getOrNull()
|
||||
if (deviceId != null) {
|
||||
deviceConfigStore.saveDeviceId(deviceId)
|
||||
Timber.d("Device ID saved successfully for BYOD device: $deviceId")
|
||||
}
|
||||
} else {
|
||||
Timber.w(
|
||||
"Failed to fetch device ID for BYOD device. Error: %s",
|
||||
deviceIdResult.exceptionOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No error but also no image URL
|
||||
val errorMessage = response.error ?: ""
|
||||
@@ -552,14 +457,6 @@ class AppSettingsPresenter
|
||||
TrmnlDeviceType.TRMNL -> false
|
||||
}
|
||||
|
||||
// For BYOD devices, retrieve the device ID that was fetched during validation
|
||||
val deviceId =
|
||||
if (deviceType == TrmnlDeviceType.BYOD) {
|
||||
deviceConfigStore.getDeviceId()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
deviceConfigStore.saveDeviceConfig(
|
||||
TrmnlDeviceConfig(
|
||||
type = deviceType,
|
||||
@@ -569,13 +466,6 @@ 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 },
|
||||
// Include device ID for BYOD devices (fetched during validation)
|
||||
deviceId = deviceId,
|
||||
),
|
||||
)
|
||||
trmnlWorkScheduler.updateRefreshInterval(result.refreshRateSecs)
|
||||
@@ -813,104 +703,6 @@ 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,
|
||||
@@ -1534,7 +1326,6 @@ private fun PreviewAppSettingsContentInitial() {
|
||||
accessToken = "",
|
||||
deviceMacId = "aa:bb:cc:dd:ee:ff",
|
||||
isByodMasterDevice = true,
|
||||
userApiToken = "",
|
||||
usesFakeApiData = true,
|
||||
isLoading = false,
|
||||
validationResult = null,
|
||||
@@ -1557,7 +1348,6 @@ private fun PreviewAppSettingsContentLoading() {
|
||||
accessToken = "some-token",
|
||||
deviceMacId = "aa:bb:cc:dd:ee:ff",
|
||||
isByodMasterDevice = true,
|
||||
userApiToken = "",
|
||||
usesFakeApiData = false,
|
||||
isLoading = true,
|
||||
validationResult = null,
|
||||
@@ -1580,7 +1370,6 @@ private fun PreviewAppSettingsContentSuccess() {
|
||||
accessToken = "valid-token-123",
|
||||
deviceMacId = "aa:bb:cc:dd:ee:ff",
|
||||
isByodMasterDevice = true,
|
||||
userApiToken = "",
|
||||
usesFakeApiData = false,
|
||||
isLoading = false,
|
||||
validationResult =
|
||||
@@ -1607,7 +1396,6 @@ private fun PreviewAppSettingsContentFailure() {
|
||||
accessToken = "invalid-token",
|
||||
deviceMacId = "aa:bb:cc:dd:ee:ff",
|
||||
isByodMasterDevice = true,
|
||||
userApiToken = "",
|
||||
usesFakeApiData = false,
|
||||
isLoading = false,
|
||||
validationResult =
|
||||
@@ -1637,7 +1425,6 @@ 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
|
||||
@@ -1670,7 +1457,6 @@ private fun PreviewWorkScheduleStatusCardScheduled() {
|
||||
accessToken = "some-token",
|
||||
deviceMacId = "AA:BB:CC:DD:EE:FF",
|
||||
isByodMasterDevice = true,
|
||||
userApiToken = "",
|
||||
usesFakeApiData = false,
|
||||
nextRefreshJobInfo =
|
||||
NextImageRefreshDisplayInfo(
|
||||
@@ -1697,7 +1483,6 @@ private fun PreviewWorkScheduleStatusCardNoWork() {
|
||||
accessToken = "some-token",
|
||||
deviceMacId = "aa:bb:cc:dd:ee:ff",
|
||||
isByodMasterDevice = true,
|
||||
userApiToken = "",
|
||||
usesFakeApiData = false,
|
||||
nextRefreshJobInfo = null,
|
||||
eventSink = {},
|
||||
@@ -1726,7 +1511,6 @@ private fun PreviewAppSettingsContentByod() {
|
||||
accessToken = "byod-access-token-here",
|
||||
deviceMacId = "",
|
||||
isByodMasterDevice = false,
|
||||
userApiToken = "user_test123",
|
||||
usesFakeApiData = false,
|
||||
isLoading = false,
|
||||
validationResult = null,
|
||||
@@ -1750,7 +1534,6 @@ private fun PreviewAppSettingsContentByos() {
|
||||
accessToken = "byos-access-token-here",
|
||||
deviceMacId = "AA:BB:CC:DD:EE:FF",
|
||||
isByodMasterDevice = true,
|
||||
userApiToken = "",
|
||||
usesFakeApiData = false,
|
||||
isLoading = false,
|
||||
validationResult = null,
|
||||
|
||||
@@ -151,7 +151,7 @@ internal fun ByosDeviceTypeInfoText() {
|
||||
val linkStyle = SpanStyle(color = MaterialTheme.colorScheme.primary, textDecoration = TextDecoration.Underline)
|
||||
val annotatedString =
|
||||
buildAnnotatedString {
|
||||
append("Bring your own server (BYOS) config. Only secure HTTPS URL supported. ")
|
||||
append("Bring your own server (BYOS) config.")
|
||||
|
||||
withLink(
|
||||
LinkAnnotation.Url(
|
||||
|
||||
@@ -170,8 +170,9 @@ class TrmnlImageRefreshWorker(
|
||||
httpResponseMetadata = trmnlDisplayInfo.httpResponseMetadata,
|
||||
)
|
||||
|
||||
// Report battery status for BYOD devices after successful image refresh
|
||||
displayRepository.reportDeviceBatteryStatus(deviceConfig)
|
||||
// DEPRECATED: Battery reporting now happens via Percent-Charged header in /api/display call
|
||||
// Battery percentage is automatically sent when fetching the next image for BYOD devices
|
||||
// displayRepository.reportDeviceBatteryStatus(deviceConfig)
|
||||
|
||||
// NOTE: Image metadata caching is handled automatically by `TrmnlDisplayRepository`
|
||||
// when the API call succeeds, so we don't need to save it again here.
|
||||
|
||||
@@ -626,100 +626,4 @@ class TrmnlDeviceConfigDataStoreTest {
|
||||
val preferences = deviceConfigDataStore.deviceModelPreferencesFlow.first()
|
||||
assertThat(preferences).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saveDeviceId and getDeviceId work correctly`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val expectedDeviceId = 12345
|
||||
|
||||
// Act
|
||||
deviceConfigDataStore.saveDeviceId(expectedDeviceId)
|
||||
val retrievedDeviceId = deviceConfigDataStore.getDeviceId()
|
||||
|
||||
// Assert
|
||||
assertThat(retrievedDeviceId).isEqualTo(expectedDeviceId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDeviceId returns null when not saved`() =
|
||||
runTest {
|
||||
// Act
|
||||
val deviceId = deviceConfigDataStore.getDeviceId()
|
||||
|
||||
// Assert
|
||||
assertThat(deviceId).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saveDeviceConfig persists deviceId to both JSON and legacy storage`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val configWithDeviceId =
|
||||
TrmnlDeviceConfig(
|
||||
type = TrmnlDeviceType.BYOD,
|
||||
apiAccessToken = "test-token",
|
||||
apiBaseUrl = "https://trmnl.com",
|
||||
userApiToken = "user_test_token",
|
||||
deviceId = 999,
|
||||
)
|
||||
|
||||
// Act
|
||||
deviceConfigDataStore.saveDeviceConfig(configWithDeviceId)
|
||||
|
||||
// Assert - Verify device ID is persisted via getDeviceId (legacy storage)
|
||||
val deviceId = deviceConfigDataStore.getDeviceId()
|
||||
assertThat(deviceId).isEqualTo(999)
|
||||
|
||||
// Assert - Verify device ID is persisted via deviceConfigFlow (JSON storage)
|
||||
val loadedConfig = deviceConfigDataStore.deviceConfigFlow.first()
|
||||
assertThat(loadedConfig).isNotNull()
|
||||
assertThat(loadedConfig?.deviceId).isEqualTo(999)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deviceConfigFlow loads deviceId from legacy storage when JSON not present`() =
|
||||
runTest {
|
||||
// Arrange - Save individual fields (legacy approach) without JSON
|
||||
deviceConfigDataStore.saveDeviceType(TrmnlDeviceType.BYOD)
|
||||
deviceConfigDataStore.saveAccessToken("test-token")
|
||||
deviceConfigDataStore.saveServerUrl("https://trmnl.com")
|
||||
deviceConfigDataStore.saveDeviceId(777)
|
||||
|
||||
// Act
|
||||
val loadedConfig = deviceConfigDataStore.deviceConfigFlow.first()
|
||||
|
||||
// Assert
|
||||
assertThat(loadedConfig).isNotNull()
|
||||
assertThat(loadedConfig?.deviceId).isEqualTo(777)
|
||||
assertThat(loadedConfig?.type).isEqualTo(TrmnlDeviceType.BYOD)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saveDeviceConfig removes deviceId from storage when null`() =
|
||||
runTest {
|
||||
// Arrange - First save config with deviceId
|
||||
val configWithDeviceId =
|
||||
TrmnlDeviceConfig(
|
||||
type = TrmnlDeviceType.BYOD,
|
||||
apiAccessToken = "test-token",
|
||||
apiBaseUrl = "https://trmnl.com",
|
||||
deviceId = 123,
|
||||
)
|
||||
deviceConfigDataStore.saveDeviceConfig(configWithDeviceId)
|
||||
|
||||
// Verify it was saved
|
||||
assertThat(deviceConfigDataStore.getDeviceId()).isEqualTo(123)
|
||||
|
||||
// Act - Now save config without deviceId
|
||||
val configWithoutDeviceId = configWithDeviceId.copy(deviceId = null)
|
||||
deviceConfigDataStore.saveDeviceConfig(configWithoutDeviceId)
|
||||
|
||||
// Assert - DeviceId should be removed
|
||||
val deviceId = deviceConfigDataStore.getDeviceId()
|
||||
assertThat(deviceId).isNull()
|
||||
|
||||
val loadedConfig = deviceConfigDataStore.deviceConfigFlow.first()
|
||||
assertThat(loadedConfig?.deviceId).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
@@ -410,6 +411,8 @@ class TrmnlDisplayRepositoryTest {
|
||||
accessToken = byodDeviceConfig.apiAccessToken,
|
||||
useBase64 = any(),
|
||||
rssi = any(),
|
||||
percentCharged = any(),
|
||||
deviceMacId = any(),
|
||||
)
|
||||
} returns ApiResult.success(successResponse)
|
||||
|
||||
@@ -426,8 +429,10 @@ class TrmnlDisplayRepositoryTest {
|
||||
apiService.getNextDisplayData(
|
||||
fullApiUrl = expectedNextApiUrl,
|
||||
accessToken = byodDeviceConfig.apiAccessToken,
|
||||
deviceMacId = any(),
|
||||
useBase64 = any(),
|
||||
rssi = any(),
|
||||
percentCharged = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -579,6 +584,9 @@ class TrmnlDisplayRepositoryTest {
|
||||
assertThat(result.httpResponseMetadata?.contentLength).isEqualTo(123L)
|
||||
}
|
||||
|
||||
// DEPRECATED: Device ID fetching is no longer needed for battery reporting
|
||||
// Battery is now sent via Percent-Charged header
|
||||
@Ignore("Device ID fetching deprecated - battery now uses Percent-Charged header")
|
||||
@Test
|
||||
fun `getDeviceIdFromApi should return mocked device ID`() =
|
||||
runTest {
|
||||
@@ -593,15 +601,14 @@ class TrmnlDisplayRepositoryTest {
|
||||
coVerify(exactly = 0) { apiService.getDeviceMe(any(), any()) }
|
||||
}
|
||||
|
||||
// DEPRECATED: Battery reporting via separate API call is no longer used
|
||||
// Battery is now sent via Percent-Charged header in /api/display call
|
||||
@Ignore("Battery reporting via PATCH /api/devices/{id} deprecated - now uses Percent-Charged header")
|
||||
@Test
|
||||
fun `reportDeviceBatteryStatus should report battery for valid BYOD config`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val byodConfigWithDeviceId =
|
||||
byodDeviceConfig.copy(
|
||||
deviceId = 123,
|
||||
userApiToken = "user_test_token",
|
||||
)
|
||||
val byodConfigWithDeviceId = byodDeviceConfig
|
||||
|
||||
every { androidDeviceInfoProvider.getBatteryLevel() } returns 75
|
||||
|
||||
@@ -623,20 +630,18 @@ class TrmnlDisplayRepositoryTest {
|
||||
userApiService.updateDevice(
|
||||
fullApiUrl = expectedApiUrl,
|
||||
accessToken = "Bearer user_test_token",
|
||||
updateRequest = match { it.percentCharged == 75.0 },
|
||||
updateRequest = match { it.percentCharged == 75 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DEPRECATED: Battery reporting via separate API call is no longer used
|
||||
@Ignore("Battery reporting via PATCH /api/devices/{id} deprecated - now uses Percent-Charged header")
|
||||
@Test
|
||||
fun `reportDeviceBatteryStatus should skip for non-BYOD device`() =
|
||||
runTest {
|
||||
// Arrange - TRMNL device (not BYOD)
|
||||
val trmnlConfig =
|
||||
testDeviceConfig.copy(
|
||||
deviceId = 123,
|
||||
userApiToken = "user_test_token",
|
||||
)
|
||||
val trmnlConfig = testDeviceConfig
|
||||
|
||||
// Act
|
||||
repository.reportDeviceBatteryStatus(trmnlConfig)
|
||||
@@ -646,15 +651,13 @@ class TrmnlDisplayRepositoryTest {
|
||||
coVerify(exactly = 0) { androidDeviceInfoProvider.getBatteryLevel() }
|
||||
}
|
||||
|
||||
// DEPRECATED: Battery reporting via separate API call is no longer used
|
||||
@Ignore("Battery reporting via PATCH /api/devices/{id} deprecated - now uses Percent-Charged header")
|
||||
@Test
|
||||
fun `reportDeviceBatteryStatus should skip when deviceId is null`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val configWithoutDeviceId =
|
||||
byodDeviceConfig.copy(
|
||||
deviceId = null,
|
||||
userApiToken = "user_test_token",
|
||||
)
|
||||
val configWithoutDeviceId = byodDeviceConfig
|
||||
|
||||
// Act
|
||||
repository.reportDeviceBatteryStatus(configWithoutDeviceId)
|
||||
@@ -664,15 +667,13 @@ class TrmnlDisplayRepositoryTest {
|
||||
coVerify(exactly = 0) { androidDeviceInfoProvider.getBatteryLevel() }
|
||||
}
|
||||
|
||||
// DEPRECATED: Battery reporting via separate API call is no longer used
|
||||
@Ignore("Battery reporting via PATCH /api/devices/{id} deprecated - now uses Percent-Charged header")
|
||||
@Test
|
||||
fun `reportDeviceBatteryStatus should skip when userApiToken is null`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val configWithoutUserToken =
|
||||
byodDeviceConfig.copy(
|
||||
deviceId = 123,
|
||||
userApiToken = null,
|
||||
)
|
||||
val configWithoutUserToken = byodDeviceConfig
|
||||
|
||||
// Act
|
||||
repository.reportDeviceBatteryStatus(configWithoutUserToken)
|
||||
@@ -682,15 +683,13 @@ class TrmnlDisplayRepositoryTest {
|
||||
coVerify(exactly = 0) { androidDeviceInfoProvider.getBatteryLevel() }
|
||||
}
|
||||
|
||||
// DEPRECATED: Battery reporting via separate API call is no longer used
|
||||
@Ignore("Battery reporting via PATCH /api/devices/{id} deprecated - now uses Percent-Charged header")
|
||||
@Test
|
||||
fun `reportDeviceBatteryStatus should skip when battery level unavailable`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val byodConfigWithDeviceId =
|
||||
byodDeviceConfig.copy(
|
||||
deviceId = 123,
|
||||
userApiToken = "user_test_token",
|
||||
)
|
||||
val byodConfigWithDeviceId = byodDeviceConfig
|
||||
|
||||
every { androidDeviceInfoProvider.getBatteryLevel() } returns null
|
||||
|
||||
@@ -710,13 +709,13 @@ class TrmnlDisplayRepositoryTest {
|
||||
// Arrange
|
||||
val byodConfig =
|
||||
byodDeviceConfig.copy(
|
||||
deviceId = null,
|
||||
userApiToken = "test_token",
|
||||
apiAccessToken = "test_api_key",
|
||||
)
|
||||
val expectedRssi = -65
|
||||
val expectedBattery = 80
|
||||
|
||||
every { androidDeviceInfoProvider.getWifiSignalStrength() } returns expectedRssi
|
||||
every { androidDeviceInfoProvider.getBatteryLevel() } returns expectedBattery
|
||||
|
||||
coEvery {
|
||||
apiService.getNextDisplayData(
|
||||
@@ -725,6 +724,7 @@ class TrmnlDisplayRepositoryTest {
|
||||
deviceMacId = any(),
|
||||
useBase64 = any(),
|
||||
rssi = expectedRssi,
|
||||
percentCharged = expectedBattery,
|
||||
)
|
||||
} returns ApiResult.success(mockk(relaxed = true))
|
||||
|
||||
@@ -740,6 +740,7 @@ class TrmnlDisplayRepositoryTest {
|
||||
deviceMacId = any(),
|
||||
useBase64 = any(),
|
||||
rssi = expectedRssi,
|
||||
percentCharged = expectedBattery,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -750,14 +751,14 @@ class TrmnlDisplayRepositoryTest {
|
||||
// Arrange
|
||||
val byodConfig =
|
||||
byodDeviceConfig.copy(
|
||||
deviceId = null,
|
||||
userApiToken = "test_token",
|
||||
apiAccessToken = "test_api_key",
|
||||
)
|
||||
val expectedBattery = 80
|
||||
|
||||
every { androidDeviceInfoProvider.getWifiSignalStrength() } returns null
|
||||
every { androidDeviceInfoProvider.getBatteryLevel() } returns expectedBattery
|
||||
|
||||
coEvery { apiService.getNextDisplayData(any(), any(), any(), any()) } returns
|
||||
coEvery { apiService.getNextDisplayData(any(), any(), any(), any(), any(), any()) } returns
|
||||
ApiResult.success(mockk(relaxed = true))
|
||||
|
||||
// Act
|
||||
@@ -772,6 +773,7 @@ class TrmnlDisplayRepositoryTest {
|
||||
deviceMacId = any(),
|
||||
useBase64 = any(),
|
||||
rssi = null,
|
||||
percentCharged = expectedBattery,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -785,7 +787,7 @@ class TrmnlDisplayRepositoryTest {
|
||||
apiAccessToken = "trmnl_api_key",
|
||||
)
|
||||
|
||||
coEvery { apiService.getNextDisplayData(any(), any(), any(), any()) } returns
|
||||
coEvery { apiService.getNextDisplayData(any(), any(), any(), any(), any(), any()) } returns
|
||||
ApiResult.success(mockk(relaxed = true))
|
||||
|
||||
// Act
|
||||
@@ -800,6 +802,7 @@ class TrmnlDisplayRepositoryTest {
|
||||
deviceMacId = any(),
|
||||
useBase64 = any(),
|
||||
rssi = null,
|
||||
percentCharged = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -833,6 +836,164 @@ class TrmnlDisplayRepositoryTest {
|
||||
}
|
||||
}
|
||||
|
||||
// Battery Percentage (Percent-Charged Header) Tests
|
||||
|
||||
@Test
|
||||
fun `getNextDisplayData should send battery percentage for BYOD device when available`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val byodConfig =
|
||||
byodDeviceConfig.copy(
|
||||
apiAccessToken = "test_api_key",
|
||||
)
|
||||
val expectedBatteryLevel = 75
|
||||
|
||||
every { androidDeviceInfoProvider.getBatteryLevel() } returns expectedBatteryLevel
|
||||
every { androidDeviceInfoProvider.getWifiSignalStrength() } returns -65
|
||||
|
||||
coEvery {
|
||||
apiService.getNextDisplayData(
|
||||
fullApiUrl = any(),
|
||||
accessToken = any(),
|
||||
deviceMacId = any(),
|
||||
useBase64 = any(),
|
||||
rssi = any(),
|
||||
percentCharged = 75,
|
||||
)
|
||||
} returns ApiResult.success(mockk(relaxed = true))
|
||||
|
||||
// Act
|
||||
repository.getNextDisplayData(byodConfig)
|
||||
|
||||
// Assert - Verify battery level was fetched and sent as header
|
||||
coVerify(exactly = 1) { androidDeviceInfoProvider.getBatteryLevel() }
|
||||
coVerify {
|
||||
apiService.getNextDisplayData(
|
||||
fullApiUrl = any(),
|
||||
accessToken = any(),
|
||||
deviceMacId = any(),
|
||||
useBase64 = any(),
|
||||
rssi = any(),
|
||||
percentCharged = 75,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getNextDisplayData should send null battery percentage for BYOD when unavailable`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val byodConfig =
|
||||
byodDeviceConfig.copy(
|
||||
apiAccessToken = "test_api_key",
|
||||
)
|
||||
|
||||
every { androidDeviceInfoProvider.getBatteryLevel() } returns null
|
||||
every { androidDeviceInfoProvider.getWifiSignalStrength() } returns -65
|
||||
|
||||
coEvery {
|
||||
apiService.getNextDisplayData(
|
||||
fullApiUrl = any(),
|
||||
accessToken = any(),
|
||||
deviceMacId = any(),
|
||||
useBase64 = any(),
|
||||
rssi = any(),
|
||||
percentCharged = null,
|
||||
)
|
||||
} returns ApiResult.success(mockk(relaxed = true))
|
||||
|
||||
// Act
|
||||
repository.getNextDisplayData(byodConfig)
|
||||
|
||||
// Assert - Verify battery level was fetched but null was sent
|
||||
coVerify(exactly = 1) { androidDeviceInfoProvider.getBatteryLevel() }
|
||||
coVerify {
|
||||
apiService.getNextDisplayData(
|
||||
fullApiUrl = any(),
|
||||
accessToken = any(),
|
||||
deviceMacId = any(),
|
||||
useBase64 = any(),
|
||||
rssi = any(),
|
||||
percentCharged = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getNextDisplayData should NOT send battery percentage for TRMNL device`() =
|
||||
runTest {
|
||||
// Arrange - TRMNL device (not BYOD)
|
||||
val trmnlConfig =
|
||||
testDeviceConfig.copy(
|
||||
apiAccessToken = "trmnl_api_key",
|
||||
)
|
||||
|
||||
coEvery {
|
||||
apiService.getNextDisplayData(
|
||||
fullApiUrl = any(),
|
||||
accessToken = any(),
|
||||
deviceMacId = any(),
|
||||
useBase64 = any(),
|
||||
rssi = null,
|
||||
percentCharged = null,
|
||||
)
|
||||
} returns ApiResult.success(mockk(relaxed = true))
|
||||
|
||||
// Act
|
||||
repository.getNextDisplayData(trmnlConfig)
|
||||
|
||||
// Assert - Verify battery level was NOT fetched and null was sent
|
||||
coVerify(exactly = 0) { androidDeviceInfoProvider.getBatteryLevel() }
|
||||
coVerify {
|
||||
apiService.getNextDisplayData(
|
||||
fullApiUrl = any(),
|
||||
accessToken = any(),
|
||||
deviceMacId = any(),
|
||||
useBase64 = any(),
|
||||
rssi = null,
|
||||
percentCharged = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getNextDisplayData should NOT send battery percentage for BYOS device`() =
|
||||
runTest {
|
||||
// Arrange - BYOS device
|
||||
val byosConfig =
|
||||
byosDeviceConfig.copy(
|
||||
apiAccessToken = "byos_api_key",
|
||||
)
|
||||
|
||||
coEvery {
|
||||
apiService.getNextDisplayData(
|
||||
fullApiUrl = any(),
|
||||
accessToken = any(),
|
||||
deviceMacId = any(),
|
||||
useBase64 = any(),
|
||||
rssi = null,
|
||||
percentCharged = null,
|
||||
)
|
||||
} returns ApiResult.success(mockk(relaxed = true))
|
||||
|
||||
// Act
|
||||
repository.getNextDisplayData(byosConfig)
|
||||
|
||||
// Assert - Verify battery level was NOT fetched for BYOS device
|
||||
coVerify(exactly = 0) { androidDeviceInfoProvider.getBatteryLevel() }
|
||||
// Verify null battery percentage was sent
|
||||
coVerify {
|
||||
apiService.getNextDisplayData(
|
||||
fullApiUrl = any(),
|
||||
accessToken = any(),
|
||||
deviceMacId = any(),
|
||||
useBase64 = any(),
|
||||
rssi = null,
|
||||
percentCharged = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getNextDisplayData should call getWifiSignalStrength only for BYOD devices`() =
|
||||
runTest {
|
||||
@@ -842,8 +1003,9 @@ class TrmnlDisplayRepositoryTest {
|
||||
val byosConfig = byosDeviceConfig.copy(apiAccessToken = "byos_key")
|
||||
|
||||
every { androidDeviceInfoProvider.getWifiSignalStrength() } returns -70
|
||||
every { androidDeviceInfoProvider.getBatteryLevel() } returns 75
|
||||
|
||||
coEvery { apiService.getNextDisplayData(any(), any(), any(), any(), any()) } returns
|
||||
coEvery { apiService.getNextDisplayData(any(), any(), any(), any(), any(), any()) } returns
|
||||
ApiResult.success(mockk(relaxed = true))
|
||||
|
||||
// Act - Fetch for all device types
|
||||
@@ -861,8 +1023,10 @@ class TrmnlDisplayRepositoryTest {
|
||||
// Arrange
|
||||
val byodConfig = byodDeviceConfig.copy(apiAccessToken = "test_key")
|
||||
val strongSignal = -30 // Excellent signal
|
||||
val expectedBattery = 80
|
||||
|
||||
every { androidDeviceInfoProvider.getWifiSignalStrength() } returns strongSignal
|
||||
every { androidDeviceInfoProvider.getBatteryLevel() } returns expectedBattery
|
||||
|
||||
coEvery {
|
||||
apiService.getNextDisplayData(
|
||||
@@ -871,6 +1035,7 @@ class TrmnlDisplayRepositoryTest {
|
||||
deviceMacId = any(),
|
||||
useBase64 = any(),
|
||||
rssi = strongSignal,
|
||||
percentCharged = expectedBattery,
|
||||
)
|
||||
} returns ApiResult.success(mockk(relaxed = true))
|
||||
|
||||
@@ -885,6 +1050,7 @@ class TrmnlDisplayRepositoryTest {
|
||||
deviceMacId = any(),
|
||||
useBase64 = any(),
|
||||
rssi = strongSignal,
|
||||
percentCharged = expectedBattery,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -895,8 +1061,10 @@ class TrmnlDisplayRepositoryTest {
|
||||
// Arrange
|
||||
val byodConfig = byodDeviceConfig.copy(apiAccessToken = "test_key")
|
||||
val weakSignal = -90 // Very weak signal
|
||||
val expectedBattery = 80
|
||||
|
||||
every { androidDeviceInfoProvider.getWifiSignalStrength() } returns weakSignal
|
||||
every { androidDeviceInfoProvider.getBatteryLevel() } returns expectedBattery
|
||||
|
||||
coEvery {
|
||||
apiService.getNextDisplayData(
|
||||
@@ -905,6 +1073,7 @@ class TrmnlDisplayRepositoryTest {
|
||||
deviceMacId = any(),
|
||||
useBase64 = any(),
|
||||
rssi = weakSignal,
|
||||
percentCharged = expectedBattery,
|
||||
)
|
||||
} returns ApiResult.success(mockk(relaxed = true))
|
||||
|
||||
@@ -919,6 +1088,7 @@ class TrmnlDisplayRepositoryTest {
|
||||
deviceMacId = any(),
|
||||
useBase64 = any(),
|
||||
rssi = weakSignal,
|
||||
percentCharged = expectedBattery,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,12 @@ paths:
|
||||
description: Device battery voltage (eg. 3.7)
|
||||
schema:
|
||||
type: number
|
||||
- name: Percent-Charged
|
||||
in: header
|
||||
required: false
|
||||
description: Device percent charged (eg. 69.4)
|
||||
schema:
|
||||
type: number
|
||||
- name: FW-Version
|
||||
in: header
|
||||
required: false
|
||||
|
||||
Reference in New Issue
Block a user