Skip to content

Commit 5686ca7

Browse files
Wearable: fix incoming data pipe closure bug; add notification bridging and ANCS action dispatch
1 parent e26b182 commit 5686ca7

File tree

6 files changed

+287
-8
lines changed

6 files changed

+287
-8
lines changed

play-services-core/src/main/AndroidManifest.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,15 @@
462462
</intent-filter>
463463
</service>
464464

465+
<service
466+
android:name="org.microg.gms.wearable.notification.WearableNotificationService"
467+
android:exported="true"
468+
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
469+
<intent-filter>
470+
<action android:name="android.service.notification.NotificationListenerService" />
471+
</intent-filter>
472+
</service>
473+
465474
<activity
466475
android:name="com.google.android.gms.wearable.consent.TermsOfServiceActivity"
467476
android:process=":ui"
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 microG Project Team
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.microg.gms.wearable.notification
7+
8+
import android.app.Notification
9+
import android.os.Build
10+
import android.service.notification.NotificationListenerService
11+
import android.service.notification.StatusBarNotification
12+
import android.util.Log
13+
import org.microg.gms.wearable.NotificationBridge
14+
import org.microg.gms.wearable.WearableService
15+
import java.io.ByteArrayOutputStream
16+
import java.io.DataOutputStream
17+
import java.util.concurrent.atomic.AtomicInteger
18+
19+
private const val TAG = "WearNotificationSvc"
20+
21+
/** Path used for forwarding bridged Android notifications to wearable peers. */
22+
const val NOTIFICATION_PATH = "/wearable/notification"
23+
24+
/** Monotonic counter used to assign collision-free UIDs to bridged notifications. */
25+
private val uidCounter = AtomicInteger(1)
26+
27+
/**
28+
* [NotificationListenerService] that bridges Android notifications to connected
29+
* Wear OS peers via the microG WearableImpl message transport.
30+
*
31+
* Filters out:
32+
* - Notifications originating from this GmsCore package itself.
33+
* - Ongoing notifications ([Notification.FLAG_ONGOING_EVENT]).
34+
* - Non-clearable notifications ([Notification.FLAG_NO_CLEAR]).
35+
*/
36+
class WearableNotificationService : NotificationListenerService() {
37+
38+
/**
39+
* Maps the notification's stable [StatusBarNotification.getKey] to the UID we
40+
* assigned to it, so that we send the same UID on removal.
41+
*/
42+
private val keyToUid = HashMap<String, Int>()
43+
44+
override fun onNotificationPosted(sbn: StatusBarNotification) {
45+
if (shouldSkip(sbn)) return
46+
47+
// Assign a stable, collision-free UID for this notification.
48+
val uid = keyToUid.getOrPut(sbn.key) { uidCounter.getAndIncrement() }
49+
NotificationBridge.activeNotifications[uid] = sbn
50+
51+
val payload = encodeNotification(uid, sbn) ?: return
52+
sendToWearable(payload)
53+
}
54+
55+
override fun onNotificationRemoved(sbn: StatusBarNotification) {
56+
val uid = keyToUid.remove(sbn.key) ?: return
57+
NotificationBridge.activeNotifications.remove(uid)
58+
59+
// Notify peers that this notification was dismissed
60+
val payload = encodeRemoval(uid, sbn.key) ?: return
61+
sendToWearable(payload)
62+
}
63+
64+
// -------------------------------------------------------------------------
65+
66+
private fun shouldSkip(sbn: StatusBarNotification): Boolean {
67+
if (sbn.packageName == packageName) return true
68+
val flags = sbn.notification?.flags ?: return true
69+
if (flags and Notification.FLAG_ONGOING_EVENT != 0) return true
70+
if (flags and Notification.FLAG_NO_CLEAR != 0) return true
71+
return false
72+
}
73+
74+
private fun sendToWearable(payload: ByteArray) {
75+
val wearable = WearableService.getInstance() ?: run {
76+
Log.d(TAG, "WearableService not running, skipping notification forward")
77+
return
78+
}
79+
val connectedNodes = wearable.allConnectedNodes
80+
if (connectedNodes.isEmpty()) {
81+
Log.d(TAG, "No connected wearable nodes, skipping notification forward")
82+
return
83+
}
84+
for (nodeId in connectedNodes) {
85+
val result = wearable.sendMessage(packageName, nodeId, NOTIFICATION_PATH, payload)
86+
if (result < 0) {
87+
Log.w(TAG, "sendMessage to $nodeId failed (result=$result)")
88+
}
89+
}
90+
}
91+
}
92+
93+
// -------------------------------------------------------------------------
94+
// Encoding helpers
95+
// -------------------------------------------------------------------------
96+
97+
/**
98+
* Encodes a posted notification as:
99+
* - byte type = 1 (posted)
100+
* - int uid
101+
* - UTF packageName
102+
* - UTF key
103+
* - UTF title (empty string if absent)
104+
* - UTF text (empty string if absent)
105+
* - long timestamp
106+
* - int actionCount
107+
* - UTF[] action titles
108+
*/
109+
internal fun encodeNotification(uid: Int, sbn: StatusBarNotification): ByteArray? {
110+
return try {
111+
val n = sbn.notification ?: return null
112+
val extras = n.extras
113+
val title = extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString() ?: ""
114+
val text = (extras?.getCharSequence(Notification.EXTRA_BIG_TEXT)
115+
?: extras?.getCharSequence(Notification.EXTRA_TEXT))?.toString() ?: ""
116+
val actions: Array<Notification.Action> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
117+
n.actions ?: emptyArray()
118+
} else {
119+
emptyArray()
120+
}
121+
122+
ByteArrayOutputStream().also { baos ->
123+
DataOutputStream(baos).use { dos ->
124+
dos.writeByte(1) // type: posted
125+
dos.writeInt(uid)
126+
dos.writeUTF(sbn.packageName)
127+
dos.writeUTF(sbn.key)
128+
dos.writeUTF(title)
129+
dos.writeUTF(text)
130+
dos.writeLong(sbn.postTime)
131+
dos.writeInt(actions.size)
132+
for (action in actions) {
133+
dos.writeUTF(action.title?.toString() ?: "")
134+
}
135+
}
136+
}.toByteArray()
137+
} catch (e: Exception) {
138+
Log.e(TAG, "Failed to encode notification", e)
139+
null
140+
}
141+
}
142+
143+
/**
144+
* Encodes a removal event as:
145+
* - byte type = 2 (removed)
146+
* - int uid
147+
* - UTF key
148+
*/
149+
internal fun encodeRemoval(uid: Int, key: String): ByteArray? {
150+
return try {
151+
ByteArrayOutputStream().also { baos ->
152+
DataOutputStream(baos).use { dos ->
153+
dos.writeByte(2) // type: removed
154+
dos.writeInt(uid)
155+
dos.writeUTF(key)
156+
}
157+
}.toByteArray()
158+
} catch (e: Exception) {
159+
Log.e(TAG, "Failed to encode notification removal", e)
160+
null
161+
}
162+
}

play-services-wearable/core/src/main/java/org/microg/gms/wearable/ChannelManager.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import org.microg.wearable.proto.ChannelRequest;
3030
import org.microg.wearable.proto.Request;
3131

32+
import java.io.FileOutputStream;
3233
import java.io.IOException;
3334
import java.io.InputStream;
3435
import java.io.OutputStream;
@@ -424,12 +425,13 @@ private void handleIncomingData(ChannelDataRequest data) {
424425
if (state.inputPipe == null) {
425426
state.inputPipe = ParcelFileDescriptor.createPipe();
426427
}
427-
// Write payload into the write-end of the input pipe so the app can read it
428+
// Write payload into the write-end of the input pipe so the app can read it.
429+
// Use FileOutputStream over the raw FileDescriptor so the PFD is NOT closed
430+
// when the stream is closed — the pipe must remain open for subsequent chunks.
428431
if (data.payload != null && data.payload.size() > 0) {
429-
try (OutputStream out =
430-
new ParcelFileDescriptor.AutoCloseOutputStream(state.inputPipe[1])) {
432+
try (OutputStream out = new FileOutputStream(
433+
state.inputPipe[1].getFileDescriptor())) {
431434
out.write(data.payload.toByteArray());
432-
// Do not close the pipe here; keep it open for subsequent data chunks
433435
} catch (IOException e) {
434436
// Re-create a fresh pipe on error so subsequent data isn't lost
435437
state.inputPipe = null;
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 microG Project Team
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.microg.gms.wearable;
7+
8+
import android.app.Notification;
9+
import android.app.NotificationManager;
10+
import android.content.Context;
11+
import android.os.Build;
12+
import android.service.notification.StatusBarNotification;
13+
import android.util.Log;
14+
15+
import java.util.Map;
16+
import java.util.concurrent.ConcurrentHashMap;
17+
18+
/**
19+
* Shared bridge between {@link WearableServiceImpl} and the in-process
20+
* {@code WearableNotificationService} (play-services-core).
21+
* <p>
22+
* {@code WearableNotificationService} populates {@link #activeNotifications}
23+
* so that ANCS action requests from a Wear OS peer can be dispatched to the
24+
* correct Android notification.
25+
*/
26+
public class NotificationBridge {
27+
28+
private static final String TAG = "GmsWearNotifBridge";
29+
30+
/**
31+
* Maps notification UID (the value sent to the wearable peer) to the live
32+
* {@link StatusBarNotification}. Entries are added/removed by the
33+
* {@code WearableNotificationService} running in the same process.
34+
*/
35+
public static final Map<Integer, StatusBarNotification> activeNotifications =
36+
new ConcurrentHashMap<>();
37+
38+
/**
39+
* Executes the <em>positive</em> ANCS action for {@code uid}: fires the first
40+
* {@link android.app.Notification.Action} on the notification if one exists.
41+
*/
42+
public static void doPositiveAction(Context context, int uid) {
43+
StatusBarNotification sbn = activeNotifications.get(uid);
44+
if (sbn == null) {
45+
Log.d(TAG, "doPositiveAction: no notification for uid=" + uid);
46+
return;
47+
}
48+
Notification n = sbn.getNotification();
49+
if (n == null) return;
50+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
51+
Notification.Action[] actions = n.actions;
52+
if (actions != null && actions.length > 0 && actions[0].actionIntent != null) {
53+
try {
54+
actions[0].actionIntent.send(context, 0, null);
55+
} catch (Exception e) {
56+
Log.w(TAG, "doPositiveAction: PendingIntent.send() failed", e);
57+
}
58+
return;
59+
}
60+
}
61+
// No action available — fall back to content intent
62+
if (n.contentIntent != null) {
63+
try {
64+
n.contentIntent.send(context, 0, null);
65+
} catch (Exception e) {
66+
Log.w(TAG, "doPositiveAction: contentIntent.send() failed", e);
67+
}
68+
}
69+
}
70+
71+
/**
72+
* Executes the <em>negative</em> ANCS action for {@code uid}: dismisses / cancels
73+
* the notification.
74+
*/
75+
public static void doNegativeAction(Context context, int uid) {
76+
StatusBarNotification sbn = activeNotifications.get(uid);
77+
if (sbn == null) {
78+
Log.d(TAG, "doNegativeAction: no notification for uid=" + uid);
79+
return;
80+
}
81+
NotificationManager nm =
82+
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
83+
if (nm != null) {
84+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
85+
nm.cancel(sbn.getTag(), sbn.getId());
86+
}
87+
}
88+
activeNotifications.remove(uid);
89+
}
90+
}

play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableService.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,21 @@
2525
import org.microg.gms.common.GmsService;
2626
import org.microg.gms.common.PackageUtils;
2727

28+
import java.util.concurrent.atomic.AtomicReference;
29+
2830
public class WearableService extends BaseService {
2931

3032
private WearableImpl wearable;
33+
private static final AtomicReference<WearableImpl> sInstance = new AtomicReference<>();
34+
35+
/**
36+
* Returns the running {@link WearableImpl} instance, or {@code null} if the service
37+
* has not been started yet. Intended for in-process components such as
38+
* {@link org.microg.gms.wearable.notification.WearableNotificationService}.
39+
*/
40+
public static WearableImpl getInstance() {
41+
return sInstance.get();
42+
}
3143

3244
public WearableService() {
3345
super("GmsWearSvc", GmsService.WEAR);
@@ -39,10 +51,12 @@ public void onCreate() {
3951
ConfigurationDatabaseHelper configurationDatabaseHelper = new ConfigurationDatabaseHelper(getApplicationContext());
4052
NodeDatabaseHelper nodeDatabaseHelper = new NodeDatabaseHelper(getApplicationContext());
4153
wearable = new WearableImpl(getApplicationContext(), nodeDatabaseHelper, configurationDatabaseHelper);
54+
sInstance.set(wearable);
4255
}
4356

4457
@Override
4558
public void onDestroy() {
59+
sInstance.set(null);
4660
super.onDestroy();
4761
wearable.stop();
4862
}

play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableServiceImpl.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -377,14 +377,20 @@ public void injectAncsNotificationForTesting(IWearableCallbacks callbacks, AncsN
377377

378378
@Override
379379
public void doAncsPositiveAction(IWearableCallbacks callbacks, int i) throws RemoteException {
380+
NotificationBridge.doPositiveAction(context, i);
380381
callbacks.onStatus(Status.SUCCESS);
381382
}
382383

383384
@Override
384385
public void doAncsNegativeAction(IWearableCallbacks callbacks, int i) throws RemoteException {
386+
NotificationBridge.doNegativeAction(context, i);
385387
callbacks.onStatus(Status.SUCCESS);
386388
}
387389

390+
/*
391+
* Channels
392+
*/
393+
388394
@Override
389395
public void openChannel(IWearableCallbacks callbacks, String targetNodeId, String path) throws RemoteException {
390396
Log.d(TAG, "openChannel: " + targetNodeId + ", " + path);
@@ -400,10 +406,6 @@ public void openChannel(IWearableCallbacks callbacks, String targetNodeId, Strin
400406
});
401407
}
402408

403-
/*
404-
* Channels
405-
*/
406-
407409
@Override
408410
public void closeChannel(IWearableCallbacks callbacks, String token) throws RemoteException {
409411
Log.d(TAG, "closeChannel: " + token);

0 commit comments

Comments
 (0)