From 1eeeb63a765aabe7c15a7ccf0ecd6a0820b4bfbe Mon Sep 17 00:00:00 2001 From: Matthias Urhahn Date: Thu, 7 May 2026 13:33:22 +0200 Subject: [PATCH] test: Cover nudge persistence, permission gating, and autolaunch reactivity --- .../bluetooth/BluetoothPersistenceModule.kt | 35 ++++++ .../common/bluetooth/NudgeCapabilityStore.kt | 38 ++---- .../darken/capod/main/core/PermissionTool.kt | 23 +++- .../bluetooth/NudgeCapabilityStoreTest.kt | 117 ++++++++++++++++++ .../capod/main/core/PermissionToolTest.kt | 102 +++++++++++++++ .../main/ui/overview/OverviewViewModelTest.kt | 46 +++++++ 6 files changed, 330 insertions(+), 31 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothPersistenceModule.kt create mode 100644 app/src/test/java/eu/darken/capod/common/bluetooth/NudgeCapabilityStoreTest.kt create mode 100644 app/src/test/java/eu/darken/capod/main/core/PermissionToolTest.kt diff --git a/app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothPersistenceModule.kt b/app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothPersistenceModule.kt new file mode 100644 index 00000000..d1b50970 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothPersistenceModule.kt @@ -0,0 +1,35 @@ +package eu.darken.capod.common.bluetooth + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import eu.darken.capod.common.datastore.DataStoreValue +import eu.darken.capod.common.datastore.createValue +import eu.darken.capod.common.serialization.SerializationCapod +import kotlinx.serialization.json.Json +import javax.inject.Singleton + +private val Context.bluetoothDataStore: DataStore by preferencesDataStore(name = "bluetooth_state") + +@InstallIn(SingletonComponent::class) +@Module +object BluetoothPersistenceModule { + + @Provides + @Singleton + fun provideNudgeAvailability( + @ApplicationContext context: Context, + @SerializationCapod json: Json, + ): DataStoreValue = context.bluetoothDataStore.createValue( + key = "core.bluetooth.nudge.availability", + defaultValue = NudgeAvailability.UNKNOWN, + json = json, + onErrorFallbackToDefault = true, + ) +} diff --git a/app/src/main/java/eu/darken/capod/common/bluetooth/NudgeCapabilityStore.kt b/app/src/main/java/eu/darken/capod/common/bluetooth/NudgeCapabilityStore.kt index bf836f16..cb3f24b8 100644 --- a/app/src/main/java/eu/darken/capod/common/bluetooth/NudgeCapabilityStore.kt +++ b/app/src/main/java/eu/darken/capod/common/bluetooth/NudgeCapabilityStore.kt @@ -1,47 +1,28 @@ package eu.darken.capod.common.bluetooth -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStore -import dagger.hilt.android.qualifiers.ApplicationContext import eu.darken.capod.common.coroutine.AppScope import eu.darken.capod.common.coroutine.DispatcherProvider -import eu.darken.capod.common.datastore.createValue +import eu.darken.capod.common.datastore.DataStoreValue import eu.darken.capod.common.datastore.value import eu.darken.capod.common.debug.logging.Logging.Priority.INFO import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag -import eu.darken.capod.common.serialization.SerializationCapod import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.plus -import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton @Singleton class NudgeCapabilityStore @Inject constructor( - @ApplicationContext private val context: Context, - @SerializationCapod json: Json, + private val persistedValue: DataStoreValue, @AppScope private val appScope: CoroutineScope, private val dispatcherProvider: DispatcherProvider, ) { - private val Context.bluetoothDataStore by preferencesDataStore(name = "bluetooth_state") - - private val dataStore: DataStore get() = context.bluetoothDataStore - - private val persistedValue = dataStore.createValue( - "core.bluetooth.nudge.availability", - NudgeAvailability.UNKNOWN, - json, - onErrorFallbackToDefault = true, - ) - val availability: StateFlow = persistedValue.flow .stateIn( scope = appScope + dispatcherProvider.IO, @@ -50,13 +31,7 @@ class NudgeCapabilityStore @Inject constructor( ) fun record(result: NudgeAttemptResult) { - val verdict = when (result) { - NudgeAttemptResult.Accepted -> NudgeAvailability.AVAILABLE - NudgeAttemptResult.UnavailableHiddenApi -> NudgeAvailability.BROKEN - NudgeAttemptResult.Rejected, - NudgeAttemptResult.UnavailableMissingPermission -> null - } - if (verdict == null) return + val verdict = verdictFor(result) ?: return if (availability.value == verdict) return log(TAG, INFO) { "Recording nudge verdict: $verdict (from $result)" } appScope.launch(dispatcherProvider.IO) { @@ -66,5 +41,12 @@ class NudgeCapabilityStore @Inject constructor( companion object { private val TAG = logTag("Bluetooth", "NudgeCapabilityStore") + + internal fun verdictFor(result: NudgeAttemptResult): NudgeAvailability? = when (result) { + NudgeAttemptResult.Accepted -> NudgeAvailability.AVAILABLE + NudgeAttemptResult.UnavailableHiddenApi -> NudgeAvailability.BROKEN + NudgeAttemptResult.Rejected, + NudgeAttemptResult.UnavailableMissingPermission -> null + } } } diff --git a/app/src/main/java/eu/darken/capod/main/core/PermissionTool.kt b/app/src/main/java/eu/darken/capod/main/core/PermissionTool.kt index 22550c5a..9313f686 100644 --- a/app/src/main/java/eu/darken/capod/main/core/PermissionTool.kt +++ b/app/src/main/java/eu/darken/capod/main/core/PermissionTool.kt @@ -46,9 +46,7 @@ class PermissionTool @Inject constructor( anyPopupEnabled, ) { _, monitorMode, showPopUp -> Permission.entries - .filter { it != Permission.IGNORE_BATTERY_OPTIMIZATION || monitorMode == MonitorMode.ALWAYS } - .filter { it != Permission.ACCESS_BACKGROUND_LOCATION || monitorMode == MonitorMode.ALWAYS } - .filter { it != Permission.SYSTEM_ALERT_WINDOW || showPopUp } + .filter { isApplicable(it, monitorMode, showPopUp) } .filter { it.isRequired(context) } .toSet() } @@ -62,5 +60,24 @@ class PermissionTool @Inject constructor( companion object { private val TAG = logTag("PermissionTool") + + /** + * Whether [permission] is applicable for the user's current configuration. + * Three permissions are conditional on monitor mode or popup usage: + * - [Permission.IGNORE_BATTERY_OPTIMIZATION] only when always-on scanning is needed + * - [Permission.ACCESS_BACKGROUND_LOCATION] same + * - [Permission.SYSTEM_ALERT_WINDOW] only when at least one popup reaction is enabled + * Everything else is unconditionally applicable. + */ + internal fun isApplicable( + permission: Permission, + monitorMode: MonitorMode, + anyPopupEnabled: Boolean, + ): Boolean = when (permission) { + Permission.IGNORE_BATTERY_OPTIMIZATION, + Permission.ACCESS_BACKGROUND_LOCATION -> monitorMode == MonitorMode.ALWAYS + Permission.SYSTEM_ALERT_WINDOW -> anyPopupEnabled + else -> true + } } } diff --git a/app/src/test/java/eu/darken/capod/common/bluetooth/NudgeCapabilityStoreTest.kt b/app/src/test/java/eu/darken/capod/common/bluetooth/NudgeCapabilityStoreTest.kt new file mode 100644 index 00000000..c4a33013 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/common/bluetooth/NudgeCapabilityStoreTest.kt @@ -0,0 +1,117 @@ +package eu.darken.capod.common.bluetooth + +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import testhelpers.coroutine.TestDispatcherProvider +import testhelpers.datastore.FakeDataStoreValue + +class NudgeCapabilityStoreTest : BaseTest() { + + private val testDispatcher = UnconfinedTestDispatcher() + + private lateinit var fakePersisted: FakeDataStoreValue + + @BeforeEach + fun setup() { + Dispatchers.setMain(testDispatcher) + fakePersisted = FakeDataStoreValue(NudgeAvailability.UNKNOWN) + } + + @AfterEach + fun teardown() { + Dispatchers.resetMain() + } + + private fun createStore() = NudgeCapabilityStore( + persistedValue = fakePersisted.mock, + appScope = kotlinx.coroutines.CoroutineScope(testDispatcher), + dispatcherProvider = TestDispatcherProvider(testDispatcher), + ) + + @Test + fun `verdictFor maps Accepted to AVAILABLE`() { + NudgeCapabilityStore.verdictFor(NudgeAttemptResult.Accepted) shouldBe NudgeAvailability.AVAILABLE + } + + @Test + fun `verdictFor maps UnavailableHiddenApi to BROKEN`() { + NudgeCapabilityStore.verdictFor(NudgeAttemptResult.UnavailableHiddenApi) shouldBe NudgeAvailability.BROKEN + } + + @Test + fun `verdictFor returns null for Rejected`() { + NudgeCapabilityStore.verdictFor(NudgeAttemptResult.Rejected) shouldBe null + } + + @Test + fun `verdictFor returns null for UnavailableMissingPermission`() { + NudgeCapabilityStore.verdictFor(NudgeAttemptResult.UnavailableMissingPermission) shouldBe null + } + + @Test + fun `record Accepted persists AVAILABLE`() = runTest(testDispatcher) { + val store = createStore() + + store.record(NudgeAttemptResult.Accepted) + + fakePersisted.value shouldBe NudgeAvailability.AVAILABLE + store.availability.value shouldBe NudgeAvailability.AVAILABLE + } + + @Test + fun `record UnavailableHiddenApi persists BROKEN`() = runTest(testDispatcher) { + val store = createStore() + + store.record(NudgeAttemptResult.UnavailableHiddenApi) + + fakePersisted.value shouldBe NudgeAvailability.BROKEN + } + + @Test + fun `record Rejected does not persist (stays UNKNOWN)`() = runTest(testDispatcher) { + val store = createStore() + + store.record(NudgeAttemptResult.Rejected) + + fakePersisted.value shouldBe NudgeAvailability.UNKNOWN + store.availability.value shouldBe NudgeAvailability.UNKNOWN + } + + @Test + fun `record UnavailableMissingPermission does not persist`() = runTest(testDispatcher) { + // Pre-existing AVAILABLE — a transient permission failure should not flip it to BROKEN. + fakePersisted.value = NudgeAvailability.AVAILABLE + val store = createStore() + + store.record(NudgeAttemptResult.UnavailableMissingPermission) + + fakePersisted.value shouldBe NudgeAvailability.AVAILABLE + } + + @Test + fun `availability StateFlow reflects persisted value on construction`() = runTest(testDispatcher) { + fakePersisted.value = NudgeAvailability.BROKEN + + val store = createStore() + + store.availability.value shouldBe NudgeAvailability.BROKEN + } + + @Test + fun `availability StateFlow tracks persisted updates`() = runTest(testDispatcher) { + val store = createStore() + store.availability.value shouldBe NudgeAvailability.UNKNOWN + + fakePersisted.value = NudgeAvailability.AVAILABLE + + store.availability.value shouldBe NudgeAvailability.AVAILABLE + } +} diff --git a/app/src/test/java/eu/darken/capod/main/core/PermissionToolTest.kt b/app/src/test/java/eu/darken/capod/main/core/PermissionToolTest.kt new file mode 100644 index 00000000..2087a517 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/main/core/PermissionToolTest.kt @@ -0,0 +1,102 @@ +package eu.darken.capod.main.core + +import eu.darken.capod.common.permissions.Permission +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class PermissionToolTest : BaseTest() { + + @Test + fun `IGNORE_BATTERY_OPTIMIZATION is applicable only in ALWAYS mode`() { + PermissionTool.isApplicable( + Permission.IGNORE_BATTERY_OPTIMIZATION, + MonitorMode.ALWAYS, + anyPopupEnabled = false, + ) shouldBe true + PermissionTool.isApplicable( + Permission.IGNORE_BATTERY_OPTIMIZATION, + MonitorMode.AUTOMATIC, + anyPopupEnabled = false, + ) shouldBe false + PermissionTool.isApplicable( + Permission.IGNORE_BATTERY_OPTIMIZATION, + MonitorMode.MANUAL, + anyPopupEnabled = false, + ) shouldBe false + } + + @Test + fun `ACCESS_BACKGROUND_LOCATION is applicable only in ALWAYS mode`() { + PermissionTool.isApplicable( + Permission.ACCESS_BACKGROUND_LOCATION, + MonitorMode.ALWAYS, + anyPopupEnabled = false, + ) shouldBe true + PermissionTool.isApplicable( + Permission.ACCESS_BACKGROUND_LOCATION, + MonitorMode.AUTOMATIC, + anyPopupEnabled = false, + ) shouldBe false + PermissionTool.isApplicable( + Permission.ACCESS_BACKGROUND_LOCATION, + MonitorMode.MANUAL, + anyPopupEnabled = false, + ) shouldBe false + } + + @Test + fun `SYSTEM_ALERT_WINDOW is applicable only when popups are enabled`() { + PermissionTool.isApplicable( + Permission.SYSTEM_ALERT_WINDOW, + MonitorMode.AUTOMATIC, + anyPopupEnabled = true, + ) shouldBe true + PermissionTool.isApplicable( + Permission.SYSTEM_ALERT_WINDOW, + MonitorMode.ALWAYS, + anyPopupEnabled = false, + ) shouldBe false + } + + @Test + fun `popup permission gating is independent of monitor mode`() { + // SYSTEM_ALERT_WINDOW gating depends on popups, not on mode. + for (mode in MonitorMode.entries) { + PermissionTool.isApplicable( + Permission.SYSTEM_ALERT_WINDOW, + mode, + anyPopupEnabled = true, + ) shouldBe true + } + } + + @Test + fun `mode-gated permissions don't depend on popup state`() { + // IGNORE_BATTERY_OPTIMIZATION and ACCESS_BACKGROUND_LOCATION gate on mode only. + listOf(Permission.IGNORE_BATTERY_OPTIMIZATION, Permission.ACCESS_BACKGROUND_LOCATION).forEach { perm -> + PermissionTool.isApplicable(perm, MonitorMode.ALWAYS, anyPopupEnabled = true) shouldBe true + PermissionTool.isApplicable(perm, MonitorMode.ALWAYS, anyPopupEnabled = false) shouldBe true + PermissionTool.isApplicable(perm, MonitorMode.AUTOMATIC, anyPopupEnabled = true) shouldBe false + PermissionTool.isApplicable(perm, MonitorMode.AUTOMATIC, anyPopupEnabled = false) shouldBe false + } + } + + @Test + fun `unconditional permissions are always applicable`() { + // BLUETOOTH_SCAN, BLUETOOTH_CONNECT, ACCESS_FINE_LOCATION, POST_NOTIFICATIONS, etc. + // are not mode/popup-gated. + val unconditional = Permission.entries.filter { + it != Permission.IGNORE_BATTERY_OPTIMIZATION && + it != Permission.ACCESS_BACKGROUND_LOCATION && + it != Permission.SYSTEM_ALERT_WINDOW + } + for (perm in unconditional) { + for (mode in MonitorMode.entries) { + for (popups in listOf(true, false)) { + PermissionTool.isApplicable(perm, mode, popups) shouldBe true + } + } + } + } +} diff --git a/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt index 583f2b12..d0c21326 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt @@ -21,6 +21,7 @@ import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -404,6 +405,51 @@ class OverviewViewModelTest : BaseTest() { verify(exactly = 0) { monitorControl.startMonitor(any()) } } + + @Test + fun `mode transition AUTOMATIC to ALWAYS triggers startMonitor`() = runTest(testDispatcher) { + // Regression guard: the old impl only re-evaluated on missingScanPermissions emissions, + // so toggling auto-connect (which flips effective mode AUTOMATIC -> ALWAYS) wouldn't + // actually start the monitor. The combine() over effectiveMode must keep it reactive. + effectiveModeFlow.value = MonitorMode.AUTOMATIC + connectedDevicesFlow.value = emptyList() + val vm = createViewModel() + + // Subscribe so the workerAutolaunch onEach actually runs. + val collectJob = launch { vm.workerAutolaunch.collect {} } + advanceUntilIdle() + + verify(exactly = 0) { monitorControl.startMonitor(any()) } + + // Flip to ALWAYS — should start the monitor without any other input changing. + effectiveModeFlow.value = MonitorMode.ALWAYS + advanceUntilIdle() + + verify(exactly = 1) { monitorControl.startMonitor(any()) } + + collectJob.cancel() + } + + @Test + fun `connected device appearing in AUTOMATIC triggers startMonitor`() = runTest(testDispatcher) { + // Symmetric to the mode-transition test: flipping connectedDevices from empty + // to non-empty under AUTOMATIC must also trigger autolaunch. + effectiveModeFlow.value = MonitorMode.AUTOMATIC + connectedDevicesFlow.value = emptyList() + val vm = createViewModel() + + val collectJob = launch { vm.workerAutolaunch.collect {} } + advanceUntilIdle() + + verify(exactly = 0) { monitorControl.startMonitor(any()) } + + connectedDevicesFlow.value = listOf(mockk(relaxed = true)) + advanceUntilIdle() + + verify(exactly = 1) { monitorControl.startMonitor(any()) } + + collectJob.cancel() + } } @Nested