Skip to content

Commit 6ff92e8

Browse files
feat(wearable): WearOS foundation — TOS, client APIs, Bluetooth RFCOMM
Unblock companion pairing and wire the Data Layer for Wear OS work on #2843/#2444: - TermsOfServiceActivity shows accept/decline instead of immediate cancel - NodeApi/DataApi/MessageApi delegate to WearableServiceImpl (no more stubs) - BluetoothConnectionThread uses researched WearableBt/Flow/Flow15 UUIDs - enableConnection starts WearableBt client when config.address is a BT MAC - Unit tests guard against known-wrong RFCOMM UUIDs from prior PRs This is foundation work; stock notification/media bridging still needs device-verified companion protocol work. Co-authored-by: Robert Chaney <rwchaneyjr@users.noreply.github.com>
1 parent 9a206ae commit 6ff92e8

15 files changed

Lines changed: 1013 additions & 35 deletions

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,11 @@
138138

139139
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
140140
<uses-permission android:name="android.permission.INTERNET" />
141+
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
142+
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
143+
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
144+
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
145+
android:usesPermissionFlags="neverForLocation" />
141146
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
142147
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
143148
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />

play-services-core/src/main/kotlin/com/google/android/gms/wearable/consent/TermsOfServiceActivity.kt

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,37 @@
66
package com.google.android.gms.wearable.consent
77

88
import android.os.Bundle
9+
import androidx.appcompat.app.AlertDialog
910
import androidx.appcompat.app.AppCompatActivity
11+
import com.google.android.gms.R
1012

13+
/**
14+
* Shown by Wear OS / Galaxy Wearable companion apps during device setup.
15+
*
16+
* Prior to this implementation the activity immediately returned
17+
* [RESULT_CANCELED], which blocked pairing (#2444 / #2843).
18+
*/
1119
class TermsOfServiceActivity : AppCompatActivity() {
1220

1321
override fun onCreate(savedInstanceState: Bundle?) {
1422
super.onCreate(savedInstanceState)
15-
setResult(RESULT_CANCELED)
16-
finish()
23+
24+
AlertDialog.Builder(this)
25+
.setTitle(R.string.wearable_tos_dialog_title)
26+
.setMessage(R.string.wearable_tos_dialog_message)
27+
.setPositiveButton(R.string.wearable_tos_accept) { _, _ ->
28+
setResult(RESULT_OK)
29+
finish()
30+
}
31+
.setNegativeButton(R.string.wearable_tos_decline) { _, _ ->
32+
setResult(RESULT_CANCELED)
33+
finish()
34+
}
35+
.setOnCancelListener {
36+
setResult(RESULT_CANCELED)
37+
finish()
38+
}
39+
.setCancelable(true)
40+
.show()
1741
}
18-
}
42+
}

play-services-core/src/main/res/values/strings.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,4 +475,10 @@ Please set up a password, PIN, or pattern lock screen."</string>
475475
<string name="pref_passkey_manager_transport_bluetooth">Bluetooth</string>
476476
<string name="pref_passkey_manager_transport_hybrid">Hybrid</string>
477477
<string name="pref_passkey_manager_credential_id_format_internal">ID: %1$s</string>
478+
479+
<!-- Wearable / Wear OS consent -->
480+
<string name="wearable_tos_dialog_title">Wear OS terms</string>
481+
<string name="wearable_tos_dialog_message">Companion apps require acceptance of Wear OS terms to finish pairing a watch with this device. microG does not send this choice to Google; accepting only unblocks local setup.</string>
482+
<string name="wearable_tos_accept">Accept</string>
483+
<string name="wearable_tos_decline">Decline</string>
478484
</resources>

play-services-wearable/README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<!--
2+
SPDX-FileCopyrightText: 2026 microG Project Team
3+
SPDX-License-Identifier: Apache-2.0
4+
-->
5+
6+
# play-services-wearable
7+
8+
Client library and service implementation for the Wearable Data Layer APIs used by Wear OS
9+
companion apps and phone↔watch app pairs.
10+
11+
## Current support (foundation)
12+
13+
This module now provides:
14+
15+
1. **Client API facades**`NodeApi`, `DataApi`, and `MessageApi` delegate to
16+
`WearableServiceImpl` (they previously threw `UnsupportedOperationException`).
17+
2. **Wear OS TOS activity**`TermsOfServiceActivity` shows an accept/decline dialog so
18+
companion apps (Galaxy Wearable, Wear OS) are not blocked by an immediate
19+
`RESULT_CANCELED` (see #2444 / #2843).
20+
3. **Bluetooth RFCOMM transport**`BluetoothConnectionThread` speaks the existing
21+
length-prefixed protobuf wire format over Bluetooth Classic, using UUIDs documented in
22+
[teccheck/wearos-research](https://github.com/teccheck/wearos-research/blob/main/docs/btcomm.md):
23+
- `5e8945b0-9525-11e3-a5e2-0800200c9a66` — WearableBt (watch is server; phone connects)
24+
- `fafbdd20-83f0-4389-addf-917ac9dae5b2` — Flow (phone is server)
25+
- `6a1eafb1-61c0-42a0-8bb0-a336fb1c3f00` — Flow15 (phone is server)
26+
27+
When a `ConnectionConfiguration` with a Bluetooth MAC (`address`) is enabled, microG starts a
28+
WearableBt client connection to that device and ensures Flow/Flow15 listeners are running.
29+
The legacy TCP server on port `5601` (config name `"server"`) is unchanged for emulator use.
30+
31+
## What is still missing for full Wear OS
32+
33+
Stock watches will not gain notification mirroring or media controls from custom message paths
34+
alone. Those features depend on a successful companion pairing session and additional
35+
protocol/OEM work beyond the Data Layer framing. Physical-device verification is required
36+
before claiming end-to-end Wear OS support for #2843.
37+
38+
## Testing
39+
40+
```bash
41+
./gradlew :play-services-wearable-core:testDebugUnitTest
42+
```
43+
44+
Unit tests assert the RFCOMM UUIDs match the researched values (and reject known-wrong UUIDs
45+
from earlier PRs).

play-services-wearable/core/build.gradle

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ dependencies {
1515
implementation project(':play-services-wearable')
1616

1717
implementation "org.microg:wearable:$wearableVersion"
18+
19+
testImplementation 'junit:junit:4.13.2'
1820
}
1921

2022
android {
@@ -49,6 +51,10 @@ android {
4951
kotlinOptions {
5052
jvmTarget = 1.8
5153
}
54+
55+
testOptions {
56+
unitTests.returnDefaultValues = true
57+
}
5258
}
5359

5460
apply from: '../../gradle/publish-android.gradle'

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66

77
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
88

9+
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
10+
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
11+
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
12+
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
13+
android:usesPermissionFlags="neverForLocation" />
14+
915
<application>
1016
</application>
1117
</manifest>
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
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.annotation.SuppressLint;
9+
import android.bluetooth.BluetoothAdapter;
10+
import android.bluetooth.BluetoothDevice;
11+
import android.bluetooth.BluetoothServerSocket;
12+
import android.bluetooth.BluetoothSocket;
13+
import android.util.Log;
14+
15+
import org.microg.wearable.SocketWearableConnection;
16+
import org.microg.wearable.WearableConnection;
17+
18+
import java.io.IOException;
19+
import java.io.InputStream;
20+
import java.io.OutputStream;
21+
import java.net.Socket;
22+
import java.util.UUID;
23+
24+
/**
25+
* Bluetooth RFCOMM transport mirroring {@code org.microg.wearable.SocketConnectionThread}.
26+
*
27+
* <p>UUIDs are taken from Pixel Watch / Wear OS traffic research
28+
* (see https://github.com/teccheck/wearos-research/blob/main/docs/btcomm.md):
29+
* <ul>
30+
* <li>{@link #WEARABLE_BT_UUID} — primary WearableBt channel; watch is the RFCOMM server</li>
31+
* <li>{@link #FLOW_UUID} — Flow channel; phone is the RFCOMM server</li>
32+
* <li>{@link #FLOW15_UUID} — Flow15 channel; phone is the RFCOMM server</li>
33+
* </ul>
34+
*
35+
* Each {@link BluetoothSocket} is wrapped in a minimal {@link Socket} proxy so the existing
36+
* length-prefixed protobuf framing in {@link SocketWearableConnection} can be reused.
37+
*/
38+
public abstract class BluetoothConnectionThread extends Thread {
39+
40+
private static final String TAG = "GmsWearBtThread";
41+
42+
/**
43+
* Primary WearableBt RFCOMM UUID. The wearable advertises this service; the phone connects
44+
* as a client.
45+
*/
46+
public static final UUID WEARABLE_BT_UUID =
47+
UUID.fromString("5e8945b0-9525-11e3-a5e2-0800200c9a66");
48+
49+
/** Flow RFCOMM UUID. The phone advertises this service. */
50+
public static final UUID FLOW_UUID =
51+
UUID.fromString("fafbdd20-83f0-4389-addf-917ac9dae5b2");
52+
53+
/** Flow15 RFCOMM UUID. The phone advertises this service. */
54+
public static final UUID FLOW15_UUID =
55+
UUID.fromString("6a1eafb1-61c0-42a0-8bb0-a336fb1c3f00");
56+
57+
static final String WEARABLE_BT_SERVICE_NAME = "WearableBt";
58+
static final String FLOW_SERVICE_NAME = "Flow";
59+
static final String FLOW15_SERVICE_NAME = "Flow15";
60+
61+
private volatile SocketWearableConnection wearableConnection;
62+
63+
BluetoothConnectionThread() {
64+
}
65+
66+
protected void setWearableConnection(SocketWearableConnection connection) {
67+
SocketWearableConnection prev = this.wearableConnection;
68+
this.wearableConnection = connection;
69+
if (prev != null && prev != connection) {
70+
try {
71+
prev.close();
72+
} catch (IOException e) {
73+
Log.w(TAG, "Failed to close previous wearable connection", e);
74+
}
75+
}
76+
}
77+
78+
public SocketWearableConnection getWearableConnection() {
79+
return wearableConnection;
80+
}
81+
82+
public abstract void close();
83+
84+
private static Socket proxySocket(final BluetoothSocket btSocket) {
85+
return new Socket() {
86+
@Override
87+
public InputStream getInputStream() throws IOException {
88+
return btSocket.getInputStream();
89+
}
90+
91+
@Override
92+
public OutputStream getOutputStream() throws IOException {
93+
return btSocket.getOutputStream();
94+
}
95+
96+
@Override
97+
public boolean isConnected() {
98+
return btSocket.isConnected();
99+
}
100+
101+
@Override
102+
public boolean isClosed() {
103+
return !btSocket.isConnected();
104+
}
105+
106+
@Override
107+
public synchronized void close() throws IOException {
108+
btSocket.close();
109+
}
110+
};
111+
}
112+
113+
/**
114+
* Listen for incoming RFCOMM connections on {@code uuid} (phone-as-server roles such as
115+
* Flow / Flow15).
116+
*/
117+
@SuppressLint("MissingPermission")
118+
public static BluetoothConnectionThread serverListen(
119+
BluetoothAdapter adapter, String serviceName, UUID uuid,
120+
WearableConnection.Listener listener) {
121+
return new BluetoothConnectionThread() {
122+
private volatile BluetoothServerSocket serverSocket;
123+
124+
@Override
125+
public void close() {
126+
BluetoothServerSocket s = serverSocket;
127+
serverSocket = null;
128+
if (s != null) {
129+
try {
130+
s.close();
131+
} catch (IOException e) {
132+
Log.w(TAG, "server close: error", e);
133+
}
134+
}
135+
}
136+
137+
@Override
138+
@SuppressLint("MissingPermission")
139+
public void run() {
140+
try {
141+
serverSocket = adapter.listenUsingRfcommWithServiceRecord(serviceName, uuid);
142+
Log.d(TAG, "server: listening on " + serviceName + " (" + uuid + ")");
143+
while (!Thread.interrupted()) {
144+
BluetoothSocket btSocket;
145+
try {
146+
btSocket = serverSocket.accept();
147+
} catch (IOException e) {
148+
break;
149+
}
150+
if (btSocket == null || Thread.interrupted()) break;
151+
try {
152+
SocketWearableConnection conn =
153+
new SocketWearableConnection(proxySocket(btSocket), listener);
154+
setWearableConnection(conn);
155+
try {
156+
conn.run();
157+
} finally {
158+
try {
159+
btSocket.close();
160+
} catch (IOException e) {
161+
Log.w(TAG, "server: close error for accepted connection", e);
162+
}
163+
}
164+
} catch (IOException e) {
165+
Log.w(TAG, "server: error on accepted connection", e);
166+
}
167+
}
168+
} catch (IOException e) {
169+
if (!Thread.interrupted()) {
170+
Log.w(TAG, "server: socket error on " + serviceName, e);
171+
}
172+
} finally {
173+
close();
174+
}
175+
}
176+
};
177+
}
178+
179+
/**
180+
* Connect to a remote wearable that advertises {@link #WEARABLE_BT_UUID}.
181+
*/
182+
@SuppressLint("MissingPermission")
183+
public static BluetoothConnectionThread clientConnect(
184+
BluetoothDevice device, WearableConnection.Listener listener) {
185+
return clientConnect(device, WEARABLE_BT_UUID, listener);
186+
}
187+
188+
@SuppressLint("MissingPermission")
189+
public static BluetoothConnectionThread clientConnect(
190+
BluetoothDevice device, UUID uuid, WearableConnection.Listener listener) {
191+
return new BluetoothConnectionThread() {
192+
private volatile BluetoothSocket btSocket;
193+
194+
@Override
195+
public void close() {
196+
BluetoothSocket s = btSocket;
197+
btSocket = null;
198+
if (s != null) {
199+
try {
200+
s.close();
201+
} catch (IOException e) {
202+
Log.w(TAG, "client close: error", e);
203+
}
204+
}
205+
}
206+
207+
@Override
208+
@SuppressLint("MissingPermission")
209+
public void run() {
210+
BluetoothSocket s = null;
211+
try {
212+
s = device.createRfcommSocketToServiceRecord(uuid);
213+
btSocket = s;
214+
Log.d(TAG, "client: connecting to " + device.getAddress() + " via " + uuid);
215+
s.connect();
216+
SocketWearableConnection conn =
217+
new SocketWearableConnection(proxySocket(s), listener);
218+
setWearableConnection(conn);
219+
conn.run();
220+
} catch (IOException e) {
221+
if (!Thread.interrupted()) {
222+
Log.w(TAG, "client: connection failed", e);
223+
}
224+
} finally {
225+
if (s != null) {
226+
try {
227+
s.close();
228+
} catch (IOException e) {
229+
Log.d(TAG, "client: close error in finally", e);
230+
}
231+
}
232+
btSocket = null;
233+
}
234+
}
235+
};
236+
}
237+
}

0 commit comments

Comments
 (0)