diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt index 13fba17c..b267c523 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt @@ -312,25 +312,31 @@ fun DeviceSettingsScreen( requiresUpgrade = !isPro, ) if (features.hasDualPods) { - val earDetectionActive = reactions.autoPlay || reactions.autoPause + val onePodModeActive = reactions.autoPlay || + reactions.autoPause || + (reactions.autoConnect && reactions.autoConnectCondition == AutoConnectCondition.IN_EAR) SettingsBaseItem( title = stringResource(R.string.settings_onepod_mode_label), subtitle = stringResource(R.string.settings_onepod_mode_description), icon = Icons.TwoTone.LooksOne, - onClick = { if (earDetectionActive) onOnePodModeChange(!reactions.onePodMode) }, - enabled = earDetectionActive, + onClick = { if (onePodModeActive) onOnePodModeChange(!reactions.onePodMode) }, + enabled = onePodModeActive, trailingContent = { Switch( checked = reactions.onePodMode, onCheckedChange = onOnePodModeChange, - enabled = earDetectionActive, + enabled = onePodModeActive, modifier = Modifier.padding(start = 16.dp), ) }, ) } val earDetectionWarningVisible = - (reactions.autoPlay || reactions.autoPause) && !device.isAapConnected + ( + reactions.autoPlay || + reactions.autoPause || + (reactions.autoConnect && reactions.autoConnectCondition == AutoConnectCondition.IN_EAR) + ) && !device.isAapConnected if (earDetectionWarningVisible) { SettingsInfoBox( text = stringResource(R.string.settings_eardetection_info_description), diff --git a/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt b/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt index 7f90dcbf..e42a4958 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt @@ -26,112 +26,154 @@ class PlayPause @Inject constructor( private val mediaControl: MediaControl, ) { - fun monitor() = deviceMonitor.primaryDevice() - .map { device -> device?.reactions?.let { it.autoPlay || it.autoPause } == true } - .distinctUntilChanged() - .flatMapLatest { shouldMonitor -> - if (shouldMonitor) bluetoothManager.connectedDevices else emptyFlow() - } - .flatMapLatest { connected -> - if (connected.isEmpty()) { - log(TAG) { "No known devices connected." } - emptyFlow() - } else { - log(TAG) { "Known devices connected: $connected" } - deviceMonitor.primaryDevice() - } - } - .distinctUntilChanged() - .withPrevious() - .filter { (previous, current) -> - if (previous == null || current == null) return@filter false - // Use profileId (stable across BLE address rotations) rather than BLE identifier. - // Only profiled devices reach this point (outer gate requires profile.autoPlay/autoPause). - val match = previous.profileId != null && previous.profileId == current.profileId - if (!match) log(TAG, WARN) { "Main device switched, skipping reaction." } - match - } - .onEach { (previous, current) -> - log(TAG, VERBOSE) { "Checking\nprevious=$previous\ncurrent=$current" } + fun monitor() = run { + var pendingPlayConfirmation: PendingPlayConfirmation? = null - val reactions = current?.reactions - if (reactions == null) { - log(TAG, VERBOSE) { "No reactions on current device, skipping reaction" } - return@onEach - } - - // Convert to EarDetectionState based on device capabilities - val prevState: EarDetectionState - val currState: EarDetectionState - - when { - previous!!.hasEarDetection && previous.hasDualPods && - current.hasEarDetection && current.hasDualPods -> { - // Dual pod devices (AirPods, AirPods Pro, etc.) - log(TAG, VERBOSE) { - "Dual-pod device: left=${current.isLeftInEar}, right=${current.isRightInEar}" - } - prevState = EarDetectionState.fromDualPod( - left = previous.isLeftInEar ?: false, - right = previous.isRightInEar ?: false, - ) - currState = EarDetectionState.fromDualPod( - left = current.isLeftInEar ?: false, - right = current.isRightInEar ?: false, - ) + deviceMonitor.primaryDevice() + .map { device -> device?.reactions?.let { it.autoPlay || it.autoPause } == true } + .distinctUntilChanged() + .flatMapLatest { shouldMonitor -> + if (shouldMonitor) { + bluetoothManager.connectedDevices + } else { + pendingPlayConfirmation = null + emptyFlow() } - - previous.hasEarDetection && current.hasEarDetection -> { - // Single pod devices (AirPods Max, etc.) - log(TAG, VERBOSE) { "Single-pod device: worn=${current.isBeingWorn}" } - prevState = EarDetectionState.fromSinglePod(worn = previous.isBeingWorn ?: false) - currState = EarDetectionState.fromSinglePod(worn = current.isBeingWorn ?: false) + } + .flatMapLatest { connected -> + if (connected.isEmpty()) { + pendingPlayConfirmation = null + log(TAG) { "No known devices connected." } + emptyFlow() + } else { + log(TAG) { "Known devices connected: $connected" } + deviceMonitor.primaryDevice() } + } + .distinctUntilChanged() + .withPrevious() + .filter { (previous, current) -> + if (previous == null || current == null) return@filter false + // Use profileId (stable across BLE address rotations) rather than BLE identifier. + // Only profiled devices reach this point (outer gate requires profile.autoPlay/autoPause). + val match = previous.profileId != null && previous.profileId == current.profileId + if (!match) log(TAG, WARN) { "Main device switched, skipping reaction." } + match + } + .onEach { (previous, current) -> + log(TAG, VERBOSE) { "Checking\nprevious=$previous\ncurrent=$current" } - else -> { - log(TAG, VERBOSE) { "Device doesn't support ear detection: $current" } + val reactions = current?.reactions + if (reactions == null) { + log(TAG, VERBOSE) { "No reactions on current device, skipping reaction" } + pendingPlayConfirmation = null return@onEach } - } - // Evaluate what action to take - val decision = evaluatePlayPauseAction( - previous = prevState, - current = currState, - onePodMode = reactions.onePodMode, - isCurrentlyPlaying = mediaControl.isPlaying, - wasRecentlyPausedByUs = mediaControl.wasRecentlyPausedByCap, - ) + // Convert to EarDetectionState based on device capabilities + val prevState: EarDetectionState + val currState: EarDetectionState - if (decision.usedRecentCapPauseOverride) { - log(TAG, VERBOSE) { - "Resume override: recent CAP pause window is active, allowing play despite playing=true" + when { + previous!!.hasEarDetection && previous.hasDualPods && + current.hasEarDetection && current.hasDualPods -> { + // Dual pod devices (AirPods, AirPods Pro, etc.) + log(TAG, VERBOSE) { + "Dual-pod device: left=${current.isLeftInEar}, right=${current.isRightInEar}" + } + prevState = EarDetectionState.fromDualPod( + left = previous.isLeftInEar ?: false, + right = previous.isRightInEar ?: false, + ) + currState = EarDetectionState.fromDualPod( + left = current.isLeftInEar ?: false, + right = current.isRightInEar ?: false, + ) + } + + previous.hasEarDetection && current.hasEarDetection -> { + // Single pod devices (AirPods Max, etc.) + log(TAG, VERBOSE) { "Single-pod device: worn=${current.isBeingWorn}" } + prevState = EarDetectionState.fromSinglePod(worn = previous.isBeingWorn ?: false) + currState = EarDetectionState.fromSinglePod(worn = current.isBeingWorn ?: false) + } + + else -> { + log(TAG, VERBOSE) { "Device doesn't support ear detection: $current" } + pendingPlayConfirmation = null + return@onEach + } + } + + val isCurrentlyPlaying = mediaControl.isPlaying + val wasRecentlyPausedByUs = mediaControl.wasRecentlyPausedByCap + + // Evaluate what action to take + val rawDecision = evaluatePlayPauseAction( + previous = prevState, + current = currState, + onePodMode = reactions.onePodMode, + isCurrentlyPlaying = isCurrentlyPlaying, + wasRecentlyPausedByUs = wasRecentlyPausedByUs, + ) + + val shouldStageBleOnlyPlay = rawDecision.shouldPlay && + reactions.autoPlay && + !reactions.onePodMode && + current.hasDualPods && + current.aap?.aapEarDetection == null + + val confirmation = applyBleOnlyPlayConfirmation( + pending = pendingPlayConfirmation, + profileId = current.profileId, + onePodMode = reactions.onePodMode, + autoPlayEnabled = reactions.autoPlay, + rawDecision = rawDecision, + currentState = currState, + shouldStageBleOnlyPlay = shouldStageBleOnlyPlay, + isCurrentlyPlaying = isCurrentlyPlaying, + wasRecentlyPausedByUs = wasRecentlyPausedByUs, + ) + pendingPlayConfirmation = confirmation.pending + + if (confirmation.stagedConfirmation) { + log(TAG, VERBOSE) { "Staging BLE-only autoplay confirmation for ${current.profileId}" } + } + if (confirmation.confirmedPendingPlay) { + log(TAG, VERBOSE) { "BLE-only autoplay confirmed by a follow-up state update" } + } + + val decision = confirmation.decision + if (decision.usedRecentCapPauseOverride) { + log(TAG, VERBOSE) { + "Resume override: recent CAP pause window is active, allowing play despite playing=true" + } + } + log(TAG, VERBOSE) { "Decision: ${decision.reason}" } + + // Execute the decision + when { + decision.shouldPlay && reactions.autoPlay -> { + log(TAG) { "autoPlay is triggered, sendPlay() - ${decision.reason}" } + mediaControl.sendPlay() + } + + decision.shouldPlay && !reactions.autoPlay -> { + log(TAG, VERBOSE) { "autoPlay is disabled" } + } + + decision.shouldPause && reactions.autoPause -> { + log(TAG) { "autoPause is triggered, sendPause() - ${decision.reason}" } + mediaControl.sendPause() + } + + decision.shouldPause && !reactions.autoPause -> { + log(TAG, VERBOSE) { "autoPause is disabled" } + } } } - log(TAG, VERBOSE) { "Decision: ${decision.reason}" } - - // Execute the decision - when { - decision.shouldPlay && reactions.autoPlay -> { - log(TAG) { "autoPlay is triggered, sendPlay() - ${decision.reason}" } - mediaControl.sendPlay() - } - - decision.shouldPlay && !reactions.autoPlay -> { - log(TAG, VERBOSE) { "autoPlay is disabled" } - } - - decision.shouldPause && reactions.autoPause -> { - log(TAG) { "autoPause is triggered, sendPause() - ${decision.reason}" } - mediaControl.sendPause() - } - - decision.shouldPause && !reactions.autoPause -> { - log(TAG, VERBOSE) { "autoPause is disabled" } - } - } - } - .setupCommonEventHandlers(TAG) { "monitor" } + .setupCommonEventHandlers(TAG) { "monitor" } + } internal fun evaluatePlayPauseAction( previous: EarDetectionState, @@ -212,6 +254,63 @@ class PlayPause @Inject constructor( } } + internal fun applyBleOnlyPlayConfirmation( + pending: PendingPlayConfirmation?, + profileId: String?, + onePodMode: Boolean, + autoPlayEnabled: Boolean, + rawDecision: PlayPauseDecision, + currentState: EarDetectionState, + shouldStageBleOnlyPlay: Boolean, + isCurrentlyPlaying: Boolean, + wasRecentlyPausedByUs: Boolean, + ): PlayConfirmationResult { + val activePending = pending?.takeIf { + it.profileId == profileId && it.onePodMode == onePodMode && autoPlayEnabled + } + + if (activePending != null && + currentState == activePending.targetState && + (!isCurrentlyPlaying || wasRecentlyPausedByUs) + ) { + return PlayConfirmationResult( + decision = PlayPauseDecision( + shouldPlay = true, + shouldPause = false, + reason = "${activePending.reason} (confirmed by a second state update)", + usedRecentCapPauseOverride = isCurrentlyPlaying && wasRecentlyPausedByUs, + ), + pending = null, + confirmedPendingPlay = true, + stagedConfirmation = false, + ) + } + + if (shouldStageBleOnlyPlay && profileId != null) { + return PlayConfirmationResult( + decision = rawDecision.copy( + shouldPlay = false, + reason = "${rawDecision.reason} (waiting for BLE confirmation)", + ), + pending = PendingPlayConfirmation( + profileId = profileId, + onePodMode = onePodMode, + targetState = currentState, + reason = rawDecision.reason, + ), + confirmedPendingPlay = false, + stagedConfirmation = true, + ) + } + + return PlayConfirmationResult( + decision = rawDecision, + pending = null, + confirmedPendingPlay = false, + stagedConfirmation = false, + ) + } + data class EarDetectionState( val leftInEar: Boolean?, // null for single pod devices val rightInEar: Boolean?, // null for single pod devices @@ -250,6 +349,20 @@ class PlayPause @Inject constructor( val usedRecentCapPauseOverride: Boolean = false, ) + data class PendingPlayConfirmation( + val profileId: String, + val onePodMode: Boolean, + val targetState: EarDetectionState, + val reason: String, + ) + + data class PlayConfirmationResult( + val decision: PlayPauseDecision, + val pending: PendingPlayConfirmation?, + val confirmedPendingPlay: Boolean, + val stagedConfirmation: Boolean, + ) + companion object { private val TAG = logTag("Reaction", "PlayPause") } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index af09f293..f5bc27dc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -80,7 +80,7 @@ Don\'t let the system group collected BLE data before forwarding it to us. One pod mode - Wearing both pods is not required, wearing a single pod is sufficient to trigger reactions. + Treat a single in-ear pod as worn. Auto play/pause and "In ear" auto-connect react to either pod instead of requiring both. Show case popup Show a popup when the device case is opened (experimental). Show connection popup @@ -528,4 +528,4 @@ Reset to defaults Reset all stem actions to defaults? - \ No newline at end of file + diff --git a/app/src/test/java/eu/darken/capod/reaction/core/playpause/PlayPauseLogicTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/playpause/PlayPauseLogicTest.kt index e3b0a79b..7d1fd6a7 100644 --- a/app/src/test/java/eu/darken/capod/reaction/core/playpause/PlayPauseLogicTest.kt +++ b/app/src/test/java/eu/darken/capod/reaction/core/playpause/PlayPauseLogicTest.kt @@ -477,6 +477,139 @@ class PlayPauseLogicTest : BaseTest() { } } + @Nested + inner class BleConfirmationTests { + + @Test + fun `ble-only normal mode autoplay waits for confirmation`() { + val rawDecision = playPause.evaluatePlayPauseAction( + previous = EarDetectionState.fromDualPod(left = true, right = false), + current = EarDetectionState.fromDualPod(left = true, right = true), + onePodMode = false, + isCurrentlyPlaying = false, + ) + + val result = playPause.applyBleOnlyPlayConfirmation( + pending = null, + profileId = "profile", + onePodMode = false, + autoPlayEnabled = true, + rawDecision = rawDecision, + currentState = EarDetectionState.fromDualPod(left = true, right = true), + shouldStageBleOnlyPlay = true, + isCurrentlyPlaying = false, + wasRecentlyPausedByUs = false, + ) + + result.decision.shouldPlay shouldBe false + result.decision.reason shouldBe "Normal mode: both pods in ear (waiting for BLE confirmation)" + result.pending shouldBe PlayPause.PendingPlayConfirmation( + profileId = "profile", + onePodMode = false, + targetState = EarDetectionState.fromDualPod(left = true, right = true), + reason = "Normal mode: both pods in ear", + ) + result.stagedConfirmation shouldBe true + result.confirmedPendingPlay shouldBe false + } + + @Test + fun `ble-only normal mode autoplay confirms on stable follow-up state`() { + val pending = PlayPause.PendingPlayConfirmation( + profileId = "profile", + onePodMode = false, + targetState = EarDetectionState.fromDualPod(left = true, right = true), + reason = "Normal mode: both pods in ear", + ) + + val rawDecision = playPause.evaluatePlayPauseAction( + previous = EarDetectionState.fromDualPod(left = true, right = true), + current = EarDetectionState.fromDualPod(left = true, right = true), + onePodMode = false, + isCurrentlyPlaying = false, + ) + + val result = playPause.applyBleOnlyPlayConfirmation( + pending = pending, + profileId = "profile", + onePodMode = false, + autoPlayEnabled = true, + rawDecision = rawDecision, + currentState = EarDetectionState.fromDualPod(left = true, right = true), + shouldStageBleOnlyPlay = false, + isCurrentlyPlaying = false, + wasRecentlyPausedByUs = false, + ) + + result.decision.shouldPlay shouldBe true + result.decision.reason shouldBe "Normal mode: both pods in ear (confirmed by a second state update)" + result.pending shouldBe null + result.stagedConfirmation shouldBe false + result.confirmedPendingPlay shouldBe true + } + + @Test + fun `aap-backed autoplay bypasses confirmation`() { + val rawDecision = playPause.evaluatePlayPauseAction( + previous = EarDetectionState.fromDualPod(left = true, right = false), + current = EarDetectionState.fromDualPod(left = true, right = true), + onePodMode = false, + isCurrentlyPlaying = false, + ) + + val result = playPause.applyBleOnlyPlayConfirmation( + pending = null, + profileId = "profile", + onePodMode = false, + autoPlayEnabled = true, + rawDecision = rawDecision, + currentState = EarDetectionState.fromDualPod(left = true, right = true), + shouldStageBleOnlyPlay = false, + isCurrentlyPlaying = false, + wasRecentlyPausedByUs = false, + ) + + result.decision.shouldPlay shouldBe true + result.pending shouldBe null + result.stagedConfirmation shouldBe false + result.confirmedPendingPlay shouldBe false + } + + @Test + fun `pending ble-only autoplay is dropped when state reverts`() { + val pending = PlayPause.PendingPlayConfirmation( + profileId = "profile", + onePodMode = false, + targetState = EarDetectionState.fromDualPod(left = true, right = true), + reason = "Normal mode: both pods in ear", + ) + + val rawDecision = playPause.evaluatePlayPauseAction( + previous = EarDetectionState.fromDualPod(left = true, right = true), + current = EarDetectionState.fromDualPod(left = true, right = false), + onePodMode = false, + isCurrentlyPlaying = false, + ) + + val result = playPause.applyBleOnlyPlayConfirmation( + pending = pending, + profileId = "profile", + onePodMode = false, + autoPlayEnabled = true, + rawDecision = rawDecision, + currentState = EarDetectionState.fromDualPod(left = true, right = false), + shouldStageBleOnlyPlay = false, + isCurrentlyPlaying = false, + wasRecentlyPausedByUs = false, + ) + + result.decision.shouldPlay shouldBe false + result.pending shouldBe null + result.stagedConfirmation shouldBe false + result.confirmedPendingPlay shouldBe false + } + } + @Nested inner class EarDetectionStateTests {