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