diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PairingCallback.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PairingCallback.java new file mode 100644 index 0000000000..261335cbdf --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PairingCallback.java @@ -0,0 +1,35 @@ +/* Copyright (C) 2024 Gadgetbridge contributors + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.ble; + +import android.bluetooth.BluetoothGatt; + +/** + * Callback interface for pairing flow completion. + * Allows pairing flows to notify PebbleGATTClient when pairing is complete + * and the appropriate subscription chain should begin. + */ +interface PairingCallback { + /** + * Called when pairing is complete (or skipped) and subscription chain should start. + * + * @param gatt The GATT connection + * @param hasConnectivityCharacteristics true if device has connectivity characteristic (modern), + * false if using connection parameters characteristic (legacy) + */ + void onPairingComplete(BluetoothGatt gatt, boolean hasConnectivityCharacteristics); +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java index ad85534ee5..714c94ef6f 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java @@ -17,7 +17,6 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.ble; import static android.bluetooth.BluetoothGattCharacteristic.FORMAT_UINT16; -import static android.bluetooth.BluetoothGattCharacteristic.PROPERTY_WRITE; import android.annotation.SuppressLint; import android.bluetooth.BluetoothDevice; @@ -26,14 +25,7 @@ import android.bluetooth.BluetoothGattCallback; import android.bluetooth.BluetoothGattCharacteristic; import android.bluetooth.BluetoothGattDescriptor; import android.bluetooth.BluetoothGattService; -import android.content.BroadcastReceiver; import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.os.Handler; -import android.os.Looper; - -import androidx.core.content.ContextCompat; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -51,21 +43,10 @@ import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.ValueDecoder; import nodomain.freeyourgadget.gadgetbridge.util.GB; @SuppressLint("MissingPermission") -class PebbleGATTClient extends BluetoothGattCallback { +class PebbleGATTClient extends BluetoothGattCallback implements PairingCallback { private static final Logger LOG = LoggerFactory.getLogger(PebbleGATTClient.class); - private static final UUID SERVICE_UUID = UUID.fromString("0000fed9-0000-1000-8000-00805f9b34fb"); - private static final UUID CONNECTIVITY_CHARACTERISTIC = UUID.fromString("00000001-328E-0FBB-C642-1AA6699BDADA"); - private static final UUID PAIRING_TRIGGER_CHARACTERISTIC = UUID.fromString("00000002-328E-0FBB-C642-1AA6699BDADA"); - private static final UUID MTU_CHARACTERISTIC = UUID.fromString("00000003-328e-0fbb-c642-1aa6699bdada"); - private static final UUID CONNECTION_PARAMETERS_CHARACTERISTIC = UUID.fromString("00000005-328E-0FBB-C642-1AA6699BDADA"); - private static final UUID CHARACTERISTIC_CONFIGURATION_DESCRIPTOR = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"); - - //PPoGATT service (Pebble side) - private static final UUID PPOGATT_SERVICE_UUID = UUID.fromString("30000003-328E-0FBB-C642-1AA6699BDADA"); - private static final UUID PPOGATT_CHARACTERISTIC_READ = UUID.fromString("30000004-328E-0FBB-C642-1AA6699BDADA"); - private static final UUID PPOGATT_CHARACTERISTIC_WRITE = UUID.fromString("30000006-328E-0FBB-C642-1AA6699BDADA"); private BluetoothGattCharacteristic writeCharacteristics; @@ -73,16 +54,12 @@ class PebbleGATTClient extends BluetoothGattCallback { private final PebbleLESupport mPebbleLESupport; private boolean hasConnectivityCharacteristics = false; - private final boolean doPairing = true; private BluetoothGatt mBluetoothGatt; private CountDownLatch mWaitWriteCompleteLatch; - // New pairing state management - private ConnectivityStatus mConnectivityStatus; - private CountDownLatch mBondingLatch; - private BroadcastReceiver mBondingReceiver; - private static final long BONDING_TIMEOUT_MS = 60000; // 60 seconds + // Pairing flow strategy + private PebblePairingFlow mPairingFlow; PebbleGATTClient(PebbleLESupport pebbleLESupport, Context context, BluetoothDevice btDevice) { mContext = context; @@ -96,11 +73,11 @@ class PebbleGATTClient extends BluetoothGattCallback { return; } - if (characteristic.getUuid().equals(MTU_CHARACTERISTIC)) { + if (characteristic.getUuid().equals(PebbleGATTConstants.MTU_CHARACTERISTIC)) { int newMTU = characteristic.getIntValue(FORMAT_UINT16, 0); LOG.info("Pebble requested MTU: {}", newMTU); mPebbleLESupport.setMTU(newMTU); - } else if (characteristic.getUuid().equals(PPOGATT_CHARACTERISTIC_READ)) { + } else if (characteristic.getUuid().equals(PebbleGATTConstants.PPOGATT_CHARACTERISTIC_READ)) { mPebbleLESupport.handlePPoGATTPacket(characteristic.getValue().clone()); } else if (characteristic.getUuid().equals(GattCharacteristic.UUID_CHARACTERISTIC_BATTERY_LEVEL)) { int battery_percent = ValueDecoder.decodePercent(characteristic, characteristic.getValue()); @@ -120,24 +97,19 @@ class PebbleGATTClient extends BluetoothGattCallback { if (status == BluetoothGatt.GATT_SUCCESS) { LOG.info("onCharacteristicRead() {} {}", characteristic.getUuid().toString(), GB.hexdump(characteristic.getValue(), 0, -1)); - if (characteristic.getUuid().equals(CONNECTIVITY_CHARACTERISTIC)) { - // This is required for Pebble Time 2 (EMERY) pairing - handleConnectivityRead(gatt, characteristic.getValue()); - } else if (characteristic.getUuid().equals(GattCharacteristic.UUID_CHARACTERISTIC_BATTERY_LEVEL)) { + // Delegate to pairing flow if applicable + if (mPairingFlow != null && mPairingFlow.onCharacteristicRead(gatt, characteristic, characteristic.getValue())) { + return; // Pairing flow handled it + } + + // Handle non-pairing characteristics + if (characteristic.getUuid().equals(GattCharacteristic.UUID_CHARACTERISTIC_BATTERY_LEVEL)) { int battery_percent = ValueDecoder.decodePercent(characteristic, characteristic.getValue()); LOG.info("Got battery level through read, is at {}%", battery_percent); GBDeviceEventBatteryInfo gbDeviceEventBatteryInfo = new GBDeviceEventBatteryInfo(); gbDeviceEventBatteryInfo.level = battery_percent; gbDeviceEventBatteryInfo.state = BatteryState.BATTERY_NORMAL; mPebbleLESupport.getPebbleSupport().evaluateGBDeviceEvent(gbDeviceEventBatteryInfo); - } else if (characteristic.getUuid().equals(PAIRING_TRIGGER_CHARACTERISTIC)) { - // Legacy path: this is just a hack to force sequential ble commands for initialization - // Only happens when READING the pairing trigger for old pebbles running fw 3.x - if (hasConnectivityCharacteristics) { - subscribeToConnectivity(gatt); - } else { - subscribeToConnectionParams(gatt); - } } } } @@ -162,7 +134,14 @@ class PebbleGATTClient extends BluetoothGattCallback { if (mPebbleLESupport.isUnexpectedDevice(gatt.getDevice())) { return; } - if (characteristic.getUuid().equals(PPOGATT_CHARACTERISTIC_WRITE)) { + + // Delegate to pairing flow if applicable + if (mPairingFlow != null && mPairingFlow.onCharacteristicWrite(gatt, characteristic, status)) { + return; // Pairing flow handled it + } + + // Handle non-pairing characteristics + if (characteristic.getUuid().equals(PebbleGATTConstants.PPOGATT_CHARACTERISTIC_WRITE)) { if (status != BluetoothGatt.GATT_SUCCESS) { LOG.error("something went wrong when writing to PPoGATT characteristics"); } @@ -171,19 +150,7 @@ class PebbleGATTClient extends BluetoothGattCallback { } else { LOG.warn("mWaitWriteCompleteLatch is null!"); } - } else if (characteristic.getUuid().equals(PAIRING_TRIGGER_CHARACTERISTIC)) { - // Pairing trigger written - now initiate Bluetooth bonding - // This is required for Pebble Time 2 (EMERY) pairing - LOG.info("Pairing trigger written successfully (status={}), initiating bond", status); - initiateBluetoothBond(gatt); - } else if (characteristic.getUuid().equals(CONNECTIVITY_CHARACTERISTIC)) { - // Legacy path for connectivity characteristic write - if (hasConnectivityCharacteristics) { - subscribeToConnectivity(gatt); - } else { - subscribeToConnectionParams(gatt); - } - } else if (characteristic.getUuid().equals(MTU_CHARACTERISTIC)) { + } else if (characteristic.getUuid().equals(PebbleGATTConstants.MTU_CHARACTERISTIC)) { gatt.requestMtu(339); } } @@ -200,17 +167,17 @@ class PebbleGATTClient extends BluetoothGattCallback { // this is just a hack to force sequential ble commands for initialization // kind of event driven - if (CHARACTERISTICUUID.equals(CONNECTION_PARAMETERS_CHARACTERISTIC)) { + if (CHARACTERISTICUUID.equals(PebbleGATTConstants.CONNECTION_PARAMETERS_CHARACTERISTIC)) { subscribeToConnectivity(gatt); - } else if (CHARACTERISTICUUID.equals(CONNECTIVITY_CHARACTERISTIC)) { + } else if (CHARACTERISTICUUID.equals(PebbleGATTConstants.CONNECTIVITY_CHARACTERISTIC)) { subscribeToMTUOrBattery(gatt); - } else if (CHARACTERISTICUUID.equals(MTU_CHARACTERISTIC) || CHARACTERISTICUUID.equals(GattCharacteristic.UUID_CHARACTERISTIC_BATTERY_LEVEL)) { + } else if (CHARACTERISTICUUID.equals(PebbleGATTConstants.MTU_CHARACTERISTIC) || CHARACTERISTICUUID.equals(GattCharacteristic.UUID_CHARACTERISTIC_BATTERY_LEVEL)) { if (mPebbleLESupport.clientOnly) { subscribeToPPoGATT(gatt); } else { setMTU(gatt); } - } else if (CHARACTERISTICUUID.equals(PPOGATT_CHARACTERISTIC_READ)) { + } else if (CHARACTERISTICUUID.equals(PebbleGATTConstants.PPOGATT_CHARACTERISTIC_READ)) { setMTU(gatt); } } @@ -227,41 +194,35 @@ class PebbleGATTClient extends BluetoothGattCallback { return; } - BluetoothGattService pairingService = gatt.getService(SERVICE_UUID); + BluetoothGattService pairingService = gatt.getService(PebbleGATTConstants.SERVICE_UUID); if (pairingService == null) { LOG.error("Pairing service not found"); return; } + // Detect device characteristics for subscription chain selection BluetoothGattCharacteristic connectionParamCharacteristic = - pairingService.getCharacteristic(CONNECTION_PARAMETERS_CHARACTERISTIC); + pairingService.getCharacteristic(PebbleGATTConstants.CONNECTION_PARAMETERS_CHARACTERISTIC); hasConnectivityCharacteristics = connectionParamCharacteristic == null; if (hasConnectivityCharacteristics) { LOG.info("This seems to be an older LE Pebble (Pebble Time), or a 2025 Pebble"); } - if (doPairing) { - // Read Connectivity characteristic FIRST to determine pairing state - // This is required for Pebble Time 2 (EMERY) pairing - BluetoothGattCharacteristic connectivityChar = - pairingService.getCharacteristic(CONNECTIVITY_CHARACTERISTIC); - if (connectivityChar != null) { - LOG.info("Reading connectivity characteristic to check pairing state"); - gatt.readCharacteristic(connectivityChar); - // Continue in onCharacteristicRead() -> handleConnectivityRead() - } else { - // Fallback for very old firmware - use legacy method - LOG.warn("Connectivity characteristic not found, using legacy pairing"); - proceedWithLegacyPairing(gatt); - } - } else { - if (hasConnectivityCharacteristics) { - subscribeToConnectivity(gatt); - } else { - subscribeToConnectionParams(gatt); - } + // Get device model for hardware-based pairing flow detection + String deviceModel = null; + try { + deviceModel = mPebbleLESupport.getPebbleSupport().getDevice().getModel(); + } catch (Exception e) { + LOG.warn("Could not get device model: {}", e.getMessage()); } + + // Create appropriate pairing flow + mPairingFlow = PebblePairingFlowFactory.createPairingFlow( + mContext, deviceModel, pairingService, mPebbleLESupport.clientOnly, this); + + // Start pairing + mPairingFlow.startPairing(gatt, pairingService); } @Override @@ -293,18 +254,18 @@ class PebbleGATTClient extends BluetoothGattCallback { private void subscribeToConnectivity(BluetoothGatt gatt) { LOG.info("subscribing to connectivity characteristic"); - BluetoothGattDescriptor descriptor = gatt.getService(SERVICE_UUID).getCharacteristic(CONNECTIVITY_CHARACTERISTIC).getDescriptor(CHARACTERISTIC_CONFIGURATION_DESCRIPTOR); + BluetoothGattDescriptor descriptor = gatt.getService(PebbleGATTConstants.SERVICE_UUID).getCharacteristic(PebbleGATTConstants.CONNECTIVITY_CHARACTERISTIC).getDescriptor(PebbleGATTConstants.CHARACTERISTIC_CONFIGURATION_DESCRIPTOR); NotifyAction.writeDescriptor(gatt, descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); - gatt.setCharacteristicNotification(gatt.getService(SERVICE_UUID).getCharacteristic(CONNECTIVITY_CHARACTERISTIC), true); + gatt.setCharacteristicNotification(gatt.getService(PebbleGATTConstants.SERVICE_UUID).getCharacteristic(PebbleGATTConstants.CONNECTIVITY_CHARACTERISTIC), true); } private void subscribeToMTU(BluetoothGatt gatt) { - BluetoothGattCharacteristic characteristic = gatt.getService(SERVICE_UUID).getCharacteristic(MTU_CHARACTERISTIC); + BluetoothGattCharacteristic characteristic = gatt.getService(PebbleGATTConstants.SERVICE_UUID).getCharacteristic(PebbleGATTConstants.MTU_CHARACTERISTIC); if (characteristic != null) { LOG.info("subscribing to mtu characteristic"); - BluetoothGattDescriptor descriptor = gatt.getService(SERVICE_UUID).getCharacteristic(MTU_CHARACTERISTIC).getDescriptor(CHARACTERISTIC_CONFIGURATION_DESCRIPTOR); + BluetoothGattDescriptor descriptor = gatt.getService(PebbleGATTConstants.SERVICE_UUID).getCharacteristic(PebbleGATTConstants.MTU_CHARACTERISTIC).getDescriptor(PebbleGATTConstants.CHARACTERISTIC_CONFIGURATION_DESCRIPTOR); NotifyAction.writeDescriptor(gatt, descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); - gatt.setCharacteristicNotification(gatt.getService(SERVICE_UUID).getCharacteristic(MTU_CHARACTERISTIC), true); + gatt.setCharacteristicNotification(gatt.getService(PebbleGATTConstants.SERVICE_UUID).getCharacteristic(PebbleGATTConstants.MTU_CHARACTERISTIC), true); } else { LOG.info("Could not find MTU Characteristic. This seems to be a 2025 Pebble"); } @@ -312,14 +273,14 @@ class PebbleGATTClient extends BluetoothGattCallback { private void subscribeToConnectionParams(BluetoothGatt gatt) { LOG.info("subscribing to connection parameters characteristic"); - BluetoothGattDescriptor descriptor = gatt.getService(SERVICE_UUID).getCharacteristic(CONNECTION_PARAMETERS_CHARACTERISTIC).getDescriptor(CHARACTERISTIC_CONFIGURATION_DESCRIPTOR); + BluetoothGattDescriptor descriptor = gatt.getService(PebbleGATTConstants.SERVICE_UUID).getCharacteristic(PebbleGATTConstants.CONNECTION_PARAMETERS_CHARACTERISTIC).getDescriptor(PebbleGATTConstants.CHARACTERISTIC_CONFIGURATION_DESCRIPTOR); NotifyAction.writeDescriptor(gatt, descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); - gatt.setCharacteristicNotification(gatt.getService(SERVICE_UUID).getCharacteristic(CONNECTION_PARAMETERS_CHARACTERISTIC), true); + gatt.setCharacteristicNotification(gatt.getService(PebbleGATTConstants.SERVICE_UUID).getCharacteristic(PebbleGATTConstants.CONNECTION_PARAMETERS_CHARACTERISTIC), true); } private void subscribeToMTUOrBattery(BluetoothGatt gatt) { // This is dumb, right now there is only one of them present in all pebbles - BluetoothGattCharacteristic characteristic = gatt.getService(SERVICE_UUID).getCharacteristic(MTU_CHARACTERISTIC); + BluetoothGattCharacteristic characteristic = gatt.getService(PebbleGATTConstants.SERVICE_UUID).getCharacteristic(PebbleGATTConstants.MTU_CHARACTERISTIC); if (characteristic != null) { subscribeToMTU(gatt); } else { @@ -331,7 +292,7 @@ class PebbleGATTClient extends BluetoothGattCallback { BluetoothGattCharacteristic characteristic = gatt.getService(GattService.UUID_SERVICE_BATTERY_SERVICE).getCharacteristic(GattCharacteristic.UUID_CHARACTERISTIC_BATTERY_LEVEL); if (characteristic != null) { LOG.info("subscribing to battery characteristic"); - BluetoothGattDescriptor descriptor = gatt.getService(GattService.UUID_SERVICE_BATTERY_SERVICE).getCharacteristic(GattCharacteristic.UUID_CHARACTERISTIC_BATTERY_LEVEL).getDescriptor(CHARACTERISTIC_CONFIGURATION_DESCRIPTOR); + BluetoothGattDescriptor descriptor = gatt.getService(GattService.UUID_SERVICE_BATTERY_SERVICE).getCharacteristic(GattCharacteristic.UUID_CHARACTERISTIC_BATTERY_LEVEL).getDescriptor(PebbleGATTConstants.CHARACTERISTIC_CONFIGURATION_DESCRIPTOR); NotifyAction.writeDescriptor(gatt, descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); gatt.setCharacteristicNotification(gatt.getService(GattService.UUID_SERVICE_BATTERY_SERVICE).getCharacteristic(GattCharacteristic.UUID_CHARACTERISTIC_BATTERY_LEVEL), true); } else { @@ -341,9 +302,9 @@ class PebbleGATTClient extends BluetoothGattCallback { private void setMTU(BluetoothGatt gatt) { LOG.info("setting MTU"); - BluetoothGattCharacteristic characteristic = gatt.getService(SERVICE_UUID).getCharacteristic(MTU_CHARACTERISTIC); + BluetoothGattCharacteristic characteristic = gatt.getService(PebbleGATTConstants.SERVICE_UUID).getCharacteristic(PebbleGATTConstants.MTU_CHARACTERISTIC); if (characteristic != null) { - BluetoothGattDescriptor descriptor = characteristic.getDescriptor(CHARACTERISTIC_CONFIGURATION_DESCRIPTOR); + BluetoothGattDescriptor descriptor = characteristic.getDescriptor(PebbleGATTConstants.CHARACTERISTIC_CONFIGURATION_DESCRIPTOR); descriptor.setValue(new byte[]{0x0b, 0x01}); // unknown // descriptor is not wrote back to the device, but the characteristic is. // Reason is unclear but writing back the descriptor instead of the characteristic breaks the connection. @@ -355,10 +316,10 @@ class PebbleGATTClient extends BluetoothGattCallback { private void subscribeToPPoGATT(BluetoothGatt gatt) { LOG.info("subscribing to PPoGATT read characteristic"); - BluetoothGattDescriptor descriptor = gatt.getService(PPOGATT_SERVICE_UUID).getCharacteristic(PPOGATT_CHARACTERISTIC_READ).getDescriptor(CHARACTERISTIC_CONFIGURATION_DESCRIPTOR); + BluetoothGattDescriptor descriptor = gatt.getService(PebbleGATTConstants.PPOGATT_SERVICE_UUID).getCharacteristic(PebbleGATTConstants.PPOGATT_CHARACTERISTIC_READ).getDescriptor(PebbleGATTConstants.CHARACTERISTIC_CONFIGURATION_DESCRIPTOR); NotifyAction.writeDescriptor(gatt, descriptor, new byte[]{1, 0}); - gatt.setCharacteristicNotification(gatt.getService(PPOGATT_SERVICE_UUID).getCharacteristic(PPOGATT_CHARACTERISTIC_READ), true); - writeCharacteristics = gatt.getService(PPOGATT_SERVICE_UUID).getCharacteristic(PPOGATT_CHARACTERISTIC_WRITE); + gatt.setCharacteristicNotification(gatt.getService(PebbleGATTConstants.PPOGATT_SERVICE_UUID).getCharacteristic(PebbleGATTConstants.PPOGATT_CHARACTERISTIC_READ), true); + writeCharacteristics = gatt.getService(PebbleGATTConstants.PPOGATT_SERVICE_UUID).getCharacteristic(PebbleGATTConstants.PPOGATT_CHARACTERISTIC_WRITE); } synchronized void sendDataToPebble(byte[] data) { @@ -377,169 +338,11 @@ class PebbleGATTClient extends BluetoothGattCallback { mWaitWriteCompleteLatch = null; } - // ================== New Pairing Methods (based on libpebble3) ================== + // ================== Pairing Callback Implementation ================== - /** - * Handle the connectivity characteristic read to determine pairing state. - * Based on libpebble3 ConnectivityWatcher and PebblePairing. - */ - private void handleConnectivityRead(BluetoothGatt gatt, byte[] value) { - mConnectivityStatus = new ConnectivityStatus(value); - LOG.info("Connectivity status: {}", mConnectivityStatus); - - BluetoothDevice device = gatt.getDevice(); - int bondState = device.getBondState(); - boolean phoneBonded = bondState == BluetoothDevice.BOND_BONDED; - LOG.info("Phone bond state: {} (BONDED={})", bondState, BluetoothDevice.BOND_BONDED); - - // If BOTH sides are already paired, skip pairing trigger and bonding entirely - if (mConnectivityStatus.paired && phoneBonded) { - LOG.info("Already paired on both sides - skipping pairing trigger, proceeding with subscriptions"); - proceedAfterPairing(gatt); - return; - } - - LOG.info("Proceeding with pairing trigger write (watch.paired={}, phone.bonded={})", - mConnectivityStatus.paired, phoneBonded); - - BluetoothGattCharacteristic pairingTrigger = - gatt.getService(SERVICE_UUID).getCharacteristic(PAIRING_TRIGGER_CHARACTERISTIC); - - if (pairingTrigger == null) { - LOG.error("Pairing trigger characteristic not found"); - proceedAfterPairing(gatt); - return; - } - - if ((pairingTrigger.getProperties() & PROPERTY_WRITE) != 0) { - LOG.info("Writing pairing trigger for Pebble Time 2 / modern Pebble"); - - // Use the original working values, but we'll add createBond() after - // 0x09 = pinAddress(1) + autoAccept(8) - worked for normal mode - // 0x11 = pinAddress(1) + watchAsServer(16) - for clientOnly mode - byte[] triggerValue; - if (mPebbleLESupport.clientOnly) { - triggerValue = new byte[]{0x11}; - LOG.info("Using clientOnly pairing trigger: 0x11"); - } else { - triggerValue = new byte[]{0x09}; - LOG.info("Using normal pairing trigger: 0x09"); - } - WriteAction.writeCharacteristic(gatt, pairingTrigger, triggerValue); - // Continue in onCharacteristicWrite() -> initiateBluetoothBond() - } else { - // Old firmware - just read the characteristic (legacy path) - LOG.info("Pairing trigger not writable, using legacy read"); - gatt.readCharacteristic(pairingTrigger); - } - } - - /** - * Initiate Bluetooth bonding after writing the pairing trigger. - * Based on libpebble3 PebblePairing.requestBlePairing(). - */ - private void initiateBluetoothBond(BluetoothGatt gatt) { - BluetoothDevice device = gatt.getDevice(); - int currentBondState = device.getBondState(); - LOG.info("Current bond state before pairing: {} (NONE={}, BONDING={}, BONDED={})", - currentBondState, BluetoothDevice.BOND_NONE, - BluetoothDevice.BOND_BONDING, BluetoothDevice.BOND_BONDED); - - // If already bonded, just proceed - don't try to re-bond - if (currentBondState == BluetoothDevice.BOND_BONDED) { - LOG.info("Device already bonded, proceeding with subscriptions"); - proceedAfterPairing(gatt); - return; - } - - LOG.info("Initiating Bluetooth bond with {}ms timeout", BONDING_TIMEOUT_MS); - - // Register bond state receiver - mBondingLatch = new CountDownLatch(1); - mBondingReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - BluetoothDevice bondDevice = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); - if (bondDevice == null || !bondDevice.getAddress().equals(device.getAddress())) { - return; - } - int bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.BOND_NONE); - LOG.info("Bond state changed: {}", bondState); - - if (bondState == BluetoothDevice.BOND_BONDED) { - LOG.info("Bonding succeeded!"); - mBondingLatch.countDown(); - } else if (bondState == BluetoothDevice.BOND_NONE) { - int reason = intent.getIntExtra("android.bluetooth.device.extra.REASON", -1); - LOG.error("Bonding failed with reason: {}", reason); - mBondingLatch.countDown(); - } - } - }; - - IntentFilter filter = new IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED); - ContextCompat.registerReceiver(mContext, mBondingReceiver, filter, ContextCompat.RECEIVER_EXPORTED); - - // Start bonding on background thread to avoid blocking GATT callbacks - new Thread(() -> { - try { - LOG.info("Calling createBond() on device {}", device.getAddress()); - boolean bondInitiated = device.createBond(); - LOG.info("createBond() returned: {}", bondInitiated); - - if (!bondInitiated) { - LOG.error("Failed to initiate bonding - createBond() returned false"); - LOG.info("Current bond state after failed createBond: {}", device.getBondState()); - cleanupBondingReceiver(); - // Still proceed to try connection - new Handler(Looper.getMainLooper()).post(() -> proceedAfterPairing(gatt)); - return; - } - - LOG.info("Waiting for bond state change (timeout: {}ms)...", BONDING_TIMEOUT_MS); - boolean bonded = mBondingLatch.await(BONDING_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS); - int finalBondState = device.getBondState(); - LOG.info("Bond wait completed: latch={}, finalState={}", bonded, finalBondState); - - if (bonded && finalBondState == BluetoothDevice.BOND_BONDED) { - LOG.info("Bonding completed successfully!"); - } else { - LOG.warn("Bonding timed out or failed (latch={}, state={}), proceeding anyway", - bonded, finalBondState); - } - } catch (InterruptedException e) { - LOG.error("Bonding interrupted", e); - } catch (SecurityException e) { - LOG.error("SecurityException during createBond - missing BLUETOOTH_CONNECT permission?", e); - } catch (Exception e) { - LOG.error("Unexpected error during bonding", e); - } finally { - cleanupBondingReceiver(); - // Continue with subscription chain on main thread - new Handler(Looper.getMainLooper()).post(() -> proceedAfterPairing(gatt)); - } - }).start(); - } - - /** - * Clean up the bonding broadcast receiver. - */ - private void cleanupBondingReceiver() { - if (mBondingReceiver != null) { - try { - mContext.unregisterReceiver(mBondingReceiver); - } catch (Exception e) { - LOG.warn("Error unregistering bonding receiver: {}", e.getMessage()); - } - mBondingReceiver = null; - } - } - - /** - * Continue with the subscription chain after pairing is complete or skipped. - */ - private void proceedAfterPairing(BluetoothGatt gatt) { - LOG.info("Proceeding after pairing to subscription chain"); + @Override + public void onPairingComplete(BluetoothGatt gatt, boolean hasConnectivityCharacteristics) { + LOG.info("Pairing complete, starting subscription chain (hasConnectivityChar={})", hasConnectivityCharacteristics); if (hasConnectivityCharacteristics) { subscribeToConnectivity(gatt); } else { @@ -547,70 +350,11 @@ class PebbleGATTClient extends BluetoothGattCallback { } } - /** - * Legacy pairing path for backward compatibility with older firmwares. - */ - private void proceedWithLegacyPairing(BluetoothGatt gatt) { - LOG.info("Using legacy pairing method"); - BluetoothGattCharacteristic characteristic = - gatt.getService(SERVICE_UUID).getCharacteristic(PAIRING_TRIGGER_CHARACTERISTIC); - if ((characteristic.getProperties() & PROPERTY_WRITE) != 0) { - byte[] value = mPebbleLESupport.clientOnly ? new byte[]{0x11} : new byte[]{0x09}; - WriteAction.writeCharacteristic(gatt, characteristic, value); - } else { - gatt.readCharacteristic(characteristic); - } - } - - /** - * Determines if we need to pair based on connectivity status and bond state. - * Based on libpebble3 PebbleBle.kt pairing logic. - */ - private boolean shouldPair(ConnectivityStatus status, boolean phoneBonded) { - if (status.paired && phoneBonded) { - LOG.info("Already paired on both sides, skipping pairing"); - return false; - } - if (status.paired && !phoneBonded) { - LOG.info("Watch thinks it's paired, phone does not - need to re-pair"); - return true; - } - if (!status.paired && phoneBonded) { - LOG.info("Phone thinks it's paired, watch does not - need to re-pair"); - return true; - } - LOG.info("Neither side paired - need full pairing"); - return true; - } - - /** - * Builds the pairing trigger value with proper bit flags. - * Based on libpebble3 PebblePairing.makePairingTriggerValue() - * - * Bit 0: pinAddress - pin BLE address to prevent MAC rotation - * Bit 1: noSecurityRequest - if true, watch won't request security (phone will via createBond) - * Bit 2: forceSecurityRequest - if true, watch will request security (inverse of bit 1) - * Bit 3: autoAcceptFuturePairing - * Bit 4: watchAsGattServer - for reversed PPoG mode - * - * When phone calls createBond(), set noSecurityRequest=true so watch doesn't also request. - */ - private byte buildPairingTriggerValue(boolean pinAddress, boolean noSecurityRequest, - boolean autoAcceptFuture, boolean watchAsGattServer) { - byte value = 0; - if (pinAddress) value |= 0b00001; // Bit 0 - if (noSecurityRequest) value |= 0b00010; // Bit 1 - if (!noSecurityRequest) value |= 0b00100; // Bit 2: force security (only if bit 1 is 0) - if (autoAcceptFuture) value |= 0b01000; // Bit 3 - if (watchAsGattServer) value |= 0b10000; // Bit 4 - LOG.debug("buildPairingTriggerValue: pin={}, noSec={}, auto={}, server={} -> 0x{}", - pinAddress, noSecurityRequest, autoAcceptFuture, watchAsGattServer, - String.format("%02X", value)); - return value; - } - public void close() { - cleanupBondingReceiver(); + if (mPairingFlow != null) { + mPairingFlow.close(); + mPairingFlow = null; + } if (mBluetoothGatt != null) { mBluetoothGatt.disconnect(); mBluetoothGatt.close(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTConstants.java new file mode 100644 index 0000000000..519c25f20d --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTConstants.java @@ -0,0 +1,40 @@ +/* Copyright (C) 2024 Gadgetbridge contributors + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.ble; + +import java.util.UUID; + +/** + * Shared GATT constants for Pebble BLE pairing flows. + */ +class PebbleGATTConstants { + static final UUID SERVICE_UUID = UUID.fromString("0000fed9-0000-1000-8000-00805f9b34fb"); + static final UUID CONNECTIVITY_CHARACTERISTIC = UUID.fromString("00000001-328E-0FBB-C642-1AA6699BDADA"); + static final UUID PAIRING_TRIGGER_CHARACTERISTIC = UUID.fromString("00000002-328E-0FBB-C642-1AA6699BDADA"); + static final UUID MTU_CHARACTERISTIC = UUID.fromString("00000003-328e-0fbb-c642-1aa6699bdada"); + static final UUID CONNECTION_PARAMETERS_CHARACTERISTIC = UUID.fromString("00000005-328E-0FBB-C642-1AA6699BDADA"); + static final UUID CHARACTERISTIC_CONFIGURATION_DESCRIPTOR = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"); + + // PPoGATT service (Pebble side) + static final UUID PPOGATT_SERVICE_UUID = UUID.fromString("30000003-328E-0FBB-C642-1AA6699BDADA"); + static final UUID PPOGATT_CHARACTERISTIC_READ = UUID.fromString("30000004-328E-0FBB-C642-1AA6699BDADA"); + static final UUID PPOGATT_CHARACTERISTIC_WRITE = UUID.fromString("30000006-328E-0FBB-C642-1AA6699BDADA"); + + private PebbleGATTConstants() { + // Utility class + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebblePairingFlow.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebblePairingFlow.java new file mode 100644 index 0000000000..0351bee919 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebblePairingFlow.java @@ -0,0 +1,61 @@ +/* Copyright (C) 2024 Gadgetbridge contributors + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.ble; + +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattService; + +/** + * Strategy interface for different Pebble BLE pairing flows. + * Different Pebble hardware versions require different pairing protocols. + */ +interface PebblePairingFlow { + /** + * Start the pairing sequence after service discovery. + * + * @param gatt The GATT connection + * @param pairingService The Pebble pairing service (0000fed9) + */ + void startPairing(BluetoothGatt gatt, BluetoothGattService pairingService); + + /** + * Handle characteristic read callback during pairing. + * + * @param gatt The GATT connection + * @param characteristic The characteristic that was read + * @param value The value that was read + * @return true if this flow handled the read, false otherwise + */ + boolean onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] value); + + /** + * Handle characteristic write callback during pairing. + * + * @param gatt The GATT connection + * @param characteristic The characteristic that was written + * @param status The write status (GATT_SUCCESS, etc.) + * @return true if this flow handled the write, false otherwise + */ + boolean onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status); + + /** + * Clean up any resources held by this pairing flow. + * Called when the GATT client is closed. + */ + void close(); +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebblePairingFlowFactory.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebblePairingFlowFactory.java new file mode 100644 index 0000000000..f44de75229 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebblePairingFlowFactory.java @@ -0,0 +1,117 @@ +/* Copyright (C) 2024 Gadgetbridge contributors + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.ble; + +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattService; +import android.content.Context; + +import androidx.annotation.Nullable; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import nodomain.freeyourgadget.gadgetbridge.devices.pebble.PebbleHardware; + +/** + * Factory for creating the appropriate Pebble pairing flow based on device hardware and characteristics. + *

+ * Device detection strategy: + * 1. Primary: Use hardware platform (via PebbleHardware.isBleOnlyByModel) to determine expected flow + * 2. Validation: Verify device has expected GATT characteristics + * 3. Defensive: If mismatch, adapt based on actual characteristics (e.g., firmware upgrade scenario) + *

+ * Expected characteristics by hardware: + * - BLE-only Pebbles (Pebble 2, Time 2, 2 Duo): CONNECTIVITY_CHARACTERISTIC, no CONNECTION_PARAMETERS → ModernPebblePairingFlow + * - Dual-mode Pebbles (Pebble Time, Time Round): CONNECTION_PARAMETERS_CHARACTERISTIC → LegacyPebblePairingFlow + * - Classic Pebbles: No BLE pairing needed + */ +class PebblePairingFlowFactory { + private static final Logger LOG = LoggerFactory.getLogger(PebblePairingFlowFactory.class); + + /** + * Create the appropriate pairing flow for the given device. + * + * @param context Android context for bonding operations + * @param deviceModel Device model string from GBDevice.getModel() (may be null) + * @param pairingService The Pebble pairing service (0000fed9) + * @param clientOnly Whether to use clientOnly mode (affects pairing trigger value) + * @param callback Callback to invoke when pairing is complete + * @return The appropriate pairing flow implementation + */ + static PebblePairingFlow createPairingFlow( + Context context, + @Nullable String deviceModel, + BluetoothGattService pairingService, + boolean clientOnly, + PairingCallback callback) { + + // Detect GATT characteristics + BluetoothGattCharacteristic connectivityChar = + pairingService.getCharacteristic(PebbleGATTConstants.CONNECTIVITY_CHARACTERISTIC); + BluetoothGattCharacteristic connectionParamChar = + pairingService.getCharacteristic(PebbleGATTConstants.CONNECTION_PARAMETERS_CHARACTERISTIC); + + boolean hasConnectivityChar = (connectivityChar != null); + boolean hasConnectionParamChar = (connectionParamChar != null); + + // Hardware-based expectation (if model is known) + Boolean hardwareExpectsModern = null; // null = unknown + if (deviceModel != null && !deviceModel.isEmpty()) { + hardwareExpectsModern = PebbleHardware.isBleOnlyByModel(deviceModel); + LOG.info("Hardware detection: model='{}', BLE-only={}", deviceModel, hardwareExpectsModern); + } + + if (hardwareExpectsModern != null) { + if (hardwareExpectsModern && !hasConnectivityChar) { + LOG.warn("Mismatch: Hardware expects modern flow but CONNECTIVITY_CHARACTERISTIC missing (model={})", deviceModel); + } + + if (!hardwareExpectsModern && hasConnectivityChar && !hasConnectionParamChar) { + LOG.warn("Mismatch: Hardware expects legacy flow but has modern characteristics (model={}) - possible firmware upgrade", deviceModel); + } + } + + boolean useModernFlow; + if (hasConnectivityChar) { + // Modern characteristic present - use V2 flow + useModernFlow = true; + LOG.info("Using PebblePairingFlowV2 (CONNECTIVITY_CHARACTERISTIC present)"); + } else if (hasConnectionParamChar) { + // Legacy characteristic present - use V1 flow + useModernFlow = false; + LOG.info("Using PebblePairingFlowV1 (CONNECTION_PARAMETERS_CHARACTERISTIC present)"); + } else { + // No characteristics - fall back to hardware expectation or default to V1 + useModernFlow = (hardwareExpectsModern != null && hardwareExpectsModern); + LOG.warn("No pairing characteristics found - using {} flow based on {}", + useModernFlow ? "V2" : "V1", + hardwareExpectsModern != null ? "hardware expectation" : "default"); + } + + if (useModernFlow) { + return new PebblePairingFlowV2(context, clientOnly, callback); + } else { + return new PebblePairingFlowV1(clientOnly, callback); + } + } + + private PebblePairingFlowFactory() { + // Utility class + } +} + diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebblePairingFlowV1.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebblePairingFlowV1.java new file mode 100644 index 0000000000..498deb5806 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebblePairingFlowV1.java @@ -0,0 +1,123 @@ +/* Copyright (C) 2024 Gadgetbridge contributors + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.ble; + +import static android.bluetooth.BluetoothGattCharacteristic.PROPERTY_WRITE; + +import android.annotation.SuppressLint; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattService; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.WriteAction; + +/** + * V1 Pebble pairing flow for older BLE-enabled devices (Pebble Time, Pebble Time Round). + * These devices use a simpler pairing protocol without connectivity status checking + * or explicit bonding management. + *

+ * This is the original pairing flow from before commit 945e06946. + */ +@SuppressLint("MissingPermission") +class PebblePairingFlowV1 implements PebblePairingFlow { + private static final Logger LOG = LoggerFactory.getLogger(PebblePairingFlowV1.class); + + private final boolean mClientOnly; + private final PairingCallback mCallback; + + PebblePairingFlowV1(boolean clientOnly, PairingCallback callback) { + mClientOnly = clientOnly; + mCallback = callback; + } + + @Override + public void startPairing(BluetoothGatt gatt, BluetoothGattService pairingService) { + LOG.info("PebblePairingFlowV1: Starting pairing for Pebble Time/Time Round"); + + BluetoothGattCharacteristic characteristic = + pairingService.getCharacteristic(PebbleGATTConstants.PAIRING_TRIGGER_CHARACTERISTIC); + + if (characteristic == null) { + LOG.error("Pairing trigger characteristic not found"); + // Proceed anyway to subscription chain + mCallback.onPairingComplete(gatt, false); + return; + } + + if ((characteristic.getProperties() & PROPERTY_WRITE) != 0) { + LOG.info("This seems to be a >=4.0 FW Pebble, writing to pairing trigger"); + // flags: + // 0 - always 1 + // 1 - unknown + // 2 - always 0 + // 3 - unknown, set on kitkat (seems to help to get a "better" pairing) + // 4 - unknown, set on some phones + byte[] value; + if (mClientOnly) { + value = new byte[]{0x11}; // needed in clientOnly mode + } else { + value = new byte[]{0x09}; // I just keep this, because it worked + } + WriteAction.writeCharacteristic(gatt, characteristic, value); + // Continue in onCharacteristicWrite() + } else { + LOG.info("This seems to be some <4.0 FW Pebble, reading pairing trigger"); + gatt.readCharacteristic(characteristic); + // Continue in onCharacteristicRead() + } + } + + @Override + public boolean onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] value) { + if (!characteristic.getUuid().equals(PebbleGATTConstants.PAIRING_TRIGGER_CHARACTERISTIC)) { + return false; // Not handled by this flow + } + + // Legacy pairing trigger read complete - proceed to subscriptions + LOG.info("V1 pairing trigger read complete, proceeding to subscriptions"); + proceedAfterPairing(gatt); + return true; + } + + @Override + public boolean onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + if (!characteristic.getUuid().equals(PebbleGATTConstants.PAIRING_TRIGGER_CHARACTERISTIC)) { + return false; // Not handled by this flow + } + + // Legacy pairing trigger write complete - proceed to subscriptions + LOG.info("V1 pairing trigger write complete (status={}), proceeding to subscriptions", status); + proceedAfterPairing(gatt); + return true; + } + + @Override + public void close() { + // No resources to clean up for V1 flow + } + + /** + * Continue with the subscription chain after pairing trigger is complete. + */ + private void proceedAfterPairing(BluetoothGatt gatt) { + LOG.info("PebblePairingFlowV1: Pairing complete, proceeding to subscriptions"); + mCallback.onPairingComplete(gatt, false); // false = NO hasConnectivityCharacteristics (V1) + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebblePairingFlowV2.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebblePairingFlowV2.java new file mode 100644 index 0000000000..b611f72855 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebblePairingFlowV2.java @@ -0,0 +1,279 @@ +/* Copyright (C) 2024 Gadgetbridge contributors + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.ble; + +import static android.bluetooth.BluetoothGattCharacteristic.PROPERTY_WRITE; + +import android.annotation.SuppressLint; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattService; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Handler; +import android.os.Looper; + +import androidx.core.content.ContextCompat; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.UUID; +import java.util.concurrent.CountDownLatch; + +import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.WriteAction; + +/** + * V2 Pebble pairing flow for BLE-only devices (Pebble 2, Time 2, 2 Duo). + * These devices require reading connectivity status, writing a pairing trigger, + * and initiating Bluetooth bonding before the connection can complete. + *

+ * Based on the PebbleOS open source implementation and libpebble3. + */ +@SuppressLint("MissingPermission") +class PebblePairingFlowV2 implements PebblePairingFlow { + private static final Logger LOG = LoggerFactory.getLogger(PebblePairingFlowV2.class); + + private static final long BONDING_TIMEOUT_MS = 60000; // 60 seconds + + private final Context mContext; + private final boolean mClientOnly; + private final PairingCallback mCallback; + + private ConnectivityStatus mConnectivityStatus; + private CountDownLatch mBondingLatch; + private BroadcastReceiver mBondingReceiver; + + PebblePairingFlowV2(Context context, boolean clientOnly, PairingCallback callback) { + mContext = context; + mClientOnly = clientOnly; + mCallback = callback; + } + + @Override + public void startPairing(BluetoothGatt gatt, BluetoothGattService pairingService) { + LOG.info("PebblePairingFlowV2: Starting pairing for Pebble 2/Time 2/2 Duo"); + + // Read Connectivity characteristic FIRST to determine pairing state + BluetoothGattCharacteristic connectivityChar = + pairingService.getCharacteristic(PebbleGATTConstants.CONNECTIVITY_CHARACTERISTIC); + + if (connectivityChar != null) { + LOG.info("Reading connectivity characteristic to check pairing state"); + gatt.readCharacteristic(connectivityChar); + // Continue in onCharacteristicRead() -> handleConnectivityRead() + } else { + LOG.error("Connectivity characteristic not found - cannot proceed with modern pairing"); + // Proceed anyway to subscription chain + mCallback.onPairingComplete(gatt, true); + } + } + + @Override + public boolean onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] value) { + if (!characteristic.getUuid().equals(PebbleGATTConstants.CONNECTIVITY_CHARACTERISTIC)) { + return false; // Not handled by this flow + } + + handleConnectivityRead(gatt, value); + return true; + } + + @Override + public boolean onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + if (!characteristic.getUuid().equals(PebbleGATTConstants.PAIRING_TRIGGER_CHARACTERISTIC)) { + return false; // Not handled by this flow + } + + // Pairing trigger written - now initiate Bluetooth bonding + LOG.info("Pairing trigger written successfully (status={}), initiating bond", status); + initiateBluetoothBond(gatt); + return true; + } + + @Override + public void close() { + cleanupBondingReceiver(); + } + + /** + * Handle the connectivity characteristic read to determine pairing state. + * Based on libpebble3 ConnectivityWatcher and PebblePairing. + */ + private void handleConnectivityRead(BluetoothGatt gatt, byte[] value) { + mConnectivityStatus = new ConnectivityStatus(value); + LOG.info("Connectivity status: {}", mConnectivityStatus); + + BluetoothDevice device = gatt.getDevice(); + int bondState = device.getBondState(); + boolean phoneBonded = bondState == BluetoothDevice.BOND_BONDED; + LOG.info("Phone bond state: {} (BONDED={})", bondState, BluetoothDevice.BOND_BONDED); + + // If BOTH sides are already paired, skip pairing trigger and bonding entirely + if (mConnectivityStatus.paired && phoneBonded) { + LOG.info("Already paired on both sides - skipping pairing trigger, proceeding with subscriptions"); + proceedAfterPairing(gatt); + return; + } + + LOG.info("Proceeding with pairing trigger write (watch.paired={}, phone.bonded={})", + mConnectivityStatus.paired, phoneBonded); + + BluetoothGattCharacteristic pairingTrigger = + gatt.getService(PebbleGATTConstants.SERVICE_UUID).getCharacteristic(PebbleGATTConstants.PAIRING_TRIGGER_CHARACTERISTIC); + + if (pairingTrigger == null) { + LOG.error("Pairing trigger characteristic not found"); + proceedAfterPairing(gatt); + return; + } + + if ((pairingTrigger.getProperties() & PROPERTY_WRITE) != 0) { + LOG.info("Writing pairing trigger for Pebble Time 2 / modern Pebble"); + + // Use the original working values + // 0x09 = pinAddress(1) + autoAccept(8) - worked for normal mode + // 0x11 = pinAddress(1) + watchAsServer(16) - for clientOnly mode + byte[] triggerValue; + if (mClientOnly) { + triggerValue = new byte[]{0x11}; + LOG.info("Using clientOnly pairing trigger: 0x11"); + } else { + triggerValue = new byte[]{0x09}; + LOG.info("Using normal pairing trigger: 0x09"); + } + WriteAction.writeCharacteristic(gatt, pairingTrigger, triggerValue); + // Continue in onCharacteristicWrite() -> initiateBluetoothBond() + } else { + // Old firmware - just read the characteristic (legacy path - shouldn't happen in modern flow) + LOG.warn("Pairing trigger not writable in modern flow - unexpected"); + gatt.readCharacteristic(pairingTrigger); + } + } + + /** + * Initiate Bluetooth bonding after writing the pairing trigger. + * Based on libpebble3 PebblePairing.requestBlePairing(). + */ + private void initiateBluetoothBond(BluetoothGatt gatt) { + BluetoothDevice device = gatt.getDevice(); + int currentBondState = device.getBondState(); + LOG.info("Current bond state before pairing: {} (NONE={}, BONDING={}, BONDED={})", + currentBondState, BluetoothDevice.BOND_NONE, + BluetoothDevice.BOND_BONDING, BluetoothDevice.BOND_BONDED); + + // If already bonded, just proceed - don't try to re-bond + if (currentBondState == BluetoothDevice.BOND_BONDED) { + LOG.info("Device already bonded, proceeding with subscriptions"); + proceedAfterPairing(gatt); + return; + } + + LOG.info("Initiating Bluetooth bond with {}ms timeout", BONDING_TIMEOUT_MS); + + // Register bond state receiver + mBondingLatch = new CountDownLatch(1); + mBondingReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + BluetoothDevice bondDevice = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + if (bondDevice == null || !bondDevice.getAddress().equals(device.getAddress())) { + return; + } + int bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.BOND_NONE); + LOG.info("Bond state changed: {}", bondState); + + if (bondState == BluetoothDevice.BOND_BONDED) { + LOG.info("Bonding succeeded!"); + mBondingLatch.countDown(); + } else if (bondState == BluetoothDevice.BOND_NONE) { + int reason = intent.getIntExtra("android.bluetooth.device.extra.REASON", -1); + LOG.error("Bonding failed with reason: {}", reason); + mBondingLatch.countDown(); + } + } + }; + + IntentFilter filter = new IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED); + ContextCompat.registerReceiver(mContext, mBondingReceiver, filter, ContextCompat.RECEIVER_EXPORTED); + + // Start bonding on background thread to avoid blocking GATT callbacks + new Thread(() -> { + try { + LOG.info("Calling createBond() on device {}", device.getAddress()); + boolean bondInitiated = device.createBond(); + LOG.info("createBond() returned: {}", bondInitiated); + + if (!bondInitiated) { + LOG.error("Failed to initiate bonding - createBond() returned false"); + LOG.info("Current bond state after failed createBond: {}", device.getBondState()); + cleanupBondingReceiver(); + // Still proceed to try connection + new Handler(Looper.getMainLooper()).post(() -> proceedAfterPairing(gatt)); + return; + } + + LOG.info("Waiting for bond state change (timeout: {}ms)...", BONDING_TIMEOUT_MS); + boolean bonded = mBondingLatch.await(BONDING_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS); + int finalBondState = device.getBondState(); + LOG.info("Bond wait completed: latch={}, finalState={}", bonded, finalBondState); + + if (bonded && finalBondState == BluetoothDevice.BOND_BONDED) { + LOG.info("Bonding completed successfully!"); + } else { + LOG.warn("Bonding timed out or failed (latch={}, state={}), proceeding anyway", + bonded, finalBondState); + } + } catch (InterruptedException e) { + LOG.error("Bonding interrupted", e); + } catch (SecurityException e) { + LOG.error("SecurityException during createBond - missing BLUETOOTH_CONNECT permission?", e); + } catch (Exception e) { + LOG.error("Unexpected error during bonding", e); + } finally { + cleanupBondingReceiver(); + // Continue with subscription chain on main thread + new Handler(Looper.getMainLooper()).post(() -> proceedAfterPairing(gatt)); + } + }).start(); + } + + /** + * Clean up the bonding broadcast receiver. + */ + private void cleanupBondingReceiver() { + if (mBondingReceiver != null) { + try { + mContext.unregisterReceiver(mBondingReceiver); + } catch (Exception e) { + LOG.warn("Error unregistering bonding receiver: {}", e.getMessage()); + } + mBondingReceiver = null; + } + } + + /** + * Continue with the subscription chain after pairing is complete or skipped. + */ + private void proceedAfterPairing(BluetoothGatt gatt) { + LOG.info("PebblePairingFlowV2: Pairing complete, proceeding to subscriptions"); + mCallback.onPairingComplete(gatt, true); // true = hasConnectivityCharacteristics + } +}