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" />
+
+
+
+
+
+
{
isInCall = false
gestureDetector?.stopDetection()
+ if (isHeadTrackingActive) stopHeadTracking()
+ activeCallGestureLoopRunning = false
+ stopMutedReminder()
+ setupStemActions()
}
}
}
@@ -628,6 +648,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.headGesturesEnabled && 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.headGesturesEnabled && config.headGesturesMuteCall) handleActiveCall()
+ }
+
if (config.showPhoneBatteryInWidget) {
widgetMobileBatteryEnabled = true
val batteryChangedIntentFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
@@ -817,11 +867,18 @@ 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 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 singlePressCustomized = inCall ||
isCustomAction(config.leftSinglePressAction, singlePressDefault) || isCustomAction(
config.rightSinglePressAction, singlePressDefault
) || (cameraActive && config.cameraAction == StemPressType.SINGLE_PRESS)
- val doublePressCustomized =
+ val doublePressCustomized = inCall ||
isCustomAction(config.leftDoublePressAction, doublePressDefault) || isCustomAction(
config.rightDoublePressAction, doublePressDefault
)
@@ -836,7 +893,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 +901,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
triplePressCustomized,
longPressCustomized,
)
+
}
@ExperimentalEncodingApi
@@ -1067,6 +1125,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 +1151,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 +1249,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 +1272,67 @@ 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
+ * consumed (mute or end-call); false otherwise (caller falls through to normal
+ * stem-action handling).
+ *
+ * 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)
+ 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)
+ val isEndCallPress = (pressType == StemPressType.DOUBLE_PRESS && !muteIsDoublePress) ||
+ (pressType == StemPressType.SINGLE_PRESS && muteIsDoublePress)
+ if (isMutePress) {
+ toggleMicMute()
+ return true
+ }
+ if (isEndCallPress) {
+ rejectCall()
+ 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()
+
+ // Sync Teams' in-app mute UI by firing the Mute/Unmute action from its
+ // ongoing-call notification. Teams on Android skips the Telecom framework,
+ // so this notification-listener route is the only path that works.
+ TeamsNotifListener.setMuted(nowMuted)
+
+ // 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 +1469,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
relativeConversationalAwarenessVolume = sharedPreferences.getBoolean(
"relative_conversational_awareness_volume", true
),
- headGestures = sharedPreferences.getBoolean("head_gestures", 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(
"disconnect_when_not_wearing", false
),
@@ -1469,7 +1587,9 @@ 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_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 =
preferences.getBoolean(key, false)
@@ -1633,8 +1753,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 +2220,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
fun handleIncomingCall() {
if (isInCall) return
- if (config.headGestures) {
+ if (config.headGesturesEnabled && config.headGesturesAnswerCall) {
initGestureDetector()
startHeadTracking()
gestureDetector?.startDetection { accepted ->
@@ -2109,8 +2236,79 @@ 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)
+ TeamsNotifListener.setMuted(true)
+ sendToast("Mic muted")
+ Log.d(TAG, "Gesture mute: shake → muted")
+ startMutedReminder()
+ }
+ } else {
+ if (audioManager.isMicrophoneMute) {
+ audioManager.setMicrophoneMute(false)
+ TeamsNotifListener.setMuted(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 +2348,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 +2366,30 @@ 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. Try Teams'
+ // own Hang up notification action first; fall back to a HEADSETHOOK media
+ // key event for other VoIP apps.
+ if (!telecomEnded) {
+ 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")
}
+ islandWindow?.close()
}
fun sendToast(message: String) {
@@ -2185,6 +2402,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/services/TeamsNotifListener.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/TeamsNotifListener.kt
new file mode 100644
index 000000000..e6653f46c
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/services/TeamsNotifListener.kt
@@ -0,0 +1,159 @@
+/*
+ LibrePods - AirPods liberated from Apple's ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+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] / [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]
+ * 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 hangUpAction: 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")
+ 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
+ }
+ }
+
+ 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() {
+ 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
+ hangUpAction = 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
+ var foundHangUp: 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
+ } 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 || 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}, hangUp=${foundHangUp?.title}"
+ )
+ }
+ }
+}
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)