diff --git a/play-services-core/src/main/AndroidManifest.xml b/play-services-core/src/main/AndroidManifest.xml index 66038da7e2..c6b36cfa6d 100644 --- a/play-services-core/src/main/AndroidManifest.xml +++ b/play-services-core/src/main/AndroidManifest.xml @@ -136,8 +136,16 @@ android:protectionLevel="signature"/> + + + + + + + + diff --git a/play-services-wearable/core/src/main/AndroidManifest.xml b/play-services-wearable/core/src/main/AndroidManifest.xml index 9df69b73bd..e254e84af0 100644 --- a/play-services-wearable/core/src/main/AndroidManifest.xml +++ b/play-services-wearable/core/src/main/AndroidManifest.xml @@ -6,6 +6,16 @@ + + + + + + + + diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/BluetoothWearableConnection.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/BluetoothWearableConnection.java new file mode 100644 index 0000000000..ce6bbf7930 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/BluetoothWearableConnection.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2013-2019 microG Project Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.microg.gms.wearable; + +import android.bluetooth.BluetoothSocket; + +import org.microg.wearable.WearableConnection; +import org.microg.wearable.proto.MessagePiece; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.lang.reflect.Method; + +/** + * Bluetooth transport implementation for Wearable connections. + * Uses RFCOMM sockets to communicate with WearOS devices over Bluetooth Classic. + */ +public class BluetoothWearableConnection extends WearableConnection { + private final int MAX_PIECE_SIZE = 20 * 1024 * 1024; // 20MB limit + private final BluetoothSocket socket; + private final DataInputStream is; + private final DataOutputStream os; + + public BluetoothWearableConnection(BluetoothSocket socket, Listener listener) throws IOException { + super(listener); + this.socket = socket; + this.is = new DataInputStream(socket.getInputStream()); + this.os = new DataOutputStream(socket.getOutputStream()); + } + + @Override + protected void writeMessagePiece(MessagePiece piece) throws IOException { + byte[] bytes = piece.encode(); + os.writeInt(bytes.length); + os.write(bytes); + os.flush(); + } + + @Override + protected MessagePiece readMessagePiece() throws IOException { + int len = is.readInt(); + if (len > MAX_PIECE_SIZE || len < 0) { + throw new IOException("Piece size " + len + " exceeded limit of " + MAX_PIECE_SIZE + " bytes."); + } + byte[] bytes = new byte[len]; + is.readFully(bytes); + + // Use reflection to call wire.parseFrom() to work around Wire version incompatibility + // The inherited 'wire' instance from WearableConnection uses Wire 1.6.1 API + try { + Method parseFrom = wire.getClass().getMethod("parseFrom", byte[].class, Class.class); + return (MessagePiece) parseFrom.invoke(wire, bytes, MessagePiece.class); + } catch (Exception e) { + throw new IOException("Failed to deserialize MessagePiece: " + e.getMessage(), e); + } + } + + public String getRemoteAddress() { + return socket.getRemoteDevice().getAddress(); + } + + @Override + public void close() throws IOException { + socket.close(); + } +} 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..45361c43c2 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 @@ -27,6 +27,9 @@ import android.text.TextUtils; import android.util.Base64; import android.util.Log; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothSocket; import androidx.annotation.Nullable; @@ -69,6 +72,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.concurrent.CountDownLatch; import okio.ByteString; @@ -76,6 +80,8 @@ public class WearableImpl { private static final String TAG = "GmsWear"; + // Standard Serial Port Profile UUID for Bluetooth Classic + private static final UUID UUID_WEAR = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB"); private static final int WEAR_TCP_PORT = 5601; @@ -85,8 +91,10 @@ public class WearableImpl { private final Map> listeners = new HashMap>(); private final Set connectedNodes = new HashSet(); private final Map activeConnections = new HashMap(); + private final Set pendingConnections = Collections.synchronizedSet(new HashSet()); private RpcHelper rpcHelper; private SocketConnectionThread sct; + private ConnectionThread btThread; private ConnectionConfiguration[] configurations; private boolean configurationsUpdated = false; private ClockworkNodePreferences clockworkNodePreferences; @@ -105,6 +113,13 @@ public WearableImpl(Context context, NodeDatabaseHelper nodeDatabase, Configurat networkHandlerLock.countDown(); Looper.loop(); }).start(); + + // Start Bluetooth connection thread if Bluetooth is available + BluetoothAdapter btAdapter = BluetoothAdapter.getDefaultAdapter(); + if (btAdapter != null) { + btThread = new ConnectionThread(); + btThread.start(); + } } public String getLocalNodeId() { @@ -249,15 +264,25 @@ public void syncToPeer(String peerNodeId, String nodeId, long seqId) { void syncRecordToAll(DataItemRecord record) { - for (String nodeId : new ArrayList(activeConnections.keySet())) { + ArrayList nodeIds; + synchronized (activeConnections) { + nodeIds = new ArrayList(activeConnections.keySet()); + } + for (String nodeId : nodeIds) { syncRecordToPeer(nodeId, record); } } private boolean syncRecordToPeer(String nodeId, DataItemRecord record) { + WearableConnection connection; + synchronized (activeConnections) { + connection = activeConnections.get(nodeId); + } + if (connection == null) return false; + for (Asset asset : record.dataItem.getAssets().values()) { try { - syncAssetToPeer(nodeId, record, asset); + syncAssetToPeer(connection, record, asset); } catch (Exception e) { Log.w(TAG, "Could not sync asset " + asset + " for " + nodeId + " and " + record, e); closeConnection(nodeId); @@ -267,7 +292,7 @@ private boolean syncRecordToPeer(String nodeId, DataItemRecord record) { try { SetDataItem item = record.toSetDataItem(); - activeConnections.get(nodeId).writeMessage(new RootMessage.Builder().setDataItem(item).build()); + connection.writeMessage(new RootMessage.Builder().setDataItem(item).build()); } catch (Exception e) { Log.w(TAG, e); closeConnection(nodeId); @@ -276,12 +301,12 @@ private boolean syncRecordToPeer(String nodeId, DataItemRecord record) { return true; } - private void syncAssetToPeer(String nodeId, DataItemRecord record, Asset asset) throws IOException { + private void syncAssetToPeer(WearableConnection connection, DataItemRecord record, Asset asset) throws IOException { RootMessage announceMessage = new RootMessage.Builder().setAsset(new SetAsset.Builder() .digest(asset.getDigest()) .appkeys(new AppKeys(Collections.singletonList(new AppKey(record.packageName, record.signatureDigest)))) .build()).hasAsset(true).build(); - activeConnections.get(nodeId).writeMessage(announceMessage); + connection.writeMessage(announceMessage); File assetFile = createAssetFile(asset.getDigest()); String fileName = calculateDigest(announceMessage.encode()); FileInputStream fis = new FileInputStream(assetFile); @@ -290,11 +315,11 @@ private void syncAssetToPeer(String nodeId, DataItemRecord record, Asset asset) int c = 0; while ((c = fis.read(arr)) > 0) { if (lastPiece != null) { - activeConnections.get(nodeId).writeMessage(new RootMessage.Builder().filePiece(new FilePiece(fileName, false, lastPiece, null)).build()); + connection.writeMessage(new RootMessage.Builder().filePiece(new FilePiece(fileName, false, lastPiece, null)).build()); } lastPiece = ByteString.of(arr, 0, c); } - activeConnections.get(nodeId).writeMessage(new RootMessage.Builder().filePiece(new FilePiece(fileName, true, lastPiece, asset.getDigest())).build()); + connection.writeMessage(new RootMessage.Builder().filePiece(new FilePiece(fileName, true, lastPiece, asset.getDigest())).build()); } public void addAssetToDatabase(Asset asset, List appKeys) { @@ -349,7 +374,9 @@ public void onConnectReceived(WearableConnection connection, String nodeId, Conn } } Log.d(TAG, "Adding connection to list of open connections: " + connection + " with connect " + connect); - activeConnections.put(connect.id, connection); + synchronized (activeConnections) { + activeConnections.put(connect.id, connection); + } onPeerConnected(new NodeParcelable(connect.id, connect.name)); // Fetch missing assets Cursor cursor = nodeDatabase.listMissingAssets(); @@ -380,7 +407,9 @@ public void onDisconnectReceived(WearableConnection connection, Connect connect) } } Log.d(TAG, "Removing connection from list of open connections: " + connection); - activeConnections.remove(connect.id); + synchronized (activeConnections) { + activeConnections.remove(connect.id); + } onPeerDisconnected(new NodeParcelable(connect.id, connect.name)); } @@ -569,23 +598,60 @@ record = DataItemRecord.fromCursor(cursor); private IWearableListener getListener(String packageName, String action, Uri uri) { Intent intent = new Intent(action); intent.setPackage(packageName); - intent.setData(uri); + intent.setData(uri); return RemoteListenerProxy.get(context, intent, IWearableListener.class, "com.google.android.gms.wearable.BIND_LISTENER"); } - private void closeConnection(String nodeId) { - WearableConnection connection = activeConnections.get(nodeId); - try { - connection.close(); - } catch (IOException e1) { - Log.w(TAG, e1); + public boolean isPendingConnection(String address) { + return pendingConnections.contains(address); + } + + public boolean isConnectedByAddress(String address) { + synchronized (activeConnections) { + for (WearableConnection conn : activeConnections.values()) { + if (conn instanceof BluetoothWearableConnection) { + if (((BluetoothWearableConnection) conn).getRemoteAddress().equals(address)) { + return true; + } + } + } } - if (connection == sct.getWearableConnection()) { - sct.close(); - sct = null; + return false; + } + + public String getNodeIdByAddress(String address) { + synchronized (activeConnections) { + for (Map.Entry entry : activeConnections.entrySet()) { + if (entry.getValue() instanceof BluetoothWearableConnection) { + if (((BluetoothWearableConnection) entry.getValue()).getRemoteAddress().equals(address)) { + return entry.getKey(); + } + } + } + } + return null; + } + + public void closeConnection(String nodeId) { + WearableConnection connection; + synchronized (activeConnections) { + connection = activeConnections.get(nodeId); + activeConnections.remove(nodeId); + } + + if (connection != null) { + try { + connection.close(); + } catch (IOException e1) { + Log.w(TAG, e1); + } + if (sct != null && connection == sct.getWearableConnection()) { + sct.close(); + sct = null; + } } - activeConnections.remove(nodeId); + for (ConnectionConfiguration config : getConfigurations()) { if (nodeId.equals(config.nodeId) || nodeId.equals(config.peerNodeId)) { config.connected = false; @@ -621,6 +687,12 @@ public int sendMessage(String packageName, String targetNodeId, String path, byt return -1; } + public java.util.Set getConnectedNodes() { + synchronized (activeConnections) { + return new java.util.HashSet<>(activeConnections.keySet()); + } + } + public void stop() { try { this.networkHandlerLock.await(); @@ -628,6 +700,10 @@ public void stop() { } catch (InterruptedException e) { Log.w(TAG, e); } + if (btThread != null) { + btThread.interrupt(); + btThread = null; + } } private class ListenerInfo { @@ -639,4 +715,125 @@ private ListenerInfo(IWearableListener listener, IntentFilter[] filters) { this.filters = filters; } } + + private class ConnectionThread extends Thread { + + + @Override + public void run() { + while (!isInterrupted()) { + try { + BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); + if (adapter != null && adapter.isEnabled()) { + // Check BLUETOOTH_CONNECT permission for Android 12+ + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + if (context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) + != android.content.pm.PackageManager.PERMISSION_GRANTED) { + Log.w(TAG, "BLUETOOTH_CONNECT permission not granted, skipping device scan"); + Thread.sleep(10000); + continue; + } + } + + Set bondedDevices = adapter.getBondedDevices(); + if (bondedDevices != null) { + for (BluetoothDevice device : bondedDevices) { + // Synchronized check for existing connections to this device + boolean isConnected = false; + + // Check active connections + synchronized (activeConnections) { + for (WearableConnection conn : activeConnections.values()) { + if (conn instanceof BluetoothWearableConnection) { + if (((BluetoothWearableConnection) conn).getRemoteAddress().equals(device.getAddress())) { + isConnected = true; + break; + } + } + } + } + + // Check pending connections (race condition fix) + if (!isConnected && pendingConnections.contains(device.getAddress())) { + isConnected = true; + } + + if (isConnected) { + continue; + } + + pendingConnections.add(device.getAddress()); + Log.d(TAG, "Attempting BT connection to " + device.getName() + " (" + device.getAddress() + ")"); + + BluetoothSocket socket = null; + try { + // Create RFCOMM socket using SPP UUID + socket = device.createRfcommSocketToServiceRecord(UUID_WEAR); + socket.connect(); + + if (socket.isConnected()) { + Log.d(TAG, "Successfully connected via Bluetooth to " + device.getName()); + + // Create wearable connection wrapper + ConnectionConfiguration config = new ConnectionConfiguration(device.getName(), device.getAddress(), 3, 0, true); + MessageHandler messageHandler = new MessageHandler(context, WearableImpl.this, config); + BluetoothWearableConnection connection = new BluetoothWearableConnection(socket, messageHandler); + + // Enable auto-close on error + // connection.setListener(messageHandler); // Implied by constructor + + // Start message processing thread + new Thread(connection).start(); + + try { + // Send our identity to the watch + String localId = getLocalNodeId(); + + // TODO: We should probably get the actual device name from Settings? + // Using "Phone" for now as it seems to be the default GMS behavior. + connection.writeMessage( + new RootMessage.Builder() + .connect(new Connect.Builder() + .id(localId) + .name("Phone") + .networkId(localId) + .peerAndroidId(0L) + .peerVersion(2) // Need at least version 2 for modern WearOS + .build()) + .build() + ); + } catch (IOException e) { + Log.w(TAG, "Handshake failed, closing connection", e); + connection.close(); + } + } + } catch (IOException e) { + Log.d(TAG, "BT connection failed: " + e.getMessage()); + if (socket != null) { + try { + socket.close(); + } catch (IOException closeErr) { + // Ignore + } + } + } finally { + pendingConnections.remove(device.getAddress()); + } + + } + } + } + } catch (Exception e) { + Log.w(TAG, "Error in Bluetooth ConnectionThread", e); + } + + // Wait before next scan attempt + try { + Thread.sleep(10000); + } catch (InterruptedException e) { + break; + } + } + } + } } 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..342565bb62 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 @@ -33,18 +33,22 @@ public WearableService() { super("GmsWearSvc", GmsService.WEAR); } + public static volatile WearableImpl impl; + @Override public void onCreate() { super.onCreate(); ConfigurationDatabaseHelper configurationDatabaseHelper = new ConfigurationDatabaseHelper(getApplicationContext()); NodeDatabaseHelper nodeDatabaseHelper = new NodeDatabaseHelper(getApplicationContext()); wearable = new WearableImpl(getApplicationContext(), nodeDatabaseHelper, configurationDatabaseHelper); + impl = wearable; } @Override public void onDestroy() { super.onDestroy(); wearable.stop(); + impl = null; } @Override diff --git a/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableSettingsActivity.java b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableSettingsActivity.java new file mode 100644 index 0000000000..7dabc5abe7 --- /dev/null +++ b/play-services-wearable/core/src/main/java/org/microg/gms/wearable/WearableSettingsActivity.java @@ -0,0 +1,182 @@ +package org.microg.gms.wearable; + +import android.app.Activity; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.os.Bundle; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.TextView; +import android.view.View; +import android.widget.Toast; + +import org.microg.gms.wearable.core.R; + +import java.util.ArrayList; +import java.util.Set; + +public class WearableSettingsActivity extends Activity { + + private ListView listView; + private TextView emptyView; + private WearableDeviceAdapter deviceAdapter; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.wearable_settings_activity); + + listView = findViewById(R.id.device_list); + emptyView = findViewById(R.id.empty_view); + listView.setEmptyView(emptyView); + } + + @Override + protected void onResume() { + super.onResume(); + refreshList(); + } + + private void refreshList() { + ArrayList deviceList = new ArrayList<>(); + BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); + + if (adapter == null || !adapter.isEnabled()) { + emptyView.setText("Bluetooth is disabled"); + return; + } + + // Check BLUETOOTH_CONNECT permission for Android 12+ + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + if (checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) + != android.content.pm.PackageManager.PERMISSION_GRANTED) { + emptyView.setText("Bluetooth permission not granted"); + return; + } + } + + Set bondedDevices = adapter.getBondedDevices(); + if (bondedDevices != null) { + deviceList.addAll(bondedDevices); + } + + Set connectedNodes = null; + WearableImpl service = WearableService.impl; + if (service != null) { + connectedNodes = service.getConnectedNodes(); + } + + deviceAdapter = new WearableDeviceAdapter(this, deviceList, connectedNodes); + listView.setAdapter(deviceAdapter); + + listView.setOnItemClickListener((parent, view, position, id) -> { + BluetoothDevice device = deviceAdapter.getItem(position); + boolean isConnected = false; + WearableImpl currentService = WearableService.impl; + if (currentService != null) { + // Check exact Bluetooth address match + isConnected = currentService.isConnectedByAddress(device.getAddress()); + } + + android.app.AlertDialog.Builder builder = new android.app.AlertDialog.Builder(this); + builder.setTitle(device.getName()); + + if (isConnected) { + builder.setMessage("This device is currently connected via MicroG."); + builder.setPositiveButton("Disconnect", (dialog, which) -> { + if (WearableService.impl != null) { + String nodeId = WearableService.impl.getNodeIdByAddress(device.getAddress()); + if (nodeId != null) { + WearableService.impl.closeConnection(nodeId); + Toast.makeText(this, "Disconnected", Toast.LENGTH_SHORT).show(); + refreshList(); + } else { + Toast.makeText(this, "Could not find connection for device", Toast.LENGTH_SHORT).show(); + } + } + }); + } else { + builder.setMessage("This device is acting as a WearOS peer."); + builder.setPositiveButton("Connect", (dialog, which) -> { + // Connection is automatic, but we can trigger a scan or hint + Toast.makeText(this, "MicroG is managing connections automatically.", Toast.LENGTH_SHORT).show(); + }); + } + builder.setNeutralButton("Bluetooth Settings", (dialog, which) -> { + try { + startActivity(new android.content.Intent(android.provider.Settings.ACTION_BLUETOOTH_SETTINGS)); + } catch (Exception e) {} + }); + builder.setNegativeButton("Cancel", null); + builder.show(); + }); + } + + @Override + public boolean onCreateOptionsMenu(android.view.Menu menu) { + menu.add(0, 1, 0, "Scan for Devices") + .setIcon(android.R.drawable.ic_menu_search) + .setShowAsAction(android.view.MenuItem.SHOW_AS_ACTION_ALWAYS); + return true; + } + + @Override + public boolean onOptionsItemSelected(android.view.MenuItem item) { + if (item.getItemId() == 1) { + try { + startActivity(new android.content.Intent(android.provider.Settings.ACTION_BLUETOOTH_SETTINGS)); + } catch (Exception e) { + Toast.makeText(this, "Cannot open Bluetooth settings", Toast.LENGTH_SHORT).show(); + } + return true; + } + return super.onOptionsItemSelected(item); + } + + private static class WearableDeviceAdapter extends ArrayAdapter { + private final Set connectedNodes; + + public WearableDeviceAdapter(android.content.Context context, ArrayList devices, Set connectedNodes) { + super(context, 0, devices); + this.connectedNodes = connectedNodes; + } + + @Override + @android.annotation.SuppressLint("MissingPermission") + public android.view.View getView(int position, android.view.View convertView, android.view.ViewGroup parent) { + if (convertView == null) { + convertView = android.view.LayoutInflater.from(getContext()).inflate(R.layout.wearable_device_item, parent, false); + } + + BluetoothDevice device = getItem(position); + TextView nameView = convertView.findViewById(R.id.device_name); + TextView addressView = convertView.findViewById(R.id.device_address); + TextView statusView = convertView.findViewById(R.id.device_status); + android.widget.ImageView iconView = convertView.findViewById(R.id.device_icon); + + nameView.setText(device.getName()); + addressView.setText(device.getAddress()); + + // Accurate status logic + boolean isConnected = false; + if (WearableService.impl != null) { + isConnected = WearableService.impl.isConnectedByAddress(device.getAddress()); + } else if (connectedNodes != null && connectedNodes.contains(device.getAddress())) { + // Fallback + isConnected = true; + } + + if (isConnected) { + statusView.setText("Connected"); + statusView.setTextColor(getContext().getResources().getColor(android.R.color.holo_green_dark)); + iconView.setAlpha(1.0f); + } else { + statusView.setText("Bonded"); + statusView.setTextColor(getContext().getResources().getColor(android.R.color.darker_gray)); + iconView.setAlpha(0.6f); + } + + return convertView; + } + } +} diff --git a/play-services-wearable/core/src/main/res/layout/wearable_device_item.xml b/play-services-wearable/core/src/main/res/layout/wearable_device_item.xml new file mode 100644 index 0000000000..0e88c4dbed --- /dev/null +++ b/play-services-wearable/core/src/main/res/layout/wearable_device_item.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + diff --git a/play-services-wearable/core/src/main/res/layout/wearable_settings_activity.xml b/play-services-wearable/core/src/main/res/layout/wearable_settings_activity.xml new file mode 100644 index 0000000000..ba4714d5e6 --- /dev/null +++ b/play-services-wearable/core/src/main/res/layout/wearable_settings_activity.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/play-services-wearable/core/src/main/res/values/strings.xml b/play-services-wearable/core/src/main/res/values/strings.xml new file mode 100644 index 0000000000..08f60e89cb --- /dev/null +++ b/play-services-wearable/core/src/main/res/values/strings.xml @@ -0,0 +1,9 @@ + + + Wearable Devices + Manage Bluetooth connection to WearOS devices + No paired Bluetooth devices found. + Connected + Disconnected + Connect +