From 7976e7750927b5ac91aa87bcd9e5a9cb75d116fb Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 31 Jan 2026 13:21:51 -0500 Subject: [PATCH 01/10] feat: Add BYOD device ID fetching and battery reporting (#246) - Add /api/devices/me endpoint to TrmnlApiService for device info retrieval - Add deviceId field to TrmnlDeviceConfig model with documentation - Extend TrmnlDeviceConfigDataStore with deviceId persistence (dual-storage) - Add getDeviceIdFromApi() to repository (mocked until server endpoint exists) - Add getBatteryLevel() and reportBatteryStatus() for battery management - Integrate battery reporting in display fetch methods (non-blocking) - Update AppSettingsScreen to fetch deviceId during BYOD validation - Update TrmnlDisplayRepositoryTest with Context mock dependency Battery reporting is BYOD-only, requires deviceId and userApiToken. Runs asynchronously after successful display fetches without blocking UI. --- .../data/TrmnlDeviceConfigDataStore.kt | 33 +++- .../android/data/TrmnlDisplayRepository.kt | 169 ++++++++++++++++++ .../trmnl/android/model/TrmnlDeviceConfig.kt | 8 + .../trmnl/android/network/TrmnlApiService.kt | 34 ++++ .../android/ui/settings/AppSettingsScreen.kt | 14 ++ .../data/TrmnlDisplayRepositoryTest.kt | 4 + 6 files changed, 261 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt b/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt index dfe7b97..e1577c5 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt @@ -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,29 @@ class TrmnlDeviceConfigDataStore return token } + /** + * Saves the device ID (TRMNL device ID from /api/devices/me) + */ + 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 + */ + 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 */ diff --git a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt index 4cb303f..7f423ee 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt @@ -1,5 +1,7 @@ package ink.trmnl.android.data +import android.content.Context +import android.os.BatteryManager import com.slack.eithernet.ApiResult import com.slack.eithernet.exceptionOrNull import com.squareup.anvil.annotations.optional.SingleIn @@ -7,6 +9,7 @@ import ink.trmnl.android.BuildConfig.USE_FAKE_API import ink.trmnl.android.data.fake.generateFakeDeviceSetupInfo import ink.trmnl.android.data.fake.generateFakeTrmnlDisplayInfo import ink.trmnl.android.di.AppScope +import ink.trmnl.android.di.ApplicationContext import ink.trmnl.android.model.SupportedDeviceModel import ink.trmnl.android.model.TrmnlDeviceConfig import ink.trmnl.android.model.TrmnlDeviceType @@ -15,8 +18,11 @@ 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 @@ -24,6 +30,9 @@ import ink.trmnl.android.network.util.extractHttpResponseMetadata import ink.trmnl.android.network.util.extractHttpResponseMetadataFromFailure import ink.trmnl.android.util.HTTP_500 import ink.trmnl.android.util.isHttpOk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -40,11 +49,26 @@ import javax.inject.Inject class TrmnlDisplayRepository @Inject constructor( + @ApplicationContext private val context: Context, private val apiService: TrmnlApiService, private val userApiService: TrmnlUserApiService, private val imageMetadataStore: ImageMetadataStore, private val repositoryConfigProvider: RepositoryConfigProvider, ) { + /** + * Gets the current battery level of the Android device. + * + * @return Battery percentage (0-100), or null if unable to retrieve + */ + private fun getBatteryLevel(): Int? = + try { + val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager + batteryManager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) + } catch (e: Exception) { + Timber.e(e, "Failed to get battery level") + null + } + /** * Fetches display data for next plugin from the server using the provided access token. * If the app is in debug mode, it uses mock data instead. @@ -101,6 +125,19 @@ class TrmnlDisplayRepository displayInfo.imageUrl, displayInfo.refreshIntervalSeconds, ) + + // Report battery status for BYOD devices (non-blocking) + if (trmnlDeviceConfig.type == TrmnlDeviceType.BYOD && + trmnlDeviceConfig.deviceId != null && + trmnlDeviceConfig.userApiToken != null + ) { + val batteryLevel = getBatteryLevel() + if (batteryLevel != null) { + CoroutineScope(Dispatchers.IO).launch { + reportBatteryStatus(trmnlDeviceConfig, batteryLevel) + } + } + } } return displayInfo @@ -161,6 +198,19 @@ class TrmnlDisplayRepository displayInfo.imageUrl, displayInfo.refreshIntervalSeconds, ) + + // Report battery status for BYOD devices (non-blocking) + if (trmnlDeviceConfig.type == TrmnlDeviceType.BYOD && + trmnlDeviceConfig.deviceId != null && + trmnlDeviceConfig.userApiToken != null + ) { + val batteryLevel = getBatteryLevel() + if (batteryLevel != null) { + CoroutineScope(Dispatchers.IO).launch { + reportBatteryStatus(trmnlDeviceConfig, batteryLevel) + } + } + } } return displayInfo @@ -368,4 +418,123 @@ 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 { + 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 = 1, + 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. + * + * 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 operation is non-blocking and should not affect 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 + */ + suspend fun reportBatteryStatus( + config: TrmnlDeviceConfig, + batteryPercent: Int, + ): Result { + 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) + } + } + } } diff --git a/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt b/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt index 65a098f..9c8a513 100644 --- a/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt +++ b/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt @@ -40,4 +40,12 @@ 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. + */ + val deviceId: Int? = null, ) diff --git a/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt b/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt index a5f36c7..faf02f6 100644 --- a/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt +++ b/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt @@ -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,18 @@ 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 getDeviceMe + */ + internal const val DEVICE_ME_API_PATH = "api/devices/me" } /** @@ -132,4 +145,25 @@ interface TrmnlApiService { suspend fun getDeviceModels( @Url fullApiUrl: String, ): ApiResult + + /** + * 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. + * + * @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 } diff --git a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt index 9e04bc3..20a2b11 100644 --- a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt +++ b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt @@ -513,6 +513,20 @@ 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", deviceIdResult.exceptionOrNull()) + } + } } else { // No error but also no image URL val errorMessage = response.error ?: "" diff --git a/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt b/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt index fcd71b0..5bb73df 100644 --- a/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt +++ b/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt @@ -1,5 +1,6 @@ package ink.trmnl.android.data +import android.content.Context import com.google.common.truth.Truth.assertThat import com.slack.eithernet.ApiResult import ink.trmnl.android.model.TrmnlDeviceConfig @@ -28,6 +29,7 @@ import org.junit.Test @OptIn(com.slack.eithernet.InternalEitherNetApi::class) class TrmnlDisplayRepositoryTest { private lateinit var repository: TrmnlDisplayRepository + private lateinit var context: Context private lateinit var apiService: TrmnlApiService private lateinit var userApiService: TrmnlUserApiService private lateinit var imageMetadataStore: ImageMetadataStore @@ -60,6 +62,7 @@ class TrmnlDisplayRepositoryTest { @Before fun setup() { + context = mockk(relaxed = true) apiService = mockk() userApiService = mockk() repositoryConfigProvider = mockk() @@ -70,6 +73,7 @@ class TrmnlDisplayRepositoryTest { repository = TrmnlDisplayRepository( + context = context, apiService = apiService, userApiService = userApiService, imageMetadataStore = imageMetadataStore, From a8d4066af25895e7f33fbd44377723dc5ca0062a Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 31 Jan 2026 13:26:57 -0500 Subject: [PATCH 02/10] docs: Clarify deviceId is BYOD-only in documentation --- .../ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt | 8 ++++++-- .../java/ink/trmnl/android/model/TrmnlDeviceConfig.kt | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt b/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt index e1577c5..c11b629 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt @@ -383,7 +383,9 @@ class TrmnlDeviceConfigDataStore } /** - * Saves the device ID (TRMNL device ID from /api/devices/me) + * 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") @@ -394,7 +396,9 @@ class TrmnlDeviceConfigDataStore } /** - * Gets the device ID + * Gets the device ID. + * + * **Note:** This is only applicable for BYOD device types. */ suspend fun getDeviceId(): Int? { val deviceId = diff --git a/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt b/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt index 9c8a513..4c7f89e 100644 --- a/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt +++ b/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt @@ -46,6 +46,8 @@ data class TrmnlDeviceConfig constructor( * * 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, ) From 61216a3c43a97175980c2268ba0a0e7d98bb2136 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 31 Jan 2026 13:35:33 -0500 Subject: [PATCH 03/10] test: Add comprehensive test coverage for device ID and battery reporting - Add tests for getDeviceIdFromApi() method (mocked response) - Add tests for reportBatteryStatus() success and failure scenarios - Add tests for null deviceId and userApiToken validation - Add tests for fake data mode behavior - Add tests for saveDeviceId/getDeviceId in TrmnlDeviceConfigDataStore - Add tests for deviceId persistence in dual-storage (JSON + legacy) - Add tests for deviceId removal when set to null Test coverage includes: - 6 new tests in TrmnlDisplayRepositoryTest - 5 new tests in TrmnlDeviceConfigDataStoreTest - All edge cases and error conditions validated --- .../data/TrmnlDeviceConfigDataStoreTest.kt | 96 ++++++++++++ .../data/TrmnlDisplayRepositoryTest.kt | 146 ++++++++++++++++++ 2 files changed, 242 insertions(+) diff --git a/app/src/test/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStoreTest.kt b/app/src/test/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStoreTest.kt index 672c99f..093bd59 100644 --- a/app/src/test/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStoreTest.kt +++ b/app/src/test/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStoreTest.kt @@ -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() + } } diff --git a/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt b/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt index 5bb73df..e949f46 100644 --- a/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt +++ b/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt @@ -571,4 +571,150 @@ 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(1) + + // Verify API was NOT called since we're using mocked response + coVerify(exactly = 0) { apiService.getDeviceMe(any(), any()) } + } + + @Test + fun `reportBatteryStatus should succeed with valid BYOD config`() = + runTest { + // Arrange + val byodConfigWithDeviceId = + byodDeviceConfig.copy( + deviceId = 123, + userApiToken = "user_test_token", + ) + + 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 + val result = repository.reportBatteryStatus(byodConfigWithDeviceId, 85) + + // Assert + assertThat(result.isSuccess).isTrue() + + coVerify { + userApiService.updateDevice( + fullApiUrl = expectedApiUrl, + accessToken = "Bearer user_test_token", + updateRequest = match { it.percentCharged == 85.0 }, + ) + } + } + + @Test + fun `reportBatteryStatus should fail when deviceId is null`() = + runTest { + // Arrange + val configWithoutDeviceId = + byodDeviceConfig.copy( + deviceId = null, + userApiToken = "user_test_token", + ) + + // Act + val result = repository.reportBatteryStatus(configWithoutDeviceId, 85) + + // Assert + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + assertThat(result.exceptionOrNull()?.message).contains("Device ID is required") + + // Verify API was NOT called + coVerify(exactly = 0) { userApiService.updateDevice(any(), any(), any()) } + } + + @Test + fun `reportBatteryStatus should fail when userApiToken is null`() = + runTest { + // Arrange + val configWithoutUserToken = + byodDeviceConfig.copy( + deviceId = 123, + userApiToken = null, + ) + + // Act + val result = repository.reportBatteryStatus(configWithoutUserToken, 85) + + // Assert + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + assertThat(result.exceptionOrNull()?.message).contains("User API token is required") + + // Verify API was NOT called + coVerify(exactly = 0) { userApiService.updateDevice(any(), any(), any()) } + } + + @Test + fun `reportBatteryStatus should handle API failure`() = + runTest { + // Arrange + val byodConfigWithDeviceId = + byodDeviceConfig.copy( + deviceId = 123, + userApiToken = "user_test_token", + ) + + val expectedApiUrl = "https://server.example.com/api/devices/123" + val apiException = java.io.IOException("Network error") + val httpFailure: ApiResult.Failure = + ApiResult.networkFailure(apiException) + + coEvery { + userApiService.updateDevice( + fullApiUrl = expectedApiUrl, + accessToken = "Bearer user_test_token", + updateRequest = any(), + ) + } returns httpFailure + + // Act + val result = repository.reportBatteryStatus(byodConfigWithDeviceId, 85) + + // Assert + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isEqualTo(apiException) + } + + @Test + fun `reportBatteryStatus should skip API call in fake data mode`() = + runTest { + // Arrange + every { repositoryConfigProvider.shouldUseFakeData } returns true + + val byodConfigWithDeviceId = + byodDeviceConfig.copy( + deviceId = 123, + userApiToken = "user_test_token", + ) + + // Act + val result = repository.reportBatteryStatus(byodConfigWithDeviceId, 85) + + // Assert + assertThat(result.isSuccess).isTrue() + + // Verify API was NOT called + coVerify(exactly = 0) { userApiService.updateDevice(any(), any(), any()) } + } } From 78e0b265c89e0b2ac001d7d514d9c17fff132696 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 31 Jan 2026 13:41:48 -0500 Subject: [PATCH 04/10] [ADDED] Some note and references --- .../main/java/ink/trmnl/android/network/TrmnlApiService.kt | 4 ++++ .../java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt b/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt index faf02f6..f8cf661 100644 --- a/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt +++ b/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt @@ -75,6 +75,8 @@ interface TrmnlApiService { * **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" @@ -157,6 +159,8 @@ interface TrmnlApiService { * **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 diff --git a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt index 20a2b11..fea972c 100644 --- a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt +++ b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt @@ -524,7 +524,10 @@ class AppSettingsPresenter Timber.d("Device ID saved successfully for BYOD device: $deviceId") } } else { - Timber.w("Failed to fetch device ID for BYOD device", deviceIdResult.exceptionOrNull()) + Timber.w( + "Failed to fetch device ID for BYOD device. Error: %s", + deviceIdResult.exceptionOrNull(), + ) } } } else { From 2e0196be52fb10e53e88f842aeac687764ff5ac4 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 31 Jan 2026 13:49:19 -0500 Subject: [PATCH 05/10] [MINOR] Logging --- .../java/ink/trmnl/android/data/TrmnlDisplayRepository.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt index 7f423ee..8850c53 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt @@ -63,7 +63,10 @@ class TrmnlDisplayRepository private fun getBatteryLevel(): Int? = try { val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager - batteryManager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) + 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 @@ -439,7 +442,7 @@ class TrmnlDisplayRepository // TODO: Remove this mock when the server endpoint is implemented val mockedDevice = TrmnlDevice( - id = 1, + id = 41448, name = "BYOD TRMNL", friendlyId = "_____", macAddress = "********", From ff54c47a59c192b872ffca360c27204aece28c95 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 31 Jan 2026 13:57:14 -0500 Subject: [PATCH 06/10] [FIXED] Unit tests --- .../java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt b/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt index e949f46..ab8c706 100644 --- a/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt +++ b/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt @@ -580,7 +580,7 @@ class TrmnlDisplayRepositoryTest { // Assert assertThat(result.isSuccess).isTrue() - assertThat(result.getOrNull()).isEqualTo(1) + assertThat(result.getOrNull()).isEqualTo(41448) // Verify API was NOT called since we're using mocked response coVerify(exactly = 0) { apiService.getDeviceMe(any(), any()) } From 60e18686b592832c807051197ca9f4e33c7ee221 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 31 Jan 2026 14:13:33 -0500 Subject: [PATCH 07/10] refactor: Extract battery level logic and simplify battery reporting - Create AndroidDeviceInfoProvider class for device info operations - Inject AndroidDeviceInfoProvider into TrmnlDisplayRepository - Remove inline battery reporting from getNextDisplayData/getCurrentDisplayData - Add reportDeviceBatteryStatus() as public API for battery reporting - Make reportBatteryStatus() private (internal implementation) - Update TrmnlImageRefreshWorker to call reportDeviceBatteryStatus after successful refresh - Remove Context dependency from TrmnlDisplayRepository - Update tests to reflect new architecture - Add comprehensive tests for reportDeviceBatteryStatus This refactoring improves separation of concerns and makes the code more maintainable and testable. --- .../android/data/TrmnlDisplayRepository.kt | 93 +++++++--------- .../android/util/AndroidDeviceInfoProvider.kt | 39 +++++++ .../android/work/TrmnlImageRefreshWorker.kt | 3 + .../data/TrmnlDisplayRepositoryTest.kt | 105 +++++++----------- 4 files changed, 125 insertions(+), 115 deletions(-) create mode 100644 app/src/main/java/ink/trmnl/android/util/AndroidDeviceInfoProvider.kt diff --git a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt index 8850c53..c82d5bf 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt @@ -1,7 +1,5 @@ package ink.trmnl.android.data -import android.content.Context -import android.os.BatteryManager import com.slack.eithernet.ApiResult import com.slack.eithernet.exceptionOrNull import com.squareup.anvil.annotations.optional.SingleIn @@ -9,7 +7,6 @@ import ink.trmnl.android.BuildConfig.USE_FAKE_API import ink.trmnl.android.data.fake.generateFakeDeviceSetupInfo import ink.trmnl.android.data.fake.generateFakeTrmnlDisplayInfo import ink.trmnl.android.di.AppScope -import ink.trmnl.android.di.ApplicationContext import ink.trmnl.android.model.SupportedDeviceModel import ink.trmnl.android.model.TrmnlDeviceConfig import ink.trmnl.android.model.TrmnlDeviceType @@ -28,11 +25,9 @@ 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 kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -49,29 +44,12 @@ import javax.inject.Inject class TrmnlDisplayRepository @Inject constructor( - @ApplicationContext private val context: Context, private val apiService: TrmnlApiService, private val userApiService: TrmnlUserApiService, private val imageMetadataStore: ImageMetadataStore, private val repositoryConfigProvider: RepositoryConfigProvider, + private val androidDeviceInfoProvider: AndroidDeviceInfoProvider, ) { - /** - * Gets the current battery level of the Android device. - * - * @return Battery percentage (0-100), or null if unable to retrieve - */ - private 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 - } - /** * Fetches display data for next plugin from the server using the provided access token. * If the app is in debug mode, it uses mock data instead. @@ -128,19 +106,6 @@ class TrmnlDisplayRepository displayInfo.imageUrl, displayInfo.refreshIntervalSeconds, ) - - // Report battery status for BYOD devices (non-blocking) - if (trmnlDeviceConfig.type == TrmnlDeviceType.BYOD && - trmnlDeviceConfig.deviceId != null && - trmnlDeviceConfig.userApiToken != null - ) { - val batteryLevel = getBatteryLevel() - if (batteryLevel != null) { - CoroutineScope(Dispatchers.IO).launch { - reportBatteryStatus(trmnlDeviceConfig, batteryLevel) - } - } - } } return displayInfo @@ -201,19 +166,6 @@ class TrmnlDisplayRepository displayInfo.imageUrl, displayInfo.refreshIntervalSeconds, ) - - // Report battery status for BYOD devices (non-blocking) - if (trmnlDeviceConfig.type == TrmnlDeviceType.BYOD && - trmnlDeviceConfig.deviceId != null && - trmnlDeviceConfig.userApiToken != null - ) { - val batteryLevel = getBatteryLevel() - if (batteryLevel != null) { - CoroutineScope(Dispatchers.IO).launch { - reportBatteryStatus(trmnlDeviceConfig, batteryLevel) - } - } - } } return displayInfo @@ -481,6 +433,45 @@ 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. + * + * 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 + reportBatteryStatus(config, batteryLevel) + } + /** * Reports the device's battery status to the TRMNL API. * @@ -493,7 +484,7 @@ class TrmnlDisplayRepository * @param batteryPercent The current battery percentage (0-100) * @return A Result containing Unit on success or an exception on failure */ - suspend fun reportBatteryStatus( + private suspend fun reportBatteryStatus( config: TrmnlDeviceConfig, batteryPercent: Int, ): Result { diff --git a/app/src/main/java/ink/trmnl/android/util/AndroidDeviceInfoProvider.kt b/app/src/main/java/ink/trmnl/android/util/AndroidDeviceInfoProvider.kt new file mode 100644 index 0000000..933fb34 --- /dev/null +++ b/app/src/main/java/ink/trmnl/android/util/AndroidDeviceInfoProvider.kt @@ -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 + } + } diff --git a/app/src/main/java/ink/trmnl/android/work/TrmnlImageRefreshWorker.kt b/app/src/main/java/ink/trmnl/android/work/TrmnlImageRefreshWorker.kt index bc92059..df1d4a2 100644 --- a/app/src/main/java/ink/trmnl/android/work/TrmnlImageRefreshWorker.kt +++ b/app/src/main/java/ink/trmnl/android/work/TrmnlImageRefreshWorker.kt @@ -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 diff --git a/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt b/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt index ab8c706..2fa7245 100644 --- a/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt +++ b/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt @@ -1,6 +1,5 @@ package ink.trmnl.android.data -import android.content.Context import com.google.common.truth.Truth.assertThat import com.slack.eithernet.ApiResult import ink.trmnl.android.model.TrmnlDeviceConfig @@ -10,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 @@ -29,12 +29,12 @@ import org.junit.Test @OptIn(com.slack.eithernet.InternalEitherNetApi::class) class TrmnlDisplayRepositoryTest { private lateinit var repository: TrmnlDisplayRepository - private lateinit var context: Context 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 + private lateinit var androidDeviceInfoProvider: AndroidDeviceInfoProvider private val testDeviceConfig = TrmnlDeviceConfig( @@ -62,22 +62,22 @@ class TrmnlDisplayRepositoryTest { @Before fun setup() { - context = mockk(relaxed = true) apiService = mockk() userApiService = mockk() repositoryConfigProvider = mockk() deviceConfigDataStore = mockk() imageMetadataStore = mockk(relaxed = true) + androidDeviceInfoProvider = mockk(relaxed = true) every { repositoryConfigProvider.shouldUseFakeData } returns false repository = TrmnlDisplayRepository( - context = context, apiService = apiService, userApiService = userApiService, imageMetadataStore = imageMetadataStore, repositoryConfigProvider = repositoryConfigProvider, + androidDeviceInfoProvider = androidDeviceInfoProvider, ) } @@ -587,7 +587,7 @@ class TrmnlDisplayRepositoryTest { } @Test - fun `reportBatteryStatus should succeed with valid BYOD config`() = + fun `reportDeviceBatteryStatus should report battery for valid BYOD config`() = runTest { // Arrange val byodConfigWithDeviceId = @@ -596,6 +596,8 @@ class TrmnlDisplayRepositoryTest { userApiToken = "user_test_token", ) + every { androidDeviceInfoProvider.getBatteryLevel() } returns 75 + val expectedApiUrl = "https://server.example.com/api/devices/123" coEvery { @@ -607,22 +609,38 @@ class TrmnlDisplayRepositoryTest { } returns ApiResult.success(mockk(relaxed = true)) // Act - val result = repository.reportBatteryStatus(byodConfigWithDeviceId, 85) - - // Assert - assertThat(result.isSuccess).isTrue() + repository.reportDeviceBatteryStatus(byodConfigWithDeviceId) + // Assert - Verify battery status was reported coVerify { userApiService.updateDevice( fullApiUrl = expectedApiUrl, accessToken = "Bearer user_test_token", - updateRequest = match { it.percentCharged == 85.0 }, + updateRequest = match { it.percentCharged == 75.0 }, ) } } @Test - fun `reportBatteryStatus should fail when deviceId is null`() = + 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 = @@ -632,19 +650,15 @@ class TrmnlDisplayRepositoryTest { ) // Act - val result = repository.reportBatteryStatus(configWithoutDeviceId, 85) + repository.reportDeviceBatteryStatus(configWithoutDeviceId) - // Assert - assertThat(result.isFailure).isTrue() - assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) - assertThat(result.exceptionOrNull()?.message).contains("Device ID is required") - - // Verify API was NOT called + // Assert - Verify API was NOT called coVerify(exactly = 0) { userApiService.updateDevice(any(), any(), any()) } + coVerify(exactly = 0) { androidDeviceInfoProvider.getBatteryLevel() } } @Test - fun `reportBatteryStatus should fail when userApiToken is null`() = + fun `reportDeviceBatteryStatus should skip when userApiToken is null`() = runTest { // Arrange val configWithoutUserToken = @@ -654,19 +668,15 @@ class TrmnlDisplayRepositoryTest { ) // Act - val result = repository.reportBatteryStatus(configWithoutUserToken, 85) + repository.reportDeviceBatteryStatus(configWithoutUserToken) - // Assert - assertThat(result.isFailure).isTrue() - assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) - assertThat(result.exceptionOrNull()?.message).contains("User API token is required") - - // Verify API was NOT called + // Assert - Verify API was NOT called coVerify(exactly = 0) { userApiService.updateDevice(any(), any(), any()) } + coVerify(exactly = 0) { androidDeviceInfoProvider.getBatteryLevel() } } @Test - fun `reportBatteryStatus should handle API failure`() = + fun `reportDeviceBatteryStatus should skip when battery level unavailable`() = runTest { // Arrange val byodConfigWithDeviceId = @@ -675,46 +685,13 @@ class TrmnlDisplayRepositoryTest { userApiToken = "user_test_token", ) - val expectedApiUrl = "https://server.example.com/api/devices/123" - val apiException = java.io.IOException("Network error") - val httpFailure: ApiResult.Failure = - ApiResult.networkFailure(apiException) - - coEvery { - userApiService.updateDevice( - fullApiUrl = expectedApiUrl, - accessToken = "Bearer user_test_token", - updateRequest = any(), - ) - } returns httpFailure + every { androidDeviceInfoProvider.getBatteryLevel() } returns null // Act - val result = repository.reportBatteryStatus(byodConfigWithDeviceId, 85) + repository.reportDeviceBatteryStatus(byodConfigWithDeviceId) - // Assert - assertThat(result.isFailure).isTrue() - assertThat(result.exceptionOrNull()).isEqualTo(apiException) - } - - @Test - fun `reportBatteryStatus should skip API call in fake data mode`() = - runTest { - // Arrange - every { repositoryConfigProvider.shouldUseFakeData } returns true - - val byodConfigWithDeviceId = - byodDeviceConfig.copy( - deviceId = 123, - userApiToken = "user_test_token", - ) - - // Act - val result = repository.reportBatteryStatus(byodConfigWithDeviceId, 85) - - // Assert - assertThat(result.isSuccess).isTrue() - - // Verify API was NOT called + // Assert - Verify battery level was requested but API was NOT called + coVerify(exactly = 1) { androidDeviceInfoProvider.getBatteryLevel() } coVerify(exactly = 0) { userApiService.updateDevice(any(), any(), any()) } } } From 3a9b1e9e710f526a19f9ff3a3f48b40c4ae52d68 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 31 Jan 2026 16:05:17 -0500 Subject: [PATCH 08/10] Update app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt index c82d5bf..4fe70d3 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt @@ -478,7 +478,8 @@ class TrmnlDisplayRepository * 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 operation is non-blocking and should not affect display updates. + * 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) From 5be5695bc705cf687650f6ef7fbf0d863a21cdad Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 31 Jan 2026 16:05:49 -0500 Subject: [PATCH 09/10] Update app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../ink/trmnl/android/data/TrmnlDisplayRepository.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt index 4fe70d3..76401ff 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt @@ -469,7 +469,14 @@ class TrmnlDisplayRepository } // Report battery status - reportBatteryStatus(config, batteryLevel) + 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") + } } /** From 8737038c166adc540ae351d1c2aabb0288149ede Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 31 Jan 2026 16:15:04 -0500 Subject: [PATCH 10/10] fix: Include deviceId when saving config after validation The device ID was being fetched and saved to DataStore during BYOD device validation, but it wasn't included in the TrmnlDeviceConfig object when saving the full configuration. This caused the device ID to be null in the loaded config, resulting in battery reporting being skipped with 'device ID is null' warnings. Now we retrieve the device ID from DataStore before saving the full config and include it in the TrmnlDeviceConfig object for BYOD devices. --- .../ink/trmnl/android/ui/settings/AppSettingsScreen.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt index fea972c..1161821 100644 --- a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt +++ b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt @@ -552,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, @@ -566,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)