From 17bc9de8ca648f8d30fc960e3c3283a1b21adaf3 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 31 Jan 2026 21:10:59 -0500 Subject: [PATCH 1/3] feat: Add WiFi signal strength (RSSI) reporting for BYOD devices - Add getWifiSignalStrength() method to AndroidDeviceInfoProvider to retrieve WiFi RSSI in dBm - Add RSSI header parameter to TrmnlApiService.getNextDisplayData() API call - Update TrmnlDisplayRepository to fetch and send WiFi signal strength to TRMNL API for BYOD devices - Add ACCESS_WIFI_STATE permission to AndroidManifest.xml - RSSI values match firmware implementation (dBm scale, -100 to 0) - Only sends RSSI for BYOD device type to match firmware behavior This enables the TRMNL API to receive WiFi signal strength data from Android devices for monitoring and diagnostics, matching the functionality of the TRMNL firmware devices. --- app/src/main/AndroidManifest.xml | 1 + .../android/data/TrmnlDisplayRepository.kt | 7 +++++ .../trmnl/android/network/TrmnlApiService.kt | 6 ++++ .../android/util/AndroidDeviceInfoProvider.kt | 30 ++++++++++++++++++- 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f020645..6c78770 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + /** @@ -107,6 +111,8 @@ interface TrmnlApiService { * * NOTE: This API always loads the current plugin image from the playlist. * + * See [API Doc](https://docs.trmnl.com/go/private-api/screens) for additional details. + * * @see getNextDisplayData */ @GET diff --git a/app/src/main/java/ink/trmnl/android/util/AndroidDeviceInfoProvider.kt b/app/src/main/java/ink/trmnl/android/util/AndroidDeviceInfoProvider.kt index 933fb34..610a199 100644 --- a/app/src/main/java/ink/trmnl/android/util/AndroidDeviceInfoProvider.kt +++ b/app/src/main/java/ink/trmnl/android/util/AndroidDeviceInfoProvider.kt @@ -1,6 +1,7 @@ package ink.trmnl.android.util import android.content.Context +import android.net.wifi.WifiManager import android.os.BatteryManager import com.squareup.anvil.annotations.optional.SingleIn import ink.trmnl.android.di.AppScope @@ -12,7 +13,7 @@ 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. + * such as battery level and WiFi signal strength, which can be used for reporting to the TRMNL API. */ @SingleIn(AppScope::class) class AndroidDeviceInfoProvider @@ -36,4 +37,31 @@ class AndroidDeviceInfoProvider Timber.e(e, "Failed to get battery level") null } + + /** + * Gets the current WiFi signal strength (RSSI) of the Android device. + * + * RSSI (Received Signal Strength Indicator) is measured in dBm and typically + * ranges from -100 (weakest) to 0 (strongest). + * + * @return WiFi signal strength in dBm, or null if unable to retrieve or WiFi is not connected + */ + @Suppress("DEPRECATION") // WifiInfo.rssi is still the standard way to get signal strength + fun getWifiSignalStrength(): Int? = + try { + val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager + val wifiInfo = wifiManager?.connectionInfo + val rssi = wifiInfo?.rssi + + if (rssi != null && rssi != -127) { // -127 (`INVALID_RSSI`) indicates no signal + Timber.i("Current WiFi signal strength (RSSI): $rssi dBm") + rssi + } else { + Timber.d("WiFi not connected or signal unavailable") + null + } + } catch (e: Exception) { + Timber.e(e, "Failed to get WiFi signal strength") + null + } } From 49a38d6182050158683b5ce40a4fed49c5c47c14 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 31 Jan 2026 21:28:28 -0500 Subject: [PATCH 2/3] test: Fix unit tests by adding rssi parameter to API mocks - Add rssi parameter to all getNextDisplayData() mock calls - Fixes failing testDebugUnitTest task on CI --- .../ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt | 7 +++++++ 1 file changed, 7 insertions(+) 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 2fa7245..dda1e18 100644 --- a/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt +++ b/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt @@ -109,6 +109,7 @@ class TrmnlDisplayRepositoryTest { fullApiUrl = expectedNextApiUrl, accessToken = testDeviceConfig.apiAccessToken, useBase64 = any(), + rssi = any(), ) } returns ApiResult.success(successResponse) @@ -150,6 +151,7 @@ class TrmnlDisplayRepositoryTest { fullApiUrl = expectedNextApiUrl, accessToken = testDeviceConfig.apiAccessToken, useBase64 = any(), + rssi = any(), ) } returns ApiResult.success(errorResponse) @@ -360,6 +362,7 @@ class TrmnlDisplayRepositoryTest { fullApiUrl = expectedNextApiUrl, accessToken = byosDeviceConfig.apiAccessToken, useBase64 = any(), + rssi = any(), ) } returns ApiResult.success(successResponse) @@ -377,6 +380,7 @@ class TrmnlDisplayRepositoryTest { fullApiUrl = expectedNextApiUrl, accessToken = byosDeviceConfig.apiAccessToken, useBase64 = any(), + rssi = any(), ) } } @@ -405,6 +409,7 @@ class TrmnlDisplayRepositoryTest { fullApiUrl = expectedNextApiUrl, accessToken = byodDeviceConfig.apiAccessToken, useBase64 = any(), + rssi = any(), ) } returns ApiResult.success(successResponse) @@ -422,6 +427,7 @@ class TrmnlDisplayRepositoryTest { fullApiUrl = expectedNextApiUrl, accessToken = byodDeviceConfig.apiAccessToken, useBase64 = any(), + rssi = any(), ) } } @@ -502,6 +508,7 @@ class TrmnlDisplayRepositoryTest { fullApiUrl = expectedNextApiUrl, accessToken = testDeviceConfig.apiAccessToken, useBase64 = any(), + rssi = any(), ) } returns httpFailure From 662641fa390060e7408eb3e2b13faf4f0dc70c48 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Sat, 31 Jan 2026 21:38:30 -0500 Subject: [PATCH 3/3] test: Add comprehensive tests for WiFi signal strength (RSSI) feature - Add AndroidDeviceInfoProviderTest.kt with 14 unit tests * Tests for getWifiSignalStrength() method covering: - Valid RSSI values (strong, medium, weak signals) - Null handling (WiFi manager unavailable, connection info null, RSSI -127) - Exception handling * Tests for getBatteryLevel() method covering: - Valid battery percentages (0%, 85%, 100%) - Null handling (battery manager unavailable) - Exception handling - Add 8 integration tests to TrmnlDisplayRepositoryTest.kt * Verify RSSI is sent only for BYOD devices * Verify RSSI is NOT sent for TRMNL and BYOS devices * Test RSSI with various signal strengths (-30, -65, -90 dBm) * Test null RSSI handling when WiFi unavailable * Verify getWifiSignalStrength() is called only for BYOD Total: 22 new tests added (199 tests total, up from 178) All tests passing with no failures. --- .../data/TrmnlDisplayRepositoryTest.kt | 221 +++++++++++++++++ .../util/AndroidDeviceInfoProviderTest.kt | 232 ++++++++++++++++++ 2 files changed, 453 insertions(+) create mode 100644 app/src/test/java/ink/trmnl/android/util/AndroidDeviceInfoProviderTest.kt 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 dda1e18..1080dde 100644 --- a/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt +++ b/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt @@ -701,4 +701,225 @@ class TrmnlDisplayRepositoryTest { coVerify(exactly = 1) { androidDeviceInfoProvider.getBatteryLevel() } coVerify(exactly = 0) { userApiService.updateDevice(any(), any(), any()) } } + + // WiFi Signal Strength (RSSI) Tests + + @Test + fun `getNextDisplayData should send RSSI for BYOD device when WiFi available`() = + runTest { + // Arrange + val byodConfig = + byodDeviceConfig.copy( + deviceId = null, + userApiToken = "test_token", + apiAccessToken = "test_api_key", + ) + val expectedRssi = -65 + + every { androidDeviceInfoProvider.getWifiSignalStrength() } returns expectedRssi + + coEvery { + apiService.getNextDisplayData( + fullApiUrl = any(), + accessToken = any(), + deviceMacId = any(), + useBase64 = any(), + rssi = expectedRssi, + ) + } returns ApiResult.success(mockk(relaxed = true)) + + // Act + repository.getNextDisplayData(byodConfig) + + // Assert - Verify RSSI was fetched and sent + coVerify(exactly = 1) { androidDeviceInfoProvider.getWifiSignalStrength() } + coVerify { + apiService.getNextDisplayData( + fullApiUrl = any(), + accessToken = any(), + deviceMacId = any(), + useBase64 = any(), + rssi = expectedRssi, + ) + } + } + + @Test + fun `getNextDisplayData should send null RSSI for BYOD when WiFi unavailable`() = + runTest { + // Arrange + val byodConfig = + byodDeviceConfig.copy( + deviceId = null, + userApiToken = "test_token", + apiAccessToken = "test_api_key", + ) + + every { androidDeviceInfoProvider.getWifiSignalStrength() } returns null + + coEvery { apiService.getNextDisplayData(any(), any(), any(), any()) } returns + ApiResult.success(mockk(relaxed = true)) + + // Act + repository.getNextDisplayData(byodConfig) + + // Assert - Verify RSSI was fetched but null was sent + coVerify(exactly = 1) { androidDeviceInfoProvider.getWifiSignalStrength() } + coVerify { + apiService.getNextDisplayData( + fullApiUrl = any(), + accessToken = any(), + deviceMacId = any(), + useBase64 = any(), + rssi = null, + ) + } + } + + @Test + fun `getNextDisplayData should NOT send RSSI for TRMNL device`() = + runTest { + // Arrange - TRMNL device (not BYOD) + val trmnlConfig = + testDeviceConfig.copy( + apiAccessToken = "trmnl_api_key", + ) + + coEvery { apiService.getNextDisplayData(any(), any(), any(), any()) } returns + ApiResult.success(mockk(relaxed = true)) + + // Act + repository.getNextDisplayData(trmnlConfig) + + // Assert - Verify WiFi signal was NOT fetched and null RSSI was sent + coVerify(exactly = 0) { androidDeviceInfoProvider.getWifiSignalStrength() } + coVerify { + apiService.getNextDisplayData( + fullApiUrl = any(), + accessToken = any(), + deviceMacId = any(), + useBase64 = any(), + rssi = null, + ) + } + } + + @Test + fun `getNextDisplayData should NOT send RSSI for BYOS device`() = + runTest { + // Arrange - BYOS device uses next display data endpoint (not current_screen) + val byosConfig = + byosDeviceConfig.copy( + apiAccessToken = "byos_api_key", + ) + + coEvery { apiService.getNextDisplayData(any(), any(), any(), any(), any()) } returns + ApiResult.success(mockk(relaxed = true)) + + // Act + repository.getNextDisplayData(byosConfig) + + // Assert - Verify WiFi signal was NOT fetched for BYOS device + coVerify(exactly = 0) { androidDeviceInfoProvider.getWifiSignalStrength() } + // Verify null RSSI was sent + coVerify { + apiService.getNextDisplayData( + fullApiUrl = any(), + accessToken = any(), + deviceMacId = any(), + useBase64 = any(), + rssi = null, + ) + } + } + + @Test + fun `getNextDisplayData should call getWifiSignalStrength only for BYOD devices`() = + runTest { + // Arrange - Multiple device types + val byodConfig = byodDeviceConfig.copy(apiAccessToken = "byod_key") + val trmnlConfig = testDeviceConfig.copy(apiAccessToken = "trmnl_key") + val byosConfig = byosDeviceConfig.copy(apiAccessToken = "byos_key") + + every { androidDeviceInfoProvider.getWifiSignalStrength() } returns -70 + + coEvery { apiService.getNextDisplayData(any(), any(), any(), any(), any()) } returns + ApiResult.success(mockk(relaxed = true)) + + // Act - Fetch for all device types + repository.getNextDisplayData(byodConfig) + repository.getNextDisplayData(trmnlConfig) + repository.getNextDisplayData(byosConfig) + + // Assert - Verify WiFi signal was called only once (for BYOD) + coVerify(exactly = 1) { androidDeviceInfoProvider.getWifiSignalStrength() } + } + + @Test + fun `getNextDisplayData should include RSSI in header for BYOD with strong signal`() = + runTest { + // Arrange + val byodConfig = byodDeviceConfig.copy(apiAccessToken = "test_key") + val strongSignal = -30 // Excellent signal + + every { androidDeviceInfoProvider.getWifiSignalStrength() } returns strongSignal + + coEvery { + apiService.getNextDisplayData( + fullApiUrl = any(), + accessToken = any(), + deviceMacId = any(), + useBase64 = any(), + rssi = strongSignal, + ) + } returns ApiResult.success(mockk(relaxed = true)) + + // Act + repository.getNextDisplayData(byodConfig) + + // Assert + coVerify { + apiService.getNextDisplayData( + fullApiUrl = any(), + accessToken = any(), + deviceMacId = any(), + useBase64 = any(), + rssi = strongSignal, + ) + } + } + + @Test + fun `getNextDisplayData should include RSSI in header for BYOD with weak signal`() = + runTest { + // Arrange + val byodConfig = byodDeviceConfig.copy(apiAccessToken = "test_key") + val weakSignal = -90 // Very weak signal + + every { androidDeviceInfoProvider.getWifiSignalStrength() } returns weakSignal + + coEvery { + apiService.getNextDisplayData( + fullApiUrl = any(), + accessToken = any(), + deviceMacId = any(), + useBase64 = any(), + rssi = weakSignal, + ) + } returns ApiResult.success(mockk(relaxed = true)) + + // Act + repository.getNextDisplayData(byodConfig) + + // Assert + coVerify { + apiService.getNextDisplayData( + fullApiUrl = any(), + accessToken = any(), + deviceMacId = any(), + useBase64 = any(), + rssi = weakSignal, + ) + } + } } diff --git a/app/src/test/java/ink/trmnl/android/util/AndroidDeviceInfoProviderTest.kt b/app/src/test/java/ink/trmnl/android/util/AndroidDeviceInfoProviderTest.kt new file mode 100644 index 0000000..c59f2e5 --- /dev/null +++ b/app/src/test/java/ink/trmnl/android/util/AndroidDeviceInfoProviderTest.kt @@ -0,0 +1,232 @@ +package ink.trmnl.android.util + +import android.content.Context +import android.net.wifi.WifiInfo +import android.net.wifi.WifiManager +import android.os.BatteryManager +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for [AndroidDeviceInfoProvider]. + */ +class AndroidDeviceInfoProviderTest { + private lateinit var context: Context + private lateinit var provider: AndroidDeviceInfoProvider + private lateinit var batteryManager: BatteryManager + private lateinit var wifiManager: WifiManager + private lateinit var wifiInfo: WifiInfo + + @Before + fun setup() { + context = mockk(relaxed = true) + batteryManager = mockk() + wifiManager = mockk() + wifiInfo = mockk() + + provider = AndroidDeviceInfoProvider(context) + } + + @After + fun tearDown() { + unmockkAll() + } + + // Battery Level Tests + + @Test + fun `getBatteryLevel returns valid percentage when battery manager available`() { + // Arrange + val expectedBatteryLevel = 85 + every { context.getSystemService(Context.BATTERY_SERVICE) } returns batteryManager + every { batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) } returns expectedBatteryLevel + + // Act + val result = provider.getBatteryLevel() + + // Assert + assertThat(result).isEqualTo(expectedBatteryLevel) + } + + @Test + fun `getBatteryLevel returns null when battery manager unavailable`() { + // Arrange + every { context.getSystemService(Context.BATTERY_SERVICE) } returns null + + // Act + val result = provider.getBatteryLevel() + + // Assert + assertThat(result).isNull() + } + + @Test + fun `getBatteryLevel returns null when exception occurs`() { + // Arrange + every { context.getSystemService(Context.BATTERY_SERVICE) } throws RuntimeException("Test exception") + + // Act + val result = provider.getBatteryLevel() + + // Assert + assertThat(result).isNull() + } + + @Test + fun `getBatteryLevel returns 0 when battery level is 0`() { + // Arrange + every { context.getSystemService(Context.BATTERY_SERVICE) } returns batteryManager + every { batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) } returns 0 + + // Act + val result = provider.getBatteryLevel() + + // Assert + assertThat(result).isEqualTo(0) + } + + @Test + fun `getBatteryLevel returns 100 when battery is full`() { + // Arrange + every { context.getSystemService(Context.BATTERY_SERVICE) } returns batteryManager + every { batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) } returns 100 + + // Act + val result = provider.getBatteryLevel() + + // Assert + assertThat(result).isEqualTo(100) + } + + // WiFi Signal Strength Tests + + @Test + fun `getWifiSignalStrength returns valid RSSI when WiFi connected`() { + // Arrange + val expectedRssi = -65 // Good signal strength + every { context.applicationContext.getSystemService(Context.WIFI_SERVICE) } returns wifiManager + every { wifiManager.connectionInfo } returns wifiInfo + every { wifiInfo.rssi } returns expectedRssi + + // Act + val result = provider.getWifiSignalStrength() + + // Assert + assertThat(result).isEqualTo(expectedRssi) + } + + @Test + fun `getWifiSignalStrength returns null when WiFi manager unavailable`() { + // Arrange + every { context.applicationContext.getSystemService(Context.WIFI_SERVICE) } returns null + + // Act + val result = provider.getWifiSignalStrength() + + // Assert + assertThat(result).isNull() + } + + @Test + fun `getWifiSignalStrength returns null when WiFi info unavailable`() { + // Arrange + every { context.applicationContext.getSystemService(Context.WIFI_SERVICE) } returns wifiManager + every { wifiManager.connectionInfo } returns null + + // Act + val result = provider.getWifiSignalStrength() + + // Assert + assertThat(result).isNull() + } + + @Test + fun `getWifiSignalStrength returns null when RSSI is -127 (no signal)`() { + // Arrange + every { context.applicationContext.getSystemService(Context.WIFI_SERVICE) } returns wifiManager + every { wifiManager.connectionInfo } returns wifiInfo + every { wifiInfo.rssi } returns -127 // Special value indicating no signal + + // Act + val result = provider.getWifiSignalStrength() + + // Assert + assertThat(result).isNull() + } + + @Test + fun `getWifiSignalStrength handles getIntProperty exception`() { + // Arrange + every { context.applicationContext.getSystemService(Context.WIFI_SERVICE) } returns wifiManager + every { wifiManager.connectionInfo } returns wifiInfo + every { wifiInfo.rssi } throws RuntimeException("Access denied") + + // Act + val result = provider.getWifiSignalStrength() + + // Assert + assertThat(result).isNull() + } + + @Test + fun `getWifiSignalStrength returns null when exception occurs`() { + // Arrange + every { context.applicationContext.getSystemService(Context.WIFI_SERVICE) } throws RuntimeException("Test exception") + + // Act + val result = provider.getWifiSignalStrength() + + // Assert + assertThat(result).isNull() + } + + @Test + fun `getWifiSignalStrength returns strong signal value`() { + // Arrange + val strongSignal = -30 // Excellent signal + every { context.applicationContext.getSystemService(Context.WIFI_SERVICE) } returns wifiManager + every { wifiManager.connectionInfo } returns wifiInfo + every { wifiInfo.rssi } returns strongSignal + + // Act + val result = provider.getWifiSignalStrength() + + // Assert + assertThat(result).isEqualTo(strongSignal) + } + + @Test + fun `getWifiSignalStrength returns weak signal value`() { + // Arrange + val weakSignal = -90 // Very weak signal + every { context.applicationContext.getSystemService(Context.WIFI_SERVICE) } returns wifiManager + every { wifiManager.connectionInfo } returns wifiInfo + every { wifiInfo.rssi } returns weakSignal + + // Act + val result = provider.getWifiSignalStrength() + + // Assert + assertThat(result).isEqualTo(weakSignal) + } + + @Test + fun `getWifiSignalStrength returns medium signal value`() { + // Arrange + val mediumSignal = -70 // Good signal + every { context.applicationContext.getSystemService(Context.WIFI_SERVICE) } returns wifiManager + every { wifiManager.connectionInfo } returns wifiInfo + every { wifiInfo.rssi } returns mediumSignal + + // Act + val result = provider.getWifiSignalStrength() + + // Assert + assertThat(result).isEqualTo(mediumSignal) + } +}