From 7976e7750927b5ac91aa87bcd9e5a9cb75d116fb Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 31 Jan 2026 13:21:51 -0500 Subject: [PATCH] feat: Add BYOD device ID fetching and battery reporting (#246) - Add /api/devices/me endpoint to TrmnlApiService for device info retrieval - Add deviceId field to TrmnlDeviceConfig model with documentation - Extend TrmnlDeviceConfigDataStore with deviceId persistence (dual-storage) - Add getDeviceIdFromApi() to repository (mocked until server endpoint exists) - Add getBatteryLevel() and reportBatteryStatus() for battery management - Integrate battery reporting in display fetch methods (non-blocking) - Update AppSettingsScreen to fetch deviceId during BYOD validation - Update TrmnlDisplayRepositoryTest with Context mock dependency Battery reporting is BYOD-only, requires deviceId and userApiToken. Runs asynchronously after successful display fetches without blocking UI. --- .../data/TrmnlDeviceConfigDataStore.kt | 33 +++- .../android/data/TrmnlDisplayRepository.kt | 169 ++++++++++++++++++ .../trmnl/android/model/TrmnlDeviceConfig.kt | 8 + .../trmnl/android/network/TrmnlApiService.kt | 34 ++++ .../android/ui/settings/AppSettingsScreen.kt | 14 ++ .../data/TrmnlDisplayRepositoryTest.kt | 4 + 6 files changed, 261 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt b/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt index dfe7b97..e1577c5 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt @@ -100,6 +100,7 @@ class TrmnlDeviceConfigDataStore private val DEVICE_MAC_ID_KEY = stringPreferencesKey("device_mac_id") private val IS_MASTER_DEVICE_KEY = stringPreferencesKey("is_master_device") private val USER_API_TOKEN_KEY = stringPreferencesKey("user_api_token") + private val DEVICE_ID_KEY = stringPreferencesKey("device_id") private val DEVICE_MODEL_PREFERENCES_KEY = stringPreferencesKey("device_model_preferences") } @@ -261,9 +262,10 @@ class TrmnlDeviceConfigDataStore val deviceMacId = preferences[DEVICE_MAC_ID_KEY] val isMasterDevice = preferences[IS_MASTER_DEVICE_KEY]?.toBoolean() val userApiToken = preferences[USER_API_TOKEN_KEY] + val deviceId = preferences[DEVICE_ID_KEY]?.toIntOrNull() Timber.tag(TAG).d( - "Loading device config (legacy): type=$type, deviceApiToken=${token.obfuscated()}", + "Loading device config (legacy): type=$type, deviceApiToken=${token.obfuscated()}, deviceId=$deviceId", ) if (token != null) { @@ -275,6 +277,7 @@ class TrmnlDeviceConfigDataStore refreshRateSecs = refreshRate, isMasterDevice = isMasterDevice, userApiToken = userApiToken, + deviceId = deviceId, ) } else { null @@ -335,6 +338,11 @@ class TrmnlDeviceConfigDataStore config.userApiToken?.let { userToken -> preferences[USER_API_TOKEN_KEY] = userToken } ?: preferences.remove(USER_API_TOKEN_KEY) + + // Save deviceId if available + config.deviceId?.let { deviceId -> + preferences[DEVICE_ID_KEY] = deviceId.toString() + } ?: preferences.remove(DEVICE_ID_KEY) } Timber.tag(TAG).d("Device config saved successfully") } catch (e: Exception) { @@ -374,6 +382,29 @@ class TrmnlDeviceConfigDataStore return token } + /** + * Saves the device ID (TRMNL device ID from /api/devices/me) + */ + suspend fun saveDeviceId(deviceId: Int) { + Timber.tag(TAG).d("Saving device ID: $deviceId") + context.deviceConfigStore.edit { preferences -> + preferences[DEVICE_ID_KEY] = deviceId.toString() + } + Timber.tag(TAG).d("Device ID saved successfully") + } + + /** + * Gets the device ID + */ + suspend fun getDeviceId(): Int? { + val deviceId = + context.deviceConfigStore.data + .map { preferences -> preferences[DEVICE_ID_KEY]?.toIntOrNull() } + .first() + Timber.tag(TAG).d("Retrieved device ID: $deviceId") + return deviceId + } + /** * Saves the access token */ diff --git a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt index 4cb303f..7f423ee 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt @@ -1,5 +1,7 @@ package ink.trmnl.android.data +import android.content.Context +import android.os.BatteryManager import com.slack.eithernet.ApiResult import com.slack.eithernet.exceptionOrNull import com.squareup.anvil.annotations.optional.SingleIn @@ -7,6 +9,7 @@ import ink.trmnl.android.BuildConfig.USE_FAKE_API import ink.trmnl.android.data.fake.generateFakeDeviceSetupInfo import ink.trmnl.android.data.fake.generateFakeTrmnlDisplayInfo import ink.trmnl.android.di.AppScope +import ink.trmnl.android.di.ApplicationContext import ink.trmnl.android.model.SupportedDeviceModel import ink.trmnl.android.model.TrmnlDeviceConfig import ink.trmnl.android.model.TrmnlDeviceType @@ -15,8 +18,11 @@ import ink.trmnl.android.network.TrmnlApiService.Companion.CURRENT_PLAYLIST_SCRE import ink.trmnl.android.network.TrmnlApiService.Companion.MODELS_API_PATH import ink.trmnl.android.network.TrmnlApiService.Companion.NEXT_PLAYLIST_SCREEN_API_PATH import ink.trmnl.android.network.TrmnlUserApiService +import ink.trmnl.android.network.TrmnlUserApiService.Companion.DEVICE_API_PATH import ink.trmnl.android.network.TrmnlUserApiService.Companion.USER_INFO_API_PATH +import ink.trmnl.android.network.model.TrmnlDevice import ink.trmnl.android.network.model.TrmnlDeviceModel +import ink.trmnl.android.network.model.TrmnlDeviceUpdateRequest import ink.trmnl.android.network.model.TrmnlDisplayResponse import ink.trmnl.android.network.model.TrmnlUser import ink.trmnl.android.network.util.constructApiUrl @@ -24,6 +30,9 @@ import ink.trmnl.android.network.util.extractHttpResponseMetadata import ink.trmnl.android.network.util.extractHttpResponseMetadataFromFailure import ink.trmnl.android.util.HTTP_500 import ink.trmnl.android.util.isHttpOk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -40,11 +49,26 @@ import javax.inject.Inject class TrmnlDisplayRepository @Inject constructor( + @ApplicationContext private val context: Context, private val apiService: TrmnlApiService, private val userApiService: TrmnlUserApiService, private val imageMetadataStore: ImageMetadataStore, private val repositoryConfigProvider: RepositoryConfigProvider, ) { + /** + * Gets the current battery level of the Android device. + * + * @return Battery percentage (0-100), or null if unable to retrieve + */ + private fun getBatteryLevel(): Int? = + try { + val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager + batteryManager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) + } catch (e: Exception) { + Timber.e(e, "Failed to get battery level") + null + } + /** * Fetches display data for next plugin from the server using the provided access token. * If the app is in debug mode, it uses mock data instead. @@ -101,6 +125,19 @@ class TrmnlDisplayRepository displayInfo.imageUrl, displayInfo.refreshIntervalSeconds, ) + + // Report battery status for BYOD devices (non-blocking) + if (trmnlDeviceConfig.type == TrmnlDeviceType.BYOD && + trmnlDeviceConfig.deviceId != null && + trmnlDeviceConfig.userApiToken != null + ) { + val batteryLevel = getBatteryLevel() + if (batteryLevel != null) { + CoroutineScope(Dispatchers.IO).launch { + reportBatteryStatus(trmnlDeviceConfig, batteryLevel) + } + } + } } return displayInfo @@ -161,6 +198,19 @@ class TrmnlDisplayRepository displayInfo.imageUrl, displayInfo.refreshIntervalSeconds, ) + + // Report battery status for BYOD devices (non-blocking) + if (trmnlDeviceConfig.type == TrmnlDeviceType.BYOD && + trmnlDeviceConfig.deviceId != null && + trmnlDeviceConfig.userApiToken != null + ) { + val batteryLevel = getBatteryLevel() + if (batteryLevel != null) { + CoroutineScope(Dispatchers.IO).launch { + reportBatteryStatus(trmnlDeviceConfig, batteryLevel) + } + } + } } return displayInfo @@ -368,4 +418,123 @@ class TrmnlDisplayRepository } } } + + /** + * Fetches the device ID from the TRMNL API using the device API token. + * + * This method calls the /api/devices/me endpoint with device-level authentication + * to retrieve device information including the device ID, which is needed for + * user-level API calls to /api/devices/{id}. + * + * **Note:** This endpoint doesn't exist on the server yet, so this method + * returns a mocked response until the server endpoint is implemented. + * + * @param config Device configuration containing the device API token + * @return A Result containing the device ID on success or an exception on failure + */ + suspend fun getDeviceIdFromApi(config: TrmnlDeviceConfig): Result { + Timber.i("Fetching device ID from API for device type: ${config.type}") + + // Always use mocked response since the endpoint doesn't exist yet + // TODO: Remove this mock when the server endpoint is implemented + val mockedDevice = + TrmnlDevice( + id = 1, + name = "BYOD TRMNL", + friendlyId = "_____", + macAddress = "********", + batteryVoltage = null, + rssi = null, + sleepModeEnabled = false, + sleepStartTime = 1320, + sleepEndTime = 480, + percentCharged = 100.0, + wifiStrength = 100.0, + ) + + Timber.i("Using mocked device ID: ${mockedDevice.id}") + return Result.success(mockedDevice.id) + + /* + * TODO: Uncomment this when the server endpoint is implemented: + * + * val result = apiService.getDeviceMe( + * fullApiUrl = constructApiUrl(config.apiBaseUrl, DEVICE_ME_API_PATH), + * accessToken = config.apiAccessToken, + * ) + * + * return when (result) { + * is ApiResult.Failure -> { + * val exception = result.exceptionOrNull() + * Timber.e(exception, "Failed to fetch device ID") + * Result.failure(exception ?: Exception("Failed to fetch device ID")) + * } + * is ApiResult.Success -> { + * val deviceId = result.value.data.id + * Timber.i("Device ID fetched successfully: $deviceId") + * Result.success(deviceId) + * } + * } + */ + } + + /** + * Reports the device's battery status to the TRMNL API. + * + * This method sends a PATCH request to /api/devices/{id} using user-level authentication + * to update the device's battery percentage on the server. + * + * This operation is non-blocking and should not affect display updates. + * + * @param config Device configuration containing the device ID and user API token + * @param batteryPercent The current battery percentage (0-100) + * @return A Result containing Unit on success or an exception on failure + */ + suspend fun reportBatteryStatus( + config: TrmnlDeviceConfig, + batteryPercent: Int, + ): Result { + val deviceId = config.deviceId + val userApiToken = config.userApiToken + + if (deviceId == null) { + Timber.w("Cannot report battery status: device ID is null") + return Result.failure(IllegalStateException("Device ID is required")) + } + + if (userApiToken == null) { + Timber.w("Cannot report battery status: user API token is null") + return Result.failure(IllegalStateException("User API token is required")) + } + + Timber.d("Reporting battery status: $batteryPercent% for device ID: $deviceId") + + if (repositoryConfigProvider.shouldUseFakeData) { + // Skip API call in debug mode + Timber.d("Skipping battery status report (fake API mode)") + return Result.success(Unit) + } + + val updateRequest = TrmnlDeviceUpdateRequest(percentCharged = batteryPercent.toDouble()) + val apiUrl = constructApiUrl(config.apiBaseUrl, DEVICE_API_PATH.replace("{id}", deviceId.toString())) + + val result = + userApiService.updateDevice( + fullApiUrl = apiUrl, + accessToken = "Bearer $userApiToken", + updateRequest = updateRequest, + ) + + return when (result) { + is ApiResult.Failure -> { + val exception = result.exceptionOrNull() + Timber.e(exception, "Failed to report battery status") + Result.failure(exception ?: Exception("Failed to report battery status")) + } + is ApiResult.Success -> { + Timber.d("Battery status reported successfully") + Result.success(Unit) + } + } + } } diff --git a/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt b/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt index 65a098f..9c8a513 100644 --- a/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt +++ b/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt @@ -40,4 +40,12 @@ data class TrmnlDeviceConfig constructor( * This is separate from [apiAccessToken] which is the device-level API key. */ val userApiToken: String? = null, + /** + * TRMNL device ID extracted from /api/devices/me endpoint. + * Used for making user-level API calls to /api/devices/{id}. + * + * This ID is fetched during BYOD device validation and is required for + * reporting battery status and other device-specific updates. + */ + val deviceId: Int? = null, ) diff --git a/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt b/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt index a5f36c7..faf02f6 100644 --- a/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt +++ b/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt @@ -3,6 +3,7 @@ package ink.trmnl.android.network import com.slack.eithernet.ApiResult import ink.trmnl.android.data.TrmnlDisplayRepository import ink.trmnl.android.network.model.TrmnlCurrentImageResponse +import ink.trmnl.android.network.model.TrmnlDeviceResponse import ink.trmnl.android.network.model.TrmnlDisplayResponse import ink.trmnl.android.network.model.TrmnlModelsResponse import ink.trmnl.android.network.model.TrmnlSetupResponse @@ -65,6 +66,18 @@ interface TrmnlApiService { * @see getDeviceModels */ internal const val MODELS_API_PATH = "api/models" + + /** + * Path for the TRMNL API endpoint to get the device information. + * + * **Authentication:** Requires device-level Access-Token header + * + * **Note:** This endpoint doesn't exist on the server yet. The repository layer + * provides a mocked response until the server endpoint is implemented. + * + * @see getDeviceMe + */ + internal const val DEVICE_ME_API_PATH = "api/devices/me" } /** @@ -132,4 +145,25 @@ interface TrmnlApiService { suspend fun getDeviceModels( @Url fullApiUrl: String, ): ApiResult + + /** + * Retrieve device information using [DEVICE_ME_API_PATH]. + * + * This endpoint provides device details including the device ID, which is needed + * for making user-level API calls to `/api/devices/{id}`. + * + * **Authentication:** Requires device-level Access-Token header (device API key) + * + * **Note:** This endpoint doesn't exist on the server yet. The repository layer + * provides a mocked response until the server endpoint is implemented. + * + * @param fullApiUrl The complete API URL to call (e.g., "https://usetrmnl.com/api/devices/me") + * @param accessToken The device's API key (required) + * @return An [ApiResult] containing [TrmnlDeviceResponse] with the device information + */ + @GET + suspend fun getDeviceMe( + @Url fullApiUrl: String, + @Header("access-token") accessToken: String, + ): ApiResult } diff --git a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt index 9e04bc3..20a2b11 100644 --- a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt +++ b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt @@ -513,6 +513,20 @@ class AppSettingsPresenter response.imageUrl, response.refreshIntervalSeconds ?: DEFAULT_REFRESH_INTERVAL_SEC, ) + + // For BYOD devices, also fetch and save the device ID + if (deviceType == TrmnlDeviceType.BYOD) { + val deviceIdResult = displayRepository.getDeviceIdFromApi(deviceConfig) + if (deviceIdResult.isSuccess) { + val deviceId = deviceIdResult.getOrNull() + if (deviceId != null) { + deviceConfigStore.saveDeviceId(deviceId) + Timber.d("Device ID saved successfully for BYOD device: $deviceId") + } + } else { + Timber.w("Failed to fetch device ID for BYOD device", deviceIdResult.exceptionOrNull()) + } + } } else { // No error but also no image URL val errorMessage = response.error ?: "" diff --git a/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt b/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt index fcd71b0..5bb73df 100644 --- a/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt +++ b/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt @@ -1,5 +1,6 @@ package ink.trmnl.android.data +import android.content.Context import com.google.common.truth.Truth.assertThat import com.slack.eithernet.ApiResult import ink.trmnl.android.model.TrmnlDeviceConfig @@ -28,6 +29,7 @@ import org.junit.Test @OptIn(com.slack.eithernet.InternalEitherNetApi::class) class TrmnlDisplayRepositoryTest { private lateinit var repository: TrmnlDisplayRepository + private lateinit var context: Context private lateinit var apiService: TrmnlApiService private lateinit var userApiService: TrmnlUserApiService private lateinit var imageMetadataStore: ImageMetadataStore @@ -60,6 +62,7 @@ class TrmnlDisplayRepositoryTest { @Before fun setup() { + context = mockk(relaxed = true) apiService = mockk() userApiService = mockk() repositoryConfigProvider = mockk() @@ -70,6 +73,7 @@ class TrmnlDisplayRepositoryTest { repository = TrmnlDisplayRepository( + context = context, apiService = apiService, userApiService = userApiService, imageMetadataStore = imageMetadataStore,