mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
test: Cover nudge persistence, permission gating, and autolaunch reactivity
This commit is contained in:
committed by
Matthias Urhahn
parent
a4ba302223
commit
1eeeb63a76
@@ -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<Preferences> by preferencesDataStore(name = "bluetooth_state")
|
||||
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@Module
|
||||
object BluetoothPersistenceModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideNudgeAvailability(
|
||||
@ApplicationContext context: Context,
|
||||
@SerializationCapod json: Json,
|
||||
): DataStoreValue<NudgeAvailability> = context.bluetoothDataStore.createValue(
|
||||
key = "core.bluetooth.nudge.availability",
|
||||
defaultValue = NudgeAvailability.UNKNOWN,
|
||||
json = json,
|
||||
onErrorFallbackToDefault = true,
|
||||
)
|
||||
}
|
||||
@@ -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<NudgeAvailability>,
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
) {
|
||||
|
||||
private val Context.bluetoothDataStore by preferencesDataStore(name = "bluetooth_state")
|
||||
|
||||
private val dataStore: DataStore<Preferences> get() = context.bluetoothDataStore
|
||||
|
||||
private val persistedValue = dataStore.createValue(
|
||||
"core.bluetooth.nudge.availability",
|
||||
NudgeAvailability.UNKNOWN,
|
||||
json,
|
||||
onErrorFallbackToDefault = true,
|
||||
)
|
||||
|
||||
val availability: StateFlow<NudgeAvailability> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NudgeAvailability>
|
||||
|
||||
@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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user