Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions play-services-core/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@

<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,37 @@
package com.google.android.gms.wearable.consent

import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import com.google.android.gms.R

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

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setResult(RESULT_CANCELED)
finish()

AlertDialog.Builder(this)
.setTitle(R.string.wearable_tos_dialog_title)
.setMessage(R.string.wearable_tos_dialog_message)
.setPositiveButton(R.string.wearable_tos_accept) { _, _ ->
setResult(RESULT_OK)
finish()
}
.setNegativeButton(R.string.wearable_tos_decline) { _, _ ->
setResult(RESULT_CANCELED)
finish()
}
.setOnCancelListener {
setResult(RESULT_CANCELED)
finish()
}
.setCancelable(true)
.show()
}
}
}
6 changes: 6 additions & 0 deletions play-services-core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -475,4 +475,10 @@ Please set up a password, PIN, or pattern lock screen."</string>
<string name="pref_passkey_manager_transport_bluetooth">Bluetooth</string>
<string name="pref_passkey_manager_transport_hybrid">Hybrid</string>
<string name="pref_passkey_manager_credential_id_format_internal">ID: %1$s</string>

<!-- Wearable / Wear OS consent -->
<string name="wearable_tos_dialog_title">Wear OS terms</string>
<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>
<string name="wearable_tos_accept">Accept</string>
<string name="wearable_tos_decline">Decline</string>
</resources>
45 changes: 45 additions & 0 deletions play-services-wearable/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<!--
SPDX-FileCopyrightText: 2026 microG Project Team
SPDX-License-Identifier: Apache-2.0
-->

# play-services-wearable

Client library and service implementation for the Wearable Data Layer APIs used by Wear OS
companion apps and phone↔watch app pairs.

## Current support (foundation)

This module now provides:

1. **Client API facades** — `NodeApi`, `DataApi`, and `MessageApi` delegate to
`WearableServiceImpl` (they previously threw `UnsupportedOperationException`).
2. **Wear OS TOS activity** — `TermsOfServiceActivity` shows an accept/decline dialog so
companion apps (Galaxy Wearable, Wear OS) are not blocked by an immediate
`RESULT_CANCELED` (see #2444 / #2843).
3. **Bluetooth RFCOMM transport** — `BluetoothConnectionThread` speaks the existing
length-prefixed protobuf wire format over Bluetooth Classic, using UUIDs documented in
[teccheck/wearos-research](https://github.com/teccheck/wearos-research/blob/main/docs/btcomm.md):
- `5e8945b0-9525-11e3-a5e2-0800200c9a66` — WearableBt (watch is server; phone connects)
- `fafbdd20-83f0-4389-addf-917ac9dae5b2` — Flow (phone is server)
- `6a1eafb1-61c0-42a0-8bb0-a336fb1c3f00` — Flow15 (phone is server)

When a `ConnectionConfiguration` with a Bluetooth MAC (`address`) is enabled, microG starts a
WearableBt client connection to that device and ensures Flow/Flow15 listeners are running.
The legacy TCP server on port `5601` (config name `"server"`) is unchanged for emulator use.

## What is still missing for full Wear OS

Stock watches will not gain notification mirroring or media controls from custom message paths
alone. Those features depend on a successful companion pairing session and additional
protocol/OEM work beyond the Data Layer framing. Physical-device verification is required
before claiming end-to-end Wear OS support for #2843.

## Testing

```bash
./gradlew :play-services-wearable-core:testDebugUnitTest
```

Unit tests assert the RFCOMM UUIDs match the researched values (and reject known-wrong UUIDs
from earlier PRs).
6 changes: 6 additions & 0 deletions play-services-wearable/core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ dependencies {
implementation project(':play-services-wearable')

implementation "org.microg:wearable:$wearableVersion"

testImplementation 'junit:junit:4.13.2'
}

android {
Expand Down Expand Up @@ -49,6 +51,10 @@ android {
kotlinOptions {
jvmTarget = 1.8
}

testOptions {
unitTests.returnDefaultValues = true
}
}

apply from: '../../gradle/publish-android.gradle'
Expand Down
6 changes: 6 additions & 0 deletions play-services-wearable/core/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@

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

<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />

<application>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/

package org.microg.gms.wearable;

import android.annotation.SuppressLint;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothServerSocket;
import android.bluetooth.BluetoothSocket;
import android.util.Log;

import org.microg.wearable.SocketWearableConnection;
import org.microg.wearable.WearableConnection;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.util.UUID;

/**
* Bluetooth RFCOMM transport mirroring {@code org.microg.wearable.SocketConnectionThread}.
*
* <p>UUIDs are taken from Pixel Watch / Wear OS traffic research
* (see https://github.com/teccheck/wearos-research/blob/main/docs/btcomm.md):
* <ul>
* <li>{@link #WEARABLE_BT_UUID} — primary WearableBt channel; watch is the RFCOMM server</li>
* <li>{@link #FLOW_UUID} — Flow channel; phone is the RFCOMM server</li>
* <li>{@link #FLOW15_UUID} — Flow15 channel; phone is the RFCOMM server</li>
* </ul>
*
* Each {@link BluetoothSocket} is wrapped in a minimal {@link Socket} proxy so the existing
* length-prefixed protobuf framing in {@link SocketWearableConnection} can be reused.
*/
public abstract class BluetoothConnectionThread extends Thread {

private static final String TAG = "GmsWearBtThread";

/**
* Primary WearableBt RFCOMM UUID. The wearable advertises this service; the phone connects
* as a client.
*/
public static final UUID WEARABLE_BT_UUID =
UUID.fromString("5e8945b0-9525-11e3-a5e2-0800200c9a66");

/** Flow RFCOMM UUID. The phone advertises this service. */
public static final UUID FLOW_UUID =
UUID.fromString("fafbdd20-83f0-4389-addf-917ac9dae5b2");

/** Flow15 RFCOMM UUID. The phone advertises this service. */
public static final UUID FLOW15_UUID =
UUID.fromString("6a1eafb1-61c0-42a0-8bb0-a336fb1c3f00");

static final String WEARABLE_BT_SERVICE_NAME = "WearableBt";
static final String FLOW_SERVICE_NAME = "Flow";
static final String FLOW15_SERVICE_NAME = "Flow15";

private volatile SocketWearableConnection wearableConnection;

BluetoothConnectionThread() {
}

protected void setWearableConnection(SocketWearableConnection connection) {
SocketWearableConnection prev = this.wearableConnection;
this.wearableConnection = connection;
if (prev != null && prev != connection) {
try {
prev.close();
} catch (IOException e) {
Log.w(TAG, "Failed to close previous wearable connection", e);
}
}
}

public SocketWearableConnection getWearableConnection() {
return wearableConnection;
}

public abstract void close();

private static Socket proxySocket(final BluetoothSocket btSocket) {
return new Socket() {
@Override
public InputStream getInputStream() throws IOException {
return btSocket.getInputStream();
}

@Override
public OutputStream getOutputStream() throws IOException {
return btSocket.getOutputStream();
}

@Override
public boolean isConnected() {
return btSocket.isConnected();
}

@Override
public boolean isClosed() {
return !btSocket.isConnected();
}

@Override
public synchronized void close() throws IOException {
btSocket.close();
}
};
}

/**
* Listen for incoming RFCOMM connections on {@code uuid} (phone-as-server roles such as
* Flow / Flow15).
*/
@SuppressLint("MissingPermission")
public static BluetoothConnectionThread serverListen(
BluetoothAdapter adapter, String serviceName, UUID uuid,
WearableConnection.Listener listener) {
return new BluetoothConnectionThread() {
private volatile BluetoothServerSocket serverSocket;

@Override
public void close() {
BluetoothServerSocket s = serverSocket;
serverSocket = null;
if (s != null) {
try {
s.close();
} catch (IOException e) {
Log.w(TAG, "server close: error", e);
}
}
}

@Override
@SuppressLint("MissingPermission")
public void run() {
try {
serverSocket = adapter.listenUsingRfcommWithServiceRecord(serviceName, uuid);
Log.d(TAG, "server: listening on " + serviceName + " (" + uuid + ")");
while (!Thread.interrupted()) {
BluetoothSocket btSocket;
try {
btSocket = serverSocket.accept();
} catch (IOException e) {
break;
}
if (btSocket == null || Thread.interrupted()) break;
try {
SocketWearableConnection conn =
new SocketWearableConnection(proxySocket(btSocket), listener);
setWearableConnection(conn);
try {
conn.run();
} finally {
try {
btSocket.close();
} catch (IOException e) {
Log.w(TAG, "server: close error for accepted connection", e);
}
}
} catch (IOException e) {
Log.w(TAG, "server: error on accepted connection", e);
}
}
} catch (IOException e) {
if (!Thread.interrupted()) {
Log.w(TAG, "server: socket error on " + serviceName, e);
}
} finally {
close();
}
}
};
}

/**
* Connect to a remote wearable that advertises {@link #WEARABLE_BT_UUID}.
*/
@SuppressLint("MissingPermission")
public static BluetoothConnectionThread clientConnect(
BluetoothDevice device, WearableConnection.Listener listener) {
return clientConnect(device, WEARABLE_BT_UUID, listener);
}

@SuppressLint("MissingPermission")
public static BluetoothConnectionThread clientConnect(
BluetoothDevice device, UUID uuid, WearableConnection.Listener listener) {
return new BluetoothConnectionThread() {
private volatile BluetoothSocket btSocket;

@Override
public void close() {
BluetoothSocket s = btSocket;
btSocket = null;
if (s != null) {
try {
s.close();
} catch (IOException e) {
Log.w(TAG, "client close: error", e);
}
}
}

@Override
@SuppressLint("MissingPermission")
public void run() {
BluetoothSocket s = null;
try {
s = device.createRfcommSocketToServiceRecord(uuid);
btSocket = s;
Log.d(TAG, "client: connecting to " + device.getAddress() + " via " + uuid);
s.connect();
SocketWearableConnection conn =
new SocketWearableConnection(proxySocket(s), listener);
setWearableConnection(conn);
conn.run();
} catch (IOException e) {
if (!Thread.interrupted()) {
Log.w(TAG, "client: connection failed", e);
}
} finally {
if (s != null) {
try {
s.close();
} catch (IOException e) {
Log.d(TAG, "client: close error in finally", e);
}
}
btSocket = null;
}
}
};
}
}
Loading