From ce179a29ae8d9c6c660de23851a90bfbf7ff563e Mon Sep 17 00:00:00 2001 From: MrYoranimo Date: Wed, 10 Jan 2024 23:16:38 +0100 Subject: [PATCH] Xiaomi: introduce XiaomiSppSupport --- .../AbstractBLClassicDeviceCoordinator.java | 2 +- .../devices/DeviceCoordinator.java | 2 +- .../devices/xiaomi/XiaomiCoordinator.java | 4 +- .../btbr/AbstractBTBRDeviceSupport.java | 6 + .../gadgetbridge/service/btbr/BtBRQueue.java | 138 +++++---- .../btbr/actions/SetProgressAction.java | 73 +++++ .../devices/xiaomi/XiaomiAuthService.java | 21 +- .../devices/xiaomi/XiaomiBleSupport.java | 24 +- .../devices/xiaomi/XiaomiSppPacket.java | 246 +++++++++++++++++ .../devices/xiaomi/XiaomiSppSupport.java | 261 ++++++++++++++++++ .../service/devices/xiaomi/XiaomiSupport.java | 2 + .../{XiaomiBleUuids.java => XiaomiUuids.java} | 9 +- 12 files changed, 722 insertions(+), 66 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/SetProgressAction.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppPacket.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppSupport.java rename app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/{XiaomiBleUuids.java => XiaomiUuids.java} (90%) diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractBLClassicDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractBLClassicDeviceCoordinator.java index a2cf5c60d..64cda9847 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractBLClassicDeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractBLClassicDeviceCoordinator.java @@ -19,6 +19,6 @@ package nodomain.freeyourgadget.gadgetbridge.devices; public abstract class AbstractBLClassicDeviceCoordinator extends AbstractDeviceCoordinator { @Override public ConnectionType getConnectionType() { - return ConnectionType.BL_CLASSIC; + return ConnectionType.BT_CLASSIC; } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java index 011b9d6fa..db5b71116 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java @@ -96,7 +96,7 @@ public interface DeviceCoordinator { enum ConnectionType{ BLE(false, true), - BL_CLASSIC(true, false), + BT_CLASSIC(true, false), BOTH(true, true) ; boolean usesBluetoothClassic, usesBluetoothLE; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiCoordinator.java index 8b9232eef..07d066ec6 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiCoordinator.java @@ -57,7 +57,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.SleepRespiratoryRateSample; import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample; import nodomain.freeyourgadget.gadgetbridge.model.StressSample; import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport; -import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiBleUuids; +import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiUuids; import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiPreferences; import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.impl.WorkoutSummaryParser; @@ -71,7 +71,7 @@ public abstract class XiaomiCoordinator extends AbstractBLEDeviceCoordinator { @Override public Collection createBLEScanFilters() { final List filters = new ArrayList<>(); - for (final UUID uuid : XiaomiBleUuids.UUIDS.keySet()) { + for (final UUID uuid : XiaomiUuids.BLE_UUIDS.keySet()) { final ParcelUuid service = new ParcelUuid(uuid); final ScanFilter filter = new ScanFilter.Builder().setServiceUuid(service).build(); filters.add(filter); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/AbstractBTBRDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/AbstractBTBRDeviceSupport.java index 8ca3dc86d..a531db083 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/AbstractBTBRDeviceSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/AbstractBTBRDeviceSupport.java @@ -60,6 +60,12 @@ public abstract class AbstractBTBRDeviceSupport extends AbstractDeviceSupport im return mQueue.connect(); } + public void disconnect() { + if (mQueue != null) { + mQueue.disconnect(); + } + } + /** * Subclasses should populate the given builder to initialize the device (if necessary). * diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/BtBRQueue.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/BtBRQueue.java index 6cfb2a9dc..3da100165 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/BtBRQueue.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/BtBRQueue.java @@ -16,51 +16,49 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.btbr; +import android.annotation.SuppressLint; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothSocket; import android.content.Context; -import android.os.ParcelUuid; - -import androidx.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; -import java.util.ArrayList; import java.util.Arrays; -import java.util.List; +import java.util.Locale; import java.util.UUID; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CountDownLatch; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; -import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; +import nodomain.freeyourgadget.gadgetbridge.util.GB; public final class BtBRQueue { private static final Logger LOG = LoggerFactory.getLogger(BtBRQueue.class); private BluetoothAdapter mBtAdapter = null; private BluetoothSocket mBtSocket = null; - private GBDevice mGbDevice; - private SocketCallback mCallback; - private UUID mService; + private final GBDevice mGbDevice; + private final SocketCallback mCallback; + private final UUID mService; private final BlockingQueue mTransactions = new LinkedBlockingQueue<>(); private volatile boolean mDisposed; private volatile boolean mCrashed; - private Context mContext; + private final Context mContext; private CountDownLatch mConnectionLatch; private CountDownLatch mAvailableData; - private int mBufferSize; + private final int mBufferSize; - private Thread writeThread = new Thread("Gadgetbridge IO writeThread") { + private Thread writeThread = new Thread("Write Thread") { @Override public void run() { - LOG.debug("Socket Write Thread started."); + LOG.debug("Started write thread for {} (address {})", mGbDevice.getName(), mGbDevice.getAddress()); while (!mDisposed && !mCrashed) { try { @@ -102,10 +100,14 @@ public final class BtBRQueue { } }; - private Thread readThread = new Thread("Gadgetbridge IO readThread") { + private Thread readThread = new Thread("Read Thread") { @Override public void run() { - LOG.debug("Queue Read Thread started."); + byte[] buffer = new byte[mBufferSize]; + int nRead; + + LOG.debug("Read thread started, entering loop"); + while (!mDisposed && !mCrashed) { try { if (!isConnected()) { @@ -119,24 +121,43 @@ public final class BtBRQueue { mConnectionLatch.await(); mConnectionLatch = null; } + if (mAvailableData != null) { if (mBtSocket.getInputStream().available() == 0) { mAvailableData.countDown(); } } - byte[] data = new byte[mBufferSize]; - int len = mBtSocket.getInputStream().read(data); - LOG.debug("Received data: " + StringUtils.bytesToHex(data)); - mCallback.onSocketRead(Arrays.copyOf(data, len)); - } catch (InterruptedException ignored) { - mConnectionLatch = null; + + nRead = mBtSocket.getInputStream().read(buffer); + + // safety measure + if (nRead == -1) { + throw new IOException("End of stream"); + } + } catch (InterruptedException ignored) { LOG.debug("Thread interrupted"); - } catch (Throwable ex) { - LOG.error("IO Read Thread died: " + ex.getMessage(), ex); - mCrashed = true; mConnectionLatch = null; + continue; + } catch (Throwable ex) { + if (mAvailableData == null) { + LOG.error("IO read thread died: " + ex.getMessage(), ex); + mCrashed = true; + } + + mConnectionLatch = null; + continue; + } + + LOG.debug("Received {} bytes: {}", nRead, GB.hexdump(buffer, 0, nRead)); + + try { + mCallback.onSocketRead(Arrays.copyOf(buffer, nRead)); + } catch (Throwable ex) { + LOG.error("Failed to process received bytes in onSocketRead callback: ", ex); } } + + LOG.debug("Exited read thread loop"); } }; @@ -147,9 +168,6 @@ public final class BtBRQueue { mCallback = socketCallback; mService = supportedService; mBufferSize = bufferSize; - - writeThread.start(); - readThread.start(); } /** @@ -159,39 +177,51 @@ public final class BtBRQueue { * * @return true whether the connection attempt was successfully triggered and false if that failed or if there is already a connection */ - - protected boolean connect() { + @SuppressLint("MissingPermission") + public boolean connect() { if (isConnected()) { LOG.warn("Ignoring connect() because already connected."); return false; } - LOG.info("Attemping to connect to " + mGbDevice.getName()); + LOG.info("Attempting to connect to {} ({})", mGbDevice.getName(), mGbDevice.getAddress()); + + // stop discovery before connection is made + mBtAdapter.cancelDiscovery(); + + // revert to original state upon exception GBDevice.State originalState = mGbDevice.getState(); setDeviceConnectionState(GBDevice.State.CONNECTING); try { BluetoothDevice btDevice = mBtAdapter.getRemoteDevice(mGbDevice.getAddress()); - // UUID should be in a BluetoothSocket class and not in BluetoothSocketCharacteristic mBtSocket = btDevice.createRfcommSocketToServiceRecord(mService); + + LOG.debug("RFCOMM socket created, connecting"); + + // TODO this call is blocking, which makes this method preferably called from a background thread mBtSocket.connect(); - if (mBtSocket.isConnected()) { - setDeviceConnectionState(GBDevice.State.CONNECTED); - } else { - LOG.debug("Connection not established"); - } - if (mConnectionLatch != null) { - mConnectionLatch.countDown(); - } + + LOG.info("Connected to RFCOMM socket for {}", mGbDevice.getName()); + setDeviceConnectionState(GBDevice.State.CONNECTED); + + // update thread names to show device names in logs + readThread.setName(String.format(Locale.ENGLISH, + "Read Thread for %s", mGbDevice.getName())); + writeThread.setName(String.format(Locale.ENGLISH, + "Write Thread for %s", mGbDevice.getName())); + + // now that connect has been created, start the threads + readThread.start(); + writeThread.start(); } catch (IOException e) { - LOG.error("Server socket cannot be started.", e); + LOG.error("Unable to connect to RFCOMM endpoint: ", e); setDeviceConnectionState(originalState); mBtSocket = null; return false; } onConnectionEstablished(); - return true; } @@ -203,19 +233,28 @@ public final class BtBRQueue { if (mBtSocket != null) { try { mAvailableData = new CountDownLatch(1); - mAvailableData.await(); + + if (!mAvailableData.await(1, TimeUnit.SECONDS)) { + LOG.warn("disconnect(): Latch timeout reached while waiting for incoming data"); + } + mAvailableData = null; mBtSocket.close(); - } catch (IOException e) { - LOG.error(e.getMessage()); - } catch (InterruptedException e) { + } catch (IOException | InterruptedException e) { LOG.error(e.getMessage()); } } } - protected boolean isConnected() { - return mGbDevice.isConnected(); + /** + * Check whether a connection to the device exists and whether a socket connection has been + * initialized and connected + * @return true if the Bluetooth device is connected and the socket is ready, false otherwise + */ + private boolean isConnected() { + return mGbDevice.isConnected() && + mBtSocket != null && + mBtSocket.isConnected(); } /** @@ -230,7 +269,7 @@ public final class BtBRQueue { } } - protected void setDeviceConnectionState(GBDevice.State newState) { + private void setDeviceConnectionState(GBDevice.State newState) { LOG.debug("New device connection state: " + newState); mGbDevice.setState(newState); mGbDevice.sendDeviceUpdateIntent(mContext, GBDevice.DeviceUpdateSubject.CONNECTION_STATE); @@ -240,12 +279,13 @@ public final class BtBRQueue { if (mDisposed) { return; } + mDisposed = true; disconnect(); writeThread.interrupt(); writeThread = null; readThread.interrupt(); readThread = null; + mTransactions.clear(); } - } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/SetProgressAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/SetProgressAction.java new file mode 100644 index 000000000..4a5e56d01 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/SetProgressAction.java @@ -0,0 +1,73 @@ +/* Copyright (C) 2015-2023 Andreas Shimokawa, Carsten Pfeiffer, Daniele + Gobbetti, Yoran Vulker + + 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.btbr.actions; + +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothSocket; +import android.content.Context; +import android.content.Intent; + +import androidx.annotation.NonNull; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import nodomain.freeyourgadget.gadgetbridge.service.btbr.actions.PlainAction; +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +public class SetProgressAction extends PlainAction { + private static final Logger LOG = LoggerFactory.getLogger(SetProgressAction.class); + + private final String text; + private final boolean ongoing; + private final int percentage; + private final Context context; + + /** + * When run, will update the progress notification. + * + * @param text Text shown in the notification + * @param ongoing State of action, true when the action is still being performed + * @param percentage Current percentage indicating how far along the action has progressed + * @param context Context in which to create the notification + */ + public SetProgressAction(String text, boolean ongoing, int percentage, Context context) { + this.text = text; + this.ongoing = ongoing; + this.percentage = percentage; + this.context = context; + } + + @Override + public boolean run(BluetoothSocket unused) { + LOG.info(toString()); + GB.updateInstallNotification(this.text, this.ongoing, this.percentage, this.context); + + final LocalBroadcastManager broadcastManager = LocalBroadcastManager.getInstance(context); + broadcastManager.sendBroadcast(new Intent(GB.ACTION_SET_PROGRESS_BAR).putExtra(GB.PROGRESS_BAR_PROGRESS, percentage)); + + return true; + } + + @NonNull + @Override + public String toString() { + return getCreationTime() + ": " + getClass().getSimpleName() + ": " + text + "; " + percentage + "%"; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiAuthService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiAuthService.java index 3ef9f5f11..656c6c83b 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiAuthService.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiAuthService.java @@ -49,8 +49,6 @@ import nodomain.freeyourgadget.gadgetbridge.BuildConfig; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.proto.xiaomi.XiaomiProto; -import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; -import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction; import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.services.AbstractXiaomiService; import nodomain.freeyourgadget.gadgetbridge.util.GB; @@ -82,10 +80,10 @@ public class XiaomiAuthService extends AbstractXiaomiService { } // TODO also implement for spp - protected void startEncryptedHandshake(final XiaomiBleSupport support, final TransactionBuilder builder) { + protected void startEncryptedHandshake(final XiaomiBleSupport support, final nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder builder) { encryptionInitialized = false; - builder.add(new SetDeviceStateAction(getSupport().getDevice(), GBDevice.State.AUTHENTICATING, getSupport().getContext())); + builder.add(new nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction(getSupport().getDevice(), GBDevice.State.AUTHENTICATING, getSupport().getContext())); System.arraycopy(getSecretKey(getSupport().getDevice()), 0, secretKey, 0, 16); new SecureRandom().nextBytes(nonce); @@ -93,8 +91,19 @@ public class XiaomiAuthService extends AbstractXiaomiService { support.sendCommand(builder, buildNonceCommand(nonce)); } - protected void startClearTextHandshake(final XiaomiBleSupport support, final TransactionBuilder builder) { - builder.add(new SetDeviceStateAction(getSupport().getDevice(), GBDevice.State.AUTHENTICATING, getSupport().getContext())); + protected void startEncryptedHandshake(final XiaomiSppSupport support, final nodomain.freeyourgadget.gadgetbridge.service.btbr.TransactionBuilder builder) { + encryptionInitialized = false; + + builder.add(new nodomain.freeyourgadget.gadgetbridge.service.btbr.actions.SetDeviceStateAction(getSupport().getDevice(), GBDevice.State.AUTHENTICATING, getSupport().getContext())); + + System.arraycopy(getSecretKey(getSupport().getDevice()), 0, secretKey, 0, 16); + new SecureRandom().nextBytes(nonce); + + support.sendCommand(builder, buildNonceCommand(nonce)); + } + + protected void startClearTextHandshake(final XiaomiBleSupport support, final nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder builder) { + builder.add(new nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction(getSupport().getDevice(), GBDevice.State.AUTHENTICATING, getSupport().getContext())); final XiaomiProto.Auth auth = XiaomiProto.Auth.newBuilder() .setUserId(getUserId(getSupport().getDevice())) diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiBleSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiBleSupport.java index 138c5a7c2..393f19be9 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiBleSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiBleSupport.java @@ -1,3 +1,19 @@ +/* Copyright (C) 2023 José Rebelo, Yoran Vulker + + 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.xiaomi; import android.bluetooth.BluetoothAdapter; @@ -40,19 +56,19 @@ public class XiaomiBleSupport extends XiaomiConnectionSupport { @Override protected Set getSupportedServices() { - return XiaomiBleUuids.UUIDS.keySet(); + return XiaomiUuids.BLE_UUIDS.keySet(); } @Override - protected final TransactionBuilder initializeDevice(final TransactionBuilder builder) { - XiaomiBleUuids.XiaomiBleUuidSet uuidSet = null; + protected TransactionBuilder initializeDevice(final TransactionBuilder builder) { + XiaomiUuids.XiaomiBleUuidSet uuidSet = null; BluetoothGattCharacteristic btCharacteristicCommandRead = null; BluetoothGattCharacteristic btCharacteristicCommandWrite = null; BluetoothGattCharacteristic btCharacteristicActivityData = null; BluetoothGattCharacteristic btCharacteristicDataUpload = null; // Attempt to find a known xiaomi service - for (Map.Entry xiaomiUuid : XiaomiBleUuids.UUIDS.entrySet()) { + for (Map.Entry xiaomiUuid : XiaomiUuids.BLE_UUIDS.entrySet()) { if (getSupportedServices().contains(xiaomiUuid.getKey())) { LOG.debug("Found Xiaomi service: {}", xiaomiUuid.getKey()); uuidSet = xiaomiUuid.getValue(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppPacket.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppPacket.java new file mode 100644 index 000000000..c9173de59 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppPacket.java @@ -0,0 +1,246 @@ +/* Copyright (C) 2023 Yoran Vulker + + 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.xiaomi; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicInteger; + +import nodomain.freeyourgadget.gadgetbridge.proto.xiaomi.XiaomiProto; +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +public class XiaomiSppPacket { + private static final Logger LOG = LoggerFactory.getLogger(XiaomiSppPacket.class); + + public static final byte[] PACKET_PREAMBLE = new byte[]{(byte) 0xba, (byte) 0xdc, (byte) 0xfe}; + public static final byte[] PACKET_EPILOGUE = new byte[]{(byte) 0xef}; + + public static final int CHANNEL_VERSION = 0; + /** + * Channel ID for PROTO messages received from device + */ + public static final int CHANNEL_PROTO_RX = 1; + + /** + * Channel ID for PROTO messages sent to device + */ + public static final int CHANNEL_PROTO_TX = 2; + public static final int CHANNEL_FITNESS = 3; + public static final int CHANNEL_VOICE = 4; + public static final int CHANNEL_MASS = 5; + public static final int CHANNEL_OTA = 7; + + public static final int DATA_TYPE_PLAIN = 0; + public static final int DATA_TYPE_ENCRYPTED = 1; + public static final int DATA_TYPE_AUTH = 2; + + private byte[] payload; + private boolean flag, needsResponse; + private int channel, opCode, frameSerial, dataType; + + public static class Builder { + private byte[] payload = null; + private boolean flag = false, needsResponse = false; + private int channel = -1, opCode = -1, frameSerial = -1, dataType = -1; + + public XiaomiSppPacket build() { + XiaomiSppPacket result = new XiaomiSppPacket(); + + result.channel = channel; + result.flag = flag; + result.needsResponse = needsResponse; + result.opCode = opCode; + result.frameSerial = frameSerial; + result.dataType = dataType; + result.payload = payload; + + return result; + } + + public Builder channel(final int channel) { + this.channel = channel; + return this; + } + + public Builder flag(final boolean flag) { + this.flag = flag; + return this; + } + + public Builder needsResponse(final boolean needsResponse) { + this.needsResponse = needsResponse; + return this; + } + + public Builder opCode(final int opCode) { + this.opCode = opCode; + return this; + } + + public Builder frameSerial(final int frameSerial) { + this.frameSerial = frameSerial; + return this; + } + + public Builder dataType(final int dataType) { + this.dataType = dataType; + return this; + } + + public Builder payload(final byte[] payload) { + this.payload = payload; + return this; + } + } + + public int getChannel() { + return channel; + } + + public int getDataType() { + return dataType; + } + + public byte[] getPayload() { + return payload; + } + + public boolean needsResponse() { + return needsResponse; + } + + public boolean hasFlag() { + return this.flag; + } + + public static XiaomiSppPacket fromXiaomiCommand(final XiaomiProto.Command command, int frameCounter, boolean needsResponse) { + return newBuilder().channel(CHANNEL_PROTO_TX).flag(true).needsResponse(needsResponse).dataType( + command.getType() == XiaomiAuthService.COMMAND_TYPE && command.getSubtype() >= 17 ? DATA_TYPE_AUTH : DATA_TYPE_ENCRYPTED + ).frameSerial(frameCounter).opCode(2).payload(command.toByteArray()).build(); + } + + public static Builder newBuilder() { + return new Builder(); + } + + public String toString() { + return String.format(Locale.ROOT, + "SppPacket{ channel=0x%x, flag=%b, needsResponse=%b, opCode=0x%x, frameSerial=0x%x, dataType=0x%x, payloadSize=%d }", + channel, flag, needsResponse, opCode, frameSerial, dataType, payload.length); + } + + public static XiaomiSppPacket decode(final byte[] packet) { + if (packet.length < 11) { + LOG.error("Cannot decode incomplete packet"); + return null; + } + + ByteBuffer buffer = ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN); + byte[] preamble = new byte[PACKET_PREAMBLE.length]; + buffer.get(preamble); + + if (!Arrays.equals(PACKET_PREAMBLE, preamble)) { + LOG.error("Expected preamble (0x{}) does not match found preamble (0x{})", + GB.hexdump(PACKET_PREAMBLE), + GB.hexdump(preamble)); + return null; + } + + byte channel = buffer.get(); + + if ((channel & 0xf0) != 0) { + LOG.warn("Reserved bits in channel byte are non-zero: 0b{}", Integer.toBinaryString((channel & 0xf0) >> 4)); + channel = 0x0f; + } + + byte flags = buffer.get(); + boolean flag = (flags & 0x80) != 0; + boolean needsResponse = (flags & 0x40) != 0; + + if ((flags & 0x0f) != 0) { + LOG.warn("Reserved bits in flags byte are non-zero: 0b{}", Integer.toBinaryString(flags & 0x0f)); + } + + // payload header is included in size + int payloadLength = (buffer.getShort() & 0xffff) - 3; + + if (payloadLength + 11 > packet.length) { + LOG.error("Packet incomplete (expected length: {}, actual length: {})", payloadLength + 11, packet.length); + return null; + } + + int opCode = buffer.get() & 0xff; + int frameSerial = buffer.get() & 0xff; + int dataType = buffer.get() & 0xff; + byte[] payload = new byte[payloadLength]; + buffer.get(payload); + + byte[] epilogue = new byte[PACKET_EPILOGUE.length]; + buffer.get(epilogue); + + if (!Arrays.equals(PACKET_EPILOGUE, epilogue)) { + LOG.error("Expected epilogue (0x{}) does not match actual epilogue (0x{})", + GB.hexdump(PACKET_EPILOGUE), + GB.hexdump(epilogue)); + return null; + } + + XiaomiSppPacket result = new XiaomiSppPacket(); + result.channel = channel; + result.flag = flag; + result.needsResponse = needsResponse; + result.opCode = opCode; + result.frameSerial = frameSerial; + result.dataType = dataType; + result.payload = payload; + + return result; + } + + public byte[] encode(final XiaomiAuthService authService, final AtomicInteger encryptionCounter) { + byte[] payload = this.payload; + + if (dataType == DATA_TYPE_ENCRYPTED && channel == CHANNEL_PROTO_TX) { + short packetCounter = (short) encryptionCounter.incrementAndGet(); + payload = authService.encrypt(payload, packetCounter); + payload = ByteBuffer.allocate(payload.length + 2).order(ByteOrder.LITTLE_ENDIAN).putShort(packetCounter).put(payload).array(); + } else if (dataType == DATA_TYPE_ENCRYPTED) { + payload = authService.encrypt(payload, (short) 0); + } + + ByteBuffer buffer = ByteBuffer.allocate(11 + payload.length).order(ByteOrder.LITTLE_ENDIAN); + buffer.put(PACKET_PREAMBLE); + + buffer.put((byte) (channel & 0xf)); + buffer.put((byte) ((flag ? 0x80 : 0) | (needsResponse ? 0x40 : 0))); + buffer.putShort((short) (payload.length + 3)); + + buffer.put((byte) (opCode & 0xff)); + buffer.put((byte) (frameSerial & 0xff)); + buffer.put((byte) (dataType & 0xff)); + + buffer.put(payload); + + buffer.put(PACKET_EPILOGUE); + return buffer.array(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppSupport.java new file mode 100644 index 000000000..e3f79079d --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppSupport.java @@ -0,0 +1,261 @@ +/* Copyright (C) 2023 José Rebelo, Yoran Vulker + + 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.xiaomi; + +import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacket.CHANNEL_FITNESS; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacket.CHANNEL_PROTO_RX; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacket.PACKET_PREAMBLE; + +import android.bluetooth.BluetoothAdapter; +import android.content.Context; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.proto.xiaomi.XiaomiProto; +import nodomain.freeyourgadget.gadgetbridge.service.btbr.AbstractBTBRDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.btbr.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.btbr.actions.SetDeviceStateAction; +import nodomain.freeyourgadget.gadgetbridge.service.btbr.actions.SetProgressAction; + +public class XiaomiSppSupport extends XiaomiConnectionSupport { + private static final Logger LOG = LoggerFactory.getLogger(XiaomiSppSupport.class); + + AbstractBTBRDeviceSupport commsSupport = new AbstractBTBRDeviceSupport(LOG) { + @Override + public boolean useAutoConnect() { + return mXiaomiSupport.useAutoConnect(); + } + + @Override + public void onSocketRead(byte[] data) { + XiaomiSppSupport.this.onSocketRead(data); + } + + @Override + public boolean getAutoReconnect() { + return mXiaomiSupport.getAutoReconnect(); + } + + @Override + protected TransactionBuilder initializeDevice(TransactionBuilder builder) { + // FIXME unsetDynamicState unsets the fw version, which causes problems.. + if (getDevice().getFirmwareVersion() == null && mXiaomiSupport.getCachedFirmwareVersion() != null) { + getDevice().setFirmwareVersion(mXiaomiSupport.getCachedFirmwareVersion()); + } + + builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext())); + mXiaomiSupport.getAuthService().startEncryptedHandshake(XiaomiSppSupport.this, builder); + + return builder; + } + + @Override + protected UUID getSupportedService() { + return XiaomiUuids.UUID_SERVICE_SERIAL_PORT_PROFILE; + } + }; + + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + private final AtomicInteger frameCounter = new AtomicInteger(0); + private final AtomicInteger encryptionCounter = new AtomicInteger(0); + private final XiaomiSupport mXiaomiSupport; + private final Map mChannelHandlers = new HashMap<>(); + + public XiaomiSppSupport(final XiaomiSupport xiaomiSupport) { + this.mXiaomiSupport = xiaomiSupport; + + mChannelHandlers.put(CHANNEL_PROTO_RX, this.mXiaomiSupport::handleCommandBytes); + mChannelHandlers.put(CHANNEL_FITNESS, this.mXiaomiSupport.getHealthService().getActivityFetcher()::addChunk); + } + + @Override + public boolean connect() { + return commsSupport.connect(); + } + + @Override + public void onAuthSuccess() { + // Do nothing. + } + + @Override + public void onUploadProgress(final int textRsrc, final int progressPercent) { + try { + final TransactionBuilder builder = commsSupport.createTransactionBuilder("send data upload progress"); + builder.add(new SetProgressAction( + commsSupport.getContext().getString(textRsrc), + true, + progressPercent, + commsSupport.getContext() + )); + builder.queue(commsSupport.getQueue()); + } catch (final Exception e) { + LOG.error("Failed to update progress notification", e); + } + } + + @Override + public void setContext(GBDevice device, BluetoothAdapter adapter, Context context) { + this.commsSupport.setContext(device, adapter, context); + } + + @Override + public void disconnect() { + this.commsSupport.disconnect(); + } + + private int findNextPossiblePreamble(final byte[] haystack) { + for (int i = 1; i + 2 < haystack.length; i++) { + // check if first byte matches + if (haystack[i] == PACKET_PREAMBLE[0]) { + return i; + } + } + + // did not find preamble + return -1; + } + + private void processBuffer() { + // wait until at least an empty packet is in the buffer + while (buffer.size() >= 11) { + // start preamble compare + byte[] bufferState = buffer.toByteArray(); + ByteBuffer headerBuffer = ByteBuffer.wrap(bufferState, 0, 7).order(ByteOrder.LITTLE_ENDIAN); + byte[] preamble = new byte[PACKET_PREAMBLE.length]; + headerBuffer.get(preamble); + + if (!Arrays.equals(PACKET_PREAMBLE, preamble)) { + int preambleOffset = findNextPossiblePreamble(bufferState); + + if (preambleOffset == -1) { + LOG.debug("Buffer did not contain a valid (start of) preamble, resetting"); + buffer.reset(); + } else { + LOG.debug("Found possible preamble at offset {}, dumping preceeding bytes", preambleOffset); + byte[] remaining = new byte[bufferState.length - preambleOffset]; + System.arraycopy(bufferState, preambleOffset, remaining, 0, remaining.length); + buffer.reset(); + try { + buffer.write(remaining); + } catch (IOException ex) { + LOG.error("Failed to write bytes from found preamble offset back to buffer: ", ex); + } + } + + // continue processing at beginning of new buffer + continue; + } + + headerBuffer.getShort(); // skip flags and channel ID + int payloadSize = headerBuffer.getShort() & 0xffff; + int packetSize = payloadSize + 8; // payload size includes payload header + + if (bufferState.length < packetSize) { + LOG.debug("Packet buffer not yet satisfied: buffer size {} < expected packet size {}", bufferState.length, packetSize); + return; + } + + LOG.debug("Full packet in buffer (buffer size: {}, packet size: {})", bufferState.length, packetSize); + XiaomiSppPacket receivedPacket = XiaomiSppPacket.decode(bufferState); // remaining bytes unaffected + + onPacketReceived(receivedPacket); + + // extract remaining bytes from buffer + byte[] remaining = new byte[bufferState.length - packetSize]; + System.arraycopy(bufferState, packetSize, remaining, 0, remaining.length); + + buffer.reset(); + + try { + buffer.write(remaining); + } catch (IOException ex) { + LOG.error("Failed to write remaining packet bytes back to buffer: ", ex); + } + } + } + + @Override + public void dispose() { + commsSupport.dispose(); + } + + public void onSocketRead(byte[] data) { + try { + buffer.write(data); + } catch (IOException ex) { + LOG.error("Exception while writing buffer: ", ex); + } + + processBuffer(); + } + + private void onPacketReceived(final XiaomiSppPacket packet) { + if (packet == null) { + // likely failed to parse the packet + LOG.warn("Received null packet, did we fail to decode?"); + return; + } + + LOG.debug("Packet received: {}", packet); + // TODO send response if needsResponse is set + byte[] payload = packet.getPayload(); + + if (packet.getDataType() == 1) { + payload = mXiaomiSupport.getAuthService().decrypt(payload); + } + + int channel = packet.getChannel(); + if (mChannelHandlers.containsKey(channel)) { + XiaomiChannelHandler handler = mChannelHandlers.get(channel); + + if (handler != null) + handler.handle(payload); + } + + LOG.warn("Unhandled SppPacket on channel {}", packet.getChannel()); + } + + @Override + public void sendCommand(String taskName, XiaomiProto.Command command) { + XiaomiSppPacket packet = XiaomiSppPacket.fromXiaomiCommand(command, frameCounter.getAndIncrement(), false); + LOG.debug("sending packet: {}", packet); + TransactionBuilder builder = this.commsSupport.createTransactionBuilder("send " + taskName); + builder.write(packet.encode(mXiaomiSupport.getAuthService(), encryptionCounter)); + builder.queue(this.commsSupport.getQueue()); + } + + public void sendCommand(final TransactionBuilder builder, final XiaomiProto.Command command) { + XiaomiSppPacket packet = XiaomiSppPacket.fromXiaomiCommand(command, frameCounter.getAndIncrement(), false); + LOG.debug("sending packet: {}", packet); + + builder.write(packet.encode(mXiaomiSupport.getAuthService(), encryptionCounter)); + // do not queue here, that's the job of the caller + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSupport.java index 5fd6aaa41..b9783023d 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSupport.java @@ -122,6 +122,8 @@ public class XiaomiSupport extends AbstractDeviceSupport { case BLE: case BOTH: return new XiaomiBleSupport(this); + case BT_CLASSIC: + return new XiaomiSppSupport(this); } LOG.error("Cannot create connection-specific support, unhanded {} connection type", connType); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiBleUuids.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiUuids.java similarity index 90% rename from app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiBleUuids.java rename to app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiUuids.java index 0a9423d35..faa2e132c 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiBleUuids.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiUuids.java @@ -16,16 +16,19 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi; +import static nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport.BASE_UUID; + import java.util.LinkedHashMap; import java.util.Map; import java.util.UUID; -public class XiaomiBleUuids { - public static final Map UUIDS = new LinkedHashMap() {{ +public class XiaomiUuids { + public static final UUID UUID_SERVICE_SERIAL_PORT_PROFILE = UUID.fromString(String.format(BASE_UUID, "1101")); + public static final Map BLE_UUIDS = new LinkedHashMap() {{ // all encrypted devices seem to share the same characteristics // Mi Band 8 // Redmi Watch 3 Active - // Xiaomi Watch S1 Active + // Xiaomi Watch S1 (Active) // Redmi Smart Band 2 // Redmi Watch 2 Lite put(UUID.fromString("0000fe95-0000-1000-8000-00805f9b34fb"), new XiaomiBleUuidSet(