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 {