From ac5079bfe607bfa01e4c57557b8a9f016026ed13 Mon Sep 17 00:00:00 2001 From: nikos Date: Wed, 6 May 2026 11:38:03 +0200 Subject: [PATCH 1/3] android: split head-gesture call action into two independent toggles Replace the single "Head Gestures" master toggle with two independent feature toggles in the Head Tracking screen: * Answer/decline incoming calls (nod = answer, shake = decline) * Mute/unmute during a call (shake = mute, nod = unmute) The two behaviors operate in different call phases (ringing vs. active) and never overlap, so they are exposed as separate prefs (head_gestures_answer_call and head_gestures_mute_call) rather than a single switch. Additional gesture-related polish: * Stem-press toggleMicMute now plays the same confirm_yes/confirm_no tones used for head gestures (audible mute/unmute feedback over the AirPods). * New MUTE_CALL StemAction so a stem press can be mapped to mute toggling. * Periodic 15-second low-volume "still muted" reminder while the mic is muted during a call, cancelled automatically on unmute or call end. * Active-call gesture loop now mutes/unmutes directionally (shake mutes, nod unmutes) instead of toggling, fixing the case where the gesture detector re-fires the same direction during a missed restart. * Restart bug fix in GestureDetector: callback is now invoked AFTER stopDetection() so a callback that re-arms detection doesn't hit isRunning=true and silently no-op. * Removed per-movement "blip" sounds on every head turn; only the final confirmation tone is played. * Stale-state fix in testHeadGestures(): force a stop before re-arming so isRunning=true left over from a previous test no longer breaks the next. * Threshold tuning for the gesture detector to reduce accidental triggers. --- .../kavishdevar/librepods/data/StemAction.kt | 3 +- .../screens/AirPodsSettingsScreen.kt | 5 +- .../screens/HeadTrackingScreen.kt | 19 +- .../viewmodel/AirPodsViewModel.kt | 25 +- .../librepods/services/AirPodsService.kt | 242 ++++++++++++++++-- .../librepods/utils/GestureDetector.kt | 38 +-- .../librepods/utils/GestureFeedback.kt | 5 + 7 files changed, 274 insertions(+), 63 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/StemAction.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/StemAction.kt index 5bd9e6c86..a3ead0fed 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/StemAction.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/StemAction.kt @@ -25,7 +25,8 @@ enum class StemAction { PREVIOUS_TRACK, NEXT_TRACK, DIGITAL_ASSISTANT, - CYCLE_NOISE_CONTROL_MODES; + CYCLE_NOISE_CONTROL_MODES, + MUTE_CALL; companion object { fun fromString(action: String): StemAction? { return entries.find { it.name == action } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt index 339c39d98..2510de5c8 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt @@ -390,9 +390,8 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl to = "head_tracking", name = stringResource(R.string.head_gestures), navController = navController, - currentState = if (sharedPreferences.getBoolean( - "head_gestures", false - ) + currentState = if (sharedPreferences.getBoolean("head_gestures_answer_call", true) + || sharedPreferences.getBoolean("head_gestures_mute_call", true) ) stringResource(R.string.on) else stringResource(R.string.off) ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt index ce21253c4..6c5a08ec2 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt @@ -194,13 +194,22 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController } StyledToggle( - label = "Head Gestures", - checked = state.headGesturesEnabled, - onCheckedChange = { viewModel.setHeadGesturesEnabled(it) }, - enabled = state.isPremium || state.headGesturesEnabled, - description = stringResource(R.string.head_gestures_details) + label = "Answer/decline incoming calls", + checked = state.headGesturesAnswerCall, + onCheckedChange = { viewModel.setHeadGesturesAnswerCall(it) }, + enabled = state.isPremium || state.headGesturesAnswerCall, + description = "Nod to answer an incoming call, shake your head to decline." ) + StyledToggle( + label = "Mute/unmute during a call", + checked = state.headGesturesMuteCall, + onCheckedChange = { viewModel.setHeadGesturesMuteCall(it) }, + enabled = state.isPremium || state.headGesturesMuteCall, + description = "Shake your head during an active call to mute the mic, nod to unmute." + ) + + Spacer(modifier = Modifier.height(16.dp)) Text( "Head Orientation", 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 2b1846513..1275b3c68 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 @@ -74,7 +74,8 @@ data class AirPodsUiState( val version3: String = "", val headTrackingActive: Boolean = false, - val headGesturesEnabled: Boolean = true, + val headGesturesAnswerCall: Boolean = true, + val headGesturesMuteCall: Boolean = true, val eqData: FloatArray = floatArrayOf(), @@ -177,7 +178,8 @@ class AirPodsViewModel( ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, false ) - setHeadGesturesEnabled(false) + setHeadGesturesAnswerCall(false) + setHeadGesturesMuteCall(false) } _uiState.update { it.copy(isPremium = premium) } } @@ -340,7 +342,8 @@ class AirPodsViewModel( sharedPreferences.getBoolean("automatic_ear_detection", true) val automaticConnectionEnabled = sharedPreferences.getBoolean("automatic_connection_ctrl_cmd", true) - val headGesturesEnabled = sharedPreferences.getBoolean("head_gestures", true) + val headGesturesAnswerCall = sharedPreferences.getBoolean("head_gestures_answer_call", true) + val headGesturesMuteCall = sharedPreferences.getBoolean("head_gestures_mute_call", true) val leftAction = StemAction.valueOf( sharedPreferences.getString( "left_long_press_action", @@ -363,7 +366,8 @@ class AirPodsViewModel( offListeningMode = offListeningModeEnabled, automaticEarDetectionEnabled = automaticEarDetectionEnabled, automaticConnectionEnabled = automaticConnectionEnabled, - headGesturesEnabled = headGesturesEnabled, + headGesturesAnswerCall = headGesturesAnswerCall, + headGesturesMuteCall = headGesturesMuteCall, leftAction = leftAction, rightAction = rightAction, vendorIdHook = vendorIdHook, @@ -381,10 +385,17 @@ class AirPodsViewModel( } } - fun setHeadGesturesEnabled(enabled: Boolean) { - sharedPreferences.edit { putBoolean("head_gestures", enabled) } + fun setHeadGesturesAnswerCall(enabled: Boolean) { + sharedPreferences.edit { putBoolean("head_gestures_answer_call", enabled) } _uiState.update { - it.copy(headGesturesEnabled = enabled) + it.copy(headGesturesAnswerCall = enabled) + } + } + + fun setHeadGesturesMuteCall(enabled: Boolean) { + sharedPreferences.edit { putBoolean("head_gestures_mute_call", enabled) } + _uiState.update { + it.copy(headGesturesMuteCall = 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 9c54d906f..6ba12287a 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 @@ -62,6 +62,7 @@ import android.telephony.TelephonyCallback import android.telephony.TelephonyManager import android.util.Log import android.util.TypedValue +import android.view.KeyEvent import android.view.View import android.widget.RemoteViews import android.widget.Toast @@ -162,7 +163,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList var conversationalAwarenessPauseMusic: Boolean = false, var showPhoneBatteryInWidget: Boolean = true, var relativeConversationalAwarenessVolume: Boolean = true, - var headGestures: Boolean = true, + // Nod/shake answers/declines an incoming (ringing) call. + var headGesturesAnswerCall: Boolean = true, + // Shake mutes the mic and nod unmutes during an active call. + var headGesturesMuteCall: Boolean = true, var disconnectWhenNotWearing: Boolean = false, var conversationalAwarenessVolume: Int = 43, var qsClickBehavior: String = "cycle", @@ -420,8 +424,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList initGestureDetector() } else { gestureDetector = null - config.headGestures = false - sharedPreferences.edit { putBoolean("head_gestures", false) } + config.headGesturesAnswerCall = false + config.headGesturesMuteCall = false + sharedPreferences.edit { + putBoolean("head_gestures_answer_call", false) + putBoolean("head_gestures_mute_call", false) + } Log.d(TAG, "Head gestures disabled as device is running Android 9 or below") } @@ -600,7 +608,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (leAvailableForAudio) runBlocking { takeOver("call") } - if (config.headGestures) { + if (config.headGesturesAnswerCall) { handleIncomingCall() } } @@ -615,11 +623,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList takeOver("call") } isInCall = true + setupStemActions() + if (config.headGesturesMuteCall) { + handleActiveCall() + } } TelephonyManager.CALL_STATE_IDLE -> { isInCall = false gestureDetector?.stopDetection() + if (isHeadTrackingActive) stopHeadTracking() + activeCallGestureLoopRunning = false + stopMutedReminder() + setupStemActions() } } } @@ -628,6 +644,36 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList telephonyManager.registerTelephonyCallback(mainExecutor, phoneStateListener) } + val sysAudioManager = getSystemService(AUDIO_SERVICE) as AudioManager + sysAudioManager.addOnModeChangedListener(mainExecutor) { mode -> + Log.d(TAG, "Audio mode changed: $mode") + if (mode == AudioManager.MODE_IN_COMMUNICATION) { + if (!isInCall && !isVoIPCallActive) { + isVoIPCallActive = true + Log.d(TAG, "VoIP call detected (audio mode IN_COMMUNICATION)") + setupStemActions() + if (config.headGesturesMuteCall) handleActiveCall() + } + } else { + if (isVoIPCallActive) { + isVoIPCallActive = false + Log.d(TAG, "VoIP call ended (audio mode changed to $mode)") + gestureDetector?.stopDetection() + if (isHeadTrackingActive) stopHeadTracking() + activeCallGestureLoopRunning = false + stopMutedReminder() + setupStemActions() + } + } + } + // Catch a VoIP call already in progress when the listener registered + if (sysAudioManager.mode == AudioManager.MODE_IN_COMMUNICATION && !isInCall && !isVoIPCallActive) { + isVoIPCallActive = true + Log.d(TAG, "VoIP call already in progress at startup") + setupStemActions() + if (config.headGesturesMuteCall) handleActiveCall() + } + if (config.showPhoneBatteryInWidget) { widgetMobileBatteryEnabled = true val batteryChangedIntentFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED) @@ -817,11 +863,23 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val triplePressDefault = StemAction.defaultActions[StemPressType.TRIPLE_PRESS] val longPressDefault = StemAction.defaultActions[StemPressType.LONG_PRESS] - val singlePressCustomized = + // During an active call, force the mute-press type to be reported so we can + // intercept it for setMicrophoneMute. The end-call press type is left as + // non-customized so the OS / Bluetooth stack handles it natively (which + // works reliably for both telephony and VoIP). + val inCall = isInAnyCall() + val callConfig = aacpManager.getControlCommandStatus( + AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG + )?.value + val muteIsDoublePress = callConfig?.getOrNull(1) == 0x02.toByte() + val muteSinglePressInCall = inCall && !muteIsDoublePress + val muteDoublePressInCall = inCall && muteIsDoublePress + + val singlePressCustomized = muteSinglePressInCall || isCustomAction(config.leftSinglePressAction, singlePressDefault) || isCustomAction( config.rightSinglePressAction, singlePressDefault ) || (cameraActive && config.cameraAction == StemPressType.SINGLE_PRESS) - val doublePressCustomized = + val doublePressCustomized = muteDoublePressInCall || isCustomAction(config.leftDoublePressAction, doublePressDefault) || isCustomAction( config.rightDoublePressAction, doublePressDefault ) @@ -836,7 +894,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ) || (cameraActive && config.cameraAction == StemPressType.LONG_PRESS) Log.d( TAG, - "Setting up stem actions: Single Press Customized: $singlePressCustomized, Double Press Customized: $doublePressCustomized, Triple Press Customized: $triplePressCustomized, Long Press Customized: $longPressCustomized" + "Setting up stem actions: inCall=$inCall, Single=$singlePressCustomized, Double=$doublePressCustomized, Triple=$triplePressCustomized, Long=$longPressCustomized" ) aacpManager.sendStemConfigPacket( singlePressCustomized, @@ -844,6 +902,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList triplePressCustomized, longPressCustomized, ) + } @ExperimentalEncodingApi @@ -1067,6 +1126,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList @SuppressLint("NewApi") override fun onHeadTrackingReceived(headTracking: ByteArray) { + Log.d(TAG, "onHeadTrackingReceived: active=$isHeadTrackingActive len=${headTracking.size}") if (isHeadTrackingActive) { HeadTracking.processPacket(headTracking) processHeadTrackingData(headTracking) @@ -1092,6 +1152,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList "AirPodsParser", "Stem press received: $stemPressType on $bud, cameraActive: $cameraActive, cameraAction: ${config.cameraAction}" ) + + if (isInAnyCall() && handleCallStemPress(stemPressType)) { + return + } + if (cameraActive && config.cameraAction != null && stemPressType == config.cameraAction) { if (BuildConfig.FLAVOR == "xposed") { Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27")) @@ -1185,12 +1250,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private fun executeStemAction(action: StemAction) { when (action) { - StemAction.defaultActions[StemPressType.SINGLE_PRESS] -> { - Log.d( - "AirPodsParser", "Default single press action: Play/Pause, not taking action." - ) - } - StemAction.PLAY_PAUSE -> MediaController.sendPlayPause() StemAction.PREVIOUS_TRACK -> MediaController.sendPreviousTrack() StemAction.NEXT_TRACK -> MediaController.sendNextTrack() @@ -1214,9 +1273,55 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList setPackage(packageName) }) } + + StemAction.MUTE_CALL -> toggleMicMute() } } + /** + * Handles a stem press during an active call. Returns true if the press was + * the configured mute press type and was handled here; false otherwise (the + * caller should fall through to normal stem-action handling). + * + * The end-call press type is intentionally NOT handled here so the OS / AirPods + * native HFP behavior can end the call (which works for both telephony and VoIP). + */ + private fun handleCallStemPress(pressType: StemPressType): Boolean { + // CALL_MANAGEMENT_CONFIG byte[1]: 0x02 = mute on double press (flipped), 0x03 = mute on single press (default) + val callConfig = aacpManager.getControlCommandStatus( + AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG + )?.value + val muteIsDoublePress = callConfig?.getOrNull(1) == 0x02.toByte() + val isMutePress = (pressType == StemPressType.SINGLE_PRESS && !muteIsDoublePress) || + (pressType == StemPressType.DOUBLE_PRESS && muteIsDoublePress) + Log.d(TAG, "Call stem press: $pressType, muteIsDoublePress=$muteIsDoublePress, isMutePress=$isMutePress") + if (isMutePress) { + toggleMicMute() + return true + } + return false + } + + private fun toggleMicMute() { + val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager + val wasMuted = audioManager.isMicrophoneMute + val nowMuted = !wasMuted + + // Hardware-level system mic mute. Cuts mic input at the audio HAL so the + // other party hears silence even if the VoIP app's own UI shows "unmuted". + // (Teams maintains its own UI state but the actual audio is silenced.) + audioManager.setMicrophoneMute(nowMuted) + val actualAfter = audioManager.isMicrophoneMute + Log.d(TAG, "toggleMicMute: setMicrophoneMute($nowMuted) -> isMicrophoneMute=$actualAfter (was=$wasMuted)") + + sendToast(if (nowMuted) "Mic muted" else "Mic unmuted") + if (nowMuted) startMutedReminder() else stopMutedReminder() + + // Same confirmation tone as head gestures: confirm_no for mute, confirm_yes for unmute. + initGestureDetector() + gestureDetector?.audio?.playConfirmation(!nowMuted) + } + private fun processEarDetectionChange(earDetection: ByteArray) { var inEar: Boolean val inEarData = listOf( @@ -1353,7 +1458,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList relativeConversationalAwarenessVolume = sharedPreferences.getBoolean( "relative_conversational_awareness_volume", true ), - headGestures = sharedPreferences.getBoolean("head_gestures", true), + headGesturesAnswerCall = sharedPreferences.getBoolean("head_gestures_answer_call", true), + headGesturesMuteCall = sharedPreferences.getBoolean("head_gestures_mute_call", true), disconnectWhenNotWearing = sharedPreferences.getBoolean( "disconnect_when_not_wearing", false ), @@ -1469,7 +1575,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList "relative_conversational_awareness_volume" -> config.relativeConversationalAwarenessVolume = preferences.getBoolean(key, true) - "head_gestures" -> config.headGestures = preferences.getBoolean(key, true) + "head_gestures_answer_call" -> config.headGesturesAnswerCall = preferences.getBoolean(key, true) + "head_gestures_mute_call" -> config.headGesturesMuteCall = preferences.getBoolean(key, true) "disconnect_when_not_wearing" -> config.disconnectWhenNotWearing = preferences.getBoolean(key, false) @@ -1633,8 +1740,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private var gestureDetector: GestureDetector? = null private var isInCall = false + private var isVoIPCallActive = false private var callNumber: String? = null + private fun isInAnyCall(): Boolean { + if (isInCall || isVoIPCallActive) return true + val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager + return audioManager.mode == AudioManager.MODE_IN_COMMUNICATION + } + private fun initGestureDetector() { if (gestureDetector == null) { gestureDetector = GestureDetector(this) @@ -2093,7 +2207,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList fun handleIncomingCall() { if (isInCall) return - if (config.headGestures) { + if (config.headGesturesAnswerCall) { initGestureDetector() startHeadTracking() gestureDetector?.startDetection { accepted -> @@ -2109,8 +2223,77 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } + private var activeCallGestureLoopRunning = false + private var mutedReminderJob: kotlinx.coroutines.Job? = null + + private fun startMutedReminder() { + mutedReminderJob?.cancel() + mutedReminderJob = CoroutineScope(Dispatchers.Default).launch { + while (true) { + delay(15_000) + val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager + if (isInAnyCall() && audioManager.isMicrophoneMute) { + gestureDetector?.audio?.playMuteReminder() + Log.d(TAG, "Mute reminder beep played") + } else { + break + } + } + } + } + + private fun stopMutedReminder() { + mutedReminderJob?.cancel() + mutedReminderJob = null + } + + fun handleActiveCall() { + if (activeCallGestureLoopRunning) { + Log.d(TAG, "handleActiveCall: already running, skip") + return + } + Log.d(TAG, "handleActiveCall: starting head gesture loop for call mute/unmute") + initGestureDetector() + // Force-stop any pre-existing detection (e.g. left over from the test screen) + // so we re-start with our own callback wired to toggleMicMute / rejectCall. + gestureDetector?.stopDetection(doNotStop = true) + startHeadTracking() + activeCallGestureLoopRunning = true + startActiveCallGestureLoop() + } + + private fun startActiveCallGestureLoop() { + gestureDetector?.startDetection(doNotStop = true) { accepted -> + Log.d(TAG, "Active-call gesture detected: accepted=$accepted, inAnyCall=${isInAnyCall()}") + if (!isInAnyCall()) return@startDetection + val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager + if (!accepted) { + if (!audioManager.isMicrophoneMute) { + audioManager.setMicrophoneMute(true) + sendToast("Mic muted") + Log.d(TAG, "Gesture mute: shake → muted") + startMutedReminder() + } + } else { + if (audioManager.isMicrophoneMute) { + audioManager.setMicrophoneMute(false) + sendToast("Mic unmuted") + Log.d(TAG, "Gesture unmute: nod → unmuted") + stopMutedReminder() + } + } + if (isInAnyCall()) { + startActiveCallGestureLoop() + } + } + } + @OptIn(ExperimentalCoroutinesApi::class) suspend fun testHeadGestures(): Boolean { + // Stop any stale detection (e.g. from a previous test where stopDetection was never + // called because doNotStop=true and the screen closed via stopHeadTracking only). + // Without this, isRunning stays true and startDetection returns immediately. + gestureDetector?.stopDetection(doNotStop = true) return suspendCancellableCoroutine { continuation -> gestureDetector?.startDetection(doNotStop = true) { accepted -> if (continuation.isActive) { @@ -2150,11 +2333,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } private fun rejectCall() { + Log.d(TAG, "rejectCall called") + var telecomEnded = false try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager if (checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) == PackageManager.PERMISSION_GRANTED) { - telecomManager.endCall() // TODO: Switch to InCallService (needs CDM association) + telecomEnded = telecomManager.endCall() // TODO: Switch to InCallService (needs CDM association) + Log.d(TAG, "telecomManager.endCall() returned $telecomEnded") } } else { val telephonyService = getSystemService(TELEPHONY_SERVICE) as TelephonyManager @@ -2165,14 +2351,23 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val endCallMethod = telephonyInterface.javaClass.getDeclaredMethod("endCall") endCallMethod.invoke(telephonyInterface) } - - sendToast("Call rejected via head gesture") } catch (e: Exception) { - e.printStackTrace() - sendToast("Failed to reject call: ${e.message}") - } finally { - islandWindow?.close() + Log.w(TAG, "telecomManager.endCall failed: ${e.message}") + } + + // For VoIP calls (Teams/Zoom/Meet), telecomManager.endCall() returns false + // because the call isn't owned by the system telecom stack. Fall back to + // a HEADSETHOOK media key event — many VoIP apps treat that as end-call. + if (!telecomEnded) { + val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager + audioManager.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_HEADSETHOOK)) + audioManager.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_HEADSETHOOK)) + Log.d(TAG, "rejectCall: dispatched HEADSETHOOK as VoIP end-call fallback") + sendToast("End call (VoIP)") + } else { + sendToast("Call ended") } + islandWindow?.close() } fun sendToast(message: String) { @@ -2185,6 +2380,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList fun processHeadTrackingData(data: ByteArray) { val horizontal = ByteBuffer.wrap(data, 51, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt() val vertical = ByteBuffer.wrap(data, 53, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt() + Log.d(TAG, "headData h=$horizontal v=$vertical detector=${gestureDetector != null} running=${gestureDetector?.isRunning}") try { gestureDetector?.processHeadOrientation(horizontal, vertical) } catch (e: Exception) { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt index 9892096bc..3e30aae42 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt @@ -45,8 +45,8 @@ class GestureDetector( companion object { private const val TAG = "GestureDetector" - private const val IMMEDIATE_FEEDBACK_THRESHOLD = 600 - private const val DIRECTION_CHANGE_SENSITIVITY = 150 + private const val IMMEDIATE_FEEDBACK_THRESHOLD = 400 + private const val DIRECTION_CHANGE_SENSITIVITY = 80 private const val FAST_MOVEMENT_THRESHOLD = 300.0 private const val MIN_REQUIRED_EXTREMES = 3 @@ -78,14 +78,15 @@ class GestureDetector( private val peakThreshold = 400 private val directionChangeThreshold = DIRECTION_CHANGE_SENSITIVITY - private val rhythmConsistencyThreshold = 0.5 + private val rhythmConsistencyThreshold = 0.8 private var horizontalIncreasing: Boolean? = null private var verticalIncreasing: Boolean? = null - private val minConfidenceThreshold = 0.7 + private val minConfidenceThreshold = 0.6 - private var isRunning = false + var isRunning = false + private set private var detectionJob: Job? = null private var gestureDetectedCallback: ((Boolean) -> Unit)? = null @@ -119,9 +120,11 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U if (gesture != null) { withContext(Dispatchers.Main) { audio.playConfirmation(gesture) - - gestureDetectedCallback?.invoke(gesture) + // Save callback before stopDetection clears it, then stop first so + // isRunning=false when the callback tries to restart detection. + val cb = gestureDetectedCallback stopDetection(doNotStop) + cb?.invoke(gesture) } break } @@ -157,25 +160,10 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U val significantVertical = abs(verticalDelta) > IMMEDIATE_FEEDBACK_THRESHOLD if (significantHorizontal && (!significantVertical || abs(horizontalDelta) > abs(verticalDelta))) { - CoroutineScope(Dispatchers.Main).launch { - audio.playDirectional(isVertical = false, value = horizontalDelta) - } - significantMotion = true - lastSignificantMotionTime = System.currentTimeMillis() Log.d(TAG, "Significant HORIZONTAL movement: $horizontalDelta") - } - else if (significantVertical) { - CoroutineScope(Dispatchers.Main).launch { - audio.playDirectional(isVertical = true, value = verticalDelta) - } - significantMotion = true - lastSignificantMotionTime = System.currentTimeMillis() + } else if (significantVertical) { Log.d(TAG, "Significant VERTICAL movement: $verticalDelta") } - else if (significantMotion && - (System.currentTimeMillis() - lastSignificantMotionTime) > 300) { - significantMotion = false - } prevHorizontal = horizontal.toDouble() prevVertical = vertical.toDouble() @@ -248,6 +236,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U val now = System.currentTimeMillis() if (increasing && current < prev - dynamicThreshold) { + Log.d(TAG, "Direction change (peak): prev=$prev abs=${abs(prev)} threshold=$peakThreshold accepted=${abs(prev) > peakThreshold}") if (abs(prev) > peakThreshold) { peaks.add(Triple(buffer.size - 1, prev, now)) if (lastPeakTime > 0) { @@ -268,6 +257,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U } increasing = false } else if (!increasing && current > prev + dynamicThreshold) { + Log.d(TAG, "Direction change (trough): prev=$prev abs=${abs(prev)} threshold=$peakThreshold accepted=${abs(prev) > peakThreshold}") if (abs(prev) > peakThreshold) { troughs.add(Triple(buffer.size - 1, prev, now)) @@ -365,7 +355,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U private fun detectGestures(): Boolean? { val requiredExtremes = getRequiredExtremes() - Log.d(TAG, "Current required extremes: $requiredExtremes") + Log.d(TAG, "Current required extremes: $requiredExtremes, vPeaks=${verticalPeaks.size} vTroughs=${verticalTroughs.size} hPeaks=${horizontalPeaks.size} hTroughs=${horizontalTroughs.size}") if (verticalPeaks.size + verticalTroughs.size >= requiredExtremes) { val allExtremes = (verticalPeaks + verticalTroughs).sortedBy { it.first } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt index 88ab8cf5f..8281bfbdb 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt @@ -162,6 +162,11 @@ class GestureFeedback(context: Context) { } } + fun playMuteReminder() { + if (!soundsLoaded.get()) return + soundPool.play(confirmNoId, 0.2f, 0.2f, 1, 0, 1.0f) + } + fun playConfirmation(isYes: Boolean) { if (currentHorizontalStreamId > 0) { soundPool.stop(currentHorizontalStreamId) From 3510d7f59707868ed4d7147696b795ae50fcabdc Mon Sep 17 00:00:00 2001 From: nikos Date: Wed, 6 May 2026 11:39:17 +0200 Subject: [PATCH 2/3] android: sync OS mic mute with Microsoft Teams via NotificationListener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teams on Android does not register VoIP calls with the Telecom framework, so InCallService.setMuted() cannot drive its in-app mute UI. Instead, watch Teams' ongoing-call notification and fire the cached Mute/Unmute action PendingIntent — Teams reacts as if the user had tapped the action in the notification, which keeps the in-app mute icon in sync with the OS mute set by hardware controls (stem press, head gesture). * New TeamsNotifListener service (BIND_NOTIFICATION_LISTENER_SERVICE) that caches the latest Mute and Unmute actions from notifications posted by com.microsoft.teams (and a couple of related package variants). * AirPodsService.toggleMicMute() and the active-call gesture loop now also call TeamsNotifListener.setMuted() alongside AudioManager.setMicrophoneMute, so Teams' UI follows the OS state. * Notification access permission is requested from the initial setup screen, matching the existing permission cards. The "Ask for regular permissions" button now also opens the system Notification access settings if not yet granted. The user must enable LibrePods in Settings → Apps → Notification access for the sync to work; without it the call is a no-op and OS-level mute still applies, just without Teams' UI updating. --- android/app/src/main/AndroidManifest.xml | 10 ++ .../me/kavishdevar/librepods/MainActivity.kt | 50 ++++++- .../librepods/services/AirPodsService.kt | 7 + .../librepods/services/TeamsNotifListener.kt | 138 ++++++++++++++++++ 4 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/services/TeamsNotifListener.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0474dfd88..c0a577589 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -112,6 +112,16 @@ android:exported="true" android:foregroundServiceType="connectedDevice" android:permission="android.permission.BLUETOOTH_CONNECT" /> + + + + + + . +*/ + +package me.kavishdevar.librepods.services + +import android.app.Notification +import android.content.Context +import android.provider.Settings +import android.service.notification.NotificationListenerService +import android.service.notification.StatusBarNotification +import android.util.Log + +/** + * Watches the ongoing-call notification posted by Microsoft Teams (and a few + * variants) and caches the action PendingIntents. AirPodsService can then call + * [setMuted] to fire the right one — Teams reacts as if the user tapped the + * Mute / Unmute button in the notification, which keeps its in-app UI in sync. + * + * Requires the user to grant Notification access (Settings → Apps → Special + * access → Notification access). Use [isAccessGranted] / [openAccessSettings] + * from UI to drive the grant flow. + */ +class TeamsNotifListener : NotificationListenerService() { + + companion object { + private const val TAG = "TeamsNotifListener" + + private val TEAMS_PACKAGES = setOf( + "com.microsoft.teams", + "com.microsoft.teams.ipphone", + "com.microsoft.teams2", + ) + + @Volatile private var muteAction: Notification.Action? = null + @Volatile private var unmuteAction: Notification.Action? = null + @Volatile private var lastSeenKey: String? = null + + fun isAccessGranted(context: Context): Boolean { + val flat = Settings.Secure.getString( + context.contentResolver, "enabled_notification_listeners" + ) ?: return false + val cn = "${context.packageName}/${TeamsNotifListener::class.java.name}" + return flat.split(":").any { it == cn } + } + + fun openAccessSettings(context: Context) { + val intent = android.content.Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS) + .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } + + fun setMuted(muted: Boolean): Boolean { + val action = if (muted) muteAction else unmuteAction + if (action == null) { + Log.d(TAG, "setMuted($muted): no cached action (muteAction=${muteAction != null}, unmuteAction=${unmuteAction != null})") + return false + } + return try { + action.actionIntent.send() + Log.d(TAG, "setMuted($muted): fired ${action.title}") + true + } catch (t: Throwable) { + Log.w(TAG, "setMuted($muted) failed: ${t.message}") + false + } + } + } + + override fun onListenerConnected() { + super.onListenerConnected() + Log.d(TAG, "Listener connected") + // Re-scan currently posted notifications so we pick up an in-progress call. + try { + activeNotifications?.forEach { handle(it) } + } catch (t: Throwable) { + Log.w(TAG, "scan active notifications failed: ${t.message}") + } + } + + override fun onNotificationPosted(sbn: StatusBarNotification) { + handle(sbn) + } + + override fun onNotificationRemoved(sbn: StatusBarNotification) { + if (sbn.packageName !in TEAMS_PACKAGES) return + if (sbn.key == lastSeenKey) { + Log.d(TAG, "Call notification removed; clearing cached actions") + muteAction = null + unmuteAction = null + lastSeenKey = null + } + } + + private fun handle(sbn: StatusBarNotification) { + if (sbn.packageName !in TEAMS_PACKAGES) return + val n = sbn.notification ?: return + val actions = n.actions ?: return + + var foundMute: Notification.Action? = null + var foundUnmute: Notification.Action? = null + for (a in actions) { + val title = a.title?.toString().orEmpty() + val lower = title.lowercase() + // Order matters: "unmute" contains "mute". + if (lower.contains("unmute") || lower.contains("réactiver") || lower.contains("activar")) { + foundUnmute = a + } else if (lower.contains("mute") || lower.contains("muet") || lower.contains("silenc") || lower.contains("stumm")) { + foundMute = a + } + } + + if (foundMute != null || foundUnmute != null) { + muteAction = foundMute ?: muteAction + unmuteAction = foundUnmute ?: unmuteAction + lastSeenKey = sbn.key + Log.d( + TAG, + "Cached actions from ${sbn.packageName}: mute=${foundMute?.title}, unmute=${foundUnmute?.title}, " + + "all=${actions.joinToString { it.title?.toString().orEmpty() }}" + ) + } + } +} From 66b536d227784a41f82402b67368deb13279b8f9 Mon Sep 17 00:00:00 2001 From: nikos Date: Wed, 6 May 2026 19:30:06 +0200 Subject: [PATCH 3/3] android: fix stem double-press hang-up and add head gestures master toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related improvements to call handling and head gesture control: 1. Fix stem double-press not ending VoIP calls (Teams, etc.) - Force both single and double press to be reported to the app during any active call. Previously only the mute-press type was customized, leaving end-call to the firmware's native HFP CHUP — which telephony handles fine but Teams and other non-Telecom VoIP apps ignore. - handleCallStemPress() now routes the end-call press to rejectCall(), which tries TelecomManager, then Teams' own notification Hang Up action, then falls back to HEADSETHOOK for other VoIP apps. 2. Teams call hang-up via notification action - TeamsNotifListener now also caches the Hang Up PendingIntent from Teams' ongoing-call notification (keywords: hang, end call, colgar, raccrocher, auflegen, finalizar). - hangUp() fires it so Teams reacts as if the user tapped the button in the notification, keeping its in-app UI in sync. 3. Global head gestures master toggle (head_gestures_enabled pref) - New master switch in the Head Tracking screen that disables all head gesture features at once (answer/decline + mute/unmute). - Sub-toggles are grayed out when the master is off. - ServiceConfig, ViewModel, and the settings screen summary all respect the new pref. Android 9 fallback disables it along with the others. Co-Authored-By: Claude Sonnet 4.6 --- .../screens/AirPodsSettingsScreen.kt | 5 +- .../screens/HeadTrackingScreen.kt | 12 ++- .../viewmodel/AirPodsViewModel.kt | 11 +++ .../librepods/services/AirPodsService.kt | 73 +++++++++++-------- .../librepods/services/TeamsNotifListener.kt | 35 +++++++-- 5 files changed, 96 insertions(+), 40 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt index 2510de5c8..f6bc72c98 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt @@ -390,8 +390,9 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl to = "head_tracking", name = stringResource(R.string.head_gestures), navController = navController, - currentState = if (sharedPreferences.getBoolean("head_gestures_answer_call", true) - || sharedPreferences.getBoolean("head_gestures_mute_call", true) + currentState = if (sharedPreferences.getBoolean("head_gestures_enabled", true) && + (sharedPreferences.getBoolean("head_gestures_answer_call", true) + || sharedPreferences.getBoolean("head_gestures_mute_call", true)) ) stringResource(R.string.on) else stringResource(R.string.off) ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt index 6c5a08ec2..c685244b5 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt @@ -193,11 +193,19 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController Spacer(modifier = Modifier.height(8.dp)) } + StyledToggle( + label = "Head gestures", + checked = state.headGesturesEnabled, + onCheckedChange = { viewModel.setHeadGesturesEnabled(it) }, + enabled = state.isPremium || state.headGesturesEnabled, + description = "Master switch for all head gesture features." + ) + StyledToggle( label = "Answer/decline incoming calls", checked = state.headGesturesAnswerCall, onCheckedChange = { viewModel.setHeadGesturesAnswerCall(it) }, - enabled = state.isPremium || state.headGesturesAnswerCall, + enabled = state.headGesturesEnabled && (state.isPremium || state.headGesturesAnswerCall), description = "Nod to answer an incoming call, shake your head to decline." ) @@ -205,7 +213,7 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController label = "Mute/unmute during a call", checked = state.headGesturesMuteCall, onCheckedChange = { viewModel.setHeadGesturesMuteCall(it) }, - enabled = state.isPremium || state.headGesturesMuteCall, + enabled = state.headGesturesEnabled && (state.isPremium || state.headGesturesMuteCall), description = "Shake your head during an active call to mute the mic, nod to unmute." ) 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 1275b3c68..cec3dcfb7 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 @@ -74,6 +74,7 @@ data class AirPodsUiState( val version3: String = "", val headTrackingActive: Boolean = false, + val headGesturesEnabled: Boolean = true, val headGesturesAnswerCall: Boolean = true, val headGesturesMuteCall: Boolean = true, @@ -178,6 +179,7 @@ class AirPodsViewModel( ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, false ) + setHeadGesturesEnabled(false) setHeadGesturesAnswerCall(false) setHeadGesturesMuteCall(false) } @@ -342,6 +344,7 @@ class AirPodsViewModel( sharedPreferences.getBoolean("automatic_ear_detection", true) val automaticConnectionEnabled = sharedPreferences.getBoolean("automatic_connection_ctrl_cmd", true) + val headGesturesEnabled = sharedPreferences.getBoolean("head_gestures_enabled", true) val headGesturesAnswerCall = sharedPreferences.getBoolean("head_gestures_answer_call", true) val headGesturesMuteCall = sharedPreferences.getBoolean("head_gestures_mute_call", true) val leftAction = StemAction.valueOf( @@ -366,6 +369,7 @@ class AirPodsViewModel( offListeningMode = offListeningModeEnabled, automaticEarDetectionEnabled = automaticEarDetectionEnabled, automaticConnectionEnabled = automaticConnectionEnabled, + headGesturesEnabled = headGesturesEnabled, headGesturesAnswerCall = headGesturesAnswerCall, headGesturesMuteCall = headGesturesMuteCall, leftAction = leftAction, @@ -385,6 +389,13 @@ class AirPodsViewModel( } } + fun setHeadGesturesEnabled(enabled: Boolean) { + sharedPreferences.edit { putBoolean("head_gestures_enabled", enabled) } + _uiState.update { + it.copy(headGesturesEnabled = enabled) + } + } + fun setHeadGesturesAnswerCall(enabled: Boolean) { sharedPreferences.edit { putBoolean("head_gestures_answer_call", enabled) } _uiState.update { 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 678db01fb..f10b6785f 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 @@ -163,6 +163,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList var conversationalAwarenessPauseMusic: Boolean = false, var showPhoneBatteryInWidget: Boolean = true, var relativeConversationalAwarenessVolume: Boolean = true, + // Master switch — when false, all head gesture features are disabled. + var headGesturesEnabled: Boolean = true, // Nod/shake answers/declines an incoming (ringing) call. var headGesturesAnswerCall: Boolean = true, // Shake mutes the mic and nod unmutes during an active call. @@ -424,9 +426,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList initGestureDetector() } else { gestureDetector = null + config.headGesturesEnabled = false config.headGesturesAnswerCall = false config.headGesturesMuteCall = false sharedPreferences.edit { + putBoolean("head_gestures_enabled", false) putBoolean("head_gestures_answer_call", false) putBoolean("head_gestures_mute_call", false) } @@ -608,7 +612,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (leAvailableForAudio) runBlocking { takeOver("call") } - if (config.headGesturesAnswerCall) { + if (config.headGesturesEnabled && config.headGesturesAnswerCall) { handleIncomingCall() } } @@ -624,7 +628,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } isInCall = true setupStemActions() - if (config.headGesturesMuteCall) { + if (config.headGesturesEnabled && config.headGesturesMuteCall) { handleActiveCall() } } @@ -652,7 +656,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList isVoIPCallActive = true Log.d(TAG, "VoIP call detected (audio mode IN_COMMUNICATION)") setupStemActions() - if (config.headGesturesMuteCall) handleActiveCall() + if (config.headGesturesEnabled && config.headGesturesMuteCall) handleActiveCall() } } else { if (isVoIPCallActive) { @@ -671,7 +675,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList isVoIPCallActive = true Log.d(TAG, "VoIP call already in progress at startup") setupStemActions() - if (config.headGesturesMuteCall) handleActiveCall() + if (config.headGesturesEnabled && config.headGesturesMuteCall) handleActiveCall() } if (config.showPhoneBatteryInWidget) { @@ -863,23 +867,18 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val triplePressDefault = StemAction.defaultActions[StemPressType.TRIPLE_PRESS] val longPressDefault = StemAction.defaultActions[StemPressType.LONG_PRESS] - // During an active call, force the mute-press type to be reported so we can - // intercept it for setMicrophoneMute. The end-call press type is left as - // non-customized so the OS / Bluetooth stack handles it natively (which - // works reliably for both telephony and VoIP). + // During an active call, force both single and double press to be reported + // so we can intercept them: the mute press for setMicrophoneMute() and the + // end-call press for rejectCall(). The firmware's native HFP CHUP works for + // telephony but Teams (and other non-Telecom VoIP apps) ignore it, so we + // must route end-call through rejectCall() ourselves. val inCall = isInAnyCall() - val callConfig = aacpManager.getControlCommandStatus( - AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG - )?.value - val muteIsDoublePress = callConfig?.getOrNull(1) == 0x02.toByte() - val muteSinglePressInCall = inCall && !muteIsDoublePress - val muteDoublePressInCall = inCall && muteIsDoublePress - val singlePressCustomized = muteSinglePressInCall || + val singlePressCustomized = inCall || isCustomAction(config.leftSinglePressAction, singlePressDefault) || isCustomAction( config.rightSinglePressAction, singlePressDefault ) || (cameraActive && config.cameraAction == StemPressType.SINGLE_PRESS) - val doublePressCustomized = muteDoublePressInCall || + val doublePressCustomized = inCall || isCustomAction(config.leftDoublePressAction, doublePressDefault) || isCustomAction( config.rightDoublePressAction, doublePressDefault ) @@ -1280,11 +1279,13 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList /** * Handles a stem press during an active call. Returns true if the press was - * the configured mute press type and was handled here; false otherwise (the - * caller should fall through to normal stem-action handling). + * consumed (mute or end-call); false otherwise (caller falls through to normal + * stem-action handling). * - * The end-call press type is intentionally NOT handled here so the OS / AirPods - * native HFP behavior can end the call (which works for both telephony and VoIP). + * Both single and double press are forced customized during calls by + * setupStemActions(), so the firmware no longer handles end-call natively via + * HFP CHUP. We intercept both presses here and route end-call to rejectCall(), + * which handles Teams and other VoIP apps that ignore HFP CHUP. */ private fun handleCallStemPress(pressType: StemPressType): Boolean { // CALL_MANAGEMENT_CONFIG byte[1]: 0x02 = mute on double press (flipped), 0x03 = mute on single press (default) @@ -1294,11 +1295,16 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val muteIsDoublePress = callConfig?.getOrNull(1) == 0x02.toByte() val isMutePress = (pressType == StemPressType.SINGLE_PRESS && !muteIsDoublePress) || (pressType == StemPressType.DOUBLE_PRESS && muteIsDoublePress) - Log.d(TAG, "Call stem press: $pressType, muteIsDoublePress=$muteIsDoublePress, isMutePress=$isMutePress") + val isEndCallPress = (pressType == StemPressType.DOUBLE_PRESS && !muteIsDoublePress) || + (pressType == StemPressType.SINGLE_PRESS && muteIsDoublePress) if (isMutePress) { toggleMicMute() return true } + if (isEndCallPress) { + rejectCall() + return true + } return false } @@ -1463,6 +1469,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList relativeConversationalAwarenessVolume = sharedPreferences.getBoolean( "relative_conversational_awareness_volume", true ), + headGesturesEnabled = sharedPreferences.getBoolean("head_gestures_enabled", true), headGesturesAnswerCall = sharedPreferences.getBoolean("head_gestures_answer_call", true), headGesturesMuteCall = sharedPreferences.getBoolean("head_gestures_mute_call", true), disconnectWhenNotWearing = sharedPreferences.getBoolean( @@ -1580,6 +1587,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList "relative_conversational_awareness_volume" -> config.relativeConversationalAwarenessVolume = preferences.getBoolean(key, true) + "head_gestures_enabled" -> config.headGesturesEnabled = preferences.getBoolean(key, true) "head_gestures_answer_call" -> config.headGesturesAnswerCall = preferences.getBoolean(key, true) "head_gestures_mute_call" -> config.headGesturesMuteCall = preferences.getBoolean(key, true) "disconnect_when_not_wearing" -> config.disconnectWhenNotWearing = @@ -2212,7 +2220,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList fun handleIncomingCall() { if (isInCall) return - if (config.headGesturesAnswerCall) { + if (config.headGesturesEnabled && config.headGesturesAnswerCall) { initGestureDetector() startHeadTracking() gestureDetector?.startDetection { accepted -> @@ -2363,14 +2371,21 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } // For VoIP calls (Teams/Zoom/Meet), telecomManager.endCall() returns false - // because the call isn't owned by the system telecom stack. Fall back to - // a HEADSETHOOK media key event — many VoIP apps treat that as end-call. + // because the call isn't owned by the system telecom stack. Try Teams' + // own Hang up notification action first; fall back to a HEADSETHOOK media + // key event for other VoIP apps. if (!telecomEnded) { - val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager - audioManager.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_HEADSETHOOK)) - audioManager.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_HEADSETHOOK)) - Log.d(TAG, "rejectCall: dispatched HEADSETHOOK as VoIP end-call fallback") - sendToast("End call (VoIP)") + val teamsHandled = TeamsNotifListener.hangUp() + if (teamsHandled) { + Log.d(TAG, "rejectCall: ended via Teams notification action") + sendToast("Teams call ended") + } else { + val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager + audioManager.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_HEADSETHOOK)) + audioManager.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_HEADSETHOOK)) + Log.d(TAG, "rejectCall: dispatched HEADSETHOOK as VoIP end-call fallback") + sendToast("End call (VoIP)") + } } else { sendToast("Call ended") } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/TeamsNotifListener.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/TeamsNotifListener.kt index d919fffcf..e6653f46c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/TeamsNotifListener.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/TeamsNotifListener.kt @@ -1,5 +1,5 @@ /* - LibrePods - AirPods liberated from Apple’s ecosystem + LibrePods - AirPods liberated from Apple's ecosystem Copyright (C) 2025 LibrePods contributors This program is free software: you can redistribute it and/or modify @@ -28,8 +28,8 @@ import android.util.Log /** * Watches the ongoing-call notification posted by Microsoft Teams (and a few * variants) and caches the action PendingIntents. AirPodsService can then call - * [setMuted] to fire the right one — Teams reacts as if the user tapped the - * Mute / Unmute button in the notification, which keeps its in-app UI in sync. + * [setMuted] / [hangUp] to fire the right one — Teams reacts as if the user + * tapped the button in the notification, keeping its in-app UI in sync. * * Requires the user to grant Notification access (Settings → Apps → Special * access → Notification access). Use [isAccessGranted] / [openAccessSettings] @@ -48,6 +48,7 @@ class TeamsNotifListener : NotificationListenerService() { @Volatile private var muteAction: Notification.Action? = null @Volatile private var unmuteAction: Notification.Action? = null + @Volatile private var hangUpAction: Notification.Action? = null @Volatile private var lastSeenKey: String? = null fun isAccessGranted(context: Context): Boolean { @@ -67,7 +68,7 @@ class TeamsNotifListener : NotificationListenerService() { fun setMuted(muted: Boolean): Boolean { val action = if (muted) muteAction else unmuteAction if (action == null) { - Log.d(TAG, "setMuted($muted): no cached action (muteAction=${muteAction != null}, unmuteAction=${unmuteAction != null})") + Log.d(TAG, "setMuted($muted): no cached action") return false } return try { @@ -79,6 +80,21 @@ class TeamsNotifListener : NotificationListenerService() { false } } + + fun hangUp(): Boolean { + val action = hangUpAction ?: run { + Log.d(TAG, "hangUp(): no cached action") + return false + } + return try { + action.actionIntent.send() + Log.d(TAG, "hangUp(): fired ${action.title}") + true + } catch (t: Throwable) { + Log.w(TAG, "hangUp() failed: ${t.message}") + false + } + } } override fun onListenerConnected() { @@ -102,6 +118,7 @@ class TeamsNotifListener : NotificationListenerService() { Log.d(TAG, "Call notification removed; clearing cached actions") muteAction = null unmuteAction = null + hangUpAction = null lastSeenKey = null } } @@ -113,6 +130,7 @@ class TeamsNotifListener : NotificationListenerService() { var foundMute: Notification.Action? = null var foundUnmute: Notification.Action? = null + var foundHangUp: Notification.Action? = null for (a in actions) { val title = a.title?.toString().orEmpty() val lower = title.lowercase() @@ -121,17 +139,20 @@ class TeamsNotifListener : NotificationListenerService() { foundUnmute = a } else if (lower.contains("mute") || lower.contains("muet") || lower.contains("silenc") || lower.contains("stumm")) { foundMute = a + } else if (lower.contains("hang") || lower.contains("end call") || lower.contains("colgar") || + lower.contains("raccrocher") || lower.contains("auflegen") || lower.contains("finalizar")) { + foundHangUp = a } } - if (foundMute != null || foundUnmute != null) { + if (foundMute != null || foundUnmute != null || foundHangUp != null) { muteAction = foundMute ?: muteAction unmuteAction = foundUnmute ?: unmuteAction + hangUpAction = foundHangUp ?: hangUpAction lastSeenKey = sbn.key Log.d( TAG, - "Cached actions from ${sbn.packageName}: mute=${foundMute?.title}, unmute=${foundUnmute?.title}, " + - "all=${actions.joinToString { it.title?.toString().orEmpty() }}" + "Cached actions from ${sbn.packageName}: mute=${foundMute?.title}, unmute=${foundUnmute?.title}, hangUp=${foundHangUp?.title}" ) } }