refactor(monitor): Compute monitor mode automatically from profile state

This commit is contained in:
Matthias Urhahn
2026-05-07 14:10:16 +02:00
committed by Matthias Urhahn
parent 8c9094d85a
commit a4ba302223
95 changed files with 379 additions and 840 deletions
@@ -4,10 +4,13 @@ import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.bluetooth.BluetoothDevice2
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.bluetooth.NudgeAttemptResult
import eu.darken.capod.common.bluetooth.NudgeAvailability
import eu.darken.capod.common.bluetooth.NudgeCapabilityStore
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
@@ -43,7 +46,6 @@ import org.junit.jupiter.api.extension.ExtendWith
import testhelpers.BaseTest
import testhelpers.TestTimeSource
import testhelpers.coroutine.TestDispatcherProvider
import testhelpers.datastore.FakeDataStoreValue
import testhelpers.livedata.InstantExecutorExtension
@ExtendWith(InstantExecutorExtension::class)
@@ -60,8 +62,10 @@ class DeviceSettingsViewModelTest : BaseTest() {
private lateinit var upgradeRepo: UpgradeRepo
private lateinit var bluetoothManager: BluetoothManager2
private lateinit var profilesRepo: DeviceProfilesRepo
private lateinit var generalSettings: GeneralSettings
private lateinit var fakeMonitorMode: FakeDataStoreValue<MonitorMode>
private lateinit var monitorModeResolver: MonitorModeResolver
private lateinit var nudgeCapabilityStore: NudgeCapabilityStore
private lateinit var nudgeAvailabilityFlow: MutableStateFlow<NudgeAvailability>
private lateinit var effectiveModeFlow: MutableStateFlow<MonitorMode>
private val timeSource: TimeSource = TestTimeSource()
private lateinit var devicesFlow: MutableStateFlow<List<PodDevice>>
@@ -100,9 +104,9 @@ class DeviceSettingsViewModelTest : BaseTest() {
}
connectedDevicesFlow = MutableStateFlow(emptyList())
bluetoothManager = mockk(relaxed = true) {
every { isNudgeAvailable } returns true
every { bondedDevices() } returns flowOf(emptySet())
every { connectedDevices } returns connectedDevicesFlow
coEvery { nudgeConnection(any()) } returns NudgeAttemptResult.Rejected
}
profilesFlow = MutableStateFlow(
listOf(
@@ -112,9 +116,13 @@ class DeviceSettingsViewModelTest : BaseTest() {
profilesRepo = mockk(relaxed = true) {
every { profiles } returns profilesFlow
}
fakeMonitorMode = FakeDataStoreValue(MonitorMode.AUTOMATIC)
generalSettings = mockk<GeneralSettings>().also {
every { it.monitorMode } returns fakeMonitorMode.mock
effectiveModeFlow = MutableStateFlow(MonitorMode.AUTOMATIC)
monitorModeResolver = mockk<MonitorModeResolver>().also {
every { it.effectiveMode } returns effectiveModeFlow
}
nudgeAvailabilityFlow = MutableStateFlow(NudgeAvailability.UNKNOWN)
nudgeCapabilityStore = mockk<NudgeCapabilityStore>(relaxed = true).also {
every { it.availability } returns nudgeAvailabilityFlow
}
}
@@ -132,7 +140,8 @@ class DeviceSettingsViewModelTest : BaseTest() {
upgradeRepo = upgradeRepo,
bluetoothManager = bluetoothManager,
profilesRepo = profilesRepo,
generalSettings = generalSettings,
monitorModeResolver = monitorModeResolver,
nudgeCapabilityStore = nudgeCapabilityStore,
timeSource = timeSource,
webpageTool = mockk(relaxed = true),
).also { vm = it }
@@ -150,7 +159,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
fun `forceConnect happy path - bonded exists, nudge accepted, no event emitted`() = runVmTest {
val bonded = mockBondedDevice(testAddress)
every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded))
coEvery { bluetoothManager.nudgeConnection(bonded) } returns true
coEvery { bluetoothManager.nudgeConnection(bonded) } returns NudgeAttemptResult.Accepted
val vm = createViewModel()
vm.initialize(testAddress)
@@ -166,7 +175,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
fun `forceConnect when nudge not accepted - emits OpenBluetoothSettings`() = runVmTest {
val bonded = mockBondedDevice(testAddress)
every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded))
coEvery { bluetoothManager.nudgeConnection(bonded) } returns false
coEvery { bluetoothManager.nudgeConnection(bonded) } returns NudgeAttemptResult.Rejected
val vm = createViewModel()
vm.initialize(testAddress)
@@ -183,7 +192,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
fun `forceConnect when nudge unavailable - emits OpenBluetoothSettings without calling nudge`() = runVmTest {
val bonded = mockBondedDevice(testAddress)
every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded))
every { bluetoothManager.isNudgeAvailable } returns false
nudgeAvailabilityFlow.value = NudgeAvailability.BROKEN
val vm = createViewModel()
vm.initialize(testAddress)
@@ -251,7 +260,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
val bonded = mockBondedDevice(testAddress)
every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded))
val gate = CompletableDeferred<Boolean>()
val gate = CompletableDeferred<NudgeAttemptResult>()
coEvery { bluetoothManager.nudgeConnection(bonded) } coAnswers { gate.await() }
val vm = createViewModel()
@@ -261,7 +270,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
vm.forceConnect()
vm.forceConnect()
gate.complete(true)
gate.complete(NudgeAttemptResult.Accepted)
coVerify(exactly = 1) { bluetoothManager.nudgeConnection(bonded) }
vm.state.first().isForceConnecting shouldBe false
@@ -10,6 +10,7 @@ import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.profiles.core.DeviceProfile
@@ -51,6 +52,7 @@ class OverviewViewModelTest : BaseTest() {
private lateinit var upgradeRepo: UpgradeRepo
private lateinit var bluetoothManager: BluetoothManager2
private lateinit var profilesRepo: DeviceProfilesRepo
private lateinit var monitorModeResolver: MonitorModeResolver
private val timeSource: TimeSource = TestTimeSource()
private lateinit var missingPermissionsFlow: MutableStateFlow<Set<Permission>>
@@ -60,7 +62,7 @@ class OverviewViewModelTest : BaseTest() {
private lateinit var profilesFlow: MutableStateFlow<List<DeviceProfile>>
private lateinit var hadLegacyReactionDataFlow: MutableStateFlow<Boolean>
private lateinit var upgradeInfoFlow: MutableStateFlow<UpgradeRepo.Info>
private lateinit var fakeMonitorMode: FakeDataStoreValue<MonitorMode>
private lateinit var effectiveModeFlow: MutableStateFlow<MonitorMode>
private lateinit var fakeDebugMode: FakeDataStoreValue<Boolean>
private lateinit var fakeReactionsHintDismissed: FakeDataStoreValue<Boolean>
@@ -75,7 +77,7 @@ class OverviewViewModelTest : BaseTest() {
profilesFlow = MutableStateFlow(emptyList())
hadLegacyReactionDataFlow = MutableStateFlow(false)
upgradeInfoFlow = MutableStateFlow(mockk<UpgradeRepo.Info>(relaxed = true))
fakeMonitorMode = FakeDataStoreValue(MonitorMode.AUTOMATIC)
effectiveModeFlow = MutableStateFlow(MonitorMode.AUTOMATIC)
fakeDebugMode = FakeDataStoreValue(false)
fakeReactionsHintDismissed = FakeDataStoreValue(false)
@@ -93,10 +95,13 @@ class OverviewViewModelTest : BaseTest() {
}
generalSettings = mockk<GeneralSettings>().also {
every { it.monitorMode } returns fakeMonitorMode.mock
every { it.reactionsHintDismissed } returns fakeReactionsHintDismissed.mock
}
monitorModeResolver = mockk<MonitorModeResolver>().also {
every { it.effectiveMode } returns effectiveModeFlow
}
debugSettings = mockk<DebugSettings>().also {
every { it.isDebugModeEnabled } returns fakeDebugMode.mock
}
@@ -132,6 +137,7 @@ class OverviewViewModelTest : BaseTest() {
bluetoothManager = bluetoothManager,
profilesRepo = profilesRepo,
aapManager = mockk(relaxed = true),
monitorModeResolver = monitorModeResolver,
timeSource = timeSource,
)
@@ -347,7 +353,7 @@ class OverviewViewModelTest : BaseTest() {
@Test
fun `MANUAL mode - never starts monitor`() = runTest(testDispatcher) {
fakeMonitorMode.value = MonitorMode.MANUAL
effectiveModeFlow.value = MonitorMode.MANUAL
val vm = createViewModel()
// Collect workerAutolaunch to trigger the side effect
@@ -358,7 +364,7 @@ class OverviewViewModelTest : BaseTest() {
@Test
fun `ALWAYS mode - starts monitor when permissions OK`() = runTest(testDispatcher) {
fakeMonitorMode.value = MonitorMode.ALWAYS
effectiveModeFlow.value = MonitorMode.ALWAYS
val vm = createViewModel()
vm.workerAutolaunch.first()
@@ -368,7 +374,7 @@ class OverviewViewModelTest : BaseTest() {
@Test
fun `ALWAYS mode - does NOT start monitor when permissions missing`() = runTest(testDispatcher) {
fakeMonitorMode.value = MonitorMode.ALWAYS
effectiveModeFlow.value = MonitorMode.ALWAYS
missingPermissionsFlow.value = setOf(Permission.BLUETOOTH)
val vm = createViewModel()
@@ -379,7 +385,7 @@ class OverviewViewModelTest : BaseTest() {
@Test
fun `AUTOMATIC mode - starts monitor when connected devices exist`() = runTest(testDispatcher) {
fakeMonitorMode.value = MonitorMode.AUTOMATIC
effectiveModeFlow.value = MonitorMode.AUTOMATIC
connectedDevicesFlow.value = listOf(mockk(relaxed = true))
val vm = createViewModel()
@@ -390,7 +396,7 @@ class OverviewViewModelTest : BaseTest() {
@Test
fun `AUTOMATIC mode - does NOT start monitor when no connected devices`() = runTest(testDispatcher) {
fakeMonitorMode.value = MonitorMode.AUTOMATIC
effectiveModeFlow.value = MonitorMode.AUTOMATIC
connectedDevicesFlow.value = emptyList()
val vm = createViewModel()
@@ -0,0 +1,132 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.common.bluetooth.NudgeAvailability
import eu.darken.capod.common.bluetooth.NudgeCapabilityStore
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class MonitorModeResolverTest : BaseTest() {
private val profilesFlow = MutableStateFlow<List<DeviceProfile>>(emptyList())
private val nudgeFlow = MutableStateFlow(NudgeAvailability.UNKNOWN)
private val profilesRepo = mockk<DeviceProfilesRepo>().also {
every { it.profiles } returns profilesFlow
}
private val nudgeStore = mockk<NudgeCapabilityStore>().also {
every { it.availability } returns nudgeFlow
}
private val resolver = MonitorModeResolver(profilesRepo, nudgeStore)
private fun profile(
id: String = "p",
address: String? = "AA:BB:CC:DD:EE:FF",
autoConnect: Boolean = false,
): AppleDeviceProfile = AppleDeviceProfile(
id = id,
label = id,
address = address,
autoConnect = autoConnect,
)
@Test
fun `case 1 - no profiles - MANUAL`() = runTest {
profilesFlow.value = emptyList()
resolver.effectiveMode.first() shouldBe MonitorMode.MANUAL
}
@Test
fun `case 2 - paired profile, autoConnect off - AUTOMATIC`() = runTest {
profilesFlow.value = listOf(profile(autoConnect = false))
resolver.effectiveMode.first() shouldBe MonitorMode.AUTOMATIC
}
@Test
fun `case 3 - paired profile, autoConnect on, nudge AVAILABLE - ALWAYS`() = runTest {
profilesFlow.value = listOf(profile(autoConnect = true))
nudgeFlow.value = NudgeAvailability.AVAILABLE
resolver.effectiveMode.first() shouldBe MonitorMode.ALWAYS
}
@Test
fun `case 3 - paired profile, autoConnect on, nudge UNKNOWN - ALWAYS (assume available)`() = runTest {
profilesFlow.value = listOf(profile(autoConnect = true))
nudgeFlow.value = NudgeAvailability.UNKNOWN
resolver.effectiveMode.first() shouldBe MonitorMode.ALWAYS
}
@Test
fun `case 3b - paired profile, autoConnect on, nudge BROKEN - AUTOMATIC`() = runTest {
profilesFlow.value = listOf(profile(autoConnect = true))
nudgeFlow.value = NudgeAvailability.BROKEN
resolver.effectiveMode.first() shouldBe MonitorMode.AUTOMATIC
}
@Test
fun `case 4 - profile without paired address - MANUAL`() = runTest {
profilesFlow.value = listOf(profile(address = null, autoConnect = true))
resolver.effectiveMode.first() shouldBe MonitorMode.MANUAL
}
@Test
fun `multi-profile primary-only - primary case 2 + secondary case 3 - AUTOMATIC`() = runTest {
profilesFlow.value = listOf(
profile(id = "primary", autoConnect = false),
profile(id = "secondary", autoConnect = true),
)
nudgeFlow.value = NudgeAvailability.AVAILABLE
resolver.effectiveMode.first() shouldBe MonitorMode.AUTOMATIC
}
@Test
fun `multi-profile primary-only - primary case 4 + secondary case 2 - MANUAL`() = runTest {
profilesFlow.value = listOf(
profile(id = "primary", address = null),
profile(id = "secondary", address = "AA:BB:CC:DD:EE:FF"),
)
resolver.effectiveMode.first() shouldBe MonitorMode.MANUAL
}
@Test
fun `list reorder makes the secondary primary - mode flips`() = runTest {
val a = profile(id = "a", autoConnect = true)
val b = profile(id = "b", autoConnect = false)
nudgeFlow.value = NudgeAvailability.AVAILABLE
profilesFlow.value = listOf(a, b)
resolver.effectiveMode.first() shouldBe MonitorMode.ALWAYS
profilesFlow.value = listOf(b, a)
resolver.effectiveMode.first() shouldBe MonitorMode.AUTOMATIC
}
@Test
fun `nudge availability flip from UNKNOWN to BROKEN drops case 3 to AUTOMATIC`() = runTest {
profilesFlow.value = listOf(profile(autoConnect = true))
nudgeFlow.value = NudgeAvailability.UNKNOWN
resolver.effectiveMode.first() shouldBe MonitorMode.ALWAYS
nudgeFlow.value = NudgeAvailability.BROKEN
resolver.effectiveMode.first() shouldBe MonitorMode.AUTOMATIC
}
}
@@ -17,6 +17,7 @@ class AutoConnectLogicTest : BaseTest() {
autoConnect = AutoConnect(
bluetoothManager = mockk(relaxed = true),
deviceMonitor = mockk(relaxed = true),
nudgeCapabilityStore = mockk(relaxed = true),
)
}