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 + } } 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..1080dde 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 @@ -694,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) + } +}