From dbbfa75429185814aebe543ecfe18aa936248193 Mon Sep 17 00:00:00 2001 From: darken Date: Mon, 25 May 2026 23:27:48 +0200 Subject: [PATCH] feat(reaction): Add Conversational Awareness media reaction Reacts to the AirPods speaking-detection event (AAP 0x4B): lowers media volume by a configurable amount or pauses playback when you start talking, and reverts when you stop. Per-profile, Pro-gated, opt-in (default off). Decodes the speaking status from the last payload byte ({1,2}=start, {6,8,9}=stop, else keep-alive); engage/disengage with a frame-idle stale timeout to recover a dropped stop, plus disconnect and service-stop cleanup. --- .../eu/darken/capod/common/MediaControl.kt | 66 ++++ .../ui/devicesettings/DeviceSettingsScreen.kt | 7 + .../devicesettings/DeviceSettingsViewModel.kt | 17 ++ .../ui/devicesettings/cards/ReactionsCard.kt | 56 +++- .../dialogs/ConversationActionDialog.kt | 75 +++++ .../monitor/core/worker/MonitorService.kt | 7 + .../core/apple/aap/AapConnectionManager.kt | 19 ++ .../core/apple/aap/engine/AapConnection.kt | 2 + .../core/apple/aap/engine/AapSessionEngine.kt | 13 + .../core/apple/aap/protocol/AapSetting.kt | 10 +- .../protocol/ConversationAwarenessEvent.kt | 31 ++ .../aap/protocol/DefaultAapDeviceProfile.kt | 22 +- .../capod/profiles/core/AppleDeviceProfile.kt | 7 + .../capod/profiles/core/ReactionConfig.kt | 14 +- .../core/conversation/ConversationAction.kt | 28 ++ .../core/conversation/ConversationReaction.kt | 281 ++++++++++++++++++ app/src/main/res/values/strings.xml | 5 + .../devices/DefaultAapDeviceProfileTest.kt | 42 +++ .../apple/aap/engine/AapSessionEngineTest.kt | 45 +++ .../conversation/ConversationReactionTest.kt | 275 +++++++++++++++++ 20 files changed, 1010 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/main/ui/devicesettings/dialogs/ConversationActionDialog.kt create mode 100644 app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/ConversationAwarenessEvent.kt create mode 100644 app/src/main/java/eu/darken/capod/reaction/core/conversation/ConversationAction.kt create mode 100644 app/src/main/java/eu/darken/capod/reaction/core/conversation/ConversationReaction.kt create mode 100644 app/src/test/java/eu/darken/capod/reaction/core/conversation/ConversationReactionTest.kt diff --git a/app/src/main/java/eu/darken/capod/common/MediaControl.kt b/app/src/main/java/eu/darken/capod/common/MediaControl.kt index 46310600..9c0af6f4 100644 --- a/app/src/main/java/eu/darken/capod/common/MediaControl.kt +++ b/app/src/main/java/eu/darken/capod/common/MediaControl.kt @@ -2,8 +2,10 @@ package eu.darken.capod.common import android.media.AudioManager import android.media.AudioPlaybackConfiguration +import android.os.Build import android.view.KeyEvent import eu.darken.capod.common.debug.logging.Logging.Priority.INFO +import eu.darken.capod.common.debug.logging.Logging.Priority.WARN import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import kotlinx.coroutines.delay @@ -157,6 +159,70 @@ class MediaControl @Inject constructor( ) } + /** Current STREAM_MUSIC volume index. Used to detect user-initiated volume changes after a duck. */ + fun currentMusicVolume(): Int = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + + /** + * Lowers STREAM_MUSIC volume by [reductionPercent] (relative to the current level) and returns + * the prior + the volume actually applied, so the caller can later restore it and detect whether + * the user changed the volume in the meantime. + * + * Returns `null` (no-op) when nothing is playing, the device has fixed volume, or the computed + * target wouldn't actually lower the volume. No [AudioManager.FLAG_SHOW_UI] — this fires on a + * frequent push event and the volume panel flashing would be noisy. The applied target is read + * back from the system because Bluetooth absolute-volume routes can quantize the requested value. + */ + fun duckMusicVolume(reductionPercent: Int): VolumeDuck? { + if (!audioManager.isMusicActive) { + log(TAG, INFO) { "duckMusicVolume: nothing playing, skipping" } + return null + } + if (audioManager.isVolumeFixed) { + log(TAG, INFO) { "duckMusicVolume: device has fixed volume, skipping" } + return null + } + val percent = reductionPercent.coerceIn(0, 100) + val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + val min = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + audioManager.getStreamMinVolume(AudioManager.STREAM_MUSIC) + } else { + 0 + } + val prior = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + val target = (prior * (100 - percent) / 100).coerceIn(min, max) + if (target >= prior) { + log(TAG, INFO) { "duckMusicVolume: target $target >= current $prior, skipping" } + return null + } + return try { + audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0) + val applied = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + log(TAG, INFO) { "duckMusicVolume($percent%): $prior -> $applied (requested $target)" } + VolumeDuck(priorVolume = prior, appliedVolume = applied) + } catch (e: SecurityException) { + // setStreamVolume throws under Do-Not-Disturb without notification policy access. + log(TAG, WARN) { "duckMusicVolume: setStreamVolume denied: ${e.message}" } + null + } + } + + /** Restores STREAM_MUSIC to [priorVolume]. No-op on fixed-volume devices; denials are logged. */ + fun restoreMusicVolume(priorVolume: Int) { + if (audioManager.isVolumeFixed) return + try { + audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, priorVolume, 0) + log(TAG, INFO) { "restoreMusicVolume($priorVolume)" } + } catch (e: SecurityException) { + log(TAG, WARN) { "restoreMusicVolume: setStreamVolume denied: ${e.message}" } + } + } + + /** Snapshot of a volume duck so the caller can restore the prior level and detect user changes. */ + data class VolumeDuck( + val priorVolume: Int, + val appliedVolume: Int, + ) + companion object { private val TAG = logTag("MediaControl") } 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 1bec765b..31e3fcc5 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 @@ -64,6 +64,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.ble.devices.HasStateDetection import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition +import eu.darken.capod.reaction.core.conversation.ConversationAction import java.time.Duration import java.time.Instant import java.time.ZoneId @@ -148,6 +149,8 @@ fun DeviceSettingsScreenHost( onNavigateUp = { vm.navUp() }, onAncModeChange = { vm.setAncMode(it) }, onConversationalAwarenessChange = { vm.setConversationalAwareness(it) }, + onConversationActionChange = { vm.setConversationAction(it) }, + onConversationVolumeReductionChange = { vm.setConversationVolumeReduction(it) }, onNcWithOneAirPodChange = { vm.setNcWithOneAirPod(it) }, onPersonalizedVolumeChange = { vm.setPersonalizedVolume(it) }, onToneVolumeChange = { vm.setToneVolume(it) }, @@ -186,6 +189,8 @@ fun DeviceSettingsScreen( onNavigateUp: () -> Unit, onAncModeChange: (AapSetting.AncMode.Value) -> Unit = {}, onConversationalAwarenessChange: (Boolean) -> Unit = {}, + onConversationActionChange: (ConversationAction) -> Unit = {}, + onConversationVolumeReductionChange: (Int) -> Unit = {}, onNcWithOneAirPodChange: (Boolean) -> Unit = {}, onPersonalizedVolumeChange: (Boolean) -> Unit = {}, onToneVolumeChange: (Int) -> Unit = {}, @@ -344,6 +349,8 @@ fun DeviceSettingsScreen( onStartMusicOnWearChange = onStartMusicOnWearChange, onOnePodModeChange = onOnePodModeChange, onConversationalAwarenessChange = onConversationalAwarenessChange, + onConversationActionChange = onConversationActionChange, + onConversationVolumeReductionChange = onConversationVolumeReductionChange, onSleepDetectionChange = onSleepDetectionChange, onAutoConnectChange = onAutoConnectChange, onAutoConnectConditionChange = onAutoConnectConditionChange, diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt index 898ac1b9..09d1dfbf 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt @@ -31,6 +31,7 @@ import eu.darken.capod.profiles.core.DeviceProfilesRepo import eu.darken.capod.profiles.core.ProfileId import eu.darken.capod.profiles.core.ReactionConfig import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition +import eu.darken.capod.reaction.core.conversation.ConversationAction import eu.darken.capod.reaction.core.stem.StemAction import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -437,6 +438,22 @@ class DeviceSettingsViewModel @Inject constructor( proGatedReaction(enabled) { it.copy(showPopUpOnConnection = enabled) } } + fun setConversationAction(action: ConversationAction) = launch { + log(TAG, INFO) { "setConversationAction($action)" } + // The action picker is only shown while Conversation Awareness is already enabled (the pod + // emits no speaking frames otherwise), so no need to auto-enable it here. + if (action != ConversationAction.NOTHING && !upgradeRepo.isPro()) { + navTo(Nav.Main.Upgrade) + return@launch + } + updateProfileNow { it.copy(conversationAction = action) } + } + + fun setConversationVolumeReduction(percent: Int) = launch { + log(TAG, INFO) { "setConversationVolumeReduction($percent)" } + updateProfileNow { it.copy(conversationVolumeReduction = percent) } + } + fun navToPressControls() = launch { log(TAG, INFO) { "navToPressControls()" } diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ReactionsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ReactionsCard.kt index dd94b420..a6eb9e09 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ReactionsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ReactionsCard.kt @@ -4,12 +4,14 @@ import android.os.Build import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.twotone.Message +import androidx.compose.material.icons.automirrored.twotone.VolumeDown import androidx.compose.material.icons.twotone.BluetoothConnected import androidx.compose.material.icons.twotone.Hearing import androidx.compose.material.icons.twotone.LooksOne import androidx.compose.material.icons.twotone.Nightlight import androidx.compose.material.icons.twotone.PauseCircle import androidx.compose.material.icons.twotone.PlayCircle +import androidx.compose.material.icons.twotone.RecordVoiceOver import androidx.compose.material.icons.twotone.Workspaces import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -29,15 +31,20 @@ import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.settings.InfoBoxType import eu.darken.capod.common.settings.SettingsBaseItem +import eu.darken.capod.common.settings.SettingsCategoryHeader import eu.darken.capod.common.settings.SettingsInfoBox import eu.darken.capod.common.settings.SettingsSection +import eu.darken.capod.common.settings.SettingsSliderItem import eu.darken.capod.common.settings.SettingsSwitchItem import eu.darken.capod.main.ui.devicesettings.dialogs.AutoConnectConditionDialog +import eu.darken.capod.main.ui.devicesettings.dialogs.ConversationActionDialog import eu.darken.capod.main.ui.devicesettings.previewFullState import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.pods.core.apple.PodModel import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting +import eu.darken.capod.profiles.core.ReactionConfig import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition +import eu.darken.capod.reaction.core.conversation.ConversationAction @Composable internal fun ReactionsCard( @@ -49,6 +56,8 @@ internal fun ReactionsCard( onStartMusicOnWearChange: (Boolean) -> Unit = {}, onOnePodModeChange: (Boolean) -> Unit = {}, onConversationalAwarenessChange: (Boolean) -> Unit = {}, + onConversationActionChange: (ConversationAction) -> Unit = {}, + onConversationVolumeReductionChange: (Int) -> Unit = {}, onSleepDetectionChange: (Boolean) -> Unit = {}, onAutoConnectChange: (Boolean) -> Unit = {}, onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {}, @@ -60,6 +69,7 @@ internal fun ReactionsCard( val enabled = device.isAapReady var showAutoConnectConditionDialog by remember { mutableStateOf(false) } + var showConversationActionDialog by remember { mutableStateOf(false) } SettingsSection(title = stringResource(R.string.settings_reaction_label)) { if (features.hasEarDetection) { @@ -125,20 +135,45 @@ internal fun ReactionsCard( } if (device.isAapConnected) { val convAwareness = device.conversationalAwareness - val hasAnyAapReaction = - (features.hasConversationAwareness && convAwareness != null) || - features.hasSleepDetection - if (features.hasConversationAwareness && convAwareness != null) { + val showConversationAwareness = features.hasConversationAwareness && convAwareness != null + val hasAnyAapReaction = showConversationAwareness || features.hasSleepDetection + if (showConversationAwareness) { + SettingsCategoryHeader(text = stringResource(R.string.conversation_awareness_label)) SettingsSwitchItem( icon = Icons.TwoTone.Hearing, title = stringResource(R.string.conversation_awareness_label), subtitle = stringResource(R.string.device_settings_conversation_awareness_description), - checked = convAwareness.enabled, + checked = convAwareness?.enabled == true, onCheckedChange = onConversationalAwarenessChange, enabled = enabled, ) + // The "when you start speaking" reaction only fires while CA is on (the pod emits no + // speaking frames otherwise), so only surface it once CA is actually enabled. + if (convAwareness?.enabled == true) { + SettingsBaseItem( + icon = Icons.TwoTone.RecordVoiceOver, + title = stringResource(R.string.settings_conversation_action_label), + subtitle = stringResource(reactions.conversationAction.labelRes), + onClick = { showConversationActionDialog = true }, + enabled = enabled, + requiresUpgrade = !isPro, + ) + if (reactions.conversationAction == ConversationAction.LOWER_VOLUME) { + SettingsSliderItem( + icon = Icons.AutoMirrored.TwoTone.VolumeDown, + title = stringResource(R.string.settings_conversation_volume_reduction_label), + value = reactions.conversationVolumeReduction.toFloat(), + onValueChange = { onConversationVolumeReductionChange(it.toInt()) }, + valueRange = ReactionConfig.MIN_CONVERSATION_VOLUME_REDUCTION.toFloat().. + ReactionConfig.MAX_CONVERSATION_VOLUME_REDUCTION.toFloat(), + enabled = enabled, + valueLabel = { "${it.toInt()}%" }, + ) + } + } } if (features.hasSleepDetection) { + if (showConversationAwareness) ReactionsDivider() val sleepDet = device.sleepDetection ?: AapSetting.SleepDetection(enabled = true) SettingsSwitchItem( @@ -213,6 +248,17 @@ internal fun ReactionsCard( } } + if (showConversationActionDialog) { + ConversationActionDialog( + current = reactions.conversationAction, + onSelect = { + onConversationActionChange(it) + showConversationActionDialog = false + }, + onDismiss = { showConversationActionDialog = false }, + ) + } + if (showAutoConnectConditionDialog) { AutoConnectConditionDialog( current = reactions.autoConnectCondition, diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/dialogs/ConversationActionDialog.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/dialogs/ConversationActionDialog.kt new file mode 100644 index 00000000..336f38ab --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/dialogs/ConversationActionDialog.kt @@ -0,0 +1,75 @@ +package eu.darken.capod.main.ui.devicesettings.dialogs + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import eu.darken.capod.R +import eu.darken.capod.common.compose.Preview2 +import eu.darken.capod.common.compose.PreviewWrapper +import eu.darken.capod.reaction.core.conversation.ConversationAction + +@Composable +internal fun ConversationActionDialog( + current: ConversationAction, + onSelect: (ConversationAction) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(text = stringResource(R.string.settings_conversation_action_label)) }, + text = { + Column(Modifier.selectableGroup()) { + ConversationAction.entries.forEach { action -> + val isSelected = action == current + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = isSelected, + onClick = { onSelect(action) }, + role = Role.RadioButton, + ) + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = isSelected, onClick = null) + Text( + text = stringResource(action.labelRes), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 16.dp), + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + ) +} + +@Preview2 +@Composable +private fun ConversationActionDialogPreview() = PreviewWrapper { + ConversationActionDialog( + current = ConversationAction.LOWER_VOLUME, + onSelect = {}, + onDismiss = {}, + ) +} 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 7668e484..4a4fc36e 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 @@ -42,6 +42,7 @@ import eu.darken.capod.profiles.core.DeviceProfilesRepo import eu.darken.capod.reaction.core.autoconnect.AutoConnect import eu.darken.capod.reaction.core.playpause.PlayPause import eu.darken.capod.reaction.core.popup.PopUpReaction +import eu.darken.capod.reaction.core.conversation.ConversationReaction import eu.darken.capod.reaction.core.sleep.SleepReaction import eu.darken.capod.reaction.ui.popup.PopUpWindow import kotlinx.coroutines.CancellationException @@ -78,6 +79,7 @@ class MonitorService : Service() { @Inject lateinit var autoConnect: AutoConnect @Inject lateinit var popUpReaction: PopUpReaction @Inject lateinit var sleepReaction: SleepReaction + @Inject lateinit var conversationReaction: ConversationReaction @Inject lateinit var popUpWindow: PopUpWindow @Inject lateinit var profilesRepo: DeviceProfilesRepo @Inject lateinit var aapConnectionManager: AapConnectionManager @@ -331,6 +333,11 @@ class MonitorService : Service() { .catch { log(TAG, WARN) { "sleepReaction failed:\n${it.asLog()}" } } .launchIn(monitorScope) + conversationReaction.monitor() + .setupCommonEventHandlers(TAG) { "conversationReaction" } + .catch { log(TAG, WARN) { "conversationReaction failed:\n${it.asLog()}" } } + .launchIn(monitorScope) + log(TAG, VERBOSE) { "Monitor job is active" } monitorJob.join() log(TAG, VERBOSE) { "Monitor job quit" } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt index e7306229..8f1ecc19 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt @@ -11,6 +11,7 @@ import eu.darken.capod.pods.core.apple.PodModel import eu.darken.capod.pods.core.apple.aap.engine.AapConnection import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile +import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent import kotlinx.coroutines.CoroutineScope @@ -73,6 +74,17 @@ class AapConnectionManager @Inject constructor( private val _sleepEvents = MutableSharedFlow(extraBufferCapacity = 16) val sleepEvents: SharedFlow = _sleepEvents.asSharedFlow() + /** + * Emits when a connected device reports a Conversational Awareness speaking transition + * (START/STOP, AAP command 0x4B). Paired with the origin address so the conversation + * reaction can gate on the primary device. Only the known 0x01/0x04 markers reach here — + * the engine drops unknown raw values (see [AapSessionEngine]). + */ + private val _conversationalAwarenessEvents = + MutableSharedFlow>(extraBufferCapacity = 16) + val conversationalAwarenessEvents: SharedFlow> = + _conversationalAwarenessEvents.asSharedFlow() + /** Emits when a SetAncMode(OFF) command was rejected by the device (inferred by the engine). */ private val _offRejectedEvents = MutableSharedFlow(extraBufferCapacity = 16) val offRejectedEvents: SharedFlow = _offRejectedEvents.asSharedFlow() @@ -140,6 +152,13 @@ class AapConnectionManager @Inject constructor( } } + // Forward conversational-awareness speaking transitions from this connection (child coroutine). + launch { + connection.conversationalAwarenessEvents.collect { event -> + _conversationalAwarenessEvents.tryEmit(address to event) + } + } + // Forward OFF-rejection events from this connection (child coroutine) launch { connection.offRejected.collect { diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt index 8f641763..6e2d73eb 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt @@ -14,6 +14,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent +import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent import kotlinx.coroutines.CompletableDeferred @@ -53,6 +54,7 @@ internal class AapConnection( val keysReceived: SharedFlow get() = engine.keysReceived val stemPressEvents: SharedFlow get() = engine.stemPressEvents val sleepEvents: SharedFlow get() = engine.sleepEvents + val conversationalAwarenessEvents: SharedFlow get() = engine.conversationalAwarenessEvents val offRejected: SharedFlow get() = engine.offRejected val settingRejected: SharedFlow get() = engine.settingRejected diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt index bdf16983..8ff4482a 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt @@ -14,6 +14,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapMessageType import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent +import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent import kotlinx.coroutines.CoroutineScope @@ -54,6 +55,11 @@ internal class AapSessionEngine( MutableSharedFlow(extraBufferCapacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST) val sleepEvents: SharedFlow = _sleepEvents.asSharedFlow() + private val _conversationalAwarenessEvents = + MutableSharedFlow(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val conversationalAwarenessEvents: SharedFlow = + _conversationalAwarenessEvents.asSharedFlow() + private val _offRejected = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) val offRejected: SharedFlow = _offRejected.asSharedFlow() @@ -301,6 +307,13 @@ internal class AapSessionEngine( ) } } + + // Re-emit every (well-formed) Conversational Awareness frame as a classified event for the + // conversation reaction: START / STOP / HOLD (keep-alive). The decoder already dropped + // malformed frames (rawValue stays null only in that case). The raw payload remains logged. + if (value is AapSetting.ConversationalAwarenessState) { + value.rawValue?.let { _conversationalAwarenessEvents.tryEmit(ConversationAwarenessEvent.fromStatus(it)) } + } } private fun handleTimerFired(key: EngineTimerKey) { diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt index d2e115d5..c9296a6f 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt @@ -99,9 +99,17 @@ sealed class AapSetting { val level: Int, ) : AapSetting() - /** Push-only from device — reports speaking detection state (command 0x4B). */ + /** + * Push-only from device — reports speaking detection state (command 0x4B). + * + * [rawValue] is the first payload byte, preserved so consumers can distinguish the known + * speaking-start (0x01) and speaking-stop (0x04) markers from other values (e.g. 0x00, seen + * in captures with unclear meaning). [speaking] collapses everything non-0x01 to false for + * storage/UI; reaction logic must gate on [rawValue] to avoid acting on unknown values. + */ data class ConversationalAwarenessState( val speaking: Boolean, + val rawValue: Int? = null, ) : AapSetting() data class MicrophoneMode( diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/ConversationAwarenessEvent.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/ConversationAwarenessEvent.kt new file mode 100644 index 00000000..d5844a2b --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/ConversationAwarenessEvent.kt @@ -0,0 +1,31 @@ +package eu.darken.capod.pods.core.apple.aap.protocol + +/** + * Classified Conversational Awareness signal derived from the status byte of a `0x4B` frame. + * + * Status-byte mapping (confirmed against a live AirPods Pro 3 capture and the librepods project): + * - `1`, `2` → [START] (wearer started / is speaking → engage the reaction) + * - `6`, `8`, `9` → [STOP] (wearer stopped → disengage) + * - any other value (`3`, `4`, `0x0B`, …) → [HOLD] (intermediate "still in session" frame; the pod + * streams these while speaking — they act as a keep-alive and must NOT disengage the reaction) + * + * The pod emits no `0x4B` frames at all during silence, so [HOLD] frames ceasing is itself a + * reliable "speaking ended" signal (used as a stale-timeout fallback for a missed [STOP]). + */ +enum class ConversationAwarenessEvent { + START, + HOLD, + STOP, + ; + + companion object { + val SPEAKING_STATUSES = setOf(1, 2) + val STOPPED_STATUSES = setOf(6, 8, 9) + + fun fromStatus(status: Int): ConversationAwarenessEvent = when (status) { + in SPEAKING_STATUSES -> START + in STOPPED_STATUSES -> STOP + else -> HOLD + } + } +} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt index adc5fff8..dcd503a3 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt @@ -183,12 +183,24 @@ class DefaultAapDeviceProfile( return AapSetting.PmeConfig::class to AapSetting.PmeConfig(sets) } - // Conversation Awareness State is a separate command type (push-only) + // Conversation Awareness State is a separate command type (push-only). Two payload shapes + // are seen: a legacy single byte, and the 4-byte `02 00 01 ` form on Pro 3 fw 81.x. + // The status (speaking marker) is the last byte in both — but we validate the shape rather + // than blindly taking payload.last(), so a truncated/garbled frame can't be misread as a + // speaking transition. Unrecognised shapes return null → logged as an unhandled message. if (message.commandType == AapMessageType.CONVERSATIONAL_AWARENESS.value) { - if (message.payload.isEmpty()) return null - val value = message.payload[0].toInt() and 0xFF - val speaking = value == 0x01 - return AapSetting.ConversationalAwarenessState::class to AapSetting.ConversationalAwarenessState(speaking) + val p = message.payload + val status = when { + p.size == 1 -> p[0].toInt() and 0xFF + p.size == 4 && + p[0] == 0x02.toByte() && p[1] == 0x00.toByte() && p[2] == 0x01.toByte() -> + p[3].toInt() and 0xFF + else -> return null + } + // speaking = the "started/active speaking" statuses (1,2); see ConversationAwarenessEvent. + val speaking = status in ConversationAwarenessEvent.SPEAKING_STATUSES + return AapSetting.ConversationalAwarenessState::class to + AapSetting.ConversationalAwarenessState(speaking, rawValue = status) } if (message.commandType != AapMessageType.CONTROL.value) return null diff --git a/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt b/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt index 62577fbe..5a1e941a 100644 --- a/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt +++ b/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt @@ -5,6 +5,7 @@ import eu.darken.capod.pods.core.apple.PodModel import eu.darken.capod.pods.core.apple.ble.protocol.IdentityResolvingKey import eu.darken.capod.pods.core.apple.ble.protocol.ProximityEncryptionKey import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition +import eu.darken.capod.reaction.core.conversation.ConversationAction import eu.darken.capod.reaction.core.stem.StemActionsConfig import kotlinx.parcelize.Parcelize import kotlinx.serialization.SerialName @@ -31,6 +32,8 @@ data class AppleDeviceProfile( @SerialName("reactionAutoConnectCondition") val autoConnectCondition: AutoConnectCondition = AutoConnectCondition.WHEN_SEEN, @SerialName("reactionShowPopUpOnCaseOpen") val showPopUpOnCaseOpen: Boolean = false, @SerialName("reactionShowPopUpOnConnection") val showPopUpOnConnection: Boolean = false, + @SerialName("reactionConversationAction") val conversationAction: ConversationAction = ConversationAction.NOTHING, + @SerialName("reactionConversationVolumeReduction") val conversationVolumeReduction: Int = ReactionConfig.DEFAULT_CONVERSATION_VOLUME_REDUCTION, /** * Last-known device-side AllowOffOption (AAP setting 0x34). Persisted so the UI can honor * the learned value across sessions — AAP state is dropped on disconnect, but whether OFF @@ -56,6 +59,8 @@ data class AppleDeviceProfile( autoConnectCondition = autoConnectCondition, showPopUpOnCaseOpen = showPopUpOnCaseOpen, showPopUpOnConnection = showPopUpOnConnection, + conversationAction = conversationAction, + conversationVolumeReduction = conversationVolumeReduction, ) override fun toString(): String = "AppleDeviceProfile(" + @@ -69,6 +74,8 @@ data class AppleDeviceProfile( "autoConnectCondition=$autoConnectCondition, " + "showPopUpOnCaseOpen=$showPopUpOnCaseOpen, " + "showPopUpOnConnection=$showPopUpOnConnection, " + + "conversationAction=$conversationAction, " + + "conversationVolumeReduction=$conversationVolumeReduction, " + "learnedAllowOffEnabled=$learnedAllowOffEnabled, " + "lastRequestedListeningModeCycleMask=$lastRequestedListeningModeCycleMask, " + "stemActions=$stemActions" + diff --git a/app/src/main/java/eu/darken/capod/profiles/core/ReactionConfig.kt b/app/src/main/java/eu/darken/capod/profiles/core/ReactionConfig.kt index c380fe9a..c88139e4 100644 --- a/app/src/main/java/eu/darken/capod/profiles/core/ReactionConfig.kt +++ b/app/src/main/java/eu/darken/capod/profiles/core/ReactionConfig.kt @@ -1,6 +1,7 @@ package eu.darken.capod.profiles.core import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition +import eu.darken.capod.reaction.core.conversation.ConversationAction data class ReactionConfig( val autoPause: Boolean = false, @@ -11,7 +12,18 @@ data class ReactionConfig( val autoConnectCondition: AutoConnectCondition = AutoConnectCondition.WHEN_SEEN, val showPopUpOnCaseOpen: Boolean = false, val showPopUpOnConnection: Boolean = false, -) + val conversationAction: ConversationAction = ConversationAction.NOTHING, + /** Percentage to lower media volume by when [conversationAction] is LOWER_VOLUME (clamped 10..90 on use). */ + val conversationVolumeReduction: Int = DEFAULT_CONVERSATION_VOLUME_REDUCTION, +) { + companion object { + const val DEFAULT_CONVERSATION_VOLUME_REDUCTION = 50 + const val MIN_CONVERSATION_VOLUME_REDUCTION = 10 + + /** 100% lowers media volume all the way to 0 — i.e. full mute while speaking. */ + const val MAX_CONVERSATION_VOLUME_REDUCTION = 100 + } +} interface HasReactionConfig { val reactionConfig: ReactionConfig diff --git a/app/src/main/java/eu/darken/capod/reaction/core/conversation/ConversationAction.kt b/app/src/main/java/eu/darken/capod/reaction/core/conversation/ConversationAction.kt new file mode 100644 index 00000000..b68e68e4 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/reaction/core/conversation/ConversationAction.kt @@ -0,0 +1,28 @@ +package eu.darken.capod.reaction.core.conversation + +import androidx.annotation.StringRes +import eu.darken.capod.R +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * What CAPod does when the pods report that the wearer started speaking (Conversational + * Awareness). On Android the firmware does not duck audio on its own, so CAPod performs the + * reaction itself. The action is reverted when speaking stops. + * + * Extend by adding entries — the persisted [SerialName] strings are stable identifiers. + */ +@Serializable +enum class ConversationAction( + val identifier: String, + @StringRes val labelRes: Int, +) { + @SerialName("conversation.action.nothing") + NOTHING("conversation.action.nothing", R.string.settings_conversation_action_nothing_label), + + @SerialName("conversation.action.lower_volume") + LOWER_VOLUME("conversation.action.lower_volume", R.string.settings_conversation_action_lower_volume_label), + + @SerialName("conversation.action.pause") + PAUSE("conversation.action.pause", R.string.settings_conversation_action_pause_label), +} diff --git a/app/src/main/java/eu/darken/capod/reaction/core/conversation/ConversationReaction.kt b/app/src/main/java/eu/darken/capod/reaction/core/conversation/ConversationReaction.kt new file mode 100644 index 00000000..4e60e9a9 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/reaction/core/conversation/ConversationReaction.kt @@ -0,0 +1,281 @@ +package eu.darken.capod.reaction.core.conversation + +import eu.darken.capod.common.MediaControl +import eu.darken.capod.common.TimeSource +import eu.darken.capod.common.bluetooth.BluetoothAddress +import eu.darken.capod.common.coroutine.AppScope +import eu.darken.capod.common.debug.logging.Logging.Priority.INFO +import eu.darken.capod.common.debug.logging.log +import eu.darken.capod.common.debug.logging.logTag +import eu.darken.capod.common.flow.setupCommonEventHandlers +import eu.darken.capod.monitor.core.DeviceMonitor +import eu.darken.capod.monitor.core.PodDevice +import eu.darken.capod.monitor.core.primaryDevice +import eu.darken.capod.pods.core.apple.aap.AapConnectionManager +import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent +import eu.darken.capod.profiles.core.ReactionConfig +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Reacts to Conversational Awareness speaking transitions (AAP `0x4B`) by either lowering media + * volume or pausing, per the primary device's [ReactionConfig.conversationAction], and reverts when + * speaking stops. On Android the pod firmware does not duck audio itself, so CAPod performs it. + * + * The pod streams classified frames while you talk ([ConversationAwarenessEvent.START] at onset, + * then [ConversationAwarenessEvent.HOLD] keep-alives) and an explicit [ConversationAwarenessEvent.STOP] + * when you finish; no frames at all during silence. Disengage happens on STOP, or — if a STOP is + * dropped — via a stale timeout that fires once frames stop arriving. Each frame (START or HOLD) + * resets that timer, so a long conversation stays engaged. + * + * State is a single global slot (media volume / playback is system-wide, not per-device) guarded by + * a [Mutex] — events, AAP-state-removal, the stale timer, and monitor completion all mutate it. + * Volume ducks are additionally reverted on owner-disconnect and monitor completion so a dropped + * session never strands the user at low volume. + */ +@Singleton +class ConversationReaction @Inject constructor( + private val aapManager: AapConnectionManager, + private val deviceMonitor: DeviceMonitor, + private val mediaControl: MediaControl, + @AppScope private val appScope: CoroutineScope, + private val timeSource: TimeSource, +) { + + private sealed interface Kind { + data object Paused : Kind + data class Ducked(val priorVolume: Int, val appliedVolume: Int) : Kind + } + + private data class Active( + val id: Long, + val owner: BluetoothAddress, + val kind: Kind, + val at: Long, + ) + + private val mutex = Mutex() + private var active: Active? = null + private var staleJob: Job? = null + private var idCounter = 0L + + fun monitor(): Flow = merge( + aapManager.conversationalAwarenessEvents.onEach { (address, event) -> onEvent(address, event) }, + // Reverts a stranded duck when the owning device leaves the AAP state map for any reason + // (intentional or not) — disconnectEvents only fires for unintentional drops. + aapManager.allStates.onEach { states -> onActiveDevicesChanged(states.keys) }, + ) + .map { } + // Service stop / scope cancellation: undo any active duck so we don't leave volume lowered. + .onCompletion { withContext(NonCancellable) { onMonitorCompleted() } } + .setupCommonEventHandlers(TAG) { "conversationReaction" } + + private suspend fun onEvent(address: BluetoothAddress, event: ConversationAwarenessEvent) = when (event) { + ConversationAwarenessEvent.START -> onSpeakingStart(address) + ConversationAwarenessEvent.HOLD -> onSpeakingHold(address) + ConversationAwarenessEvent.STOP -> onSpeakingStop(address) + } + + private suspend fun onSpeakingStart(address: BluetoothAddress) { + val primary = deviceMonitor.primaryDevice().first() + if (primary?.address != address) { + log(TAG) { "START from $address ignored — not primary device (primary=${primary?.address})" } + return + } + val action = primary.reactions.conversationAction + if (action == ConversationAction.NOTHING) return + + mutex.withLock { + val current = active + if (current != null && current.owner == address) { + // Duplicate START for the same speaker — don't re-act, just keep the session alive. + restartStaleTimer(current) + log(TAG) { "START from $address — already active ($action), keep-alive" } + return + } + // A different device started speaking while we were active — undo the old one first. + if (current != null) revert(current, "superseded by $address") + + when (action) { + ConversationAction.PAUSE -> { + val paused = mediaControl.sendPause(rememberForResume = false) + if (paused) { + val record = Active(nextId(), address, Kind.Paused, timeSource.elapsedRealtime()) + active = record + restartStaleTimer(record) + log(TAG, INFO) { "START on $address → paused media" } + } else { + active = null + log(TAG) { "START on $address → nothing playing, no pause" } + } + } + + ConversationAction.LOWER_VOLUME -> { + val reduction = primary.reactions.conversationVolumeReduction + .coerceIn( + ReactionConfig.MIN_CONVERSATION_VOLUME_REDUCTION, + ReactionConfig.MAX_CONVERSATION_VOLUME_REDUCTION, + ) + val duck = mediaControl.duckMusicVolume(reduction) + if (duck != null) { + val record = Active( + nextId(), + address, + Kind.Ducked(duck.priorVolume, duck.appliedVolume), + timeSource.elapsedRealtime(), + ) + active = record + restartStaleTimer(record) + log(TAG, INFO) { "START on $address → ducked volume ${duck.priorVolume}→${duck.appliedVolume}" } + } else { + active = null + log(TAG) { "START on $address → duck no-op" } + } + } + + ConversationAction.NOTHING -> Unit + } + } + } + + /** Intermediate keep-alive frame: the wearer is still speaking — refresh the stale timer. */ + private suspend fun onSpeakingHold(address: BluetoothAddress) = mutex.withLock { + val current = active ?: return + if (current.owner != address) return + restartStaleTimer(current) + } + + private suspend fun onSpeakingStop(address: BluetoothAddress) { + val primary = deviceMonitor.primaryDevice().first() + mutex.withLock { + val current = active ?: return + if (current.owner != address) { + log(TAG) { "STOP from $address ignored — owner is ${current.owner}" } + return + } + clearActive() + disengage(current, primary, "STOP on $address") + } + } + + /** Graceful disengage (STOP event or stale timeout). Must be called under [mutex]. */ + private suspend fun disengage(record: Active, primary: PodDevice?, reason: String) { + when (val kind = record.kind) { + is Kind.Paused -> { + // Resume the pause WE caused, regardless of the current action setting. Gating on + // "action still == PAUSE" would strand media paused if the user switched the action + // (or set NOTHING) mid-conversation — undoing our own side effect is the least + // surprising behaviour. The remaining guards are about real device/playback state. + val age = timeSource.elapsedRealtime() - record.at + when { + age > PAUSE_RESUME_WINDOW_MS -> + log(TAG) { "$reason — resume skipped (stale, ${age}ms)" } + primary?.address != record.owner -> + log(TAG) { "$reason — resume skipped (primary switched)" } + primary.isBeingWorn == false -> + log(TAG) { "$reason — resume skipped (not worn)" } + mediaControl.isPlaying -> + log(TAG) { "$reason — resume skipped (already playing)" } + else -> { + mediaControl.sendPlay() + log(TAG, INFO) { "$reason → resumed media" } + } + } + } + + is Kind.Ducked -> revertDuck(kind, reason) + } + } + + private suspend fun onActiveDevicesChanged(addresses: Set) = mutex.withLock { + val current = active ?: return + if (current.owner !in addresses) { + clearActive() + revert(current, "owner ${current.owner} gone") + } + } + + private suspend fun onMonitorCompleted() = mutex.withLock { + val current = active ?: return + clearActive() + revert(current, "monitor completed") + } + + /** Forced revert (disconnect / supersede / shutdown): undo volume ducks; leave pauses as-is. */ + private fun revert(record: Active, reason: String) { + when (val kind = record.kind) { + is Kind.Ducked -> revertDuck(kind, reason) + is Kind.Paused -> log(TAG) { "Clearing pause ($reason) — leaving playback as-is" } + } + } + + private fun revertDuck(kind: Kind.Ducked, reason: String) { + // Restore to the pre-duck volume unconditionally — we do NOT gate on + // currentMusicVolume() == appliedVolume. Such a guard skips the restore whenever anything + // else moved the volume meanwhile (e.g. a concurrent volume-manager app), which lets the + // baseline ratchet down across conversations. Restoring deterministically to the level we + // saved at engage avoids that; the trade-off is overriding a deliberate mid-conversation + // volume change (rare). Matches librepods' unconditional restore. + log(TAG, INFO) { "Restoring volume to ${kind.priorVolume} (ducked to ${kind.appliedVolume}, now ${mediaControl.currentMusicVolume()}, $reason)" } + mediaControl.restoreMusicVolume(kind.priorVolume) + } + + /** Must be called under [mutex]. Clears the active slot and cancels its stale timer. */ + private fun clearActive() { + staleJob?.cancel() + staleJob = null + active = null + } + + /** + * Must be called under [mutex]. (Re)starts the stale timer for [record]. Reset on every frame + * (START/HOLD); if no frame arrives for [STALE_TIMEOUT_MS] the speaking session is treated as + * ended (recovers from a dropped STOP). Identity-checked so a late timer can't disengage a newer + * session. + */ + private fun restartStaleTimer(record: Active) { + staleJob?.cancel() + staleJob = appScope.launch { + delay(STALE_TIMEOUT_MS) + val primary = deviceMonitor.primaryDevice().first() + mutex.withLock { + if (active?.id == record.id) { + val current = active!! + clearActive() + disengage(current, primary, "stale timeout (frames ceased)") + } + } + } + } + + private fun nextId(): Long = ++idCounter + + companion object { + private val TAG = logTag("Reaction", "Conversation") + + /** + * Disengage if no `0x4B` frame arrives for this long while engaged. The pod streams frames + * (~1/s) throughout active speech and none during silence, so this both recovers a dropped + * STOP and bounds how long a duck can linger. Long enough not to disengage mid-conversation + * between frames. + */ + private const val STALE_TIMEOUT_MS = 12L * 1000L + + /** A disengage older than this no longer auto-resumes a pause — unexpected late playback is worse. */ + private const val PAUSE_RESUME_WINDOW_MS = 2L * 60L * 1000L + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4e97961c..32f3f944 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -278,6 +278,11 @@ Transparency Adaptive Conversation Awareness + When you start speaking + Do nothing + Lower volume + Pause media + Volume reduction BLE scan active Identity verified Encrypted connection diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt index 8198469d..c70fa2f8 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt @@ -148,6 +148,48 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() { @Test fun `decode unknown value returns null`() { profile.decodeSetting(settingsMessage(0x28, 0x00)).shouldBeNull() } } + // ── Conversational Awareness State (push-only 0x4B) ────── + + @Nested + inner class ConversationalAwarenessStateTests { + @Test fun `4-byte frame status 1 is speaking`() { + decodeSetting("04 00 04 00 4B 00 02 00 01 01").let { + it.speaking shouldBe true + it.rawValue shouldBe 1 + } + } + + @Test fun `4-byte frame status 2 is speaking`() { + decodeSetting("04 00 04 00 4B 00 02 00 01 02").speaking shouldBe true + } + + @Test fun `4-byte frame stop status 9 is not speaking`() { + decodeSetting("04 00 04 00 4B 00 02 00 01 09").let { + it.speaking shouldBe false + it.rawValue shouldBe 9 + } + } + + @Test fun `4-byte frame intermediate status 4 is not speaking`() { + decodeSetting("04 00 04 00 4B 00 02 00 01 04").speaking shouldBe false + } + + @Test fun `legacy single-byte frame status 0 is not speaking`() { + decodeSetting("04 00 04 00 4B 00 00").let { + it.speaking shouldBe false + it.rawValue shouldBe 0 + } + } + + @Test fun `truncated 3-byte payload returns null`() { + profile.decodeSetting(aapMessage("04 00 04 00 4B 00 02 00 01")).shouldBeNull() + } + + @Test fun `invalid 4-byte prefix returns null`() { + profile.decodeSetting(aapMessage("04 00 04 00 4B 00 02 00 02 01")).shouldBeNull() + } + } + // ── Press Speed ────────────────────────────────────────── @Nested diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt index 6ecc06cd..d015f6ba 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt @@ -8,6 +8,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent +import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.nulls.shouldBeNull @@ -497,6 +498,50 @@ class AapSessionEngineTest : BaseTest() { } } + // ── Conversational Awareness ───────────────────────────── + + @Nested + inner class ConversationalAwarenessTests { + + private fun caProfile(status: Int) = mockProfile { + every { decodeSetting(any()) } returns settingPair( + AapSetting.ConversationalAwarenessState( + speaking = status in ConversationAwarenessEvent.SPEAKING_STATUSES, + rawValue = status, + ), + ) + } + + private suspend fun TestScope.firstEventFor(status: Int): ConversationAwarenessEvent { + val engine = AapSessionEngine(caProfile(status), timeSource) + engine.start(this) + var emitted: ConversationAwarenessEvent? = null + val job = launch { emitted = engine.conversationalAwarenessEvents.first() } + engine.processMessage(dummyMessage(commandType = 0x004B)) + job.join() + return emitted!! + } + + @Test + fun `status 1 and 2 emit START`() = runTest(UnconfinedTestDispatcher()) { + firstEventFor(1) shouldBe ConversationAwarenessEvent.START + firstEventFor(2) shouldBe ConversationAwarenessEvent.START + } + + @Test + fun `status 6, 8, 9 emit STOP`() = runTest(UnconfinedTestDispatcher()) { + firstEventFor(6) shouldBe ConversationAwarenessEvent.STOP + firstEventFor(8) shouldBe ConversationAwarenessEvent.STOP + firstEventFor(9) shouldBe ConversationAwarenessEvent.STOP + } + + @Test + fun `intermediate status emits HOLD (keep-alive)`() = runTest(UnconfinedTestDispatcher()) { + firstEventFor(3) shouldBe ConversationAwarenessEvent.HOLD + firstEventFor(0x0B) shouldBe ConversationAwarenessEvent.HOLD + } + } + // ── Inference ─────────────────────────────────────────── @Nested diff --git a/app/src/test/java/eu/darken/capod/reaction/core/conversation/ConversationReactionTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/conversation/ConversationReactionTest.kt new file mode 100644 index 00000000..7bb8d0dd --- /dev/null +++ b/app/src/test/java/eu/darken/capod/reaction/core/conversation/ConversationReactionTest.kt @@ -0,0 +1,275 @@ +package eu.darken.capod.reaction.core.conversation + +import eu.darken.capod.common.MediaControl +import eu.darken.capod.common.bluetooth.BluetoothAddress +import eu.darken.capod.monitor.core.DeviceMonitor +import eu.darken.capod.monitor.core.PodDevice +import eu.darken.capod.pods.core.apple.aap.AapConnectionManager +import eu.darken.capod.pods.core.apple.aap.AapPodState +import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent +import eu.darken.capod.profiles.core.ReactionConfig +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import testhelpers.TestTimeSource + +class ConversationReactionTest : BaseTest() { + + private val primaryAddress: BluetoothAddress = "AA:BB:CC:DD:EE:FF" + private val otherAddress: BluetoothAddress = "11:22:33:44:55:66" + + // Mirror of ConversationReaction.STALE_TIMEOUT_MS (private there). + private val staleTimeoutMs = 12_000L + + private lateinit var eventsFlow: MutableSharedFlow> + private lateinit var statesFlow: MutableStateFlow> + private lateinit var devicesFlow: MutableStateFlow> + private lateinit var aapManager: AapConnectionManager + private lateinit var deviceMonitor: DeviceMonitor + private lateinit var mediaControl: MediaControl + private lateinit var timeSource: TestTimeSource + + private fun mockPodDevice( + address: BluetoothAddress, + action: ConversationAction, + reduction: Int = 50, + worn: Boolean = true, + ): PodDevice = mockk(relaxed = true) { + every { profileId } returns address + every { this@mockk.address } returns address + every { reactions } returns ReactionConfig( + conversationAction = action, + conversationVolumeReduction = reduction, + ) + every { isBeingWorn } returns worn + } + + @BeforeEach + fun setup() { + eventsFlow = MutableSharedFlow(extraBufferCapacity = 16) + statesFlow = MutableStateFlow(mapOf(primaryAddress to mockk(relaxed = true))) + devicesFlow = MutableStateFlow(listOf(mockPodDevice(primaryAddress, ConversationAction.LOWER_VOLUME))) + aapManager = mockk(relaxed = true) { + every { conversationalAwarenessEvents } returns eventsFlow + every { allStates } returns statesFlow + } + deviceMonitor = mockk(relaxed = true) { + every { devices } returns devicesFlow + } + mediaControl = mockk(relaxed = true) { + coEvery { sendPause(any()) } returns true + every { isPlaying } returns false + every { duckMusicVolume(any()) } returns MediaControl.VolumeDuck(priorVolume = 10, appliedVolume = 5) + every { currentMusicVolume() } returns 5 + } + timeSource = TestTimeSource() + } + + // appScope = the test scope so the stale timer runs on the controllable virtual clock. + private fun TestScope.launchReaction() = ConversationReaction( + aapManager = aapManager, + deviceMonitor = deviceMonitor, + mediaControl = mediaControl, + appScope = this, + timeSource = timeSource, + ).monitor().launchIn(this) + + private suspend fun TestScope.emit(address: BluetoothAddress, event: ConversationAwarenessEvent) { + eventsFlow.emit(address to event) + runCurrent() + } + + @Test + fun `LOWER_VOLUME start ducks, stop restores`() = runTest(UnconfinedTestDispatcher()) { + val job = launchReaction() + + emit(primaryAddress, ConversationAwarenessEvent.START) + verify(exactly = 1) { mediaControl.duckMusicVolume(50) } + verify(exactly = 0) { mediaControl.restoreMusicVolume(any()) } + + emit(primaryAddress, ConversationAwarenessEvent.STOP) + verify(exactly = 1) { mediaControl.restoreMusicVolume(10) } + job.cancel() + } + + @Test + fun `LOWER_VOLUME restores unconditionally even if the volume was moved meanwhile`() = runTest(UnconfinedTestDispatcher()) { + // Something else (e.g. another volume-manager app) moved the volume during the duck, so the + // current reading no longer matches what we applied — we must still restore to the saved prior. + every { mediaControl.currentMusicVolume() } returns 7 + val job = launchReaction() + + emit(primaryAddress, ConversationAwarenessEvent.START) + emit(primaryAddress, ConversationAwarenessEvent.STOP) + + verify(exactly = 1) { mediaControl.restoreMusicVolume(10) } + job.cancel() + } + + @Test + fun `LOWER_VOLUME owner leaving the AAP state map restores volume`() = runTest(UnconfinedTestDispatcher()) { + val job = launchReaction() + + emit(primaryAddress, ConversationAwarenessEvent.START) + statesFlow.value = emptyMap() // device disconnected before STOP arrived + runCurrent() + + verify(exactly = 1) { mediaControl.restoreMusicVolume(10) } + job.cancel() + } + + @Test + fun `LOWER_VOLUME missed STOP restores via stale timeout`() = runTest(UnconfinedTestDispatcher()) { + val job = launchReaction() + + emit(primaryAddress, ConversationAwarenessEvent.START) + verify(exactly = 1) { mediaControl.duckMusicVolume(50) } + verify(exactly = 0) { mediaControl.restoreMusicVolume(any()) } + + // No STOP arrives; frames cease. After the stale timeout, volume must be restored. + advanceTimeBy(staleTimeoutMs + 500) + runCurrent() + + verify(exactly = 1) { mediaControl.restoreMusicVolume(10) } + job.cancel() + } + + @Test + fun `HOLD keep-alive resets the stale timer`() = runTest(UnconfinedTestDispatcher()) { + val job = launchReaction() + + emit(primaryAddress, ConversationAwarenessEvent.START) + advanceTimeBy(8_000) + runCurrent() + emit(primaryAddress, ConversationAwarenessEvent.HOLD) // resets the timer + advanceTimeBy(8_000) // 8s since the HOLD — still within the window + runCurrent() + verify(exactly = 0) { mediaControl.restoreMusicVolume(any()) } + + advanceTimeBy(5_000) // now >12s since the last frame + runCurrent() + verify(exactly = 1) { mediaControl.restoreMusicVolume(10) } + job.cancel() + } + + @Test + fun `HOLD without a prior START does not engage`() = runTest(UnconfinedTestDispatcher()) { + val job = launchReaction() + + emit(primaryAddress, ConversationAwarenessEvent.HOLD) + + verify(exactly = 0) { mediaControl.duckMusicVolume(any()) } + job.cancel() + } + + @Test + fun `PAUSE start pauses, stop resumes when worn and idle`() = runTest(UnconfinedTestDispatcher()) { + devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE)) + val job = launchReaction() + + emit(primaryAddress, ConversationAwarenessEvent.START) + coVerify(exactly = 1) { mediaControl.sendPause(false) } + + emit(primaryAddress, ConversationAwarenessEvent.STOP) + coVerify(exactly = 1) { mediaControl.sendPlay() } + job.cancel() + } + + @Test + fun `PAUSE stop does not resume when pods not worn`() = runTest(UnconfinedTestDispatcher()) { + devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE, worn = false)) + val job = launchReaction() + + emit(primaryAddress, ConversationAwarenessEvent.START) + emit(primaryAddress, ConversationAwarenessEvent.STOP) + + coVerify(exactly = 0) { mediaControl.sendPlay() } + job.cancel() + } + + @Test + fun `PAUSE stop does not resume when something is already playing`() = runTest(UnconfinedTestDispatcher()) { + devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE)) + val job = launchReaction() + + emit(primaryAddress, ConversationAwarenessEvent.START) + every { mediaControl.isPlaying } returns true // user/app restarted playback during the talk + emit(primaryAddress, ConversationAwarenessEvent.STOP) + + coVerify(exactly = 0) { mediaControl.sendPlay() } + job.cancel() + } + + @Test + fun `PAUSE resumes on stop even if the action was switched away mid-talk`() = runTest(UnconfinedTestDispatcher()) { + devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE)) + val job = launchReaction() + + emit(primaryAddress, ConversationAwarenessEvent.START) + coVerify(exactly = 1) { mediaControl.sendPause(false) } + + // User changes the action mid-conversation; we must still undo the pause WE caused. + devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.LOWER_VOLUME)) + emit(primaryAddress, ConversationAwarenessEvent.STOP) + + coVerify(exactly = 1) { mediaControl.sendPlay() } + job.cancel() + } + + @Test + fun `NOTHING action ignores speaking`() = runTest(UnconfinedTestDispatcher()) { + devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.NOTHING)) + val job = launchReaction() + + emit(primaryAddress, ConversationAwarenessEvent.START) + + verify(exactly = 0) { mediaControl.duckMusicVolume(any()) } + coVerify(exactly = 0) { mediaControl.sendPause(any()) } + job.cancel() + } + + @Test + fun `non-primary device start is ignored`() = runTest(UnconfinedTestDispatcher()) { + val job = launchReaction() + + emit(otherAddress, ConversationAwarenessEvent.START) + + verify(exactly = 0) { mediaControl.duckMusicVolume(any()) } + job.cancel() + } + + @Test + fun `stop without a prior start is a no-op`() = runTest(UnconfinedTestDispatcher()) { + val job = launchReaction() + + emit(primaryAddress, ConversationAwarenessEvent.STOP) + + verify(exactly = 0) { mediaControl.restoreMusicVolume(any()) } + coVerify(exactly = 0) { mediaControl.sendPlay() } + job.cancel() + } + + @Test + fun `duplicate start does not duck twice`() = runTest(UnconfinedTestDispatcher()) { + val job = launchReaction() + + emit(primaryAddress, ConversationAwarenessEvent.START) + emit(primaryAddress, ConversationAwarenessEvent.START) // status 1 then 2 both classify as START + + verify(exactly = 1) { mediaControl.duckMusicVolume(any()) } + job.cancel() + } +}