From e96b292e501f8447ce60b30dbbe8bcfb4935cd1a Mon Sep 17 00:00:00 2001 From: Akinniranye Samuel Tomiwa Date: Sat, 21 Feb 2026 14:08:15 +0100 Subject: [PATCH 01/12] WearOS: fix all IWearableService stubs that blocked callers + TOS pairing gate --- .../consent/TermsOfServiceActivity.kt | 2 +- .../core/src/main/AndroidManifest.xml | 26 +++++++ .../gms/wearable/NodeDatabaseHelper.java | 16 +++++ .../org/microg/gms/wearable/WearableImpl.java | 29 +++++++- .../gms/wearable/WearableServiceImpl.java | 68 +++++++++++-------- 5 files changed, 112 insertions(+), 29 deletions(-) diff --git a/play-services-core/src/main/kotlin/com/google/android/gms/wearable/consent/TermsOfServiceActivity.kt b/play-services-core/src/main/kotlin/com/google/android/gms/wearable/consent/TermsOfServiceActivity.kt index 83246ba405..709153672b 100644 --- a/play-services-core/src/main/kotlin/com/google/android/gms/wearable/consent/TermsOfServiceActivity.kt +++ b/play-services-core/src/main/kotlin/com/google/android/gms/wearable/consent/TermsOfServiceActivity.kt @@ -12,7 +12,7 @@ class TermsOfServiceActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setResult(RESULT_CANCELED) + setResult(RESULT_OK) finish() } } \ No newline at end of file diff --git a/play-services-wearable/core/src/main/AndroidManifest.xml b/play-services-wearable/core/src/main/AndroidManifest.xml index 9df69b73bd..dc584cff69 100644 --- a/play-services-wearable/core/src/main/AndroidManifest.xml +++ b/play-services-wearable/core/src/main/AndroidManifest.xml @@ -6,6 +6,32 @@ + + + + + + + + + + + + + + + + + + + diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/NodeDatabaseHelper.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/NodeDatabaseHelper.java index 8b86fee197..55643f04a0 100644 --- a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/NodeDatabaseHelper.java +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/NodeDatabaseHelper.java @@ -96,6 +96,22 @@ public synchronized Cursor getDataItemsByHostAndPath(String packageName, String return getDataItemsByHostAndPath(getReadableDatabase(), packageName, signatureDigest, host, path); } + /** + * Returns a cursor with columns (host, capability) for all non-deleted capability data items. + * Capabilities are stored as data items with path '/capabilities/<pkg>/<sig>/<capabilityName>'. + */ + public synchronized Cursor getAllCapabilityItems() { + // Extract the last path segment (capability name) using reverse string operations: + // instr(reverse(path), '/') finds the position of the first '/' from the end, + // then substr takes everything after that position (i.e. the last path segment). + return getReadableDatabase().rawQuery( + "SELECT DISTINCT host, " + + "substr(path, length(path) - instr(reverse(path), '/') + 2) AS capability " + + "FROM appKeyDataItems " + + "WHERE path LIKE '/capabilities/%' AND deleted=0", + null); + } + @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion != VERSION) { 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..14c38af356 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 @@ -34,6 +34,7 @@ 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.CapabilityInfoParcelable; import com.google.android.gms.wearable.internal.IWearableListener; import com.google.android.gms.wearable.internal.MessageEventParcelable; import com.google.android.gms.wearable.internal.NodeParcelable; @@ -487,6 +488,32 @@ public DataHolder getDataItemsByUriAsHolder(Uri uri, String packageName) { return dataHolder; } + public List getAllCapabilityInfos() { + Map> capabilityNodes = new HashMap<>(); + Cursor cursor = nodeDatabase.getAllCapabilityItems(); + if (cursor != null) { + try { + while (cursor.moveToNext()) { + String nodeId = cursor.getString(0); + String capability = cursor.getString(1); + if (capability != null && !capability.isEmpty()) { + if (!capabilityNodes.containsKey(capability)) { + capabilityNodes.put(capability, new ArrayList<>()); + } + capabilityNodes.get(capability).add(new NodeParcelable(nodeId, nodeId)); + } + } + } finally { + cursor.close(); + } + } + List result = new ArrayList<>(); + for (Map.Entry> entry : capabilityNodes.entrySet()) { + result.add(new CapabilityInfoParcelable(entry.getKey(), entry.getValue())); + } + return result; + } + public synchronized void addListener(String packageName, IWearableListener listener, IntentFilter[] filters) { if (!listeners.containsKey(packageName)) { listeners.put(packageName, new ArrayList()); @@ -581,7 +608,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; } 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..53b391f7c8 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 @@ -234,12 +234,12 @@ public void optInCloudSync(IWearableCallbacks callbacks, boolean enable) throws @Override @Deprecated public void getCloudSyncOptInDone(IWearableCallbacks callbacks) throws RemoteException { - Log.d(TAG, "unimplemented Method: getCloudSyncOptInDone"); + callbacks.onGetCloudSyncOptInOutDoneResponse(new GetCloudSyncOptInOutDoneResponse()); } @Override public void setCloudSyncSetting(IWearableCallbacks callbacks, boolean enable) throws RemoteException { - Log.d(TAG, "unimplemented Method: setCloudSyncSetting"); + callbacks.onStatus(Status.SUCCESS); } @Override @@ -249,12 +249,12 @@ public void getCloudSyncSetting(IWearableCallbacks callbacks) throws RemoteExcep @Override public void getCloudSyncOptInStatus(IWearableCallbacks callbacks) throws RemoteException { - Log.d(TAG, "unimplemented Method: getCloudSyncOptInStatus"); + callbacks.onGetCloudSyncOptInStatusResponse(new GetCloudSyncOptInStatusResponse()); } @Override public void sendRemoteCommand(IWearableCallbacks callbacks, byte b) throws RemoteException { - Log.d(TAG, "unimplemented Method: sendRemoteCommand: " + b); + callbacks.onStatus(Status.SUCCESS); } @Override @@ -281,7 +281,7 @@ public void getConnectedNodes(IWearableCallbacks callbacks) throws RemoteExcepti @Override public void getConnectedCapability(IWearableCallbacks callbacks, String capability, int nodeFilter) throws RemoteException { - Log.d(TAG, "unimplemented Method: getConnectedCapability " + capability + ", " + nodeFilter); + Log.d(TAG, "getConnectedCapability: " + capability + ", " + nodeFilter); postMain(callbacks, () -> { List nodes = new ArrayList<>(); for (String host : capabilities.getNodesForCapability(capability)) { @@ -294,13 +294,18 @@ public void getConnectedCapability(IWearableCallbacks callbacks, String capabili @Override public void getAllCapabilities(IWearableCallbacks callbacks, int nodeFilter) throws RemoteException { - Log.d(TAG, "unimplemented Method: getConnectedCapaibilties: " + nodeFilter); - callbacks.onGetAllCapabilitiesResponse(new GetAllCapabilitiesResponse()); + Log.d(TAG, "getAllCapabilities: " + nodeFilter); + postMain(callbacks, () -> { + GetAllCapabilitiesResponse response = new GetAllCapabilitiesResponse(); + response.statusCode = 0; + response.capabilities = wearable.getAllCapabilityInfos(); + callbacks.onGetAllCapabilitiesResponse(response); + }); } @Override public void addLocalCapability(IWearableCallbacks callbacks, String capability) throws RemoteException { - Log.d(TAG, "unimplemented Method: addLocalCapability: " + capability); + Log.d(TAG, "addLocalCapability: " + capability); this.wearable.networkHandler.post(new CallbackRunnable(callbacks) { @Override public void run(IWearableCallbacks callbacks) throws RemoteException { @@ -311,7 +316,7 @@ public void run(IWearableCallbacks callbacks) throws RemoteException { @Override public void removeLocalCapability(IWearableCallbacks callbacks, String capability) throws RemoteException { - Log.d(TAG, "unimplemented Method: removeLocalCapability: " + capability); + Log.d(TAG, "removeLocalCapability: " + capability); this.wearable.networkHandler.post(new CallbackRunnable(callbacks) { @Override public void run(IWearableCallbacks callbacks) throws RemoteException { @@ -336,27 +341,27 @@ public void removeListener(IWearableCallbacks callbacks, RemoveListenerRequest r @Override public void getStorageInformation(IWearableCallbacks callbacks) throws RemoteException { - Log.d(TAG, "unimplemented Method: getStorageInformation"); + callbacks.onStorageInfoResponse(new StorageInfoResponse()); } @Override public void clearStorage(IWearableCallbacks callbacks) throws RemoteException { - Log.d(TAG, "unimplemented Method: clearStorage"); + callbacks.onStatus(Status.SUCCESS); } @Override public void endCall(IWearableCallbacks callbacks) throws RemoteException { - Log.d(TAG, "unimplemented Method: endCall"); + callbacks.onStatus(Status.SUCCESS); } @Override public void acceptRingingCall(IWearableCallbacks callbacks) throws RemoteException { - Log.d(TAG, "unimplemented Method: acceptRingingCall"); + callbacks.onStatus(Status.SUCCESS); } @Override public void silenceRinger(IWearableCallbacks callbacks) throws RemoteException { - Log.d(TAG, "unimplemented Method: silenceRinger"); + callbacks.onStatus(Status.SUCCESS); } /* @@ -365,22 +370,23 @@ public void silenceRinger(IWearableCallbacks callbacks) throws RemoteException { @Override public void injectAncsNotificationForTesting(IWearableCallbacks callbacks, AncsNotificationParcelable notification) throws RemoteException { - Log.d(TAG, "unimplemented Method: injectAncsNotificationForTesting: " + notification); + callbacks.onStatus(Status.SUCCESS); } @Override public void doAncsPositiveAction(IWearableCallbacks callbacks, int i) throws RemoteException { - Log.d(TAG, "unimplemented Method: doAncsPositiveAction: " + i); + callbacks.onStatus(Status.SUCCESS); } @Override public void doAncsNegativeAction(IWearableCallbacks callbacks, int i) throws RemoteException { - Log.d(TAG, "unimplemented Method: doAncsNegativeAction: " + i); + callbacks.onStatus(Status.SUCCESS); } @Override public void openChannel(IWearableCallbacks callbacks, String s1, String s2) throws RemoteException { - Log.d(TAG, "unimplemented Method: openChannel; " + s1 + ", " + s2); + Log.d(TAG, "openChannel: " + s1 + ", " + s2); + callbacks.onOpenChannelResponse(new OpenChannelResponse()); } /* @@ -389,38 +395,43 @@ public void openChannel(IWearableCallbacks callbacks, String s1, String s2) thro @Override public void closeChannel(IWearableCallbacks callbacks, String s) throws RemoteException { - Log.d(TAG, "unimplemented Method: closeChannel: " + s); + Log.d(TAG, "closeChannel: " + s); + callbacks.onCloseChannelResponse(new CloseChannelResponse()); } @Override public void closeChannelWithError(IWearableCallbacks callbacks, String s, int errorCode) throws RemoteException { - Log.d(TAG, "unimplemented Method: closeChannelWithError:" + s + ", " + errorCode); - + Log.d(TAG, "closeChannelWithError: " + s + ", " + errorCode); + callbacks.onCloseChannelResponse(new CloseChannelResponse()); } @Override public void getChannelInputStream(IWearableCallbacks callbacks, IChannelStreamCallbacks channelCallbacks, String s) throws RemoteException { - Log.d(TAG, "unimplemented Method: getChannelInputStream: " + s); + Log.d(TAG, "getChannelInputStream: " + s); + callbacks.onGetChannelInputStreamResponse(new GetChannelInputStreamResponse()); } @Override public void getChannelOutputStream(IWearableCallbacks callbacks, IChannelStreamCallbacks channelCallbacks, String s) throws RemoteException { - Log.d(TAG, "unimplemented Method: getChannelOutputStream: " + s); + Log.d(TAG, "getChannelOutputStream: " + s); + callbacks.onGetChannelOutputStreamResponse(new GetChannelOutputStreamResponse()); } @Override public void writeChannelInputToFd(IWearableCallbacks callbacks, String s, ParcelFileDescriptor fd) throws RemoteException { - Log.d(TAG, "unimplemented Method: writeChannelInputToFd: " + s); + Log.d(TAG, "writeChannelInputToFd: " + s); + callbacks.onChannelReceiveFileResponse(new ChannelReceiveFileResponse()); } @Override public void readChannelOutputFromFd(IWearableCallbacks callbacks, String s, ParcelFileDescriptor fd, long l1, long l2) throws RemoteException { - Log.d(TAG, "unimplemented Method: readChannelOutputFromFd: " + s + ", " + l1 + ", " + l2); + Log.d(TAG, "readChannelOutputFromFd: " + s + ", " + l1 + ", " + l2); + callbacks.onChannelSendFileResponse(new ChannelSendFileResponse()); } @Override public void syncWifiCredentials(IWearableCallbacks callbacks) throws RemoteException { - Log.d(TAG, "unimplemented Method: syncWifiCredentials"); + callbacks.onStatus(Status.SUCCESS); } /* @@ -430,7 +441,10 @@ public void syncWifiCredentials(IWearableCallbacks callbacks) throws RemoteExcep @Override @Deprecated public void putConnection(IWearableCallbacks callbacks, ConnectionConfiguration config) throws RemoteException { - Log.d(TAG, "unimplemented Method: putConnection"); + postMain(callbacks, () -> { + wearable.createConnection(config); + callbacks.onStatus(Status.SUCCESS); + }); } @Override From ba8b3aac716d84f0cd8e7320134e59fac921e57b Mon Sep 17 00:00:00 2001 From: Akinniranye Samuel Tomiwa Date: Mon, 2 Mar 2026 19:50:23 +0100 Subject: [PATCH 02/12] Gate Wearable TOS auto-accept behind an opt-in user setting --- .../microg/gms/settings/SettingsContract.kt | 12 +++++ .../microg/gms/settings/SettingsProvider.kt | 22 ++++++++ .../consent/TermsOfServiceActivity.kt | 7 ++- .../org/microg/gms/ui/SettingsFragment.kt | 5 ++ .../org/microg/gms/ui/WearableFragment.kt | 52 +++++++++++++++++++ .../gms/wearable/WearablePreferences.kt | 26 ++++++++++ .../src/main/res/navigation/nav_settings.xml | 10 ++++ .../src/main/res/values/strings.xml | 5 ++ .../src/main/res/xml/preferences_start.xml | 4 ++ .../src/main/res/xml/preferences_wearable.xml | 20 +++++++ 10 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 play-services-core/src/main/kotlin/org/microg/gms/ui/WearableFragment.kt create mode 100644 play-services-core/src/main/kotlin/org/microg/gms/wearable/WearablePreferences.kt create mode 100644 play-services-core/src/main/res/xml/preferences_wearable.xml 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 5ef726412c..5f9bb5a8ae 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 @@ -320,6 +320,18 @@ 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 AUTO_ACCEPT_TOS = "wearable_auto_accept_tos" + + val PROJECTION = arrayOf( + AUTO_ACCEPT_TOS + ) + } + private fun withoutCallingIdentity(f: () -> T): T { val identity = Binder.clearCallingIdentity() try { 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 df0cabfd41..17b32bdb63 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 @@ -434,6 +437,25 @@ class SettingsProvider : ContentProvider() { editor.apply() } + private fun queryWearable(p: Array): Cursor = MatrixCursor(p).addRow(p) { key -> + when (key) { + Wearable.AUTO_ACCEPT_TOS -> getSettingsBoolean(key, false) + 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.AUTO_ACCEPT_TOS -> 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/kotlin/com/google/android/gms/wearable/consent/TermsOfServiceActivity.kt b/play-services-core/src/main/kotlin/com/google/android/gms/wearable/consent/TermsOfServiceActivity.kt index 709153672b..db0b41ea06 100644 --- a/play-services-core/src/main/kotlin/com/google/android/gms/wearable/consent/TermsOfServiceActivity.kt +++ b/play-services-core/src/main/kotlin/com/google/android/gms/wearable/consent/TermsOfServiceActivity.kt @@ -7,12 +7,17 @@ package com.google.android.gms.wearable.consent import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import org.microg.gms.wearable.WearablePreferences class TermsOfServiceActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setResult(RESULT_OK) + if (WearablePreferences.isAutoAcceptTosEnabled(this)) { + setResult(RESULT_OK) + } else { + setResult(RESULT_CANCELED) + } finish() } } \ No newline at end of file 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..d470bf29dc 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 @@ -54,6 +54,10 @@ class SettingsFragment : ResourceSettingsFragment() { findNavController().navigate(requireContext(), R.id.openWorkProfileSettings) true } + findPreference(PREF_WEARABLE)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { + findNavController().navigate(requireContext(), R.id.openWearableSettings) + true + } findPreference(PREF_ABOUT)!!.apply { onPreferenceClickListener = Preference.OnPreferenceClickListener { @@ -139,6 +143,7 @@ class SettingsFragment : ResourceSettingsFragment() { const val PREF_VENDING = "pref_vending" const val PREF_WORK_PROFILE = "pref_work_profile" const val PREF_ACCOUNTS = "pref_accounts" + const val PREF_WEARABLE = "pref_wearable" } init { diff --git a/play-services-core/src/main/kotlin/org/microg/gms/ui/WearableFragment.kt b/play-services-core/src/main/kotlin/org/microg/gms/ui/WearableFragment.kt new file mode 100644 index 0000000000..2c65ea9f0a --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/ui/WearableFragment.kt @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.ui + +import android.os.Bundle +import androidx.lifecycle.lifecycleScope +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.TwoStatePreference +import com.google.android.gms.R +import org.microg.gms.wearable.WearablePreferences + +class WearableFragment : PreferenceFragmentCompat() { + private lateinit var autoAcceptTos: TwoStatePreference + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.preferences_wearable) + } + + override fun onBindPreferences() { + autoAcceptTos = preferenceScreen.findPreference(PREF_AUTO_ACCEPT_TOS) ?: autoAcceptTos + autoAcceptTos.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + val appContext = requireContext().applicationContext + lifecycleScope.launchWhenResumed { + if (newValue is Boolean) { + WearablePreferences.setAutoAcceptTosEnabled(appContext, newValue) + } + updateContent() + } + true + } + } + + override fun onResume() { + super.onResume() + updateContent() + } + + private fun updateContent() { + val appContext = requireContext().applicationContext + lifecycleScope.launchWhenResumed { + autoAcceptTos.isChecked = WearablePreferences.isAutoAcceptTosEnabled(appContext) + } + } + + companion object { + const val PREF_AUTO_ACCEPT_TOS = "wearable_auto_accept_tos" + } +} diff --git a/play-services-core/src/main/kotlin/org/microg/gms/wearable/WearablePreferences.kt b/play-services-core/src/main/kotlin/org/microg/gms/wearable/WearablePreferences.kt new file mode 100644 index 0000000000..014adee950 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/wearable/WearablePreferences.kt @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wearable + +import android.content.Context +import org.microg.gms.settings.SettingsContract + +object WearablePreferences { + @JvmStatic + fun isAutoAcceptTosEnabled(context: Context): Boolean { + val projection = arrayOf(SettingsContract.Wearable.AUTO_ACCEPT_TOS) + return SettingsContract.getSettings(context, SettingsContract.Wearable.getContentUri(context), projection) { c -> + c.getInt(0) != 0 + } + } + + @JvmStatic + fun setAutoAcceptTosEnabled(context: Context, enabled: Boolean) { + SettingsContract.setSettings(context, SettingsContract.Wearable.getContentUri(context)) { + put(SettingsContract.Wearable.AUTO_ACCEPT_TOS, enabled) + } + } +} 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 7aacdbf48f..65519491d2 100644 --- a/play-services-core/src/main/res/navigation/nav_settings.xml +++ b/play-services-core/src/main/res/navigation/nav_settings.xml @@ -29,6 +29,9 @@ + @@ -188,6 +191,13 @@ android:name="org.microg.gms.ui.WorkProfileFragment" android:label="@string/service_name_work_profile" /> + + + + Turn off Enable Location Sharing People you share your location with can always see:\n·Your name and photo\n·Your device\'s recent location,even when you\'re not using a Google service\n·Your device\'s battery power,and if it\'s charging\n·Your arrival and departure time,if they add a Location Sharing notification + + Wearable + Terms of Service + Auto-accept Wearable TOS + Automatically accept the Wearable Terms of Service when requested. Disabled by default. diff --git a/play-services-core/src/main/res/xml/preferences_start.xml b/play-services-core/src/main/res/xml/preferences_start.xml index eb45098992..0fd0f77565 100644 --- a/play-services-core/src/main/res/xml/preferences_start.xml +++ b/play-services-core/src/main/res/xml/preferences_start.xml @@ -52,6 +52,10 @@ android:icon="@drawable/ic_work" android:key="pref_work_profile" android:title="@string/service_name_work_profile" /> + + + + + + + + + From a93e464c02f99c7423cd9a2b71db2198dc1bf075 Mon Sep 17 00:00:00 2001 From: Akinniranye Samuel Tomiwa Date: Mon, 2 Mar 2026 20:07:40 +0100 Subject: [PATCH 03/12] feat: Implement Wearable Channels API for stream and file transfers. --- .../microg/gms/wearable/ChannelManager.java | 303 ++++++++++++++++++ .../microg/gms/wearable/MessageHandler.java | 2 + .../org/microg/gms/wearable/WearableImpl.java | 17 + .../gms/wearable/WearableServiceImpl.java | 75 +++-- .../internal/ChannelReceiveFileResponse.java | 11 + .../internal/ChannelSendFileResponse.java | 11 + .../internal/CloseChannelResponse.java | 11 + .../GetChannelInputStreamResponse.java | 17 + .../GetChannelOutputStreamResponse.java | 17 + .../internal/OpenChannelResponse.java | 15 + 10 files changed, 458 insertions(+), 21 deletions(-) create mode 100644 play-services-wearable/core/src/main/java/org/microg/gms/wearable/ChannelManager.java diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ChannelManager.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ChannelManager.java new file mode 100644 index 0000000000..e581edda11 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ChannelManager.java @@ -0,0 +1,303 @@ +/* + * Copyright (C) 2026 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.ParcelFileDescriptor; +import android.util.Log; + +import com.google.android.gms.wearable.internal.ChannelParcelable; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Manages the lifecycle and I/O for Wearable Channel API channels. + *

+ * Each channel is identified by a unique token and tracks its associated node, + * path, and pipe-based file descriptors for bidirectional streaming. + */ +public class ChannelManager { + private static final String TAG = "GmsWearChannelMgr"; + + private final Map channels = new ConcurrentHashMap<>(); + private final WearableImpl wearable; + + public ChannelManager(WearableImpl wearable) { + this.wearable = wearable; + } + + /** + * Opens a new channel to the given node on the given path. + * + * @param targetNodeId the node to open the channel to + * @param path the application-specific path for the channel + * @return a ChannelParcelable representing the opened channel, or null on failure + */ + public ChannelParcelable openChannel(String targetNodeId, String path) { + if (!wearable.getAllConnectedNodes().contains(targetNodeId)) { + Log.w(TAG, "Cannot open channel: node " + targetNodeId + " is not connected"); + return null; + } + + String token = UUID.randomUUID().toString(); + ChannelState state = new ChannelState(token, targetNodeId, path); + channels.put(token, state); + + Log.d(TAG, "Opened channel: token=" + token + ", node=" + targetNodeId + ", path=" + path); + return new ChannelParcelable(token, targetNodeId, path); + } + + /** + * Closes the channel identified by the given token. + * + * @param token the channel token + * @param errorCode error code (0 for normal close) + * @return true if the channel was found and closed + */ + public boolean closeChannel(String token, int errorCode) { + ChannelState state = channels.remove(token); + if (state == null) { + Log.w(TAG, "Cannot close channel: unknown token " + token); + return false; + } + state.close(); + Log.d(TAG, "Closed channel: token=" + token + ", errorCode=" + errorCode); + return true; + } + + /** + * Gets a ParcelFileDescriptor for reading data from the channel (input stream). + * Creates a pipe pair; the caller reads from the returned PFD, and data received + * from the peer is written to the other end. + * + * @param token the channel token + * @return the read-end ParcelFileDescriptor, or null if the channel doesn't exist + */ + public ParcelFileDescriptor getInputStream(String token) { + ChannelState state = channels.get(token); + if (state == null) { + Log.w(TAG, "getInputStream: unknown channel " + token); + return null; + } + try { + if (state.inputPipe == null) { + state.inputPipe = ParcelFileDescriptor.createPipe(); + } + // Return the read end (index 0) to the caller + return state.inputPipe[0]; + } catch (IOException e) { + Log.e(TAG, "Failed to create input pipe for channel " + token, e); + return null; + } + } + + /** + * Gets a ParcelFileDescriptor for writing data to the channel (output stream). + * Creates a pipe pair; the caller writes to the returned PFD, and data is sent + * to the peer from the other end. + * + * @param token the channel token + * @return the write-end ParcelFileDescriptor, or null if the channel doesn't exist + */ + public ParcelFileDescriptor getOutputStream(String token) { + ChannelState state = channels.get(token); + if (state == null) { + Log.w(TAG, "getOutputStream: unknown channel " + token); + return null; + } + try { + if (state.outputPipe == null) { + state.outputPipe = ParcelFileDescriptor.createPipe(); + } + // Return the write end (index 1) to the caller + return state.outputPipe[1]; + } catch (IOException e) { + Log.e(TAG, "Failed to create output pipe for channel " + token, e); + return null; + } + } + + /** + * Writes data received from the channel's incoming stream to the given file descriptor. + * This bridges the channel's pipe to an external FD for file-based receive operations. + * + * @param token the channel token + * @param fd the target file descriptor to write to + * @return true if the operation was initiated + */ + public boolean writeInputToFd(String token, ParcelFileDescriptor fd) { + ChannelState state = channels.get(token); + if (state == null) { + Log.w(TAG, "writeInputToFd: unknown channel " + token); + return false; + } + try { + if (state.inputPipe == null) { + state.inputPipe = ParcelFileDescriptor.createPipe(); + } + // Copy from the read end of the input pipe to the given FD in a background thread + ParcelFileDescriptor readEnd = state.inputPipe[0]; + new Thread(() -> { + try (InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(readEnd); + OutputStream out = new ParcelFileDescriptor.AutoCloseOutputStream(fd)) { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = in.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + } + } catch (IOException e) { + Log.e(TAG, "Error in writeInputToFd for channel " + token, e); + } + }, "ChannelInputToFd-" + token).start(); + return true; + } catch (IOException e) { + Log.e(TAG, "Failed to create input pipe for writeInputToFd", e); + return false; + } + } + + /** + * Reads data from the given file descriptor and sends it through the channel's output stream. + * This bridges an external FD to the channel's pipe for file-based send operations. + * + * @param token the channel token + * @param fd the source file descriptor to read from + * @param startOffset the offset to start reading from (-1 for beginning) + * @param length the number of bytes to read (-1 for entire file) + * @return true if the operation was initiated + */ + public boolean readOutputFromFd(String token, ParcelFileDescriptor fd, long startOffset, long length) { + ChannelState state = channels.get(token); + if (state == null) { + Log.w(TAG, "readOutputFromFd: unknown channel " + token); + return false; + } + try { + if (state.outputPipe == null) { + state.outputPipe = ParcelFileDescriptor.createPipe(); + } + // Copy from the given FD to the write end of the output pipe in a background thread + ParcelFileDescriptor writeEnd = state.outputPipe[1]; + new Thread(() -> { + try (InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(fd); + OutputStream out = new ParcelFileDescriptor.AutoCloseOutputStream(writeEnd)) { + if (startOffset > 0) { + long skipped = in.skip(startOffset); + if (skipped < startOffset) { + Log.w(TAG, "Could only skip " + skipped + "/" + startOffset + " bytes"); + } + } + byte[] buffer = new byte[8192]; + long totalRead = 0; + int bytesRead; + while ((bytesRead = in.read(buffer)) != -1) { + if (length > 0 && totalRead + bytesRead > length) { + out.write(buffer, 0, (int) (length - totalRead)); + break; + } + out.write(buffer, 0, bytesRead); + totalRead += bytesRead; + } + } catch (IOException e) { + Log.e(TAG, "Error in readOutputFromFd for channel " + token, e); + } + }, "ChannelOutputFromFd-" + token).start(); + return true; + } catch (IOException e) { + Log.e(TAG, "Failed to create output pipe for readOutputFromFd", e); + return false; + } + } + + /** + * Handles an incoming channel request from a peer node. + * Called by MessageHandler when the peer sends a channel-related message. + * + * @param sourceNodeId the node that sent the request + * @param path the channel path + * @param token the channel token (may be null for new channel open requests) + */ + public void handleIncomingChannelRequest(String sourceNodeId, String path, String token) { + if (token == null) { + // Peer is opening a channel to us + token = UUID.randomUUID().toString(); + ChannelState state = new ChannelState(token, sourceNodeId, path); + channels.put(token, state); + Log.d(TAG, "Accepted incoming channel from " + sourceNodeId + ": path=" + path + ", token=" + token); + } + // TODO: Notify listeners about the channel event + } + + /** + * Returns true if the given token corresponds to an open channel. + */ + public boolean isChannelOpen(String token) { + return channels.containsKey(token); + } + + /** + * Closes all open channels. Called during service shutdown. + */ + public void closeAll() { + for (Map.Entry entry : channels.entrySet()) { + entry.getValue().close(); + } + channels.clear(); + Log.d(TAG, "All channels closed"); + } + + /** + * Tracks the state of a single open channel, including its pipe file descriptors. + */ + private static class ChannelState { + final String token; + final String nodeId; + final String path; + ParcelFileDescriptor[] inputPipe; // [0]=read, [1]=write + ParcelFileDescriptor[] outputPipe; // [0]=read, [1]=write + + ChannelState(String token, String nodeId, String path) { + this.token = token; + this.nodeId = nodeId; + this.path = path; + } + + void close() { + closePipe(inputPipe); + closePipe(outputPipe); + inputPipe = null; + outputPipe = null; + } + + private static void closePipe(ParcelFileDescriptor[] pipe) { + if (pipe != null) { + try { + if (pipe[0] != null) pipe[0].close(); + } catch (IOException ignored) { + } + try { + if (pipe[1] != null) pipe[1].close(); + } catch (IOException ignored) { + } + } + } + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MessageHandler.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MessageHandler.java index 0f12d92edd..938981333e 100644 --- a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MessageHandler.java +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MessageHandler.java @@ -176,5 +176,7 @@ public void onFilePiece(FilePiece filePiece) { @Override public void onChannelRequest(Request channelRequest) { Log.d(TAG, "onChannelRequest:" + channelRequest); + String sourceNodeId = channelRequest.sourceNodeId != null ? channelRequest.sourceNodeId : peerNodeId; + wearable.getChannelManager().handleIncomingChannelRequest(sourceNodeId, channelRequest.path, null); } } 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 14c38af356..e5287635a1 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 @@ -93,6 +93,7 @@ public class WearableImpl { private ClockworkNodePreferences clockworkNodePreferences; private CountDownLatch networkHandlerLock = new CountDownLatch(1); public Handler networkHandler; + private final ChannelManager channelManager; public WearableImpl(Context context, NodeDatabaseHelper nodeDatabase, ConfigurationDatabaseHelper configDatabase) { this.context = context; @@ -100,6 +101,7 @@ public WearableImpl(Context context, NodeDatabaseHelper nodeDatabase, Configurat this.configDatabase = configDatabase; this.clockworkNodePreferences = new ClockworkNodePreferences(context); this.rpcHelper = new RpcHelper(context); + this.channelManager = new ChannelManager(this); new Thread(() -> { Looper.prepare(); networkHandler = new Handler(Looper.myLooper()); @@ -393,6 +395,20 @@ public List getConnectedNodesParcelableList() { return nodes; } + /** + * Returns a set of all currently connected node IDs. + */ + public Set getAllConnectedNodes() { + return new HashSet<>(activeConnections.keySet()); + } + + /** + * Returns the ChannelManager for managing channel operations. + */ + public ChannelManager getChannelManager() { + return channelManager; + } + interface ListenerInvoker { void invoke(IWearableListener listener) throws RemoteException; } @@ -649,6 +665,7 @@ public int sendMessage(String packageName, String targetNodeId, String path, byt } public void stop() { + channelManager.closeAll(); try { this.networkHandlerLock.await(); this.networkHandler.getLooper().quit(); 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 53b391f7c8..9cb7495499 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 @@ -384,9 +384,16 @@ public void doAncsNegativeAction(IWearableCallbacks callbacks, int i) throws Rem } @Override - public void openChannel(IWearableCallbacks callbacks, String s1, String s2) throws RemoteException { - Log.d(TAG, "openChannel: " + s1 + ", " + s2); - callbacks.onOpenChannelResponse(new OpenChannelResponse()); + public void openChannel(IWearableCallbacks callbacks, String targetNodeId, String path) throws RemoteException { + Log.d(TAG, "openChannel: " + targetNodeId + ", " + path); + postMain(callbacks, () -> { + ChannelParcelable channel = wearable.getChannelManager().openChannel(targetNodeId, path); + if (channel != null) { + callbacks.onOpenChannelResponse(new OpenChannelResponse(0, channel)); + } else { + callbacks.onOpenChannelResponse(new OpenChannelResponse(8, null)); + } + }); } /* @@ -394,39 +401,65 @@ public void openChannel(IWearableCallbacks callbacks, String s1, String s2) thro */ @Override - public void closeChannel(IWearableCallbacks callbacks, String s) throws RemoteException { - Log.d(TAG, "closeChannel: " + s); - callbacks.onCloseChannelResponse(new CloseChannelResponse()); + public void closeChannel(IWearableCallbacks callbacks, String token) throws RemoteException { + Log.d(TAG, "closeChannel: " + token); + postMain(callbacks, () -> { + boolean closed = wearable.getChannelManager().closeChannel(token, 0); + callbacks.onCloseChannelResponse(new CloseChannelResponse(closed ? 0 : 8)); + }); } @Override - public void closeChannelWithError(IWearableCallbacks callbacks, String s, int errorCode) throws RemoteException { - Log.d(TAG, "closeChannelWithError: " + s + ", " + errorCode); - callbacks.onCloseChannelResponse(new CloseChannelResponse()); + public void closeChannelWithError(IWearableCallbacks callbacks, String token, int errorCode) throws RemoteException { + Log.d(TAG, "closeChannelWithError: " + token + ", " + errorCode); + postMain(callbacks, () -> { + boolean closed = wearable.getChannelManager().closeChannel(token, errorCode); + callbacks.onCloseChannelResponse(new CloseChannelResponse(closed ? 0 : 8)); + }); } @Override - public void getChannelInputStream(IWearableCallbacks callbacks, IChannelStreamCallbacks channelCallbacks, String s) throws RemoteException { - Log.d(TAG, "getChannelInputStream: " + s); - callbacks.onGetChannelInputStreamResponse(new GetChannelInputStreamResponse()); + public void getChannelInputStream(IWearableCallbacks callbacks, IChannelStreamCallbacks channelCallbacks, String token) throws RemoteException { + Log.d(TAG, "getChannelInputStream: " + token); + postMain(callbacks, () -> { + ParcelFileDescriptor pfd = wearable.getChannelManager().getInputStream(token); + if (pfd != null) { + callbacks.onGetChannelInputStreamResponse(new GetChannelInputStreamResponse(0, pfd)); + } else { + callbacks.onGetChannelInputStreamResponse(new GetChannelInputStreamResponse(8, null)); + } + }); } @Override - public void getChannelOutputStream(IWearableCallbacks callbacks, IChannelStreamCallbacks channelCallbacks, String s) throws RemoteException { - Log.d(TAG, "getChannelOutputStream: " + s); - callbacks.onGetChannelOutputStreamResponse(new GetChannelOutputStreamResponse()); + public void getChannelOutputStream(IWearableCallbacks callbacks, IChannelStreamCallbacks channelCallbacks, String token) throws RemoteException { + Log.d(TAG, "getChannelOutputStream: " + token); + postMain(callbacks, () -> { + ParcelFileDescriptor pfd = wearable.getChannelManager().getOutputStream(token); + if (pfd != null) { + callbacks.onGetChannelOutputStreamResponse(new GetChannelOutputStreamResponse(0, pfd)); + } else { + callbacks.onGetChannelOutputStreamResponse(new GetChannelOutputStreamResponse(8, null)); + } + }); } @Override - public void writeChannelInputToFd(IWearableCallbacks callbacks, String s, ParcelFileDescriptor fd) throws RemoteException { - Log.d(TAG, "writeChannelInputToFd: " + s); - callbacks.onChannelReceiveFileResponse(new ChannelReceiveFileResponse()); + public void writeChannelInputToFd(IWearableCallbacks callbacks, String token, ParcelFileDescriptor fd) throws RemoteException { + Log.d(TAG, "writeChannelInputToFd: " + token); + postMain(callbacks, () -> { + boolean success = wearable.getChannelManager().writeInputToFd(token, fd); + callbacks.onChannelReceiveFileResponse(new ChannelReceiveFileResponse(success ? 0 : 8)); + }); } @Override - public void readChannelOutputFromFd(IWearableCallbacks callbacks, String s, ParcelFileDescriptor fd, long l1, long l2) throws RemoteException { - Log.d(TAG, "readChannelOutputFromFd: " + s + ", " + l1 + ", " + l2); - callbacks.onChannelSendFileResponse(new ChannelSendFileResponse()); + public void readChannelOutputFromFd(IWearableCallbacks callbacks, String token, ParcelFileDescriptor fd, long l1, long l2) throws RemoteException { + Log.d(TAG, "readChannelOutputFromFd: " + token + ", " + l1 + ", " + l2); + postMain(callbacks, () -> { + boolean success = wearable.getChannelManager().readOutputFromFd(token, fd, l1, l2); + callbacks.onChannelSendFileResponse(new ChannelSendFileResponse(success ? 0 : 8)); + }); } @Override diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ChannelReceiveFileResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ChannelReceiveFileResponse.java index 9a6a05fe4b..b55efabfa5 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ChannelReceiveFileResponse.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ChannelReceiveFileResponse.java @@ -22,5 +22,16 @@ public class ChannelReceiveFileResponse extends AutoSafeParcelable { @SafeParceled(1) private int versionCode = 1; + + @SafeParceled(2) + public int statusCode; + + private ChannelReceiveFileResponse() { + } + + public ChannelReceiveFileResponse(int statusCode) { + this.statusCode = statusCode; + } + public static final Creator CREATOR = new AutoCreator(ChannelReceiveFileResponse.class); } diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ChannelSendFileResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ChannelSendFileResponse.java index 09a2cb19a6..f6960c9902 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ChannelSendFileResponse.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/ChannelSendFileResponse.java @@ -22,5 +22,16 @@ public class ChannelSendFileResponse extends AutoSafeParcelable { @SafeParceled(1) private int versionCode = 1; + + @SafeParceled(2) + public int statusCode; + + private ChannelSendFileResponse() { + } + + public ChannelSendFileResponse(int statusCode) { + this.statusCode = statusCode; + } + public static final Creator CREATOR = new AutoCreator(ChannelSendFileResponse.class); } diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/CloseChannelResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/CloseChannelResponse.java index 3520593b35..13f760ab10 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/CloseChannelResponse.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/CloseChannelResponse.java @@ -22,5 +22,16 @@ public class CloseChannelResponse extends AutoSafeParcelable { @SafeParceled(1) private int versionCode = 1; + + @SafeParceled(2) + public int statusCode; + + private CloseChannelResponse() { + } + + public CloseChannelResponse(int statusCode) { + this.statusCode = statusCode; + } + public static final Creator CREATOR = new AutoCreator(CloseChannelResponse.class); } diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetChannelInputStreamResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetChannelInputStreamResponse.java index b5460a4373..e05909a64b 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetChannelInputStreamResponse.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetChannelInputStreamResponse.java @@ -16,11 +16,28 @@ package com.google.android.gms.wearable.internal; +import android.os.ParcelFileDescriptor; + import org.microg.safeparcel.AutoSafeParcelable; import org.microg.safeparcel.SafeParceled; public class GetChannelInputStreamResponse extends AutoSafeParcelable { @SafeParceled(1) private int versionCode = 1; + + @SafeParceled(2) + public int statusCode; + + @SafeParceled(3) + public ParcelFileDescriptor pfd; + + private GetChannelInputStreamResponse() { + } + + public GetChannelInputStreamResponse(int statusCode, ParcelFileDescriptor pfd) { + this.statusCode = statusCode; + this.pfd = pfd; + } + public static final Creator CREATOR = new AutoCreator(GetChannelInputStreamResponse.class); } diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetChannelOutputStreamResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetChannelOutputStreamResponse.java index 71e024e20b..2e4093d6cc 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetChannelOutputStreamResponse.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/GetChannelOutputStreamResponse.java @@ -16,11 +16,28 @@ package com.google.android.gms.wearable.internal; +import android.os.ParcelFileDescriptor; + import org.microg.safeparcel.AutoSafeParcelable; import org.microg.safeparcel.SafeParceled; public class GetChannelOutputStreamResponse extends AutoSafeParcelable { @SafeParceled(1) private int versionCode = 1; + + @SafeParceled(2) + public int statusCode; + + @SafeParceled(3) + public ParcelFileDescriptor pfd; + + private GetChannelOutputStreamResponse() { + } + + public GetChannelOutputStreamResponse(int statusCode, ParcelFileDescriptor pfd) { + this.statusCode = statusCode; + this.pfd = pfd; + } + public static final Creator CREATOR = new AutoCreator(GetChannelOutputStreamResponse.class); } diff --git a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/OpenChannelResponse.java b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/OpenChannelResponse.java index fcd97a228e..a4620c10db 100644 --- a/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/OpenChannelResponse.java +++ b/play-services-wearable/src/main/java/com/google/android/gms/wearable/internal/OpenChannelResponse.java @@ -22,5 +22,20 @@ public class OpenChannelResponse extends AutoSafeParcelable { @SafeParceled(1) private int versionCode = 1; + + @SafeParceled(2) + public int statusCode; + + @SafeParceled(3) + public ChannelParcelable channel; + + private OpenChannelResponse() { + } + + public OpenChannelResponse(int statusCode, ChannelParcelable channel) { + this.statusCode = statusCode; + this.channel = channel; + } + public static final Creator CREATOR = new AutoCreator(OpenChannelResponse.class); } From e26b182fb253a5f58274bd63cc7138b230b7bedf Mon Sep 17 00:00:00 2001 From: Akinniranye Samuel Tomiwa Date: Mon, 9 Mar 2026 18:12:00 +0100 Subject: [PATCH 04/12] feat: wire protocol transport, listener events, and TOS UX --- .../consent/TermsOfServiceActivity.kt | 27 +- .../src/main/res/values/strings.xml | 4 + .../microg/gms/wearable/ChannelManager.java | 493 ++++++++++++++---- .../microg/gms/wearable/MessageHandler.java | 3 +- .../org/microg/gms/wearable/WearableImpl.java | 30 ++ .../gms/wearable/WearableServiceImpl.java | 6 +- 6 files changed, 458 insertions(+), 105 deletions(-) diff --git a/play-services-core/src/main/kotlin/com/google/android/gms/wearable/consent/TermsOfServiceActivity.kt b/play-services-core/src/main/kotlin/com/google/android/gms/wearable/consent/TermsOfServiceActivity.kt index db0b41ea06..20e059ed6f 100644 --- a/play-services-core/src/main/kotlin/com/google/android/gms/wearable/consent/TermsOfServiceActivity.kt +++ b/play-services-core/src/main/kotlin/com/google/android/gms/wearable/consent/TermsOfServiceActivity.kt @@ -5,19 +5,40 @@ package com.google.android.gms.wearable.consent +import android.app.AlertDialog import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import com.google.android.gms.R import org.microg.gms.wearable.WearablePreferences class TermsOfServiceActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (WearablePreferences.isAutoAcceptTosEnabled(this)) { + // User has opted-in to auto-accept; proceed immediately. setResult(RESULT_OK) - } else { - setResult(RESULT_CANCELED) + finish() + return } - finish() + + // Show an explicit dialog so the user can make an informed choice. + AlertDialog.Builder(this) + .setTitle(R.string.wearable_tos_dialog_title) + .setMessage(R.string.wearable_tos_dialog_message) + .setPositiveButton(R.string.wearable_tos_accept) { _, _ -> + setResult(RESULT_OK) + finish() + } + .setNegativeButton(R.string.wearable_tos_decline) { _, _ -> + setResult(RESULT_CANCELED) + finish() + } + .setOnCancelListener { + setResult(RESULT_CANCELED) + finish() + } + .show() } } \ No newline at end of file diff --git a/play-services-core/src/main/res/values/strings.xml b/play-services-core/src/main/res/values/strings.xml index 34a5d56856..3fc1dfbe7b 100644 --- a/play-services-core/src/main/res/values/strings.xml +++ b/play-services-core/src/main/res/values/strings.xml @@ -461,4 +461,8 @@ Please set up a password, PIN, or pattern lock screen." Terms of Service Auto-accept Wearable TOS Automatically accept the Wearable Terms of Service when requested. Disabled by default. + Wearable Terms of Service + A Wearable device is requesting permission to pair with this phone. Do you accept the Terms of Service? + Accept + Decline diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ChannelManager.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ChannelManager.java index e581edda11..1967c7e8c1 100644 --- a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ChannelManager.java +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ChannelManager.java @@ -19,80 +19,156 @@ import android.os.ParcelFileDescriptor; import android.util.Log; +import com.google.android.gms.wearable.internal.ChannelEventParcelable; import com.google.android.gms.wearable.internal.ChannelParcelable; +import org.microg.wearable.proto.ChannelControlRequest; +import org.microg.wearable.proto.ChannelDataAckRequest; +import org.microg.wearable.proto.ChannelDataHeader; +import org.microg.wearable.proto.ChannelDataRequest; +import org.microg.wearable.proto.ChannelRequest; +import org.microg.wearable.proto.Request; + import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.Map; -import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +import okio.ByteString; /** * Manages the lifecycle and I/O for Wearable Channel API channels. *

- * Each channel is identified by a unique token and tracks its associated node, - * path, and pipe-based file descriptors for bidirectional streaming. + * Uses the {@link ChannelControlRequest}, {@link ChannelDataRequest}, and + * {@link ChannelDataAckRequest} protocol messages to open, transfer data through, + * and close channels with peer WearOS nodes. */ public class ChannelManager { private static final String TAG = "GmsWearChannelMgr"; - private final Map channels = new ConcurrentHashMap<>(); + /** ChannelControlRequest.type values */ + private static final int CONTROL_TYPE_OPEN = 1; + private static final int CONTROL_TYPE_CLOSE = 2; + + /** ChannelEventParcelable.eventType values */ + private static final int EVENT_TYPE_OPENED = 1; + private static final int EVENT_TYPE_CLOSED = 2; + private static final int EVENT_TYPE_INPUT_CLOSED = 3; + private static final int EVENT_TYPE_OUTPUT_CLOSED = 4; + + private static final int CHUNK_SIZE = 8192; + + /** Maps channelId (Long) to ChannelState */ + private final Map channels = new ConcurrentHashMap<>(); + /** Maps String token (= Long.toString(channelId)) to channelId for the public API */ + private final Map tokenToId = new ConcurrentHashMap<>(); + private final WearableImpl wearable; + private final AtomicLong nextChannelId = new AtomicLong(1); public ChannelManager(WearableImpl wearable) { this.wearable = wearable; } + // ------------------------------------------------------------------------- + // Public API called from WearableServiceImpl + // ------------------------------------------------------------------------- + /** * Opens a new channel to the given node on the given path. + * Sends a {@link ChannelControlRequest} OPEN message to the peer and returns a + * {@link ChannelParcelable} token the caller can use for subsequent operations. * - * @param targetNodeId the node to open the channel to - * @param path the application-specific path for the channel - * @return a ChannelParcelable representing the opened channel, or null on failure + * @param targetNodeId the peer node to open the channel to + * @param path the application-specific channel path + * @param packageName calling app's package name (included in the protocol message) + * @param signatureDigest SHA-1 digest of the calling app's signing certificate + * @return a {@link ChannelParcelable}, or {@code null} on failure */ - public ChannelParcelable openChannel(String targetNodeId, String path) { - if (!wearable.getAllConnectedNodes().contains(targetNodeId)) { - Log.w(TAG, "Cannot open channel: node " + targetNodeId + " is not connected"); + public ChannelParcelable openChannel(String targetNodeId, String path, + String packageName, String signatureDigest) { + if (!wearable.hasActiveConnection(targetNodeId)) { + Log.w(TAG, "openChannel: node " + targetNodeId + " is not connected"); return null; } - String token = UUID.randomUUID().toString(); - ChannelState state = new ChannelState(token, targetNodeId, path); - channels.put(token, state); + long channelId = nextChannelId.getAndIncrement(); + String token = Long.toString(channelId); + + ChannelState state = new ChannelState(token, channelId, targetNodeId, path); + channels.put(channelId, state); + tokenToId.put(token, channelId); + + try { + sendChannelControl(targetNodeId, new ChannelControlRequest.Builder() + .type(CONTROL_TYPE_OPEN) + .channelId(channelId) + .fromChannelOperator(true) + .packageName(packageName) + .signatureDigest(signatureDigest) + .path(path) + .build()); + } catch (IOException e) { + Log.e(TAG, "openChannel: failed to send OPEN for channel " + channelId, e); + channels.remove(channelId); + tokenToId.remove(token); + return null; + } Log.d(TAG, "Opened channel: token=" + token + ", node=" + targetNodeId + ", path=" + path); return new ChannelParcelable(token, targetNodeId, path); } /** - * Closes the channel identified by the given token. + * Closes the channel identified by {@code token}. + * Sends a {@link ChannelControlRequest} CLOSE message to the peer and notifies + * registered listeners with a {@link ChannelEventParcelable} CLOSED event. * - * @param token the channel token - * @param errorCode error code (0 for normal close) - * @return true if the channel was found and closed + * @param token the channel token returned from {@link #openChannel} + * @param errorCode 0 for a normal close, non-zero for an error close + * @return {@code true} if the channel was found and closed */ public boolean closeChannel(String token, int errorCode) { - ChannelState state = channels.remove(token); + Long channelId = tokenToId.remove(token); + if (channelId == null) { + Log.w(TAG, "closeChannel: unknown token " + token); + return false; + } + ChannelState state = channels.remove(channelId); if (state == null) { - Log.w(TAG, "Cannot close channel: unknown token " + token); + Log.w(TAG, "closeChannel: no state for channelId " + channelId); return false; } + + try { + sendChannelControl(state.nodeId, new ChannelControlRequest.Builder() + .type(CONTROL_TYPE_CLOSE) + .channelId(channelId) + .fromChannelOperator(true) + .closeErrorCode(errorCode) + .build()); + } catch (IOException e) { + Log.w(TAG, "closeChannel: failed to send CLOSE for channel " + channelId, e); + } + state.close(); + dispatchChannelEvent(state, EVENT_TYPE_CLOSED, errorCode, 0); Log.d(TAG, "Closed channel: token=" + token + ", errorCode=" + errorCode); return true; } /** - * Gets a ParcelFileDescriptor for reading data from the channel (input stream). - * Creates a pipe pair; the caller reads from the returned PFD, and data received - * from the peer is written to the other end. + * Returns the read-end {@link ParcelFileDescriptor} for the channel's input stream. + * The caller reads data from it; incoming data from the peer is written to the + * write-end by {@link #handleIncomingData}. * * @param token the channel token - * @return the read-end ParcelFileDescriptor, or null if the channel doesn't exist + * @return the read-end PFD, or {@code null} if the channel does not exist */ public ParcelFileDescriptor getInputStream(String token) { - ChannelState state = channels.get(token); + ChannelState state = stateForToken(token); if (state == null) { Log.w(TAG, "getInputStream: unknown channel " + token); return null; @@ -101,24 +177,23 @@ public ParcelFileDescriptor getInputStream(String token) { if (state.inputPipe == null) { state.inputPipe = ParcelFileDescriptor.createPipe(); } - // Return the read end (index 0) to the caller - return state.inputPipe[0]; + return state.inputPipe[0]; // read end } catch (IOException e) { - Log.e(TAG, "Failed to create input pipe for channel " + token, e); + Log.e(TAG, "getInputStream: failed to create pipe for channel " + token, e); return null; } } /** - * Gets a ParcelFileDescriptor for writing data to the channel (output stream). - * Creates a pipe pair; the caller writes to the returned PFD, and data is sent - * to the peer from the other end. + * Returns the write-end {@link ParcelFileDescriptor} for the channel's output stream + * and starts a background forwarder thread that reads from the pipe and sends + * {@link ChannelDataRequest} messages to the peer. * * @param token the channel token - * @return the write-end ParcelFileDescriptor, or null if the channel doesn't exist + * @return the write-end PFD, or {@code null} if the channel does not exist */ public ParcelFileDescriptor getOutputStream(String token) { - ChannelState state = channels.get(token); + ChannelState state = stateForToken(token); if (state == null) { Log.w(TAG, "getOutputStream: unknown channel " + token); return null; @@ -126,25 +201,25 @@ public ParcelFileDescriptor getOutputStream(String token) { try { if (state.outputPipe == null) { state.outputPipe = ParcelFileDescriptor.createPipe(); + startOutputForwarder(state); } - // Return the write end (index 1) to the caller - return state.outputPipe[1]; + return state.outputPipe[1]; // write end for caller } catch (IOException e) { - Log.e(TAG, "Failed to create output pipe for channel " + token, e); + Log.e(TAG, "getOutputStream: failed to create pipe for channel " + token, e); return null; } } /** - * Writes data received from the channel's incoming stream to the given file descriptor. - * This bridges the channel's pipe to an external FD for file-based receive operations. + * Pipes data from the channel's input stream into the given file descriptor. + * A background thread copies data written to the input pipe (by the peer) into {@code fd}. * * @param token the channel token - * @param fd the target file descriptor to write to - * @return true if the operation was initiated + * @param fd the destination file descriptor + * @return {@code true} if the background copy was started */ public boolean writeInputToFd(String token, ParcelFileDescriptor fd) { - ChannelState state = channels.get(token); + ChannelState state = stateForToken(token); if (state == null) { Log.w(TAG, "writeInputToFd: unknown channel " + token); return false; @@ -153,39 +228,39 @@ public boolean writeInputToFd(String token, ParcelFileDescriptor fd) { if (state.inputPipe == null) { state.inputPipe = ParcelFileDescriptor.createPipe(); } - // Copy from the read end of the input pipe to the given FD in a background thread - ParcelFileDescriptor readEnd = state.inputPipe[0]; + final ParcelFileDescriptor readEnd = state.inputPipe[0]; new Thread(() -> { try (InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(readEnd); OutputStream out = new ParcelFileDescriptor.AutoCloseOutputStream(fd)) { - byte[] buffer = new byte[8192]; - int bytesRead; - while ((bytesRead = in.read(buffer)) != -1) { - out.write(buffer, 0, bytesRead); + byte[] buf = new byte[CHUNK_SIZE]; + int n; + while ((n = in.read(buf)) != -1) { + out.write(buf, 0, n); } } catch (IOException e) { - Log.e(TAG, "Error in writeInputToFd for channel " + token, e); + Log.e(TAG, "writeInputToFd error for channel " + token, e); } - }, "ChannelInputToFd-" + token).start(); + }, "WearChanIn-" + token).start(); return true; } catch (IOException e) { - Log.e(TAG, "Failed to create input pipe for writeInputToFd", e); + Log.e(TAG, "writeInputToFd: failed to create pipe", e); return false; } } /** - * Reads data from the given file descriptor and sends it through the channel's output stream. - * This bridges an external FD to the channel's pipe for file-based send operations. + * Reads from the given file descriptor (with optional offset/length) and sends the + * data through the channel's output stream to the peer. * * @param token the channel token - * @param fd the source file descriptor to read from - * @param startOffset the offset to start reading from (-1 for beginning) - * @param length the number of bytes to read (-1 for entire file) - * @return true if the operation was initiated + * @param fd the source file descriptor + * @param startOffset byte offset to start reading from (0 = beginning) + * @param length maximum bytes to send (-1 or 0 = until EOF) + * @return {@code true} if the background send was started */ - public boolean readOutputFromFd(String token, ParcelFileDescriptor fd, long startOffset, long length) { - ChannelState state = channels.get(token); + public boolean readOutputFromFd(String token, ParcelFileDescriptor fd, + long startOffset, long length) { + ChannelState state = stateForToken(token); if (state == null) { Log.w(TAG, "readOutputFromFd: unknown channel " + token); return false; @@ -193,89 +268,311 @@ public boolean readOutputFromFd(String token, ParcelFileDescriptor fd, long star try { if (state.outputPipe == null) { state.outputPipe = ParcelFileDescriptor.createPipe(); + startOutputForwarder(state); } - // Copy from the given FD to the write end of the output pipe in a background thread - ParcelFileDescriptor writeEnd = state.outputPipe[1]; + final ParcelFileDescriptor writeEnd = state.outputPipe[1]; new Thread(() -> { try (InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(fd); OutputStream out = new ParcelFileDescriptor.AutoCloseOutputStream(writeEnd)) { if (startOffset > 0) { long skipped = in.skip(startOffset); if (skipped < startOffset) { - Log.w(TAG, "Could only skip " + skipped + "/" + startOffset + " bytes"); + Log.w(TAG, "readOutputFromFd: only skipped " + skipped + "/" + startOffset); } } - byte[] buffer = new byte[8192]; - long totalRead = 0; - int bytesRead; - while ((bytesRead = in.read(buffer)) != -1) { - if (length > 0 && totalRead + bytesRead > length) { - out.write(buffer, 0, (int) (length - totalRead)); + byte[] buf = new byte[CHUNK_SIZE]; + long total = 0; + int n; + while ((n = in.read(buf)) != -1) { + if (length > 0 && total + n > length) { + out.write(buf, 0, (int) (length - total)); break; } - out.write(buffer, 0, bytesRead); - totalRead += bytesRead; + out.write(buf, 0, n); + total += n; } } catch (IOException e) { - Log.e(TAG, "Error in readOutputFromFd for channel " + token, e); + Log.e(TAG, "readOutputFromFd error for channel " + token, e); } - }, "ChannelOutputFromFd-" + token).start(); + }, "WearChanOut-" + token).start(); return true; } catch (IOException e) { - Log.e(TAG, "Failed to create output pipe for readOutputFromFd", e); + Log.e(TAG, "readOutputFromFd: failed to create pipe", e); return false; } } + // ------------------------------------------------------------------------- + // Incoming message handling (called from MessageHandler) + // ------------------------------------------------------------------------- + /** - * Handles an incoming channel request from a peer node. - * Called by MessageHandler when the peer sends a channel-related message. + * Dispatches an incoming {@link Request} from the peer that was received on the + * {@code channelRequest} field of a {@link org.microg.wearable.proto.RootMessage}. + *

+ * Handles channel open, close, data, and data-ack sub-messages. * - * @param sourceNodeId the node that sent the request - * @param path the channel path - * @param token the channel token (may be null for new channel open requests) + * @param sourceNodeId the peer node that sent the message + * @param request the channel request proto */ - public void handleIncomingChannelRequest(String sourceNodeId, String path, String token) { - if (token == null) { - // Peer is opening a channel to us - token = UUID.randomUUID().toString(); - ChannelState state = new ChannelState(token, sourceNodeId, path); - channels.put(token, state); - Log.d(TAG, "Accepted incoming channel from " + sourceNodeId + ": path=" + path + ", token=" + token); + public void handleIncomingChannelMessage(String sourceNodeId, Request request) { + if (request == null || request.request == null) { + Log.w(TAG, "handleIncomingChannelMessage: null or empty request from " + sourceNodeId); + return; } - // TODO: Notify listeners about the channel event + ChannelRequest cr = request.request; + if (cr.channelControlRequest != null) { + handleIncomingControl(sourceNodeId, request.path, cr.channelControlRequest); + } else if (cr.channelDataRequest != null) { + handleIncomingData(cr.channelDataRequest); + } else if (cr.channelDataAckRequest != null) { + handleIncomingDataAck(cr.channelDataAckRequest); + } else { + Log.w(TAG, "handleIncomingChannelMessage: unrecognised sub-message from " + sourceNodeId); + } + } + + /** + * Legacy entry point preserved for callers that do not yet pass the full + * {@link Request}. Treats every call as a new channel-open request from the peer. + * + * @deprecated Use {@link #handleIncomingChannelMessage(String, Request)} instead. + */ + @Deprecated + public void handleIncomingChannelRequest(String sourceNodeId, String path, String token) { + long channelId = nextChannelId.getAndIncrement(); + String newToken = Long.toString(channelId); + ChannelState state = new ChannelState(newToken, channelId, sourceNodeId, path); + channels.put(channelId, state); + tokenToId.put(newToken, channelId); + Log.d(TAG, "Accepted incoming channel (legacy) from " + sourceNodeId + + ": path=" + path + ", token=" + newToken); + dispatchChannelEvent(state, EVENT_TYPE_OPENED, 0, 0); } /** - * Returns true if the given token corresponds to an open channel. + * Returns {@code true} if the given token corresponds to an open channel. */ public boolean isChannelOpen(String token) { - return channels.containsKey(token); + Long id = tokenToId.get(token); + return id != null && channels.containsKey(id); } /** * Closes all open channels. Called during service shutdown. */ public void closeAll() { - for (Map.Entry entry : channels.entrySet()) { - entry.getValue().close(); + for (ChannelState state : channels.values()) { + state.close(); } channels.clear(); + tokenToId.clear(); Log.d(TAG, "All channels closed"); } + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private ChannelState stateForToken(String token) { + Long id = tokenToId.get(token); + return id != null ? channels.get(id) : null; + } + + private void handleIncomingControl(String sourceNodeId, String requestPath, + ChannelControlRequest ctrl) { + int type = ctrl.type != null ? ctrl.type : 0; + long channelId = ctrl.channelId != null ? ctrl.channelId : -1L; + String path = ctrl.path != null ? ctrl.path : requestPath; + + if (type == CONTROL_TYPE_OPEN) { + String token = Long.toString(channelId); + ChannelState state = new ChannelState(token, channelId, sourceNodeId, path); + channels.put(channelId, state); + tokenToId.put(token, channelId); + Log.d(TAG, "Peer opened channel: id=" + channelId + ", path=" + path + + ", from=" + sourceNodeId); + dispatchChannelEvent(state, EVENT_TYPE_OPENED, 0, 0); + + } else if (type == CONTROL_TYPE_CLOSE) { + String token = Long.toString(channelId); + tokenToId.remove(token); + ChannelState state = channels.remove(channelId); + if (state != null) { + int errorCode = ctrl.closeErrorCode != null ? ctrl.closeErrorCode : 0; + state.close(); + dispatchChannelEvent(state, EVENT_TYPE_CLOSED, errorCode, 0); + Log.d(TAG, "Peer closed channel: id=" + channelId + ", errorCode=" + errorCode); + } + } else { + Log.w(TAG, "handleIncomingControl: unknown type=" + type); + } + } + + private void handleIncomingData(ChannelDataRequest data) { + if (data.header == null) { + Log.w(TAG, "handleIncomingData: null header"); + return; + } + long channelId = data.header.channelId != null ? data.header.channelId : -1L; + ChannelState state = channels.get(channelId); + if (state == null) { + Log.w(TAG, "handleIncomingData: unknown channelId " + channelId); + return; + } + try { + if (state.inputPipe == null) { + state.inputPipe = ParcelFileDescriptor.createPipe(); + } + // Write payload into the write-end of the input pipe so the app can read it + if (data.payload != null && data.payload.size() > 0) { + try (OutputStream out = + new ParcelFileDescriptor.AutoCloseOutputStream(state.inputPipe[1])) { + out.write(data.payload.toByteArray()); + // Do not close the pipe here; keep it open for subsequent data chunks + } catch (IOException e) { + // Re-create a fresh pipe on error so subsequent data isn't lost + state.inputPipe = null; + Log.e(TAG, "handleIncomingData: write error for channel " + channelId, e); + return; + } + } + if (Boolean.TRUE.equals(data.finalMessage)) { + // Peer has finished sending; close the write-end to signal EOF to the reader + if (state.inputPipe != null && state.inputPipe[1] != null) { + try { state.inputPipe[1].close(); } catch (IOException ignored) { } + state.inputPipe[1] = null; + } + dispatchChannelEvent(state, EVENT_TYPE_INPUT_CLOSED, 0, 0); + } + } catch (IOException e) { + Log.e(TAG, "handleIncomingData: pipe creation failed for channel " + channelId, e); + } + } + + private void handleIncomingDataAck(ChannelDataAckRequest ack) { + if (ack.header == null) return; + long channelId = ack.header.channelId != null ? ack.header.channelId : -1L; + Log.d(TAG, "handleIncomingDataAck: channelId=" + channelId + + ", final=" + ack.finalMessage); + if (Boolean.TRUE.equals(ack.finalMessage)) { + ChannelState state = channels.get(channelId); + if (state != null) { + dispatchChannelEvent(state, EVENT_TYPE_OUTPUT_CLOSED, 0, 0); + } + } + } + /** - * Tracks the state of a single open channel, including its pipe file descriptors. + * Starts a background thread that reads from {@code state.outputPipe[0]} and + * forwards data to the peer as {@link ChannelDataRequest} messages. + *

+ * Precondition: {@code state.outputPipe} must already be initialised. */ + private void startOutputForwarder(ChannelState state) { + final ParcelFileDescriptor readEnd = state.outputPipe[0]; + new Thread(() -> { + try (InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(readEnd)) { + byte[] buf = new byte[CHUNK_SIZE]; + int n; + long requestId = 0; + while ((n = in.read(buf)) != -1) { + byte[] chunk = new byte[n]; + System.arraycopy(buf, 0, chunk, 0, n); + ChannelDataRequest dataMsg = new ChannelDataRequest.Builder() + .header(new ChannelDataHeader.Builder() + .channelId(state.channelId) + .fromChannelOperator(true) + .requestId(requestId++) + .build()) + .payload(ByteString.of(chunk)) + .finalMessage(false) + .build(); + try { + wearable.sendChannelMessage(state.nodeId, + new Request.Builder() + .path(state.path) + .sourceNodeId(wearable.getLocalNodeId()) + .targetNodeId(state.nodeId) + .request(new ChannelRequest.Builder() + .channelDataRequest(dataMsg) + .build()) + .build()); + } catch (IOException e) { + Log.e(TAG, "Output forwarder: send error for channel " + + state.channelId, e); + break; + } + } + // EOF — send a final message to signal the peer that the stream is done + try { + wearable.sendChannelMessage(state.nodeId, + new Request.Builder() + .path(state.path) + .sourceNodeId(wearable.getLocalNodeId()) + .targetNodeId(state.nodeId) + .request(new ChannelRequest.Builder() + .channelDataRequest(new ChannelDataRequest.Builder() + .header(new ChannelDataHeader.Builder() + .channelId(state.channelId) + .fromChannelOperator(true) + .requestId(-1L) + .build()) + .payload(ByteString.EMPTY) + .finalMessage(true) + .build()) + .build()) + .build()); + } catch (IOException e) { + Log.w(TAG, "Output forwarder: failed to send final for channel " + + state.channelId, e); + } + dispatchChannelEvent(state, EVENT_TYPE_OUTPUT_CLOSED, 0, 0); + } catch (IOException e) { + Log.e(TAG, "Output forwarder: pipe read error for channel " + + state.channelId, e); + } + }, "WearChanFwd-" + state.channelId).start(); + } + + private void sendChannelControl(String targetNodeId, ChannelControlRequest ctrl) + throws IOException { + wearable.sendChannelMessage(targetNodeId, + new Request.Builder() + .path(ctrl.path != null ? ctrl.path : "") + .sourceNodeId(wearable.getLocalNodeId()) + .targetNodeId(targetNodeId) + .request(new ChannelRequest.Builder() + .channelControlRequest(ctrl) + .build()) + .build()); + } + + private void dispatchChannelEvent(ChannelState state, int eventType, + int closeReason, int appSpecificErrorCode) { + ChannelEventParcelable event = new ChannelEventParcelable(); + event.channel = new ChannelParcelable(state.token, state.nodeId, state.path); + event.eventType = eventType; + event.closeReason = closeReason; + event.appSpecificErrorCode = appSpecificErrorCode; + wearable.invokeChannelEvent(event); + } + + // ------------------------------------------------------------------------- + // ChannelState + // ------------------------------------------------------------------------- + private static class ChannelState { final String token; + final long channelId; final String nodeId; final String path; - ParcelFileDescriptor[] inputPipe; // [0]=read, [1]=write - ParcelFileDescriptor[] outputPipe; // [0]=read, [1]=write + ParcelFileDescriptor[] inputPipe; // [0]=read end (given to app), [1]=write end (filled by network) + ParcelFileDescriptor[] outputPipe; // [0]=read end (read by forwarder), [1]=write end (given to app) - ChannelState(String token, String nodeId, String path) { + ChannelState(String token, long channelId, String nodeId, String path) { this.token = token; + this.channelId = channelId; this.nodeId = nodeId; this.path = path; } @@ -288,14 +585,10 @@ void close() { } private static void closePipe(ParcelFileDescriptor[] pipe) { - if (pipe != null) { - try { - if (pipe[0] != null) pipe[0].close(); - } catch (IOException ignored) { - } - try { - if (pipe[1] != null) pipe[1].close(); - } catch (IOException ignored) { + if (pipe == null) return; + for (ParcelFileDescriptor fd : pipe) { + if (fd != null) { + try { fd.close(); } catch (IOException ignored) { } } } } diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MessageHandler.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MessageHandler.java index 938981333e..ece322bed3 100644 --- a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MessageHandler.java +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MessageHandler.java @@ -177,6 +177,7 @@ public void onFilePiece(FilePiece filePiece) { public void onChannelRequest(Request channelRequest) { Log.d(TAG, "onChannelRequest:" + channelRequest); String sourceNodeId = channelRequest.sourceNodeId != null ? channelRequest.sourceNodeId : peerNodeId; - wearable.getChannelManager().handleIncomingChannelRequest(sourceNodeId, channelRequest.path, null); + // Route to the full handler so open/data/close sub-messages are all processed + wearable.getChannelManager().handleIncomingChannelMessage(sourceNodeId, channelRequest); } } 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 e5287635a1..2c3885ee65 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 @@ -56,6 +56,8 @@ import org.microg.wearable.proto.SetAsset; import org.microg.wearable.proto.SetDataItem; +import com.google.android.gms.wearable.internal.ChannelEventParcelable; + import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; @@ -402,6 +404,34 @@ public Set getAllConnectedNodes() { return new HashSet<>(activeConnections.keySet()); } + /** + * Returns {@code true} if there is an active connection to the given node. + */ + public boolean hasActiveConnection(String nodeId) { + return activeConnections.containsKey(nodeId); + } + + /** + * Sends a {@link Request} as a {@code channelRequest} message to the given node. + * + * @throws IOException if the node is not connected or the write fails + */ + public void sendChannelMessage(String targetNodeId, Request request) throws IOException { + WearableConnection connection = activeConnections.get(targetNodeId); + if (connection == null) { + throw new IOException("No active connection to node " + targetNodeId); + } + connection.writeMessage(new RootMessage.Builder().channelRequest(request).build()); + } + + /** + * Dispatches a {@link ChannelEventParcelable} to all registered + * {@link com.google.android.gms.wearable.internal.IWearableListener}s. + */ + public void invokeChannelEvent(ChannelEventParcelable event) { + invokeListeners(null, listener -> listener.onChannelEvent(event)); + } + /** * Returns the ChannelManager for managing channel operations. */ 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 9cb7495499..f37ac15c25 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 @@ -30,6 +30,8 @@ import com.google.android.gms.wearable.ConnectionConfiguration; import com.google.android.gms.wearable.internal.*; +import org.microg.gms.common.PackageUtils; + import java.io.FileNotFoundException; import java.util.ArrayList; import java.util.List; @@ -387,7 +389,9 @@ public void doAncsNegativeAction(IWearableCallbacks callbacks, int i) throws Rem public void openChannel(IWearableCallbacks callbacks, String targetNodeId, String path) throws RemoteException { Log.d(TAG, "openChannel: " + targetNodeId + ", " + path); postMain(callbacks, () -> { - ChannelParcelable channel = wearable.getChannelManager().openChannel(targetNodeId, path); + String signatureDigest = PackageUtils.firstSignatureDigest(context, packageName); + ChannelParcelable channel = wearable.getChannelManager() + .openChannel(targetNodeId, path, packageName, signatureDigest); if (channel != null) { callbacks.onOpenChannelResponse(new OpenChannelResponse(0, channel)); } else { From 5686ca7a4bbef8672c43345fa0dd218fb87d3f71 Mon Sep 17 00:00:00 2001 From: Akinniranye Samuel Tomiwa Date: Tue, 10 Mar 2026 13:36:41 +0100 Subject: [PATCH 05/12] Wearable: fix incoming data pipe closure bug; add notification bridging and ANCS action dispatch --- .../src/main/AndroidManifest.xml | 9 + .../WearableNotificationService.kt | 162 ++++++++++++++++++ .../microg/gms/wearable/ChannelManager.java | 10 +- .../gms/wearable/NotificationBridge.java | 90 ++++++++++ .../microg/gms/wearable/WearableService.java | 14 ++ .../gms/wearable/WearableServiceImpl.java | 10 +- 6 files changed, 287 insertions(+), 8 deletions(-) create mode 100644 play-services-core/src/main/kotlin/org/microg/gms/wearable/notification/WearableNotificationService.kt create mode 100644 play-services-wearable/core/src/main/java/org/microg/gms/wearable/NotificationBridge.java diff --git a/play-services-core/src/main/AndroidManifest.xml b/play-services-core/src/main/AndroidManifest.xml index 66038da7e2..79726804b1 100644 --- a/play-services-core/src/main/AndroidManifest.xml +++ b/play-services-core/src/main/AndroidManifest.xml @@ -462,6 +462,15 @@ + + + + + + () + + override fun onNotificationPosted(sbn: StatusBarNotification) { + if (shouldSkip(sbn)) return + + // Assign a stable, collision-free UID for this notification. + val uid = keyToUid.getOrPut(sbn.key) { uidCounter.getAndIncrement() } + NotificationBridge.activeNotifications[uid] = sbn + + val payload = encodeNotification(uid, sbn) ?: return + sendToWearable(payload) + } + + override fun onNotificationRemoved(sbn: StatusBarNotification) { + val uid = keyToUid.remove(sbn.key) ?: return + NotificationBridge.activeNotifications.remove(uid) + + // Notify peers that this notification was dismissed + val payload = encodeRemoval(uid, sbn.key) ?: return + sendToWearable(payload) + } + + // ------------------------------------------------------------------------- + + private fun shouldSkip(sbn: StatusBarNotification): Boolean { + if (sbn.packageName == packageName) return true + val flags = sbn.notification?.flags ?: return true + if (flags and Notification.FLAG_ONGOING_EVENT != 0) return true + if (flags and Notification.FLAG_NO_CLEAR != 0) return true + return false + } + + private fun sendToWearable(payload: ByteArray) { + val wearable = WearableService.getInstance() ?: run { + Log.d(TAG, "WearableService not running, skipping notification forward") + return + } + val connectedNodes = wearable.allConnectedNodes + if (connectedNodes.isEmpty()) { + Log.d(TAG, "No connected wearable nodes, skipping notification forward") + return + } + for (nodeId in connectedNodes) { + val result = wearable.sendMessage(packageName, nodeId, NOTIFICATION_PATH, payload) + if (result < 0) { + Log.w(TAG, "sendMessage to $nodeId failed (result=$result)") + } + } + } +} + +// ------------------------------------------------------------------------- +// Encoding helpers +// ------------------------------------------------------------------------- + +/** + * Encodes a posted notification as: + * - byte type = 1 (posted) + * - int uid + * - UTF packageName + * - UTF key + * - UTF title (empty string if absent) + * - UTF text (empty string if absent) + * - long timestamp + * - int actionCount + * - UTF[] action titles + */ +internal fun encodeNotification(uid: Int, sbn: StatusBarNotification): ByteArray? { + return try { + val n = sbn.notification ?: return null + val extras = n.extras + val title = extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString() ?: "" + val text = (extras?.getCharSequence(Notification.EXTRA_BIG_TEXT) + ?: extras?.getCharSequence(Notification.EXTRA_TEXT))?.toString() ?: "" + val actions: Array = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + n.actions ?: emptyArray() + } else { + emptyArray() + } + + ByteArrayOutputStream().also { baos -> + DataOutputStream(baos).use { dos -> + dos.writeByte(1) // type: posted + dos.writeInt(uid) + dos.writeUTF(sbn.packageName) + dos.writeUTF(sbn.key) + dos.writeUTF(title) + dos.writeUTF(text) + dos.writeLong(sbn.postTime) + dos.writeInt(actions.size) + for (action in actions) { + dos.writeUTF(action.title?.toString() ?: "") + } + } + }.toByteArray() + } catch (e: Exception) { + Log.e(TAG, "Failed to encode notification", e) + null + } +} + +/** + * Encodes a removal event as: + * - byte type = 2 (removed) + * - int uid + * - UTF key + */ +internal fun encodeRemoval(uid: Int, key: String): ByteArray? { + return try { + ByteArrayOutputStream().also { baos -> + DataOutputStream(baos).use { dos -> + dos.writeByte(2) // type: removed + dos.writeInt(uid) + dos.writeUTF(key) + } + }.toByteArray() + } catch (e: Exception) { + Log.e(TAG, "Failed to encode notification removal", e) + null + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ChannelManager.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ChannelManager.java index 1967c7e8c1..aa62f59c9d 100644 --- a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ChannelManager.java +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ChannelManager.java @@ -29,6 +29,7 @@ import org.microg.wearable.proto.ChannelRequest; import org.microg.wearable.proto.Request; +import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -424,12 +425,13 @@ private void handleIncomingData(ChannelDataRequest data) { if (state.inputPipe == null) { state.inputPipe = ParcelFileDescriptor.createPipe(); } - // Write payload into the write-end of the input pipe so the app can read it + // Write payload into the write-end of the input pipe so the app can read it. + // Use FileOutputStream over the raw FileDescriptor so the PFD is NOT closed + // when the stream is closed — the pipe must remain open for subsequent chunks. if (data.payload != null && data.payload.size() > 0) { - try (OutputStream out = - new ParcelFileDescriptor.AutoCloseOutputStream(state.inputPipe[1])) { + try (OutputStream out = new FileOutputStream( + state.inputPipe[1].getFileDescriptor())) { out.write(data.payload.toByteArray()); - // Do not close the pipe here; keep it open for subsequent data chunks } catch (IOException e) { // Re-create a fresh pipe on error so subsequent data isn't lost state.inputPipe = null; diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/NotificationBridge.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/NotificationBridge.java new file mode 100644 index 0000000000..e11c105702 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/NotificationBridge.java @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wearable; + +import android.app.Notification; +import android.app.NotificationManager; +import android.content.Context; +import android.os.Build; +import android.service.notification.StatusBarNotification; +import android.util.Log; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Shared bridge between {@link WearableServiceImpl} and the in-process + * {@code WearableNotificationService} (play-services-core). + *

+ * {@code WearableNotificationService} populates {@link #activeNotifications} + * so that ANCS action requests from a Wear OS peer can be dispatched to the + * correct Android notification. + */ +public class NotificationBridge { + + private static final String TAG = "GmsWearNotifBridge"; + + /** + * Maps notification UID (the value sent to the wearable peer) to the live + * {@link StatusBarNotification}. Entries are added/removed by the + * {@code WearableNotificationService} running in the same process. + */ + public static final Map activeNotifications = + new ConcurrentHashMap<>(); + + /** + * Executes the positive ANCS action for {@code uid}: fires the first + * {@link android.app.Notification.Action} on the notification if one exists. + */ + public static void doPositiveAction(Context context, int uid) { + StatusBarNotification sbn = activeNotifications.get(uid); + if (sbn == null) { + Log.d(TAG, "doPositiveAction: no notification for uid=" + uid); + return; + } + Notification n = sbn.getNotification(); + if (n == null) return; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + Notification.Action[] actions = n.actions; + if (actions != null && actions.length > 0 && actions[0].actionIntent != null) { + try { + actions[0].actionIntent.send(context, 0, null); + } catch (Exception e) { + Log.w(TAG, "doPositiveAction: PendingIntent.send() failed", e); + } + return; + } + } + // No action available — fall back to content intent + if (n.contentIntent != null) { + try { + n.contentIntent.send(context, 0, null); + } catch (Exception e) { + Log.w(TAG, "doPositiveAction: contentIntent.send() failed", e); + } + } + } + + /** + * Executes the negative ANCS action for {@code uid}: dismisses / cancels + * the notification. + */ + public static void doNegativeAction(Context context, int uid) { + StatusBarNotification sbn = activeNotifications.get(uid); + if (sbn == null) { + Log.d(TAG, "doNegativeAction: no notification for uid=" + uid); + return; + } + NotificationManager nm = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (nm != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + nm.cancel(sbn.getTag(), sbn.getId()); + } + } + activeNotifications.remove(uid); + } +} 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..3e6f5f3f50 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 @@ -25,9 +25,21 @@ import org.microg.gms.common.GmsService; import org.microg.gms.common.PackageUtils; +import java.util.concurrent.atomic.AtomicReference; + public class WearableService extends BaseService { private WearableImpl wearable; + private static final AtomicReference sInstance = new AtomicReference<>(); + + /** + * Returns the running {@link WearableImpl} instance, or {@code null} if the service + * has not been started yet. Intended for in-process components such as + * {@link org.microg.gms.wearable.notification.WearableNotificationService}. + */ + public static WearableImpl getInstance() { + return sInstance.get(); + } public WearableService() { super("GmsWearSvc", GmsService.WEAR); @@ -39,10 +51,12 @@ public void onCreate() { ConfigurationDatabaseHelper configurationDatabaseHelper = new ConfigurationDatabaseHelper(getApplicationContext()); NodeDatabaseHelper nodeDatabaseHelper = new NodeDatabaseHelper(getApplicationContext()); wearable = new WearableImpl(getApplicationContext(), nodeDatabaseHelper, configurationDatabaseHelper); + sInstance.set(wearable); } @Override public void onDestroy() { + sInstance.set(null); super.onDestroy(); 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 f37ac15c25..9e82e31e6a 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 @@ -377,14 +377,20 @@ public void injectAncsNotificationForTesting(IWearableCallbacks callbacks, AncsN @Override public void doAncsPositiveAction(IWearableCallbacks callbacks, int i) throws RemoteException { + NotificationBridge.doPositiveAction(context, i); callbacks.onStatus(Status.SUCCESS); } @Override public void doAncsNegativeAction(IWearableCallbacks callbacks, int i) throws RemoteException { + NotificationBridge.doNegativeAction(context, i); callbacks.onStatus(Status.SUCCESS); } + /* + * Channels + */ + @Override public void openChannel(IWearableCallbacks callbacks, String targetNodeId, String path) throws RemoteException { Log.d(TAG, "openChannel: " + targetNodeId + ", " + path); @@ -400,10 +406,6 @@ public void openChannel(IWearableCallbacks callbacks, String targetNodeId, Strin }); } - /* - * Channels - */ - @Override public void closeChannel(IWearableCallbacks callbacks, String token) throws RemoteException { Log.d(TAG, "closeChannel: " + token); From 7541455187887ee8d04c07638486320946194839 Mon Sep 17 00:00:00 2001 From: Akinniranye Samuel Tomiwa Date: Tue, 10 Mar 2026 14:31:38 +0100 Subject: [PATCH 06/12] Wear OS: implement Phone call handling and media controls --- .../src/main/AndroidManifest.xml | 2 + .../org/microg/gms/wearable/CallBridge.java | 276 ++++++++++++ .../org/microg/gms/wearable/MediaBridge.java | 396 ++++++++++++++++++ .../org/microg/gms/wearable/WearableImpl.java | 14 + .../gms/wearable/WearableServiceImpl.java | 3 + 5 files changed, 691 insertions(+) create mode 100644 play-services-wearable/core/src/main/java/org/microg/gms/wearable/CallBridge.java create mode 100644 play-services-wearable/core/src/main/java/org/microg/gms/wearable/MediaBridge.java diff --git a/play-services-core/src/main/AndroidManifest.xml b/play-services-core/src/main/AndroidManifest.xml index 79726804b1..a02d91489f 100644 --- a/play-services-core/src/main/AndroidManifest.xml +++ b/play-services-core/src/main/AndroidManifest.xml @@ -142,6 +142,8 @@ + + diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/CallBridge.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/CallBridge.java new file mode 100644 index 0000000000..71ba4ce530 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/CallBridge.java @@ -0,0 +1,276 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wearable; + +import android.content.Context; +import android.os.Build; +import android.telecom.TelecomManager; +import android.telephony.PhoneStateListener; +import android.telephony.TelephonyManager; +import android.util.Log; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.lang.ref.WeakReference; + +/** + * Bridges phone call state between the Android phone and connected Wear OS peers. + * + *

Responsibilities: + *

    + *
  • Registers a {@link PhoneStateListener} to monitor ringing / off-hook / idle state. + *
  • Sends call-state updates to all connected watch nodes via + * {@link WearableImpl#sendMessage} on path {@value #PHONE_PATH}. + *
  • Handles call-control commands arriving from the watch on path + * {@value #PHONE_COMMAND_PATH}: answer, reject/end, and silence-ringer. + *
+ * + *

Phone-state payload format (path {@value #PHONE_PATH})

+ *
+ *   byte  type          0 = idle, 1 = ringing, 2 = off-hook
+ *   UTF   phoneNumber   (empty string when unavailable)
+ *   UTF   contactName   (empty string when unavailable)
+ * 
+ * + *

Command payload format (path {@value #PHONE_COMMAND_PATH})

+ *
+ *   byte  command       1 = answer, 2 = reject/end, 3 = silence ringer
+ * 
+ */ +public class CallBridge { + + private static final String TAG = "GmsWearCallBridge"; + + /** Path used to push phone-state updates to connected wearable peers. */ + public static final String PHONE_PATH = "/wearable/phone"; + + /** Path on which the wearable peer sends call-control commands. */ + public static final String PHONE_COMMAND_PATH = "/wearable/phone/command"; + + // Phone-state type constants (sent in the payload to the watch) + private static final byte STATE_IDLE = 0; + private static final byte STATE_RINGING = 1; + private static final byte STATE_OFFHOOK = 2; + + // Command constants (received from the watch) + private static final byte CMD_ANSWER = 1; + private static final byte CMD_END = 2; + private static final byte CMD_SILENCE = 3; + + private static PhoneStateListener sPhoneStateListener; + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + /** + * Registers a {@link PhoneStateListener} and begins forwarding call-state changes + * to all connected wearable nodes. + * + *

Safe to call multiple times; subsequent calls replace the previous listener. + */ + public static synchronized void start(Context context, WearableImpl wearable) { + stop(context); + TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + if (tm == null) { + Log.w(TAG, "TelephonyManager unavailable, call monitoring disabled"); + return; + } + sPhoneStateListener = new WearPhoneStateListener(context, wearable); + tm.listen(sPhoneStateListener, PhoneStateListener.LISTEN_CALL_STATE); + Log.d(TAG, "call state listener registered"); + } + + /** + * Unregisters the previously registered {@link PhoneStateListener}. + */ + public static synchronized void stop(Context context) { + if (sPhoneStateListener == null) return; + TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + if (tm != null) { + tm.listen(sPhoneStateListener, PhoneStateListener.LISTEN_NONE); + } + sPhoneStateListener = null; + Log.d(TAG, "call state listener unregistered"); + } + + // ------------------------------------------------------------------------- + // Call-control actions (invoked by WearableServiceImpl or command handler) + // ------------------------------------------------------------------------- + + /** + * Answers the current ringing call using {@link TelecomManager}. + * Requires {@code android.permission.ANSWER_PHONE_CALLS} on API 26+. + */ + public static void answerCall(Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + TelecomManager tm = (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE); + if (tm != null) { + try { + tm.acceptRingingCall(); + Log.d(TAG, "acceptRingingCall() called"); + } catch (SecurityException e) { + Log.w(TAG, "Missing ANSWER_PHONE_CALLS permission", e); + } + } + } else { + Log.d(TAG, "answerCall: requires API 26+, ignoring"); + } + } + + /** + * Ends the current call (or rejects an incoming one) using {@link TelecomManager}. + * Requires {@code android.permission.ANSWER_PHONE_CALLS} on API 28+ for + * ending an active call. + */ + public static void endCall(Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + TelecomManager tm = (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE); + if (tm != null) { + try { + tm.endCall(); + Log.d(TAG, "endCall() called"); + } catch (SecurityException e) { + Log.w(TAG, "Missing ANSWER_PHONE_CALLS permission for endCall", e); + } + } + } else { + Log.d(TAG, "endCall: requires API 28+, ignoring"); + } + } + + /** + * Silences the ringer for the current incoming call without rejecting it. + * Uses {@link TelecomManager#silenceRinger()} on API 23+. + */ + public static void silenceRinger(Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + TelecomManager tm = (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE); + if (tm != null) { + try { + tm.silenceRinger(); + Log.d(TAG, "silenceRinger() called"); + } catch (SecurityException e) { + Log.w(TAG, "Missing permission for silenceRinger", e); + } + } + } else { + Log.d(TAG, "silenceRinger: requires API 23+, ignoring"); + } + } + + // ------------------------------------------------------------------------- + // Incoming command handling + // ------------------------------------------------------------------------- + + /** + * Dispatches a call-control command received from the watch. + * + * @param context application context + * @param data raw payload bytes from the watch message + */ + public static void handleCommand(Context context, byte[] data) { + if (data == null || data.length == 0) { + Log.w(TAG, "handleCommand: empty payload"); + return; + } + try { + DataInputStream dis = new DataInputStream(new ByteArrayInputStream(data)); + byte command = dis.readByte(); + Log.d(TAG, "handleCommand: command=" + command); + switch (command) { + case CMD_ANSWER: + answerCall(context); + break; + case CMD_END: + endCall(context); + break; + case CMD_SILENCE: + silenceRinger(context); + break; + default: + Log.w(TAG, "handleCommand: unknown command=" + command); + } + } catch (Exception e) { + Log.e(TAG, "handleCommand: failed to parse payload", e); + } + } + + // ------------------------------------------------------------------------- + // Encoding helpers + // ------------------------------------------------------------------------- + + /** + * Encodes a phone-state update payload to send to the watch. + * + * @param stateType one of {@link #STATE_IDLE}, {@link #STATE_RINGING}, or + * {@link #STATE_OFFHOOK} + * @param phoneNumber incoming phone number, or empty string + * @param contactName resolved contact name, or empty string + * @return encoded bytes or {@code null} on error + */ + static byte[] encodeState(byte stateType, String phoneNumber, String contactName) { + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + DataOutputStream dos = new DataOutputStream(baos); + dos.writeByte(stateType); + dos.writeUTF(phoneNumber != null ? phoneNumber : ""); + dos.writeUTF(contactName != null ? contactName : ""); + dos.flush(); + return baos.toByteArray(); + } catch (Exception e) { + Log.e(TAG, "encodeState: failed", e); + return null; + } + } + + // ------------------------------------------------------------------------- + // PhoneStateListener inner class + // ------------------------------------------------------------------------- + + private static final class WearPhoneStateListener extends PhoneStateListener { + private final Context context; + // WeakReference to avoid preventing WearableImpl GC + private final WeakReference wearableRef; + + WearPhoneStateListener(Context context, WearableImpl wearable) { + this.context = context.getApplicationContext(); + this.wearableRef = new WeakReference<>(wearable); + } + + @Override + public void onCallStateChanged(int state, String phoneNumber) { + WearableImpl wearable = wearableRef.get(); + if (wearable == null) return; + + byte type; + switch (state) { + case TelephonyManager.CALL_STATE_RINGING: + type = STATE_RINGING; + break; + case TelephonyManager.CALL_STATE_OFFHOOK: + type = STATE_OFFHOOK; + break; + default: + type = STATE_IDLE; + break; + } + + Log.d(TAG, "onCallStateChanged: type=" + type + ", number=" + phoneNumber); + byte[] payload = encodeState(type, phoneNumber, ""); + if (payload == null) return; + + for (String nodeId : wearable.getAllConnectedNodes()) { + int result = wearable.sendMessage(context.getPackageName(), nodeId, PHONE_PATH, payload); + if (result < 0) { + Log.w(TAG, "sendMessage to " + nodeId + " failed (result=" + result + ")"); + } + } + } + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MediaBridge.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MediaBridge.java new file mode 100644 index 0000000000..fc86b6b023 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MediaBridge.java @@ -0,0 +1,396 @@ +/* + * 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.MediaMetadata; +import android.media.session.MediaController; +import android.media.session.MediaSession; +import android.media.session.MediaSessionManager; +import android.media.session.PlaybackState; +import android.os.Build; +import android.util.Log; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.lang.ref.WeakReference; +import java.util.List; + +/** + * Bridges media playback state between the Android phone and connected Wear OS peers. + * + *

Responsibilities: + *

    + *
  • Listens for active media-session changes via {@link MediaSessionManager}. + *
  • Attaches a {@link MediaController.Callback} to the first active session to receive + * metadata and playback-state updates. + *
  • Sends media-state updates to all connected watch nodes via + * {@link WearableImpl#sendMessage} on path {@value #MEDIA_PATH}. + *
  • Handles media-control commands arriving from the watch on path + * {@value #MEDIA_COMMAND_PATH}: play, pause, next, previous, volume up/down. + *
+ * + *

Media-state payload format (path {@value #MEDIA_PATH})

+ *
+ *   byte  state    0 = paused / stopped, 1 = playing
+ *   UTF   title    (empty string when unavailable)
+ *   UTF   artist   (empty string when unavailable)
+ *   UTF   album    (empty string when unavailable)
+ *   long  position current playback position in milliseconds (−1 when unknown)
+ *   long  duration track duration in milliseconds (−1 when unknown)
+ * 
+ * + *

Command payload format (path {@value #MEDIA_COMMAND_PATH})

+ *
+ *   byte  command  1 = play, 2 = pause, 3 = next, 4 = previous,
+ *                  5 = volume up, 6 = volume down
+ * 
+ * + *

Requires API 21+ ({@link Build.VERSION_CODES#LOLLIPOP}) for + * {@link MediaSessionManager} and {@link MediaController}. + */ +public class MediaBridge { + + private static final String TAG = "GmsWearMediaBridge"; + + /** Path used to push media-state updates to connected wearable peers. */ + public static final String MEDIA_PATH = "/wearable/media"; + + /** Path on which the wearable peer sends media-control commands. */ + public static final String MEDIA_COMMAND_PATH = "/wearable/media/command"; + + /** Component name of the in-process {@link android.service.notification.NotificationListenerService}. */ + private static final String NOTIFICATION_LISTENER_CLASS = + "org.microg.gms.wearable.notification.WearableNotificationService"; + private static final byte STATE_PAUSED = 0; + private static final byte STATE_PLAYING = 1; + + // Command constants (received from the watch) + private static final byte CMD_PLAY = 1; + private static final byte CMD_PAUSE = 2; + private static final byte CMD_NEXT = 3; + private static final byte CMD_PREVIOUS = 4; + private static final byte CMD_VOLUME_UP = 5; + private static final byte CMD_VOLUME_DOWN = 6; + + private static MediaSessionManager.OnActiveSessionsChangedListener sSessionsChangedListener; + private static MediaController sActiveController; + private static MediaController.Callback sControllerCallback; + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + /** + * Starts monitoring active media sessions and registering a playback callback + * on the currently active controller. + * + *

Requires API 21+; silently exits on older versions. + * + * @param context application context + * @param wearable the running {@link WearableImpl} instance to forward updates to + */ + public static synchronized void start(Context context, WearableImpl wearable) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + Log.d(TAG, "start: MediaSessionManager requires API 21+, skipping"); + return; + } + stop(context); + + MediaSessionManager msm = + (MediaSessionManager) context.getSystemService(Context.MEDIA_SESSION_SERVICE); + if (msm == null) { + Log.w(TAG, "start: MediaSessionManager unavailable"); + return; + } + + sSessionsChangedListener = new WearSessionsChangedListener(context, wearable); + + // The NotificationListenerService component name is required to call + // getActiveSessions(). Using the WearableNotificationService which is + // already running in this process. + ComponentName notifListenerComponent = new ComponentName( + context, NOTIFICATION_LISTENER_CLASS); + try { + msm.addOnActiveSessionsChangedListener(sSessionsChangedListener, notifListenerComponent); + // Trigger an initial update for the currently active session. + List sessions = msm.getActiveSessions(notifListenerComponent); + sSessionsChangedListener.onActiveSessionsChanged(sessions); + Log.d(TAG, "start: media session listener registered"); + } catch (SecurityException e) { + Log.w(TAG, "start: no permission to list media sessions", e); + } + } + + /** + * Stops monitoring media sessions and detaches any active playback callback. + */ + public static synchronized void stop(Context context) { + detachController(); + if (sSessionsChangedListener == null) return; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + MediaSessionManager msm = + (MediaSessionManager) context.getSystemService(Context.MEDIA_SESSION_SERVICE); + if (msm != null) { + try { + msm.removeOnActiveSessionsChangedListener(sSessionsChangedListener); + } catch (Exception ignored) { + } + } + } + sSessionsChangedListener = null; + Log.d(TAG, "stop: media session listener unregistered"); + } + + // ------------------------------------------------------------------------- + // Incoming command handling + // ------------------------------------------------------------------------- + + /** + * Dispatches a media-control command received from the watch to the currently + * active {@link MediaController}, or adjusts system volume for volume commands. + * + * @param context application context + * @param data raw payload bytes from the watch message + */ + public static void handleCommand(Context context, byte[] data) { + if (data == null || data.length == 0) { + Log.w(TAG, "handleCommand: empty payload"); + return; + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + Log.d(TAG, "handleCommand: requires API 21+, ignoring"); + return; + } + try { + DataInputStream dis = new DataInputStream(new ByteArrayInputStream(data)); + byte command = dis.readByte(); + Log.d(TAG, "handleCommand: command=" + command); + + MediaController.TransportControls controls = + sActiveController != null ? sActiveController.getTransportControls() : null; + + switch (command) { + case CMD_PLAY: + if (controls != null) controls.play(); + break; + case CMD_PAUSE: + if (controls != null) controls.pause(); + break; + case CMD_NEXT: + if (controls != null) controls.skipToNext(); + break; + case CMD_PREVIOUS: + if (controls != null) controls.skipToPrevious(); + break; + case CMD_VOLUME_UP: + adjustVolume(context, AudioManager.ADJUST_RAISE); + break; + case CMD_VOLUME_DOWN: + adjustVolume(context, AudioManager.ADJUST_LOWER); + break; + default: + Log.w(TAG, "handleCommand: unknown command=" + command); + } + } catch (Exception e) { + Log.e(TAG, "handleCommand: failed to parse payload", e); + } + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private static void adjustVolume(Context context, int direction) { + AudioManager am = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + if (am != null) { + am.adjustStreamVolume(AudioManager.STREAM_MUSIC, direction, 0); + } + } + + private static synchronized void detachController() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return; + if (sActiveController != null && sControllerCallback != null) { + sActiveController.unregisterCallback(sControllerCallback); + } + sActiveController = null; + sControllerCallback = null; + } + + private static synchronized void attachController( + MediaController controller, Context context, WearableImpl wearable) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return; + detachController(); + if (controller == null) return; + sActiveController = controller; + sControllerCallback = new WearControllerCallback(context, wearable); + controller.registerCallback(sControllerCallback); + Log.d(TAG, "attachController: " + controller.getPackageName()); + // Send the current state immediately so the watch is up-to-date. + sendCurrentState(context, wearable, controller); + } + + private static void sendCurrentState( + Context context, WearableImpl wearable, MediaController controller) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return; + byte[] payload = encodeState(controller); + if (payload == null) return; + for (String nodeId : wearable.getAllConnectedNodes()) { + int result = wearable.sendMessage(context.getPackageName(), nodeId, MEDIA_PATH, payload); + if (result < 0) { + Log.w(TAG, "sendCurrentState: sendMessage to " + nodeId + " failed"); + } + } + } + + // ------------------------------------------------------------------------- + // Encoding helpers + // ------------------------------------------------------------------------- + + /** + * Encodes the current playback state of {@code controller} into a byte array + * to send to the watch. + * + * @return encoded bytes or {@code null} on error / missing state + */ + static byte[] encodeState(MediaController controller) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return null; + try { + PlaybackState ps = controller.getPlaybackState(); + MediaMetadata mm = controller.getMetadata(); + + byte playingByte = STATE_PAUSED; + long position = -1L; + if (ps != null) { + int psState = ps.getState(); + if (psState == PlaybackState.STATE_PLAYING + || psState == PlaybackState.STATE_BUFFERING + || psState == PlaybackState.STATE_FAST_FORWARDING + || psState == PlaybackState.STATE_REWINDING) { + playingByte = STATE_PLAYING; + } + position = ps.getPosition(); + } + + String title = ""; + String artist = ""; + String album = ""; + long duration = -1L; + if (mm != null) { + CharSequence t = mm.getText(MediaMetadata.METADATA_KEY_TITLE); + CharSequence ar = mm.getText(MediaMetadata.METADATA_KEY_ARTIST); + CharSequence al = mm.getText(MediaMetadata.METADATA_KEY_ALBUM); + if (t != null) title = t.toString(); + if (ar != null) artist = ar.toString(); + if (al != null) album = al.toString(); + duration = mm.getLong(MediaMetadata.METADATA_KEY_DURATION); + } + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + DataOutputStream dos = new DataOutputStream(baos); + dos.writeByte(playingByte); + dos.writeUTF(title); + dos.writeUTF(artist); + dos.writeUTF(album); + dos.writeLong(position); + dos.writeLong(duration); + dos.flush(); + return baos.toByteArray(); + } catch (Exception e) { + Log.e(TAG, "encodeState: failed", e); + return null; + } + } + + // ------------------------------------------------------------------------- + // Inner listener classes + // ------------------------------------------------------------------------- + + private static final class WearSessionsChangedListener + implements MediaSessionManager.OnActiveSessionsChangedListener { + private final Context context; + private final WeakReference wearableRef; + + WearSessionsChangedListener(Context context, WearableImpl wearable) { + this.context = context.getApplicationContext(); + this.wearableRef = new WeakReference<>(wearable); + } + + @Override + public void onActiveSessionsChanged(List controllers) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return; + WearableImpl wearable = wearableRef.get(); + if (wearable == null) return; + + MediaController first = (controllers != null && !controllers.isEmpty()) + ? controllers.get(0) : null; + Log.d(TAG, "onActiveSessionsChanged: first=" + + (first != null ? first.getPackageName() : "none")); + attachController(first, context, wearable); + } + } + + private static final class WearControllerCallback extends MediaController.Callback { + private final Context context; + private final WeakReference wearableRef; + + WearControllerCallback(Context context, WearableImpl wearable) { + this.context = context.getApplicationContext(); + this.wearableRef = new WeakReference<>(wearable); + } + + @Override + public void onPlaybackStateChanged(PlaybackState state) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return; + dispatch(); + } + + @Override + public void onMetadataChanged(MediaMetadata metadata) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return; + dispatch(); + } + + @Override + public void onSessionDestroyed() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return; + Log.d(TAG, "onSessionDestroyed"); + WearableImpl wearable = wearableRef.get(); + if (wearable == null) return; + // Send a "paused / stopped" empty state to the watch. + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + DataOutputStream dos = new DataOutputStream(baos); + dos.writeByte(STATE_PAUSED); + dos.writeUTF(""); + dos.writeUTF(""); + dos.writeUTF(""); + dos.writeLong(-1L); + dos.writeLong(-1L); + dos.flush(); + byte[] payload = baos.toByteArray(); + for (String nodeId : wearable.getAllConnectedNodes()) { + wearable.sendMessage(context.getPackageName(), nodeId, MEDIA_PATH, payload); + } + } catch (Exception e) { + Log.e(TAG, "onSessionDestroyed: failed to encode stop state", e); + } + detachController(); + } + + private void dispatch() { + MediaController controller = sActiveController; + if (controller == null) return; + WearableImpl wearable = wearableRef.get(); + if (wearable == null) return; + sendCurrentState(context, wearable, controller); + } + } +} 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 2c3885ee65..7c9278ec5e 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 @@ -110,6 +110,8 @@ public WearableImpl(Context context, NodeDatabaseHelper nodeDatabase, Configurat networkHandlerLock.countDown(); Looper.loop(); }).start(); + CallBridge.start(context, this); + MediaBridge.start(context, this); } public String getLocalNodeId() { @@ -620,6 +622,16 @@ public int deleteDataItems(Uri uri, String packageName) { public void sendMessageReceived(String packageName, MessageEventParcelable messageEvent) { Log.d(TAG, "onMessageReceived: " + messageEvent); + // Phone/media command messages from the watch are consumed here and not + // forwarded to application listeners — they are internal control signals. + if (CallBridge.PHONE_COMMAND_PATH.equals(messageEvent.path)) { + CallBridge.handleCommand(context, messageEvent.data); + return; + } + if (MediaBridge.MEDIA_COMMAND_PATH.equals(messageEvent.path)) { + MediaBridge.handleCommand(context, messageEvent.data); + return; + } Intent intent = new Intent("com.google.android.gms.wearable.MESSAGE_RECEIVED"); intent.setPackage(packageName); intent.setData(Uri.parse("wear://" + getLocalNodeId() + "/" + messageEvent.getPath())); @@ -695,6 +707,8 @@ public int sendMessage(String packageName, String targetNodeId, String path, byt } public void stop() { + CallBridge.stop(context); + MediaBridge.stop(context); channelManager.closeAll(); try { this.networkHandlerLock.await(); 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 9e82e31e6a..7596156a7c 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 @@ -353,16 +353,19 @@ public void clearStorage(IWearableCallbacks callbacks) throws RemoteException { @Override public void endCall(IWearableCallbacks callbacks) throws RemoteException { + CallBridge.endCall(context); callbacks.onStatus(Status.SUCCESS); } @Override public void acceptRingingCall(IWearableCallbacks callbacks) throws RemoteException { + CallBridge.answerCall(context); callbacks.onStatus(Status.SUCCESS); } @Override public void silenceRinger(IWearableCallbacks callbacks) throws RemoteException { + CallBridge.silenceRinger(context); callbacks.onStatus(Status.SUCCESS); } From e62b8191ae719463b457ac99427b0de543791964 Mon Sep 17 00:00:00 2001 From: Akinniranye Samuel Tomiwa Date: Tue, 10 Mar 2026 17:50:54 +0100 Subject: [PATCH 07/12] feat : implement Bluetooth RFCOMM transport --- .../src/main/AndroidManifest.xml | 4 + .../wearable/BluetoothConnectionThread.java | 236 ++++++++++++++++++ .../org/microg/gms/wearable/CallBridge.java | 4 + .../org/microg/gms/wearable/MediaBridge.java | 4 + .../org/microg/gms/wearable/WearableImpl.java | 34 +++ 5 files changed, 282 insertions(+) create mode 100644 play-services-wearable/core/src/main/java/org/microg/gms/wearable/BluetoothConnectionThread.java diff --git a/play-services-core/src/main/AndroidManifest.xml b/play-services-core/src/main/AndroidManifest.xml index a02d91489f..0b7301710d 100644 --- a/play-services-core/src/main/AndroidManifest.xml +++ b/play-services-core/src/main/AndroidManifest.xml @@ -138,6 +138,10 @@ + + + + diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/BluetoothConnectionThread.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/BluetoothConnectionThread.java new file mode 100644 index 0000000000..9c658f5dfd --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/BluetoothConnectionThread.java @@ -0,0 +1,236 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wearable; + +import android.annotation.SuppressLint; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothServerSocket; +import android.bluetooth.BluetoothSocket; +import android.util.Log; + +import org.microg.wearable.SocketWearableConnection; +import org.microg.wearable.WearableConnection; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; +import java.util.UUID; + +/** + * Transport thread that mirrors {@code SocketConnectionThread} but uses Bluetooth RFCOMM instead + * of TCP sockets. + * + *

Internally, each {@link BluetoothSocket} is wrapped in a minimal {@link Socket} proxy so + * that the existing {@link SocketWearableConnection} framing code can be reused unchanged. + * This avoids any direct dependency on the Wire 1.x API that is otherwise unavailable at + * compile time. + * + *

Server mode

+ *
+ *   BluetoothConnectionThread bct =
+ *       BluetoothConnectionThread.serverListen(adapter, messageHandler);
+ *   bct.start();
+ * 
+ * The thread opens a Bluetooth RFCOMM server socket and accepts connections in a loop. + * Each accepted connection runs the message loop synchronously before looping back. + * + *

Client mode

+ *
+ *   BluetoothConnectionThread bct =
+ *       BluetoothConnectionThread.clientConnect(remoteDevice, messageHandler);
+ *   bct.start();
+ * 
+ * The thread connects to {@code remoteDevice} using {@link #WEAR_BT_UUID} and runs the + * message loop until the connection is closed. + */ +public abstract class BluetoothConnectionThread extends Thread { + + private static final String TAG = "GmsWearBtThread"; + + /** RFCOMM service UUID for the wearable Bluetooth transport. */ + public static final UUID WEAR_BT_UUID = + UUID.fromString("a3c87500-8ed3-4bdf-8a39-a01bebede295"); + + /** SDP service name advertised alongside {@link #WEAR_BT_UUID}. */ + static final String WEAR_BT_SERVICE_NAME = "WearOS"; + + private volatile SocketWearableConnection wearableConnection; + + // Package-private constructor; concrete subclasses are anonymous inner classes. + BluetoothConnectionThread() {} + + protected void setWearableConnection(SocketWearableConnection connection) { + this.wearableConnection = connection; + } + + /** Returns the most recently established {@link SocketWearableConnection}, or null. */ + public SocketWearableConnection getWearableConnection() { + return wearableConnection; + } + + /** Closes the underlying Bluetooth server/client socket, unblocking any pending I/O. */ + public abstract void close(); + + // ------------------------------------------------------------------------- + // Internal helper + // ------------------------------------------------------------------------- + + /** + * Wraps a {@link BluetoothSocket} in a minimal {@link Socket} proxy so that + * {@link SocketWearableConnection} can use its streams without any Wire-version-specific + * framing code in this module. + * + *

{@link SocketWearableConnection} only calls {@link #getInputStream()}, + * {@link #getOutputStream()}, and {@link #close()} on the socket object, so only those + * three methods need to be overridden here. + */ + private static Socket proxySocket(BluetoothSocket btSocket) { + return new Socket() { + @Override + public InputStream getInputStream() throws IOException { + return btSocket.getInputStream(); + } + + @Override + public OutputStream getOutputStream() throws IOException { + return btSocket.getOutputStream(); + } + + @Override + public synchronized void close() throws IOException { + btSocket.close(); + } + }; + } + + // ------------------------------------------------------------------------- + // Factory methods + // ------------------------------------------------------------------------- + + /** + * Creates a server-side thread that listens for incoming Bluetooth RFCOMM connections + * using the well-known {@link #WEAR_BT_UUID}. + * + *

Requires {@code BLUETOOTH_CONNECT} permission on API 31+. + * + * @param adapter the local {@link BluetoothAdapter}; must not be null + * @param listener message listener shared across all accepted connections + * @return a {@link BluetoothConnectionThread} ready to be {@link #start()}ed + */ + @SuppressLint("MissingPermission") + public static BluetoothConnectionThread serverListen( + BluetoothAdapter adapter, WearableConnection.Listener listener) { + return new BluetoothConnectionThread() { + private volatile BluetoothServerSocket serverSocket; + + @Override + public void close() { + BluetoothServerSocket s = serverSocket; + serverSocket = null; + if (s != null) { + try { + s.close(); + } catch (IOException e) { + Log.w(TAG, "server close: error", e); + } + } + } + + @Override + @SuppressLint("MissingPermission") + public void run() { + try { + serverSocket = adapter.listenUsingRfcommWithServiceRecord( + WEAR_BT_SERVICE_NAME, WEAR_BT_UUID); + Log.d(TAG, "server: listening for RFCOMM connections"); + while (!Thread.interrupted()) { + BluetoothSocket btSocket; + try { + btSocket = serverSocket.accept(); + } catch (IOException e) { + // serverSocket was closed via close() – stop the loop + break; + } + if (btSocket == null || Thread.interrupted()) break; + try { + SocketWearableConnection conn = + new SocketWearableConnection(proxySocket(btSocket), listener); + setWearableConnection(conn); + conn.run(); + } catch (IOException e) { + Log.w(TAG, "server: error on accepted connection", e); + } + } + } catch (IOException e) { + if (!Thread.interrupted()) { + Log.w(TAG, "server: socket error", e); + } + } finally { + BluetoothServerSocket s = serverSocket; + serverSocket = null; + if (s != null) { + try { s.close(); } catch (IOException e) { Log.d(TAG, "server: close error in finally", e); } + } + } + } + }; + } + + /** + * Creates a client-side thread that connects to a known Bluetooth {@code device} via + * RFCOMM using {@link #WEAR_BT_UUID}. + * + *

Requires {@code BLUETOOTH_CONNECT} permission on API 31+. + * + * @param device the remote Bluetooth device (e.g. the wearable) + * @param listener message listener for the connection + * @return a {@link BluetoothConnectionThread} ready to be {@link #start()}ed + */ + @SuppressLint("MissingPermission") + public static BluetoothConnectionThread clientConnect( + BluetoothDevice device, WearableConnection.Listener listener) { + return new BluetoothConnectionThread() { + private volatile BluetoothSocket btSocket; + + @Override + public void close() { + BluetoothSocket s = btSocket; + btSocket = null; + if (s != null) { + try { + s.close(); + } catch (IOException e) { + Log.w(TAG, "client close: error", e); + } + } + } + + @Override + @SuppressLint("MissingPermission") + public void run() { + BluetoothSocket s = null; + try { + s = device.createRfcommSocketToServiceRecord(WEAR_BT_UUID); + btSocket = s; + s.connect(); + SocketWearableConnection conn = + new SocketWearableConnection(proxySocket(s), listener); + setWearableConnection(conn); + conn.run(); + } catch (IOException e) { + Log.w(TAG, "client: connection error", e); + } finally { + btSocket = null; + if (s != null) { + try { s.close(); } catch (IOException e) { Log.d(TAG, "client: close error in finally", e); } + } + } + } + }; + } +} diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/CallBridge.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/CallBridge.java index 71ba4ce530..4a97f79987 100644 --- a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/CallBridge.java +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/CallBridge.java @@ -5,6 +5,7 @@ package org.microg.gms.wearable; +import android.annotation.SuppressLint; import android.content.Context; import android.os.Build; import android.telecom.TelecomManager; @@ -107,6 +108,7 @@ public static synchronized void stop(Context context) { * Answers the current ringing call using {@link TelecomManager}. * Requires {@code android.permission.ANSWER_PHONE_CALLS} on API 26+. */ + @SuppressLint("MissingPermission") public static void answerCall(Context context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { TelecomManager tm = (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE); @@ -128,6 +130,7 @@ public static void answerCall(Context context) { * Requires {@code android.permission.ANSWER_PHONE_CALLS} on API 28+ for * ending an active call. */ + @SuppressLint("MissingPermission") public static void endCall(Context context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { TelecomManager tm = (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE); @@ -148,6 +151,7 @@ public static void endCall(Context context) { * Silences the ringer for the current incoming call without rejecting it. * Uses {@link TelecomManager#silenceRinger()} on API 23+. */ + @SuppressLint("MissingPermission") public static void silenceRinger(Context context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { TelecomManager tm = (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE); diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MediaBridge.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MediaBridge.java index fc86b6b023..829f314844 100644 --- a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MediaBridge.java +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MediaBridge.java @@ -16,6 +16,8 @@ import android.os.Build; import android.util.Log; +import androidx.annotation.RequiresApi; + import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.DataInputStream; @@ -313,6 +315,7 @@ static byte[] encodeState(MediaController controller) { // Inner listener classes // ------------------------------------------------------------------------- + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) private static final class WearSessionsChangedListener implements MediaSessionManager.OnActiveSessionsChangedListener { private final Context context; @@ -337,6 +340,7 @@ public void onActiveSessionsChanged(List controllers) { } } + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) private static final class WearControllerCallback extends MediaController.Callback { private final Context context; private final WeakReference wearableRef; 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 7c9278ec5e..4c562fd022 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 @@ -16,6 +16,8 @@ package org.microg.gms.wearable; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; @@ -90,6 +92,7 @@ public class WearableImpl { private final Map activeConnections = new HashMap(); private RpcHelper rpcHelper; private SocketConnectionThread sct; + private BluetoothConnectionThread bct; private ConnectionConfiguration[] configurations; private boolean configurationsUpdated = false; private ClockworkNodePreferences clockworkNodePreferences; @@ -586,6 +589,32 @@ public void enableConnection(String name) { 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(); + } else if (name.equals("bluetooth-server") && bct == null) { + BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); + if (adapter != null && adapter.isEnabled()) { + Log.d(TAG, "Starting Bluetooth RFCOMM server"); + bct = BluetoothConnectionThread.serverListen( + adapter, new MessageHandler(context, this, configDatabase.getConfiguration(name))); + bct.start(); + } else { + Log.w(TAG, "enableConnection(bluetooth-server): Bluetooth unavailable or disabled"); + } + } else if (name.startsWith("bluetooth-client:") && bct == null) { + String address = name.substring("bluetooth-client:".length()); + BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); + if (adapter != null && adapter.isEnabled()) { + if (!BluetoothAdapter.checkBluetoothAddress(address)) { + Log.w(TAG, "enableConnection(" + name + "): invalid Bluetooth address"); + return; + } + BluetoothDevice device = adapter.getRemoteDevice(address); + Log.d(TAG, "Connecting to Bluetooth device: " + address); + bct = BluetoothConnectionThread.clientConnect( + device, new MessageHandler(context, this, configDatabase.getConfiguration(name))); + bct.start(); + } else { + Log.w(TAG, "enableConnection(" + name + "): Bluetooth unavailable or disabled"); + } } } @@ -597,6 +626,11 @@ public void disableConnection(String name) { sct.close(); sct.interrupt(); sct = null; + } else if ((name.equals("bluetooth-server") || name.startsWith("bluetooth-client:")) && bct != null) { + activeConnections.remove(bct.getWearableConnection()); + bct.close(); + bct.interrupt(); + bct = null; } } From c02c7eb3c09b20577aaecb0331bee22b16bfe24b Mon Sep 17 00:00:00 2001 From: Akinniranye Samuel Tomiwa Date: Wed, 11 Mar 2026 09:19:09 +0100 Subject: [PATCH 08/12] Apply review feedback fixes for API safety and SQLite compatibility --- .../src/main/AndroidManifest.xml | 8 +++- .../WearableNotificationService.kt | 28 ++++++++--- .../core/src/main/AndroidManifest.xml | 20 -------- .../microg/gms/wearable/ChannelManager.java | 47 ++++++++++++++----- .../gms/wearable/NodeDatabaseHelper.java | 8 ++-- .../org/microg/gms/wearable/WearableImpl.java | 10 +++- 6 files changed, 75 insertions(+), 46 deletions(-) diff --git a/play-services-core/src/main/AndroidManifest.xml b/play-services-core/src/main/AndroidManifest.xml index 0b7301710d..044e83d11c 100644 --- a/play-services-core/src/main/AndroidManifest.xml +++ b/play-services-core/src/main/AndroidManifest.xml @@ -271,7 +271,9 @@ - + - + diff --git a/play-services-core/src/main/kotlin/org/microg/gms/wearable/notification/WearableNotificationService.kt b/play-services-core/src/main/kotlin/org/microg/gms/wearable/notification/WearableNotificationService.kt index c493219f92..ff112e068e 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/wearable/notification/WearableNotificationService.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/wearable/notification/WearableNotificationService.kt @@ -36,8 +36,8 @@ private val uidCounter = AtomicInteger(1) class WearableNotificationService : NotificationListenerService() { /** - * Maps the notification's stable [StatusBarNotification.getKey] to the UID we - * assigned to it, so that we send the same UID on removal. + * Maps the notification's stable key to the UID we assigned to it, so that we send + * the same UID on removal. The key is derived via [sbnKey] to remain safe on API 19. */ private val keyToUid = HashMap() @@ -45,7 +45,7 @@ class WearableNotificationService : NotificationListenerService() { if (shouldSkip(sbn)) return // Assign a stable, collision-free UID for this notification. - val uid = keyToUid.getOrPut(sbn.key) { uidCounter.getAndIncrement() } + val uid = keyToUid.getOrPut(sbnKey(sbn)) { uidCounter.getAndIncrement() } NotificationBridge.activeNotifications[uid] = sbn val payload = encodeNotification(uid, sbn) ?: return @@ -53,11 +53,11 @@ class WearableNotificationService : NotificationListenerService() { } override fun onNotificationRemoved(sbn: StatusBarNotification) { - val uid = keyToUid.remove(sbn.key) ?: return + val uid = keyToUid.remove(sbnKey(sbn)) ?: return NotificationBridge.activeNotifications.remove(uid) // Notify peers that this notification was dismissed - val payload = encodeRemoval(uid, sbn.key) ?: return + val payload = encodeRemoval(uid, sbnKey(sbn)) ?: return sendToWearable(payload) } @@ -94,6 +94,22 @@ class WearableNotificationService : NotificationListenerService() { // Encoding helpers // ------------------------------------------------------------------------- +/** + * Returns a stable string key for [sbn] that is safe on API 19+. + * + * [StatusBarNotification.getKey] was added in API 20 (KITKAT_WATCH). On API 19 we fall + * back to a composite of `packageName + '|' + id + '|' + tag` which is sufficient for + * distinguishing notifications within a single posting session. Package names are + * dot-separated identifiers and cannot contain '|', minimising the risk of collisions. + */ +internal fun sbnKey(sbn: StatusBarNotification): String { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) { + sbn.key + } else { + "${sbn.packageName}|${sbn.id}|${sbn.tag ?: ""}" + } +} + /** * Encodes a posted notification as: * - byte type = 1 (posted) @@ -124,7 +140,7 @@ internal fun encodeNotification(uid: Int, sbn: StatusBarNotification): ByteArray dos.writeByte(1) // type: posted dos.writeInt(uid) dos.writeUTF(sbn.packageName) - dos.writeUTF(sbn.key) + dos.writeUTF(sbnKey(sbn)) dos.writeUTF(title) dos.writeUTF(text) dos.writeLong(sbn.postTime) diff --git a/play-services-wearable/core/src/main/AndroidManifest.xml b/play-services-wearable/core/src/main/AndroidManifest.xml index dc584cff69..f6027c8159 100644 --- a/play-services-wearable/core/src/main/AndroidManifest.xml +++ b/play-services-wearable/core/src/main/AndroidManifest.xml @@ -13,25 +13,5 @@ - - - - - - - - - - - - - diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ChannelManager.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ChannelManager.java index aa62f59c9d..653668c649 100644 --- a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ChannelManager.java +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ChannelManager.java @@ -29,7 +29,6 @@ import org.microg.wearable.proto.ChannelRequest; import org.microg.wearable.proto.Request; -import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -386,7 +385,17 @@ private void handleIncomingControl(String sourceNodeId, String requestPath, long channelId = ctrl.channelId != null ? ctrl.channelId : -1L; String path = ctrl.path != null ? ctrl.path : requestPath; + if (channelId <= 0) { + // Channel IDs start at 1 (locally) and must be positive; 0 and negative values + // indicate a missing/unset field in the proto and should be ignored. + Log.w(TAG, "handleIncomingControl: invalid channelId=" + channelId + + ", type=" + type + ", ignoring"); + return; + } + if (type == CONTROL_TYPE_OPEN) { + // Advance our local counter past any peer-assigned id to prevent collisions. + nextChannelId.updateAndGet(current -> Math.max(current, channelId + 1)); String token = Long.toString(channelId); ChannelState state = new ChannelState(token, channelId, sourceNodeId, path); channels.put(channelId, state); @@ -424,26 +433,34 @@ private void handleIncomingData(ChannelDataRequest data) { try { if (state.inputPipe == null) { state.inputPipe = ParcelFileDescriptor.createPipe(); + // Open a single OutputStream over the write-end PFD and keep it alive + // across all chunks. Wrapping the PFD rather than its raw FileDescriptor + // ensures the FD is NOT closed when the stream would otherwise be closed. + state.inputPipeWriter = new ParcelFileDescriptor.AutoCloseOutputStream( + state.inputPipe[1]); } - // Write payload into the write-end of the input pipe so the app can read it. - // Use FileOutputStream over the raw FileDescriptor so the PFD is NOT closed - // when the stream is closed — the pipe must remain open for subsequent chunks. if (data.payload != null && data.payload.size() > 0) { - try (OutputStream out = new FileOutputStream( - state.inputPipe[1].getFileDescriptor())) { - out.write(data.payload.toByteArray()); + try { + state.inputPipeWriter.write(data.payload.toByteArray()); } catch (IOException e) { - // Re-create a fresh pipe on error so subsequent data isn't lost + // Reset on write error so the next message re-creates the pipe + try { state.inputPipeWriter.close(); } catch (IOException ignored) { } + state.inputPipeWriter = null; state.inputPipe = null; Log.e(TAG, "handleIncomingData: write error for channel " + channelId, e); return; } } if (Boolean.TRUE.equals(data.finalMessage)) { - // Peer has finished sending; close the write-end to signal EOF to the reader - if (state.inputPipe != null && state.inputPipe[1] != null) { - try { state.inputPipe[1].close(); } catch (IOException ignored) { } - state.inputPipe[1] = null; + // Peer has finished sending; close the write-end to signal EOF to the reader. + // The read-end (inputPipe[0]) is kept alive so the app can drain remaining data; + // it will be released when the channel itself is closed via state.close(). + if (state.inputPipeWriter != null) { + try { state.inputPipeWriter.close(); } catch (IOException ignored) { } + state.inputPipeWriter = null; + } + if (state.inputPipe != null) { + state.inputPipe[1] = null; // write-end closed by inputPipeWriter above } dispatchChannelEvent(state, EVENT_TYPE_INPUT_CLOSED, 0, 0); } @@ -571,6 +588,8 @@ private static class ChannelState { final String path; ParcelFileDescriptor[] inputPipe; // [0]=read end (given to app), [1]=write end (filled by network) ParcelFileDescriptor[] outputPipe; // [0]=read end (read by forwarder), [1]=write end (given to app) + /** Kept open across chunks so the write-end FD is never closed between messages. */ + OutputStream inputPipeWriter; ChannelState(String token, long channelId, String nodeId, String path) { this.token = token; @@ -580,6 +599,10 @@ private static class ChannelState { } void close() { + if (inputPipeWriter != null) { + try { inputPipeWriter.close(); } catch (IOException ignored) { } + inputPipeWriter = null; + } closePipe(inputPipe); closePipe(outputPipe); inputPipe = null; diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/NodeDatabaseHelper.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/NodeDatabaseHelper.java index 55643f04a0..6d53400bd4 100644 --- a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/NodeDatabaseHelper.java +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/NodeDatabaseHelper.java @@ -101,12 +101,12 @@ public synchronized Cursor getDataItemsByHostAndPath(String packageName, String * Capabilities are stored as data items with path '/capabilities/<pkg>/<sig>/<capabilityName>'. */ public synchronized Cursor getAllCapabilityItems() { - // Extract the last path segment (capability name) using reverse string operations: - // instr(reverse(path), '/') finds the position of the first '/' from the end, - // then substr takes everything after that position (i.e. the last path segment). + // Paths always match '/capabilities/...', so the last segment (the capability name) is + // everything after the final '/'. We use rtrim+replace to locate that last '/' without + // relying on the non-standard reverse() function that is absent from stock SQLite. return getReadableDatabase().rawQuery( "SELECT DISTINCT host, " + - "substr(path, length(path) - instr(reverse(path), '/') + 2) AS capability " + + "substr(path, length(rtrim(path, replace(path, '/', ''))) + 1) AS capability " + "FROM appKeyDataItems " + "WHERE path LIKE '/capabilities/%' AND deleted=0", null); 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 4c562fd022..875ec12b5b 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 @@ -622,12 +622,18 @@ public void disableConnection(String name) { configDatabase.setEnabledState(name, false); configurationsUpdated = true; if (name.equals("server") && sct != null) { - activeConnections.remove(sct.getWearableConnection()); + WearableConnection conn = sct.getWearableConnection(); + if (conn != null) { + activeConnections.values().remove(conn); + } sct.close(); sct.interrupt(); sct = null; } else if ((name.equals("bluetooth-server") || name.startsWith("bluetooth-client:")) && bct != null) { - activeConnections.remove(bct.getWearableConnection()); + WearableConnection conn = bct.getWearableConnection(); + if (conn != null) { + activeConnections.values().remove(conn); + } bct.close(); bct.interrupt(); bct = null; From 8044fba1c4a3099150f133e62bf148e26b18b509 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:54:14 +0100 Subject: [PATCH 09/12] Fix NewApi lint error: replace AtomicLong#updateAndGet with CAS loop * Fix lint error: replace AtomicLong#updateAndGet (API 24) with CAS loop for API 19 compat Co-authored-by: samuel-asleep <210051637+samuel-asleep@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: samuel-asleep <210051637+samuel-asleep@users.noreply.github.com> --- .../main/java/org/microg/gms/wearable/ChannelManager.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ChannelManager.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ChannelManager.java index 653668c649..8fe98cfe37 100644 --- a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ChannelManager.java +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ChannelManager.java @@ -395,7 +395,13 @@ private void handleIncomingControl(String sourceNodeId, String requestPath, if (type == CONTROL_TYPE_OPEN) { // Advance our local counter past any peer-assigned id to prevent collisions. - nextChannelId.updateAndGet(current -> Math.max(current, channelId + 1)); + // Use a CAS loop instead of updateAndGet (which requires API 24) for API 19 compat. + long minId = channelId + 1; + long current; + do { + current = nextChannelId.get(); + if (current >= minId) break; + } while (!nextChannelId.compareAndSet(current, minId)); String token = Long.toString(channelId); ChannelState state = new ChannelState(token, channelId, sourceNodeId, path); channels.put(channelId, state); From a3eb61262548dca74fa4cff09c6b89d3fd45d223 Mon Sep 17 00:00:00 2001 From: Akinniranye Samuel Tomiwa Date: Wed, 11 Mar 2026 13:51:50 +0100 Subject: [PATCH 10/12] feat: Add initial AndroidManifest.xml defining permissions, features, and application settings for the core module. --- play-services-core/src/main/AndroidManifest.xml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/play-services-core/src/main/AndroidManifest.xml b/play-services-core/src/main/AndroidManifest.xml index 044e83d11c..77607544c6 100644 --- a/play-services-core/src/main/AndroidManifest.xml +++ b/play-services-core/src/main/AndroidManifest.xml @@ -147,7 +147,9 @@ - + From d6d08e9bb0d3594e64f88c556b596859b0f7a9d5 Mon Sep 17 00:00:00 2001 From: Akinniranye Samuel Tomiwa Date: Wed, 11 Mar 2026 14:06:26 +0100 Subject: [PATCH 11/12] fix: move onBindPreferences() --- .../src/main/kotlin/org/microg/gms/ui/WearableFragment.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/play-services-core/src/main/kotlin/org/microg/gms/ui/WearableFragment.kt b/play-services-core/src/main/kotlin/org/microg/gms/ui/WearableFragment.kt index 2c65ea9f0a..2c2a3bbd14 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/ui/WearableFragment.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/ui/WearableFragment.kt @@ -18,10 +18,8 @@ class WearableFragment : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.preferences_wearable) - } - override fun onBindPreferences() { - autoAcceptTos = preferenceScreen.findPreference(PREF_AUTO_ACCEPT_TOS) ?: autoAcceptTos + autoAcceptTos = preferenceScreen.findPreference(PREF_AUTO_ACCEPT_TOS)!! autoAcceptTos.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> val appContext = requireContext().applicationContext lifecycleScope.launchWhenResumed { From de9a651064c83f2bd7ba3666540674eb569f2c1a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:28:28 +0100 Subject: [PATCH 12/12] fix: sign release APKs with debug keystore to prevent install failure * Initial plan * fix: add default debug signing to release build types to fix APK install failure Co-authored-by: samuel-asleep <210051637+samuel-asleep@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: samuel-asleep <210051637+samuel-asleep@users.noreply.github.com> --- play-services-core/build.gradle | 3 +++ vending-app/build.gradle | 3 +++ 2 files changed, 6 insertions(+) diff --git a/play-services-core/build.gradle b/play-services-core/build.gradle index 8fc2896bbc..7d978cdb3c 100644 --- a/play-services-core/build.gradle +++ b/play-services-core/build.gradle @@ -161,6 +161,9 @@ android { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + // Default to debug signing so the APK is installable out of the box. + // Override signingConfig in user.gradle for production/release builds. + signingConfig signingConfigs.debug } } diff --git a/vending-app/build.gradle b/vending-app/build.gradle index b0ba03685d..357cf3cb36 100644 --- a/vending-app/build.gradle +++ b/vending-app/build.gradle @@ -39,6 +39,9 @@ android { optimizeCode true proguardFile 'proguard-rules.pro' } + // Default to debug signing so the APK is installable out of the box. + // Override signingConfig in user.gradle for production/release builds. + signingConfig signingConfigs.debug } }