From 71b136dfceb55635af5a596bf47555019dfc91a6 Mon Sep 17 00:00:00 2001 From: QuantumBreakz Date: Sat, 18 Jul 2026 00:09:26 +0500 Subject: [PATCH 1/2] feat(android): add "both pods only" conversational awareness setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add conversationalAwarenessBothPodsOnly field to ServiceConfig (default false) - Seed 'conversational_awareness_both_pods_only' pref on first run (default false) - Load pref in initializeConfig() and handle live changes in onSharedPreferenceChanged() - Add conversationalAwarenessBothPodsOnlyEnabled to AppSettingsUiState - Add setConversationalAwarenessBothPodsOnlyEnabled() setter to AppSettingsViewModel - Add StyledToggle in AppSettingsScreen inside the Conversational Awareness list - Add string resources: conversational_awareness_both_pods_only + _description Default is off — no behavior change for existing users. Fixes #616 --- .../presentation/screens/AppSettingsScreen.kt | 8 ++++++++ .../viewmodel/AppSettingsViewModel.kt | 7 +++++++ .../librepods/services/AirPodsService.kt | 20 ++++++++++++++++--- android/app/src/main/res/values/strings.xml | 2 ++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt index 06436561d..0b4927562 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt @@ -230,6 +230,14 @@ fun AppSettingsScreen( enabled = state.isPremium ) + StyledToggle( + label = stringResource(R.string.conversational_awareness_both_pods_only), + description = stringResource(R.string.conversational_awareness_both_pods_only_description), + checked = state.conversationalAwarenessBothPodsOnlyEnabled, + onCheckedChange = viewModel::setConversationalAwarenessBothPodsOnlyEnabled, + enabled = state.isPremium, + ) + StyledToggle( label = stringResource(R.string.relative_conversational_awareness_volume), description = stringResource(R.string.relative_conversational_awareness_volume_description), diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt index 2c1716c33..606d7f0b2 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt @@ -18,6 +18,7 @@ import kotlin.math.roundToInt data class AppSettingsUiState( val showPhoneBatteryInWidget: Boolean = false, val conversationalAwarenessPauseMusicEnabled: Boolean = false, + val conversationalAwarenessBothPodsOnlyEnabled: Boolean = false, val relativeConversationalAwarenessVolumeEnabled: Boolean = true, val disconnectWhenNotWearing: Boolean = false, val takeoverWhenDisconnected: Boolean = false, @@ -137,6 +138,7 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat currentState.copy( showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", false), conversationalAwarenessPauseMusicEnabled = sharedPreferences.getBoolean("conversational_awareness_pause_music", false), + conversationalAwarenessBothPodsOnlyEnabled = sharedPreferences.getBoolean("conversational_awareness_both_pods_only", false), relativeConversationalAwarenessVolumeEnabled = sharedPreferences.getBoolean("relative_conversational_awareness_volume", true), disconnectWhenNotWearing = sharedPreferences.getBoolean("disconnect_when_not_wearing", false), takeoverWhenDisconnected = sharedPreferences.getBoolean("takeover_when_disconnected", false), @@ -167,6 +169,11 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat _uiState.update { it.copy(conversationalAwarenessPauseMusicEnabled = enabled) } } + fun setConversationalAwarenessBothPodsOnlyEnabled(enabled: Boolean) { + sharedPreferences.edit { putBoolean("conversational_awareness_both_pods_only", enabled) } + _uiState.update { it.copy(conversationalAwarenessBothPodsOnlyEnabled = enabled) } + } + fun setRelativeConversationalAwarenessVolumeEnabled(enabled: Boolean) { sharedPreferences.edit { putBoolean("relative_conversational_awareness_volume", enabled) } _uiState.update { it.copy(relativeConversationalAwarenessVolumeEnabled = enabled) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index 0cf08c11d..387ab6ba9 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -165,6 +165,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList var deviceName: String = "AirPods", var earDetectionEnabled: Boolean = true, var conversationalAwarenessPauseMusic: Boolean = false, + var conversationalAwarenessBothPodsOnly: Boolean = false, var showPhoneBatteryInWidget: Boolean = true, var relativeConversationalAwarenessVolume: Boolean = true, var headGestures: Boolean = true, @@ -440,6 +441,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (!contains("conversational_awareness_pause_music")) putBoolean( "conversational_awareness_pause_music", false ) + if (!contains("conversational_awareness_both_pods_only")) putBoolean( + "conversational_awareness_both_pods_only", false + ) if (!contains("personalized_volume")) putBoolean("personalized_volume", false) if (!contains("automatic_ear_detection")) putBoolean( "automatic_ear_detection", true @@ -912,10 +916,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList setPackage(packageName) }) + // Number of buds currently in the ear (0x00 == in ear). + val budsInEar = earDetectionNotification.status.count { it == 0x00.toByte() } + val blockedForSinglePod = config.conversationalAwarenessBothPodsOnly && budsInEar < 2 + if (conversationAwarenessNotification.status == 1.toByte() || conversationAwarenessNotification.status == 2.toByte()) { - MediaController.startSpeaking() - } else if (conversationAwarenessNotification.status == 6.toByte() ||conversationAwarenessNotification.status == 8.toByte() || conversationAwarenessNotification.status == 9.toByte()) { - MediaController.stopSpeaking() + if (!blockedForSinglePod) MediaController.startSpeaking() + } else if (conversationAwarenessNotification.status == 6.toByte() || conversationAwarenessNotification.status == 8.toByte() || conversationAwarenessNotification.status == 9.toByte()) { + MediaController.stopSpeaking() // Never gate the "stop" side — volume must always be restored. } Log.d( @@ -1360,6 +1368,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList conversationalAwarenessPauseMusic = sharedPreferences.getBoolean( "conversational_awareness_pause_music", false ), + conversationalAwarenessBothPodsOnly = sharedPreferences.getBoolean( + "conversational_awareness_both_pods_only", false + ), showPhoneBatteryInWidget = sharedPreferences.getBoolean( "show_phone_battery_in_widget", true ), @@ -1473,6 +1484,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList "conversational_awareness_pause_music" -> config.conversationalAwarenessPauseMusic = preferences.getBoolean(key, false) + "conversational_awareness_both_pods_only" -> config.conversationalAwarenessBothPodsOnly = + preferences.getBoolean(key, false) + "show_phone_battery_in_widget" -> { config.showPhoneBatteryInWidget = preferences.getBoolean(key, true) widgetMobileBatteryEnabled = config.showPhoneBatteryInWidget diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 3356edc4a..050f09fd3 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -39,6 +39,8 @@ Reduces to a percentage of the current volume instead of the maximum volume. Pause Music When you start speaking, music will be paused. + Only when both AirPods are worn + Only lowers the volume when you speak if both AirPods are in your ears. EXAMPLE Add widget Control Noise Control Mode directly from your Home Screen. From 5d4d36a8ac411b937957e6e98752122789410881 Mon Sep 17 00:00:00 2001 From: QuantumBreakz Date: Mon, 20 Jul 2026 22:40:36 +0500 Subject: [PATCH 2/2] refactor(android): address tabjy's pre-flight review on CA both-pods-only - Rename label to 'Both AirPods in Ear' (shorter, Apple-style) - Expand description to note that Pause Music is also suppressed when wearing a single AirPod, since the gate sits above startSpeaking() - Add Log.d on both branches of the startSpeaking() gate so suppression is visible in logcat during testing; stopSpeaking() remains ungated --- .../me/kavishdevar/librepods/services/AirPodsService.kt | 9 +++++++-- android/app/src/main/res/values/strings.xml | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index 387ab6ba9..f6111cd25 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -921,9 +921,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val blockedForSinglePod = config.conversationalAwarenessBothPodsOnly && budsInEar < 2 if (conversationAwarenessNotification.status == 1.toByte() || conversationAwarenessNotification.status == 2.toByte()) { - if (!blockedForSinglePod) MediaController.startSpeaking() + if (blockedForSinglePod) { + Log.d("AirPodsParser", "CA startSpeaking() suppressed — both-pods-only is ON and only $budsInEar bud(s) in ear") + } else { + Log.d("AirPodsParser", "CA startSpeaking() called (budsInEar=$budsInEar, bothPodsOnly=${config.conversationalAwarenessBothPodsOnly})") + MediaController.startSpeaking() + } } else if (conversationAwarenessNotification.status == 6.toByte() || conversationAwarenessNotification.status == 8.toByte() || conversationAwarenessNotification.status == 9.toByte()) { - MediaController.stopSpeaking() // Never gate the "stop" side — volume must always be restored. + MediaController.stopSpeaking() // Never gated — volume must always be restored. } Log.d( diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 050f09fd3..cebdc44a0 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -39,8 +39,8 @@ Reduces to a percentage of the current volume instead of the maximum volume. Pause Music When you start speaking, music will be paused. - Only when both AirPods are worn - Only lowers the volume when you speak if both AirPods are in your ears. + Both AirPods in Ear + Conversational Awareness only activates when both AirPods are in your ears. This also suppresses the Pause Music action when wearing a single AirPod. EXAMPLE Add widget Control Noise Control Mode directly from your Home Screen.