/* Copyright (C) 2023-2024 Andreas Shimokawa, José Rebelo
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.BluetoothGattCharacteristic;
import android.os.Handler;
import android.os.Looper;
import androidx.annotation.Nullable;
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.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BtLEQueue;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
public class XiaomiCharacteristic {
private final Logger LOG = LoggerFactory.getLogger(XiaomiCharacteristic.class);
private static final long TIMEOUT_TASK_DELAY = 5000L;
public static final byte[] PAYLOAD_ACK = new byte[]{0, 0, 3, 0};
private final XiaomiBleSupport mSupport;
private final BluetoothGattCharacteristic bluetoothGattCharacteristic;
private final UUID characteristicUUID;
// Encryption
private final XiaomiAuthService authService;
private boolean isEncrypted;
public boolean incrementNonce = true;
private int encryptedIndex = 0;
// max chunk size, including headers
private int maxWriteSize = 244; // MTU of 247 - 3 bytes for the ATT overhead (based on lowest MTU observed after increasing MTU to 512)
private int maxWriteSizeForCurrentMessage;
// Chunking
private int numChunks = 0;
private final Handler timeoutHandler = new Handler(Looper.getMainLooper());
private final Map receivedChunks = new HashMap<>();
// Scheduling
// TODO timeouts
private final Queue payloadQueue = new LinkedList<>();
private boolean waitingAck = false;
private boolean sendingChunked = false;
private Payload currentPayload = null;
private XiaomiChannelHandler channelHandler = null;
public XiaomiCharacteristic(final XiaomiBleSupport support,
final BluetoothGattCharacteristic bluetoothGattCharacteristic,
@Nullable final XiaomiAuthService authService) {
this.mSupport = support;
this.bluetoothGattCharacteristic = bluetoothGattCharacteristic;
this.authService = authService;
this.isEncrypted = authService != null;
this.characteristicUUID = bluetoothGattCharacteristic.getUuid();
}
public UUID getCharacteristicUUID() {
return characteristicUUID;
}
public void setChannelHandler(final XiaomiChannelHandler handler) {
this.channelHandler = handler;
}
public void setEncrypted(final boolean encrypted) {
this.isEncrypted = encrypted;
}
public void setIncrementNonce(final boolean incrementNonce) {
this.incrementNonce = incrementNonce;
}
public void reset() {
this.numChunks = 0;
this.encryptedIndex = 1; // 0 is used by auth service
this.receivedChunks.clear();
this.payloadQueue.clear();
this.waitingAck = false;
this.sendingChunked = false;
this.currentPayload = null;
cancelTimeoutTask();
}
/**
* Write bytes to this characteristic, encrypting and splitting it into chunks if necessary.
* Callback will be notified when a (n)ack has been received by the remote device.
*/
public void write(final String taskName, final byte[] value, final SendCallback callback) {
write(null, new Payload(taskName, value, callback));
}
/**
* Write bytes to this characteristic, encrypting and splitting it into chunks if necessary.
*/
public void write(final String taskName, final byte[] value) {
write(taskName, value, null);
}
/**
* Write bytes to this characteristic, encrypting and splitting it into chunks if necessary. Uses
* the provided builder if we need to schedule something, otherwise it will be queued as other
* commands. The callback will be notified when a (n)ack has been received from the remote
* device in response to the payload being sent.
*/
public void write(final TransactionBuilder builder, final byte[] value, final SendCallback callback) {
write(builder, new Payload(builder.getTaskName(), value, callback));
}
/**
* Write bytes to this characteristic, encrypting and splitting it into chunks if necessary. Uses
* the provided if we need to schedule something, otherwise it will be queued as other commands.
*/
public void write(final TransactionBuilder builder, final byte[] value) {
write(builder, value, null);
}
private void write(final TransactionBuilder builder, final Payload payload) {
payloadQueue.add(payload);
sendNext(builder);
}
private void sendChunk(final TransactionBuilder builder, final int index, final int chunkPayloadSize) {
final byte[] payload = currentPayload.getBytesToSend();
final int startIndex = index * chunkPayloadSize;
final int endIndex = Math.min((index + 1) * chunkPayloadSize, payload.length);
LOG.debug("Sending chunk {} from {} to {} for {}", index, startIndex, endIndex, currentPayload.getTaskName());
final byte[] chunkToSend = new byte[2 + endIndex - startIndex];
BLETypeConversions.writeUint16(chunkToSend, 0, index + 1);
System.arraycopy(payload, startIndex, chunkToSend, 2, endIndex - startIndex);
builder.write(bluetoothGattCharacteristic, chunkToSend);
}
public void dispose() {
cancelTimeoutTask();
}
private void requestMissingChunks() {
if (!(numChunks > 0)) {
LOG.warn("Timeout task ran but not expecting any chunks");
return;
}
LOG.debug("Timeout reached while waiting for all chunks from device");
final List missingChunks = new ArrayList<>();
for (int i = 0; i < numChunks; i++) {
if (!this.receivedChunks.containsKey(i + 1)) {
missingChunks.add(i + 1);
}
}
// prevent going over maximum message length
int reqChunkCount = Math.min(missingChunks.size(), (maxWriteSize - 4) / 2);
if (reqChunkCount < missingChunks.size()) {
LOG.debug("Missing {} chunk(s), only requesting first {}: {}", missingChunks.size(), reqChunkCount, missingChunks);
} else {
LOG.debug("Missing {} chunk(s): {}", missingChunks.size(), missingChunks);
}
final ByteBuffer bb = ByteBuffer.allocate(4 + reqChunkCount * 2).order(ByteOrder.LITTLE_ENDIAN);
bb.putShort((short) 0); // chunk ID
bb.put((byte) 1); // type CHUNKED_ACK
bb.put((byte) 5); // indicate partially received transmission, followed by missing chunks
for (int i = 0; i < reqChunkCount; i++) {
bb.putShort(missingChunks.get(i).shortValue());
}
final TransactionBuilder tb = mSupport.createTransactionBuilder(String.format("send nack with missing chunks %s", missingChunks));
tb.write(bluetoothGattCharacteristic, bb.array());
tb.queue(mSupport.getQueue());
}
private void cancelTimeoutTask() {
this.timeoutHandler.removeCallbacksAndMessages(null);
}
private void rescheduleTimeoutTask() {
cancelTimeoutTask();
this.timeoutHandler.postDelayed(this::requestMissingChunks, TIMEOUT_TASK_DELAY);
}
private byte[] reconstructPayloadFromChunks() {
final ByteArrayOutputStream out = new ByteArrayOutputStream();
try {
for (int i = 0; i < this.numChunks; i++) {
if (!this.receivedChunks.containsKey(i + 1) || this.receivedChunks.get(i + 1) == null) {
LOG.error("Missing chunk {}", i + 1);
return new byte[0];
}
out.write(this.receivedChunks.get(i + 1));
}
} catch (final IOException ex) {
LOG.error("Failed to reconstruct payload", ex);
return new byte[0];
}
return out.toByteArray();
}
public void onCharacteristicChanged(final byte[] value) {
final ByteBuffer buf = ByteBuffer.wrap(value).order(ByteOrder.LITTLE_ENDIAN);
final int chunk = buf.getShort();
if (chunk != 0) {
// Chunked packet
LOG.debug("Got chunk {} of {}", chunk, numChunks);
if (chunk > numChunks) {
LOG.warn("Ignoring chunk {} exceeding upper bound {}", chunk, numChunks);
return;
}
if (this.receivedChunks.containsKey(chunk)) {
LOG.warn("Already received chunk {}", chunk);
}
final byte[] chunkBytes = new byte[buf.limit() - buf.position()];
buf.get(chunkBytes);
this.receivedChunks.put(chunk, chunkBytes);
rescheduleTimeoutTask();
if (this.receivedChunks.keySet().size() == numChunks) {
cancelTimeoutTask();
sendChunkEndAck();
final byte[] payload = reconstructPayloadFromChunks();
if (payload.length == 0) {
LOG.warn("Payload reconstructed from chunks was empty");
} else if (channelHandler != null) {
if (isEncrypted) {
// chunks are always encrypted if an auth service is available
channelHandler.handle(authService.decrypt(payload));
} else {
channelHandler.handle(payload);
}
} else {
LOG.warn("Channel handler for char {} is null!", characteristicUUID);
}
this.numChunks = 0;
this.receivedChunks.clear();
}
} else {
// Not a chunk / single-packet
final byte type = buf.get();
switch (type) {
case 0:
// Chunked start request
// TODO verify previous transfer completed
final byte messageEncrypted = buf.get();
byte expectedResult = (byte) (isEncrypted ? 1 : 0);
if (messageEncrypted != expectedResult) {
LOG.warn("Chunked start request: expected {}, got {}", expectedResult, messageEncrypted);
return;
}
this.numChunks = buf.getShort();
this.receivedChunks.clear();
LOG.debug("Got chunked start request for {} chunks", this.numChunks);
sendChunkStartAck();
return;
case 1:
// Chunked ack
final byte subtype = buf.get();
final byte[] remaining = new byte[buf.remaining()];
if (buf.hasRemaining()) {
buf.get(remaining);
LOG.debug("Operation CHUNK_ACK of type {} has additional payload: {}",
subtype, GB.hexdump(remaining));
}
switch (subtype) {
case 0: {
LOG.debug("Got chunked ack end");
if (currentPayload != null && currentPayload.getCallback() != null) {
currentPayload.getCallback().onSend();
}
currentPayload = null;
sendingChunked = false;
sendNext(null);
return;
}
case 1: {
LOG.debug("Got chunked ack start");
final TransactionBuilder builder = mSupport.createTransactionBuilder("send chunks for " + currentPayload.getTaskName());
final byte[] payload = currentPayload.getBytesToSend();
final int chunkPayloadSize = maxWriteSizeForCurrentMessage - 2;
for (int i = 0; i * chunkPayloadSize < payload.length; i++) {
sendChunk(builder, i, chunkPayloadSize);
}
builder.queue(mSupport.getQueue());
return;
}
case 2: {
LOG.warn("Got chunked nack for {}", currentPayload.getTaskName());
if (currentPayload != null && currentPayload.getCallback() != null) {
currentPayload.getCallback().onNack();
}
currentPayload = null;
sendingChunked = false;
sendNext(null);
return;
}
case 5: {
short[] invalidChunks = new short[remaining.length / 2];
if (remaining.length > 0) {
ByteBuffer remainingBuffer = ByteBuffer.wrap(remaining).order(ByteOrder.LITTLE_ENDIAN);
for (int i = 0; i < remaining.length / 2; i++) {
invalidChunks[i] = remainingBuffer.getShort();
}
LOG.info("Got chunk request, requested chunks: {}", Arrays.toString(invalidChunks));
final TransactionBuilder builder = mSupport.createTransactionBuilder("resend chunks for " + currentPayload.getTaskName());
for (short chunkIndex : invalidChunks) {
// chunk indices start at 1
sendChunk(builder, chunkIndex - 1, maxWriteSizeForCurrentMessage - 2);
}
} else {
LOG.warn("Got chunk request, no chunk indices requested");
if (maxWriteSize != maxWriteSizeForCurrentMessage) {
LOG.info("MTU changed while sending message, prepending message to queue and resending");
((LinkedList) payloadQueue).addFirst(currentPayload);
currentPayload = null;
sendingChunked = false;
sendNext(null);
return;
}
}
}
}
LOG.warn("Unknown chunked ack subtype {} for {}", subtype, currentPayload.getTaskName());
return;
case 2:
// Single command
sendAck();
final byte encryption = buf.get();
final byte[] plainValue;
if (encryption == 1) {
final byte[] encryptedValue = new byte[buf.limit() - buf.position()];
buf.get(encryptedValue);
plainValue = authService.decrypt(encryptedValue);
} else {
plainValue = new byte[buf.limit() - buf.position()];
buf.get(plainValue);
}
if (channelHandler != null)
channelHandler.handle(plainValue);
else
LOG.warn("Channel handler for char {} is null!", characteristicUUID);
return;
case 3:
// ack
final byte result = buf.get();
if (result == 0) {
LOG.debug("Got ack for {}", currentPayload.getTaskName());
if (currentPayload != null && currentPayload.getCallback() != null) {
currentPayload.getCallback().onSend();
}
} else {
LOG.warn("Got single cmd NACK ({}) for {}", result, currentPayload.getTaskName());
if (currentPayload != null && currentPayload.getCallback() != null) {
currentPayload.getCallback().onNack();
}
}
currentPayload = null;
waitingAck = false;
sendNext(null);
return;
}
LOG.warn("Unhandled command type {}", type);
}
}
private void sendNext(@Nullable final TransactionBuilder b) {
if (waitingAck || sendingChunked) {
LOG.debug("Already sending something");
return;
}
currentPayload = payloadQueue.poll();
if (currentPayload == null) {
LOG.debug("Nothing to send");
return;
}
LOG.debug("Will send {}", GB.hexdump(currentPayload.getBytesToSend()));
final boolean encrypt = isEncrypted && authService.isEncryptionInitialized();
if (encrypt) {
currentPayload.setBytesToSend(authService.encrypt(currentPayload.getBytesToSend(), incrementNonce ? encryptedIndex : 0));
}
// before checking whether message should be chunked, read the maximum message size for this transaction
maxWriteSizeForCurrentMessage = maxWriteSize;
if (shouldWriteChunked(currentPayload.getBytesToSend())) {
if (encrypt && incrementNonce) {
// Prepend encrypted index for the nonce
currentPayload.setBytesToSend(
ByteBuffer.allocate(2 + currentPayload.getBytesToSend().length).order(ByteOrder.LITTLE_ENDIAN)
.putShort((short) encryptedIndex++)
.put(currentPayload.getBytesToSend())
.array()
);
}
LOG.debug("Sending {} - chunked", currentPayload.getTaskName());
sendingChunked = true;
final ByteBuffer buf = ByteBuffer.allocate(6).order(ByteOrder.LITTLE_ENDIAN);
buf.putShort((short) 0);
buf.put((byte) 0);
buf.put((byte) (encrypt ? 1 : 0));
buf.putShort((short) Math.ceil(currentPayload.getBytesToSend().length / (float) (maxWriteSizeForCurrentMessage - 2)));
final TransactionBuilder builder = b == null ? mSupport.createTransactionBuilder("send chunked start for " + currentPayload.getTaskName()) : b;
builder.write(bluetoothGattCharacteristic, buf.array());
if (b == null) {
builder.queue(mSupport.getQueue());
}
} else {
LOG.debug("Sending {} - single", currentPayload.getTaskName());
// Encrypt single command
final int commandLength = (encrypt ? 6 : 4) + currentPayload.getBytesToSend().length;
final ByteBuffer buf = ByteBuffer.allocate(commandLength).order(ByteOrder.LITTLE_ENDIAN);
buf.putShort((short) 0);
buf.put((byte) 2); // 2 for command
buf.put((byte) (encrypt ? 1 : 2));
if (encrypt) {
if (incrementNonce) {
buf.putShort((short) encryptedIndex++);
} else {
buf.putShort((short) 0);
}
}
buf.put(currentPayload.getBytesToSend()); // it's already encrypted
waitingAck = true;
final TransactionBuilder builder = b == null ? mSupport.createTransactionBuilder("send single command for " + currentPayload.getTaskName()) : b;
builder.write(bluetoothGattCharacteristic, buf.array());
if (b == null) {
builder.queue(mSupport.getQueue());
}
}
}
private boolean shouldWriteChunked(final byte[] payload) {
if (!isEncrypted) {
// non-encrypted are always chunked
return true;
}
// payload + 6 bytes at the start with the encryption stuff
return payload.length + 6 > maxWriteSizeForCurrentMessage;
}
private void sendAck() {
final TransactionBuilder builder = mSupport.createTransactionBuilder("send ack");
builder.write(bluetoothGattCharacteristic, PAYLOAD_ACK);
builder.queue(mSupport.getQueue());
}
private void sendChunkStartAck() {
final TransactionBuilder builder = mSupport.createTransactionBuilder("send chunked start ack");
builder.write(bluetoothGattCharacteristic, new byte[]{0x00, 0x00, 0x01, 0x01});
builder.queue(mSupport.getQueue());
}
private void sendChunkEndAck() {
final TransactionBuilder builder = mSupport.createTransactionBuilder("send chunked end ack");
builder.write(bluetoothGattCharacteristic, new byte[]{0x00, 0x00, 0x01, 0x00});
builder.queue(mSupport.getQueue());
}
public void setMtu(final int newMtu) {
// subtract ATT packet header size
maxWriteSize = newMtu - 3;
}
private static class Payload {
private final String taskName;
private final byte[] bytes;
// Bytes that will actually be sent (might be encrypted)
private byte[] bytesToSend;
private final SendCallback callback;
public Payload(final String taskName, final byte[] bytes, final SendCallback callback) {
this.taskName = taskName;
this.bytes = bytes;
this.callback = callback;
}
public Payload(final String taskName, final byte[] bytes) {
this(taskName, bytes, null);
}
public String getTaskName() {
return taskName;
}
public void setBytesToSend(final byte[] bytesToSend) {
this.bytesToSend = bytesToSend;
}
public byte[] getBytesToSend() {
return bytesToSend != null ? bytesToSend : bytes;
}
public SendCallback getCallback() { return this.callback; }
}
public interface SendCallback {
void onSend();
void onNack();
}
}