From 0614f59ff9a79caab574110dccbfc891e52c7edd Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Thu, 5 Feb 2026 19:37:50 -0500 Subject: [PATCH 1/5] feat: migrate battery reporting to Percent-Charged header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace complex user-level battery reporting (PATCH /api/devices/{id}) with simplified device-level Percent-Charged header sent with image fetch requests. Changes: - Add percentCharged parameter to TrmnlApiService.getNextDisplayData() - Update TrmnlDisplayRepository to send battery for BYOD devices only - Deprecate reportDeviceBatteryStatus() and getDeviceIdFromApi() methods - Remove battery reporting call from TrmnlImageRefreshWorker - Disable user API token UI in AppSettingsScreen (100+ lines) - Deprecate TrmnlUserApiService and TrmnlDeviceUpdateRequest - Deprecate userApiToken and deviceId in TrmnlDeviceConfig - Deprecate DataStore methods for user token and device ID - Add 4 new battery percentage header tests - Ignore 6 deprecated battery/device ID tests - Update existing RSSI tests to include percentCharged parameter Benefits: - Simpler: No separate API call needed - Secure: Uses device-level auth only (no user token required) - Consistent: Follows same pattern as RSSI header - BYOD-only: Battery reporting limited to BYOD devices as designed All changes maintain backward compatibility with deprecated code preserved. Verified with: - formatKotlin: ✅ (0 errors) - lintKotlin: ✅ (0 errors) - testDebugUnitTest: ✅ (203 passed, 9 skipped) - assembleDebug: ✅ (APK built successfully) --- .../data/TrmnlDeviceConfigDataStore.kt | 28 +++ .../android/data/TrmnlDisplayRepository.kt | 62 +++++- .../trmnl/android/model/TrmnlDeviceConfig.kt | 22 ++ .../trmnl/android/network/TrmnlApiService.kt | 2 + .../android/network/TrmnlUserApiService.kt | 10 + .../network/model/TrmnlDeviceUpdateRequest.kt | 9 + .../android/ui/settings/AppSettingsScreen.kt | 22 ++ .../android/work/TrmnlImageRefreshWorker.kt | 5 +- .../data/TrmnlDisplayRepositoryTest.kt | 200 +++++++++++++++++- .../trmnl-api/trmnl-openapi.yaml | 6 + 10 files changed, 350 insertions(+), 16 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 c11b629..cb11382 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt @@ -361,7 +361,14 @@ class TrmnlDeviceConfigDataStore /** * Saves the user-level API token (Account API key) + * + * **DEPRECATED:** User API token is no longer needed for battery reporting. + * Battery percentage is now sent via the Percent-Charged header in /api/display call. */ + @Deprecated( + message = "No longer needed for battery reporting. Battery is now sent via Percent-Charged header.", + level = DeprecationLevel.WARNING, + ) suspend fun saveUserApiToken(token: String) { Timber.tag(TAG).d("Saving user API token: ${token.obfuscated()}") context.deviceConfigStore.edit { preferences -> @@ -372,7 +379,14 @@ class TrmnlDeviceConfigDataStore /** * Gets the user-level API token + * + * **DEPRECATED:** User API token is no longer needed for battery reporting. + * Battery percentage is now sent via the Percent-Charged header in /api/display call. */ + @Deprecated( + message = "No longer needed for battery reporting. Battery is now sent via Percent-Charged header.", + level = DeprecationLevel.WARNING, + ) suspend fun getUserApiToken(): String? { val token = context.deviceConfigStore.data @@ -385,8 +399,15 @@ class TrmnlDeviceConfigDataStore /** * Saves the device ID (TRMNL device ID from /api/devices/me). * + * **DEPRECATED:** Device ID is no longer needed for battery reporting. + * Battery percentage is now sent via the Percent-Charged header in /api/display call. + * * **Note:** This is only applicable for BYOD device types. */ + @Deprecated( + message = "No longer needed for battery reporting. Battery is now sent via Percent-Charged header.", + level = DeprecationLevel.WARNING, + ) suspend fun saveDeviceId(deviceId: Int) { Timber.tag(TAG).d("Saving device ID: $deviceId") context.deviceConfigStore.edit { preferences -> @@ -398,8 +419,15 @@ class TrmnlDeviceConfigDataStore /** * Gets the device ID. * + * **DEPRECATED:** Device ID is no longer needed for battery reporting. + * Battery percentage is now sent via the Percent-Charged header in /api/display call. + * * **Note:** This is only applicable for BYOD device types. */ + @Deprecated( + message = "No longer needed for battery reporting. Battery is now sent via Percent-Charged header.", + level = DeprecationLevel.WARNING, + ) suspend fun getDeviceId(): Int? { val deviceId = context.deviceConfigStore.data 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 0b11321..c5c5995 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt @@ -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,13 @@ class TrmnlDisplayRepository } else { null }, + percentCharged = + if (trmnlDeviceConfig.type == TrmnlDeviceType.BYOD) { + // Send battery percentage if available for BYOD devices only + androidDeviceInfoProvider.getBatteryLevel()?.toDouble() + } else { + null + }, ) when (result) { @@ -384,6 +388,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 +404,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 { + 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 @@ -438,20 +458,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 +513,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 { + // 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 @@ -545,5 +584,6 @@ class TrmnlDisplayRepository 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 4c7f89e..5bdba97 100644 --- a/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt +++ b/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt @@ -35,13 +35,31 @@ data class TrmnlDeviceConfig constructor( val isMasterDevice: Boolean? = null, /** * User-level API token (Account API key) for user-level endpoints. + * + * **DEPRECATED:** This field 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 ([apiAccessToken]). + * + * This field is kept for backward compatibility and may be removed in a future version. + * * 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. */ + @Deprecated( + message = "No longer needed for battery reporting. Battery is now sent via Percent-Charged header.", + level = DeprecationLevel.WARNING, + ) val userApiToken: String? = null, /** * TRMNL device ID extracted from /api/devices/me endpoint. + * + * **DEPRECATED:** This field is no longer needed for battery reporting. + * Battery percentage is now sent via the Percent-Charged header in /api/display call, + * which doesn't require device ID or user-level authentication. + * + * This field is kept for backward compatibility and may be removed in a future version. + * * Used for making user-level API calls to /api/devices/{id}. * * This ID is fetched during BYOD device validation and is required for @@ -49,5 +67,9 @@ data class TrmnlDeviceConfig constructor( * * **Note:** This field is only applicable for BYOD device types. */ + @Deprecated( + message = "No longer needed for battery reporting. Battery is now sent via Percent-Charged header.", + level = DeprecationLevel.WARNING, + ) 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 7b815e3..8380409 100644 --- a/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt +++ b/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt @@ -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: Double? = null, ): ApiResult /** diff --git a/app/src/main/java/ink/trmnl/android/network/TrmnlUserApiService.kt b/app/src/main/java/ink/trmnl/android/network/TrmnlUserApiService.kt index dadba14..34f28ed 100644 --- a/app/src/main/java/ink/trmnl/android/network/TrmnlUserApiService.kt +++ b/app/src/main/java/ink/trmnl/android/network/TrmnlUserApiService.kt @@ -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 { /** diff --git a/app/src/main/java/ink/trmnl/android/network/model/TrmnlDeviceUpdateRequest.kt b/app/src/main/java/ink/trmnl/android/network/model/TrmnlDeviceUpdateRequest.kt index f9d5971..c9facc3 100644 --- a/app/src/main/java/ink/trmnl/android/network/model/TrmnlDeviceUpdateRequest.kt +++ b/app/src/main/java/ink/trmnl/android/network/model/TrmnlDeviceUpdateRequest.kt @@ -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,6 +29,10 @@ 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, 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 ea0f862..dacfec9 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 @@ -391,6 +391,10 @@ class AppSettingsPresenter } AppSettingsScreen.Event.ValidateUserToken -> { + // DEPRECATED: User API token validation is no longer needed + // Battery reporting now uses Percent-Charged header instead of user-level API + Timber.d("User token validation skipped - no longer needed for battery reporting") + /* DISABLED - User token no longer needed for battery reporting scope.launch { focusManager.clearFocus() isLoading = true @@ -440,6 +444,7 @@ class AppSettingsPresenter isLoading = false } + */ } AppSettingsScreen.Event.ValidateToken -> { @@ -514,6 +519,10 @@ class AppSettingsPresenter response.refreshIntervalSeconds ?: DEFAULT_REFRESH_INTERVAL_SEC, ) + // DEPRECATED: Device ID fetching no longer needed + // Battery reporting now uses Percent-Charged header instead of user-level API + + /* DISABLED - Device ID no longer needed for battery reporting // For BYOD devices, also fetch and save the device ID if (deviceType == TrmnlDeviceType.BYOD) { val deviceIdResult = displayRepository.getDeviceIdFromApi(deviceConfig) @@ -530,6 +539,7 @@ class AppSettingsPresenter ) } } + */ } else { // No error but also no image URL val errorMessage = response.error ?: "" @@ -813,6 +823,17 @@ fun AppSettingsContent( deviceIdError = (state.validationResult as? ValidationResult.InvalidDeviceMacId)?.message, ) + // + // DEPRECATED: User API Token field is no longer needed + // + // Battery reporting now uses the Percent-Charged header in /api/display call, + // which only requires device-level authentication (Access-Token). + // User-level authentication is no longer needed for BYOD device battery reporting. + // + // This UI has been disabled but kept in code for reference. + // + + /* DISABLED - User API token no longer needed for battery reporting // User API Token field (only for BYOD) AnimatedVisibility( visible = state.deviceType == TrmnlDeviceType.BYOD, @@ -908,6 +929,7 @@ fun AppSettingsContent( } } } + */ Spacer(modifier = Modifier.height(16.dp)) 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 df1d4a2..09460a4 100644 --- a/app/src/main/java/ink/trmnl/android/work/TrmnlImageRefreshWorker.kt +++ b/app/src/main/java/ink/trmnl/android/work/TrmnlImageRefreshWorker.kt @@ -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. 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 1080dde..9fb616c 100644 --- a/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt +++ b/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt @@ -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,6 +601,9 @@ 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 { @@ -628,6 +639,8 @@ class TrmnlDisplayRepositoryTest { } } + // 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 { @@ -646,6 +659,8 @@ 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 { @@ -664,6 +679,8 @@ 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 { @@ -682,6 +699,8 @@ 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 { @@ -715,8 +734,10 @@ class TrmnlDisplayRepositoryTest { 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 +746,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = expectedRssi, + percentCharged = expectedBattery.toDouble(), ) } returns ApiResult.success(mockk(relaxed = true)) @@ -740,6 +762,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = expectedRssi, + percentCharged = expectedBattery.toDouble(), ) } } @@ -754,10 +777,12 @@ class TrmnlDisplayRepositoryTest { 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 +797,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = null, + percentCharged = expectedBattery.toDouble(), ) } } @@ -785,7 +811,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 +826,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = null, + percentCharged = null, ) } } @@ -833,6 +860,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.0, + ) + } 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.0, + ) + } + } + + @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 +1027,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 +1047,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 +1059,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = strongSignal, + percentCharged = expectedBattery.toDouble(), ) } returns ApiResult.success(mockk(relaxed = true)) @@ -885,6 +1074,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = strongSignal, + percentCharged = expectedBattery.toDouble(), ) } } @@ -895,8 +1085,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 +1097,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = weakSignal, + percentCharged = expectedBattery.toDouble(), ) } returns ApiResult.success(mockk(relaxed = true)) @@ -919,6 +1112,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = weakSignal, + percentCharged = expectedBattery.toDouble(), ) } } diff --git a/project-resources/trmnl-api/trmnl-openapi.yaml b/project-resources/trmnl-api/trmnl-openapi.yaml index df7436d..553e41f 100644 --- a/project-resources/trmnl-api/trmnl-openapi.yaml +++ b/project-resources/trmnl-api/trmnl-openapi.yaml @@ -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 From b0958b8564c9933ec2ced07bce2f987efec13d20 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Thu, 5 Feb 2026 19:54:43 -0500 Subject: [PATCH 2/5] refactor: remove deprecated user token and device ID code Complete removal of deprecated fields and methods: - Remove userApiToken and deviceId from TrmnlDeviceConfig - Remove deprecated DataStore methods (saveUserApiToken, getUserApiToken, saveDeviceId, getDeviceId) - Remove userApiToken from AppSettingsScreen state and UI - Remove USER_API_TOKEN_KEY and DEVICE_ID_KEY constants - Add context comments for battery percentage reporting This simplifies the codebase by fully removing unused code paths rather than keeping them as deprecated. --- .../data/TrmnlDeviceConfigDataStore.kt | 100 +------- .../android/data/TrmnlDisplayRepository.kt | 4 + .../trmnl/android/model/TrmnlDeviceConfig.kt | 39 --- .../android/ui/settings/AppSettingsScreen.kt | 239 ------------------ 4 files changed, 7 insertions(+), 375 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 cb11382..bc575b7 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt @@ -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,84 +343,6 @@ class TrmnlDeviceConfigDataStore } } - /** - * Saves the user-level API token (Account API key) - * - * **DEPRECATED:** User API token is no longer needed for battery reporting. - * Battery percentage is now sent via the Percent-Charged header in /api/display call. - */ - @Deprecated( - message = "No longer needed for battery reporting. Battery is now sent via Percent-Charged header.", - level = DeprecationLevel.WARNING, - ) - 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 - * - * **DEPRECATED:** User API token is no longer needed for battery reporting. - * Battery percentage is now sent via the Percent-Charged header in /api/display call. - */ - @Deprecated( - message = "No longer needed for battery reporting. Battery is now sent via Percent-Charged header.", - level = DeprecationLevel.WARNING, - ) - 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). - * - * **DEPRECATED:** Device ID is no longer needed for battery reporting. - * Battery percentage is now sent via the Percent-Charged header in /api/display call. - * - * **Note:** This is only applicable for BYOD device types. - */ - @Deprecated( - message = "No longer needed for battery reporting. Battery is now sent via Percent-Charged header.", - level = DeprecationLevel.WARNING, - ) - 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. - * - * **DEPRECATED:** Device ID is no longer needed for battery reporting. - * Battery percentage is now sent via the Percent-Charged header in /api/display call. - * - * **Note:** This is only applicable for BYOD device types. - */ - @Deprecated( - message = "No longer needed for battery reporting. Battery is now sent via Percent-Charged header.", - level = DeprecationLevel.WARNING, - ) - 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 c5c5995..db6600d 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt @@ -82,6 +82,10 @@ class TrmnlDisplayRepository 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()?.toDouble() } else { null 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 5bdba97..33926a1 100644 --- a/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt +++ b/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt @@ -33,43 +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. - * - * **DEPRECATED:** This field 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 ([apiAccessToken]). - * - * This field is kept for backward compatibility and may be removed in a future version. - * - * 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. - */ - @Deprecated( - message = "No longer needed for battery reporting. Battery is now sent via Percent-Charged header.", - level = DeprecationLevel.WARNING, - ) - val userApiToken: String? = null, - /** - * TRMNL device ID extracted from /api/devices/me endpoint. - * - * **DEPRECATED:** This field is no longer needed for battery reporting. - * Battery percentage is now sent via the Percent-Charged header in /api/display call, - * which doesn't require device ID or user-level authentication. - * - * This field is kept for backward compatibility and may be removed in a future version. - * - * 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. - */ - @Deprecated( - message = "No longer needed for battery reporting. Battery is now sent via Percent-Charged header.", - level = DeprecationLevel.WARNING, - ) - val deviceId: Int? = null, ) 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 dacfec9..1775ee2 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 @@ -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(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,73 +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 -> { - // DEPRECATED: User API token validation is no longer needed - // Battery reporting now uses Percent-Charged header instead of user-level API - Timber.d("User token validation skipped - no longer needed for battery reporting") - /* DISABLED - User token no longer needed for battery reporting - 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() @@ -518,28 +435,6 @@ class AppSettingsPresenter response.imageUrl, response.refreshIntervalSeconds ?: DEFAULT_REFRESH_INTERVAL_SEC, ) - - // DEPRECATED: Device ID fetching no longer needed - // Battery reporting now uses Percent-Charged header instead of user-level API - - /* DISABLED - Device ID no longer needed for battery reporting - // 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 ?: "" @@ -562,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, @@ -579,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) @@ -823,116 +703,6 @@ fun AppSettingsContent( deviceIdError = (state.validationResult as? ValidationResult.InvalidDeviceMacId)?.message, ) - // - // DEPRECATED: User API Token field is no longer needed - // - // Battery reporting now uses the Percent-Charged header in /api/display call, - // which only requires device-level authentication (Access-Token). - // User-level authentication is no longer needed for BYOD device battery reporting. - // - // This UI has been disabled but kept in code for reference. - // - - /* DISABLED - User API token no longer needed for battery reporting - // 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, @@ -1556,7 +1326,6 @@ private fun PreviewAppSettingsContentInitial() { accessToken = "", deviceMacId = "aa:bb:cc:dd:ee:ff", isByodMasterDevice = true, - userApiToken = "", usesFakeApiData = true, isLoading = false, validationResult = null, @@ -1579,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, @@ -1602,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 = @@ -1629,7 +1396,6 @@ private fun PreviewAppSettingsContentFailure() { accessToken = "invalid-token", deviceMacId = "aa:bb:cc:dd:ee:ff", isByodMasterDevice = true, - userApiToken = "", usesFakeApiData = false, isLoading = false, validationResult = @@ -1659,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 @@ -1692,7 +1457,6 @@ private fun PreviewWorkScheduleStatusCardScheduled() { accessToken = "some-token", deviceMacId = "AA:BB:CC:DD:EE:FF", isByodMasterDevice = true, - userApiToken = "", usesFakeApiData = false, nextRefreshJobInfo = NextImageRefreshDisplayInfo( @@ -1719,7 +1483,6 @@ private fun PreviewWorkScheduleStatusCardNoWork() { accessToken = "some-token", deviceMacId = "aa:bb:cc:dd:ee:ff", isByodMasterDevice = true, - userApiToken = "", usesFakeApiData = false, nextRefreshJobInfo = null, eventSink = {}, @@ -1748,7 +1511,6 @@ private fun PreviewAppSettingsContentByod() { accessToken = "byod-access-token-here", deviceMacId = "", isByodMasterDevice = false, - userApiToken = "user_test123", usesFakeApiData = false, isLoading = false, validationResult = null, @@ -1772,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, From 5bf17d8e6695f00f7a17b60c5b57a35b4a6d786d Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Thu, 5 Feb 2026 19:59:08 -0500 Subject: [PATCH 3/5] test: remove references to deleted userApiToken and deviceId fields --- .../data/TrmnlDeviceConfigDataStoreTest.kt | 96 ------------------- .../data/TrmnlDisplayRepositoryTest.kt | 34 +------ 2 files changed, 5 insertions(+), 125 deletions(-) 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 95a9a33..672c99f 100644 --- a/app/src/test/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStoreTest.kt +++ b/app/src/test/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStoreTest.kt @@ -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() - } } 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 9fb616c..0fe8481 100644 --- a/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt +++ b/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt @@ -608,11 +608,7 @@ class TrmnlDisplayRepositoryTest { 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 @@ -645,11 +641,7 @@ class TrmnlDisplayRepositoryTest { 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) @@ -665,11 +657,7 @@ class TrmnlDisplayRepositoryTest { 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) @@ -685,11 +673,7 @@ class TrmnlDisplayRepositoryTest { 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) @@ -705,11 +689,7 @@ class TrmnlDisplayRepositoryTest { 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 @@ -729,8 +709,6 @@ class TrmnlDisplayRepositoryTest { // Arrange val byodConfig = byodDeviceConfig.copy( - deviceId = null, - userApiToken = "test_token", apiAccessToken = "test_api_key", ) val expectedRssi = -65 @@ -773,8 +751,6 @@ class TrmnlDisplayRepositoryTest { // Arrange val byodConfig = byodDeviceConfig.copy( - deviceId = null, - userApiToken = "test_token", apiAccessToken = "test_api_key", ) val expectedBattery = 80 From 834b8fc77ae84313ce1bc1e5faff558e63eb5e02 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Thu, 5 Feb 2026 20:02:47 -0500 Subject: [PATCH 4/5] [MINOR] Fixed messaging for BYOS --- .../java/ink/trmnl/android/ui/settings/DeviceTypeInfoTexts.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/ink/trmnl/android/ui/settings/DeviceTypeInfoTexts.kt b/app/src/main/java/ink/trmnl/android/ui/settings/DeviceTypeInfoTexts.kt index c91adbd..9da7985 100644 --- a/app/src/main/java/ink/trmnl/android/ui/settings/DeviceTypeInfoTexts.kt +++ b/app/src/main/java/ink/trmnl/android/ui/settings/DeviceTypeInfoTexts.kt @@ -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( From 073961f4ae8d8b6fa3c06dd47000bbb30ca68b1f Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Thu, 5 Feb 2026 20:08:25 -0500 Subject: [PATCH 5/5] refactor: change Percent-Charged header from Double to Int - Update TrmnlApiService to accept Int? instead of Double? - Update TrmnlDeviceUpdateRequest percentCharged field to Int? - Remove .toDouble() conversions in TrmnlDisplayRepository - Update test assertions to use Int values - Eliminates unnecessary type conversions since battery level is already an Int --- .../android/data/TrmnlDisplayRepository.kt | 6 +++--- .../trmnl/android/network/TrmnlApiService.kt | 2 +- .../network/model/TrmnlDeviceUpdateRequest.kt | 2 +- .../data/TrmnlDisplayRepositoryTest.kt | 20 +++++++++---------- 4 files changed, 15 insertions(+), 15 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 db6600d..3669002 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt @@ -86,7 +86,7 @@ class TrmnlDisplayRepository // 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()?.toDouble() + androidDeviceInfoProvider.getBatteryLevel() } else { null }, @@ -434,7 +434,7 @@ class TrmnlDisplayRepository sleepModeEnabled = false, sleepStartTime = 1320, sleepEndTime = 480, - percentCharged = 100.0, + percentCharged = 100, wifiStrength = 100.0, ) @@ -567,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 = 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 8380409..9add287 100644 --- a/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt +++ b/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt @@ -105,7 +105,7 @@ interface TrmnlApiService { @Header("ID") deviceMacId: String? = null, @Header("BASE64") useBase64: Boolean? = null, @Header("RSSI") rssi: Int? = null, - @Header("Percent-Charged") percentCharged: Double? = null, + @Header("Percent-Charged") percentCharged: Int? = null, ): ApiResult /** diff --git a/app/src/main/java/ink/trmnl/android/network/model/TrmnlDeviceUpdateRequest.kt b/app/src/main/java/ink/trmnl/android/network/model/TrmnlDeviceUpdateRequest.kt index c9facc3..9e635cb 100644 --- a/app/src/main/java/ink/trmnl/android/network/model/TrmnlDeviceUpdateRequest.kt +++ b/app/src/main/java/ink/trmnl/android/network/model/TrmnlDeviceUpdateRequest.kt @@ -38,5 +38,5 @@ 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, ) 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 0fe8481..1310e70 100644 --- a/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt +++ b/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt @@ -630,7 +630,7 @@ class TrmnlDisplayRepositoryTest { userApiService.updateDevice( fullApiUrl = expectedApiUrl, accessToken = "Bearer user_test_token", - updateRequest = match { it.percentCharged == 75.0 }, + updateRequest = match { it.percentCharged == 75 }, ) } } @@ -724,7 +724,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = expectedRssi, - percentCharged = expectedBattery.toDouble(), + percentCharged = expectedBattery, ) } returns ApiResult.success(mockk(relaxed = true)) @@ -740,7 +740,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = expectedRssi, - percentCharged = expectedBattery.toDouble(), + percentCharged = expectedBattery, ) } } @@ -773,7 +773,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = null, - percentCharged = expectedBattery.toDouble(), + percentCharged = expectedBattery, ) } } @@ -858,7 +858,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = any(), - percentCharged = 75.0, + percentCharged = 75, ) } returns ApiResult.success(mockk(relaxed = true)) @@ -874,7 +874,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = any(), - percentCharged = 75.0, + percentCharged = 75, ) } } @@ -1035,7 +1035,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = strongSignal, - percentCharged = expectedBattery.toDouble(), + percentCharged = expectedBattery, ) } returns ApiResult.success(mockk(relaxed = true)) @@ -1050,7 +1050,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = strongSignal, - percentCharged = expectedBattery.toDouble(), + percentCharged = expectedBattery, ) } } @@ -1073,7 +1073,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = weakSignal, - percentCharged = expectedBattery.toDouble(), + percentCharged = expectedBattery, ) } returns ApiResult.success(mockk(relaxed = true)) @@ -1088,7 +1088,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = weakSignal, - percentCharged = expectedBattery.toDouble(), + percentCharged = expectedBattery, ) } }