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/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/play-services-core/src/main/AndroidManifest.xml b/play-services-core/src/main/AndroidManifest.xml index 7efdc9bf29..f41e2cdcc7 100644 --- a/play-services-core/src/main/AndroidManifest.xml +++ b/play-services-core/src/main/AndroidManifest.xml @@ -138,10 +138,18 @@ + + + + + + @@ -265,7 +273,9 @@ - + - + + + + + + + + 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/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..2c2a3bbd14 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/ui/WearableFragment.kt @@ -0,0 +1,50 @@ +/* + * 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) + + autoAcceptTos = preferenceScreen.findPreference(PREF_AUTO_ACCEPT_TOS)!! + 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/kotlin/org/microg/gms/wearable/notification/WearableNotificationService.kt b/play-services-core/src/main/kotlin/org/microg/gms/wearable/notification/WearableNotificationService.kt new file mode 100644 index 0000000000..ff112e068e --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/wearable/notification/WearableNotificationService.kt @@ -0,0 +1,178 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wearable.notification + +import android.app.Notification +import android.os.Build +import android.service.notification.NotificationListenerService +import android.service.notification.StatusBarNotification +import android.util.Log +import org.microg.gms.wearable.NotificationBridge +import org.microg.gms.wearable.WearableService +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream +import java.util.concurrent.atomic.AtomicInteger + +private const val TAG = "WearNotificationSvc" + +/** Path used for forwarding bridged Android notifications to wearable peers. */ +const val NOTIFICATION_PATH = "/wearable/notification" + +/** Monotonic counter used to assign collision-free UIDs to bridged notifications. */ +private val uidCounter = AtomicInteger(1) + +/** + * [NotificationListenerService] that bridges Android notifications to connected + * Wear OS peers via the microG WearableImpl message transport. + * + * Filters out: + * - Notifications originating from this GmsCore package itself. + * - Ongoing notifications ([Notification.FLAG_ONGOING_EVENT]). + * - Non-clearable notifications ([Notification.FLAG_NO_CLEAR]). + */ +class WearableNotificationService : NotificationListenerService() { + + /** + * 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() + + override fun onNotificationPosted(sbn: StatusBarNotification) { + if (shouldSkip(sbn)) return + + // Assign a stable, collision-free UID for this notification. + val uid = keyToUid.getOrPut(sbnKey(sbn)) { uidCounter.getAndIncrement() } + NotificationBridge.activeNotifications[uid] = sbn + + val payload = encodeNotification(uid, sbn) ?: return + sendToWearable(payload) + } + + override fun onNotificationRemoved(sbn: StatusBarNotification) { + val uid = keyToUid.remove(sbnKey(sbn)) ?: return + NotificationBridge.activeNotifications.remove(uid) + + // Notify peers that this notification was dismissed + val payload = encodeRemoval(uid, sbnKey(sbn)) ?: 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 +// ------------------------------------------------------------------------- + +/** + * 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) + * - 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(sbnKey(sbn)) + 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-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. + 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-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" /> + + + + + + + + + diff --git a/play-services-wearable/core/src/main/AndroidManifest.xml b/play-services-wearable/core/src/main/AndroidManifest.xml index 9df69b73bd..f6027c8159 100644 --- a/play-services-wearable/core/src/main/AndroidManifest.xml +++ b/play-services-wearable/core/src/main/AndroidManifest.xml @@ -6,6 +6,12 @@ + + + + + + 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 new file mode 100644 index 0000000000..4a97f79987 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/CallBridge.java @@ -0,0 +1,280 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wearable; + +import android.annotation.SuppressLint; +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+. + */ + @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); + 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. + */ + @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); + 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+. + */ + @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); + 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/ChannelManager.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ChannelManager.java new file mode 100644 index 0000000000..8fe98cfe37 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/ChannelManager.java @@ -0,0 +1,627 @@ +/* + * 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.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.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +import okio.ByteString; + +/** + * Manages the lifecycle and I/O for Wearable Channel API channels. + *

+ * 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"; + + /** 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 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, + String packageName, String signatureDigest) { + if (!wearable.hasActiveConnection(targetNodeId)) { + Log.w(TAG, "openChannel: node " + targetNodeId + " is not connected"); + return null; + } + + 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 {@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 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) { + 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, "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; + } + + /** + * 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 PFD, or {@code null} if the channel does not exist + */ + public ParcelFileDescriptor getInputStream(String token) { + ChannelState state = stateForToken(token); + if (state == null) { + Log.w(TAG, "getInputStream: unknown channel " + token); + return null; + } + try { + if (state.inputPipe == null) { + state.inputPipe = ParcelFileDescriptor.createPipe(); + } + return state.inputPipe[0]; // read end + } catch (IOException e) { + Log.e(TAG, "getInputStream: failed to create pipe for channel " + token, e); + return null; + } + } + + /** + * 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 PFD, or {@code null} if the channel does not exist + */ + public ParcelFileDescriptor getOutputStream(String token) { + ChannelState state = stateForToken(token); + if (state == null) { + Log.w(TAG, "getOutputStream: unknown channel " + token); + return null; + } + try { + if (state.outputPipe == null) { + state.outputPipe = ParcelFileDescriptor.createPipe(); + startOutputForwarder(state); + } + return state.outputPipe[1]; // write end for caller + } catch (IOException e) { + Log.e(TAG, "getOutputStream: failed to create pipe for channel " + token, e); + return null; + } + } + + /** + * 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 destination file descriptor + * @return {@code true} if the background copy was started + */ + public boolean writeInputToFd(String token, ParcelFileDescriptor fd) { + ChannelState state = stateForToken(token); + if (state == null) { + Log.w(TAG, "writeInputToFd: unknown channel " + token); + return false; + } + try { + if (state.inputPipe == null) { + state.inputPipe = ParcelFileDescriptor.createPipe(); + } + final ParcelFileDescriptor readEnd = state.inputPipe[0]; + new Thread(() -> { + try (InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(readEnd); + OutputStream out = new ParcelFileDescriptor.AutoCloseOutputStream(fd)) { + 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, "writeInputToFd error for channel " + token, e); + } + }, "WearChanIn-" + token).start(); + return true; + } catch (IOException e) { + Log.e(TAG, "writeInputToFd: failed to create pipe", e); + return false; + } + } + + /** + * 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 + * @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 = stateForToken(token); + if (state == null) { + Log.w(TAG, "readOutputFromFd: unknown channel " + token); + return false; + } + try { + if (state.outputPipe == null) { + state.outputPipe = ParcelFileDescriptor.createPipe(); + startOutputForwarder(state); + } + 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, "readOutputFromFd: only skipped " + skipped + "/" + startOffset); + } + } + 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(buf, 0, n); + total += n; + } + } catch (IOException e) { + Log.e(TAG, "readOutputFromFd error for channel " + token, e); + } + }, "WearChanOut-" + token).start(); + return true; + } catch (IOException e) { + Log.e(TAG, "readOutputFromFd: failed to create pipe", e); + return false; + } + } + + // ------------------------------------------------------------------------- + // Incoming message handling (called from MessageHandler) + // ------------------------------------------------------------------------- + + /** + * 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 peer node that sent the message + * @param request the channel request proto + */ + public void handleIncomingChannelMessage(String sourceNodeId, Request request) { + if (request == null || request.request == null) { + Log.w(TAG, "handleIncomingChannelMessage: null or empty request from " + sourceNodeId); + return; + } + 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 {@code true} if the given token corresponds to an open channel. + */ + public boolean isChannelOpen(String token) { + Long id = tokenToId.get(token); + return id != null && channels.containsKey(id); + } + + /** + * Closes all open channels. Called during service shutdown. + */ + public void closeAll() { + 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 (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. + // 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); + 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(); + // 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]); + } + if (data.payload != null && data.payload.size() > 0) { + try { + state.inputPipeWriter.write(data.payload.toByteArray()); + } catch (IOException e) { + // 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. + // 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); + } + } 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); + } + } + } + + /** + * 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 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; + this.channelId = channelId; + this.nodeId = nodeId; + this.path = path; + } + + void close() { + if (inputPipeWriter != null) { + try { inputPipeWriter.close(); } catch (IOException ignored) { } + inputPipeWriter = null; + } + closePipe(inputPipe); + closePipe(outputPipe); + inputPipe = null; + outputPipe = null; + } + + private static void closePipe(ParcelFileDescriptor[] pipe) { + 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/MediaBridge.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MediaBridge.java new file mode 100644 index 0000000000..829f314844 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MediaBridge.java @@ -0,0 +1,400 @@ +/* + * 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 androidx.annotation.RequiresApi; + +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 + // ------------------------------------------------------------------------- + + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + 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); + } + } + + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + 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/MessageHandler.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/MessageHandler.java index 0f12d92edd..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 @@ -176,5 +176,8 @@ public void onFilePiece(FilePiece filePiece) { @Override public void onChannelRequest(Request channelRequest) { Log.d(TAG, "onChannelRequest:" + channelRequest); + String sourceNodeId = channelRequest.sourceNodeId != null ? channelRequest.sourceNodeId : peerNodeId; + // 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/NodeDatabaseHelper.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/NodeDatabaseHelper.java index 8b86fee197..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 @@ -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() { + // 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(rtrim(path, replace(path, '/', ''))) + 1) 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/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/WearableImpl.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableImpl.java index 1f0ed12669..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 @@ -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; @@ -34,6 +36,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; @@ -55,6 +58,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; @@ -87,11 +92,13 @@ 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; private CountDownLatch networkHandlerLock = new CountDownLatch(1); public Handler networkHandler; + private final ChannelManager channelManager; public WearableImpl(Context context, NodeDatabaseHelper nodeDatabase, ConfigurationDatabaseHelper configDatabase) { this.context = context; @@ -99,12 +106,15 @@ 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()); networkHandlerLock.countDown(); Looper.loop(); }).start(); + CallBridge.start(context, this); + MediaBridge.start(context, this); } public String getLocalNodeId() { @@ -392,6 +402,48 @@ public List getConnectedNodesParcelableList() { return nodes; } + /** + * Returns a set of all currently connected node IDs. + */ + 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. + */ + public ChannelManager getChannelManager() { + return channelManager; + } + interface ListenerInvoker { void invoke(IWearableListener listener) throws RemoteException; } @@ -487,6 +539,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()); @@ -511,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"); + } } } @@ -518,10 +622,21 @@ 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) { + WearableConnection conn = bct.getWearableConnection(); + if (conn != null) { + activeConnections.values().remove(conn); + } + bct.close(); + bct.interrupt(); + bct = null; } } @@ -547,6 +662,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())); @@ -581,7 +706,7 @@ private void closeConnection(String nodeId) { } catch (IOException e1) { Log.w(TAG, e1); } - if (connection == sct.getWearableConnection()) { + if (sct != null && connection == sct.getWearableConnection()) { sct.close(); sct = null; } @@ -622,6 +747,9 @@ 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(); this.networkHandler.getLooper().quit(); 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 1fb2c589eb..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 @@ -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; @@ -234,12 +236,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 +251,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 +283,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 +296,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 +318,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 +343,30 @@ 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"); + CallBridge.endCall(context); + callbacks.onStatus(Status.SUCCESS); } @Override public void acceptRingingCall(IWearableCallbacks callbacks) throws RemoteException { - Log.d(TAG, "unimplemented Method: acceptRingingCall"); + CallBridge.answerCall(context); + callbacks.onStatus(Status.SUCCESS); } @Override public void silenceRinger(IWearableCallbacks callbacks) throws RemoteException { - Log.d(TAG, "unimplemented Method: silenceRinger"); + CallBridge.silenceRinger(context); + callbacks.onStatus(Status.SUCCESS); } /* @@ -365,22 +375,19 @@ 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); + NotificationBridge.doPositiveAction(context, i); + callbacks.onStatus(Status.SUCCESS); } @Override public void doAncsNegativeAction(IWearableCallbacks callbacks, int i) throws RemoteException { - Log.d(TAG, "unimplemented Method: doAncsNegativeAction: " + i); - } - - @Override - public void openChannel(IWearableCallbacks callbacks, String s1, String s2) throws RemoteException { - Log.d(TAG, "unimplemented Method: openChannel; " + s1 + ", " + s2); + NotificationBridge.doNegativeAction(context, i); + callbacks.onStatus(Status.SUCCESS); } /* @@ -388,39 +395,85 @@ 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); + public void openChannel(IWearableCallbacks callbacks, String targetNodeId, String path) throws RemoteException { + Log.d(TAG, "openChannel: " + targetNodeId + ", " + path); + postMain(callbacks, () -> { + 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 { + callbacks.onOpenChannelResponse(new OpenChannelResponse(8, null)); + } + }); } @Override - public void closeChannelWithError(IWearableCallbacks callbacks, String s, int errorCode) throws RemoteException { - Log.d(TAG, "unimplemented Method: closeChannelWithError:" + s + ", " + errorCode); + 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 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, "unimplemented Method: getChannelInputStream: " + s); + 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, "unimplemented Method: getChannelOutputStream: " + s); + 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, "unimplemented Method: writeChannelInputToFd: " + s); + 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, "unimplemented Method: readChannelOutputFromFd: " + s + ", " + l1 + ", " + l2); + 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 public void syncWifiCredentials(IWearableCallbacks callbacks) throws RemoteException { - Log.d(TAG, "unimplemented Method: syncWifiCredentials"); + callbacks.onStatus(Status.SUCCESS); } /* @@ -430,7 +483,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 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); } 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 } }