mirror of
https://github.com/usetrmnl/trmnl-android.git
synced 2026-04-29 13:35:26 -07:00
Merge pull request #247 from usetrmnl/feature/246-byod-device-id-battery-reporting
feat: Add BYOD device ID fetching and battery reporting
This commit is contained in:
@@ -100,6 +100,7 @@ class TrmnlDeviceConfigDataStore
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -261,9 +262,10 @@ class TrmnlDeviceConfigDataStore
|
||||
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()}",
|
||||
"Loading device config (legacy): type=$type, deviceApiToken=${token.obfuscated()}, deviceId=$deviceId",
|
||||
)
|
||||
|
||||
if (token != null) {
|
||||
@@ -275,6 +277,7 @@ class TrmnlDeviceConfigDataStore
|
||||
refreshRateSecs = refreshRate,
|
||||
isMasterDevice = isMasterDevice,
|
||||
userApiToken = userApiToken,
|
||||
deviceId = deviceId,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -335,6 +338,11 @@ class TrmnlDeviceConfigDataStore
|
||||
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) {
|
||||
@@ -374,6 +382,33 @@ class TrmnlDeviceConfigDataStore
|
||||
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,13 +15,17 @@ 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
|
||||
import ink.trmnl.android.network.util.extractHttpResponseMetadata
|
||||
import ink.trmnl.android.network.util.extractHttpResponseMetadataFromFailure
|
||||
import ink.trmnl.android.util.AndroidDeviceInfoProvider
|
||||
import ink.trmnl.android.util.HTTP_500
|
||||
import ink.trmnl.android.util.isHttpOk
|
||||
import timber.log.Timber
|
||||
@@ -44,6 +48,7 @@ class TrmnlDisplayRepository
|
||||
private val userApiService: TrmnlUserApiService,
|
||||
private val imageMetadataStore: ImageMetadataStore,
|
||||
private val repositoryConfigProvider: RepositoryConfigProvider,
|
||||
private val androidDeviceInfoProvider: AndroidDeviceInfoProvider,
|
||||
) {
|
||||
/**
|
||||
* Fetches display data for next plugin from the server using the provided access token.
|
||||
@@ -368,4 +373,170 @@ class TrmnlDisplayRepository
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the device ID from the TRMNL API using the device API token.
|
||||
*
|
||||
* 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}.
|
||||
*
|
||||
* **Note:** This endpoint doesn't exist on the server yet, so this method
|
||||
* returns a mocked response until the server endpoint is implemented.
|
||||
*
|
||||
* @param config Device configuration containing the device API token
|
||||
* @return A Result containing the device ID on success or an exception on failure
|
||||
*/
|
||||
suspend fun getDeviceIdFromApi(config: TrmnlDeviceConfig): Result<Int> {
|
||||
Timber.i("Fetching device ID from API for device type: ${config.type}")
|
||||
|
||||
// Always use mocked response since the endpoint doesn't exist yet
|
||||
// TODO: Remove this mock when the server endpoint is implemented
|
||||
val mockedDevice =
|
||||
TrmnlDevice(
|
||||
id = 41448,
|
||||
name = "BYOD TRMNL",
|
||||
friendlyId = "_____",
|
||||
macAddress = "********",
|
||||
batteryVoltage = null,
|
||||
rssi = null,
|
||||
sleepModeEnabled = false,
|
||||
sleepStartTime = 1320,
|
||||
sleepEndTime = 480,
|
||||
percentCharged = 100.0,
|
||||
wifiStrength = 100.0,
|
||||
)
|
||||
|
||||
Timber.i("Using mocked device ID: ${mockedDevice.id}")
|
||||
return Result.success(mockedDevice.id)
|
||||
|
||||
/*
|
||||
* TODO: Uncomment this when the server endpoint is implemented:
|
||||
*
|
||||
* val result = apiService.getDeviceMe(
|
||||
* fullApiUrl = constructApiUrl(config.apiBaseUrl, DEVICE_ME_API_PATH),
|
||||
* accessToken = config.apiAccessToken,
|
||||
* )
|
||||
*
|
||||
* return when (result) {
|
||||
* is ApiResult.Failure -> {
|
||||
* val exception = result.exceptionOrNull()
|
||||
* Timber.e(exception, "Failed to fetch device ID")
|
||||
* Result.failure(exception ?: Exception("Failed to fetch device ID"))
|
||||
* }
|
||||
* is ApiResult.Success -> {
|
||||
* val deviceId = result.value.data.id
|
||||
* Timber.i("Device ID fetched successfully: $deviceId")
|
||||
* Result.success(deviceId)
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* This method should be called after successful image refresh operations.
|
||||
*
|
||||
* @param config Device configuration containing device type, device ID, and user API token
|
||||
*/
|
||||
suspend fun reportDeviceBatteryStatus(config: TrmnlDeviceConfig) {
|
||||
// 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})")
|
||||
return
|
||||
}
|
||||
|
||||
if (config.deviceId == null) {
|
||||
Timber.w("Battery reporting skipped: device ID is null")
|
||||
return
|
||||
}
|
||||
|
||||
if (config.userApiToken == null) {
|
||||
Timber.w("Battery reporting skipped: user API token is null")
|
||||
return
|
||||
}
|
||||
|
||||
// Get current battery level
|
||||
val batteryLevel = androidDeviceInfoProvider.getBatteryLevel()
|
||||
if (batteryLevel == null) {
|
||||
Timber.w("Battery reporting skipped: unable to get battery level")
|
||||
return
|
||||
}
|
||||
|
||||
// Report battery status
|
||||
try {
|
||||
val result = reportBatteryStatus(config, batteryLevel)
|
||||
result.onFailure { throwable ->
|
||||
Timber.e(throwable, "Failed to report battery status")
|
||||
}
|
||||
} 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.
|
||||
*
|
||||
* This suspend function performs network I/O and should be called from a background
|
||||
* coroutine so it does not block or delay display updates.
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
private suspend fun reportBatteryStatus(
|
||||
config: TrmnlDeviceConfig,
|
||||
batteryPercent: Int,
|
||||
): Result<Unit> {
|
||||
val deviceId = config.deviceId
|
||||
val userApiToken = config.userApiToken
|
||||
|
||||
if (deviceId == null) {
|
||||
Timber.w("Cannot report battery status: device ID is null")
|
||||
return Result.failure(IllegalStateException("Device ID is required"))
|
||||
}
|
||||
|
||||
if (userApiToken == null) {
|
||||
Timber.w("Cannot report battery status: user API token is null")
|
||||
return Result.failure(IllegalStateException("User API token is required"))
|
||||
}
|
||||
|
||||
Timber.d("Reporting battery status: $batteryPercent% for device ID: $deviceId")
|
||||
|
||||
if (repositoryConfigProvider.shouldUseFakeData) {
|
||||
// Skip API call in debug mode
|
||||
Timber.d("Skipping battery status report (fake API mode)")
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
val updateRequest = TrmnlDeviceUpdateRequest(percentCharged = batteryPercent.toDouble())
|
||||
val apiUrl = constructApiUrl(config.apiBaseUrl, DEVICE_API_PATH.replace("{id}", deviceId.toString()))
|
||||
|
||||
val result =
|
||||
userApiService.updateDevice(
|
||||
fullApiUrl = apiUrl,
|
||||
accessToken = "Bearer $userApiToken",
|
||||
updateRequest = updateRequest,
|
||||
)
|
||||
|
||||
return when (result) {
|
||||
is ApiResult.Failure -> {
|
||||
val exception = result.exceptionOrNull()
|
||||
Timber.e(exception, "Failed to report battery status")
|
||||
Result.failure(exception ?: Exception("Failed to report battery status"))
|
||||
}
|
||||
is ApiResult.Success -> {
|
||||
Timber.d("Battery status reported successfully")
|
||||
Result.success(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,4 +40,14 @@ data class TrmnlDeviceConfig constructor(
|
||||
* 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,
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ package ink.trmnl.android.network
|
||||
import com.slack.eithernet.ApiResult
|
||||
import ink.trmnl.android.data.TrmnlDisplayRepository
|
||||
import ink.trmnl.android.network.model.TrmnlCurrentImageResponse
|
||||
import ink.trmnl.android.network.model.TrmnlDeviceResponse
|
||||
import ink.trmnl.android.network.model.TrmnlDisplayResponse
|
||||
import ink.trmnl.android.network.model.TrmnlModelsResponse
|
||||
import ink.trmnl.android.network.model.TrmnlSetupResponse
|
||||
@@ -65,6 +66,20 @@ interface TrmnlApiService {
|
||||
* @see getDeviceModels
|
||||
*/
|
||||
internal const val MODELS_API_PATH = "api/models"
|
||||
|
||||
/**
|
||||
* Path for the TRMNL API endpoint to get the device information.
|
||||
*
|
||||
* **Authentication:** Requires device-level Access-Token header
|
||||
*
|
||||
* **Note:** This endpoint doesn't exist on the server yet. The repository layer
|
||||
* provides a mocked response until the server endpoint is implemented.
|
||||
*
|
||||
* See https://discord.com/channels/1281055965508141100/1466924426460397765
|
||||
*
|
||||
* @see getDeviceMe
|
||||
*/
|
||||
internal const val DEVICE_ME_API_PATH = "api/devices/me"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,4 +147,27 @@ interface TrmnlApiService {
|
||||
suspend fun getDeviceModels(
|
||||
@Url fullApiUrl: String,
|
||||
): ApiResult<TrmnlModelsResponse, Unit>
|
||||
|
||||
/**
|
||||
* Retrieve device information using [DEVICE_ME_API_PATH].
|
||||
*
|
||||
* This endpoint provides device details including the device ID, which is needed
|
||||
* for making user-level API calls to `/api/devices/{id}`.
|
||||
*
|
||||
* **Authentication:** Requires device-level Access-Token header (device API key)
|
||||
*
|
||||
* **Note:** This endpoint doesn't exist on the server yet. The repository layer
|
||||
* provides a mocked response until the server endpoint is implemented.
|
||||
*
|
||||
* See https://discord.com/channels/1281055965508141100/1466924426460397765
|
||||
*
|
||||
* @param fullApiUrl The complete API URL to call (e.g., "https://usetrmnl.com/api/devices/me")
|
||||
* @param accessToken The device's API key (required)
|
||||
* @return An [ApiResult] containing [TrmnlDeviceResponse] with the device information
|
||||
*/
|
||||
@GET
|
||||
suspend fun getDeviceMe(
|
||||
@Url fullApiUrl: String,
|
||||
@Header("access-token") accessToken: String,
|
||||
): ApiResult<TrmnlDeviceResponse, Unit>
|
||||
}
|
||||
|
||||
@@ -513,6 +513,23 @@ 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 ?: ""
|
||||
@@ -535,6 +552,14 @@ 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,
|
||||
@@ -549,6 +574,8 @@ class AppSettingsPresenter
|
||||
// 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)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package ink.trmnl.android.util
|
||||
|
||||
import android.content.Context
|
||||
import android.os.BatteryManager
|
||||
import com.squareup.anvil.annotations.optional.SingleIn
|
||||
import ink.trmnl.android.di.AppScope
|
||||
import ink.trmnl.android.di.ApplicationContext
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Provider class for accessing Android device information.
|
||||
*
|
||||
* This class provides utility methods to retrieve device-specific information
|
||||
* such as battery level, which can be used for reporting to the TRMNL API.
|
||||
*/
|
||||
@SingleIn(AppScope::class)
|
||||
class AndroidDeviceInfoProvider
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
/**
|
||||
* Gets the current battery level of the Android device.
|
||||
*
|
||||
* @return Battery percentage (0-100), or null if unable to retrieve
|
||||
*/
|
||||
fun getBatteryLevel(): Int? =
|
||||
try {
|
||||
val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager
|
||||
val batteryLevel =
|
||||
batteryManager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
|
||||
Timber.i("Current battery level: $batteryLevel%")
|
||||
batteryLevel
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to get battery level")
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,9 @@ class TrmnlImageRefreshWorker(
|
||||
httpResponseMetadata = trmnlDisplayInfo.httpResponseMetadata,
|
||||
)
|
||||
|
||||
// Report battery status for BYOD devices after successful image refresh
|
||||
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.
|
||||
// See https://github.com/usetrmnl/trmnl-android/issues/195
|
||||
|
||||
@@ -626,4 +626,100 @@ 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://usetrmnl.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://usetrmnl.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://usetrmnl.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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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
|
||||
import ink.trmnl.android.util.AndroidDeviceInfoProvider
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
@@ -33,6 +34,7 @@ class TrmnlDisplayRepositoryTest {
|
||||
private lateinit var imageMetadataStore: ImageMetadataStore
|
||||
private lateinit var repositoryConfigProvider: RepositoryConfigProvider
|
||||
private lateinit var deviceConfigDataStore: TrmnlDeviceConfigDataStore
|
||||
private lateinit var androidDeviceInfoProvider: AndroidDeviceInfoProvider
|
||||
|
||||
private val testDeviceConfig =
|
||||
TrmnlDeviceConfig(
|
||||
@@ -65,6 +67,7 @@ class TrmnlDisplayRepositoryTest {
|
||||
repositoryConfigProvider = mockk()
|
||||
deviceConfigDataStore = mockk()
|
||||
imageMetadataStore = mockk(relaxed = true)
|
||||
androidDeviceInfoProvider = mockk(relaxed = true)
|
||||
|
||||
every { repositoryConfigProvider.shouldUseFakeData } returns false
|
||||
|
||||
@@ -74,6 +77,7 @@ class TrmnlDisplayRepositoryTest {
|
||||
userApiService = userApiService,
|
||||
imageMetadataStore = imageMetadataStore,
|
||||
repositoryConfigProvider = repositoryConfigProvider,
|
||||
androidDeviceInfoProvider = androidDeviceInfoProvider,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -567,4 +571,127 @@ class TrmnlDisplayRepositoryTest {
|
||||
assertThat(result.httpResponseMetadata?.statusCode).isEqualTo(500)
|
||||
assertThat(result.httpResponseMetadata?.contentLength).isEqualTo(123L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDeviceIdFromApi should return mocked device ID`() =
|
||||
runTest {
|
||||
// Act
|
||||
val result = repository.getDeviceIdFromApi(byodDeviceConfig)
|
||||
|
||||
// Assert
|
||||
assertThat(result.isSuccess).isTrue()
|
||||
assertThat(result.getOrNull()).isEqualTo(41448)
|
||||
|
||||
// Verify API was NOT called since we're using mocked response
|
||||
coVerify(exactly = 0) { apiService.getDeviceMe(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reportDeviceBatteryStatus should report battery for valid BYOD config`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val byodConfigWithDeviceId =
|
||||
byodDeviceConfig.copy(
|
||||
deviceId = 123,
|
||||
userApiToken = "user_test_token",
|
||||
)
|
||||
|
||||
every { androidDeviceInfoProvider.getBatteryLevel() } returns 75
|
||||
|
||||
val expectedApiUrl = "https://server.example.com/api/devices/123"
|
||||
|
||||
coEvery {
|
||||
userApiService.updateDevice(
|
||||
fullApiUrl = expectedApiUrl,
|
||||
accessToken = "Bearer user_test_token",
|
||||
updateRequest = any(),
|
||||
)
|
||||
} returns ApiResult.success(mockk(relaxed = true))
|
||||
|
||||
// Act
|
||||
repository.reportDeviceBatteryStatus(byodConfigWithDeviceId)
|
||||
|
||||
// Assert - Verify battery status was reported
|
||||
coVerify {
|
||||
userApiService.updateDevice(
|
||||
fullApiUrl = expectedApiUrl,
|
||||
accessToken = "Bearer user_test_token",
|
||||
updateRequest = match { it.percentCharged == 75.0 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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",
|
||||
)
|
||||
|
||||
// Act
|
||||
repository.reportDeviceBatteryStatus(trmnlConfig)
|
||||
|
||||
// Assert - Verify API was NOT called
|
||||
coVerify(exactly = 0) { userApiService.updateDevice(any(), any(), any()) }
|
||||
coVerify(exactly = 0) { androidDeviceInfoProvider.getBatteryLevel() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reportDeviceBatteryStatus should skip when deviceId is null`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val configWithoutDeviceId =
|
||||
byodDeviceConfig.copy(
|
||||
deviceId = null,
|
||||
userApiToken = "user_test_token",
|
||||
)
|
||||
|
||||
// Act
|
||||
repository.reportDeviceBatteryStatus(configWithoutDeviceId)
|
||||
|
||||
// Assert - Verify API was NOT called
|
||||
coVerify(exactly = 0) { userApiService.updateDevice(any(), any(), any()) }
|
||||
coVerify(exactly = 0) { androidDeviceInfoProvider.getBatteryLevel() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reportDeviceBatteryStatus should skip when userApiToken is null`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val configWithoutUserToken =
|
||||
byodDeviceConfig.copy(
|
||||
deviceId = 123,
|
||||
userApiToken = null,
|
||||
)
|
||||
|
||||
// Act
|
||||
repository.reportDeviceBatteryStatus(configWithoutUserToken)
|
||||
|
||||
// Assert - Verify API was NOT called
|
||||
coVerify(exactly = 0) { userApiService.updateDevice(any(), any(), any()) }
|
||||
coVerify(exactly = 0) { androidDeviceInfoProvider.getBatteryLevel() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reportDeviceBatteryStatus should skip when battery level unavailable`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val byodConfigWithDeviceId =
|
||||
byodDeviceConfig.copy(
|
||||
deviceId = 123,
|
||||
userApiToken = "user_test_token",
|
||||
)
|
||||
|
||||
every { androidDeviceInfoProvider.getBatteryLevel() } returns null
|
||||
|
||||
// Act
|
||||
repository.reportDeviceBatteryStatus(byodConfigWithDeviceId)
|
||||
|
||||
// Assert - Verify battery level was requested but API was NOT called
|
||||
coVerify(exactly = 1) { androidDeviceInfoProvider.getBatteryLevel() }
|
||||
coVerify(exactly = 0) { userApiService.updateDevice(any(), any(), any()) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user