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..c11b629 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,33 @@ class TrmnlDeviceConfigDataStore return token } + /** + * Saves the device ID (TRMNL device ID from /api/devices/me). + * + * **Note:** This is only applicable for BYOD device types. + */ + suspend fun saveDeviceId(deviceId: Int) { + Timber.tag(TAG).d("Saving device ID: $deviceId") + context.deviceConfigStore.edit { preferences -> + preferences[DEVICE_ID_KEY] = deviceId.toString() + } + Timber.tag(TAG).d("Device ID saved successfully") + } + + /** + * Gets the device ID. + * + * **Note:** This is only applicable for BYOD device types. + */ + suspend fun getDeviceId(): Int? { + val deviceId = + context.deviceConfigStore.data + .map { preferences -> preferences[DEVICE_ID_KEY]?.toIntOrNull() } + .first() + Timber.tag(TAG).d("Retrieved device ID: $deviceId") + return deviceId + } + /** * Saves the access token */ 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..76401ff 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt @@ -15,13 +15,17 @@ import ink.trmnl.android.network.TrmnlApiService.Companion.CURRENT_PLAYLIST_SCRE import ink.trmnl.android.network.TrmnlApiService.Companion.MODELS_API_PATH import ink.trmnl.android.network.TrmnlApiService.Companion.NEXT_PLAYLIST_SCREEN_API_PATH import ink.trmnl.android.network.TrmnlUserApiService +import ink.trmnl.android.network.TrmnlUserApiService.Companion.DEVICE_API_PATH import ink.trmnl.android.network.TrmnlUserApiService.Companion.USER_INFO_API_PATH +import ink.trmnl.android.network.model.TrmnlDevice import ink.trmnl.android.network.model.TrmnlDeviceModel +import ink.trmnl.android.network.model.TrmnlDeviceUpdateRequest import ink.trmnl.android.network.model.TrmnlDisplayResponse import ink.trmnl.android.network.model.TrmnlUser import ink.trmnl.android.network.util.constructApiUrl import ink.trmnl.android.network.util.extractHttpResponseMetadata import ink.trmnl.android.network.util.extractHttpResponseMetadataFromFailure +import ink.trmnl.android.util.AndroidDeviceInfoProvider import ink.trmnl.android.util.HTTP_500 import ink.trmnl.android.util.isHttpOk import timber.log.Timber @@ -44,6 +48,7 @@ class TrmnlDisplayRepository private val userApiService: TrmnlUserApiService, private val imageMetadataStore: ImageMetadataStore, private val repositoryConfigProvider: RepositoryConfigProvider, + private val androidDeviceInfoProvider: AndroidDeviceInfoProvider, ) { /** * Fetches display data for next plugin from the server using the provided access token. @@ -368,4 +373,170 @@ class TrmnlDisplayRepository } } } + + /** + * Fetches the device ID from the TRMNL API using the device API token. + * + * This method calls the /api/devices/me endpoint with device-level authentication + * to retrieve device information including the device ID, which is needed for + * user-level API calls to /api/devices/{id}. + * + * **Note:** This endpoint doesn't exist on the server yet, so this method + * returns a mocked response until the server endpoint is implemented. + * + * @param config Device configuration containing the device API token + * @return A Result containing the device ID on success or an exception on failure + */ + suspend fun getDeviceIdFromApi(config: TrmnlDeviceConfig): Result { + Timber.i("Fetching device ID from API for device type: ${config.type}") + + // Always use mocked response since the endpoint doesn't exist yet + // TODO: Remove this mock when the server endpoint is implemented + val mockedDevice = + TrmnlDevice( + id = 41448, + name = "BYOD TRMNL", + friendlyId = "_____", + macAddress = "********", + batteryVoltage = null, + rssi = null, + sleepModeEnabled = false, + sleepStartTime = 1320, + sleepEndTime = 480, + percentCharged = 100.0, + wifiStrength = 100.0, + ) + + Timber.i("Using mocked device ID: ${mockedDevice.id}") + return Result.success(mockedDevice.id) + + /* + * TODO: Uncomment this when the server endpoint is implemented: + * + * val result = apiService.getDeviceMe( + * fullApiUrl = constructApiUrl(config.apiBaseUrl, DEVICE_ME_API_PATH), + * accessToken = config.apiAccessToken, + * ) + * + * return when (result) { + * is ApiResult.Failure -> { + * val exception = result.exceptionOrNull() + * Timber.e(exception, "Failed to fetch device ID") + * Result.failure(exception ?: Exception("Failed to fetch device ID")) + * } + * is ApiResult.Success -> { + * val deviceId = result.value.data.id + * Timber.i("Device ID fetched successfully: $deviceId") + * Result.success(deviceId) + * } + * } + */ + } + + /** + * Reports the device's battery status to the TRMNL API for BYOD devices. + * + * This is a convenience method that checks if the device is a BYOD device with the necessary + * configuration (deviceId and userApiToken), retrieves the current battery level, + * and reports it to the server. + * + * This method should be called after successful image refresh operations. + * + * @param config Device configuration containing device type, device ID, and user API token + */ + suspend fun reportDeviceBatteryStatus(config: TrmnlDeviceConfig) { + // Only report battery for BYOD devices with required configuration + if (config.type != TrmnlDeviceType.BYOD) { + Timber.d("Battery reporting skipped: not a BYOD device (type: ${config.type})") + return + } + + if (config.deviceId == null) { + Timber.w("Battery reporting skipped: device ID is null") + return + } + + if (config.userApiToken == null) { + Timber.w("Battery reporting skipped: user API token is null") + return + } + + // Get current battery level + val batteryLevel = androidDeviceInfoProvider.getBatteryLevel() + if (batteryLevel == null) { + Timber.w("Battery reporting skipped: unable to get battery level") + return + } + + // Report battery status + try { + val result = reportBatteryStatus(config, batteryLevel) + result.onFailure { throwable -> + Timber.e(throwable, "Failed to report battery status") + } + } catch (e: Exception) { + Timber.e(e, "Unexpected error during battery reporting") + } + } + + /** + * Reports the device's battery status to the TRMNL API. + * + * This method sends a PATCH request to /api/devices/{id} using user-level authentication + * to update the device's battery percentage on the server. + * + * This suspend function performs network I/O and should be called from a background + * coroutine so it does not block or delay display updates. + * + * @param config Device configuration containing the device ID and user API token + * @param batteryPercent The current battery percentage (0-100) + * @return A Result containing Unit on success or an exception on failure + */ + private suspend fun reportBatteryStatus( + config: TrmnlDeviceConfig, + batteryPercent: Int, + ): Result { + 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..4c7f89e 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,14 @@ data class TrmnlDeviceConfig constructor( * This is separate from [apiAccessToken] which is the device-level API key. */ val userApiToken: String? = null, + /** + * TRMNL device ID extracted from /api/devices/me endpoint. + * Used for making user-level API calls to /api/devices/{id}. + * + * This ID is fetched during BYOD device validation and is required for + * reporting battery status and other device-specific updates. + * + * **Note:** This field is only applicable for BYOD device types. + */ + val deviceId: Int? = null, ) 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..f8cf661 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,20 @@ interface TrmnlApiService { * @see getDeviceModels */ internal const val MODELS_API_PATH = "api/models" + + /** + * Path for the TRMNL API endpoint to get the device information. + * + * **Authentication:** Requires device-level Access-Token header + * + * **Note:** This endpoint doesn't exist on the server yet. The repository layer + * provides a mocked response until the server endpoint is implemented. + * + * See https://discord.com/channels/1281055965508141100/1466924426460397765 + * + * @see getDeviceMe + */ + internal const val DEVICE_ME_API_PATH = "api/devices/me" } /** @@ -132,4 +147,27 @@ interface TrmnlApiService { suspend fun getDeviceModels( @Url fullApiUrl: String, ): ApiResult + + /** + * Retrieve device information using [DEVICE_ME_API_PATH]. + * + * This endpoint provides device details including the device ID, which is needed + * for making user-level API calls to `/api/devices/{id}`. + * + * **Authentication:** Requires device-level Access-Token header (device API key) + * + * **Note:** This endpoint doesn't exist on the server yet. The repository layer + * provides a mocked response until the server endpoint is implemented. + * + * See https://discord.com/channels/1281055965508141100/1466924426460397765 + * + * @param fullApiUrl The complete API URL to call (e.g., "https://usetrmnl.com/api/devices/me") + * @param accessToken The device's API key (required) + * @return An [ApiResult] containing [TrmnlDeviceResponse] with the device information + */ + @GET + suspend fun getDeviceMe( + @Url fullApiUrl: String, + @Header("access-token") accessToken: String, + ): ApiResult } 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..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 @@ -513,6 +513,23 @@ class AppSettingsPresenter response.imageUrl, response.refreshIntervalSeconds ?: DEFAULT_REFRESH_INTERVAL_SEC, ) + + // For BYOD devices, also fetch and save the device ID + if (deviceType == TrmnlDeviceType.BYOD) { + val deviceIdResult = displayRepository.getDeviceIdFromApi(deviceConfig) + if (deviceIdResult.isSuccess) { + val deviceId = deviceIdResult.getOrNull() + if (deviceId != null) { + deviceConfigStore.saveDeviceId(deviceId) + Timber.d("Device ID saved successfully for BYOD device: $deviceId") + } + } else { + Timber.w( + "Failed to fetch device ID for BYOD device. Error: %s", + deviceIdResult.exceptionOrNull(), + ) + } + } } else { // No error but also no image URL val errorMessage = response.error ?: "" @@ -535,6 +552,14 @@ class AppSettingsPresenter TrmnlDeviceType.TRMNL -> false } + // For BYOD devices, retrieve the device ID that was fetched during validation + val deviceId = + if (deviceType == TrmnlDeviceType.BYOD) { + deviceConfigStore.getDeviceId() + } else { + null + } + deviceConfigStore.saveDeviceConfig( TrmnlDeviceConfig( type = deviceType, @@ -549,6 +574,8 @@ class AppSettingsPresenter // We still persist the token here; any invalid or expired token will be // detected and surfaced via downstream API error handling. userApiToken = userApiToken.ifBlank { null }, + // Include device ID for BYOD devices (fetched during validation) + deviceId = deviceId, ), ) trmnlWorkScheduler.updateRefreshInterval(result.refreshRateSecs) 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/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 fcd71b0..2fa7245 100644 --- a/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt +++ b/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt @@ -9,6 +9,7 @@ import ink.trmnl.android.network.TrmnlUserApiService import ink.trmnl.android.network.model.TrmnlCurrentImageResponse import ink.trmnl.android.network.model.TrmnlDisplayResponse import ink.trmnl.android.network.util.constructApiUrl +import ink.trmnl.android.util.AndroidDeviceInfoProvider import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -33,6 +34,7 @@ class TrmnlDisplayRepositoryTest { private lateinit var imageMetadataStore: ImageMetadataStore private lateinit var repositoryConfigProvider: RepositoryConfigProvider private lateinit var deviceConfigDataStore: TrmnlDeviceConfigDataStore + private lateinit var androidDeviceInfoProvider: AndroidDeviceInfoProvider private val testDeviceConfig = TrmnlDeviceConfig( @@ -65,6 +67,7 @@ class TrmnlDisplayRepositoryTest { repositoryConfigProvider = mockk() deviceConfigDataStore = mockk() imageMetadataStore = mockk(relaxed = true) + androidDeviceInfoProvider = mockk(relaxed = true) every { repositoryConfigProvider.shouldUseFakeData } returns false @@ -74,6 +77,7 @@ class TrmnlDisplayRepositoryTest { userApiService = userApiService, imageMetadataStore = imageMetadataStore, repositoryConfigProvider = repositoryConfigProvider, + androidDeviceInfoProvider = androidDeviceInfoProvider, ) } @@ -567,4 +571,127 @@ class TrmnlDisplayRepositoryTest { assertThat(result.httpResponseMetadata?.statusCode).isEqualTo(500) assertThat(result.httpResponseMetadata?.contentLength).isEqualTo(123L) } + + @Test + fun `getDeviceIdFromApi should return mocked device ID`() = + runTest { + // Act + val result = repository.getDeviceIdFromApi(byodDeviceConfig) + + // Assert + assertThat(result.isSuccess).isTrue() + assertThat(result.getOrNull()).isEqualTo(41448) + + // Verify API was NOT called since we're using mocked response + coVerify(exactly = 0) { apiService.getDeviceMe(any(), any()) } + } + + @Test + fun `reportDeviceBatteryStatus should report battery for valid BYOD config`() = + runTest { + // Arrange + val byodConfigWithDeviceId = + byodDeviceConfig.copy( + deviceId = 123, + userApiToken = "user_test_token", + ) + + every { androidDeviceInfoProvider.getBatteryLevel() } returns 75 + + val expectedApiUrl = "https://server.example.com/api/devices/123" + + coEvery { + userApiService.updateDevice( + fullApiUrl = expectedApiUrl, + accessToken = "Bearer user_test_token", + updateRequest = any(), + ) + } returns ApiResult.success(mockk(relaxed = true)) + + // Act + repository.reportDeviceBatteryStatus(byodConfigWithDeviceId) + + // Assert - Verify battery status was reported + coVerify { + userApiService.updateDevice( + fullApiUrl = expectedApiUrl, + accessToken = "Bearer user_test_token", + updateRequest = match { it.percentCharged == 75.0 }, + ) + } + } + + @Test + fun `reportDeviceBatteryStatus should skip for non-BYOD device`() = + runTest { + // Arrange - TRMNL device (not BYOD) + val trmnlConfig = + testDeviceConfig.copy( + deviceId = 123, + userApiToken = "user_test_token", + ) + + // Act + repository.reportDeviceBatteryStatus(trmnlConfig) + + // Assert - Verify API was NOT called + coVerify(exactly = 0) { userApiService.updateDevice(any(), any(), any()) } + coVerify(exactly = 0) { androidDeviceInfoProvider.getBatteryLevel() } + } + + @Test + fun `reportDeviceBatteryStatus should skip when deviceId is null`() = + runTest { + // Arrange + val configWithoutDeviceId = + byodDeviceConfig.copy( + deviceId = null, + userApiToken = "user_test_token", + ) + + // Act + repository.reportDeviceBatteryStatus(configWithoutDeviceId) + + // Assert - Verify API was NOT called + coVerify(exactly = 0) { userApiService.updateDevice(any(), any(), any()) } + coVerify(exactly = 0) { androidDeviceInfoProvider.getBatteryLevel() } + } + + @Test + fun `reportDeviceBatteryStatus should skip when userApiToken is null`() = + runTest { + // Arrange + val configWithoutUserToken = + byodDeviceConfig.copy( + deviceId = 123, + userApiToken = null, + ) + + // Act + repository.reportDeviceBatteryStatus(configWithoutUserToken) + + // Assert - Verify API was NOT called + coVerify(exactly = 0) { userApiService.updateDevice(any(), any(), any()) } + coVerify(exactly = 0) { androidDeviceInfoProvider.getBatteryLevel() } + } + + @Test + fun `reportDeviceBatteryStatus should skip when battery level unavailable`() = + runTest { + // Arrange + val byodConfigWithDeviceId = + byodDeviceConfig.copy( + deviceId = 123, + userApiToken = "user_test_token", + ) + + every { androidDeviceInfoProvider.getBatteryLevel() } returns null + + // Act + repository.reportDeviceBatteryStatus(byodConfigWithDeviceId) + + // Assert - Verify battery level was requested but API was NOT called + coVerify(exactly = 1) { androidDeviceInfoProvider.getBatteryLevel() } + coVerify(exactly = 0) { userApiService.updateDevice(any(), any(), any()) } + } }