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); + } +}