From ac5079bfe607bfa01e4c57557b8a9f016026ed13 Mon Sep 17 00:00:00 2001 From: nikos Date: Wed, 6 May 2026 11:38:03 +0200 Subject: [PATCH] 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)