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 +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,60 @@ 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() + + // 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 +1463,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 +1580,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 +1745,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 +2212,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList fun handleIncomingCall() { if (isInCall) return - if (config.headGestures) { + if (config.headGesturesAnswerCall) { initGestureDetector() startHeadTracking() gestureDetector?.startDetection { accepted -> @@ -2109,8 +2228,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 +2340,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 +2358,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 +2387,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..d919fffcf --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/TeamsNotifListener.kt @@ -0,0 +1,138 @@ +/* + 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] 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() }}" + ) + } + } +} 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)