Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the original issue, two approaches were discussed. Your PR seems to be approach 2 (app-side suppression). @kavishdevar pointed out this approach will still let one bud to switch to transparency mode. To me this is perfectly acceptable, but @kavishdevar what's your thought?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To add context: the transparency-mode switch is firmware-driven, it happens the moment the bud detects you're speaking, independently of what the app does. App-side suppression (Approach 2) only prevents startSpeaking() from lowering the volume; it can't intercept the firmware-level transparency switch.

Approach 1 (sending a firmware command to disable CA entirely) would suppress both, but it means toggling a firmware feature on/off reactively based on ear state, which is more invasive and has different tradeoffs (latency, firmware round-trips, potential edge cases on reconnect).

I'm happy to implement either, but leaving this for @kavishdevar to call, don't want to pick the approach and have it reopened at merge time.

var showPhoneBatteryInWidget: Boolean = true,
var relativeConversationalAwarenessVolume: Boolean = true,
var headGestures: Boolean = true,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -912,10 +916,19 @@ 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) {
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 gated — volume must always be restored.
}

Log.d(
Expand Down Expand Up @@ -1360,6 +1373,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
),
Expand Down Expand Up @@ -1473,6 +1489,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
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
<string name="relative_conversational_awareness_volume_description">Reduces to a percentage of the current volume instead of the maximum volume.</string>
<string name="conversational_awareness_pause_music">Pause Music</string>
<string name="conversational_awareness_pause_music_description">When you start speaking, music will be paused.</string>
<string name="conversational_awareness_both_pods_only">Both AirPods in Ear</string>
<string name="conversational_awareness_both_pods_only_description">Conversational Awareness only activates when both AirPods are in your ears. This also suppresses the Pause Music action when wearing a single AirPod.</string>
<string name="appwidget_text">EXAMPLE</string>
<string name="add_widget">Add widget</string>
<string name="noise_control_widget_description">Control Noise Control Mode directly from your Home Screen.</string>
Expand Down