From cc8b54131dc44bc49f83df5b25a9750bbd6cda00 Mon Sep 17 00:00:00 2001 From: MrYoranimo Date: Tue, 3 Sep 2024 13:50:21 +0200 Subject: [PATCH] Xiaomi: add support for SPPv2 packet protocol --- .../xiaomi/AbstractXiaomiSppProtocol.java | 49 ++ .../devices/xiaomi/XiaomiAuthService.java | 35 +- .../devices/xiaomi/XiaomiChannelHandler.java | 25 + .../xiaomi/XiaomiConnectionSupport.java | 4 +- ...iSppPacket.java => XiaomiSppPacketV1.java} | 123 ++++- .../devices/xiaomi/XiaomiSppPacketV2.java | 514 ++++++++++++++++++ .../devices/xiaomi/XiaomiSppProtocolV1.java | 120 ++++ .../devices/xiaomi/XiaomiSppProtocolV2.java | 146 +++++ .../devices/xiaomi/XiaomiSppSupport.java | 245 ++++----- 9 files changed, 1102 insertions(+), 159 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/AbstractXiaomiSppProtocol.java rename app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/{XiaomiSppPacket.java => XiaomiSppPacketV1.java} (63%) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppPacketV2.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppProtocolV1.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppProtocolV2.java diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/AbstractXiaomiSppProtocol.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/AbstractXiaomiSppProtocol.java new file mode 100644 index 000000000..80de02fe0 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/AbstractXiaomiSppProtocol.java @@ -0,0 +1,49 @@ +/* Copyright (C) 2024 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 nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiChannelHandler.Channel; + +public abstract class AbstractXiaomiSppProtocol { + + public static class ParseResult { + public enum Status { + Invalid, + Incomplete, + Complete, + }; + + final Status status; + int packetSize; + + public ParseResult(final Status status) { + this.status = status; + } + + public ParseResult(final Status status, final int packetSize) { + this(status); + this.packetSize = packetSize; + } + }; + + public abstract int findNextPacketOffset(final byte[] buffer); + public abstract ParseResult processPacket(final byte[] buffer); + public abstract byte[] encodePacket(Channel channel, byte[] chunk); + public boolean initializeSession() { + return true; + } +} 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 b032fdcc0..2f2a14bd6 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 @@ -1,4 +1,4 @@ -/* Copyright (C) 2023-2024 Andreas Shimokawa, José Rebelo +/* Copyright (C) 2023-2024 Andreas Shimokawa, José Rebelo, Yoran Vulker This file is part of Gadgetbridge. @@ -37,14 +37,19 @@ import org.slf4j.LoggerFactory; import java.nio.ByteBuffer; import java.nio.ByteOrder; +import java.security.GeneralSecurityException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Arrays; import java.util.Locale; +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; import javax.crypto.Mac; import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import nodomain.freeyourgadget.gadgetbridge.GBApplication; @@ -359,4 +364,32 @@ public class XiaomiAuthService extends AbstractXiaomiService { blockCipher.init(forEncrypt, new AEADParameters(new KeyParameter(secretKey.getEncoded()), macSizeBits, nonce, null)); return blockCipher; } + + public byte[] encryptV2(final byte[] message) { + try { + // I wish I was kidding + return ctrCrypt(Cipher.ENCRYPT_MODE, encryptionKey, encryptionKey, message); + } catch (final GeneralSecurityException ex) { + throw new RuntimeException("failed to encrypt message", ex); + } + } + + public byte[] decryptV2(final byte[] ciphertext) { + try { + // I wish I was kidding + return ctrCrypt(Cipher.DECRYPT_MODE, decryptionKey, decryptionKey, ciphertext); + } catch (final GeneralSecurityException ex) { + throw new RuntimeException("failed to decrypt message", ex); + } + } + + public byte[] ctrCrypt(final int op, final byte[] key, final byte[] iv, final byte[] message) throws GeneralSecurityException { + final Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding"); + cipher.init( + op, + new SecretKeySpec(key, "AES"), + new IvParameterSpec(iv) + ); + return cipher.doFinal(message); + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiChannelHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiChannelHandler.java index eee5a20e8..0b08151bd 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiChannelHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiChannelHandler.java @@ -1,5 +1,30 @@ +/* Copyright (C) 2023-2024 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; public interface XiaomiChannelHandler { + enum Channel { + Unknown, + Version, + ProtobufCommand, + Activity, + Data, + Authentication, + }; + void handle(final byte[] payload); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiConnectionSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiConnectionSupport.java index ce5a9a4fd..8adbd7240 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiConnectionSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiConnectionSupport.java @@ -26,12 +26,12 @@ import nodomain.freeyourgadget.gadgetbridge.proto.xiaomi.XiaomiProto; public abstract class XiaomiConnectionSupport { public abstract boolean connect(); - public abstract void onAuthSuccess(); + public void onAuthSuccess() {} public abstract void onUploadProgress(int textRsrc, int progressPercent, boolean ongoing); public abstract void runOnQueue(String taskName, Runnable run); public abstract void dispose(); public abstract void setContext(final GBDevice device, final BluetoothAdapter adapter, final Context context); public abstract void sendCommand(final String taskName, final XiaomiProto.Command command); public abstract void sendDataChunk(final String taskName, final byte[] chunk, @Nullable final XiaomiCharacteristic.SendCallback callback); - public abstract void setAutoReconnect(final boolean enabled); + public void setAutoReconnect(final boolean enabled) {} } 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/XiaomiSppPacketV1.java similarity index 63% rename from app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppPacket.java rename to app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppPacketV1.java index bf45786ed..9d97c9875 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppPacket.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppPacketV1.java @@ -1,4 +1,4 @@ -/* Copyright (C) 2023 Yoran Vulker +/* Copyright (C) 2023-2024 Yoran Vulker This file is part of Gadgetbridge. @@ -28,10 +28,11 @@ import java.util.Locale; import java.util.concurrent.atomic.AtomicInteger; import nodomain.freeyourgadget.gadgetbridge.proto.xiaomi.XiaomiProto; +import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiChannelHandler.Channel; import nodomain.freeyourgadget.gadgetbridge.util.GB; -public class XiaomiSppPacket { - private static final Logger LOG = LoggerFactory.getLogger(XiaomiSppPacket.class); +public class XiaomiSppPacketV1 { + private static final Logger LOG = LoggerFactory.getLogger(XiaomiSppPacketV1.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}; @@ -55,17 +56,38 @@ public class XiaomiSppPacket { public static final int DATA_TYPE_ENCRYPTED = 1; public static final int DATA_TYPE_AUTH = 2; + public static final int OPCODE_READ = 0; + public static final int OPCODE_SEND = 2; + private byte[] payload; private boolean flag, needsResponse; - private int channel, opCode, frameSerial, dataType; + private Channel channel; + private int rawChannel, opCode, frameSerial, dataType; + + public static int getDataTypeForChannel(final Channel channel) { + switch (channel) { + case Authentication: + return DATA_TYPE_AUTH; + case ProtobufCommand: + case Version: + case Data: + return DATA_TYPE_ENCRYPTED; + default: + LOG.warn("getDataTypeForChannel(): cannot determine data type for channel {}", channel); + // fall through + case Activity: // and voice + return DATA_TYPE_PLAIN; + } + } public static class Builder { - private byte[] payload = null; - private boolean flag = false, needsResponse = false; - private int channel = -1, opCode = -1, frameSerial = -1, dataType = -1; + private byte[] payload = new byte[0]; + private boolean flag = true, needsResponse = false; + private Channel channel = Channel.Unknown; + private int opCode = -1, frameSerial = -1, dataType = -1; - public XiaomiSppPacket build() { - XiaomiSppPacket result = new XiaomiSppPacket(); + public XiaomiSppPacketV1 build() { + XiaomiSppPacketV1 result = new XiaomiSppPacketV1(); result.channel = channel; result.flag = flag; @@ -74,11 +96,12 @@ public class XiaomiSppPacket { result.frameSerial = frameSerial; result.dataType = dataType; result.payload = payload; + result.rawChannel = getRawChannel(channel, true); return result; } - public Builder channel(final int channel) { + public Builder channel(final Channel channel) { this.channel = channel; return this; } @@ -114,7 +137,7 @@ public class XiaomiSppPacket { } } - public int getChannel() { + public Channel getChannel() { return channel; } @@ -126,6 +149,29 @@ public class XiaomiSppPacket { return payload; } + public byte[] getDecryptedPayload(final XiaomiAuthService authService) { + if (payload == null) { + LOG.warn("getDecryptedPayload(): payload is null"); + return null; + } + + if (authService == null) { + LOG.warn("getDecryptedPayload(): authService is null"); + return payload; + } + + if (!authService.isEncryptionInitialized() && dataType == DATA_TYPE_ENCRYPTED) { + LOG.warn("getDecryptedPayload(): authService is not ready to decrypt"); + return payload; + } + + if (dataType == DATA_TYPE_ENCRYPTED) { + return authService.decrypt(payload); + } + + return payload; + } + public boolean needsResponse() { return needsResponse; } @@ -134,10 +180,10 @@ public class XiaomiSppPacket { 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( + public static XiaomiSppPacketV1 fromXiaomiCommand(final XiaomiProto.Command command, int frameCounter, boolean needsResponse) { + return newBuilder().channel(Channel.ProtobufCommand).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(); + ).frameSerial(frameCounter).opCode(OPCODE_SEND).payload(command.toByteArray()).build(); } public static Builder newBuilder() { @@ -148,11 +194,45 @@ public class XiaomiSppPacket { @Override 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); + "SppPacket{ channel=%s, rawChannel=%d, flag=%b, needsResponse=%b, opCode=0x%x, frameSerial=0x%x, dataType=0x%x, payloadSize=%d }", + channel, rawChannel, flag, needsResponse, opCode, frameSerial, dataType, payload.length); } - public static XiaomiSppPacket decode(final byte[] packet) { + public static int getRawChannel(final Channel channel, final boolean tx) { + switch (channel) { + case Version: + return CHANNEL_VERSION; + case Authentication: + case ProtobufCommand: + return tx ? CHANNEL_PROTO_TX : CHANNEL_PROTO_RX; + case Activity: + return CHANNEL_FITNESS; + case Data: + return CHANNEL_MASS; + default: + LOG.warn("Raw channel for {} unknown", channel); + return -1; + } + } + + public static Channel getChannel(final byte rawChannel) { + switch (rawChannel & 0xff) { + case CHANNEL_PROTO_RX: + case CHANNEL_PROTO_TX: + return Channel.ProtobufCommand; + case CHANNEL_FITNESS: + return Channel.Activity; + case CHANNEL_MASS: + return Channel.Data; + case CHANNEL_VERSION: + return Channel.Version; + default: + LOG.warn("Cannot convert raw channel {} to known channel", rawChannel & 0xff); + return Channel.Unknown; + } + } + + public static XiaomiSppPacketV1 decode(final byte[] packet) { if (packet.length < 11) { LOG.error("Cannot decode incomplete packet"); return null; @@ -208,8 +288,9 @@ public class XiaomiSppPacket { return null; } - XiaomiSppPacket result = new XiaomiSppPacket(); - result.channel = channel; + XiaomiSppPacketV1 result = new XiaomiSppPacketV1(); + result.rawChannel = channel; + result.channel = getChannel(channel); result.flag = flag; result.needsResponse = needsResponse; result.opCode = opCode; @@ -223,7 +304,7 @@ public class XiaomiSppPacket { public byte[] encode(final XiaomiAuthService authService, final AtomicInteger encryptionCounter) { byte[] payload = this.payload; - if (dataType == DATA_TYPE_ENCRYPTED && channel == CHANNEL_PROTO_TX) { + if (dataType == DATA_TYPE_ENCRYPTED && channel == Channel.ProtobufCommand) { int packetCounter = encryptionCounter.incrementAndGet(); payload = authService.encrypt(payload, packetCounter); payload = ByteBuffer.allocate(payload.length + 2).order(ByteOrder.LITTLE_ENDIAN).putShort((short) packetCounter).put(payload).array(); @@ -234,7 +315,7 @@ public class XiaomiSppPacket { ByteBuffer buffer = ByteBuffer.allocate(11 + payload.length).order(ByteOrder.LITTLE_ENDIAN); buffer.put(PACKET_PREAMBLE); - buffer.put((byte) (channel & 0xf)); + buffer.put((byte) (getRawChannel(channel, true) & 0xf)); buffer.put((byte) ((flag ? 0x80 : 0) | (needsResponse ? 0x40 : 0))); buffer.putShort((short) (payload.length + 3)); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppPacketV2.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppPacketV2.java new file mode 100644 index 000000000..c699fe00d --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppPacketV2.java @@ -0,0 +1,514 @@ +/* Copyright (C) 2024 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 nodomain.freeyourgadget.gadgetbridge.util.GB; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiChannelHandler.Channel; + +public abstract class XiaomiSppPacketV2 { + private static final Logger LOG = LoggerFactory.getLogger(XiaomiSppPacketV2.class); + + public static final byte[] PACKET_PREAMBLE = new byte[]{(byte) 0xa5, (byte) 0xa5}; + + // TODO NACK + public static final int PACKET_TYPE_UNKNOWN = -1; + public static final int PACKET_TYPE_ACK = 1; + public static final int PACKET_TYPE_SESSION_CONFIG = 2; + public static final int PACKET_TYPE_DATA = 3; + + private final int sequenceNumber; + private final int packetType; + + protected abstract byte[] getPacketPayloadBytes(XiaomiAuthService authService); + + public static abstract class Builder> { + int packetNumber = -1; + int packetType = PACKET_TYPE_UNKNOWN; + + public T setSequenceNumber(int packetNumber) { + this.packetNumber = packetNumber; + return (T) this; + } + + /** + * @noinspection UnusedReturnValue + */ + public T setPacketType(int packetType) { + this.packetType = packetType; + return (T) this; + } + + public abstract XiaomiSppPacketV2 build(); + } + + public static class AckPacket extends XiaomiSppPacketV2 { + public static class Builder extends XiaomiSppPacketV2.Builder { + public Builder() { + setPacketType(PACKET_TYPE_ACK); + } + + @Override + public XiaomiSppPacketV2 build() { + return new AckPacket(this); + } + } + + protected AckPacket(final Builder builder) { + super(builder.packetType, builder.packetNumber); + } + + @Override + protected byte[] getPacketPayloadBytes(XiaomiAuthService authService) { + return new byte[0]; + } + } + + public static class SessionConfigPacket extends XiaomiSppPacketV2 { + public static final int OPCODE_START_SESSION_REQUEST = 1; + public static final int OPCODE_START_SESSION_RESPONSE = 2; + public static final int OPCODE_STOP_SESSION_REQUEST = 3; + public static final int OPCODE_STOP_SESSION_RESPONSE = 4; + + public static final int KEY_VERSION = 1; + public static final int KEY_MAX_PACKET_SIZE = 2; + public static final int KEY_TX_WIN = 3; + public static final int KEY_SEND_TIMEOUT = 4; + + private static final int VALUE_SIZE_VERSION = 3; + private static final int VALUE_SIZE_MAX_PACKET_SIZE = 2; + private static final int VALUE_SIZE_TX_WIN = 2; + private static final int VALUE_SIZE_SEND_TIMEOUT = 2; + + public static class Builder extends XiaomiSppPacketV2.Builder { + private int opCode = -1; + + public Builder() { + setPacketType(PACKET_TYPE_SESSION_CONFIG); + } + + public Builder setOpCode(final int opCode) { + this.opCode = opCode; + return this; + } + + @Override + public XiaomiSppPacketV2 build() { + return new SessionConfigPacket(this); + } + } + + private final int opCode; + + protected SessionConfigPacket(final Builder builder) { + super(builder.packetType, builder.packetNumber); + this.opCode = builder.opCode; + } + + public int getOpCode() { + return this.opCode; + } + + @Override + protected byte[] getPacketPayloadBytes(XiaomiAuthService authService) { + // from packet dump of official app + return new byte[]{ + // opcode + (byte) this.opCode, + + // VERSION (type 1) = 01.00.00 + KEY_VERSION, + 0x03, 0x00, + 0x01, 0x00, 0x00, + + // MAX_FRAME_SIZE (type 2) = 0xfc00 -> 64512 bytes + KEY_MAX_PACKET_SIZE, + 0x02, 0x00, + 0x00, (byte) 0xfc, + + // TX_WIN (type 3) = 0x0020 -> 32 frames + KEY_TX_WIN, + 0x02, 0x00, + 0x20, 0x00, + + // SEND_TIMEOUT (type 4) = 0x2710 -> 10000ms + KEY_SEND_TIMEOUT, + 0x02, + 0x10, 0x27, + }; + } + + public static XiaomiSppPacketV2 decodePayloadBytes(final int sequenceNumber, final byte[] payloadBytes) { + final ByteBuffer buffer = ByteBuffer.wrap(payloadBytes).order(ByteOrder.LITTLE_ENDIAN); + + if (buffer.remaining() < 1) { + LOG.warn("SessionConfig.decodePayloadBytes(): at least 1 byte required to decode"); + return null; + } + + final int opCode = buffer.get() & 0xff; + + switch (opCode) { + case OPCODE_START_SESSION_REQUEST: + case OPCODE_START_SESSION_RESPONSE: { + while (buffer.remaining() >= 3) { + final int key = buffer.get() & 0xff; + final int valueSize = buffer.getShort() & 0xffff; + + if (buffer.remaining() < valueSize) { + LOG.warn("not enough bytes remaining to extract value"); + break; + } + + // TODO store and handle values + switch (key) { + case KEY_VERSION: { + if (valueSize != VALUE_SIZE_VERSION) { + LOG.warn("expected {} bytes for version value, got {}", VALUE_SIZE_VERSION, valueSize); + buffer.get(new byte[valueSize]); + break; + } + + final byte[] version = new byte[valueSize]; + buffer.get(version); + LOG.debug("received SPPv2 version: {}", GB.hexdump(version)); + break; + } + case KEY_MAX_PACKET_SIZE: { + if (valueSize != VALUE_SIZE_MAX_PACKET_SIZE) { + LOG.warn("expected 2 bytes for maximum packet size, got {}", valueSize); + buffer.get(new byte[valueSize]); + break; + } + + LOG.debug("received max packet size: {}", buffer.getShort() & 0xffff); + break; + } + case KEY_TX_WIN: { + if (valueSize != VALUE_SIZE_TX_WIN) { + LOG.warn("expected {} bytes for transmission window, got {}", VALUE_SIZE_TX_WIN, valueSize); + buffer.get(new byte[valueSize]); + break; + } + + LOG.debug("received tx win: {}", buffer.getShort() & 0xffff); + break; + } + case KEY_SEND_TIMEOUT: { + if (valueSize != VALUE_SIZE_SEND_TIMEOUT) { + LOG.warn("expected {} bytes for send timeout value, got {}", VALUE_SIZE_SEND_TIMEOUT, valueSize); + buffer.get(new byte[valueSize]); + break; + } + + LOG.debug("received send timeout: {}ms", buffer.getShort() & 0xffff); + break; + } + default: { + final byte[] value = new byte[valueSize]; + LOG.debug("received unknown config type {} with byte value {}", + key, + GB.hexdump(value)); + break; + } + } + } + + break; + } + case OPCODE_STOP_SESSION_REQUEST: + case OPCODE_STOP_SESSION_RESPONSE: { + break; + } + default: { + LOG.error("SessionConfigPacket#decode(): unknown opcode {}", opCode); + break; + } + } + + return new Builder() + .setSequenceNumber(sequenceNumber) + .setOpCode(opCode) + .build(); + } + } + + public static class DataPacket extends XiaomiSppPacketV2 { + private static final int CHANNEL_UNKNOWN = -1; + private static final int CHANNEL_PROTOBUF = 1; // encrypted after authentication + private static final int CHANNEL_DATA = 2; // not encrypted + private static final int CHANNEL_ACTIVITY = 5; // encrypted + + public static final int OPCODE_UNKNOWN = -1; + public static final int OPCODE_SEND_PLAINTEXT = 1; + public static final int OPCODE_SEND_ENCRYPTED = 2; + + public static class Builder extends XiaomiSppPacketV2.Builder { + private Channel channel = Channel.Unknown; + private int opCode = OPCODE_UNKNOWN; + private byte[] payload = new byte[0]; + + public Builder() { + setPacketType(PACKET_TYPE_DATA); + } + + public Builder setOpCode(final int opCode) { + this.opCode = opCode; + return this; + } + + public Builder setChannel(final Channel channel) { + this.channel = channel; + return this; + } + + public Builder setPayload(final byte[] payload) { + this.payload = payload; + return this; + } + + public XiaomiSppPacketV2 build() { + return new DataPacket(this); + } + } + + private final Channel channel; + private final int opCode; + private final byte[] payload; + + protected DataPacket(final Builder builder) { + super(builder.packetType, builder.packetNumber); + this.channel = builder.channel; + this.opCode = builder.opCode; + this.payload = builder.payload; + } + + private static byte getRawChannel(final Channel channel) { + switch (channel) { + case Authentication: // fall through + case ProtobufCommand: + return CHANNEL_PROTOBUF; + case Data: + return CHANNEL_DATA; + case Activity: + return CHANNEL_ACTIVITY; + default: + LOG.warn("getRawChannel(): unable to get raw channel value for channel '{}'", channel); + return CHANNEL_UNKNOWN; + } + } + + private static Channel getChannelFromRaw(final int rawChannel) { + switch (rawChannel) { + case CHANNEL_PROTOBUF: + return Channel.ProtobufCommand; + case CHANNEL_ACTIVITY: + return Channel.Activity; + case CHANNEL_DATA: + return Channel.Data; + default: + LOG.warn("getChannelFromRaw(): unknown raw channel {}", rawChannel); + return Channel.Unknown; + } + } + + public static int getOpCodeForChannel(final Channel channel) { + switch (channel) { + case Authentication: + case Data: + return OPCODE_SEND_PLAINTEXT; + case ProtobufCommand: + case Activity: + return OPCODE_SEND_ENCRYPTED; + default: + LOG.warn("getOpCodeForChannel(): conversion for channel {} unknown", channel); + return OPCODE_UNKNOWN; + } + } + + public static XiaomiSppPacketV2 decodePacketPayload(int sequenceNumber, byte[] payloadBytes) { + if (payloadBytes == null || payloadBytes.length < 2) { + LOG.error("DataPacket.decodePacketPayload(): not enough bytes to decode data packet payload"); + return null; + } + + final ByteBuffer buffer = ByteBuffer.wrap(payloadBytes).order(ByteOrder.LITTLE_ENDIAN); + final int rawChannel = buffer.get() & 0xf; + final int opCode = buffer.get() & 0xff; + final byte[] payload = new byte[buffer.remaining()]; + buffer.get(payload); + + return new Builder() + .setSequenceNumber(sequenceNumber) + .setChannel(getChannelFromRaw(rawChannel)) + .setOpCode(opCode) + .setPayload(payload) + .build(); + } + + @Override + protected byte[] getPacketPayloadBytes(XiaomiAuthService authService) { + final ByteBuffer buffer = ByteBuffer.allocate(2 + payload.length); + buffer.put((byte) (getRawChannel(this.channel) & 0xf)); + buffer.put((byte) (opCode & 0xff)); + buffer.put(opCode == OPCODE_SEND_ENCRYPTED ? authService.encryptV2(payload) : payload); + return buffer.array(); + } + + public Channel getChannel() { + return channel; + } + + public byte[] getPayloadBytes(final XiaomiAuthService authService) { + if (this.opCode == OPCODE_SEND_ENCRYPTED) { + return authService.decryptV2(this.payload); + } + + return this.payload; + } + } + + protected XiaomiSppPacketV2(final int packetType, final int sequenceNumber) { + this.packetType = packetType; + this.sequenceNumber = sequenceNumber; + } + + public static SessionConfigPacket.Builder newSessionConfigPacketBuilder() { + return new SessionConfigPacket.Builder(); + } + + public static DataPacket.Builder newDataPacketBuilder() { + return new DataPacket.Builder(); + } + + public int getPacketType() { + return this.packetType; + } + + public int getSequenceNumber() { + return this.sequenceNumber; + } + + private static int calculatePayloadChecksum(final byte[] payload) { + // consider moving to nodomain.freeyourgadget.gadgetbridge.util.CheckSums + // configuration: CRC-16/ARC (poly=0x8005, init=0, xorout=0. refin, refout) + int crc = 0; + for (final byte b : payload) { + for (int j = 0; j < 8; j++) { + crc <<= 1; + if ((((crc >> 16) & 1) ^ ((b >> j) & 1)) == 1) + crc ^= 0x8005; + } + } + return (Integer.reverse(crc) >>> 16); + } + + public byte[] encode(final XiaomiAuthService authService) { + final byte[] payloadBytes = getPacketPayloadBytes(authService); + final ByteBuffer buffer = ByteBuffer.allocate(8 + payloadBytes.length).order(ByteOrder.LITTLE_ENDIAN); + buffer.put(PACKET_PREAMBLE); + buffer.put((byte) (packetType & 0xf)); + buffer.put((byte) (sequenceNumber & 0xff)); + buffer.putShort((short) payloadBytes.length); + buffer.putShort((short) calculatePayloadChecksum(payloadBytes)); + buffer.put(payloadBytes); + return buffer.array(); + } + + public static XiaomiSppPacketV2 decode(final byte[] packetBytes) { + if (packetBytes.length < 8) { + // caller should have checked if a full packet is in the given buffer + LOG.warn("decode(): at least 8 bytes required, got {}", packetBytes.length); + return null; + } + + final ByteBuffer packetBuffer = ByteBuffer.wrap(packetBytes).order(ByteOrder.LITTLE_ENDIAN); + + // verify packet preamble + { + final byte[] preamble = new byte[PACKET_PREAMBLE.length]; + packetBuffer.get(preamble); + if (!Arrays.equals(PACKET_PREAMBLE, preamble)) { + LOG.error("decode(): packet header mismatch: expected {}, got {}", GB.hexdump(PACKET_PREAMBLE), GB.hexdump(preamble)); + return null; + } + } + + final int packetType, sequenceNumber, payloadLength, givenChecksum; + final byte[] payloadBytes; + + // extract header fields and verify all bytes present + { + final byte b = packetBuffer.get(); // flags and packet type + // TODO process flags + packetType = b & 0xf; + sequenceNumber = packetBuffer.get() & 0xff; + payloadLength = packetBuffer.getShort() & 0xffff; + givenChecksum = packetBuffer.getShort() & 0xffff; + + if (packetBuffer.remaining() < payloadLength) { + LOG.error("decode(): expected at least {} bytes in buffer, got {} (missing {} bytes to complete packet)", + payloadLength + 8, + packetBytes.length, + payloadLength - packetBuffer.remaining()); + return null; + } + } + + // get payload and verify checksum + { + payloadBytes = new byte[payloadLength]; + packetBuffer.get(payloadBytes); + final int calculatedChecksum = calculatePayloadChecksum(payloadBytes); + + if (calculatedChecksum != givenChecksum) { + LOG.error("decode(): payload checksum mismatch (given {} != calculated {})", + givenChecksum, + calculatedChecksum); + return null; + } + } + + final XiaomiSppPacketV2 decodedPacket; + + switch (packetType) { + case PACKET_TYPE_SESSION_CONFIG: + decodedPacket = SessionConfigPacket.decodePayloadBytes(sequenceNumber, payloadBytes); + break; + case PACKET_TYPE_DATA: + decodedPacket = DataPacket.decodePacketPayload(sequenceNumber, payloadBytes); + break; + case PACKET_TYPE_ACK: + decodedPacket = new AckPacket.Builder() + .setSequenceNumber(sequenceNumber) + .build(); + break; + default: + LOG.warn("decode(): unhandled packet type {}", packetType); + decodedPacket = null; + break; + } + + return decodedPacket; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppProtocolV1.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppProtocolV1.java new file mode 100644 index 000000000..a3efd27bf --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppProtocolV1.java @@ -0,0 +1,120 @@ +/* Copyright (C) 2024 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.concurrent.atomic.AtomicInteger; + +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacketV1.OPCODE_SEND; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacketV1.PACKET_PREAMBLE; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacketV1.getDataTypeForChannel; + +public class XiaomiSppProtocolV1 extends AbstractXiaomiSppProtocol { + private static final Logger LOG = LoggerFactory.getLogger(XiaomiSppProtocolV1.class); + + private final XiaomiSppSupport support; + private final AtomicInteger frameCounter = new AtomicInteger(0); + private final AtomicInteger encryptionCounter = new AtomicInteger(0); + + public XiaomiSppProtocolV1(XiaomiSppSupport support) { + this.support = support; + } + + @Override + public int findNextPacketOffset(byte[] buffer) { + for (int i = 1; i < buffer.length; i++) { + // just check for the first byte, the processPacket method checks the full magic + if (buffer[i] == PACKET_PREAMBLE[0]) { + return i; + } + } + + return -1; + } + + @Override + public ParseResult processPacket(byte[] buffer) { + if (buffer.length < 11) { + LOG.debug("processPacket(): not enough bytes in rx buffer to decode packet header"); + return new ParseResult(ParseResult.Status.Incomplete); + } + + final ByteBuffer headerBuffer = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN); + final int packetSize; + + // verify preamble + { + byte[] preamble = new byte[PACKET_PREAMBLE.length]; + headerBuffer.get(preamble); + + if (!Arrays.equals(PACKET_PREAMBLE, preamble)) { + LOG.debug("processPacket(): header mismatch, expected {}, got {}", + GB.hexdump(PACKET_PREAMBLE), + GB.hexdump(preamble)); + return new ParseResult(ParseResult.Status.Invalid); + } + } + + // verify packet size + { + headerBuffer.getShort(); // skip flags and channel ID + int payloadSize = headerBuffer.getShort() & 0xffff; + packetSize = payloadSize + 8; // payload size includes payload header + + if (buffer.length < packetSize) { + LOG.debug("processPacket(): received {}, missing {}/{} packet bytes", + buffer.length, + packetSize - buffer.length, + packetSize); + return new ParseResult(ParseResult.Status.Incomplete); + } + + LOG.debug("processPacket(): all bytes for packet of {} bytes in buffer", packetSize); + } + + XiaomiSppPacketV1 receivedPacket = XiaomiSppPacketV1.decode(buffer); + + if (receivedPacket == null) { + LOG.debug("processPacket(): decoded packet is null"); + return new ParseResult(ParseResult.Status.Invalid); + } + + LOG.debug("processPacket(): Packet received: {}", receivedPacket); + support.onPacketReceived(receivedPacket.getChannel(), receivedPacket.getDecryptedPayload(support.getAuthService())); + // TODO send response if requested by device + return new ParseResult(ParseResult.Status.Complete, packetSize); + } + + @Override + public byte[] encodePacket(XiaomiChannelHandler.Channel channel, byte[] data) { + return XiaomiSppPacketV1.newBuilder() + .channel(channel) + .opCode(OPCODE_SEND) + .frameSerial(frameCounter.getAndIncrement()) + .dataType(getDataTypeForChannel(channel)) + .payload(data) + .build() + .encode(support.getAuthService(), encryptionCounter); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppProtocolV2.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppProtocolV2.java new file mode 100644 index 000000000..90e42f29a --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppProtocolV2.java @@ -0,0 +1,146 @@ +/* Copyright (C) 2024 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.service.btbr.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacketV2.PACKET_PREAMBLE; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacketV2.PACKET_TYPE_ACK; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacketV2.PACKET_TYPE_DATA; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacketV2.PACKET_TYPE_SESSION_CONFIG; + +public class XiaomiSppProtocolV2 extends AbstractXiaomiSppProtocol { + private static final Logger LOG = LoggerFactory.getLogger(XiaomiSppProtocolV2.class); + + private final AtomicInteger packetSequenceCounter = new AtomicInteger(0); + private final XiaomiSppSupport support; + + public XiaomiSppProtocolV2(final XiaomiSppSupport support) { + this.support = support; + } + + private void sendAck(final int sequenceNumber) { + final TransactionBuilder b = support.commsSupport.createTransactionBuilder(String.format(Locale.ROOT, "send ack for %d", sequenceNumber)); + b.write(new XiaomiSppPacketV2.AckPacket.Builder() + .setSequenceNumber(sequenceNumber) + .build() + .encode(null)); + b.queue(support.commsSupport.getQueue()); + } + + @Override + public int findNextPacketOffset(byte[] buffer) { + for (int i = 1; i < buffer.length; i++) { + if (buffer[i] == PACKET_PREAMBLE[0]) + return i; + } + + return -1; + } + + @Override + public ParseResult processPacket(byte[] rxBuf) { + if (rxBuf.length < 8) { + LOG.debug("processPacket(): not enough bytes in buffer to process packet (got {} of required {} bytes)", + rxBuf.length, + 8); + return new ParseResult(ParseResult.Status.Incomplete); + } + + final ByteBuffer buffer = ByteBuffer.wrap(rxBuf).order(ByteOrder.LITTLE_ENDIAN); + final byte[] headerMagic = new byte[PACKET_PREAMBLE.length]; + buffer.get(headerMagic); + + if (!Arrays.equals(PACKET_PREAMBLE, headerMagic)) { + LOG.warn("processPacket(): invalid header magic (expected {}, got {})", + GB.hexdump(PACKET_PREAMBLE), + GB.hexdump(headerMagic)); + return new ParseResult(ParseResult.Status.Invalid); + } + + buffer.get(); // flags and packet type + buffer.get(); // packet sequence number + final int packetSize = 8 + (buffer.getShort() & 0xffff); + buffer.getShort(); // checksum + + if (rxBuf.length < packetSize) { + LOG.debug("processPacket(): missing {} bytes (got {}/{} bytes)", + packetSize - rxBuf.length, + rxBuf.length, + packetSize); + return new ParseResult(ParseResult.Status.Incomplete); + } + + final XiaomiSppPacketV2 decodedPacket = XiaomiSppPacketV2.decode(rxBuf); + if (decodedPacket != null) { + switch (decodedPacket.getPacketType()) { + case PACKET_TYPE_SESSION_CONFIG: + // TODO handle device's session config + LOG.info("Received session config, opcode={}", ((XiaomiSppPacketV2.SessionConfigPacket)decodedPacket).getOpCode()); + support.getAuthService().startEncryptedHandshake(); + break; + case PACKET_TYPE_DATA: + XiaomiSppPacketV2.DataPacket dataPacket = (XiaomiSppPacketV2.DataPacket) decodedPacket; + support.onPacketReceived(dataPacket.getChannel(), dataPacket.getPayloadBytes(support.getAuthService())); + // TODO: only directly ack protobuf packets, bulk ack others + sendAck(decodedPacket.getSequenceNumber()); + break; + case PACKET_TYPE_ACK: + LOG.debug("receive ack for packet {}", decodedPacket.getSequenceNumber()); + break; + default: + LOG.warn("Unhandled packet with type {} (decoded type {})", decodedPacket.getPacketType(), decodedPacket.getClass().getSimpleName()); + break; + } + } + + return new ParseResult(ParseResult.Status.Complete, packetSize); + } + + @Override + public boolean initializeSession() { + final TransactionBuilder builder = support.commsSupport.createTransactionBuilder("send session config"); + builder.write(XiaomiSppPacketV2.newSessionConfigPacketBuilder() + .setOpCode(XiaomiSppPacketV2.SessionConfigPacket.OPCODE_START_SESSION_REQUEST) + .setSequenceNumber(0) + .build() + .encode(null)); + builder.queue(support.commsSupport.getQueue()); + return false; + } + + @Override + public byte[] encodePacket(XiaomiChannelHandler.Channel channel, byte[] payloadBytes) { + return XiaomiSppPacketV2.newDataPacketBuilder() + .setChannel(channel) + .setSequenceNumber(packetSequenceCounter.getAndIncrement()) + .setOpCode(XiaomiSppPacketV2.DataPacket.getOpCodeForChannel(channel)) + .setPayload(payloadBytes) + .build() + .encode(support.getAuthService()); + } +} 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 index 79c09725b..66c225cea 100644 --- 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 @@ -1,4 +1,4 @@ -/* Copyright (C) 2023 José Rebelo, Yoran Vulker +/* Copyright (C) 2023-2024 José Rebelo, Yoran Vulker This file is part of Gadgetbridge. @@ -16,15 +16,14 @@ 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_MASS; -import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacket.CHANNEL_PROTO_RX; -import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacket.DATA_TYPE_ENCRYPTED; -import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacket.PACKET_PREAMBLE; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacketV1.DATA_TYPE_PLAIN; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacketV1.OPCODE_READ; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothSocket; import android.content.Context; +import android.os.Handler; +import android.os.Looper; import androidx.annotation.Nullable; @@ -33,14 +32,11 @@ 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.deviceevents.GBDeviceEventUpdateDeviceInfo; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.proto.xiaomi.XiaomiProto; import nodomain.freeyourgadget.gadgetbridge.service.btbr.AbstractBTBRDeviceSupport; @@ -48,6 +44,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.btbr.TransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.service.btbr.actions.PlainAction; import nodomain.freeyourgadget.gadgetbridge.service.btbr.actions.SetDeviceStateAction; import nodomain.freeyourgadget.gadgetbridge.service.btbr.actions.SetProgressAction; +import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiChannelHandler.Channel; import nodomain.freeyourgadget.gadgetbridge.util.GB; public class XiaomiSppSupport extends XiaomiConnectionSupport { @@ -64,11 +61,6 @@ public class XiaomiSppSupport extends XiaomiConnectionSupport { 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.. @@ -80,10 +72,18 @@ public class XiaomiSppSupport extends XiaomiConnectionSupport { builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext())); builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.AUTHENTICATING, getContext())); + builder.write(XiaomiSppPacketV1.newBuilder() + .channel(Channel.Version) + .needsResponse(true) + .opCode(OPCODE_READ) + .dataType(DATA_TYPE_PLAIN) + .frameSerial(0) + .build() + .encode(null, null)); builder.add(new PlainAction() { @Override public boolean run(BluetoothSocket socket) { - mXiaomiSupport.getAuthService().startEncryptedHandshake(); + mVersionResponseTimeoutHandler.postDelayed(new VersionTimeoutRunnable(), 5000L); return true; } }); @@ -96,12 +96,6 @@ public class XiaomiSppSupport extends XiaomiConnectionSupport { return XiaomiUuids.UUID_SERVICE_SERIAL_PORT_PROFILE; } - @Override - public void disconnect() { - mXiaomiSupport.onDisconnect(); - super.disconnect(); - } - @Override public void dispose() { mXiaomiSupport.onDisconnect(); @@ -110,16 +104,22 @@ public class XiaomiSppSupport extends XiaomiConnectionSupport { }; 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<>(); + private final Map mChannelHandlers = new HashMap<>(); + private final Handler mVersionResponseTimeoutHandler = new Handler(Looper.getMainLooper()); + private AbstractXiaomiSppProtocol mProtocol = new XiaomiSppProtocolV1(this); 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); + mChannelHandlers.put(Channel.Version, this::handleVersionPacket); + mChannelHandlers.put(Channel.ProtobufCommand, this.mXiaomiSupport::handleCommandBytes); + mChannelHandlers.put(Channel.Activity, this.mXiaomiSupport.getHealthService().getActivityFetcher()::addChunk); + } + + @Override + public void setContext(GBDevice device, BluetoothAdapter adapter, Context context) { + this.commsSupport.setContext(device, adapter, context); } @Override @@ -128,8 +128,12 @@ public class XiaomiSppSupport extends XiaomiConnectionSupport { } @Override - public void onAuthSuccess() { - // Do nothing. + public void dispose() { + commsSupport.dispose(); + } + + protected XiaomiAuthService getAuthService() { + return mXiaomiSupport.getAuthService(); } @Override @@ -153,7 +157,7 @@ public class XiaomiSppSupport extends XiaomiConnectionSupport { if (commsSupport == null) { LOG.error("commsSupport is null, unable to queue task"); return; - } + } final TransactionBuilder b = commsSupport.createTransactionBuilder("run task " + taskName + " on queue"); b.add(new PlainAction() { @@ -166,87 +170,54 @@ public class XiaomiSppSupport extends XiaomiConnectionSupport { b.queue(commsSupport.getQueue()); } - @Override - public void setContext(GBDevice device, BluetoothAdapter adapter, Context context) { - this.commsSupport.setContext(device, adapter, context); - } + private void skipBuffer(int newStart) { + final byte[] bufferState = buffer.toByteArray(); + buffer.reset(); - 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; - } + if (newStart < 0) { + newStart = bufferState.length; } - // did not find preamble - return -1; + if (newStart >= bufferState.length) { + return; + } + + buffer.write(bufferState, newStart, bufferState.length - newStart); } 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); + boolean shouldProcess = true; + while (shouldProcess) { + final byte[] bufferState = buffer.toByteArray(); + final AbstractXiaomiSppProtocol.ParseResult parseResult = mProtocol.processPacket(bufferState); + LOG.debug("processBuffer(): protocol.processPacket() returned status {}", parseResult.status); + int skipBytes; - 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); + switch (parseResult.status) { + case Incomplete: + skipBytes = 0; + shouldProcess = false; + break; + case Complete: + skipBytes = parseResult.packetSize; + break; + case Invalid: + skipBytes = mProtocol.findNextPacketOffset(bufferState); + if (skipBytes < 0) { + skipBytes = bufferState.length; } - } - - // continue processing at beginning of new buffer - continue; + break; + default: + throw new IllegalStateException(String.format("Unhandled parse state %s", parseResult.status)); } - 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); + if (skipBytes > 0) { + LOG.debug("processBuffer(): skipping {} bytes for state {}", skipBytes, parseResult.status); + skipBuffer(skipBytes); } } } - @Override - public void dispose() { - commsSupport.dispose(); - } - public void onSocketRead(byte[] data) { try { buffer.write(data); @@ -257,26 +228,12 @@ public class XiaomiSppSupport extends XiaomiConnectionSupport { 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); - } - - final XiaomiChannelHandler handler = mChannelHandlers.get(packet.getChannel()); + protected void onPacketReceived(final Channel channel, final byte[] payload) { + final XiaomiChannelHandler handler = mChannelHandlers.get(channel); if (handler != null) { handler.handle(payload); } else { - LOG.warn("Unhandled SppPacket on channel {}", packet.getChannel()); + LOG.warn("Unhandled SppPacket on channel {}", channel); } } @@ -292,27 +249,20 @@ public class XiaomiSppSupport extends XiaomiConnectionSupport { } public void sendCommand(final TransactionBuilder builder, final XiaomiProto.Command command) { - final XiaomiSppPacket packet = XiaomiSppPacket.fromXiaomiCommand(command, frameCounter.getAndIncrement(), false); - LOG.debug("sending packet: {}, payload={}", packet, GB.hexdump(packet.getPayload())); - - builder.write(packet.encode(mXiaomiSupport.getAuthService(), encryptionCounter)); + LOG.debug("sendCommand(): encoded command for task '{}': {}", builder.getTransaction().getTaskName(), GB.hexdump(command.toByteArray())); + if (command.getType() == XiaomiAuthService.COMMAND_TYPE) { + builder.write(mProtocol.encodePacket(Channel.Authentication, command.toByteArray())); + } else { + builder.write(mProtocol.encodePacket(Channel.ProtobufCommand, command.toByteArray())); + } // do not queue here, that's the job of the caller } public void sendDataChunk(final String taskName, final byte[] chunk, @Nullable final XiaomiCharacteristic.SendCallback callback) { - XiaomiSppPacket packet = XiaomiSppPacket.newBuilder() - .channel(CHANNEL_MASS) - .needsResponse(false) - .flag(true) - .opCode(2) - .frameSerial(frameCounter.getAndIncrement()) - .dataType(DATA_TYPE_ENCRYPTED) - .payload(chunk) - .build(); - LOG.debug("sending data packet: {}", packet); - TransactionBuilder b = this.commsSupport.createTransactionBuilder("send " + taskName); - b.write(packet.encode(mXiaomiSupport.getAuthService(), encryptionCounter)); - b.queue(commsSupport.getQueue()); + LOG.debug("sendDataChunk(): encoded data chunk for task '{}': {}", taskName, GB.hexdump(chunk)); + this.commsSupport.createTransactionBuilder("send " + taskName) + .write(mProtocol.encodePacket(Channel.Data, chunk)) + .queue(commsSupport.getQueue()); if (callback != null) { // callback puts a SetProgressAction onto the queue @@ -320,9 +270,34 @@ public class XiaomiSppSupport extends XiaomiConnectionSupport { } } - @Override - public void setAutoReconnect(boolean enabled) { - // for sanity, but this is not supposed to be set on BT Classic devices - this.commsSupport.setAutoReconnect(enabled); + private void handleVersionPacket(final byte[] payloadBytes) { + // remove timeout actions from handler + mVersionResponseTimeoutHandler.removeCallbacksAndMessages(null); + + if (payloadBytes != null && payloadBytes.length > 0) { + LOG.debug("Received SPP protocol version: {}", GB.hexdump(payloadBytes)); + + // show in details + final GBDeviceEventUpdateDeviceInfo event = new GBDeviceEventUpdateDeviceInfo("SPP_PROTOCOL: ", GB.hexdump(payloadBytes)); + mXiaomiSupport.evaluateGBDeviceEvent(event); + + // TODO handle different protocol versions + if (payloadBytes[0] >= 2) { + LOG.info("handleVersionPacket(): detected protocol version higher than 2, switching protocol"); + mProtocol = new XiaomiSppProtocolV2(this); + } + } + + if (mProtocol.initializeSession()) { + mXiaomiSupport.getAuthService().startEncryptedHandshake(); + } + } + + class VersionTimeoutRunnable implements Runnable { + @Override + public void run() { + LOG.warn("SPP protocol version request timed out"); + XiaomiSppSupport.this.handleVersionPacket(new byte[0]); + } } }