Skip to content

Commit 88075dd

Browse files
fix(wearable): harden WearOS transport ownership and drop false UUID claims
- Document WEAR_BT_UUID as microG experimental RFCOMM (not stock Wear/Eddystone interop) - Dup ChannelManager pipe ends for AIDL callers; avoid AutoClose on canonical FDs - Use ConcurrentHashMap for activeConnections and multi BT thread map - Route /wearable/notification/command to NotificationBridge - Revert unrelated vending-app debug release signing - Expand unit tests; pin Robolectric to SDK 28 Signed-off-by: namdamdoi68-oss <namdamdoi68@gmail.com>
1 parent e6a6d34 commit 88075dd

11 files changed

Lines changed: 361 additions & 80 deletions

File tree

play-services-wearable/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ This module implements the Wearable API (`com.google.android.gms.wearable`), ena
55
## Features
66

77
### Bluetooth RFCOMM Transport (`BluetoothConnectionThread.java`)
8-
- Implements the standard Wear OS Bluetooth RFCOMM UUID (`a3c87500-8ed3-4bdf-8a39-a01bebede295`)
8+
- Uses a **microG experimental** Bluetooth RFCOMM UUID (`a3c87500-8ed3-4bdf-8a39-a01bebede295`) for lab / microG-to-microG transport. This is **not** a documented stock Wear OS companion RFCOMM UUID (the bit pattern is publicly known as Eddystone BLE; microG does not implement Eddystone). Stock Wear interop needs a device-proven UUID from an HCI snoop.
99
- Wraps `BluetoothSocket` in a `java.net.Socket` proxy to reuse the existing `SocketWearableConnection` framing code
1010
- Supports both server (listen) and client (connect) modes
1111

@@ -18,7 +18,7 @@ This module implements the Wearable API (`com.google.android.gms.wearable`), ena
1818
- Registers as a media session listener to receive playback state updates
1919
- Forwards play/pause/skip events from the phone to connected Wear OS devices
2020

21-
### Notification Bridging (`WearableNotificationService.java`)
21+
### Notification Bridging (`WearableNotificationService.kt`)
2222
- Extends `NotificationListenerService` to intercept phone notifications
2323
- Forwards clearable, non-ongoing notifications to connected Wear OS devices
2424
- Supports notification removal events (same UID on removal)

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

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,25 @@ public abstract class BluetoothConnectionThread extends Thread {
5252

5353
private static final String TAG = "GmsWearBtThread";
5454

55-
/** RFCOMM service UUID for the wearable Bluetooth transport. */
55+
/**
56+
* RFCOMM service UUID used by microG for the wearable Bluetooth transport.
57+
*
58+
* <p><b>Important:</b> This is a <em>microG experimental</em> service UUID chosen for
59+
* local RFCOMM pairing experiments. It is <b>not</b> a documented Google Wear OS / Play
60+
* Services companion RFCOMM UUID. Notably, the same 128-bit value is publicly known as
61+
* the Eddystone BLE service UUID; microG reuses the bit pattern only as an RFCOMM SDP
62+
* record id and does <b>not</b> implement Eddystone advertising or claim stock Wear
63+
* companion interop through this constant.
64+
*
65+
* <p>Interoperability with factory Wear OS companions requires a device-proven UUID from
66+
* an HCI/BT snoop of a real pairing session. Until then, treat this transport as
67+
* microG-to-microG / lab-only.
68+
*/
5669
public static final UUID WEAR_BT_UUID =
5770
UUID.fromString("a3c87500-8ed3-4bdf-8a39-a01bebede295");
5871

5972
/** SDP service name advertised alongside {@link #WEAR_BT_UUID}. */
60-
static final String WEAR_BT_SERVICE_NAME = "WearOS";
73+
static final String WEAR_BT_SERVICE_NAME = "microG Wearable";
6174

6275
private volatile SocketWearableConnection wearableConnection;
6376

@@ -177,22 +190,26 @@ public void run() {
177190
break;
178191
}
179192
if (btSocket == null || Thread.interrupted()) break;
180-
try {
181-
SocketWearableConnection conn =
182-
new SocketWearableConnection(proxySocket(btSocket), listener);
183-
setWearableConnection(conn);
193+
final BluetoothSocket accepted = btSocket;
194+
// Run each peer on its own worker so accept() is not blocked by one client.
195+
new Thread(() -> {
184196
try {
185-
conn.run();
186-
} finally {
197+
SocketWearableConnection conn =
198+
new SocketWearableConnection(proxySocket(accepted), listener);
199+
setWearableConnection(conn);
187200
try {
188-
btSocket.close();
189-
} catch (IOException e) {
190-
Log.w(TAG, "server: close error for accepted connection", e);
201+
conn.run();
202+
} finally {
203+
try {
204+
accepted.close();
205+
} catch (IOException e) {
206+
Log.w(TAG, "server: close error for accepted connection", e);
207+
}
191208
}
209+
} catch (IOException e) {
210+
Log.w(TAG, "server: error on accepted connection", e);
192211
}
193-
} catch (IOException e) {
194-
Log.w(TAG, "server: error on accepted connection", e);
195-
}
212+
}, "GmsWearBtPeer").start();
196213
}
197214
} catch (IOException e) {
198215
if (!Thread.interrupted()) {

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

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@
3333
* Uses the {@link ChannelControlRequest}, {@link ChannelDataRequest}, and
3434
* {@link ChannelDataAckRequest} protocol messages to open, transfer data through,
3535
* and close channels with peer WearOS nodes.
36+
*
37+
* <h3>ParcelFileDescriptor ownership</h3>
38+
* <ul>
39+
* <li>{@link ChannelState} owns the canonical pipe ends created via
40+
* {@link ParcelFileDescriptor#createPipe()}.</li>
41+
* <li>AIDL callers receive {@link ParcelFileDescriptor#dup() duplicated} ends from
42+
* {@link #getInputStream}/{@link #getOutputStream}; closing those dups must not
43+
* tear down the canonical ends still used by the network path.</li>
44+
* <li>{@link #writeInputToFd} / {@link #readOutputFromFd} operate on dups of the
45+
* canonical ends so AutoClose streams cannot invalidate app-facing PFDs.</li>
46+
* <li>The output forwarder owns the read end of the output pipe for the lifetime of
47+
* the channel (or until EOF).</li>
48+
* </ul>
3649
*/
3750
public class ChannelManager {
3851
private static final String TAG = "GmsWearChannelMgr";
@@ -132,14 +145,19 @@ public boolean closeChannel(String token, int errorCode) {
132145
}
133146

134147
try {
135-
sendChannelControl(state.nodeId, new ChannelControlRequest.Builder()
136-
.type(CONTROL_TYPE_CLOSE)
137-
.channelId(channelId)
138-
.fromChannelOperator(true)
139-
.closeErrorCode(errorCode)
140-
.build());
148+
if (wearable != null) {
149+
sendChannelControl(state.nodeId, new ChannelControlRequest.Builder()
150+
.type(CONTROL_TYPE_CLOSE)
151+
.channelId(channelId)
152+
.fromChannelOperator(true)
153+
.closeErrorCode(errorCode)
154+
.build());
155+
}
141156
} catch (IOException e) {
142157
Log.w(TAG, "closeChannel: failed to send CLOSE for channel " + channelId, e);
158+
} catch (RuntimeException e) {
159+
// Unit tests may construct ChannelManager without a WearableImpl.
160+
Log.w(TAG, "closeChannel: skip peer CLOSE for channel " + channelId, e);
143161
}
144162

145163
state.close();
@@ -166,8 +184,13 @@ public ParcelFileDescriptor getInputStream(String token) {
166184
synchronized (state) {
167185
if (state.inputPipe == null) {
168186
state.inputPipe = ParcelFileDescriptor.createPipe();
187+
// Keep a long-lived writer over the write-end for incoming network data.
188+
state.inputPipeWriter = new ParcelFileDescriptor.AutoCloseOutputStream(
189+
state.inputPipe[1]);
169190
}
170-
return state.inputPipe[0]; // read end
191+
// Return a dup so the caller's close (or Binder FD handoff) cannot destroy
192+
// the canonical read end still owned by ChannelState.
193+
return state.inputPipe[0].dup();
171194
}
172195
} catch (IOException e) {
173196
Log.e(TAG, "getInputStream: failed to create pipe for channel " + token, e);
@@ -195,7 +218,8 @@ public ParcelFileDescriptor getOutputStream(String token) {
195218
state.outputPipe = ParcelFileDescriptor.createPipe();
196219
startOutputForwarder(state);
197220
}
198-
return state.outputPipe[1]; // write end for caller
221+
// Dup the write end for the AIDL caller; ChannelState keeps the canonical end.
222+
return state.outputPipe[1].dup();
199223
}
200224
} catch (IOException e) {
201225
Log.e(TAG, "getOutputStream: failed to create pipe for channel " + token, e);
@@ -224,14 +248,18 @@ public boolean writeInputToFd(String token, ParcelFileDescriptor fd) {
224248
return false;
225249
}
226250
try {
251+
final ParcelFileDescriptor readEndDup;
227252
synchronized (state) {
228253
if (state.inputPipe == null) {
229254
state.inputPipe = ParcelFileDescriptor.createPipe();
255+
state.inputPipeWriter = new ParcelFileDescriptor.AutoCloseOutputStream(
256+
state.inputPipe[1]);
230257
}
258+
// Dup so AutoCloseInputStream cannot close the canonical app/network read end.
259+
readEndDup = state.inputPipe[0].dup();
231260
}
232-
final ParcelFileDescriptor readEnd = state.inputPipe[0];
233261
new Thread(() -> {
234-
try (InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(readEnd);
262+
try (InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(readEndDup);
235263
OutputStream out = new ParcelFileDescriptor.AutoCloseOutputStream(fd)) {
236264
byte[] buf = new byte[CHUNK_SIZE];
237265
int n;
@@ -279,16 +307,18 @@ public boolean readOutputFromFd(String token, ParcelFileDescriptor fd,
279307
return false;
280308
}
281309
try {
310+
final ParcelFileDescriptor writeEndDup;
282311
synchronized (state) {
283312
if (state.outputPipe == null) {
284313
state.outputPipe = ParcelFileDescriptor.createPipe();
285314
startOutputForwarder(state);
286315
}
316+
// Dup so AutoCloseOutputStream cannot close the canonical app write end.
317+
writeEndDup = state.outputPipe[1].dup();
287318
}
288-
final ParcelFileDescriptor writeEnd = state.outputPipe[1];
289319
new Thread(() -> {
290320
try (InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(fd);
291-
OutputStream out = new ParcelFileDescriptor.AutoCloseOutputStream(writeEnd)) {
321+
OutputStream out = new ParcelFileDescriptor.AutoCloseOutputStream(writeEndDup)) {
292322
if (startOffset > 0) {
293323
long skipped = in.skip(startOffset);
294324
if (skipped < startOffset) {
@@ -461,6 +491,8 @@ private void handleIncomingData(ChannelDataRequest data) {
461491
try {
462492
if (state.inputPipe == null) {
463493
state.inputPipe = ParcelFileDescriptor.createPipe();
494+
}
495+
if (state.inputPipeWriter == null && state.inputPipe[1] != null) {
464496
// Open a single OutputStream over the write-end PFD and keep it alive
465497
// across all chunks. Wrapping the PFD rather than its raw FileDescriptor
466498
// ensures the FD is NOT closed when the stream would otherwise be closed.
@@ -526,7 +558,15 @@ private void handleIncomingDataAck(ChannelDataAckRequest ack) {
526558
* Precondition: {@code state.outputPipe} must already be initialised.
527559
*/
528560
private void startOutputForwarder(ChannelState state) {
529-
final ParcelFileDescriptor readEnd = state.outputPipe[0];
561+
final ParcelFileDescriptor readEnd;
562+
try {
563+
// Forwarder holds its own dup; ChannelState retains the canonical read end.
564+
readEnd = state.outputPipe[0].dup();
565+
} catch (IOException e) {
566+
Log.e(TAG, "startOutputForwarder: failed to dup output read end for channel "
567+
+ state.channelId, e);
568+
return;
569+
}
530570
new Thread(() -> {
531571
try (InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(readEnd)) {
532572
byte[] buf = new byte[CHUNK_SIZE];
@@ -606,6 +646,9 @@ private void sendChannelControl(String targetNodeId, ChannelControlRequest ctrl)
606646

607647
private void dispatchChannelEvent(ChannelState state, int eventType,
608648
int closeReason, int appSpecificErrorCode) {
649+
if (wearable == null) {
650+
return;
651+
}
609652
ChannelEventParcelable event = new ChannelEventParcelable();
610653
event.channel = new ChannelParcelable(state.token, state.nodeId, state.path);
611654
event.eventType = eventType;

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ public class NotificationBridge {
2727

2828
private static final String TAG = "GmsWearNotifBridge";
2929

30+
/** Path on which the wearable peer sends notification-control commands. */
31+
public static final String NOTIFICATION_COMMAND_PATH = "/wearable/notification/command";
32+
33+
/** Positive / content action (first Notification.Action or contentIntent). */
34+
public static final byte CMD_POSITIVE = 1;
35+
36+
/** Negative action: dismiss / cancel the notification. */
37+
public static final byte CMD_NEGATIVE = 2;
38+
3039
/**
3140
* Maps notification UID (the value sent to the wearable peer) to the live
3241
* {@link StatusBarNotification}. Entries are added/removed by the
@@ -35,6 +44,42 @@ public class NotificationBridge {
3544
public static final Map<Integer, StatusBarNotification> activeNotifications =
3645
new ConcurrentHashMap<>();
3746

47+
48+
/**
49+
* Dispatches a notification-control command received from the watch.
50+
*
51+
* <p>Payload format:
52+
* <pre>
53+
* byte action 1 = positive, 2 = negative
54+
* int uid notification uid previously assigned by WearableNotificationService
55+
* </pre>
56+
*/
57+
public static void handleCommand(Context context, byte[] data) {
58+
if (data == null || data.length < 5) {
59+
Log.w(TAG, "handleCommand: empty or short payload");
60+
return;
61+
}
62+
try {
63+
java.io.DataInputStream dis =
64+
new java.io.DataInputStream(new java.io.ByteArrayInputStream(data));
65+
byte action = dis.readByte();
66+
int uid = dis.readInt();
67+
Log.d(TAG, "handleCommand: action=" + action + ", uid=" + uid);
68+
switch (action) {
69+
case CMD_POSITIVE:
70+
doPositiveAction(context, uid);
71+
break;
72+
case CMD_NEGATIVE:
73+
doNegativeAction(context, uid);
74+
break;
75+
default:
76+
Log.w(TAG, "handleCommand: unknown action=" + action);
77+
}
78+
} catch (Exception e) {
79+
Log.e(TAG, "handleCommand: failed to parse payload", e);
80+
}
81+
}
82+
3883
/**
3984
* Executes the <em>positive</em> ANCS action for {@code uid}: fires the first
4085
* {@link android.app.Notification.Action} on the notification if one exists.

0 commit comments

Comments
 (0)