diff --git a/play-services-core/src/main/AndroidManifest.xml b/play-services-core/src/main/AndroidManifest.xml
index a17d7f03ef..cb2930f544 100644
--- a/play-services-core/src/main/AndroidManifest.xml
+++ b/play-services-core/src/main/AndroidManifest.xml
@@ -1,1472 +1,1493 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/play-services-wearable/core/build.gradle b/play-services-wearable/core/build.gradle
index 5675f7e736..6755ac4375 100644
--- a/play-services-wearable/core/build.gradle
+++ b/play-services-wearable/core/build.gradle
@@ -1,56 +1,57 @@
-/*
- * SPDX-FileCopyrightText: 2022 microG Project Team
- * SPDX-License-Identifier: Apache-2.0
- */
-
-apply plugin: 'com.android.library'
-apply plugin: 'kotlin-android'
-apply plugin: 'maven-publish'
-apply plugin: 'signing'
-
-dependencies {
- implementation project(':play-services-base-core')
-
- implementation project(':play-services-location')
- implementation project(':play-services-wearable')
-
- implementation "org.microg:wearable:$wearableVersion"
-}
-
-android {
- namespace "org.microg.gms.wearable.core"
-
- compileSdkVersion androidCompileSdk
- buildToolsVersion "$androidBuildVersionTools"
-
- defaultConfig {
- versionName version
- minSdkVersion androidMinSdk
- targetSdkVersion androidTargetSdk
- }
-
- buildFeatures {
- dataBinding = true
- }
-
- sourceSets {
- main.java.srcDirs += 'src/main/kotlin'
- }
-
- lintOptions {
- disable 'MissingTranslation'
- }
-
- compileOptions {
- sourceCompatibility = 1.8
- targetCompatibility = 1.8
- }
-
- kotlinOptions {
- jvmTarget = 1.8
- }
-}
-
-apply from: '../../gradle/publish-android.gradle'
-
-description = 'microG service implementation for play-services-wearable'
+/*
+ * SPDX-FileCopyrightText: 2022 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+apply plugin: 'maven-publish'
+apply plugin: 'signing'
+
+dependencies {
+ implementation project(':play-services-base-core')
+
+ implementation project(':play-services-location')
+ implementation project(':play-services-wearable')
+
+ implementation "org.microg:wearable:$wearableVersion"
+ implementation "com.squareup.wire:wire-runtime:$wireVersion"
+}
+
+android {
+ namespace "org.microg.gms.wearable.core"
+
+ compileSdkVersion androidCompileSdk
+ buildToolsVersion "$androidBuildVersionTools"
+
+ defaultConfig {
+ versionName version
+ minSdkVersion androidMinSdk
+ targetSdkVersion androidTargetSdk
+ }
+
+ buildFeatures {
+ dataBinding = true
+ }
+
+ sourceSets {
+ main.java.srcDirs += 'src/main/kotlin'
+ }
+
+ lintOptions {
+ disable 'MissingTranslation'
+ }
+
+ compileOptions {
+ sourceCompatibility = 1.8
+ targetCompatibility = 1.8
+ }
+
+ kotlinOptions {
+ jvmTarget = 1.8
+ }
+}
+
+apply from: '../../gradle/publish-android.gradle'
+
+description = 'microG service implementation for play-services-wearable'
diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/BluetoothConnectionServer.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/BluetoothConnectionServer.java
new file mode 100644
index 0000000000..37eb30a032
--- /dev/null
+++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/BluetoothConnectionServer.java
@@ -0,0 +1,113 @@
+/*
+ * SPDX-FileCopyrightText: 2024, microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.wearable;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothServerSocket;
+import android.bluetooth.BluetoothSocket;
+import android.util.Log;
+
+import org.microg.wearable.WearableConnection;
+
+import java.io.IOException;
+import java.util.UUID;
+
+/**
+ * Bluetooth RFCOMM server that accepts incoming connections from Wear OS watches.
+ *
+ * Wear OS watches use Bluetooth SPP (Serial Port Profile) with the standard
+ * SPP UUID (00001101-0000-1000-8000-00805F9B34FB) or Wear OS-specific UUIDs.
+ */
+public class BluetoothConnectionServer extends Thread {
+
+ private static final String TAG = "GmsWearBtSrv";
+
+ /**
+ * Standard SPP UUID used by Wear OS for initial Bluetooth pairing and communication.
+ */
+ private static final UUID WEAROS_SPP_UUID =
+ UUID.fromString("00001101-0000-1000-8000-00805F9B34FB");
+
+ /**
+ * Additional Wear OS-specific UUID for Google's Wearable protocol.
+ */
+ private static final UUID WEAROS_GOOGLE_UUID =
+ UUID.fromString("00000000-0000-1000-8000-00805F9B34FB");
+
+ private final String serviceName;
+ private final WearableConnection.Listener connectionListener;
+ private BluetoothServerSocket serverSocket;
+
+ public BluetoothConnectionServer(String serviceName, WearableConnection.Listener connectionListener) {
+ super("BluetoothConnectionServer");
+ this.serviceName = serviceName;
+ this.connectionListener = connectionListener;
+ }
+
+ @Override
+ public void run() {
+ BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
+ if (adapter == null) {
+ Log.w(TAG, "Bluetooth not available on this device");
+ return;
+ }
+
+ if (!adapter.isEnabled()) {
+ Log.w(TAG, "Bluetooth is not enabled, cannot start WearOS server");
+ return;
+ }
+
+ // Try to listen on the standard SPP UUID first, fall back to Google UUID
+ BluetoothServerSocket socket = null;
+ try {
+ // Ensure device is discoverable for Wear OS companion app pairing
+ if (adapter.getScanMode() != BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE) {
+ Log.d(TAG, "Requesting discoverable mode for WearOS pairing...");
+ // Note: actual discoverable request requires user consent via intent
+ // This is handled by the calling activity/notification
+ }
+
+ // Listen on the SPP UUID for Wear OS connections
+ socket = adapter.listenUsingRfcommWithServiceRecord(serviceName, WEAROS_SPP_UUID);
+ serverSocket = socket;
+ Log.d(TAG, "Bluetooth server listening on " + WEAROS_SPP_UUID);
+
+ while (!Thread.interrupted()) {
+ BluetoothSocket clientSocket = socket.accept();
+ if (clientSocket != null) {
+ Log.d(TAG, "Accepted Bluetooth connection from: "
+ + clientSocket.getRemoteDevice().getName()
+ + " [" + clientSocket.getRemoteDevice().getAddress() + "]");
+ BluetoothWearableConnection connection =
+ new BluetoothWearableConnection(clientSocket, connectionListener);
+ // Start the connection in a new thread for each client
+ new Thread(connection, "BtConn-" + clientSocket.getRemoteDevice().getAddress()).start();
+ }
+ }
+ } catch (IOException e) {
+ if (!Thread.interrupted()) {
+ Log.e(TAG, "Bluetooth server error", e);
+ }
+ } finally {
+ closeSocket();
+ }
+ }
+
+ public void close() {
+ interrupt();
+ closeSocket();
+ }
+
+ private void closeSocket() {
+ if (serverSocket != null) {
+ try {
+ serverSocket.close();
+ } catch (IOException ignored) {
+ }
+ serverSocket = null;
+ }
+ }
+}
diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/BluetoothWearableConnection.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/BluetoothWearableConnection.java
new file mode 100644
index 0000000000..3777cd87a9
--- /dev/null
+++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/BluetoothWearableConnection.java
@@ -0,0 +1,68 @@
+/*
+ * SPDX-FileCopyrightText: 2024, microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.wearable;
+
+import android.bluetooth.BluetoothSocket;
+import android.util.Log;
+
+import com.squareup.wire.ProtoAdapter;
+
+import org.microg.wearable.WearableConnection;
+import org.microg.wearable.proto.MessagePiece;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+
+/**
+ * WearableConnection that uses Bluetooth RFCOMM (SPP) transport.
+ * This is the primary transport used by Wear OS watches for phone pairing.
+ */
+public class BluetoothWearableConnection extends WearableConnection {
+
+ private static final String TAG = "GmsWearBtConn";
+ private static final int MAX_PIECE_SIZE = 20 * 1024 * 1024;
+
+ private final BluetoothSocket socket;
+ private final DataInputStream is;
+ private final DataOutputStream os;
+
+ public BluetoothWearableConnection(BluetoothSocket socket, Listener listener) throws IOException {
+ super(listener);
+ this.socket = socket;
+ this.is = new DataInputStream(socket.getInputStream());
+ this.os = new DataOutputStream(socket.getOutputStream());
+ }
+
+ @Override
+ protected void writeMessagePiece(MessagePiece piece) throws IOException {
+ byte[] bytes = piece.encode();
+ os.writeInt(bytes.length);
+ os.write(bytes);
+ os.flush();
+ }
+
+ @Override
+ protected MessagePiece readMessagePiece() throws IOException {
+ int len = is.readInt();
+ if (len > MAX_PIECE_SIZE) {
+ throw new IOException("Piece size " + len + " exceeded limit of " + MAX_PIECE_SIZE + " bytes.");
+ }
+ Log.d(TAG, "Reading piece of length " + len);
+ byte[] bytes = new byte[len];
+ is.readFully(bytes);
+ return ProtoAdapter.get(MessagePiece.class).decode(bytes);
+ }
+
+ @Override
+ public void close() throws IOException {
+ try {
+ socket.close();
+ } catch (IOException e) {
+ Log.w(TAG, "Error closing Bluetooth socket", e);
+ }
+ }
+}
diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableImpl.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableImpl.java
index 1f0ed12669..44c68d8859 100644
--- a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableImpl.java
+++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableImpl.java
@@ -1,642 +1,652 @@
-/*
- * Copyright (C) 2013-2019 microG Project Team
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.microg.gms.wearable;
-
-import android.content.Context;
-import android.content.Intent;
-import android.content.IntentFilter;
-import android.database.Cursor;
-import android.net.Uri;
-import android.os.Handler;
-import android.os.Looper;
-import android.os.RemoteException;
-import android.text.TextUtils;
-import android.util.Base64;
-import android.util.Log;
-
-import androidx.annotation.Nullable;
-
-import com.google.android.gms.common.data.DataHolder;
-import com.google.android.gms.wearable.Asset;
-import com.google.android.gms.wearable.ConnectionConfiguration;
-import com.google.android.gms.wearable.Node;
-import com.google.android.gms.wearable.internal.IWearableListener;
-import com.google.android.gms.wearable.internal.MessageEventParcelable;
-import com.google.android.gms.wearable.internal.NodeParcelable;
-import com.google.android.gms.wearable.internal.PutDataRequest;
-
-import org.microg.gms.common.PackageUtils;
-import org.microg.gms.common.RemoteListenerProxy;
-import org.microg.gms.common.Utils;
-import org.microg.wearable.SocketConnectionThread;
-import org.microg.wearable.WearableConnection;
-import org.microg.wearable.proto.AckAsset;
-import org.microg.wearable.proto.AppKey;
-import org.microg.wearable.proto.AppKeys;
-import org.microg.wearable.proto.Connect;
-import org.microg.wearable.proto.FetchAsset;
-import org.microg.wearable.proto.FilePiece;
-import org.microg.wearable.proto.Request;
-import org.microg.wearable.proto.RootMessage;
-import org.microg.wearable.proto.SetAsset;
-import org.microg.wearable.proto.SetDataItem;
-
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.CountDownLatch;
-
-import okio.ByteString;
-
-public class WearableImpl {
-
- private static final String TAG = "GmsWear";
-
- private static final int WEAR_TCP_PORT = 5601;
-
- private final Context context;
- private final NodeDatabaseHelper nodeDatabase;
- private final ConfigurationDatabaseHelper configDatabase;
- private final Map> listeners = new HashMap>();
- private final Set connectedNodes = new HashSet();
- private final Map activeConnections = new HashMap();
- private RpcHelper rpcHelper;
- private SocketConnectionThread sct;
- private ConnectionConfiguration[] configurations;
- private boolean configurationsUpdated = false;
- private ClockworkNodePreferences clockworkNodePreferences;
- private CountDownLatch networkHandlerLock = new CountDownLatch(1);
- public Handler networkHandler;
-
- public WearableImpl(Context context, NodeDatabaseHelper nodeDatabase, ConfigurationDatabaseHelper configDatabase) {
- this.context = context;
- this.nodeDatabase = nodeDatabase;
- this.configDatabase = configDatabase;
- this.clockworkNodePreferences = new ClockworkNodePreferences(context);
- this.rpcHelper = new RpcHelper(context);
- new Thread(() -> {
- Looper.prepare();
- networkHandler = new Handler(Looper.myLooper());
- networkHandlerLock.countDown();
- Looper.loop();
- }).start();
- }
-
- public String getLocalNodeId() {
- return clockworkNodePreferences.getLocalNodeId();
- }
-
- public DataItemRecord putDataItem(String packageName, String signatureDigest, String source, DataItemInternal dataItem) {
- DataItemRecord record = new DataItemRecord();
- record.packageName = packageName;
- record.signatureDigest = signatureDigest;
- record.deleted = false;
- record.source = source;
- record.dataItem = dataItem;
- record.v1SeqId = clockworkNodePreferences.getNextSeqId();
- if (record.source.equals(getLocalNodeId())) record.seqId = record.v1SeqId;
- nodeDatabase.putRecord(record);
- return record;
- }
-
- public DataItemRecord putDataItem(DataItemRecord record) {
- nodeDatabase.putRecord(record);
- if (!record.assetsAreReady) {
- for (Asset asset : record.dataItem.getAssets().values()) {
- if (!nodeDatabase.hasAsset(asset)) {
- Log.d(TAG, "Asset is missing: " + asset);
- }
- }
- }
- Intent intent = new Intent("com.google.android.gms.wearable.DATA_CHANGED");
- intent.setPackage(record.packageName);
- intent.setData(record.dataItem.uri);
- invokeListeners(intent, listener -> listener.onDataChanged(record.toEventDataHolder()));
- return record;
- }
-
- private Asset prepareAsset(String packageName, Asset asset) {
- if (asset.getFd() != null && asset.data == null) {
- try {
- asset.data = Utils.readStreamToEnd(new FileInputStream(asset.getFd().getFileDescriptor()));
- } catch (IOException e) {
- Log.w(TAG, e);
- }
- }
- if (asset.data != null) {
- String digest = calculateDigest(asset.data);
- File assetFile = createAssetFile(digest);
- boolean success = assetFile.exists();
- if (!success) {
- File tmpFile = new File(assetFile.getParent(), assetFile.getName() + ".tmp");
-
- try {
- FileOutputStream stream = new FileOutputStream(tmpFile);
- stream.write(asset.data);
- stream.close();
- success = tmpFile.renameTo(assetFile);
- } catch (IOException e) {
- Log.w(TAG, e);
- }
- }
- if (success) {
- Log.d(TAG, "Successfully created asset file " + assetFile);
- return Asset.createFromRef(digest);
- } else {
- Log.w(TAG, "Failed creating asset file " + assetFile);
- }
- }
- return null;
- }
-
- public File createAssetFile(String digest) {
- File dir = new File(new File(context.getFilesDir(), "assets"), digest.substring(digest.length() - 2));
- dir.mkdirs();
- return new File(dir, digest + ".asset");
- }
-
- private File createAssetReceiveTempFile(String name) {
- File dir = new File(context.getFilesDir(), "piece");
- dir.mkdirs();
- return new File(dir, name);
- }
-
- private String calculateDigest(byte[] data) {
- try {
- return Base64.encodeToString(MessageDigest.getInstance("SHA1").digest(data), Base64.NO_WRAP | Base64.NO_PADDING | Base64.URL_SAFE);
- } catch (NoSuchAlgorithmException e) {
- throw new RuntimeException(e);
- }
- }
-
- public synchronized ConnectionConfiguration[] getConfigurations() {
- if (configurations == null) {
- configurations = configDatabase.getAllConfigurations();
- }
- if (configurationsUpdated) {
- configurationsUpdated = false;
- ConnectionConfiguration[] newConfigurations = configDatabase.getAllConfigurations();
- for (ConnectionConfiguration configuration : configurations) {
- for (ConnectionConfiguration newConfiguration : newConfigurations) {
- if (newConfiguration.name.equals(configuration.name)) {
- newConfiguration.connected = configuration.connected;
- newConfiguration.peerNodeId = configuration.peerNodeId;
- newConfiguration.nodeId = configuration.nodeId;
- break;
- }
- }
- }
- configurations = newConfigurations;
- }
- Log.d(TAG, "Configurations reported: " + Arrays.toString(configurations));
- return configurations;
- }
-
- private void addConnectedNode(Node node) {
- connectedNodes.add(node);
- onConnectedNodes(getConnectedNodesParcelableList());
- }
-
- private void removeConnectedNode(String nodeId) {
- for (Node connectedNode : new ArrayList(connectedNodes)) {
- if (connectedNode.getId().equals(nodeId))
- connectedNodes.remove(connectedNode);
- }
- onConnectedNodes(getConnectedNodesParcelableList());
- }
-
-
- public Context getContext() {
- return context;
- }
-
- public void syncToPeer(String peerNodeId, String nodeId, long seqId) {
- Log.d(TAG, "-- Start syncing over to " + peerNodeId + ", nodeId " + nodeId + " starting with seqId " + seqId);
- Cursor cursor = nodeDatabase.getModifiedDataItems(nodeId, seqId, true);
- if (cursor != null) {
- while (cursor.moveToNext()) {
- if (!syncRecordToPeer(peerNodeId, DataItemRecord.fromCursor(cursor))) break;
- }
- cursor.close();
- }
- Log.d(TAG, "-- Done syncing over to " + peerNodeId + ", nodeId " + nodeId + " starting with seqId " + seqId);
- }
-
-
- void syncRecordToAll(DataItemRecord record) {
- for (String nodeId : new ArrayList(activeConnections.keySet())) {
- syncRecordToPeer(nodeId, record);
- }
- }
-
- private boolean syncRecordToPeer(String nodeId, DataItemRecord record) {
- for (Asset asset : record.dataItem.getAssets().values()) {
- try {
- syncAssetToPeer(nodeId, record, asset);
- } catch (Exception e) {
- Log.w(TAG, "Could not sync asset " + asset + " for " + nodeId + " and " + record, e);
- closeConnection(nodeId);
- return false;
- }
- }
-
- try {
- SetDataItem item = record.toSetDataItem();
- activeConnections.get(nodeId).writeMessage(new RootMessage.Builder().setDataItem(item).build());
- } catch (Exception e) {
- Log.w(TAG, e);
- closeConnection(nodeId);
- return false;
- }
- return true;
- }
-
- private void syncAssetToPeer(String nodeId, DataItemRecord record, Asset asset) throws IOException {
- RootMessage announceMessage = new RootMessage.Builder().setAsset(new SetAsset.Builder()
- .digest(asset.getDigest())
- .appkeys(new AppKeys(Collections.singletonList(new AppKey(record.packageName, record.signatureDigest))))
- .build()).hasAsset(true).build();
- activeConnections.get(nodeId).writeMessage(announceMessage);
- File assetFile = createAssetFile(asset.getDigest());
- String fileName = calculateDigest(announceMessage.encode());
- FileInputStream fis = new FileInputStream(assetFile);
- byte[] arr = new byte[12215];
- ByteString lastPiece = null;
- int c = 0;
- while ((c = fis.read(arr)) > 0) {
- if (lastPiece != null) {
- activeConnections.get(nodeId).writeMessage(new RootMessage.Builder().filePiece(new FilePiece(fileName, false, lastPiece, null)).build());
- }
- lastPiece = ByteString.of(arr, 0, c);
- }
- activeConnections.get(nodeId).writeMessage(new RootMessage.Builder().filePiece(new FilePiece(fileName, true, lastPiece, asset.getDigest())).build());
- }
-
- public void addAssetToDatabase(Asset asset, List appKeys) {
- nodeDatabase.putAsset(asset, false);
- for (AppKey appKey : appKeys) {
- nodeDatabase.allowAssetAccess(asset.getDigest(), appKey.packageName, appKey.signatureDigest);
- }
- }
-
- public long getCurrentSeqId(String nodeId) {
- return nodeDatabase.getCurrentSeqId(nodeId);
- }
-
- public void handleFilePiece(WearableConnection connection, String fileName, byte[] bytes, String finalPieceDigest) {
- File file = createAssetReceiveTempFile(fileName);
- try {
- FileOutputStream fos = new FileOutputStream(file, true);
- fos.write(bytes);
- fos.close();
- } catch (IOException e) {
- Log.w(TAG, e);
- }
- if (finalPieceDigest != null) {
- // This is a final piece. If digest matches we're so happy!
- try {
- String digest = calculateDigest(Utils.readStreamToEnd(new FileInputStream(file)));
- if (digest.equals(finalPieceDigest)) {
- if (file.renameTo(createAssetFile(digest))) {
- nodeDatabase.markAssetAsPresent(digest);
- connection.writeMessage(new RootMessage.Builder().ackAsset(new AckAsset(digest)).build());
- } else {
- Log.w(TAG, "Could not rename to target file name. delete=" + file.delete());
- }
- } else {
- Log.w(TAG, "Received digest does not match. delete=" + file.delete());
- }
- } catch (IOException e) {
- Log.w(TAG, "Failed working with temp file. delete=" + file.delete(), e);
- }
- }
- }
-
- public void onConnectReceived(WearableConnection connection, String nodeId, Connect connect) {
- for (ConnectionConfiguration config : getConfigurations()) {
- if (config.nodeId.equals(nodeId)) {
- if (config.nodeId != nodeId) {
- config.nodeId = connect.id;
- configDatabase.putConfiguration(config, nodeId);
- }
- config.peerNodeId = connect.id;
- config.connected = true;
- }
- }
- Log.d(TAG, "Adding connection to list of open connections: " + connection + " with connect " + connect);
- activeConnections.put(connect.id, connection);
- onPeerConnected(new NodeParcelable(connect.id, connect.name));
- // Fetch missing assets
- Cursor cursor = nodeDatabase.listMissingAssets();
- if (cursor != null) {
- while (cursor.moveToNext()) {
- try {
- Log.d(TAG, "Fetch for " + cursor.getString(12));
- connection.writeMessage(new RootMessage.Builder()
- .fetchAsset(new FetchAsset.Builder()
- .assetName(cursor.getString(12))
- .packageName(cursor.getString(1))
- .signatureDigest(cursor.getString(2))
- .permission(false)
- .build()).build());
- } catch (IOException e) {
- Log.w(TAG, e);
- closeConnection(connect.id);
- }
- }
- cursor.close();
- }
- }
-
- public void onDisconnectReceived(WearableConnection connection, Connect connect) {
- for (ConnectionConfiguration config : getConfigurations()) {
- if (config.nodeId.equals(connect.id)) {
- config.connected = false;
- }
- }
- Log.d(TAG, "Removing connection from list of open connections: " + connection);
- activeConnections.remove(connect.id);
- onPeerDisconnected(new NodeParcelable(connect.id, connect.name));
- }
-
- public List getConnectedNodesParcelableList() {
- List nodes = new ArrayList();
- for (Node connectedNode : connectedNodes) {
- nodes.add(new NodeParcelable(connectedNode));
- }
- return nodes;
- }
-
- interface ListenerInvoker {
- void invoke(IWearableListener listener) throws RemoteException;
- }
-
- private void invokeListeners(@Nullable Intent intent, ListenerInvoker invoker) {
- for (String packageName : new ArrayList<>(listeners.keySet())) {
- List listeners = this.listeners.get(packageName);
- if (listeners == null) continue;
- for (int i = 0; i < listeners.size(); i++) {
- boolean filterMatched = false;
- if (intent != null) {
- for (IntentFilter filter : listeners.get(i).filters) {
- filterMatched |= filter.match(context.getContentResolver(), intent, false, TAG) > 0;
- }
- }
- if (filterMatched || listeners.get(i).filters.length == 0) {
- try {
- invoker.invoke(listeners.get(i).listener);
- } catch (RemoteException e) {
- Log.w(TAG, "Registered listener at package " + packageName + " failed, removing.");
- listeners.remove(i);
- i--;
- }
- }
- }
- if (listeners.isEmpty()) {
- this.listeners.remove(packageName);
- }
- }
- if (intent != null) {
- try {
- invoker.invoke(RemoteListenerProxy.get(context, intent, IWearableListener.class, "com.google.android.gms.wearable.BIND_LISTENER"));
- } catch (RemoteException e) {
- Log.w(TAG, "Failed to deliver message received to " + intent, e);
- }
- }
- }
-
- public void onPeerConnected(NodeParcelable node) {
- Log.d(TAG, "onPeerConnected: " + node);
- invokeListeners(null, listener -> listener.onPeerConnected(node));
- addConnectedNode(node);
- }
-
- public void onPeerDisconnected(NodeParcelable node) {
- Log.d(TAG, "onPeerDisconnected: " + node);
- invokeListeners(null, listener -> listener.onPeerDisconnected(node));
- removeConnectedNode(node.getId());
- }
-
- public void onConnectedNodes(List nodes) {
- Log.d(TAG, "onConnectedNodes: " + nodes);
- invokeListeners(null, listener -> listener.onConnectedNodes(nodes));
- }
-
- public DataItemRecord putData(PutDataRequest request, String packageName) {
- DataItemInternal dataItem = new DataItemInternal(fixHost(request.getUri().getHost(), true), request.getUri().getPath());
- for (Map.Entry assetEntry : request.getAssets().entrySet()) {
- Asset asset = prepareAsset(packageName, assetEntry.getValue());
- if (asset != null) {
- nodeDatabase.putAsset(asset, true);
- dataItem.addAsset(assetEntry.getKey(), asset);
- }
- }
- dataItem.data = request.getData();
- DataItemRecord record = putDataItem(packageName, PackageUtils.firstSignatureDigest(context, packageName), getLocalNodeId(), dataItem);
- syncRecordToAll(record);
- return record;
- }
-
- public DataHolder getDataItemsAsHolder(String packageName) {
- Cursor dataHolderItems = nodeDatabase.getDataItemsForDataHolder(packageName, PackageUtils.firstSignatureDigest(context, packageName));
- return new DataHolder(dataHolderItems, 0, null);
- }
-
- private String fixHost(String host, boolean nothingToLocal) {
- if (TextUtils.isEmpty(host) && nothingToLocal) return getLocalNodeId();
- if (TextUtils.isEmpty(host)) return null;
- if (host.equals("local")) return getLocalNodeId();
- return host;
- }
-
- public DataHolder getDataItemsByUriAsHolder(Uri uri, String packageName) {
- String firstSignature;
- try {
- firstSignature = PackageUtils.firstSignatureDigest(context, packageName);
- } catch (Exception e) {
- return null;
- }
- Cursor dataHolderItems = nodeDatabase.getDataItemsForDataHolderByHostAndPath(packageName, firstSignature, fixHost(uri.getHost(), false), uri.getPath());
- DataHolder dataHolder = new DataHolder(dataHolderItems, 0, null);
- Log.d(TAG, "Returning data holder of size " + dataHolder.getCount() + " for query " + uri);
- return dataHolder;
- }
-
- public synchronized void addListener(String packageName, IWearableListener listener, IntentFilter[] filters) {
- if (!listeners.containsKey(packageName)) {
- listeners.put(packageName, new ArrayList());
- }
- listeners.get(packageName).add(new ListenerInfo(listener, filters));
- }
-
- public void removeListener(IWearableListener listener) {
- for (List list : listeners.values()) {
- for (int i = 0; i < list.size(); i++) {
- if (list.get(i).listener.equals(listener)) {
- list.remove(i);
- i--;
- }
- }
- }
- }
-
- public void enableConnection(String name) {
- configDatabase.setEnabledState(name, true);
- configurationsUpdated = true;
- if (name.equals("server") && sct == null) {
- Log.d(TAG, "Starting server on :" + WEAR_TCP_PORT);
- (sct = SocketConnectionThread.serverListen(WEAR_TCP_PORT, new MessageHandler(context, this, configDatabase.getConfiguration(name)))).start();
- }
- }
-
- public void disableConnection(String name) {
- configDatabase.setEnabledState(name, false);
- configurationsUpdated = true;
- if (name.equals("server") && sct != null) {
- activeConnections.remove(sct.getWearableConnection());
- sct.close();
- sct.interrupt();
- sct = null;
- }
- }
-
- public void deleteConnection(String name) {
- configDatabase.deleteConfiguration(name);
- configurationsUpdated = true;
- }
-
- public void createConnection(ConnectionConfiguration config) {
- if (config.nodeId == null) config.nodeId = getLocalNodeId();
- Log.d(TAG, "putConfig[nyp]: " + config);
- configDatabase.putConfiguration(config);
- configurationsUpdated = true;
- }
-
- public int deleteDataItems(Uri uri, String packageName) {
- List records = nodeDatabase.deleteDataItems(packageName, PackageUtils.firstSignatureDigest(context, packageName), fixHost(uri.getHost(), false), uri.getPath());
- for (DataItemRecord record : records) {
- syncRecordToAll(record);
- }
- return records.size();
- }
-
- public void sendMessageReceived(String packageName, MessageEventParcelable messageEvent) {
- Log.d(TAG, "onMessageReceived: " + messageEvent);
- Intent intent = new Intent("com.google.android.gms.wearable.MESSAGE_RECEIVED");
- intent.setPackage(packageName);
- intent.setData(Uri.parse("wear://" + getLocalNodeId() + "/" + messageEvent.getPath()));
- invokeListeners(intent, listener -> listener.onMessageReceived(messageEvent));
- }
-
- public DataItemRecord getDataItemByUri(Uri uri, String packageName) {
- Cursor cursor = nodeDatabase.getDataItemsByHostAndPath(packageName, PackageUtils.firstSignatureDigest(context, packageName), fixHost(uri.getHost(), true), uri.getPath());
- DataItemRecord record = null;
- if (cursor != null) {
- if (cursor.moveToNext()) {
- record = DataItemRecord.fromCursor(cursor);
- }
- cursor.close();
- }
- Log.d(TAG, "getDataItem: " + record);
- return record;
- }
-
- private IWearableListener getListener(String packageName, String action, Uri uri) {
- Intent intent = new Intent(action);
- intent.setPackage(packageName);
- intent.setData(uri);
-
- return RemoteListenerProxy.get(context, intent, IWearableListener.class, "com.google.android.gms.wearable.BIND_LISTENER");
- }
-
- private void closeConnection(String nodeId) {
- WearableConnection connection = activeConnections.get(nodeId);
- try {
- connection.close();
- } catch (IOException e1) {
- Log.w(TAG, e1);
- }
- if (connection == sct.getWearableConnection()) {
- sct.close();
- sct = null;
- }
- activeConnections.remove(nodeId);
- for (ConnectionConfiguration config : getConfigurations()) {
- if (nodeId.equals(config.nodeId) || nodeId.equals(config.peerNodeId)) {
- config.connected = false;
- }
- }
- onPeerDisconnected(new NodeParcelable(nodeId, "Wear device"));
- Log.d(TAG, "Closed connection to " + nodeId + " on error");
- }
-
- public int sendMessage(String packageName, String targetNodeId, String path, byte[] data) {
- if (activeConnections.containsKey(targetNodeId)) {
- WearableConnection connection = activeConnections.get(targetNodeId);
- RpcHelper.RpcConnectionState state = rpcHelper.useConnectionState(packageName, targetNodeId, path);
- try {
- connection.writeMessage(new RootMessage.Builder().rpcRequest(new Request.Builder()
- .targetNodeId(targetNodeId)
- .path(path)
- .rawData(ByteString.of(data))
- .packageName(packageName)
- .signatureDigest(PackageUtils.firstSignatureDigest(context, packageName))
- .sourceNodeId(getLocalNodeId())
- .generation(state.generation)
- .requestId(state.lastRequestId)
- .build()).build());
- } catch (IOException e) {
- Log.w(TAG, "Error while writing, closing link", e);
- closeConnection(targetNodeId);
- return -1;
- }
- return (state.generation + 527) * 31 + state.lastRequestId;
- }
- Log.d(TAG, targetNodeId + " seems not reachable");
- return -1;
- }
-
- public void stop() {
- try {
- this.networkHandlerLock.await();
- this.networkHandler.getLooper().quit();
- } catch (InterruptedException e) {
- Log.w(TAG, e);
- }
- }
-
- private class ListenerInfo {
- private IWearableListener listener;
- private IntentFilter[] filters;
-
- private ListenerInfo(IWearableListener listener, IntentFilter[] filters) {
- this.listener = listener;
- this.filters = filters;
- }
- }
-}
+/*
+ * Copyright (C) 2013-2019 microG Project Team
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.microg.gms.wearable;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.RemoteException;
+import android.text.TextUtils;
+import android.util.Base64;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+import com.google.android.gms.common.data.DataHolder;
+import com.google.android.gms.wearable.Asset;
+import com.google.android.gms.wearable.ConnectionConfiguration;
+import com.google.android.gms.wearable.Node;
+import com.google.android.gms.wearable.internal.IWearableListener;
+import com.google.android.gms.wearable.internal.MessageEventParcelable;
+import com.google.android.gms.wearable.internal.NodeParcelable;
+import com.google.android.gms.wearable.internal.PutDataRequest;
+
+import org.microg.gms.common.PackageUtils;
+import org.microg.gms.common.RemoteListenerProxy;
+import org.microg.gms.common.Utils;
+import org.microg.wearable.SocketConnectionThread;
+import org.microg.wearable.WearableConnection;
+import org.microg.wearable.proto.AckAsset;
+import org.microg.wearable.proto.AppKey;
+import org.microg.wearable.proto.AppKeys;
+import org.microg.wearable.proto.Connect;
+import org.microg.wearable.proto.FetchAsset;
+import org.microg.wearable.proto.FilePiece;
+import org.microg.wearable.proto.Request;
+import org.microg.wearable.proto.RootMessage;
+import org.microg.wearable.proto.SetAsset;
+import org.microg.wearable.proto.SetDataItem;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+
+import okio.ByteString;
+
+public class WearableImpl {
+
+ private static final String TAG = "GmsWear";
+
+ private static final int WEAR_TCP_PORT = 5601;
+
+ private final Context context;
+ private final NodeDatabaseHelper nodeDatabase;
+ private final ConfigurationDatabaseHelper configDatabase;
+ private final Map> listeners = new HashMap>();
+ private final Set connectedNodes = new HashSet();
+ private final Map activeConnections = new HashMap();
+ private RpcHelper rpcHelper;
+ private SocketConnectionThread sct;
+ private BluetoothConnectionServer btServer;
+ private ConnectionConfiguration[] configurations;
+ private boolean configurationsUpdated = false;
+ private ClockworkNodePreferences clockworkNodePreferences;
+ private CountDownLatch networkHandlerLock = new CountDownLatch(1);
+ public Handler networkHandler;
+
+ public WearableImpl(Context context, NodeDatabaseHelper nodeDatabase, ConfigurationDatabaseHelper configDatabase) {
+ this.context = context;
+ this.nodeDatabase = nodeDatabase;
+ this.configDatabase = configDatabase;
+ this.clockworkNodePreferences = new ClockworkNodePreferences(context);
+ this.rpcHelper = new RpcHelper(context);
+ new Thread(() -> {
+ Looper.prepare();
+ networkHandler = new Handler(Looper.myLooper());
+ networkHandlerLock.countDown();
+ Looper.loop();
+ }).start();
+ }
+
+ public String getLocalNodeId() {
+ return clockworkNodePreferences.getLocalNodeId();
+ }
+
+ public DataItemRecord putDataItem(String packageName, String signatureDigest, String source, DataItemInternal dataItem) {
+ DataItemRecord record = new DataItemRecord();
+ record.packageName = packageName;
+ record.signatureDigest = signatureDigest;
+ record.deleted = false;
+ record.source = source;
+ record.dataItem = dataItem;
+ record.v1SeqId = clockworkNodePreferences.getNextSeqId();
+ if (record.source.equals(getLocalNodeId())) record.seqId = record.v1SeqId;
+ nodeDatabase.putRecord(record);
+ return record;
+ }
+
+ public DataItemRecord putDataItem(DataItemRecord record) {
+ nodeDatabase.putRecord(record);
+ if (!record.assetsAreReady) {
+ for (Asset asset : record.dataItem.getAssets().values()) {
+ if (!nodeDatabase.hasAsset(asset)) {
+ Log.d(TAG, "Asset is missing: " + asset);
+ }
+ }
+ }
+ Intent intent = new Intent("com.google.android.gms.wearable.DATA_CHANGED");
+ intent.setPackage(record.packageName);
+ intent.setData(record.dataItem.uri);
+ invokeListeners(intent, listener -> listener.onDataChanged(record.toEventDataHolder()));
+ return record;
+ }
+
+ private Asset prepareAsset(String packageName, Asset asset) {
+ if (asset.getFd() != null && asset.data == null) {
+ try {
+ asset.data = Utils.readStreamToEnd(new FileInputStream(asset.getFd().getFileDescriptor()));
+ } catch (IOException e) {
+ Log.w(TAG, e);
+ }
+ }
+ if (asset.data != null) {
+ String digest = calculateDigest(asset.data);
+ File assetFile = createAssetFile(digest);
+ boolean success = assetFile.exists();
+ if (!success) {
+ File tmpFile = new File(assetFile.getParent(), assetFile.getName() + ".tmp");
+
+ try {
+ FileOutputStream stream = new FileOutputStream(tmpFile);
+ stream.write(asset.data);
+ stream.close();
+ success = tmpFile.renameTo(assetFile);
+ } catch (IOException e) {
+ Log.w(TAG, e);
+ }
+ }
+ if (success) {
+ Log.d(TAG, "Successfully created asset file " + assetFile);
+ return Asset.createFromRef(digest);
+ } else {
+ Log.w(TAG, "Failed creating asset file " + assetFile);
+ }
+ }
+ return null;
+ }
+
+ public File createAssetFile(String digest) {
+ File dir = new File(new File(context.getFilesDir(), "assets"), digest.substring(digest.length() - 2));
+ dir.mkdirs();
+ return new File(dir, digest + ".asset");
+ }
+
+ private File createAssetReceiveTempFile(String name) {
+ File dir = new File(context.getFilesDir(), "piece");
+ dir.mkdirs();
+ return new File(dir, name);
+ }
+
+ private String calculateDigest(byte[] data) {
+ try {
+ return Base64.encodeToString(MessageDigest.getInstance("SHA1").digest(data), Base64.NO_WRAP | Base64.NO_PADDING | Base64.URL_SAFE);
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public synchronized ConnectionConfiguration[] getConfigurations() {
+ if (configurations == null) {
+ configurations = configDatabase.getAllConfigurations();
+ }
+ if (configurationsUpdated) {
+ configurationsUpdated = false;
+ ConnectionConfiguration[] newConfigurations = configDatabase.getAllConfigurations();
+ for (ConnectionConfiguration configuration : configurations) {
+ for (ConnectionConfiguration newConfiguration : newConfigurations) {
+ if (newConfiguration.name.equals(configuration.name)) {
+ newConfiguration.connected = configuration.connected;
+ newConfiguration.peerNodeId = configuration.peerNodeId;
+ newConfiguration.nodeId = configuration.nodeId;
+ break;
+ }
+ }
+ }
+ configurations = newConfigurations;
+ }
+ Log.d(TAG, "Configurations reported: " + Arrays.toString(configurations));
+ return configurations;
+ }
+
+ private void addConnectedNode(Node node) {
+ connectedNodes.add(node);
+ onConnectedNodes(getConnectedNodesParcelableList());
+ }
+
+ private void removeConnectedNode(String nodeId) {
+ for (Node connectedNode : new ArrayList(connectedNodes)) {
+ if (connectedNode.getId().equals(nodeId))
+ connectedNodes.remove(connectedNode);
+ }
+ onConnectedNodes(getConnectedNodesParcelableList());
+ }
+
+
+ public Context getContext() {
+ return context;
+ }
+
+ public void syncToPeer(String peerNodeId, String nodeId, long seqId) {
+ Log.d(TAG, "-- Start syncing over to " + peerNodeId + ", nodeId " + nodeId + " starting with seqId " + seqId);
+ Cursor cursor = nodeDatabase.getModifiedDataItems(nodeId, seqId, true);
+ if (cursor != null) {
+ while (cursor.moveToNext()) {
+ if (!syncRecordToPeer(peerNodeId, DataItemRecord.fromCursor(cursor))) break;
+ }
+ cursor.close();
+ }
+ Log.d(TAG, "-- Done syncing over to " + peerNodeId + ", nodeId " + nodeId + " starting with seqId " + seqId);
+ }
+
+
+ void syncRecordToAll(DataItemRecord record) {
+ for (String nodeId : new ArrayList(activeConnections.keySet())) {
+ syncRecordToPeer(nodeId, record);
+ }
+ }
+
+ private boolean syncRecordToPeer(String nodeId, DataItemRecord record) {
+ for (Asset asset : record.dataItem.getAssets().values()) {
+ try {
+ syncAssetToPeer(nodeId, record, asset);
+ } catch (Exception e) {
+ Log.w(TAG, "Could not sync asset " + asset + " for " + nodeId + " and " + record, e);
+ closeConnection(nodeId);
+ return false;
+ }
+ }
+
+ try {
+ SetDataItem item = record.toSetDataItem();
+ activeConnections.get(nodeId).writeMessage(new RootMessage.Builder().setDataItem(item).build());
+ } catch (Exception e) {
+ Log.w(TAG, e);
+ closeConnection(nodeId);
+ return false;
+ }
+ return true;
+ }
+
+ private void syncAssetToPeer(String nodeId, DataItemRecord record, Asset asset) throws IOException {
+ RootMessage announceMessage = new RootMessage.Builder().setAsset(new SetAsset.Builder()
+ .digest(asset.getDigest())
+ .appkeys(new AppKeys(Collections.singletonList(new AppKey(record.packageName, record.signatureDigest))))
+ .build()).hasAsset(true).build();
+ activeConnections.get(nodeId).writeMessage(announceMessage);
+ File assetFile = createAssetFile(asset.getDigest());
+ String fileName = calculateDigest(announceMessage.encode());
+ FileInputStream fis = new FileInputStream(assetFile);
+ byte[] arr = new byte[12215];
+ ByteString lastPiece = null;
+ int c = 0;
+ while ((c = fis.read(arr)) > 0) {
+ if (lastPiece != null) {
+ activeConnections.get(nodeId).writeMessage(new RootMessage.Builder().filePiece(new FilePiece(fileName, false, lastPiece, null)).build());
+ }
+ lastPiece = ByteString.of(arr, 0, c);
+ }
+ activeConnections.get(nodeId).writeMessage(new RootMessage.Builder().filePiece(new FilePiece(fileName, true, lastPiece, asset.getDigest())).build());
+ }
+
+ public void addAssetToDatabase(Asset asset, List appKeys) {
+ nodeDatabase.putAsset(asset, false);
+ for (AppKey appKey : appKeys) {
+ nodeDatabase.allowAssetAccess(asset.getDigest(), appKey.packageName, appKey.signatureDigest);
+ }
+ }
+
+ public long getCurrentSeqId(String nodeId) {
+ return nodeDatabase.getCurrentSeqId(nodeId);
+ }
+
+ public void handleFilePiece(WearableConnection connection, String fileName, byte[] bytes, String finalPieceDigest) {
+ File file = createAssetReceiveTempFile(fileName);
+ try {
+ FileOutputStream fos = new FileOutputStream(file, true);
+ fos.write(bytes);
+ fos.close();
+ } catch (IOException e) {
+ Log.w(TAG, e);
+ }
+ if (finalPieceDigest != null) {
+ // This is a final piece. If digest matches we're so happy!
+ try {
+ String digest = calculateDigest(Utils.readStreamToEnd(new FileInputStream(file)));
+ if (digest.equals(finalPieceDigest)) {
+ if (file.renameTo(createAssetFile(digest))) {
+ nodeDatabase.markAssetAsPresent(digest);
+ connection.writeMessage(new RootMessage.Builder().ackAsset(new AckAsset(digest)).build());
+ } else {
+ Log.w(TAG, "Could not rename to target file name. delete=" + file.delete());
+ }
+ } else {
+ Log.w(TAG, "Received digest does not match. delete=" + file.delete());
+ }
+ } catch (IOException e) {
+ Log.w(TAG, "Failed working with temp file. delete=" + file.delete(), e);
+ }
+ }
+ }
+
+ public void onConnectReceived(WearableConnection connection, String nodeId, Connect connect) {
+ for (ConnectionConfiguration config : getConfigurations()) {
+ if (config.nodeId.equals(nodeId)) {
+ if (config.nodeId != nodeId) {
+ config.nodeId = connect.id;
+ configDatabase.putConfiguration(config, nodeId);
+ }
+ config.peerNodeId = connect.id;
+ config.connected = true;
+ }
+ }
+ Log.d(TAG, "Adding connection to list of open connections: " + connection + " with connect " + connect);
+ activeConnections.put(connect.id, connection);
+ onPeerConnected(new NodeParcelable(connect.id, connect.name));
+ // Fetch missing assets
+ Cursor cursor = nodeDatabase.listMissingAssets();
+ if (cursor != null) {
+ while (cursor.moveToNext()) {
+ try {
+ Log.d(TAG, "Fetch for " + cursor.getString(12));
+ connection.writeMessage(new RootMessage.Builder()
+ .fetchAsset(new FetchAsset.Builder()
+ .assetName(cursor.getString(12))
+ .packageName(cursor.getString(1))
+ .signatureDigest(cursor.getString(2))
+ .permission(false)
+ .build()).build());
+ } catch (IOException e) {
+ Log.w(TAG, e);
+ closeConnection(connect.id);
+ }
+ }
+ cursor.close();
+ }
+ }
+
+ public void onDisconnectReceived(WearableConnection connection, Connect connect) {
+ for (ConnectionConfiguration config : getConfigurations()) {
+ if (config.nodeId.equals(connect.id)) {
+ config.connected = false;
+ }
+ }
+ Log.d(TAG, "Removing connection from list of open connections: " + connection);
+ activeConnections.remove(connect.id);
+ onPeerDisconnected(new NodeParcelable(connect.id, connect.name));
+ }
+
+ public List getConnectedNodesParcelableList() {
+ List nodes = new ArrayList();
+ for (Node connectedNode : connectedNodes) {
+ nodes.add(new NodeParcelable(connectedNode));
+ }
+ return nodes;
+ }
+
+ interface ListenerInvoker {
+ void invoke(IWearableListener listener) throws RemoteException;
+ }
+
+ private void invokeListeners(@Nullable Intent intent, ListenerInvoker invoker) {
+ for (String packageName : new ArrayList<>(listeners.keySet())) {
+ List listeners = this.listeners.get(packageName);
+ if (listeners == null) continue;
+ for (int i = 0; i < listeners.size(); i++) {
+ boolean filterMatched = false;
+ if (intent != null) {
+ for (IntentFilter filter : listeners.get(i).filters) {
+ filterMatched |= filter.match(context.getContentResolver(), intent, false, TAG) > 0;
+ }
+ }
+ if (filterMatched || listeners.get(i).filters.length == 0) {
+ try {
+ invoker.invoke(listeners.get(i).listener);
+ } catch (RemoteException e) {
+ Log.w(TAG, "Registered listener at package " + packageName + " failed, removing.");
+ listeners.remove(i);
+ i--;
+ }
+ }
+ }
+ if (listeners.isEmpty()) {
+ this.listeners.remove(packageName);
+ }
+ }
+ if (intent != null) {
+ try {
+ invoker.invoke(RemoteListenerProxy.get(context, intent, IWearableListener.class, "com.google.android.gms.wearable.BIND_LISTENER"));
+ } catch (RemoteException e) {
+ Log.w(TAG, "Failed to deliver message received to " + intent, e);
+ }
+ }
+ }
+
+ public void onPeerConnected(NodeParcelable node) {
+ Log.d(TAG, "onPeerConnected: " + node);
+ invokeListeners(null, listener -> listener.onPeerConnected(node));
+ addConnectedNode(node);
+ }
+
+ public void onPeerDisconnected(NodeParcelable node) {
+ Log.d(TAG, "onPeerDisconnected: " + node);
+ invokeListeners(null, listener -> listener.onPeerDisconnected(node));
+ removeConnectedNode(node.getId());
+ }
+
+ public void onConnectedNodes(List nodes) {
+ Log.d(TAG, "onConnectedNodes: " + nodes);
+ invokeListeners(null, listener -> listener.onConnectedNodes(nodes));
+ }
+
+ public DataItemRecord putData(PutDataRequest request, String packageName) {
+ DataItemInternal dataItem = new DataItemInternal(fixHost(request.getUri().getHost(), true), request.getUri().getPath());
+ for (Map.Entry assetEntry : request.getAssets().entrySet()) {
+ Asset asset = prepareAsset(packageName, assetEntry.getValue());
+ if (asset != null) {
+ nodeDatabase.putAsset(asset, true);
+ dataItem.addAsset(assetEntry.getKey(), asset);
+ }
+ }
+ dataItem.data = request.getData();
+ DataItemRecord record = putDataItem(packageName, PackageUtils.firstSignatureDigest(context, packageName), getLocalNodeId(), dataItem);
+ syncRecordToAll(record);
+ return record;
+ }
+
+ public DataHolder getDataItemsAsHolder(String packageName) {
+ Cursor dataHolderItems = nodeDatabase.getDataItemsForDataHolder(packageName, PackageUtils.firstSignatureDigest(context, packageName));
+ return new DataHolder(dataHolderItems, 0, null);
+ }
+
+ private String fixHost(String host, boolean nothingToLocal) {
+ if (TextUtils.isEmpty(host) && nothingToLocal) return getLocalNodeId();
+ if (TextUtils.isEmpty(host)) return null;
+ if (host.equals("local")) return getLocalNodeId();
+ return host;
+ }
+
+ public DataHolder getDataItemsByUriAsHolder(Uri uri, String packageName) {
+ String firstSignature;
+ try {
+ firstSignature = PackageUtils.firstSignatureDigest(context, packageName);
+ } catch (Exception e) {
+ return null;
+ }
+ Cursor dataHolderItems = nodeDatabase.getDataItemsForDataHolderByHostAndPath(packageName, firstSignature, fixHost(uri.getHost(), false), uri.getPath());
+ DataHolder dataHolder = new DataHolder(dataHolderItems, 0, null);
+ Log.d(TAG, "Returning data holder of size " + dataHolder.getCount() + " for query " + uri);
+ return dataHolder;
+ }
+
+ public synchronized void addListener(String packageName, IWearableListener listener, IntentFilter[] filters) {
+ if (!listeners.containsKey(packageName)) {
+ listeners.put(packageName, new ArrayList());
+ }
+ listeners.get(packageName).add(new ListenerInfo(listener, filters));
+ }
+
+ public void removeListener(IWearableListener listener) {
+ for (List list : listeners.values()) {
+ for (int i = 0; i < list.size(); i++) {
+ if (list.get(i).listener.equals(listener)) {
+ list.remove(i);
+ i--;
+ }
+ }
+ }
+ }
+
+ public void enableConnection(String name) {
+ configDatabase.setEnabledState(name, true);
+ configurationsUpdated = true;
+ if (name.equals("server") && sct == null) {
+ Log.d(TAG, "Starting TCP server on :" + WEAR_TCP_PORT);
+ (sct = SocketConnectionThread.serverListen(WEAR_TCP_PORT, new MessageHandler(context, this, configDatabase.getConfiguration(name)))).start();
+ }
+ if (name.equals("server") && btServer == null) {
+ Log.d(TAG, "Starting Bluetooth server for WearOS");
+ btServer = new BluetoothConnectionServer("WearOS", new MessageHandler(context, this, configDatabase.getConfiguration(name)));
+ btServer.start();
+ }
+ }
+
+ public void disableConnection(String name) {
+ configDatabase.setEnabledState(name, false);
+ configurationsUpdated = true;
+ if (name.equals("server") && sct != null) {
+ activeConnections.remove(sct.getWearableConnection());
+ sct.close();
+ sct.interrupt();
+ sct = null;
+ }
+ if (name.equals("server") && btServer != null) {
+ btServer.close();
+ btServer = null;
+ }
+ }
+
+ public void deleteConnection(String name) {
+ configDatabase.deleteConfiguration(name);
+ configurationsUpdated = true;
+ }
+
+ public void createConnection(ConnectionConfiguration config) {
+ if (config.nodeId == null) config.nodeId = getLocalNodeId();
+ Log.d(TAG, "putConfig[nyp]: " + config);
+ configDatabase.putConfiguration(config);
+ configurationsUpdated = true;
+ }
+
+ public int deleteDataItems(Uri uri, String packageName) {
+ List records = nodeDatabase.deleteDataItems(packageName, PackageUtils.firstSignatureDigest(context, packageName), fixHost(uri.getHost(), false), uri.getPath());
+ for (DataItemRecord record : records) {
+ syncRecordToAll(record);
+ }
+ return records.size();
+ }
+
+ public void sendMessageReceived(String packageName, MessageEventParcelable messageEvent) {
+ Log.d(TAG, "onMessageReceived: " + messageEvent);
+ Intent intent = new Intent("com.google.android.gms.wearable.MESSAGE_RECEIVED");
+ intent.setPackage(packageName);
+ intent.setData(Uri.parse("wear://" + getLocalNodeId() + "/" + messageEvent.getPath()));
+ invokeListeners(intent, listener -> listener.onMessageReceived(messageEvent));
+ }
+
+ public DataItemRecord getDataItemByUri(Uri uri, String packageName) {
+ Cursor cursor = nodeDatabase.getDataItemsByHostAndPath(packageName, PackageUtils.firstSignatureDigest(context, packageName), fixHost(uri.getHost(), true), uri.getPath());
+ DataItemRecord record = null;
+ if (cursor != null) {
+ if (cursor.moveToNext()) {
+ record = DataItemRecord.fromCursor(cursor);
+ }
+ cursor.close();
+ }
+ Log.d(TAG, "getDataItem: " + record);
+ return record;
+ }
+
+ private IWearableListener getListener(String packageName, String action, Uri uri) {
+ Intent intent = new Intent(action);
+ intent.setPackage(packageName);
+ intent.setData(uri);
+
+ return RemoteListenerProxy.get(context, intent, IWearableListener.class, "com.google.android.gms.wearable.BIND_LISTENER");
+ }
+
+ private void closeConnection(String nodeId) {
+ WearableConnection connection = activeConnections.get(nodeId);
+ try {
+ connection.close();
+ } catch (IOException e1) {
+ Log.w(TAG, e1);
+ }
+ if (connection == sct.getWearableConnection()) {
+ sct.close();
+ sct = null;
+ }
+ activeConnections.remove(nodeId);
+ for (ConnectionConfiguration config : getConfigurations()) {
+ if (nodeId.equals(config.nodeId) || nodeId.equals(config.peerNodeId)) {
+ config.connected = false;
+ }
+ }
+ onPeerDisconnected(new NodeParcelable(nodeId, "Wear device"));
+ Log.d(TAG, "Closed connection to " + nodeId + " on error");
+ }
+
+ public int sendMessage(String packageName, String targetNodeId, String path, byte[] data) {
+ if (activeConnections.containsKey(targetNodeId)) {
+ WearableConnection connection = activeConnections.get(targetNodeId);
+ RpcHelper.RpcConnectionState state = rpcHelper.useConnectionState(packageName, targetNodeId, path);
+ try {
+ connection.writeMessage(new RootMessage.Builder().rpcRequest(new Request.Builder()
+ .targetNodeId(targetNodeId)
+ .path(path)
+ .rawData(ByteString.of(data))
+ .packageName(packageName)
+ .signatureDigest(PackageUtils.firstSignatureDigest(context, packageName))
+ .sourceNodeId(getLocalNodeId())
+ .generation(state.generation)
+ .requestId(state.lastRequestId)
+ .build()).build());
+ } catch (IOException e) {
+ Log.w(TAG, "Error while writing, closing link", e);
+ closeConnection(targetNodeId);
+ return -1;
+ }
+ return (state.generation + 527) * 31 + state.lastRequestId;
+ }
+ Log.d(TAG, targetNodeId + " seems not reachable");
+ return -1;
+ }
+
+ public void stop() {
+ try {
+ this.networkHandlerLock.await();
+ this.networkHandler.getLooper().quit();
+ } catch (InterruptedException e) {
+ Log.w(TAG, e);
+ }
+ }
+
+ private class ListenerInfo {
+ private IWearableListener listener;
+ private IntentFilter[] filters;
+
+ private ListenerInfo(IWearableListener listener, IntentFilter[] filters) {
+ this.listener = listener;
+ this.filters = filters;
+ }
+ }
+}
diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableMediaSessionBridge.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableMediaSessionBridge.java
new file mode 100644
index 0000000000..833094aef4
--- /dev/null
+++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableMediaSessionBridge.java
@@ -0,0 +1,433 @@
+/*
+ * SPDX-FileCopyrightText: 2024, microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.wearable;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.graphics.Bitmap;
+import android.media.MediaMetadata;
+import android.media.session.MediaController;
+import android.media.session.MediaSessionManager;
+import android.media.session.PlaybackState;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.util.Log;
+
+import java.io.ByteArrayOutputStream;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Bridges media playback info from the phone's active media sessions to connected Wear OS watches.
+ *
+ * Monitors all active {@link android.media.session.MediaSession} instances on the device
+ * and pushes metadata (track title, artist, album) and playback state (playing/paused,
+ * position) to the WearableImpl data layer. Also listens via WearableImpl for incoming
+ * control commands from the watch and forwards them to MediaController.TransportControls.
+ *
+ * Started automatically by {@link WearableService} when the wearable system is initialized.
+ */
+public class WearableMediaSessionBridge extends android.app.Service {
+
+ private static final String TAG = "GmsWearMedia";
+
+ /** Path prefix for media state data items. */
+ private static final String WEAR_PATH_MEDIA = "/wearable/media";
+
+ /** Path for media state items sent phone -> watch. */
+ private static final String PATH_MEDIA_STATE = WEAR_PATH_MEDIA + "/state";
+
+ /** Path prefix for incoming control commands watch -> phone. */
+ private static final String PATH_MEDIA_CONTROL = WEAR_PATH_MEDIA + "/control";
+
+ private MediaSessionManager mediaSessionManager;
+ private MediaController activeMediaController;
+ private final Set activeControllers = new HashSet<>();
+ private final Handler mainHandler = new Handler(Looper.getMainLooper());
+ private WearableImpl wearable;
+
+ // -------------------------------------------------------------------------
+ // Service lifecycle
+ // -------------------------------------------------------------------------
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ Log.d(TAG, "MediaSessionBridge created");
+
+ // Get reference to WearableImpl via static accessor on WearableService
+ this.wearable = WearableService.getWearableImpl();
+
+ mediaSessionManager = (MediaSessionManager) getSystemService(Context.MEDIA_SESSION_SERVICE);
+
+ // Register media button receiver to re-evaluate sessions on hardware button press
+ IntentFilter filter = new IntentFilter(Intent.ACTION_MEDIA_BUTTON);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ registerReceiver(mediaButtonReceiver, filter, RECEIVER_EXPORTED);
+ } else {
+ registerReceiver(mediaButtonReceiver, filter);
+ }
+
+ // Start monitoring active media sessions immediately
+ monitorActiveSessions();
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ // When started from WearableService, get a reference to WearableImpl
+ if (intent != null && intent.hasExtra("wearable")) {
+ // WearableImpl reference is passed through the service - we access it via singleton
+ }
+ return START_STICKY;
+ }
+
+ /**
+ * Sets the WearableImpl instance. Called by WearableService after creation.
+ */
+ public void setWearable(WearableImpl impl) {
+ this.wearable = impl;
+ }
+
+ @Override
+ public void onDestroy() {
+ Log.d(TAG, "MediaSessionBridge destroyed");
+ try {
+ unregisterReceiver(mediaButtonReceiver);
+ } catch (Exception ignored) {
+ }
+ unregisterCallbacks();
+ super.onDestroy();
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ // -------------------------------------------------------------------------
+ // Session monitoring
+ // -------------------------------------------------------------------------
+
+ private void monitorActiveSessions() {
+ if (mediaSessionManager == null) return;
+
+ List controllers = mediaSessionManager.getActiveSessions(null);
+ registerCallbacks(controllers);
+ }
+
+ private synchronized void registerCallbacks(List controllers) {
+ unregisterCallbacks();
+ for (MediaController controller : controllers) {
+ Log.d(TAG, "Monitoring media session from: " + controller.getPackageName());
+ MediaController.Callback callback = createCallback(controller);
+ controller.registerCallback(callback, mainHandler);
+ activeControllers.add(controller);
+
+ PlaybackState state = controller.getPlaybackState();
+ if (state != null && state.getState() == PlaybackState.STATE_PLAYING) {
+ setActiveController(controller);
+ }
+ }
+
+ if (activeMediaController == null && !controllers.isEmpty()) {
+ setActiveController(controllers.get(0));
+ }
+
+ if (activeMediaController != null) {
+ pushMediaState(activeMediaController);
+ }
+ }
+
+ private void unregisterCallbacks() {
+ activeControllers.clear();
+ activeMediaController = null;
+ }
+
+ private MediaController.Callback createCallback(final MediaController controller) {
+ return new MediaController.Callback() {
+ @Override
+ public void onSessionDestroyed() {
+ Log.d(TAG, "Session destroyed: " + controller.getPackageName());
+ if (controller == activeMediaController) {
+ pickNextActiveSession(controller);
+ }
+ activeControllers.remove(controller);
+ }
+
+ @Override
+ public void onPlaybackStateChanged(PlaybackState state) {
+ if (state == null) return;
+ if (state.getState() == PlaybackState.STATE_PLAYING) {
+ setActiveController(controller);
+ }
+ if (controller == activeMediaController) {
+ pushMediaState(controller);
+ }
+ }
+
+ @Override
+ public void onMetadataChanged(MediaMetadata metadata) {
+ if (metadata == null) return;
+ if (activeMediaController == null) {
+ setActiveController(controller);
+ }
+ pushMediaState(controller);
+ }
+
+ public void onSessionReady() {
+ if (activeMediaController == null) {
+ setActiveController(controller);
+ }
+ pushMediaState(controller);
+ }
+ };
+ }
+
+ private void pickNextActiveSession(MediaController exclude) {
+ List controllers = mediaSessionManager.getActiveSessions(null);
+ for (MediaController c : controllers) {
+ if (c != exclude && c.getPlaybackState() != null) {
+ setActiveController(c);
+ pushMediaState(c);
+ return;
+ }
+ }
+ activeMediaController = null;
+ }
+
+ private synchronized void setActiveController(MediaController controller) {
+ if (controller != activeMediaController) {
+ Log.d(TAG, "Active media controller: " + controller.getPackageName());
+ activeMediaController = controller;
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Push media state to data layer (phone -> watch)
+ // -------------------------------------------------------------------------
+
+ private void pushMediaState(MediaController controller) {
+ if (controller == null || wearable == null) return;
+
+ try {
+ // Build data item containing media state
+ DataItemInternal dataItem = new DataItemInternal(
+ wearable.getLocalNodeId(), PATH_MEDIA_STATE);
+
+ // Metadata
+ MediaMetadata metadata = controller.getMetadata();
+ if (metadata != null) {
+ putString(dataItem, "title",
+ metadata.getString(MediaMetadata.METADATA_KEY_TITLE));
+ putString(dataItem, "artist",
+ metadata.getString(MediaMetadata.METADATA_KEY_ARTIST));
+ putString(dataItem, "album",
+ metadata.getString(MediaMetadata.METADATA_KEY_ALBUM));
+ putString(dataItem, "albumArtist",
+ metadata.getString(MediaMetadata.METADATA_KEY_ALBUM_ARTIST));
+ putLong(dataItem, "duration",
+ metadata.getLong(MediaMetadata.METADATA_KEY_DURATION));
+ putInt(dataItem, "trackNumber",
+ (int) metadata.getLong(MediaMetadata.METADATA_KEY_TRACK_NUMBER));
+ putInt(dataItem, "totalTrackCount",
+ (int) metadata.getLong(MediaMetadata.METADATA_KEY_NUM_TRACKS));
+
+ // Album art
+ Bitmap albumArt = metadata.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART);
+ if (albumArt != null) {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ albumArt.compress(Bitmap.CompressFormat.WEBP, 80, baos);
+ dataItem.data = baos.toByteArray();
+ }
+ }
+
+ // Playback state
+ PlaybackState playbackState = controller.getPlaybackState();
+ if (playbackState != null) {
+ int state = playbackState.getState();
+ putBool(dataItem, "active", true);
+ putBool(dataItem, "isPlaying", state == PlaybackState.STATE_PLAYING);
+ putInt(dataItem, "playbackState", state);
+ putLong(dataItem, "position", playbackState.getPosition());
+ putLong(dataItem, "actions", playbackState.getActions());
+ } else {
+ putBool(dataItem, "active", true);
+ putBool(dataItem, "isPlaying", false);
+ }
+
+ putString(dataItem, "packageName", controller.getPackageName());
+
+ // Push via WearableImpl (internal data layer)
+ DataItemRecord record = wearable.putDataItem(
+ "com.google.android.gms",
+ "media_bridge",
+ wearable.getLocalNodeId(),
+ dataItem);
+ wearable.syncRecordToAll(record);
+
+ Log.d(TAG, "Media state pushed for " + controller.getPackageName());
+ } catch (Exception e) {
+ Log.w(TAG, "Failed to push media state", e);
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Handle control commands from the watch
+ // -------------------------------------------------------------------------
+
+ /**
+ * Called by WearableServiceImpl when a message is received from the watch.
+ * Route media control commands to the active media session.
+ */
+ public void handleMessage(String path, byte[] data) {
+ if (!path.startsWith(PATH_MEDIA_CONTROL)) return;
+
+ if (activeMediaController == null) {
+ Log.w(TAG, "No active media session to control");
+ return;
+ }
+
+ MediaController.TransportControls controls = activeMediaController.getTransportControls();
+ if (controls == null) return;
+
+ String command = path.substring(PATH_MEDIA_CONTROL.length());
+ if (command.startsWith("/")) command = command.substring(1);
+
+ Log.d(TAG, "Media control command: " + command);
+
+ try {
+ switch (command) {
+ case "play":
+ controls.play();
+ break;
+ case "pause":
+ controls.pause();
+ break;
+ case "toggle":
+ PlaybackState ps = activeMediaController.getPlaybackState();
+ if (ps != null && ps.getState() == PlaybackState.STATE_PLAYING) {
+ controls.pause();
+ } else {
+ controls.play();
+ }
+ break;
+ case "stop":
+ controls.stop();
+ break;
+ case "next":
+ controls.skipToNext();
+ break;
+ case "previous":
+ controls.skipToPrevious();
+ break;
+ case "fast-forward":
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ controls.fastForward();
+ } else {
+ PlaybackState p = activeMediaController.getPlaybackState();
+ if (p != null) controls.seekTo(p.getPosition() + 15000);
+ }
+ break;
+ case "rewind":
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ controls.rewind();
+ } else {
+ PlaybackState p = activeMediaController.getPlaybackState();
+ if (p != null) controls.seekTo(Math.max(0, p.getPosition() - 15000));
+ }
+ break;
+ case "seek":
+ if (data != null && data.length >= 8) {
+ controls.seekTo(bytesToLong(data));
+ }
+ break;
+ case "rate":
+ if (data != null && data.length >= 4) {
+ controls.setPlaybackSpeed(bytesToFloat(data));
+ }
+ break;
+ default:
+ Log.w(TAG, "Unknown media control command: " + command);
+ }
+
+ pushMediaState(activeMediaController);
+ } catch (Exception e) {
+ Log.w(TAG, "Failed to execute media control: " + command, e);
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // DataItem helpers (avoid using public DataMap API)
+ // -------------------------------------------------------------------------
+
+ private static void putString(DataItemInternal item, String key, String value) {
+ if (value != null && item.data == null) {
+ // Store as key=value in data bytes, simple text format
+ String entry = key + "=" + value + "\n";
+ byte[] existing = item.data;
+ if (existing == null) {
+ item.data = entry.getBytes();
+ } else {
+ byte[] combined = new byte[existing.length + entry.getBytes().length];
+ System.arraycopy(existing, 0, combined, 0, existing.length);
+ System.arraycopy(entry.getBytes(), 0, combined, existing.length, entry.getBytes().length);
+ item.data = combined;
+ }
+ }
+ }
+
+ private static void putBool(DataItemInternal item, String key, boolean value) {
+ putString(item, key, Boolean.toString(value));
+ }
+
+ private static void putInt(DataItemInternal item, String key, int value) {
+ putString(item, key, Integer.toString(value));
+ }
+
+ private static void putLong(DataItemInternal item, String key, long value) {
+ putString(item, key, Long.toString(value));
+ }
+
+ // -------------------------------------------------------------------------
+ // Media button broadcast receiver
+ // -------------------------------------------------------------------------
+
+ private final BroadcastReceiver mediaButtonReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())) {
+ monitorActiveSessions();
+ }
+ }
+ };
+
+ // -------------------------------------------------------------------------
+ // Helper: byte[] -> primitive conversion
+ // -------------------------------------------------------------------------
+
+ private static long bytesToLong(byte[] bytes) {
+ long value = 0;
+ for (int i = 0; i < Math.min(8, bytes.length); i++) {
+ value = (value << 8) | (bytes[i] & 0xFF);
+ }
+ return value;
+ }
+
+ private static float bytesToFloat(byte[] bytes) {
+ return Float.intBitsToFloat(
+ (bytes[0] & 0xFF) << 24 |
+ (bytes[1] & 0xFF) << 16 |
+ (bytes[2] & 0xFF) << 8 |
+ (bytes[3] & 0xFF)
+ );
+ }
+}
diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableNotificationListenerService.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableNotificationListenerService.java
new file mode 100644
index 0000000000..10c7f6e2d6
--- /dev/null
+++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableNotificationListenerService.java
@@ -0,0 +1,133 @@
+/*
+ * SPDX-FileCopyrightText: 2024, microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.wearable;
+
+import android.app.Notification;
+import android.os.Build;
+import android.os.Bundle;
+import android.service.notification.NotificationListenerService;
+import android.service.notification.StatusBarNotification;
+import android.util.Log;
+
+/**
+ * NotificationListenerService that mirrors phone notifications to connected Wear OS watches.
+ *
+ * Captures all posted notifications and forwards them to the watch via the internal
+ * WearableImpl data layer.
+ */
+public class WearableNotificationListenerService extends NotificationListenerService {
+
+ private static final String TAG = "GmsWearNotif";
+
+ private static final String WEAR_PATH_PREFIX = "/wearable/notification/";
+
+ @Override
+ public void onNotificationPosted(StatusBarNotification sbn) {
+ Log.d(TAG, "Notification posted: " + sbn.getPackageName() + " / " + sbn.getId());
+ forwardNotificationToWear(sbn);
+ }
+
+ @Override
+ public void onNotificationPosted(StatusBarNotification sbn, RankingMap rankingMap) {
+ onNotificationPosted(sbn);
+ }
+
+ @Override
+ public void onNotificationRemoved(StatusBarNotification sbn) {
+ Log.d(TAG, "Notification removed: " + sbn.getPackageName() + " / " + sbn.getId());
+ }
+
+ @Override
+ public void onNotificationRemoved(StatusBarNotification sbn, RankingMap rankingMap) {
+ onNotificationRemoved(sbn);
+ }
+
+ private void forwardNotificationToWear(StatusBarNotification sbn) {
+ try {
+ Notification notification = sbn.getNotification();
+ if (notification == null) return;
+
+ String notificationId = sbn.getPackageName() + ":" + sbn.getId() + ":" + sbn.getTag();
+ String path = WEAR_PATH_PREFIX + notificationId.replace(":", "/");
+
+ // Build a simple text-format data payload
+ StringBuilder sb = new StringBuilder();
+
+ // Basic notification data
+ appendField(sb, "package", sbn.getPackageName());
+ appendField(sb, "id", String.valueOf(sbn.getId()));
+ appendField(sb, "postTime", String.valueOf(sbn.getPostTime()));
+ appendField(sb, "isOngoing", String.valueOf(sbn.isOngoing()));
+ appendField(sb, "isClearable", String.valueOf(sbn.isClearable()));
+
+ // Extract notification content
+ Bundle extras = notification.extras;
+ if (extras != null) {
+ CharSequence title = extras.getCharSequence(Notification.EXTRA_TITLE);
+ CharSequence text = extras.getCharSequence(Notification.EXTRA_TEXT);
+ CharSequence subText = extras.getCharSequence(Notification.EXTRA_SUB_TEXT);
+ CharSequence info = extras.getCharSequence(Notification.EXTRA_INFO_TEXT);
+ CharSequence summary = extras.getCharSequence(Notification.EXTRA_SUMMARY_TEXT);
+
+ if (title != null) appendField(sb, "title", title.toString());
+ if (text != null) appendField(sb, "text", text.toString());
+ if (subText != null) appendField(sb, "subText", subText.toString());
+ if (info != null) appendField(sb, "infoText", info.toString());
+ if (summary != null) appendField(sb, "summaryText", summary.toString());
+
+ int smallIconRes = extras.getInt(Notification.EXTRA_SMALL_ICON);
+ appendField(sb, "smallIcon", String.valueOf(smallIconRes));
+
+ appendField(sb, "category", notification.category);
+ appendField(sb, "priority", String.valueOf(notification.priority));
+
+ // Notification actions
+ Notification.Action[] actions = notification.actions;
+ if (actions != null) {
+ for (int i = 0; i < Math.min(actions.length, 3); i++) {
+ String actionTitle = actions[i].title != null ? actions[i].title.toString() : "";
+ appendField(sb, "action_" + i, actionTitle);
+ }
+ appendField(sb, "actionCount", String.valueOf(Math.min(actions.length, 3)));
+ }
+ }
+
+ byte[] data = sb.toString().getBytes();
+
+ // Push through WearableImpl if available
+ WearableImpl wearable = getWearableImpl();
+ if (wearable != null) {
+ DataItemInternal dataItem = new DataItemInternal(wearable.getLocalNodeId(), path);
+ dataItem.data = data;
+ DataItemRecord record = wearable.putDataItem(
+ "com.google.android.gms",
+ "notif_bridge",
+ wearable.getLocalNodeId(),
+ dataItem);
+ wearable.syncRecordToAll(record);
+ Log.d(TAG, "Notification forwarded to watch: " + notificationId);
+ }
+ } catch (Exception e) {
+ Log.w(TAG, "Failed to forward notification to wear", e);
+ }
+ }
+
+ private static void appendField(StringBuilder sb, String key, String value) {
+ if (value == null) return;
+ // Simple escaping: replace \n with \\n
+ sb.append(key).append("=").append(value.replace("\n", "\\n")).append("\n");
+ }
+
+ /**
+ * Gets the WearableImpl instance. In microG, this is accessible via the
+ * WearableService. Falls back to a static reference if one was set.
+ */
+ private WearableImpl getWearableImpl() {
+ // The WearableImpl is held by WearableService. Since the notification
+ // listener runs in the same process, we can access it via the static holder.
+ return WearableService.getWearableImpl();
+ }
+}
diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableService.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableService.java
index c9f3194ede..6bdf46eed4 100644
--- a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableService.java
+++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableService.java
@@ -1,55 +1,70 @@
-/*
- * Copyright (C) 2013-2017 microG Project Team
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.microg.gms.wearable;
-
-import android.os.RemoteException;
-
-import com.google.android.gms.common.internal.GetServiceRequest;
-import com.google.android.gms.common.internal.IGmsCallbacks;
-
-import org.microg.gms.BaseService;
-import org.microg.gms.common.GmsService;
-import org.microg.gms.common.PackageUtils;
-
-public class WearableService extends BaseService {
-
- private WearableImpl wearable;
-
- public WearableService() {
- super("GmsWearSvc", GmsService.WEAR);
- }
-
- @Override
- public void onCreate() {
- super.onCreate();
- ConfigurationDatabaseHelper configurationDatabaseHelper = new ConfigurationDatabaseHelper(getApplicationContext());
- NodeDatabaseHelper nodeDatabaseHelper = new NodeDatabaseHelper(getApplicationContext());
- wearable = new WearableImpl(getApplicationContext(), nodeDatabaseHelper, configurationDatabaseHelper);
- }
-
- @Override
- public void onDestroy() {
- super.onDestroy();
- wearable.stop();
- }
-
- @Override
- public void handleServiceRequest(IGmsCallbacks callback, GetServiceRequest request, GmsService service) throws RemoteException {
- PackageUtils.getAndCheckCallingPackage(this, request.packageName);
- callback.onPostInitComplete(0, new WearableServiceImpl(this, wearable, request.packageName), null);
- }
-}
+/*
+ * Copyright (C) 2013-2017 microG Project Team
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.microg.gms.wearable;
+
+import android.content.Intent;
+import android.os.RemoteException;
+
+import com.google.android.gms.common.internal.GetServiceRequest;
+import com.google.android.gms.common.internal.IGmsCallbacks;
+
+import org.microg.gms.BaseService;
+import org.microg.gms.common.GmsService;
+import org.microg.gms.common.PackageUtils;
+
+public class WearableService extends BaseService {
+
+ private static WearableImpl wearableInstance;
+
+ private WearableImpl wearable;
+
+ public static WearableImpl getWearableImpl() {
+ return wearableInstance;
+ }
+
+ public WearableService() {
+ super("GmsWearSvc", GmsService.WEAR);
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ ConfigurationDatabaseHelper configurationDatabaseHelper = new ConfigurationDatabaseHelper(getApplicationContext());
+ NodeDatabaseHelper nodeDatabaseHelper = new NodeDatabaseHelper(getApplicationContext());
+ wearable = new WearableImpl(getApplicationContext(), nodeDatabaseHelper, configurationDatabaseHelper);
+ wearableInstance = wearable;
+
+ // Start the MediaSession bridge to push media playback info to Wear OS watches
+ // This allows the watch to see what's playing and send control commands.
+ Intent mediaBridgeIntent = new Intent(this, WearableMediaSessionBridge.class);
+ mediaBridgeIntent.putExtra("wearable", true);
+ startService(mediaBridgeIntent);
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ wearable.stop();
+ wearableInstance = null;
+ }
+
+ @Override
+ public void handleServiceRequest(IGmsCallbacks callback, GetServiceRequest request, GmsService service) throws RemoteException {
+ PackageUtils.getAndCheckCallingPackage(this, request.packageName);
+ callback.onPostInitComplete(0, new WearableServiceImpl(this, wearable, request.packageName), null);
+ }
+}