refactor(reaction): Switch to sticky resume flag with opt-in cold-wear

Replaces the origin-tracking machinery with a simpler model that matches Apple's iOS/macOS behavior: auto-play strictly resumes a CAP-dispatched pause, gated by a sticky boolean cleared on inactive→active transitions. The original fire-on-cold-wear behavior is preserved as a per-device opt-in 'Start music on wear' setting.
This commit is contained in:
Matthias Urhahn
2026-05-07 14:08:22 +02:00
committed by Matthias Urhahn
parent 278257998b
commit 17663bc759
10 changed files with 273 additions and 359 deletions
@@ -15,23 +15,34 @@ class MediaControl @Inject constructor(
private val audioManager: AudioManager,
private val timeSource: TimeSource,
) {
private var capPauseExpiryElapsedRealtime: Long = 0L
private val transitionLock = Any()
/**
* Set when [sendPause] dispatches a pause we expect to take effect, cleared when [sendPlay]
* dispatches a resume or when music transitions inactive→active from any source. Read by
* [PlayPause] to gate auto-resume on pod-in: only resume if we're the ones who paused.
*
* "Sticky" — no time-based expiry. The original 15-second window was too short for typical
* pod-out conversations and had a known race where music starting again via another source
* (e.g. user manually resumed) could trigger a stray play-key dispatch on the next pod-in.
*/
@Volatile private var capPaused: Boolean = false
@Volatile private var lastKnownMusicActive: Boolean = false
@Volatile private var externalStopAt: Long = NO_TIMESTAMP
@Volatile private var capPauseDispatchedAt: Long = NO_TIMESTAMP
private val playbackCallback = object : AudioManager.AudioPlaybackCallback() {
override fun onPlaybackConfigChanged(configs: List<AudioPlaybackConfiguration>) {
recordTransition(audioManager.isMusicActive)
val nowActive = audioManager.isMusicActive
if (!lastKnownMusicActive && nowActive) {
// Music started by some source (could be us via sendPlay or someone else).
// Either way, our pause memory is stale — drop it so a future pod-in doesn't
// re-fire a play key over already-active music.
capPaused = false
}
lastKnownMusicActive = nowActive
}
}
init {
// Seed from current state so we can't miss a true→false transition when
// MediaControl is constructed while music is already active.
// Seed the active flag from current state so we won't miss the next inactive→active
// transition if music is already playing when MediaControl is constructed.
lastKnownMusicActive = audioManager.isMusicActive
audioManager.registerAudioPlaybackCallback(playbackCallback, null)
}
@@ -40,74 +51,27 @@ class MediaControl @Inject constructor(
get() = audioManager.isMusicActive
val wasRecentlyPausedByCap: Boolean
get() = capPauseExpiryElapsedRealtime > timeSource.elapsedRealtime()
/**
* `true` when music has been stopped recently by something *other* than CAP — i.e. the
* user paused via the phone, the playing app stopped on its own, or playback ended.
*
* Used by [PlayPause] to suppress auto-play on pod re-insertion when the user clearly
* wanted music to stay stopped. Stays `false` for stops attributed to CAP — those are
* detected by [recordTransition] from the pending [capPauseDispatchedAt] set by
* [sendPause].
*/
val wasMusicExternallyStoppedRecently: Boolean
get() {
val nowActive = audioManager.isMusicActive
// Defense in depth: if the callback was missed (race, re-register, etc.), record
// the transition on read.
if (lastKnownMusicActive != nowActive) recordTransition(nowActive)
if (nowActive) return false
val stoppedAt = externalStopAt
return stoppedAt != NO_TIMESTAMP &&
timeSource.elapsedRealtime() - stoppedAt < EXTERNAL_STOP_WINDOW_MS
}
private fun recordTransition(nowActive: Boolean) = synchronized(transitionLock) {
if (lastKnownMusicActive && !nowActive) {
val now = timeSource.elapsedRealtime()
val pendingAt = capPauseDispatchedAt
val byCap = pendingAt != NO_TIMESTAMP &&
(now - pendingAt) < CAP_PAUSE_ATTRIBUTION_TTL_MS
if (byCap) {
// Consume the pending CAP attribution; clear any stale prior external stop
// since we're attributing the *current* state-of-music to CAP.
capPauseDispatchedAt = NO_TIMESTAMP
externalStopAt = NO_TIMESTAMP
} else {
externalStopAt = now
// Stale pending dispatch (TTL exceeded — pause was probably ignored). Drop it
// so a future stop isn't misattributed.
capPauseDispatchedAt = NO_TIMESTAMP
}
} else if (!lastKnownMusicActive && nowActive) {
// Music is active again — any pending CAP attribution is stale, and any prior
// external stop is no longer "recent" (music has been resumed since).
capPauseDispatchedAt = NO_TIMESTAMP
externalStopAt = NO_TIMESTAMP
}
lastKnownMusicActive = nowActive
}
get() = capPaused
suspend fun sendPlay() {
log(TAG, INFO) { "sendPlay()" }
if (audioManager.isMusicActive && !wasRecentlyPausedByCap) {
if (audioManager.isMusicActive && !capPaused) {
log(TAG, INFO) { "Music is already playing, not sending play" }
return
}
sendKey(KeyEvent.KEYCODE_MEDIA_PLAY)
clearRecentCapPause()
capPaused = false
}
/**
* Dispatches a MEDIA_PAUSE key event if music is currently playing.
*
* Returns `true` when a key event was actually dispatched (and the 15-second
* [wasRecentlyPausedByCap] window was set), `false` when the call was a no-op because
* nothing was playing. Callers that need to distinguish "we actually paused" from "there
* was nothing to pause" — e.g. the sleep reaction, which gates its notification and
* cooldown on a real pause — should branch on the return value rather than checking
* [isPlaying] themselves to avoid a check-then-act race with the audio system.
* Returns `true` when a key event was actually dispatched (and the [wasRecentlyPausedByCap]
* flag was set), `false` when the call was a no-op because nothing was playing. Callers that
* need to distinguish "we actually paused" from "there was nothing to pause" — e.g. the sleep
* reaction, which gates its notification and cooldown on a real pause — should branch on the
* return value rather than checking [isPlaying] themselves to avoid a check-then-act race
* with the audio system.
*/
suspend fun sendPause(): Boolean {
log(TAG, INFO) { "sendPause()" }
@@ -115,20 +79,19 @@ class MediaControl @Inject constructor(
log(TAG, INFO) { "Music is not playing, not sending pause" }
return false
}
// Set BEFORE dispatch so the resulting active→inactive transition (whether observed
// by the playback callback or detected by the getter's read-time fallback) attributes
// the stop to CAP. Held under transitionLock for memory ordering against recordTransition.
synchronized(transitionLock) {
capPauseDispatchedAt = timeSource.elapsedRealtime()
}
// Set BEFORE the suspending sendKey() call. If we set after, an inactive→active
// playback callback that fires during the dispatch (e.g. a fast user resume on the
// phone, or another app grabbing audio focus and immediately starting) could clear
// capPaused, and then we'd overwrite it back to true on a stale pause — leaving the
// sticky flag set while music is genuinely playing.
capPaused = true
sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE)
markRecentCapPause()
return true
}
suspend fun sendPlayPause() {
log(TAG) { "sendPlayPause()" }
if (wasRecentlyPausedByCap) {
if (capPaused) {
sendPlay()
return
}
@@ -174,23 +137,7 @@ class MediaControl @Inject constructor(
)
}
private fun markRecentCapPause() {
capPauseExpiryElapsedRealtime = timeSource.elapsedRealtime() + RECENT_CAP_PAUSE_WINDOW_MS
}
private fun clearRecentCapPause() {
capPauseExpiryElapsedRealtime = 0L
}
companion object {
private val TAG = logTag("MediaControl")
private const val RECENT_CAP_PAUSE_WINDOW_MS = 15_000L
private const val EXTERNAL_STOP_WINDOW_MS = 60_000L
// TTL for a pending CAP-pause attribution. Long enough to cover delayed/missed
// playback-config callbacks (the read-time fallback may fire many seconds later);
// short enough that an "ignored pause" doesn't wrongly claim a much later external
// stop as CAP-attributed.
private const val CAP_PAUSE_ATTRIBUTION_TTL_MS = 30_000L
private const val NO_TIMESTAMP = -1L
}
}
@@ -167,6 +167,7 @@ fun DeviceSettingsScreenHost(
onOnePodModeChange = { vm.setOnePodMode(it) },
onAutoPlayChange = { vm.setAutoPlay(it) },
onAutoPauseChange = { vm.setAutoPause(it) },
onStartMusicOnWearChange = { vm.setStartMusicOnWear(it) },
onAutoConnectChange = { vm.setAutoConnect(it) },
onAutoConnectConditionChange = { vm.setAutoConnectCondition(it) },
onShowPopUpOnCaseOpenChange = { vm.setShowPopUpOnCaseOpen(it) },
@@ -205,6 +206,7 @@ fun DeviceSettingsScreen(
onOnePodModeChange: (Boolean) -> Unit = {},
onAutoPlayChange: (Boolean) -> Unit = {},
onAutoPauseChange: (Boolean) -> Unit = {},
onStartMusicOnWearChange: (Boolean) -> Unit = {},
onAutoConnectChange: (Boolean) -> Unit = {},
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
@@ -342,6 +344,7 @@ fun DeviceSettingsScreen(
monitorMode = state.monitorMode,
onAutoPlayChange = onAutoPlayChange,
onAutoPauseChange = onAutoPauseChange,
onStartMusicOnWearChange = onStartMusicOnWearChange,
onOnePodModeChange = onOnePodModeChange,
onConversationalAwarenessChange = onConversationalAwarenessChange,
onSleepDetectionChange = onSleepDetectionChange,
@@ -382,6 +382,15 @@ class DeviceSettingsViewModel @Inject constructor(
syncEarDetection(autoPause = enabled)
}
fun setStartMusicOnWear(enabled: Boolean) = launch {
log(TAG, INFO) { "setStartMusicOnWear($enabled)" }
if (enabled && !upgradeRepo.isPro()) {
navTo(Nav.Main.Upgrade)
return@launch
}
updateProfileNow { it.copy(startMusicOnWear = enabled) }
}
/**
* Keeps the device-side Automatic Ear Detection setting in sync with the
* auto-play / auto-pause reaction toggles. When either reaction is active
@@ -48,6 +48,7 @@ internal fun ReactionsCard(
monitorMode: MonitorMode,
onAutoPlayChange: (Boolean) -> Unit = {},
onAutoPauseChange: (Boolean) -> Unit = {},
onStartMusicOnWearChange: (Boolean) -> Unit = {},
onOnePodModeChange: (Boolean) -> Unit = {},
onConversationalAwarenessChange: (Boolean) -> Unit = {},
onSleepDetectionChange: (Boolean) -> Unit = {},
@@ -73,6 +74,16 @@ internal fun ReactionsCard(
onCheckedChange = onAutoPlayChange,
requiresUpgrade = !isPro,
)
if (reactions.autoPlay) {
SettingsSwitchItem(
icon = Icons.TwoTone.PlayCircle,
title = stringResource(R.string.settings_start_music_on_wear_label),
subtitle = stringResource(R.string.settings_start_music_on_wear_description),
checked = reactions.startMusicOnWear,
onCheckedChange = onStartMusicOnWearChange,
requiresUpgrade = !isPro,
)
}
SettingsSwitchItem(
icon = Icons.TwoTone.PauseCircle,
title = stringResource(R.string.settings_autopause_label),
@@ -25,6 +25,7 @@ data class AppleDeviceProfile(
@SerialName("address") override val address: String? = null,
@SerialName("reactionAutoPause") val autoPause: Boolean = false,
@SerialName("reactionAutoPlay") val autoPlay: Boolean = false,
@SerialName("reactionStartMusicOnWear") val startMusicOnWear: Boolean = false,
@SerialName("reactionOnePodMode") val onePodMode: Boolean = false,
@SerialName("reactionAutoConnect") val autoConnect: Boolean = false,
@SerialName("reactionAutoConnectCondition") val autoConnectCondition: AutoConnectCondition = AutoConnectCondition.WHEN_SEEN,
@@ -49,6 +50,7 @@ data class AppleDeviceProfile(
get() = ReactionConfig(
autoPause = autoPause,
autoPlay = autoPlay,
startMusicOnWear = startMusicOnWear,
onePodMode = onePodMode,
autoConnect = autoConnect,
autoConnectCondition = autoConnectCondition,
@@ -62,6 +64,7 @@ data class AppleDeviceProfile(
"identityKey=${if (identityKey == null) "null" else "<redacted>"}, " +
"encryptionKey=${if (encryptionKey == null) "null" else "<redacted>"}, " +
"address=$address, autoPause=$autoPause, autoPlay=$autoPlay, " +
"startMusicOnWear=$startMusicOnWear, " +
"onePodMode=$onePodMode, autoConnect=$autoConnect, " +
"autoConnectCondition=$autoConnectCondition, " +
"showPopUpOnCaseOpen=$showPopUpOnCaseOpen, " +
@@ -5,6 +5,7 @@ import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
data class ReactionConfig(
val autoPause: Boolean = false,
val autoPlay: Boolean = false,
val startMusicOnWear: Boolean = false,
val onePodMode: Boolean = false,
val autoConnect: Boolean = false,
val autoConnectCondition: AutoConnectCondition = AutoConnectCondition.WHEN_SEEN,
@@ -169,7 +169,6 @@ class PlayPause @Inject constructor(
val isCurrentlyPlaying = mediaControl.isPlaying
val wasRecentlyPausedByUs = mediaControl.wasRecentlyPausedByCap
val wasMusicExternallyStoppedRecently = mediaControl.wasMusicExternallyStoppedRecently
val source = current.earDetectionSource()
@@ -180,7 +179,7 @@ class PlayPause @Inject constructor(
onePodMode = reactions.onePodMode,
isCurrentlyPlaying = isCurrentlyPlaying,
wasRecentlyPausedByUs = wasRecentlyPausedByUs,
wasMusicExternallyStoppedRecently = wasMusicExternallyStoppedRecently,
startMusicOnWear = reactions.startMusicOnWear,
)
// BLE-only autoplay confirmation only applies to UNAUTHENTICATED sources.
@@ -206,7 +205,7 @@ class PlayPause @Inject constructor(
shouldStageBleOnlyPlay = shouldStageBleOnlyPlay,
isCurrentlyPlaying = isCurrentlyPlaying,
wasRecentlyPausedByUs = wasRecentlyPausedByUs,
wasMusicExternallyStoppedRecently = wasMusicExternallyStoppedRecently,
startMusicOnWear = reactions.startMusicOnWear,
)
pendingPlayConfirmation = confirmation.pending
@@ -291,11 +290,11 @@ class PlayPause @Inject constructor(
onePodMode: Boolean,
isCurrentlyPlaying: Boolean,
wasRecentlyPausedByUs: Boolean = false,
wasMusicExternallyStoppedRecently: Boolean = false,
startMusicOnWear: Boolean = false,
): PlayPauseDecision = if (onePodMode) {
evaluateOnePodMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs, wasMusicExternallyStoppedRecently)
evaluateOnePodMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs, startMusicOnWear)
} else {
evaluateNormalMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs, wasMusicExternallyStoppedRecently)
evaluateNormalMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs, startMusicOnWear)
}
private fun evaluateOnePodMode(
@@ -303,7 +302,7 @@ class PlayPause @Inject constructor(
current: EarDetectionState,
isCurrentlyPlaying: Boolean,
wasRecentlyPausedByUs: Boolean,
wasMusicExternallyStoppedRecently: Boolean,
startMusicOnWear: Boolean,
): PlayPauseDecision {
val netChange = current.podCount - previous.podCount
@@ -315,13 +314,12 @@ class PlayPause @Inject constructor(
reason = "One-pod mode: pod(s) removed (net change: $netChange)"
)
// Net increase: pod(s) inserted → play, but only when either we paused recently
// (resume our pause / handle the AudioManager.isMusicActive race) or there is no
// sign of a recent external stop (cold start). Suppress autoplay otherwise so
// CAP doesn't override a user-initiated pause.
// Net increase: pod(s) inserted → play, but only when we paused recently OR the user
// opted in to firing a play key on cold wear. Without an opt-in, suppress autoplay
// so we never override a user-initiated pause or fire over silence.
netChange > 0 && (
wasRecentlyPausedByUs ||
(!isCurrentlyPlaying && !wasMusicExternallyStoppedRecently)
(startMusicOnWear && !isCurrentlyPlaying)
) -> PlayPauseDecision(
shouldPlay = true,
shouldPause = false,
@@ -343,19 +341,18 @@ class PlayPause @Inject constructor(
current: EarDetectionState,
isCurrentlyPlaying: Boolean,
wasRecentlyPausedByCap: Boolean,
wasMusicExternallyStoppedRecently: Boolean,
startMusicOnWear: Boolean,
): PlayPauseDecision {
val wasWorn = previous.bothInEar
val isWorn = current.bothInEar
return when {
// Transition: not worn → worn → play, but only when either we paused recently
// (resume our pause / handle the AudioManager.isMusicActive race) or there is no
// sign of a recent external stop (cold start). Suppress autoplay otherwise so
// CAP doesn't override a user-initiated pause.
// Transition: not worn → worn → play, but only when we paused recently OR the user
// opted in to firing a play key on cold wear. Without an opt-in, suppress autoplay
// so we never override a user-initiated pause or fire over silence.
!wasWorn && isWorn && (
wasRecentlyPausedByCap ||
(!isCurrentlyPlaying && !wasMusicExternallyStoppedRecently)
(startMusicOnWear && !isCurrentlyPlaying)
) -> PlayPauseDecision(
shouldPlay = true,
shouldPause = false,
@@ -389,7 +386,7 @@ class PlayPause @Inject constructor(
shouldStageBleOnlyPlay: Boolean,
isCurrentlyPlaying: Boolean,
wasRecentlyPausedByUs: Boolean,
wasMusicExternallyStoppedRecently: Boolean = false,
startMusicOnWear: Boolean = false,
): PlayConfirmationResult {
val activePending = pending?.takeIf {
it.profileId == profileId && it.onePodMode == onePodMode && autoPlayEnabled
@@ -399,7 +396,7 @@ class PlayPause @Inject constructor(
currentState == activePending.targetState &&
(
wasRecentlyPausedByUs ||
(!isCurrentlyPlaying && !wasMusicExternallyStoppedRecently)
(startMusicOnWear && !isCurrentlyPlaying)
)
) {
return PlayConfirmationResult(
+3 -1
View File
@@ -53,7 +53,9 @@
<string name="settings_autopause_label">Auto pause</string>
<string name="settings_autopause_description">Pause audio when removing the device from your ear.</string>
<string name="settings_autopplay_label">Auto play</string>
<string name="settings_autoplay_description">Start audio playback when device is worn.</string>
<string name="settings_autoplay_description">Resume music when wearing your pods again, if Auto pause stopped it. Music you paused yourself stays paused.</string>
<string name="settings_start_music_on_wear_label">Start music on wear</string>
<string name="settings_start_music_on_wear_description">Always tries to play music when wearing your pods. May also restart music you paused yourself.</string>
<string name="settings_eardetection_info_label">Ear detection note</string>
<string name="settings_eardetection_info_description">If ear detection only works for one pod, this is an Apple limitation. Only the \"primary pod\" (used for microphone) is detected. Configure on Apple devices: Settings → Bluetooth → AirPods → Microphone.</string>
<string name="settings_fake_data_label">Fake data</string>