Skip to content

Commit ede9ff0

Browse files
nikosnikos
authored andcommitted
android: sync OS mic mute with Microsoft Teams via NotificationListener
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.
1 parent 0343554 commit ede9ff0

4 files changed

Lines changed: 204 additions & 1 deletion

File tree

android/app/src/main/AndroidManifest.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,16 @@
112112
android:exported="true"
113113
android:foregroundServiceType="connectedDevice"
114114
android:permission="android.permission.BLUETOOTH_CONNECT" />
115+
<service
116+
android:name=".services.TeamsNotifListener"
117+
android:exported="true"
118+
android:label="LibrePods Teams Mute Sync"
119+
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
120+
<intent-filter>
121+
<action android:name="android.service.notification.NotificationListenerService" />
122+
</intent-filter>
123+
</service>
124+
115125
<service
116126
android:name=".services.AirPodsQSService"
117127
android:exported="true"

android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
153153
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
154154
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
155155
import me.kavishdevar.librepods.services.AirPodsService
156+
import me.kavishdevar.librepods.services.TeamsNotifListener
156157
import me.kavishdevar.librepods.utils.XposedState
157158
import me.kavishdevar.librepods.utils.isSupported
158159
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -694,6 +695,14 @@ fun PermissionsScreen(
694695

695696
val basicPermissionsGranted = permissionState.permissions.all { it.status.isGranted }
696697

698+
var notifAccessGranted by remember { mutableStateOf(TeamsNotifListener.isAccessGranted(context)) }
699+
LaunchedEffect(Unit) {
700+
while (true) {
701+
kotlinx.coroutines.delay(1000)
702+
notifAccessGranted = TeamsNotifListener.isAccessGranted(context)
703+
}
704+
}
705+
697706
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
698707
val pulseScale by infiniteTransition.animateFloat(
699708
initialValue = 1f, targetValue = 1.05f, animationSpec = infiniteRepeatable(
@@ -819,10 +828,25 @@ fun PermissionsScreen(
819828
accentColor = accentColor
820829
)
821830

831+
PermissionCard(
832+
title = "Notification Access",
833+
description = "To sync mute state with Microsoft Teams",
834+
icon = Icons.Default.Notifications,
835+
isGranted = notifAccessGranted,
836+
backgroundColor = backgroundColor,
837+
textColor = textColor,
838+
accentColor = accentColor
839+
)
840+
822841
Spacer(modifier = Modifier.height(24.dp))
823842

824843
Button(
825-
onClick = { permissionState.launchMultiplePermissionRequest() },
844+
onClick = {
845+
permissionState.launchMultiplePermissionRequest()
846+
if (!notifAccessGranted) {
847+
TeamsNotifListener.openAccessSettings(context)
848+
}
849+
},
826850
modifier = Modifier
827851
.fillMaxWidth()
828852
.height(55.dp),
@@ -873,6 +897,30 @@ fun PermissionsScreen(
873897
)
874898
}
875899

900+
Spacer(modifier = Modifier.height(12.dp))
901+
902+
Button(
903+
onClick = { TeamsNotifListener.openAccessSettings(context) },
904+
modifier = Modifier
905+
.fillMaxWidth()
906+
.height(55.dp),
907+
colors = ButtonDefaults.buttonColors(
908+
containerColor = if (notifAccessGranted) Color.Gray else accentColor
909+
),
910+
enabled = !notifAccessGranted,
911+
shape = RoundedCornerShape(8.dp)
912+
) {
913+
Text(
914+
if (notifAccessGranted) "Notification Access Granted" else "Grant Notification Access",
915+
style = TextStyle(
916+
fontSize = 16.sp,
917+
fontWeight = FontWeight.Medium,
918+
fontFamily = FontFamily(Font(R.font.sf_pro)),
919+
color = Color.White
920+
),
921+
)
922+
}
923+
876924
if (!canDrawOverlays && basicPermissionsGranted) {
877925
Spacer(modifier = Modifier.height(12.dp))
878926

android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1324,6 +1324,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
13241324
sendToast(if (nowMuted) "Mic muted" else "Mic unmuted")
13251325
if (nowMuted) startMutedReminder() else stopMutedReminder()
13261326

1327+
// Sync Teams' in-app mute UI by firing the Mute/Unmute action from its
1328+
// ongoing-call notification. Teams on Android skips the Telecom framework,
1329+
// so this notification-listener route is the only path that works.
1330+
TeamsNotifListener.setMuted(nowMuted)
1331+
13271332
// Same confirmation tone as head gestures: confirm_no for mute, confirm_yes for unmute.
13281333
initGestureDetector()
13291334
gestureDetector?.audio?.playConfirmation(!nowMuted)
@@ -2277,13 +2282,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
22772282
if (!accepted) {
22782283
if (!audioManager.isMicrophoneMute) {
22792284
audioManager.setMicrophoneMute(true)
2285+
TeamsNotifListener.setMuted(true)
22802286
sendToast("Mic muted")
22812287
Log.d(TAG, "Gesture mute: shake → muted")
22822288
startMutedReminder()
22832289
}
22842290
} else {
22852291
if (audioManager.isMicrophoneMute) {
22862292
audioManager.setMicrophoneMute(false)
2293+
TeamsNotifListener.setMuted(false)
22872294
sendToast("Mic unmuted")
22882295
Log.d(TAG, "Gesture unmute: nod → unmuted")
22892296
stopMutedReminder()
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/*
2+
LibrePods - AirPods liberated from Apple’s ecosystem
3+
Copyright (C) 2025 LibrePods contributors
4+
5+
This program is free software: you can redistribute it and/or modify
6+
it under the terms of the GNU General Public License as published by
7+
the Free Software Foundation, either version 3 of the License, or
8+
any later version.
9+
10+
This program is distributed in the hope that it will be useful,
11+
but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
GNU General Public License for more details.
14+
15+
You should have received a copy of the GNU General Public License
16+
along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
19+
package me.kavishdevar.librepods.services
20+
21+
import android.app.Notification
22+
import android.content.Context
23+
import android.provider.Settings
24+
import android.service.notification.NotificationListenerService
25+
import android.service.notification.StatusBarNotification
26+
import android.util.Log
27+
28+
/**
29+
* Watches the ongoing-call notification posted by Microsoft Teams (and a few
30+
* variants) and caches the action PendingIntents. AirPodsService can then call
31+
* [setMuted] to fire the right one — Teams reacts as if the user tapped the
32+
* Mute / Unmute button in the notification, which keeps its in-app UI in sync.
33+
*
34+
* Requires the user to grant Notification access (Settings → Apps → Special
35+
* access → Notification access). Use [isAccessGranted] / [openAccessSettings]
36+
* from UI to drive the grant flow.
37+
*/
38+
class TeamsNotifListener : NotificationListenerService() {
39+
40+
companion object {
41+
private const val TAG = "TeamsNotifListener"
42+
43+
private val TEAMS_PACKAGES = setOf(
44+
"com.microsoft.teams",
45+
"com.microsoft.teams.ipphone",
46+
"com.microsoft.teams2",
47+
)
48+
49+
@Volatile private var muteAction: Notification.Action? = null
50+
@Volatile private var unmuteAction: Notification.Action? = null
51+
@Volatile private var lastSeenKey: String? = null
52+
53+
fun isAccessGranted(context: Context): Boolean {
54+
val flat = Settings.Secure.getString(
55+
context.contentResolver, "enabled_notification_listeners"
56+
) ?: return false
57+
val cn = "${context.packageName}/${TeamsNotifListener::class.java.name}"
58+
return flat.split(":").any { it == cn }
59+
}
60+
61+
fun openAccessSettings(context: Context) {
62+
val intent = android.content.Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
63+
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
64+
context.startActivity(intent)
65+
}
66+
67+
fun setMuted(muted: Boolean): Boolean {
68+
val action = if (muted) muteAction else unmuteAction
69+
if (action == null) {
70+
Log.d(TAG, "setMuted($muted): no cached action (muteAction=${muteAction != null}, unmuteAction=${unmuteAction != null})")
71+
return false
72+
}
73+
return try {
74+
action.actionIntent.send()
75+
Log.d(TAG, "setMuted($muted): fired ${action.title}")
76+
true
77+
} catch (t: Throwable) {
78+
Log.w(TAG, "setMuted($muted) failed: ${t.message}")
79+
false
80+
}
81+
}
82+
}
83+
84+
override fun onListenerConnected() {
85+
super.onListenerConnected()
86+
Log.d(TAG, "Listener connected")
87+
// Re-scan currently posted notifications so we pick up an in-progress call.
88+
try {
89+
activeNotifications?.forEach { handle(it) }
90+
} catch (t: Throwable) {
91+
Log.w(TAG, "scan active notifications failed: ${t.message}")
92+
}
93+
}
94+
95+
override fun onNotificationPosted(sbn: StatusBarNotification) {
96+
handle(sbn)
97+
}
98+
99+
override fun onNotificationRemoved(sbn: StatusBarNotification) {
100+
if (sbn.packageName !in TEAMS_PACKAGES) return
101+
if (sbn.key == lastSeenKey) {
102+
Log.d(TAG, "Call notification removed; clearing cached actions")
103+
muteAction = null
104+
unmuteAction = null
105+
lastSeenKey = null
106+
}
107+
}
108+
109+
private fun handle(sbn: StatusBarNotification) {
110+
if (sbn.packageName !in TEAMS_PACKAGES) return
111+
val n = sbn.notification ?: return
112+
val actions = n.actions ?: return
113+
114+
var foundMute: Notification.Action? = null
115+
var foundUnmute: Notification.Action? = null
116+
for (a in actions) {
117+
val title = a.title?.toString().orEmpty()
118+
val lower = title.lowercase()
119+
// Order matters: "unmute" contains "mute".
120+
if (lower.contains("unmute") || lower.contains("réactiver") || lower.contains("activar")) {
121+
foundUnmute = a
122+
} else if (lower.contains("mute") || lower.contains("muet") || lower.contains("silenc") || lower.contains("stumm")) {
123+
foundMute = a
124+
}
125+
}
126+
127+
if (foundMute != null || foundUnmute != null) {
128+
muteAction = foundMute ?: muteAction
129+
unmuteAction = foundUnmute ?: unmuteAction
130+
lastSeenKey = sbn.key
131+
Log.d(
132+
TAG,
133+
"Cached actions from ${sbn.packageName}: mute=${foundMute?.title}, unmute=${foundUnmute?.title}, " +
134+
"all=${actions.joinToString { it.title?.toString().orEmpty() }}"
135+
)
136+
}
137+
}
138+
}

0 commit comments

Comments
 (0)