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
+ }
+}