diff --git a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt index 0cfff685..e5cfe4a0 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt @@ -11,6 +11,7 @@ import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder import dagger.hilt.android.AndroidEntryPoint +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.coroutine.DispatcherProvider @@ -35,6 +36,7 @@ import eu.darken.capod.monitor.core.primaryDevice import eu.darken.capod.monitor.ui.MonitorNotifications import eu.darken.capod.pods.core.apple.PodModel import eu.darken.capod.pods.core.apple.aap.AapConnectionManager +import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.profiles.core.DeviceProfile import eu.darken.capod.profiles.core.DeviceProfilesRepo import eu.darken.capod.reaction.core.autoconnect.AutoConnect @@ -49,6 +51,7 @@ import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first @@ -225,31 +228,19 @@ class MonitorService : Service() { profilesRepo.profiles, bluetoothManager.connectedDevices, aapConnectionManager.allStates, - ) { monitorMode, profiles, connectedDevices, aapStates -> - listOf(monitorMode, profiles, connectedDevices, aapStates) + ) { mode, profiles, devices, aapStates -> + buildMonitorModeState(mode, profiles, devices, aapStates) } } } + .distinctUntilChanged() .setupCommonEventHandlers(TAG) { "MonitorMode" } - .flatMapLatest { arguments -> - val monitorMode = arguments[0] as MonitorMode + .flatMapLatest { state -> + log(TAG) { "Monitor mode: ${state.mode}" } + log(TAG) { "connectedAddresses: ${state.connectedAddresses}" } + log(TAG) { "knownAddresses: ${state.knownAddresses}" } - @Suppress("UNCHECKED_CAST") - val profiles = arguments[1] as List - - @Suppress("UNCHECKED_CAST") - val devices = arguments[2] as Collection - - @Suppress("UNCHECKED_CAST") - val aapStates = arguments[3] as Map<*, *> - - val connectedAddresses = devices.map { it.address }.toSet() - val knownAddresses = profiles.mapNotNull { it.address }.toSet() - log(TAG) { "Monitor mode: $monitorMode" } - log(TAG) { "connectedAddresses: $connectedAddresses" } - log(TAG) { "knownAddresses: $knownAddresses" } - - when (monitorMode) { + when (state.mode) { MonitorMode.MANUAL -> flow { monitorScope.coroutineContext.cancelChildren() } @@ -257,11 +248,11 @@ class MonitorService : Service() { MonitorMode.ALWAYS -> emptyFlow() MonitorMode.AUTOMATIC -> flow { when { - profiles.isEmpty() && devices.isNotEmpty() -> { + !state.hasProfiles && state.connectedAddresses.isNotEmpty() -> { log(TAG, WARN) { "Main device address not set, staying alive while any is connected" } } - knownAddresses.any { it in connectedAddresses } || aapStates.isNotEmpty() -> { + state.knownAddresses.any { it in state.connectedAddresses } || state.hasAapSession -> { log(TAG) { "A device is connected, aborting any timeout." } } @@ -347,6 +338,27 @@ class MonitorService : Service() { } } +internal data class MonitorModeState( + val mode: MonitorMode, + val hasProfiles: Boolean, + val knownAddresses: Set, + val connectedAddresses: Set, + val hasAapSession: Boolean, +) + +internal fun buildMonitorModeState( + mode: MonitorMode, + profiles: List, + devices: Collection, + aapStates: Map, +): MonitorModeState = MonitorModeState( + mode = mode, + hasProfiles = profiles.isNotEmpty(), + knownAddresses = profiles.mapNotNull { it.address }.toSet(), + connectedAddresses = devices.map { it.address }.toSet(), + hasAapSession = aapStates.isNotEmpty(), +) + private data class NotificationDeviceKey( val profileId: String?, val label: String?, diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/PodFactory.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/PodFactory.kt index 5f8d2bec..94d04924 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/PodFactory.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/PodFactory.kt @@ -3,7 +3,6 @@ package eu.darken.capod.pods.core.apple.ble import dagger.Reusable import eu.darken.capod.common.bluetooth.BleScanResult import eu.darken.capod.common.bluetooth.logSummary -import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag @@ -26,7 +25,7 @@ class PodFactory @Inject constructor( device = unknownFactory.create(scanResult) } - log(TAG, DEBUG) { "Pod created: ${device.logSummary()}" } + log(TAG, VERBOSE) { "Pod created: ${device.logSummary()}" } return Result(scanResult = scanResult, device = device) } diff --git a/app/src/test/java/eu/darken/capod/monitor/core/worker/MonitorModeStateTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/worker/MonitorModeStateTest.kt new file mode 100644 index 00000000..caa05286 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/monitor/core/worker/MonitorModeStateTest.kt @@ -0,0 +1,181 @@ +package eu.darken.capod.monitor.core.worker + +import eu.darken.capod.common.bluetooth.BluetoothAddress +import eu.darken.capod.common.bluetooth.BluetoothDevice2 +import eu.darken.capod.main.core.MonitorMode +import eu.darken.capod.pods.core.apple.PodModel +import eu.darken.capod.pods.core.apple.aap.AapPodState +import eu.darken.capod.profiles.core.AppleDeviceProfile +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import java.time.Instant + +class MonitorModeStateTest : BaseTest() { + + private val addressA: BluetoothAddress = "AA:BB:CC:DD:EE:01" + private val addressB: BluetoothAddress = "AA:BB:CC:DD:EE:02" + + private val profileA = AppleDeviceProfile( + label = "AirPods A", + model = PodModel.AIRPODS_PRO2_USBC, + address = addressA, + ) + private val profileB = AppleDeviceProfile( + label = "AirPods B", + model = PodModel.AIRPODS_PRO2_USBC, + address = addressB, + ) + private val noAddressProfile = AppleDeviceProfile( + label = "Profile no address", + model = PodModel.AIRPODS_PRO2_USBC, + address = null, + ) + + private val aapStateA = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + lastMessageAt = Instant.parse("2026-04-25T12:00:00Z"), + ) + + private fun mockDevice(addr: BluetoothAddress): BluetoothDevice2 = mockk { + every { address } returns addr + } + + @Test + fun `empty profiles - hasProfiles false and addresses empty`() { + val state = buildMonitorModeState( + mode = MonitorMode.AUTOMATIC, + profiles = emptyList(), + devices = emptyList(), + aapStates = emptyMap(), + ) + state.hasProfiles shouldBe false + state.knownAddresses shouldBe emptySet() + state.connectedAddresses shouldBe emptySet() + state.hasAapSession shouldBe false + state.mode shouldBe MonitorMode.AUTOMATIC + } + + @Test + fun `profile with valid address - hasProfiles true and address present`() { + val state = buildMonitorModeState( + mode = MonitorMode.AUTOMATIC, + profiles = listOf(profileA), + devices = emptyList(), + aapStates = emptyMap(), + ) + state.hasProfiles shouldBe true + state.knownAddresses shouldBe setOf(addressA) + } + + @Test + fun `profile with null address - hasProfiles true but knownAddresses empty`() { + val state = buildMonitorModeState( + mode = MonitorMode.AUTOMATIC, + profiles = listOf(noAddressProfile), + devices = emptyList(), + aapStates = emptyMap(), + ) + state.hasProfiles shouldBe true + state.knownAddresses shouldBe emptySet() + } + + @Test + fun `mixed profiles - hasProfiles true and only addressed profile contributes`() { + val state = buildMonitorModeState( + mode = MonitorMode.AUTOMATIC, + profiles = listOf(profileA, noAddressProfile), + devices = emptyList(), + aapStates = emptyMap(), + ) + state.hasProfiles shouldBe true + state.knownAddresses shouldBe setOf(addressA) + } + + @Test + fun `connectedDevices addresses derived from BluetoothDevice2 address`() { + val state = buildMonitorModeState( + mode = MonitorMode.AUTOMATIC, + profiles = emptyList(), + devices = listOf(mockDevice(addressA), mockDevice(addressB)), + aapStates = emptyMap(), + ) + state.connectedAddresses shouldBe setOf(addressA, addressB) + } + + @Test + fun `aapStates non-empty - hasAapSession true`() { + val state = buildMonitorModeState( + mode = MonitorMode.AUTOMATIC, + profiles = emptyList(), + devices = emptyList(), + aapStates = mapOf(addressA to aapStateA), + ) + state.hasAapSession shouldBe true + } + + @Test + fun `aapStates empty - hasAapSession false`() { + val state = buildMonitorModeState( + mode = MonitorMode.AUTOMATIC, + profiles = emptyList(), + devices = emptyList(), + aapStates = emptyMap(), + ) + state.hasAapSession shouldBe false + } + + @Test + fun `equal inputs produce equal states - data class equality enables distinctUntilChanged`() { + val first = buildMonitorModeState( + mode = MonitorMode.AUTOMATIC, + profiles = listOf(profileA, profileB), + devices = listOf(mockDevice(addressA)), + aapStates = mapOf(addressA to aapStateA), + ) + val second = buildMonitorModeState( + mode = MonitorMode.AUTOMATIC, + profiles = listOf(profileA, profileB), + devices = listOf(mockDevice(addressA)), + aapStates = mapOf(addressA to aapStateA), + ) + first shouldBe second + } + + @Test + fun `reordered profile list with same addresses produces equal knownAddresses`() { + val first = buildMonitorModeState( + mode = MonitorMode.AUTOMATIC, + profiles = listOf(profileA, profileB), + devices = emptyList(), + aapStates = emptyMap(), + ) + val second = buildMonitorModeState( + mode = MonitorMode.AUTOMATIC, + profiles = listOf(profileB, profileA), + devices = emptyList(), + aapStates = emptyMap(), + ) + first.knownAddresses shouldBe second.knownAddresses + first shouldBe second + } + + @Test + fun `mode change produces unequal states`() { + val auto = buildMonitorModeState( + mode = MonitorMode.AUTOMATIC, + profiles = emptyList(), + devices = emptyList(), + aapStates = emptyMap(), + ) + val manual = buildMonitorModeState( + mode = MonitorMode.MANUAL, + profiles = emptyList(), + devices = emptyList(), + aapStates = emptyMap(), + ) + (auto == manual) shouldBe false + } +}