diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/MicrophoneSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/MicrophoneSettingsScreen.kt
index 91bf97502..253eb91ce 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/MicrophoneSettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/MicrophoneSettingsScreen.kt
@@ -25,6 +25,7 @@ import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
+import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@@ -46,12 +47,18 @@ fun MicrophoneSettingsRoute(
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
+ val voiceTriggerId = AACPManager.Companion.ControlCommandIdentifiers.VOICE_TRIGGER
+
MicrophoneSettingsScreen(
selectedMode = state.controlStates[id]?.getOrNull(0)?.toInt() ?: 0,
+ heySiriDetectionEnabled = state.controlStates[voiceTriggerId]?.getOrNull(0) != 0x02.toByte(),
topPadding = topPadding,
bottomPadding = bottomPadding,
onMicrophoneSettingsChanged = {
viewModel.setControlCommandInt(id, it)
+ },
+ onHeySiriDetectionChanged = {
+ viewModel.setControlCommandBoolean(voiceTriggerId, it)
}
)
}
@@ -60,9 +67,11 @@ fun MicrophoneSettingsRoute(
@Composable
fun MicrophoneSettingsScreen(
selectedMode: Int,
+ heySiriDetectionEnabled: Boolean = true,
topPadding: Dp = 16.dp,
bottomPadding: Dp = 16.dp,
- onMicrophoneSettingsChanged: (Int) -> Unit
+ onMicrophoneSettingsChanged: (Int) -> Unit,
+ onHeySiriDetectionChanged: (Boolean) -> Unit = {}
) {
val scrollState = rememberScrollState()
@@ -96,6 +105,17 @@ fun MicrophoneSettingsScreen(
)
}
+ Spacer(modifier = Modifier.height(16.dp))
+
+ StyledList {
+ StyledToggle(
+ label = stringResource(R.string.hey_siri_detection),
+ description = stringResource(R.string.hey_siri_detection_description),
+ checked = heySiriDetectionEnabled,
+ onCheckedChange = onHeySiriDetectionChanged
+ )
+ }
+
Spacer(modifier = Modifier.height(bottomPadding))
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt
index 0b43370e0..4ea16540e 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt
@@ -424,6 +424,7 @@ class AirPodsViewModel(
fun observeAACP() {
val identifiersList = listOf(
ControlCommandIdentifiers.MIC_MODE,
+ ControlCommandIdentifiers.VOICE_TRIGGER,
ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
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 6c17e58ce..99d3f4726 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
@@ -751,6 +751,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
registerReceiver(bluetoothReceiver, serviceIntentFilter)
}
+ val hotwordIntentFilter = IntentFilter(BluetoothHeadset.ACTION_VENDOR_SPECIFIC_HEADSET_EVENT).apply {
+ addCategory("${BluetoothHeadset.VENDOR_SPECIFIC_HEADSET_EVENT_COMPANY_ID_CATEGORY}.$APPLE")
+ }
+ registerReceiver(hotwordReceiver, hotwordIntentFilter, RECEIVER_EXPORTED)
+
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter.bondedDevices.forEach { device ->
@@ -1185,6 +1190,45 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
})
}
+ /**
+ * Receives vendor-specific headset AT events from the Bluetooth stack. When AirPods report
+ * a "Hey Siri" trigger (+APLSIRI), start the digital assistant, unless the user has turned
+ * background Hey Siri detection off in Microphone Mode settings.
+ *
+ * The event format is not yet confirmed on a real device: every vendor event is logged so
+ * the actual traffic can be checked against VENDOR_SPECIFIC_HEADSET_EVENT_APLSIRI.
+ */
+ private val hotwordReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent == null) return
+ val cmd = intent.getStringExtra(BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_CMD)
+ val cmdType = intent.getIntExtra(BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_CMD_TYPE, -1)
+ val args = intent.getSerializableExtra(BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_ARGS)
+ Log.d(TAG, "Vendor headset event: cmd=$cmd type=$cmdType args=$args")
+
+ if (cmd != VENDOR_SPECIFIC_HEADSET_EVENT_APLSIRI) return
+
+ if (!isHeySiriDetectionEnabled()) {
+ Log.d(TAG, "Hey Siri trigger received but background detection is off; ignoring")
+ return
+ }
+
+ Log.d(TAG, "Hey Siri trigger received; starting digital assistant")
+ executeStemAction(StemAction.DIGITAL_ASSISTANT)
+ }
+ }
+
+ /**
+ * Reads the last VOICE_TRIGGER value reported by (or sent to) the buds.
+ * Treats an unknown value as enabled, matching the default on the buds.
+ */
+ private fun isHeySiriDetectionEnabled(): Boolean {
+ val status = aacpManager.getControlCommandStatus(
+ AACPManager.Companion.ControlCommandIdentifiers.VOICE_TRIGGER
+ )
+ return status?.value?.getOrNull(0) != 0x02.toByte()
+ }
+
private fun getActionFor(
bud: AACPManager.Companion.StemPressBudType, type: StemPressType
): StemAction? {
@@ -2227,6 +2271,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
@Suppress("PrivatePropertyName")
private val APPLE = 0x004C
+ @Suppress("PrivatePropertyName")
+ private val VENDOR_SPECIFIC_HEADSET_EVENT_APLSIRI = "+APLSIRI"
+
@Suppress("PrivatePropertyName")
private val ACTION_BATTERY_LEVEL_CHANGED =
"android.bluetooth.device.action.BATTERY_LEVEL_CHANGED"
@@ -3112,6 +3159,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} catch (e: Exception) {
e.printStackTrace()
}
+ try {
+ unregisterReceiver(hotwordReceiver)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
try {
unregisterReceiver(externalBroadcastReceiver)
} catch (e: Exception) {
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 03ad32df7..14c71abcd 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -92,6 +92,8 @@
Microphone
Microphone Mode
Automatic
+ Hey Siri detection
+ Start your digital assistant when AirPods hear \"Hey Siri\"
Always Right
Always Left
Answer call