From 248151105754b84ea84a811bac3bdc29645dc61a Mon Sep 17 00:00:00 2001 From: Gurjot singh Date: Mon, 31 Aug 2026 07:15:54 +0530 Subject: [PATCH] Add Wear OS pairing support with Bluetooth RFCOMM, TOS, and notification/media/call bridges. Companions currently hang on a missing TOS activity and only get a TCP debug socket, so modern watches cannot pair. This wires the WearableBt RFCOMM UUIDs, client Node/Data/Message APIs, and phone-side notification/media/call controls so Wear apps can talk to companions on microG. Co-authored-by: Cursor --- .../microg/gms/settings/SettingsContract.kt | 18 + .../microg/gms/settings/SettingsProvider.kt | 28 ++ .../src/main/AndroidManifest.xml | 2 + .../org/microg/gms/ui/SelfCheckFragment.java | 7 + .../org/microg/gms/ui/SettingsFragment.kt | 12 + .../src/main/res/navigation/nav_settings.xml | 8 + .../src/main/res/xml/preferences_start.xml | 4 + play-services-wearable/core/build.gradle | 10 + .../core/src/main/AndroidManifest.xml | 19 + .../consent/TermsOfServiceActivity.java | 54 +++ .../gms/wearable/WearableConnectionKind.java | 46 +++ .../org/microg/gms/wearable/WearableImpl.java | 51 ++- .../WearableNotificationListenerService.java | 102 ++++++ .../wearable/WearableNotificationPayload.java | 212 +++++++++++ .../gms/wearable/WearableRemoteControls.java | 202 +++++++++++ .../microg/gms/wearable/WearableService.java | 16 + .../gms/wearable/WearableServiceImpl.java | 21 +- .../bluetooth/BluetoothConnectionManager.java | 334 ++++++++++++++++++ .../BluetoothWearableConnection.java | 60 ++++ .../wearable/bluetooth/WearableBtUuids.java | 58 +++ .../gms/wearable/WearablePreferences.kt | 60 ++++ .../ui/WearablePreferencesFragment.kt | 81 +++++ .../core/src/main/res/drawable/ic_watch.xml | 15 + .../main/res/layout/activity_wearable_tos.xml | 47 +++ .../core/src/main/res/values/strings.xml | 18 + .../src/main/res/xml/preferences_wearable.xml | 37 ++ .../bluetooth/WearableBtUuidsTest.java | 58 +++ .../android/gms/wearable/DataEventBuffer.java | 2 +- .../android/gms/wearable/DataItemBuffer.java | 2 +- .../internal/DeleteDataItemsResponse.java | 8 + .../org/microg/gms/wearable/DataApiImpl.java | 148 +++++++- .../microg/gms/wearable/MessageApiImpl.java | 22 +- .../org/microg/gms/wearable/NodeApiImpl.java | 88 ++++- .../gms/wearable/WearableListenerStub.java | 119 +++++++ 34 files changed, 1943 insertions(+), 26 deletions(-) create mode 100644 play-services-wearable/core/src/main/java/com/google/android/gms/wearable/consent/TermsOfServiceActivity.java create mode 100644 play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableConnectionKind.java create mode 100644 play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableNotificationListenerService.java create mode 100644 play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableNotificationPayload.java create mode 100644 play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableRemoteControls.java create mode 100644 play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothConnectionManager.java create mode 100644 play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothWearableConnection.java create mode 100644 play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/WearableBtUuids.java create mode 100644 play-services-wearable/core/src/main/kotlin/org/microg/gms/wearable/WearablePreferences.kt create mode 100644 play-services-wearable/core/src/main/kotlin/org/microg/gms/wearable/ui/WearablePreferencesFragment.kt create mode 100644 play-services-wearable/core/src/main/res/drawable/ic_watch.xml create mode 100644 play-services-wearable/core/src/main/res/layout/activity_wearable_tos.xml create mode 100644 play-services-wearable/core/src/main/res/values/strings.xml create mode 100644 play-services-wearable/core/src/main/res/xml/preferences_wearable.xml create mode 100644 play-services-wearable/core/src/test/java/org/microg/gms/wearable/bluetooth/WearableBtUuidsTest.java create mode 100644 play-services-wearable/src/main/java/org/microg/gms/wearable/WearableListenerStub.java diff --git a/play-services-base/core/src/main/kotlin/org/microg/gms/settings/SettingsContract.kt b/play-services-base/core/src/main/kotlin/org/microg/gms/settings/SettingsContract.kt index 11bf68f564..8182442b4a 100644 --- a/play-services-base/core/src/main/kotlin/org/microg/gms/settings/SettingsContract.kt +++ b/play-services-base/core/src/main/kotlin/org/microg/gms/settings/SettingsContract.kt @@ -222,6 +222,24 @@ object SettingsContract { ) } + object Wearable { + const val ID = "wearable" + fun getContentUri(context: Context) = Uri.withAppendedPath(getAuthorityUri(context), ID) + fun getContentType(context: Context) = "vnd.android.cursor.item/vnd.${getAuthority(context)}.$ID" + + const val TOS_ACCEPTED = "wearable_tos_accepted" + const val NOTIFICATIONS_ENABLED = "wearable_notifications_enabled" + const val MEDIA_CONTROL_ENABLED = "wearable_media_control_enabled" + const val CALL_CONTROL_ENABLED = "wearable_call_control_enabled" + + val PROJECTION = arrayOf( + TOS_ACCEPTED, + NOTIFICATIONS_ENABLED, + MEDIA_CONTROL_ENABLED, + CALL_CONTROL_ENABLED, + ) + } + object Profile { const val ID = "profile" fun getContentUri(context: Context) = Uri.withAppendedPath(getAuthorityUri(context), ID) diff --git a/play-services-base/core/src/main/kotlin/org/microg/gms/settings/SettingsProvider.kt b/play-services-base/core/src/main/kotlin/org/microg/gms/settings/SettingsProvider.kt index 7a5cd42314..b271818a3b 100644 --- a/play-services-base/core/src/main/kotlin/org/microg/gms/settings/SettingsProvider.kt +++ b/play-services-base/core/src/main/kotlin/org/microg/gms/settings/SettingsProvider.kt @@ -26,6 +26,7 @@ import org.microg.gms.settings.SettingsContract.Location import org.microg.gms.settings.SettingsContract.Profile import org.microg.gms.settings.SettingsContract.SafetyNet import org.microg.gms.settings.SettingsContract.Vending +import org.microg.gms.settings.SettingsContract.Wearable import org.microg.gms.settings.SettingsContract.WorkProfile import org.microg.gms.settings.SettingsContract.getAuthority import java.io.File @@ -85,6 +86,7 @@ class SettingsProvider : ContentProvider() { Vending.ID -> queryVending(projection ?: Vending.PROJECTION) WorkProfile.ID -> queryWorkProfile(projection ?: WorkProfile.PROJECTION) GameProfile.ID -> queryGameProfile(projection ?: GameProfile.PROJECTION) + Wearable.ID -> queryWearable(projection ?: Wearable.PROJECTION) else -> null } @@ -108,6 +110,7 @@ class SettingsProvider : ContentProvider() { Vending.ID -> updateVending(values) WorkProfile.ID -> updateWorkProfile(values) GameProfile.ID -> updateGameProfile(values) + Wearable.ID -> updateWearable(values) else -> return 0 } return 1 @@ -440,6 +443,31 @@ class SettingsProvider : ContentProvider() { editor.apply() } + private fun queryWearable(p: Array): Cursor = MatrixCursor(p).addRow(p) { key -> + when (key) { + Wearable.TOS_ACCEPTED -> getSettingsBoolean(key, false) + Wearable.NOTIFICATIONS_ENABLED -> getSettingsBoolean(key, true) + Wearable.MEDIA_CONTROL_ENABLED -> getSettingsBoolean(key, true) + Wearable.CALL_CONTROL_ENABLED -> getSettingsBoolean(key, true) + else -> throw IllegalArgumentException("Unknown key: $key") + } + } + + private fun updateWearable(values: ContentValues) { + if (values.size() == 0) return + val editor = preferences.edit() + values.valueSet().forEach { (key, value) -> + when (key) { + Wearable.TOS_ACCEPTED -> editor.putBoolean(key, value as Boolean) + Wearable.NOTIFICATIONS_ENABLED -> editor.putBoolean(key, value as Boolean) + Wearable.MEDIA_CONTROL_ENABLED -> editor.putBoolean(key, value as Boolean) + Wearable.CALL_CONTROL_ENABLED -> editor.putBoolean(key, value as Boolean) + else -> throw IllegalArgumentException("Unknown key: $key") + } + } + editor.apply() + } + private fun MatrixCursor.addRow( p: Array, valueGetter: (String) -> Any? diff --git a/play-services-core/src/main/AndroidManifest.xml b/play-services-core/src/main/AndroidManifest.xml index 713deb4842..9ed9d46620 100644 --- a/play-services-core/src/main/AndroidManifest.xml +++ b/play-services-core/src/main/AndroidManifest.xml @@ -459,7 +459,9 @@ diff --git a/play-services-core/src/main/java/org/microg/gms/ui/SelfCheckFragment.java b/play-services-core/src/main/java/org/microg/gms/ui/SelfCheckFragment.java index 8d809ca0a7..3015bcb9dc 100644 --- a/play-services-core/src/main/java/org/microg/gms/ui/SelfCheckFragment.java +++ b/play-services-core/src/main/java/org/microg/gms/ui/SelfCheckFragment.java @@ -78,6 +78,13 @@ protected void prepareSelfCheckList(Context context, List checks } permissions.add(READ_PHONE_STATE); permissions.add(RECEIVE_SMS); + if (SDK_INT >= 31) { + permissions.add("android.permission.BLUETOOTH_CONNECT"); + permissions.add("android.permission.BLUETOOTH_SCAN"); + } + if (SDK_INT >= 26) { + permissions.add("android.permission.ANSWER_PHONE_CALLS"); + } checks.add(new PermissionCheckGroup(permissions.toArray(new String[0])) { @Override public void doChecks(Context context, ResultCollector collector) { diff --git a/play-services-core/src/main/kotlin/org/microg/gms/ui/SettingsFragment.kt b/play-services-core/src/main/kotlin/org/microg/gms/ui/SettingsFragment.kt index 70335535ce..16b3311b03 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/ui/SettingsFragment.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/ui/SettingsFragment.kt @@ -42,6 +42,10 @@ class SettingsFragment : ResourceSettingsFragment() { findNavController().navigate(requireContext(), R.id.openSafetyNetSettings) true } + findPreference(PREF_WEARABLE)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { + findNavController().navigate(requireContext(), R.id.openWearableSettings) + true + } findPreference(PREF_LOCATION)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { findNavController().navigate(requireContext(), R.id.openLocationSettings) true @@ -116,6 +120,13 @@ class SettingsFragment : ResourceSettingsFragment() { findPreference(PREF_CHECKIN)!!.setSummary(if (CheckinPreferences.isEnabled(requireContext())) org.microg.gms.base.core.R.string.service_status_enabled_short else org.microg.gms.base.core.R.string.service_status_disabled_short) findPreference(PREF_SNET)!!.setSummary(if (SafetyNetPreferences.isEnabled(requireContext())) org.microg.gms.base.core.R.string.service_status_enabled_short else org.microg.gms.base.core.R.string.service_status_disabled_short) + findPreference(PREF_WEARABLE)!!.setSummary( + if (org.microg.gms.wearable.WearablePreferences.isTosAccepted(requireContext())) { + org.microg.gms.base.core.R.string.service_status_enabled_short + } else { + org.microg.gms.base.core.R.string.service_status_disabled_short + } + ) lifecycleScope.launchWhenResumed { val entries = getAllSettingsProviders(requireContext()).flatMap { it.getEntriesDynamic(requireContext()) } @@ -134,6 +145,7 @@ class SettingsFragment : ResourceSettingsFragment() { const val PREF_ABOUT = "pref_about" const val PREF_GCM = "pref_gcm" const val PREF_SNET = "pref_snet" + const val PREF_WEARABLE = "pref_wearable" const val PREF_LOCATION = "pref_location" const val PREF_CHECKIN = "pref_checkin" const val PREF_VENDING = "pref_vending" diff --git a/play-services-core/src/main/res/navigation/nav_settings.xml b/play-services-core/src/main/res/navigation/nav_settings.xml index b1b11dd439..1bfa39b4fd 100644 --- a/play-services-core/src/main/res/navigation/nav_settings.xml +++ b/play-services-core/src/main/res/navigation/nav_settings.xml @@ -23,6 +23,9 @@ + @@ -171,6 +174,11 @@ app:argType="string" /> + + + + + + + + + + + + + + + + diff --git a/play-services-wearable/core/src/main/java/com/google/android/gms/wearable/consent/TermsOfServiceActivity.java b/play-services-wearable/core/src/main/java/com/google/android/gms/wearable/consent/TermsOfServiceActivity.java new file mode 100644 index 0000000000..a269d70378 --- /dev/null +++ b/play-services-wearable/core/src/main/java/com/google/android/gms/wearable/consent/TermsOfServiceActivity.java @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2026, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.wearable.consent; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.widget.Button; +import android.widget.TextView; + +import org.microg.gms.wearable.WearablePreferences; +import org.microg.gms.wearable.core.R; + +/** + * Shown by Galaxy Watch / Wear OS companion apps via {@code com.google.android.gms.wearable.TOS}. + * Accepting stores the consent so pairing can continue; declining cancels the companion flow. + */ +public class TermsOfServiceActivity extends Activity { + + public static final String EXTRA_TOS_ACCEPTED = "tosAccepted"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (WearablePreferences.isTosAccepted(this)) { + finishAccepted(); + return; + } + setContentView(R.layout.activity_wearable_tos); + TextView body = findViewById(R.id.wearable_tos_body); + body.setText(R.string.wearable_tos_body); + Button accept = findViewById(R.id.wearable_tos_accept); + Button decline = findViewById(R.id.wearable_tos_decline); + accept.setOnClickListener(v -> { + WearablePreferences.setTosAccepted(this, true); + finishAccepted(); + }); + decline.setOnClickListener(v -> { + WearablePreferences.setTosAccepted(this, false); + setResult(RESULT_CANCELED); + finish(); + }); + } + + private void finishAccepted() { + Intent result = new Intent(); + result.putExtra(EXTRA_TOS_ACCEPTED, true); + setResult(RESULT_OK, result); + finish(); + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableConnectionKind.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableConnectionKind.java new file mode 100644 index 0000000000..48a8c7b887 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableConnectionKind.java @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: 2026, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wearable; + +import com.google.android.gms.wearable.ConnectionConfiguration; + +import org.microg.gms.wearable.bluetooth.WearableBtUuids; + +/** + * Classifies {@link ConnectionConfiguration} rows so WearableImpl can start the matching transport. + *

+ * GMS uses {@code type=1} for Bluetooth Classic (Wear OS companion), {@code type=3} for the + * local TCP debug socket, and a Bluetooth MAC in {@code address} even when type is omitted. + */ +public final class WearableConnectionKind { + public static final int TYPE_BLUETOOTH = 1; + public static final int TYPE_CLOUD = 2; + public static final int TYPE_NETWORK = 3; + + private WearableConnectionKind() { + } + + public static boolean isBluetoothAddress(String address) { + return WearableBtUuids.isBluetoothAddress(address); + } + + public static boolean isBluetooth(ConnectionConfiguration config) { + if (config == null) { + return false; + } + if (config.type == TYPE_BLUETOOTH) { + return true; + } + return isBluetoothAddress(config.address); + } + + public static boolean isTcpServer(ConnectionConfiguration config) { + if (config == null) { + return false; + } + return "server".equals(config.name) || config.type == TYPE_NETWORK; + } +} 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..20e33472b4 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 @@ -42,6 +42,7 @@ import org.microg.gms.common.PackageUtils; import org.microg.gms.common.RemoteListenerProxy; import org.microg.gms.common.Utils; +import org.microg.gms.wearable.bluetooth.BluetoothConnectionManager; import org.microg.wearable.SocketConnectionThread; import org.microg.wearable.WearableConnection; import org.microg.wearable.proto.AckAsset; @@ -87,6 +88,7 @@ public class WearableImpl { private final Map activeConnections = new HashMap(); private RpcHelper rpcHelper; private SocketConnectionThread sct; + private BluetoothConnectionManager bluetoothManager; private ConnectionConfiguration[] configurations; private boolean configurationsUpdated = false; private ClockworkNodePreferences clockworkNodePreferences; @@ -99,10 +101,12 @@ public WearableImpl(Context context, NodeDatabaseHelper nodeDatabase, Configurat this.configDatabase = configDatabase; this.clockworkNodePreferences = new ClockworkNodePreferences(context); this.rpcHelper = new RpcHelper(context); + this.bluetoothManager = new BluetoothConnectionManager(context, this); new Thread(() -> { Looper.prepare(); networkHandler = new Handler(Looper.myLooper()); networkHandlerLock.countDown(); + networkHandler.post(this::restoreEnabledConnections); Looper.loop(); }).start(); } @@ -508,21 +512,49 @@ public void removeListener(IWearableListener listener) { 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(); - } + ConnectionConfiguration config = configDatabase.getConfiguration(name); + startTransport(config); } public void disableConnection(String name) { configDatabase.setEnabledState(name, false); configurationsUpdated = true; if (name.equals("server") && sct != null) { - activeConnections.remove(sct.getWearableConnection()); + if (sct.getWearableConnection() != null) { + activeConnections.remove(sct.getWearableConnection()); + } sct.close(); sct.interrupt(); sct = null; } + bluetoothManager.stop(name); + } + + private void restoreEnabledConnections() { + ConnectionConfiguration[] configs = getConfigurations(); + if (configs == null) return; + for (ConnectionConfiguration config : configs) { + if (config.enabled) { + startTransport(config); + } + } + } + + private void startTransport(ConnectionConfiguration config) { + if (config == null) return; + try { + context.startService(new Intent(context, WearableService.class)); + } catch (Exception e) { + Log.w(TAG, "Could not keep WearableService running", e); + } + if (WearableConnectionKind.isBluetooth(config)) { + Log.d(TAG, "Starting Bluetooth RFCOMM for " + config); + bluetoothManager.ensureStarted(config); + } + if (WearableConnectionKind.isTcpServer(config) && sct == null) { + Log.d(TAG, "Starting server on :" + WEAR_TCP_PORT); + (sct = SocketConnectionThread.serverListen(WEAR_TCP_PORT, new MessageHandler(context, this, config))).start(); + } } public void deleteConnection(String name) { @@ -547,6 +579,7 @@ public int deleteDataItems(Uri uri, String packageName) { public void sendMessageReceived(String packageName, MessageEventParcelable messageEvent) { Log.d(TAG, "onMessageReceived: " + messageEvent); + WearableRemoteControls.handleIncoming(context, messageEvent); Intent intent = new Intent("com.google.android.gms.wearable.MESSAGE_RECEIVED"); intent.setPackage(packageName); intent.setData(Uri.parse("wear://" + getLocalNodeId() + "/" + messageEvent.getPath())); @@ -581,7 +614,7 @@ private void closeConnection(String nodeId) { } catch (IOException e1) { Log.w(TAG, e1); } - if (connection == sct.getWearableConnection()) { + if (sct != null && connection == sct.getWearableConnection()) { sct.close(); sct = null; } @@ -622,6 +655,12 @@ public int sendMessage(String packageName, String targetNodeId, String path, byt } public void stop() { + bluetoothManager.stopAll(); + if (sct != null) { + sct.close(); + sct.interrupt(); + sct = null; + } try { this.networkHandlerLock.await(); this.networkHandler.getLooper().quit(); 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..65a4913b90 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableNotificationListenerService.java @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: 2026, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wearable; + +import android.app.Notification; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.service.notification.NotificationListenerService; +import android.service.notification.StatusBarNotification; +import android.text.TextUtils; +import android.util.Log; + +import com.google.android.gms.wearable.internal.NodeParcelable; +import com.google.android.gms.wearable.internal.PutDataRequest; + +import org.microg.gms.common.Constants; + +/** + * Mirrors phone notifications onto connected Wear OS nodes via MessageApi paths and DataItems. + */ +public class WearableNotificationListenerService extends NotificationListenerService { + private static final String TAG = "GmsWearNotif"; + + @Override + public void onListenerConnected() { + try { + startService(new Intent(this, WearableService.class)); + } catch (Exception e) { + Log.w(TAG, "Could not start WearableService", e); + } + } + + @Override + public void onNotificationPosted(StatusBarNotification sbn) { + if (sbn == null || shouldIgnore(sbn)) { + return; + } + WearableNotificationPayload payload = fromStatusBarNotification(sbn); + dispatch(payload.encode(), WearableNotificationPayload.PATH_POSTED, payload); + } + + @Override + public void onNotificationRemoved(StatusBarNotification sbn) { + if (sbn == null || shouldIgnore(sbn)) { + return; + } + WearableNotificationPayload payload = fromStatusBarNotification(sbn); + dispatch(payload.encode(), WearableNotificationPayload.PATH_REMOVED, payload); + } + + private boolean shouldIgnore(StatusBarNotification sbn) { + if (sbn.isOngoing() && (sbn.getNotification().flags & Notification.FLAG_NO_CLEAR) != 0 + && (sbn.getNotification().flags & Notification.FLAG_FOREGROUND_SERVICE) != 0) { + // Still forward ongoing; only skip our own service noise. + } + String pkg = sbn.getPackageName(); + return TextUtils.equals(pkg, getPackageName()) || TextUtils.equals(pkg, Constants.GMS_PACKAGE_NAME); + } + + private void dispatch(byte[] data, String path, WearableNotificationPayload payload) { + WearableImpl wearable = WearableService.getImpl(); + if (wearable == null || wearable.networkHandler == null) { + Log.d(TAG, "WearableImpl not ready; dropping " + path); + return; + } + if (!WearablePreferences.isNotificationsEnabled(this)) { + return; + } + Context context = getApplicationContext(); + wearable.networkHandler.post(() -> { + for (NodeParcelable node : wearable.getConnectedNodesParcelableList()) { + wearable.sendMessage(context.getPackageName(), node.getId(), path, data); + } + try { + PutDataRequest request = PutDataRequest.create(WearableNotificationPayload.dataItemPathForKey(payload.key)); + request.setData(data); + wearable.putData(request, context.getPackageName()); + } catch (Exception e) { + Log.w(TAG, "Failed to put notification data item", e); + } + }); + } + + static WearableNotificationPayload fromStatusBarNotification(StatusBarNotification sbn) { + Notification notification = sbn.getNotification(); + Bundle extras = notification.extras; + CharSequence title = extras != null ? extras.getCharSequence(Notification.EXTRA_TITLE) : null; + CharSequence text = extras != null ? extras.getCharSequence(Notification.EXTRA_TEXT) : null; + return new WearableNotificationPayload( + sbn.getKey(), + sbn.getPackageName(), + title != null ? title.toString() : "", + text != null ? text.toString() : "", + sbn.isOngoing(), + sbn.getId() + ); + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableNotificationPayload.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableNotificationPayload.java new file mode 100644 index 0000000000..6242fabbcb --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableNotificationPayload.java @@ -0,0 +1,212 @@ +/* + * SPDX-FileCopyrightText: 2026, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wearable; + +import java.nio.charset.Charset; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Compact JSON payload used to mirror phone notifications onto a paired Wear OS node. + * Kept free of Android types so the encoder can be unit-tested on the JVM. + */ +public final class WearableNotificationPayload { + public static final String PATH_POSTED = "/notification/posted"; + public static final String PATH_REMOVED = "/notification/removed"; + public static final String DATA_PATH_PREFIX = "/notification/"; + + private static final Charset UTF8 = Charset.forName("UTF-8"); + + public final String key; + public final String packageName; + public final String title; + public final String text; + public final boolean ongoing; + public final int id; + + public WearableNotificationPayload(String key, String packageName, String title, String text, boolean ongoing, int id) { + this.key = key == null ? "" : key; + this.packageName = packageName == null ? "" : packageName; + this.title = title == null ? "" : title; + this.text = text == null ? "" : text; + this.ongoing = ongoing; + this.id = id; + } + + public byte[] encode() { + return toJson().getBytes(UTF8); + } + + public String toJson() { + StringBuilder sb = new StringBuilder(128); + sb.append('{'); + appendField(sb, "key", key, true); + appendField(sb, "pkg", packageName, false); + appendField(sb, "title", title, false); + appendField(sb, "text", text, false); + sb.append(",\"ongoing\":").append(ongoing); + sb.append(",\"id\":").append(id); + sb.append('}'); + return sb.toString(); + } + + public static WearableNotificationPayload parse(String json) { + Map values = parseObject(json); + return new WearableNotificationPayload( + values.get("key"), + values.get("pkg"), + values.get("title"), + values.get("text"), + "true".equals(values.get("ongoing")), + parseInt(values.get("id"), 0) + ); + } + + public static String dataItemPathForKey(String key) { + if (key == null || key.length() == 0) { + return DATA_PATH_PREFIX + "unknown"; + } + StringBuilder sb = new StringBuilder(key.length()); + for (int i = 0; i < key.length(); i++) { + char c = key.charAt(i); + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-') { + sb.append(c); + } else { + sb.append('_'); + } + } + return DATA_PATH_PREFIX + sb; + } + + private static void appendField(StringBuilder sb, String name, String value, boolean first) { + if (!first) sb.append(','); + sb.append('"').append(name).append("\":\"").append(escape(value)).append('"'); + } + + static String escape(String value) { + if (value == null) return ""; + StringBuilder sb = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + default: + if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + } + return sb.toString(); + } + + static Map parseObject(String json) { + Map result = new LinkedHashMap<>(); + if (json == null) return result; + String trimmed = json.trim(); + if (trimmed.length() < 2 || trimmed.charAt(0) != '{') return result; + String body = trimmed.substring(1, trimmed.length() - 1); + int i = 0; + while (i < body.length()) { + int keyStart = body.indexOf('"', i); + if (keyStart < 0) break; + int keyEnd = findStringEnd(body, keyStart + 1); + String key = unescape(body.substring(keyStart + 1, keyEnd)); + int colon = body.indexOf(':', keyEnd); + if (colon < 0) break; + int valueStart = skipSpaces(body, colon + 1); + String value; + int next; + if (valueStart < body.length() && body.charAt(valueStart) == '"') { + int valueEnd = findStringEnd(body, valueStart + 1); + value = unescape(body.substring(valueStart + 1, valueEnd)); + next = valueEnd + 1; + } else { + int comma = body.indexOf(',', valueStart); + next = comma < 0 ? body.length() : comma; + value = body.substring(valueStart, next).trim(); + } + result.put(key, value); + i = next + 1; + } + return result; + } + + private static int findStringEnd(String s, int from) { + boolean escape = false; + for (int i = from; i < s.length(); i++) { + char c = s.charAt(i); + if (escape) { + escape = false; + } else if (c == '\\') { + escape = true; + } else if (c == '"') { + return i; + } + } + return s.length(); + } + + private static int skipSpaces(String s, int from) { + int i = from; + while (i < s.length() && s.charAt(i) <= ' ') i++; + return i; + } + + private static String unescape(String value) { + StringBuilder sb = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '\\' && i + 1 < value.length()) { + char n = value.charAt(++i); + switch (n) { + case 'n': + sb.append('\n'); + break; + case 'r': + sb.append('\r'); + break; + case 't': + sb.append('\t'); + break; + case '"': + case '\\': + sb.append(n); + break; + default: + sb.append(n); + } + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private static int parseInt(String value, int fallback) { + if (value == null) return fallback; + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return fallback; + } + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableRemoteControls.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableRemoteControls.java new file mode 100644 index 0000000000..732fa182f9 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableRemoteControls.java @@ -0,0 +1,202 @@ +/* + * SPDX-FileCopyrightText: 2026, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wearable; + +import android.content.ComponentName; +import android.content.Context; +import android.media.AudioManager; +import android.media.session.MediaController; +import android.media.session.MediaSessionManager; +import android.os.Build; +import android.telecom.TelecomManager; +import android.telephony.TelephonyManager; +import android.util.Log; + +import com.google.android.gms.wearable.internal.MessageEventParcelable; + +import java.util.List; + +/** + * Handles media and call RPCs coming from a paired watch, and exposes the same actions to + * {@link WearableServiceImpl} for the first-party Wearable API. + */ +public final class WearableRemoteControls { + private static final String TAG = "GmsWearCtrl"; + + public static final String PATH_MEDIA_PLAY = "/media/play"; + public static final String PATH_MEDIA_PAUSE = "/media/pause"; + public static final String PATH_MEDIA_NEXT = "/media/next"; + public static final String PATH_MEDIA_PREVIOUS = "/media/previous"; + public static final String PATH_MEDIA_STOP = "/media/stop"; + public static final String PATH_CALL_END = "/call/end"; + public static final String PATH_CALL_ANSWER = "/call/answer"; + public static final String PATH_CALL_SILENCE = "/call/silence"; + + private WearableRemoteControls() { + } + + public static boolean handleIncoming(Context context, MessageEventParcelable event) { + if (event == null || event.path == null) { + return false; + } + switch (event.path) { + case PATH_MEDIA_PLAY: + case PATH_MEDIA_PAUSE: + case PATH_MEDIA_NEXT: + case PATH_MEDIA_PREVIOUS: + case PATH_MEDIA_STOP: + if (!WearablePreferences.isMediaControlEnabled(context)) { + return true; + } + break; + case PATH_CALL_END: + case PATH_CALL_ANSWER: + case PATH_CALL_SILENCE: + if (!WearablePreferences.isCallControlEnabled(context)) { + return true; + } + break; + default: + return false; + } + switch (event.path) { + case PATH_MEDIA_PLAY: + dispatchMedia(context, Action.PLAY); + return true; + case PATH_MEDIA_PAUSE: + dispatchMedia(context, Action.PAUSE); + return true; + case PATH_MEDIA_NEXT: + dispatchMedia(context, Action.NEXT); + return true; + case PATH_MEDIA_PREVIOUS: + dispatchMedia(context, Action.PREVIOUS); + return true; + case PATH_MEDIA_STOP: + dispatchMedia(context, Action.STOP); + return true; + case PATH_CALL_END: + endCall(context); + return true; + case PATH_CALL_ANSWER: + acceptRingingCall(context); + return true; + case PATH_CALL_SILENCE: + silenceRinger(context); + return true; + default: + return false; + } + } + + public static void dispatchMedia(Context context, Action action) { + if (Build.VERSION.SDK_INT < 21) { + return; + } + MediaSessionManager manager = (MediaSessionManager) context.getSystemService(Context.MEDIA_SESSION_SERVICE); + if (manager == null) { + return; + } + ComponentName listener = new ComponentName(context, WearableNotificationListenerService.class); + List sessions; + try { + sessions = manager.getActiveSessions(listener); + } catch (SecurityException e) { + Log.w(TAG, "No notification listener access for media sessions", e); + return; + } + if (sessions == null || sessions.isEmpty()) { + Log.d(TAG, "No active media session for " + action); + return; + } + MediaController controller = sessions.get(0); + switch (action) { + case PLAY: + controller.getTransportControls().play(); + break; + case PAUSE: + controller.getTransportControls().pause(); + break; + case NEXT: + controller.getTransportControls().skipToNext(); + break; + case PREVIOUS: + controller.getTransportControls().skipToPrevious(); + break; + case STOP: + controller.getTransportControls().stop(); + break; + } + } + + public static void endCall(Context context) { + if (Build.VERSION.SDK_INT >= 28) { + TelecomManager telecom = (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE); + if (telecom != null) { + try { + telecom.endCall(); + return; + } catch (SecurityException e) { + Log.w(TAG, "endCall denied", e); + } + } + } + TelephonyManager telephony = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + if (telephony != null) { + try { + //noinspection JavaReflectionMemberAccess + TelephonyManager.class.getMethod("endCall").invoke(telephony); + } catch (Exception e) { + Log.w(TAG, "TelephonyManager.endCall failed", e); + } + } + } + + public static void acceptRingingCall(Context context) { + if (Build.VERSION.SDK_INT >= 26) { + TelecomManager telecom = (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE); + if (telecom != null) { + try { + telecom.acceptRingingCall(); + return; + } catch (SecurityException e) { + Log.w(TAG, "acceptRingingCall denied", e); + } + } + } + TelephonyManager telephony = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + if (telephony != null) { + try { + //noinspection JavaReflectionMemberAccess + TelephonyManager.class.getMethod("answerRingingCall").invoke(telephony); + } catch (Exception e) { + Log.w(TAG, "TelephonyManager.answerRingingCall failed", e); + } + } + } + + public static void silenceRinger(Context context) { + if (Build.VERSION.SDK_INT >= 28) { + TelecomManager telecom = (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE); + if (telecom != null) { + try { + telecom.silenceRinger(); + return; + } catch (SecurityException e) { + Log.w(TAG, "silenceRinger denied", e); + } + } + } + AudioManager audio = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + if (audio != null) { + audio.adjustStreamVolume(AudioManager.STREAM_RING, AudioManager.ADJUST_MUTE, 0); + } + } + + public enum Action { + PLAY, PAUSE, NEXT, PREVIOUS, STOP + } +} 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 5083bd4a05..54845c56c1 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 @@ -16,6 +16,7 @@ package org.microg.gms.wearable; +import android.content.Intent; import android.os.RemoteException; import com.google.android.gms.common.Feature; @@ -67,22 +68,37 @@ public class WearableService extends BaseService { }; private WearableImpl wearable; + private static WearableImpl instance; public WearableService() { super("GmsWearSvc", GmsService.WEAR); } + public static WearableImpl getImpl() { + return instance; + } + @Override public void onCreate() { super.onCreate(); ConfigurationDatabaseHelper configurationDatabaseHelper = new ConfigurationDatabaseHelper(getApplicationContext()); NodeDatabaseHelper nodeDatabaseHelper = new NodeDatabaseHelper(getApplicationContext()); wearable = new WearableImpl(getApplicationContext(), nodeDatabaseHelper, configurationDatabaseHelper); + instance = wearable; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + super.onStartCommand(intent, flags, startId); + return START_STICKY; } @Override public void onDestroy() { super.onDestroy(); + if (instance == wearable) { + instance = null; + } wearable.stop(); } diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableServiceImpl.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableServiceImpl.java index 1fb2c589eb..54446ce460 100644 --- a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableServiceImpl.java +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableServiceImpl.java @@ -346,17 +346,32 @@ public void clearStorage(IWearableCallbacks callbacks) throws RemoteException { @Override public void endCall(IWearableCallbacks callbacks) throws RemoteException { - Log.d(TAG, "unimplemented Method: endCall"); + postMain(callbacks, () -> { + if (WearablePreferences.isCallControlEnabled(context)) { + WearableRemoteControls.endCall(context); + } + callbacks.onStatus(Status.SUCCESS); + }); } @Override public void acceptRingingCall(IWearableCallbacks callbacks) throws RemoteException { - Log.d(TAG, "unimplemented Method: acceptRingingCall"); + postMain(callbacks, () -> { + if (WearablePreferences.isCallControlEnabled(context)) { + WearableRemoteControls.acceptRingingCall(context); + } + callbacks.onStatus(Status.SUCCESS); + }); } @Override public void silenceRinger(IWearableCallbacks callbacks) throws RemoteException { - Log.d(TAG, "unimplemented Method: silenceRinger"); + postMain(callbacks, () -> { + if (WearablePreferences.isCallControlEnabled(context)) { + WearableRemoteControls.silenceRinger(context); + } + callbacks.onStatus(Status.SUCCESS); + }); } /* diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothConnectionManager.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothConnectionManager.java new file mode 100644 index 0000000000..47c3647e81 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothConnectionManager.java @@ -0,0 +1,334 @@ +/* + * SPDX-FileCopyrightText: 2026, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wearable.bluetooth; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothManager; +import android.bluetooth.BluetoothServerSocket; +import android.bluetooth.BluetoothSocket; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; +import android.util.Log; + +import com.google.android.gms.wearable.ConnectionConfiguration; + +import org.microg.gms.wearable.MessageHandler; +import org.microg.gms.wearable.WearableConnectionKind; +import org.microg.gms.wearable.WearableImpl; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Starts Wear OS RFCOMM listeners (watch connects to the phone) and optional outbound + * reconnects to a bonded MAC. Each accepted socket is handed to {@link BluetoothWearableConnection} + * using the existing Wearable v2 {@link MessageHandler}. + */ +public class BluetoothConnectionManager { + private static final String TAG = "GmsWearBt"; + private static final long RECONNECT_MS = 10_000L; + + private final Context context; + private final WearableImpl wearable; + private final List acceptThreads = new ArrayList<>(); + private final Map outboundThreads = new HashMap<>(); + + public BluetoothConnectionManager(Context context, WearableImpl wearable) { + this.context = context; + this.wearable = wearable; + } + + public synchronized void ensureStarted(ConnectionConfiguration config) { + if (!hasBluetoothConnectPermission()) { + Log.w(TAG, "BLUETOOTH_CONNECT not granted; cannot start Wear OS RFCOMM"); + return; + } + startListeners(); + if (WearableConnectionKind.isBluetoothAddress(config.address)) { + startOutbound(config); + } + } + + public synchronized void stop(String name) { + OutboundThread outbound = outboundThreads.remove(name); + if (outbound != null) { + outbound.shutdown(); + } + boolean anyBluetooth = false; + ConnectionConfiguration[] configs = wearable.getConfigurations(); + if (configs != null) { + for (ConnectionConfiguration config : configs) { + if (config.enabled && WearableConnectionKind.isBluetooth(config)) { + anyBluetooth = true; + break; + } + } + } + if (!anyBluetooth) { + stopListeners(); + } + } + + public synchronized void stopAll() { + for (OutboundThread outbound : outboundThreads.values()) { + outbound.shutdown(); + } + outboundThreads.clear(); + stopListeners(); + } + + @SuppressLint("MissingPermission") + private void startListeners() { + if (!acceptThreads.isEmpty()) { + return; + } + BluetoothAdapter adapter = bluetoothAdapter(); + if (adapter == null || !adapter.isEnabled()) { + Log.w(TAG, "Bluetooth adapter missing or disabled"); + return; + } + for (WearableBtUuids.NamedUuid named : WearableBtUuids.ALL) { + AcceptThread thread = new AcceptThread(adapter, named.name, named.uuid); + acceptThreads.add(thread); + thread.start(); + } + } + + private void stopListeners() { + for (AcceptThread thread : acceptThreads) { + thread.shutdown(); + } + acceptThreads.clear(); + } + + private void startOutbound(ConnectionConfiguration config) { + if (outboundThreads.containsKey(config.name)) { + return; + } + BluetoothAdapter adapter = bluetoothAdapter(); + if (adapter == null) { + return; + } + OutboundThread thread = new OutboundThread(adapter, config); + outboundThreads.put(config.name, thread); + thread.start(); + } + + private BluetoothAdapter bluetoothAdapter() { + if (Build.VERSION.SDK_INT >= 18) { + BluetoothManager manager = (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE); + if (manager != null) { + return manager.getAdapter(); + } + } + return BluetoothAdapter.getDefaultAdapter(); + } + + private boolean hasBluetoothConnectPermission() { + if (Build.VERSION.SDK_INT < 31) { + return true; + } + return context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED; + } + + private void attachSocket(BluetoothSocket socket, ConnectionConfiguration config) { + try { + MessageHandler handler = new MessageHandler(context, wearable, config); + BluetoothWearableConnection connection = new BluetoothWearableConnection(socket, handler); + Thread thread = new Thread(connection, "WearBtConn-" + safeAddress(socket)); + thread.start(); + Log.d(TAG, "Wearable RFCOMM session started for " + config); + } catch (IOException e) { + Log.w(TAG, "Failed to wrap Bluetooth socket", e); + try { + socket.close(); + } catch (IOException ignored) { + } + } + } + + @SuppressLint("MissingPermission") + private static String safeAddress(BluetoothSocket socket) { + try { + BluetoothDevice device = socket.getRemoteDevice(); + return device != null ? device.getAddress() : "unknown"; + } catch (Exception e) { + return "unknown"; + } + } + + private ConnectionConfiguration configForInbound(BluetoothSocket socket) { + String address = safeAddress(socket); + ConnectionConfiguration[] configs = wearable.getConfigurations(); + if (configs != null) { + for (ConnectionConfiguration config : configs) { + if (address.equalsIgnoreCase(config.address)) { + return config; + } + } + for (ConnectionConfiguration config : configs) { + if (WearableConnectionKind.isBluetooth(config)) { + return config; + } + } + } + return new ConnectionConfiguration("bluetooth", address, WearableConnectionKind.TYPE_BLUETOOTH, 2, true); + } + + private class AcceptThread extends Thread { + private final BluetoothAdapter adapter; + private final String serviceName; + private final UUID uuid; + private BluetoothServerSocket serverSocket; + private volatile boolean running = true; + + AcceptThread(BluetoothAdapter adapter, String serviceName, UUID uuid) { + super("WearBtListen-" + serviceName); + this.adapter = adapter; + this.serviceName = serviceName; + this.uuid = uuid; + } + + @SuppressLint("MissingPermission") + @Override + public void run() { + try { + serverSocket = adapter.listenUsingRfcommWithServiceRecord(serviceName, uuid); + } catch (IOException e) { + Log.w(TAG, "listenUsingRfcommWithServiceRecord failed for " + serviceName, e); + try { + serverSocket = adapter.listenUsingInsecureRfcommWithServiceRecord(serviceName, uuid); + } catch (IOException e2) { + Log.w(TAG, "insecure listen failed for " + serviceName, e2); + return; + } + } + Log.d(TAG, "Listening for Wear OS on " + serviceName + " " + uuid); + while (running) { + try { + BluetoothSocket socket = serverSocket.accept(); + if (socket == null) continue; + Log.d(TAG, "Accepted Wear OS socket on " + serviceName + " from " + safeAddress(socket)); + attachSocket(socket, configForInbound(socket)); + } catch (IOException e) { + if (running) { + Log.w(TAG, "accept() failed for " + serviceName, e); + } + break; + } + } + } + + void shutdown() { + running = false; + if (serverSocket != null) { + try { + serverSocket.close(); + } catch (IOException ignored) { + } + } + interrupt(); + } + } + + private class OutboundThread extends Thread { + private final BluetoothAdapter adapter; + private final ConnectionConfiguration config; + private volatile boolean running = true; + private BluetoothSocket socket; + + OutboundThread(BluetoothAdapter adapter, ConnectionConfiguration config) { + super("WearBtOut-" + config.address); + this.adapter = adapter; + this.config = config; + } + + @SuppressLint("MissingPermission") + @Override + public void run() { + BluetoothDevice device; + try { + device = adapter.getRemoteDevice(config.address); + } catch (IllegalArgumentException e) { + Log.w(TAG, "Invalid Bluetooth address " + config.address, e); + return; + } + while (running) { + BluetoothSocket connected = connect(device); + if (!running) { + closeQuietly(connected); + return; + } + if (connected != null) { + socket = connected; + Log.d(TAG, "Connected outbound RFCOMM to " + config.address); + try { + MessageHandler handler = new MessageHandler(context, wearable, config); + new BluetoothWearableConnection(connected, handler).run(); + } catch (IOException e) { + Log.w(TAG, "Outbound Wear OS session ended", e); + } + socket = null; + } + if (!running) return; + try { + Thread.sleep(RECONNECT_MS); + } catch (InterruptedException e) { + return; + } + } + } + + @SuppressLint("MissingPermission") + private BluetoothSocket connect(BluetoothDevice device) { + adapter.cancelDiscovery(); + for (WearableBtUuids.NamedUuid named : WearableBtUuids.ALL) { + BluetoothSocket attempt = tryConnect(device, named.uuid, false); + if (attempt != null) return attempt; + attempt = tryConnect(device, named.uuid, true); + if (attempt != null) return attempt; + } + return null; + } + + @SuppressLint("MissingPermission") + private BluetoothSocket tryConnect(BluetoothDevice device, UUID uuid, boolean insecure) { + BluetoothSocket attempt = null; + try { + attempt = insecure + ? device.createInsecureRfcommSocketToServiceRecord(uuid) + : device.createRfcommSocketToServiceRecord(uuid); + attempt.connect(); + return attempt; + } catch (IOException e) { + closeQuietly(attempt); + return null; + } + } + + void shutdown() { + running = false; + closeQuietly(socket); + interrupt(); + } + } + + private static void closeQuietly(BluetoothSocket socket) { + if (socket == null) return; + try { + socket.close(); + } catch (IOException ignored) { + } + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothWearableConnection.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothWearableConnection.java new file mode 100644 index 0000000000..ac41043684 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/BluetoothWearableConnection.java @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: 2026, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wearable.bluetooth; + +import android.bluetooth.BluetoothSocket; + +import com.squareup.wire.Wire; + +import org.microg.wearable.WearableConnection; +import org.microg.wearable.proto.MessagePiece; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; + +/** + * Length-prefixed Wearable v2 framing over a Bluetooth RFCOMM socket, matching + * {@code SocketWearableConnection} from {@code org.microg:wearable}. + */ +public class BluetoothWearableConnection extends WearableConnection { + 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.toByteArray(); + 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."); + } + byte[] bytes = new byte[len]; + is.readFully(bytes); + return new Wire().parseFrom(bytes, MessagePiece.class); + } + + @Override + public void close() throws IOException { + socket.close(); + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/WearableBtUuids.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/WearableBtUuids.java new file mode 100644 index 0000000000..e33e127ed0 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/bluetooth/WearableBtUuids.java @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2026, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wearable.bluetooth; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.regex.Pattern; + +/** + * RFCOMM service UUIDs advertised by Wear OS / Android Wear companions. + *

+ * Generic SPP ({@code 00001101-0000-1000-8000-00805F9B34FB}) is not used by Wear OS. + * The watch looks up these named SDP records (see BluetoothSocket {@code mServiceName=WearableBt} + * in pairing traces). Listen and connect with this set, in order. + */ +public final class WearableBtUuids { + public static final UUID WEARABLE_BT = UUID.fromString("5e8945b0-9525-11e3-a5e2-0800200c9a66"); + public static final UUID FLOW = UUID.fromString("22b21d80-b0ae-11e3-9c1a-0800200c9a66"); + public static final UUID FLOW15 = UUID.fromString("ae3ead70-b0ae-11e3-9c1a-0800200c9a66"); + + public static final String NAME_WEARABLE_BT = "WearableBt"; + public static final String NAME_FLOW = "Flow"; + public static final String NAME_FLOW15 = "Flow15"; + + public static final List ALL = Collections.unmodifiableList(Arrays.asList( + new NamedUuid(NAME_WEARABLE_BT, WEARABLE_BT), + new NamedUuid(NAME_FLOW, FLOW), + new NamedUuid(NAME_FLOW15, FLOW15) + )); + + private static final Pattern BLUETOOTH_ADDRESS = + Pattern.compile("^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$"); + + private WearableBtUuids() { + } + + public static boolean isBluetoothAddress(String address) { + if (address == null || address.length() == 0 || "NULL_STRING".equals(address)) { + return false; + } + return BLUETOOTH_ADDRESS.matcher(address).matches(); + } + + public static final class NamedUuid { + public final String name; + public final UUID uuid; + + public NamedUuid(String name, UUID uuid) { + this.name = name; + this.uuid = uuid; + } + } +} diff --git a/play-services-wearable/core/src/main/kotlin/org/microg/gms/wearable/WearablePreferences.kt b/play-services-wearable/core/src/main/kotlin/org/microg/gms/wearable/WearablePreferences.kt new file mode 100644 index 0000000000..55e8f0941d --- /dev/null +++ b/play-services-wearable/core/src/main/kotlin/org/microg/gms/wearable/WearablePreferences.kt @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: 2026, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wearable + +import android.content.Context +import org.microg.gms.settings.SettingsContract +import org.microg.gms.settings.SettingsContract.Wearable + +object WearablePreferences { + @JvmStatic + fun isTosAccepted(context: Context): Boolean = SettingsContract.getSettings( + context, Wearable.getContentUri(context), arrayOf(Wearable.TOS_ACCEPTED) + ) { it.getInt(0) != 0 } + + @JvmStatic + fun setTosAccepted(context: Context, accepted: Boolean) { + SettingsContract.setSettings(context, Wearable.getContentUri(context)) { + put(Wearable.TOS_ACCEPTED, accepted) + } + } + + @JvmStatic + fun isNotificationsEnabled(context: Context): Boolean = SettingsContract.getSettings( + context, Wearable.getContentUri(context), arrayOf(Wearable.NOTIFICATIONS_ENABLED) + ) { it.getInt(0) != 0 } + + @JvmStatic + fun setNotificationsEnabled(context: Context, enabled: Boolean) { + SettingsContract.setSettings(context, Wearable.getContentUri(context)) { + put(Wearable.NOTIFICATIONS_ENABLED, enabled) + } + } + + @JvmStatic + fun isMediaControlEnabled(context: Context): Boolean = SettingsContract.getSettings( + context, Wearable.getContentUri(context), arrayOf(Wearable.MEDIA_CONTROL_ENABLED) + ) { it.getInt(0) != 0 } + + @JvmStatic + fun setMediaControlEnabled(context: Context, enabled: Boolean) { + SettingsContract.setSettings(context, Wearable.getContentUri(context)) { + put(Wearable.MEDIA_CONTROL_ENABLED, enabled) + } + } + + @JvmStatic + fun isCallControlEnabled(context: Context): Boolean = SettingsContract.getSettings( + context, Wearable.getContentUri(context), arrayOf(Wearable.CALL_CONTROL_ENABLED) + ) { it.getInt(0) != 0 } + + @JvmStatic + fun setCallControlEnabled(context: Context, enabled: Boolean) { + SettingsContract.setSettings(context, Wearable.getContentUri(context)) { + put(Wearable.CALL_CONTROL_ENABLED, enabled) + } + } +} diff --git a/play-services-wearable/core/src/main/kotlin/org/microg/gms/wearable/ui/WearablePreferencesFragment.kt b/play-services-wearable/core/src/main/kotlin/org/microg/gms/wearable/ui/WearablePreferencesFragment.kt new file mode 100644 index 0000000000..76715a46e7 --- /dev/null +++ b/play-services-wearable/core/src/main/kotlin/org/microg/gms/wearable/ui/WearablePreferencesFragment.kt @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: 2026, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wearable.ui + +import android.content.ComponentName +import android.content.Intent +import android.os.Bundle +import android.provider.Settings +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import org.microg.gms.ui.SwitchBarPreference +import org.microg.gms.wearable.WearableNotificationListenerService +import org.microg.gms.wearable.WearablePreferences +import org.microg.gms.wearable.core.R + +class WearablePreferencesFragment : PreferenceFragmentCompat() { + private lateinit var notifications: SwitchBarPreference + private lateinit var media: androidx.preference.SwitchPreferenceCompat + private lateinit var calls: androidx.preference.SwitchPreferenceCompat + private lateinit var listenerAccess: Preference + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.preferences_wearable) + } + + override fun onBindPreferences() { + notifications = preferenceScreen.findPreference(PREF_NOTIFICATIONS) ?: notifications + media = preferenceScreen.findPreference(PREF_MEDIA) ?: media + calls = preferenceScreen.findPreference(PREF_CALLS) ?: calls + listenerAccess = preferenceScreen.findPreference(PREF_LISTENER) ?: listenerAccess + notifications.setOnPreferenceChangeListener { _, newValue -> + WearablePreferences.setNotificationsEnabled(requireContext(), newValue as Boolean) + true + } + media.setOnPreferenceChangeListener { _, newValue -> + WearablePreferences.setMediaControlEnabled(requireContext(), newValue as Boolean) + true + } + calls.setOnPreferenceChangeListener { _, newValue -> + WearablePreferences.setCallControlEnabled(requireContext(), newValue as Boolean) + true + } + listenerAccess.setOnPreferenceClickListener { + startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) + true + } + } + + override fun onResume() { + super.onResume() + notifications.isChecked = WearablePreferences.isNotificationsEnabled(requireContext()) + media.isChecked = WearablePreferences.isMediaControlEnabled(requireContext()) + calls.isChecked = WearablePreferences.isCallControlEnabled(requireContext()) + listenerAccess.summary = getString( + if (isNotificationListenerEnabled()) { + org.microg.gms.base.core.R.string.service_status_enabled_short + } else { + R.string.wearable_pref_notification_access_summary + } + ) + } + + private fun isNotificationListenerEnabled(): Boolean { + val cn = ComponentName(requireContext(), WearableNotificationListenerService::class.java) + val flat = Settings.Secure.getString( + requireContext().contentResolver, + "enabled_notification_listeners" + ) ?: return false + return flat.split(":").any { ComponentName.unflattenFromString(it) == cn } + } + + companion object { + private const val PREF_NOTIFICATIONS = "pref_wearable_notifications" + private const val PREF_MEDIA = "pref_wearable_media" + private const val PREF_CALLS = "pref_wearable_calls" + private const val PREF_LISTENER = "pref_wearable_notification_access" + } +} diff --git a/play-services-wearable/core/src/main/res/drawable/ic_watch.xml b/play-services-wearable/core/src/main/res/drawable/ic_watch.xml new file mode 100644 index 0000000000..a74bd3a82d --- /dev/null +++ b/play-services-wearable/core/src/main/res/drawable/ic_watch.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/play-services-wearable/core/src/main/res/layout/activity_wearable_tos.xml b/play-services-wearable/core/src/main/res/layout/activity_wearable_tos.xml new file mode 100644 index 0000000000..2e3133ad68 --- /dev/null +++ b/play-services-wearable/core/src/main/res/layout/activity_wearable_tos.xml @@ -0,0 +1,47 @@ + + + + + + + + + + +