test: Cover nudge persistence, permission gating, and autolaunch reactivity

This commit is contained in:
Matthias Urhahn
2026-05-07 14:10:16 +02:00
committed by Matthias Urhahn
parent a4ba302223
commit 1eeeb63a76
6 changed files with 330 additions and 31 deletions
@@ -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
}
}
}