Zepp OS 3/4: Refactor file transfer service, fix screenshots

This commit is contained in:
José Rebelo
2025-03-22 17:25:02 +00:00
parent 73d15e3df7
commit b94bfa5701
10 changed files with 1117 additions and 558 deletions
@@ -41,7 +41,7 @@ public class HuamiService {
* Alarms, Display and other configuration.
*/
public static final UUID UUID_CHARACTERISTIC_3_CONFIGURATION = UUID.fromString("00000003-0000-3512-2118-0009af100700");
public static final UUID UUID_UNKNOWN_CHARACTERISTIC4 = UUID.fromString("00000004-0000-3512-2118-0009af100700");
public static final UUID UUID_CHARACTERISTIC_5_ACTIVITY_CONTROL = UUID.fromString("00000004-0000-3512-2118-0009af100700");
public static final UUID UUID_CHARACTERISTIC_5_ACTIVITY_DATA = UUID.fromString("00000005-0000-3512-2118-0009af100700");
public static final UUID UUID_CHARACTERISTIC_6_BATTERY_INFO = UUID.fromString("00000006-0000-3512-2118-0009af100700");
public static final UUID UUID_CHARACTERISTIC_7_REALTIME_STEPS = UUID.fromString("00000007-0000-3512-2118-0009af100700");
@@ -58,7 +58,8 @@ public class HuamiService {
public static final UUID UUID_CHARACTERISTIC_CHUNKEDTRANSFER_2021_READ = UUID.fromString("00000017-0000-3512-2118-0009af100700");
public static final UUID UUID_CHARACTERISTIC_CHUNKEDTRANSFER = UUID.fromString("00000020-0000-3512-2118-0009af100700");
public static final UUID UUID_CHARACTERISTIC_ZEPP_OS_FILE_TRANSFER_V3 = UUID.fromString("00000023-0000-3512-2118-0009af100700");
public static final UUID UUID_CHARACTERISTIC_ZEPP_OS_FILE_TRANSFER_V3_SEND = UUID.fromString("00000023-0000-3512-2118-0009af100700");
public static final UUID UUID_CHARACTERISTIC_ZEPP_OS_FILE_TRANSFER_V3_RECEIVE = UUID.fromString("00000024-0000-3512-2118-0009af100700");
public static final int ALERT_LEVEL_NONE = 0;
public static final int ALERT_LEVEL_MESSAGE = 1;
@@ -70,11 +70,6 @@ public class AmazfitActive2Coordinator extends ZeppOsCoordinator {
return true;
}
@Override
public boolean supportsScreenshots(final GBDevice device) {
return false;
}
@Override
public boolean supportsToDoList() {
return true;
@@ -98,6 +98,7 @@ public class BleNamesResolver {
mServices.put("0000fe95-0000-1000-8000-00805f9b34fb", "(Propr: Xiaomi Wear Service)");
mServices.put("0000fee0-0000-3512-2118-0009af100700", "(Propr: Xiaomi MiLi Service)");
mServices.put("00001530-0000-3512-2118-0009af100700", "(Propr: Xiaomi Weight Service)");
mServices.put("0000fee0-0000-1000-8000-00805f9b34fb", "(Propr: Xiaomi Huami Service)");
mServices.put("14701820-620a-3973-7c78-9cfff0876abd", "(Propr: HPLUS Service)");
mServices.put("16186f00-0000-1000-8000-00807f9b34fb", "(Propr: Xiaomi Wear Service - Mi Watch Lite/Redmi Watch)");
mServices.put("16187f00-0000-1000-8000-00807f9b34fb", "(Propr: Xiaomi Wear Service - Mi Smart Watch 4C/Redmi Band)");
@@ -219,6 +220,26 @@ public class BleNamesResolver {
//mCharacteristics.put("ebe0ccbc-7a0a-4b0c-8a1a-6ff2997da3a6", "(Propr: Lywsd HISTORY)");
//mCharacteristics.put("ebe0ccc1-7a0a-4b0c-8a1a-6ff2997da3a6", "(Propr: Lywsd LIVE_DATA)");
//mCharacteristics.put("ebe0ccba-7a0a-4b0c-8a1a-6ff2997da3a6", "(Propr: Lywsd HISTORY_LAST_ID)");
mCharacteristics.put("00000001-0000-3512-2118-0009af100700", "(Propr: Huami Raw Sensor Control)");
mCharacteristics.put("00000002-0000-3512-2118-0009af100700", "(Propr: Huami Raw Sensor Data)");
mCharacteristics.put("00000003-0000-3512-2118-0009af100700", "(Propr: Huami Configuration)");
mCharacteristics.put("00000004-0000-3512-2118-0009af100700", "(Propr: Huami Activity Control)");
mCharacteristics.put("00000005-0000-3512-2118-0009af100700", "(Propr: Huami Activity Data)");
mCharacteristics.put("00000006-0000-3512-2118-0009af100700", "(Propr: Huami Battery Info)");
mCharacteristics.put("00000007-0000-3512-2118-0009af100700", "(Propr: Huami Realtime Steps)");
mCharacteristics.put("00000008-0000-3512-2118-0009af100700", "(Propr: Huami User Settings)");
mCharacteristics.put("00000009-0000-3512-2118-0009af100700", "(Propr: Huami Auth)");
mCharacteristics.put("0000000f-0000-3512-2118-0009af100700", "(Propr: Huami Workout)");
mCharacteristics.put("00000010-0000-3512-2118-0009af100700", "(Propr: Huami Device Event)");
mCharacteristics.put("00000012-0000-3512-2118-0009af100700", "(Propr: Huami Audio Control)");
mCharacteristics.put("00000013-0000-3512-2118-0009af100700", "(Propr: Huami Audio Data)");
mCharacteristics.put("00000016-0000-3512-2118-0009af100700", "(Propr: Huami 2021 Chunked Write)");
mCharacteristics.put("00000017-0000-3512-2118-0009af100700", "(Propr: Huami 2021 Chunked Read)");
mCharacteristics.put("00000020-0000-3512-2118-0009af100700", "(Propr: Huami Chunked Transfer)");
mCharacteristics.put("00000023-0000-3512-2118-0009af100700", "(Propr: Zepp OS File Transfer V3 Send)");
mCharacteristics.put("00000024-0000-3512-2118-0009af100700", "(Propr: Zepp OS File Transfer V3 Receive)");
mCharacteristics.put("00001531-0000-3512-2118-0009af100700", "(Propr: Huami Firmware Control)");
mCharacteristics.put("00001532-0000-3512-2118-0009af100700", "(Propr: Huami Firmware Data)");
mValueFormats.put(52, "32bit float");
mValueFormats.put(50, "16bit float");
@@ -22,7 +22,6 @@ import android.bluetooth.BluetoothGattCharacteristic;
import android.content.SharedPreferences;
import android.widget.Toast;
import androidx.annotation.CallSuper;
import androidx.annotation.NonNull;
import org.slf4j.Logger;
@@ -100,7 +99,7 @@ public abstract class AbstractFetchOperation extends AbstractHuamiOperation {
characteristicActivityData = getCharacteristic(HuamiService.UUID_CHARACTERISTIC_5_ACTIVITY_DATA);
builder.notify(characteristicActivityData, false);
characteristicFetch = getCharacteristic(HuamiService.UUID_UNKNOWN_CHARACTERISTIC4);
characteristicFetch = getCharacteristic(HuamiService.UUID_CHARACTERISTIC_5_ACTIVITY_CONTROL);
builder.notify(characteristicFetch, true);
startFetching(builder);
@@ -123,7 +122,7 @@ public abstract class AbstractFetchOperation extends AbstractHuamiOperation {
if (HuamiService.UUID_CHARACTERISTIC_5_ACTIVITY_DATA.equals(characteristicUUID)) {
handleActivityData(characteristic.getValue());
return true;
} else if (HuamiService.UUID_UNKNOWN_CHARACTERISTIC4.equals(characteristicUUID)) {
} else if (HuamiService.UUID_CHARACTERISTIC_5_ACTIVITY_CONTROL.equals(characteristicUUID)) {
handleActivityMetadata(characteristic.getValue());
return true;
} else {
@@ -1378,8 +1378,11 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
public boolean onCharacteristicChanged(final BluetoothGatt gatt,
final BluetoothGattCharacteristic characteristic) {
final UUID characteristicUUID = characteristic.getUuid();
if (HuamiService.UUID_CHARACTERISTIC_ZEPP_OS_FILE_TRANSFER_V3.equals(characteristicUUID)) {
fileTransferService.onCharacteristicChanged(characteristic.getValue());
if (HuamiService.UUID_CHARACTERISTIC_ZEPP_OS_FILE_TRANSFER_V3_SEND.equals(characteristicUUID)) {
fileTransferService.onCharacteristicChanged(characteristic);
return true;
} else if (HuamiService.UUID_CHARACTERISTIC_ZEPP_OS_FILE_TRANSFER_V3_RECEIVE.equals(characteristicUUID)) {
fileTransferService.onCharacteristicChanged(characteristic);
return true;
}
@@ -1,4 +1,4 @@
/* Copyright (C) 2023-2024 José Rebelo
/* Copyright (C) 2023-2025 José Rebelo
This file is part of Gadgetbridge.
@@ -16,51 +16,24 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services;
import org.apache.commons.lang3.ArrayUtils;
import android.bluetooth.BluetoothGattCharacteristic;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.zip.DataFormatException;
import java.util.zip.Deflater;
import java.util.zip.Inflater;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.AbstractZeppOsService;
import nodomain.freeyourgadget.gadgetbridge.util.CheckSums;
import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.filetransfer.AbstractFileTransferImpl;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.filetransfer.FileTransferImplV2;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.filetransfer.FileTransferImplV3;
public class ZeppOsFileTransferService extends AbstractZeppOsService {
private static final Logger LOG = LoggerFactory.getLogger(ZeppOsFileTransferService.class);
private static final short ENDPOINT = 0x000d;
private static final byte CMD_CAPABILITIES_REQUEST = 0x01;
private static final byte CMD_CAPABILITIES_RESPONSE = 0x02;
private static final byte CMD_TRANSFER_REQUEST = 0x03;
private static final byte CMD_TRANSFER_RESPONSE = 0x04;
private static final byte CMD_DATA_SEND = 0x10;
private static final byte CMD_DATA_ACK = 0x11;
private static final byte CMD_DATA_V3_SEND = 0x12;
private static final byte CMD_DATA_V3_ACK = 0x13;
private static final byte FLAG_FIRST_CHUNK = 0x01;
private static final byte FLAG_LAST_CHUNK = 0x02;
private static final byte FLAG_CRC = 0x04;
private final Map<Byte, FileTransferRequest> mSessionRequests = new HashMap<>();
private int mVersion = -1;
private int mChunkSize = -1;
private int mCompressedChunkSize = -1;
private AbstractFileTransferImpl impl;
public ZeppOsFileTransferService(final ZeppOsSupport support) {
super(support, false);
@@ -71,538 +44,60 @@ public class ZeppOsFileTransferService extends AbstractZeppOsService {
return ENDPOINT;
}
/**
* HACK: Expose the method to the impl.
*/
@Override
public void write(final String taskName, final byte[] data) {
super.write(taskName, data);
}
@Override
public void handlePayload(final byte[] payload) {
byte session;
byte status;
switch (payload[0]) {
case CMD_CAPABILITIES_RESPONSE:
mVersion = payload[1] & 0xff;
if (mVersion != 1 && mVersion != 2 && mVersion != 3) {
LOG.error("Unsupported file transfer service version: {}", mVersion);
return;
}
mChunkSize = BLETypeConversions.toUint16(payload, 2);
if (mVersion == 3) {
// TODO parse the rest for v3
mCompressedChunkSize = BLETypeConversions.toUint32(payload, 4);
final TransactionBuilder builder = getSupport().createTransactionBuilder("enable file transfer v3 notifications");
builder.notify(getSupport().getCharacteristic(HuamiService.UUID_CHARACTERISTIC_ZEPP_OS_FILE_TRANSFER_V3), true);
builder.queue(getSupport().getQueue());
}
LOG.info(
"Got file transfer service: version={}, chunkSize={}, compressedChunkSize={}",
mVersion,
mChunkSize,
mCompressedChunkSize
);
return;
case CMD_TRANSFER_REQUEST:
handleFileTransferRequest(payload);
return;
case CMD_TRANSFER_RESPONSE:
session = payload[1];
status = payload[2];
final int existingProgress = BLETypeConversions.toUint32(payload, 3);
LOG.info("Band acknowledged file transfer request: session={}, status={}, existingProgress={}", session, status, existingProgress);
if (status != 0) {
LOG.error("Unexpected status from band for session {}, aborting", session);
onUploadFinish(session, false);
return;
}
if (existingProgress != 0) {
LOG.info("Updating existing progress for session {} to {}", session, existingProgress);
final FileTransferRequest request = mSessionRequests.get(session);
if (request == null) {
LOG.error("No request found for session {}", session);
return;
}
request.setProgress(existingProgress);
}
sendNextQueuedData(session);
return;
case CMD_DATA_SEND:
handleFileTransferData(payload);
return;
case CMD_DATA_ACK:
session = payload[1];
status = payload[2];
LOG.info("Band acknowledged file transfer data: session={}, status={}", session, status);
if (status != 0) {
LOG.error("Unexpected status from band, aborting session {}", session);
onUploadFinish(session, false);
return;
}
sendNextQueuedData(session);
return;
default:
LOG.warn("Unexpected file transfer byte {}", String.format("0x%02x", payload[0]));
if (impl != null) {
impl.handlePayload(payload);
return;
}
if (payload[0] != AbstractFileTransferImpl.CMD_CAPABILITIES_RESPONSE) {
LOG.warn("Got file transfer command, but impl is not initialized");
return;
}
final int version = payload[1] & 0xff;
if (version == 1 || version == 2) {
impl = new FileTransferImplV2(this, getSupport());
} else if (version == 3) {
impl = new FileTransferImplV3(this, getSupport());
} else {
LOG.error("Unsupported file transfer service version: {}", version);
return;
}
impl.handlePayload(payload);
}
@Override
public void initialize(final TransactionBuilder builder) {
requestCapability(builder);
}
public void requestCapability(final TransactionBuilder builder) {
write(builder, new byte[]{CMD_CAPABILITIES_REQUEST});
}
private void handleFileTransferRequest(final byte[] payload) {
// File transfer request initialized from watch
int pos = 1;
final byte session = payload[pos++];
final String url = StringUtils.untilNullTerminator(payload, pos);
if (url == null) {
LOG.error("Unable to parse url from transfer request");
return;
}
pos += url.length() + 1;
final String filename = StringUtils.untilNullTerminator(payload, pos);
if (filename == null) {
LOG.error("Unable to parse filename from transfer request");
return;
}
pos += filename.length() + 1;
final int length = BLETypeConversions.toUint32(payload, pos);
pos += 4;
final int crc32 = BLETypeConversions.toUint32(payload, pos);
pos += 4;
final boolean compressed;
if (pos < payload.length) {
final Boolean compressedBoolean = booleanFromByte(payload[pos]);
if (compressedBoolean == null) {
LOG.warn("Unknown compression type {}", payload[pos]);
return;
}
compressed = compressedBoolean;
} else {
compressed = false;
}
LOG.info("Got transfer request: session={}, url={}, filename={}, length={}, compressed={}", session, url, filename, length, compressed);
final FileTransferRequest request = new FileTransferRequest(
url,
filename,
new byte[length],
compressed,
compressed ? mCompressedChunkSize : mChunkSize,
getSupport()
);
request.setCrc32(crc32);
if (mVersion < 3) {
final ByteBuffer buf = ByteBuffer.allocate(7).order(ByteOrder.LITTLE_ENDIAN);
buf.order(ByteOrder.LITTLE_ENDIAN);
buf.put(CMD_TRANSFER_RESPONSE);
buf.put(session);
buf.put((byte) 0x00);
buf.putInt(0);
write("send file transfer response", buf.array());
} else {
// FIXME: Receive files on v3
LOG.error("Receiving files on V3 is not implemented");
return;
}
mSessionRequests.put(session, request);
}
private void handleFileTransferData(final byte[] payload) {
final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN);
buf.get(); // Discard first byte
final byte flags = buf.get();
final boolean firstPacket = (flags == 1);
final boolean lastPacket = (flags == 2);
final byte session = buf.get();
final byte index = buf.get();
if ((flags & 0x01) != 0) {
buf.getInt(); // ?
}
final short size = buf.getShort();
final FileTransferRequest request = mSessionRequests.get(session);
if (request == null) {
LOG.error("No request found for session {}", session);
return;
}
if (index != request.index) {
LOG.warn("Unexpected index {}, expected {}", index, request.index);
return;
}
if (firstPacket && request.getProgress() != 0) {
LOG.warn("Got first packet, but progress is {}", request.getProgress());
return;
}
buf.get(request.getBytes(), request.getProgress(), size);
request.setIndex((byte) (index + 1));
request.setProgress(request.getProgress() + size);
LOG.debug("Got data for session={}, progress={}/{}", session, request.getProgress(), request.getSize());
write("ack file data", new byte[]{CMD_DATA_ACK, session, 0x00});
if (lastPacket) {
mSessionRequests.remove(session);
final byte[] data;
if (request.isCompressed()) {
data = decompress(request.getBytes());
if (data == null) {
LOG.error("Failed to decompress bytes for session={}", session);
return;
}
} else {
data = request.getBytes();
}
final int checksum = CheckSums.getCRC32(data);
if (checksum != request.getCrc32()) {
LOG.warn("Checksum mismatch: expected {}, got {}", request.getCrc32(), checksum);
return;
}
request.getCallback().onFileDownloadFinish(request.getUrl(), request.getFilename(), data);
}
}
public static byte[] compress(final byte[] data) {
final Deflater deflater = new Deflater();
deflater.setInput(data);
deflater.finish();
final ByteArrayOutputStream baos = new ByteArrayOutputStream(data.length);
final byte[] buf = new byte[8096];
int read;
while ((read = deflater.deflate(buf)) > 0) {
baos.write(buf, 0, read);
}
return baos.toByteArray();
}
public static byte[] decompress(final byte[] data) {
final Inflater inflater = new Inflater();
final byte[] output = new byte[data.length];
inflater.setInput(data);
try {
inflater.inflate(output);
} catch (final DataFormatException e) {
LOG.error("Failed to decompress data", e);
return null;
} finally {
inflater.end();
}
return output;
write(builder, new byte[]{AbstractFileTransferImpl.CMD_CAPABILITIES_REQUEST});
}
public void sendFile(final String url, final String filename, final byte[] bytes, final boolean compress, final Callback callback) {
if (mChunkSize < 0) {
if (impl == null) {
LOG.error("Service not initialized, refusing to send {}", url);
callback.onFileUploadFinish(false);
return;
}
LOG.info("Sending {} bytes to {} in {}", bytes.length, filename, url);
final FileTransferRequest request = new FileTransferRequest(
url,
filename,
bytes,
compress && mCompressedChunkSize > 0,
compress && mCompressedChunkSize > 0 ? mCompressedChunkSize : mChunkSize,
callback
);
if (mVersion == 3 && !mSessionRequests.isEmpty()) {
// FIXME non-zero session on v3
LOG.error("File transfer v3 only supports single session, not sending file");
callback.onFileUploadFinish(false);
return;
}
byte session = (byte) mSessionRequests.size();
while (mSessionRequests.containsKey(session)) {
session++;
}
int payloadSize = 2 + url.length() + 1 + filename.length() + 1 + 4 + 4;
if (mVersion == 3) {
payloadSize += 2;
if (compress) {
payloadSize += 4;
}
}
final ByteBuffer buf = ByteBuffer.allocate(payloadSize);
buf.order(ByteOrder.LITTLE_ENDIAN);
buf.put(CMD_TRANSFER_REQUEST);
buf.put(session);
buf.put(url.getBytes(StandardCharsets.UTF_8));
buf.put((byte) 0x00);
buf.put(filename.getBytes(StandardCharsets.UTF_8));
buf.put((byte) 0x00);
buf.putInt(bytes.length);
buf.putInt(request.getCrc32());
if (mVersion == 3) {
buf.put((byte) (compress ? 1 : 0));
if (compress) {
buf.putInt(mCompressedChunkSize);
}
buf.put((byte) 0);
}
write("send file upload request", buf.array());
mSessionRequests.put(session, request);
impl.uploadFile(url, filename, bytes, compress, callback);
}
private void sendNextQueuedData(final byte session) {
final FileTransferRequest request = mSessionRequests.get(session);
if (request == null) {
LOG.error("No request found for session {}", session);
public void onCharacteristicChanged(final BluetoothGattCharacteristic characteristic) {
if (impl == null) {
LOG.error("Service not initialized, ignoring characteristic change for {}", characteristic.getUuid());
return;
}
if (request.getProgress() >= request.getSize()) {
LOG.info("Finished sending {}", request.getUrl());
onUploadFinish(session, true);
return;
}
LOG.debug("Sending file data for session={}, progress={}, index={}", session, request.getProgress(), request.getIndex());
if (mVersion < 3) {
writeChunkV1(request, session);
} else {
if (session != 0) {
// FIXME non-zero session on v3
LOG.error("Sending non-zero session on v3 is not supported, got session={}", session);
mSessionRequests.remove(session);
return;
}
writeChunkV3(request);
}
}
private void writeChunkV1(final FileTransferRequest request, final byte session) {
final ByteBuffer buf = ByteBuffer.allocate(10 + request.getChunkSize());
buf.order(ByteOrder.LITTLE_ENDIAN);
buf.put(CMD_DATA_SEND);
byte flags = 0;
if (request.getProgress() == 0) {
flags |= FLAG_FIRST_CHUNK;
}
if (request.getProgress() + request.getChunkSize() >= request.getSize()) {
flags |= FLAG_LAST_CHUNK;
}
buf.put(flags);
buf.put(session);
buf.put(request.getIndex());
if ((flags & FLAG_FIRST_CHUNK) > 0) {
buf.put((byte) 0x00); // ?
buf.put((byte) 0x00); // ?
buf.put((byte) 0x00); // ?
buf.put((byte) 0x00); // ?
}
final byte[] payload = ArrayUtils.subarray(
request.getBytes(),
request.getProgress(),
request.getProgress() + request.getChunkSize()
);
buf.putShort((short) payload.length);
buf.put(payload);
request.setProgress(request.getProgress() + payload.length);
request.setIndex((byte) (request.getIndex() + 1));
request.getCallback().onFileUploadProgress(request.getProgress());
write("send file data", buf.array());
}
private void writeChunkV3(final FileTransferRequest request) {
final byte[] chunk = ArrayUtils.subarray(
request.getBytes(),
request.getProgress(),
request.getProgress() + request.getChunkSize()
);
byte flags = 0;
if (request.getProgress() == 0) {
flags |= FLAG_FIRST_CHUNK;
}
if (request.getProgress() + request.getChunkSize() >= request.getSize()) {
flags |= FLAG_LAST_CHUNK;
}
final int partSize = getSupport().getMTU() - 3;
final ByteBuffer buf = ByteBuffer.allocate(chunk.length + 5);
buf.order(ByteOrder.LITTLE_ENDIAN);
buf.put(CMD_DATA_V3_SEND);
buf.put(flags);
buf.put(request.getIndex());
buf.putShort((short) chunk.length);
buf.put(chunk);
final byte[] payload = buf.array();
final TransactionBuilder builder = getSupport().createTransactionBuilder("send chunk v3");
for (int i = 0; i < payload.length; i += partSize) {
final byte[] part = ArrayUtils.subarray(payload, i, i + partSize);
builder.write(
getSupport().getCharacteristic(HuamiService.UUID_CHARACTERISTIC_ZEPP_OS_FILE_TRANSFER_V3),
part
);
}
builder.queue(getSupport().getQueue());
request.setProgress(request.getProgress() + chunk.length);
request.setIndex((byte) (request.getIndex() + 1));
request.getCallback().onFileUploadProgress(request.getProgress());
}
private void onUploadFinish(final byte session, final boolean success) {
final FileTransferRequest request = mSessionRequests.get(session);
if (request == null) {
LOG.error("No request found for session {}", session);
return;
}
mSessionRequests.remove(session);
request.getCallback().onFileUploadFinish(success);
}
public void onCharacteristicChanged(final byte[] value) {
if (value[0] != CMD_DATA_V3_ACK) {
LOG.error("Got non-ack on file transfer characteristic");
return;
}
final byte session = (byte) 0; // FIXME non-zero session on v3
final byte status = value[1];
final byte chunkIndex = value[2];
final byte unk1 = value[3]; // 1/2?
LOG.info(
"Band acknowledged file transfer data: session={}, status={}, chunkIndex={}, unk1={}",
session,
status,
chunkIndex,
unk1
);
final FileTransferRequest request = mSessionRequests.get(session);
if (request == null) {
LOG.error("No request found for v3 session {}", session);
return;
}
if (status != 0) {
LOG.error("Unexpected status from band, aborting session {}", session);
onUploadFinish(session, false);
return;
}
if (request.getIndex() - 1 != chunkIndex) {
LOG.error("Got ack for unexpected chunk index {}, expected {}", chunkIndex, request.getIndex() - 1);
onUploadFinish(session, false);
return;
}
sendNextQueuedData(session);
}
/**
* Wrapper class to keep track of ongoing file send requests and their progress.
*/
public static class FileTransferRequest {
private final String url;
private final String filename;
private final byte[] bytes;
private final boolean compressed;
private final int chunkSize;
private final Callback callback;
private int progress = 0;
private byte index = 0;
private int crc32;
public FileTransferRequest(final String url, final String filename, final byte[] bytes, boolean compressed, int chunkSize, final Callback callback) {
this.url = url;
this.filename = filename;
this.bytes = compressed ? compress(bytes) : bytes;
this.compressed = compressed;
this.chunkSize = chunkSize;
this.callback = callback;
this.crc32 = CheckSums.getCRC32(bytes);
}
public String getUrl() {
return url;
}
public String getFilename() {
return filename;
}
public byte[] getBytes() {
return bytes;
}
public int getSize() {
return bytes.length;
}
public boolean isCompressed() {
return compressed;
}
public int getChunkSize() {
return chunkSize;
}
public Callback getCallback() {
return callback;
}
public int getProgress() {
return progress;
}
public void setProgress(final int progress) {
this.progress = progress;
}
public byte getIndex() {
return index;
}
public void setIndex(final byte index) {
this.index = index;
}
public int getCrc32() {
return crc32;
}
public void setCrc32(final int crc32) {
this.crc32 = crc32;
}
impl.onCharacteristicChanged(characteristic);
}
public interface Callback {
@@ -0,0 +1,226 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.filetransfer;
import android.bluetooth.BluetoothGattCharacteristic;
import androidx.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.DataFormatException;
import java.util.zip.Deflater;
import java.util.zip.Inflater;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsFileTransferService;
import nodomain.freeyourgadget.gadgetbridge.util.CheckSums;
import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
public abstract class AbstractFileTransferImpl {
private static final Logger LOG = LoggerFactory.getLogger(AbstractFileTransferImpl.class);
public static final byte CMD_CAPABILITIES_REQUEST = 0x01;
public static final byte CMD_CAPABILITIES_RESPONSE = 0x02;
protected static final byte CMD_TRANSFER_REQUEST = 0x03;
protected static final byte CMD_TRANSFER_RESPONSE = 0x04;
protected static final byte CMD_DATA_SEND = 0x10;
protected static final byte CMD_DATA_ACK = 0x11;
protected static final byte CMD_DATA_V3_SEND = 0x12;
protected static final byte CMD_DATA_V3_ACK = 0x13;
protected static final byte FLAG_FIRST_CHUNK = 0x01;
protected static final byte FLAG_LAST_CHUNK = 0x02;
protected static final byte FLAG_CRC = 0x04;
protected final ZeppOsFileTransferService mFileTransferService;
protected final ZeppOsSupport mSupport;
protected int mVersion = -1;
protected int mChunkSize = -1;
protected int mCompressedChunkSize = -1;
protected final List<String> supportedServices = new ArrayList<>();
public AbstractFileTransferImpl(final ZeppOsFileTransferService fileTransferService,
final ZeppOsSupport support) {
this.mFileTransferService = fileTransferService;
this.mSupport = support;
}
public void handlePayload(final byte[] payload) {
switch (payload[0]) {
case CMD_CAPABILITIES_RESPONSE:
handleCapabilitiesResponse(payload);
return;
case CMD_TRANSFER_REQUEST:
handleFileTransferRequest(payload);
return;
default:
LOG.warn("Unexpected file transfer payload byte {}", String.format("0x%02x", payload[0]));
}
}
public abstract void uploadFile(final FileTransferRequest request);
public abstract void handleFileDownloadRequest(final byte session, final FileTransferRequest request);
public abstract void onCharacteristicChanged(final BluetoothGattCharacteristic characteristic);
public void uploadFile(final String url,
final String filename,
final byte[] bytes,
final boolean compress,
final ZeppOsFileTransferService.Callback callback) {
LOG.info("Sending {} bytes to {} in {}", bytes.length, filename, url);
final FileTransferRequest request = new FileTransferRequest(
url,
filename,
bytes.length,
compress && mCompressedChunkSize > 0 ? compress(bytes) : bytes,
compress && mCompressedChunkSize > 0,
CheckSums.getCRC32(bytes),
compress && mCompressedChunkSize > 0 ? mCompressedChunkSize : mChunkSize,
callback
);
uploadFile(request);
}
private void handleCapabilitiesResponse(final byte[] payload) {
supportedServices.clear();
final ByteBuffer buf = ByteBuffer.wrap(payload)
.order(ByteOrder.LITTLE_ENDIAN);
buf.get(); // discard command byte
mVersion = buf.get() & 0xff;
mChunkSize = buf.getShort();
if (mVersion >= 3) {
mCompressedChunkSize = buf.getInt();
final int numServices = buf.getShort();
for (int i = 0; i < numServices; i++) {
supportedServices.add(StringUtils.untilNullTerminator(buf));
}
// TODO: 3 unknown bytes for v3
final TransactionBuilder builder = mSupport.createTransactionBuilder("enable file transfer v3 notifications");
builder.notify(mSupport.getCharacteristic(HuamiService.UUID_CHARACTERISTIC_ZEPP_OS_FILE_TRANSFER_V3_SEND), true);
builder.notify(mSupport.getCharacteristic(HuamiService.UUID_CHARACTERISTIC_ZEPP_OS_FILE_TRANSFER_V3_RECEIVE), true);
builder.queue(mSupport.getQueue());
}
LOG.info(
"Got file transfer service: version={}, chunkSize={}, compressedChunkSize={}, supportedServices=[{}]",
mVersion,
mChunkSize,
mCompressedChunkSize,
String.join(",", supportedServices)
);
}
private void handleFileTransferRequest(final byte[] payload) {
// File transfer request initialized from watch
final ByteBuffer buf = ByteBuffer.wrap(payload)
.order(ByteOrder.LITTLE_ENDIAN);
buf.get(); // discard command byte
final byte session = buf.get();
final String url = StringUtils.untilNullTerminator(buf);
if (url == null) {
LOG.error("Unable to parse url from transfer request");
return;
}
final String filename = StringUtils.untilNullTerminator(buf);
if (filename == null) {
LOG.error("Unable to parse filename from transfer request");
return;
}
final int length = buf.getInt();
final int crc32 = buf.getInt();
final boolean compressed;
if (buf.hasRemaining()) {
final byte compressedByte = buf.get();
final Boolean compressedBoolean = booleanFromByte(compressedByte);
if (compressedBoolean == null) {
LOG.warn("Unknown compression type {}", String.format("0x%02x", compressedByte));
return;
}
compressed = compressedBoolean;
} else {
compressed = false;
}
LOG.info(
"Got transfer request: session={}, url={}, filename={}, length={}, compressed={}",
session,
url,
filename,
length,
compressed
);
final FileTransferRequest request = new FileTransferRequest(
url,
filename,
length,
new byte[length],
compressed,
crc32,
compressed ? mCompressedChunkSize : mChunkSize,
mSupport
);
handleFileDownloadRequest(session, request);
}
public static byte[] compress(final byte[] data) {
final Deflater deflater = new Deflater();
deflater.setInput(data);
deflater.finish();
final ByteArrayOutputStream baos = new ByteArrayOutputStream(data.length);
final byte[] buf = new byte[8096];
int read;
while ((read = deflater.deflate(buf)) > 0) {
baos.write(buf, 0, read);
}
return baos.toByteArray();
}
public static byte[] decompress(final byte[] data) {
final Inflater inflater = new Inflater();
final byte[] output = new byte[data.length];
inflater.setInput(data);
try {
inflater.inflate(output);
} catch (final DataFormatException e) {
LOG.error("Failed to decompress data", e);
return null;
} finally {
inflater.end();
}
return output;
}
@Nullable
protected static Boolean booleanFromByte(final byte b) {
switch (b) {
case 0x00:
return false;
case 0x01:
return true;
default:
}
return null;
}
}
@@ -0,0 +1,273 @@
/* Copyright (C) 2025 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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.filetransfer;
import android.bluetooth.BluetoothGattCharacteristic;
import org.apache.commons.lang3.ArrayUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsFileTransferService;
import nodomain.freeyourgadget.gadgetbridge.util.CheckSums;
public class FileTransferImplV2 extends AbstractFileTransferImpl {
private static final Logger LOG = LoggerFactory.getLogger(FileTransferImplV2.class);
private final Map<Byte, FileTransferRequest> mSessionRequests = new HashMap<>();
public FileTransferImplV2(final ZeppOsFileTransferService fileTransferService,
final ZeppOsSupport support) {
super(fileTransferService, support);
}
@Override
public void handlePayload(final byte[] payload) {
byte session;
byte status;
switch (payload[0]) {
case CMD_TRANSFER_RESPONSE:
session = payload[1];
status = payload[2];
final int existingProgress = BLETypeConversions.toUint32(payload, 3);
LOG.info("Band acknowledged file transfer request: session={}, status={}, existingProgress={}", session, status, existingProgress);
if (status != 0) {
LOG.error("Unexpected status from band for session {}, aborting", session);
onUploadFinish(session, false);
return;
}
if (existingProgress != 0) {
LOG.info("Updating existing progress for session {} to {}", session, existingProgress);
final FileTransferRequest request = mSessionRequests.get(session);
if (request == null) {
LOG.error("No request found for session {}", session);
return;
}
request.setProgress(existingProgress);
}
sendNextChunk(session);
return;
case CMD_DATA_SEND:
handleFileTransferData(payload);
return;
case CMD_DATA_ACK:
session = payload[1];
status = payload[2];
LOG.info("Band acknowledged file transfer data: session={}, status={}", session, status);
if (status != 0) {
LOG.error("Unexpected status from band, aborting session {}", session);
onUploadFinish(session, false);
return;
}
sendNextChunk(session);
return;
default:
super.handlePayload(payload);
}
}
@Override
public void uploadFile(final FileTransferRequest request) {
if (request.isCompressed()) {
throw new IllegalArgumentException("V1/V2 does not support compressed transfers");
}
byte session = (byte) mSessionRequests.size();
while (mSessionRequests.containsKey(session)) {
session++;
}
final int payloadSize = 2 + request.getUrl().length() + 1 + request.getFilename().length() + 1 + 4 + 4;
final ByteBuffer buf = ByteBuffer.allocate(payloadSize);
buf.order(ByteOrder.LITTLE_ENDIAN);
buf.put(CMD_TRANSFER_REQUEST);
buf.put(session);
buf.put(request.getUrl().getBytes(StandardCharsets.UTF_8));
buf.put((byte) 0x00);
buf.put(request.getFilename().getBytes(StandardCharsets.UTF_8));
buf.put((byte) 0x00);
buf.putInt(request.getRawLength());
buf.putInt(request.getCrc32());
mFileTransferService.write("send file upload request", buf.array());
mSessionRequests.put(session, request);
}
@Override
public void handleFileDownloadRequest(final byte session, final FileTransferRequest request) {
final ByteBuffer buf = ByteBuffer.allocate(7).order(ByteOrder.LITTLE_ENDIAN);
buf.order(ByteOrder.LITTLE_ENDIAN);
buf.put(CMD_TRANSFER_RESPONSE);
buf.put(session);
buf.put((byte) 0x00);
buf.putInt(0);
mFileTransferService.write("send file transfer response", buf.array());
mSessionRequests.put(session, request);
}
@Override
public void onCharacteristicChanged(final BluetoothGattCharacteristic characteristic) {
LOG.error("Unknown characteristic changed: {}", characteristic.getUuid());
}
private void handleFileTransferData(final byte[] payload) {
final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN);
buf.get(); // Discard first byte
final byte flags = buf.get();
final boolean firstChunk = (flags & FLAG_FIRST_CHUNK) != 0;
final boolean lastChunk = (flags & FLAG_LAST_CHUNK) != 0;
final byte session = buf.get();
final byte index = buf.get();
if (firstChunk) {
buf.getInt(); // ?
}
final short size = buf.getShort();
final FileTransferRequest request = mSessionRequests.get(session);
if (request == null) {
LOG.error("No download request found for V1 session {}", session);
return;
}
if (index != request.getIndex()) {
LOG.warn("Unexpected index {}, expected {}", index, request.getIndex());
return;
}
if (firstChunk && request.getProgress() != 0) {
LOG.warn("Got first packet, but progress is {}", request.getProgress());
return;
}
buf.get(request.getBytes(), request.getProgress(), size);
request.setIndex((byte) (index + 1));
request.setProgress(request.getProgress() + size);
LOG.debug("Got data for session={}, progress={}/{}", session, request.getProgress(), request.getSize());
mFileTransferService.write("ack file data", new byte[]{CMD_DATA_ACK, session, 0x00});
if (lastChunk) {
mSessionRequests.remove(session);
final byte[] data;
if (request.isCompressed()) {
data = decompress(request.getBytes());
if (data == null) {
LOG.error("Failed to decompress bytes for session={}", session);
return;
}
} else {
data = request.getBytes();
}
final int checksum = CheckSums.getCRC32(data);
if (checksum != request.getCrc32()) {
LOG.warn("Checksum mismatch: expected {}, got {}", request.getCrc32(), checksum);
return;
}
request.getCallback().onFileDownloadFinish(request.getUrl(), request.getFilename(), data);
}
}
private void onUploadFinish(final byte session, final boolean success) {
final FileTransferRequest request = mSessionRequests.get(session);
if (request == null) {
LOG.error("No request found for session {} to finish upload", session);
return;
}
mSessionRequests.remove(session);
request.getCallback().onFileUploadFinish(success);
}
private void sendNextChunk(final byte session) {
final FileTransferRequest request = mSessionRequests.get(session);
if (request == null) {
LOG.error("No request found for session {} to send next chunk", session);
return;
}
if (request.getProgress() >= request.getSize()) {
LOG.info("Finished sending {}", request.getUrl());
onUploadFinish(session, true);
return;
}
LOG.debug("Sending file data for session={}, progress={}, index={}", session, request.getProgress(), request.getIndex());
writeChunk(session, request);
}
private void writeChunk(final byte session, final FileTransferRequest request) {
final ByteBuffer buf = ByteBuffer.allocate(10 + request.getChunkSize());
buf.order(ByteOrder.LITTLE_ENDIAN);
buf.put(CMD_DATA_SEND);
byte flags = 0;
if (request.getProgress() == 0) {
flags |= FLAG_FIRST_CHUNK;
}
if (request.getProgress() + request.getChunkSize() >= request.getSize()) {
flags |= FLAG_LAST_CHUNK;
}
buf.put(flags);
buf.put(session);
buf.put(request.getIndex());
if ((flags & FLAG_FIRST_CHUNK) > 0) {
buf.put((byte) 0x00); // ?
buf.put((byte) 0x00); // ?
buf.put((byte) 0x00); // ?
buf.put((byte) 0x00); // ?
}
final byte[] payload = ArrayUtils.subarray(
request.getBytes(),
request.getProgress(),
request.getProgress() + request.getChunkSize()
);
buf.putShort((short) payload.length);
buf.put(payload);
request.setProgress(request.getProgress() + payload.length);
request.setIndex((byte) (request.getIndex() + 1));
request.getCallback().onFileUploadProgress(request.getProgress());
mFileTransferService.write("send file data", buf.array());
}
}
@@ -0,0 +1,440 @@
/* Copyright (C) 2025 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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.filetransfer;
import android.bluetooth.BluetoothGattCharacteristic;
import org.apache.commons.lang3.ArrayUtils;
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.nio.charset.StandardCharsets;
import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsFileTransferService;
import nodomain.freeyourgadget.gadgetbridge.util.CheckSums;
public class FileTransferImplV3 extends AbstractFileTransferImpl {
private static final Logger LOG = LoggerFactory.getLogger(FileTransferImplV3.class);
private static final byte CMD_DATA_V3_SEND = 0x12;
private static final byte CMD_DATA_V3_ACK = 0x13;
private static final long TRANSFER_TIMEOUT_THRESHOLD_MILLIS = 5_000L;
private byte nextSession = 0;
private FileTransferRequest currentSendRequest;
private byte currentSendSession = -1;
private long lastSendActivityMillis = -1;
private FileTransferRequest currentReceiveRequest;
private byte currentReceiveSession = -1;
private long lastReceiveActivityMillis = -1;
private int currentReceiveChunkSize = -1;
private boolean currentReceiveChunkIsLast = false;
private final ByteArrayOutputStream receivePacketBuffer = new ByteArrayOutputStream();
public FileTransferImplV3(final ZeppOsFileTransferService fileTransferService,
final ZeppOsSupport support) {
super(fileTransferService, support);
}
@Override
public void handlePayload(final byte[] payload) {
switch (payload[0]) {
case CMD_TRANSFER_RESPONSE:
final byte session = payload[1];
final byte status = payload[2];
final int existingProgress = BLETypeConversions.toUint32(payload, 3);
LOG.info("Band acknowledged file transfer request: session={}, status={}, existingProgress={}", session, status, existingProgress);
if (currentSendRequest == null) {
LOG.error("No ongoing send request found");
return;
}
if (status != 0) {
LOG.error("Unexpected status from band for session {}, aborting", session);
onUploadFinish(false);
resetSend();
return;
}
if (currentSendSession != session) {
LOG.error("Unexpected send session from band {}, expected {}, aborting", session, currentSendSession);
onUploadFinish(false);
resetSend();
return;
}
if (existingProgress != 0) {
LOG.info("Updating existing progress for session {} to {}", session, existingProgress);
currentSendRequest.setProgress(existingProgress);
}
sendNextQueuedData();
return;
default:
super.handlePayload(payload);
}
}
@Override
public void uploadFile(final FileTransferRequest request) {
if (currentSendRequest != null) {
if (System.currentTimeMillis() - lastSendActivityMillis < TRANSFER_TIMEOUT_THRESHOLD_MILLIS) {
LOG.warn("Already uploading {}", currentSendRequest.getFilename());
request.getCallback().onFileUploadFinish(false);
return;
}
LOG.warn("Timing out existing upload request for {}", currentSendRequest.getFilename());
currentSendRequest.getCallback().onFileUploadFinish(false);
resetSend();
}
final byte session = nextSession++;
int payloadSize = 2 +
request.getUrl().getBytes(StandardCharsets.UTF_8).length + 1 +
request.getFilename().getBytes(StandardCharsets.UTF_8).length + 1 +
4 + 4 + 2;
if (request.isCompressed()) {
payloadSize += 4;
}
final ByteBuffer buf = ByteBuffer.allocate(payloadSize);
buf.order(ByteOrder.LITTLE_ENDIAN);
buf.put(CMD_TRANSFER_REQUEST);
buf.put(session);
buf.put(request.getUrl().getBytes(StandardCharsets.UTF_8));
buf.put((byte) 0x00);
buf.put(request.getFilename().getBytes(StandardCharsets.UTF_8));
buf.put((byte) 0x00);
buf.putInt(request.getRawLength());
buf.putInt(request.getCrc32());
buf.put((byte) (request.isCompressed() ? 1 : 0));
if (request.isCompressed()) {
buf.putInt(mCompressedChunkSize);
}
buf.put((byte) 0);
mFileTransferService.write("send v3 file upload request", buf.array());
currentSendSession = session;
currentSendRequest = request;
lastSendActivityMillis = System.currentTimeMillis();
}
@Override
public void handleFileDownloadRequest(final byte session, final FileTransferRequest request) {
if (currentReceiveRequest != null) {
if (System.currentTimeMillis() - lastReceiveActivityMillis < TRANSFER_TIMEOUT_THRESHOLD_MILLIS) {
LOG.warn("Already downloading {}", currentReceiveRequest.getFilename());
// TODO how to send nack?
return;
}
LOG.warn("Timing out existing download request for {}", currentReceiveRequest.getFilename());
currentReceiveRequest.getCallback().onFileUploadFinish(false);
resetReceive();
}
final ByteBuffer buf = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN);
buf.order(ByteOrder.LITTLE_ENDIAN);
buf.put(CMD_TRANSFER_RESPONSE);
buf.put(session);
buf.put((byte) 0x00);
buf.putInt(0);
buf.put((byte) 0x01);
mFileTransferService.write("send file transfer response", buf.array());
currentReceiveChunkIsLast = false;
currentReceiveChunkSize = -1;
receivePacketBuffer.reset();
currentReceiveSession = session;
currentReceiveRequest = request;
lastReceiveActivityMillis = System.currentTimeMillis();
}
@Override
public void onCharacteristicChanged(final BluetoothGattCharacteristic characteristic) {
final UUID characteristicUUID = characteristic.getUuid();
final byte[] value = characteristic.getValue();
if (HuamiService.UUID_CHARACTERISTIC_ZEPP_OS_FILE_TRANSFER_V3_RECEIVE.equals(characteristicUUID)) {
handleFileReceiveData(value);
} else if (HuamiService.UUID_CHARACTERISTIC_ZEPP_OS_FILE_TRANSFER_V3_SEND.equals(characteristicUUID)) {
if (value[0] != CMD_DATA_V3_ACK) {
LOG.error("Got non-ack on file send characteristic");
return;
}
final byte status = value[1];
final byte chunkIndex = value[2];
final byte unk1 = value[3]; // 1/2?
LOG.info(
"Band acknowledged file transfer data: session={}, status={}, chunkIndex={}, unk1={}",
currentSendSession,
status,
chunkIndex,
unk1
);
if (currentSendRequest == null) {
LOG.error("Got ack for file send, but we are not uploading");
return;
}
if (status != 0) {
LOG.error("Unexpected status from band, aborting session {}", currentSendSession);
onUploadFinish(false);
return;
}
if (currentSendRequest.getIndex() - 1 != chunkIndex) {
LOG.error("Got ack for unexpected chunk index {}, expected {}", chunkIndex, currentSendRequest.getIndex() - 1);
onUploadFinish(false);
return;
}
sendNextQueuedData();
} else {
LOG.warn("Unknown characteristic changed: {}", characteristicUUID);
}
}
private void writeChunk(final FileTransferRequest request) {
final byte[] chunk = ArrayUtils.subarray(
request.getBytes(),
request.getProgress(),
request.getProgress() + request.getChunkSize()
);
byte flags = 0;
if (request.getProgress() == 0) {
flags |= FLAG_FIRST_CHUNK;
}
if (request.getProgress() + request.getChunkSize() >= request.getSize()) {
flags |= FLAG_LAST_CHUNK;
}
final int partSize = mSupport.getMTU() - 3;
final ByteBuffer buf = ByteBuffer.allocate(chunk.length + 5);
buf.order(ByteOrder.LITTLE_ENDIAN);
buf.put(CMD_DATA_V3_SEND);
buf.put(flags);
buf.put(request.getIndex());
buf.putShort((short) chunk.length);
buf.put(chunk);
final byte[] payload = buf.array();
final TransactionBuilder builder = mSupport.createTransactionBuilder("send chunk v3");
for (int i = 0; i < payload.length; i += partSize) {
final byte[] part = ArrayUtils.subarray(payload, i, i + partSize);
builder.write(
mSupport.getCharacteristic(HuamiService.UUID_CHARACTERISTIC_ZEPP_OS_FILE_TRANSFER_V3_SEND),
part
);
}
builder.queue(mSupport.getQueue());
request.setProgress(request.getProgress() + chunk.length);
request.setIndex((byte) (request.getIndex() + 1));
request.getCallback().onFileUploadProgress(request.getProgress());
}
private void handleFileReceiveData(final byte[] payload) {
if (currentReceiveRequest == null) {
LOG.error("No receive request found for V3 session {}", currentReceiveSession);
return;
}
lastReceiveActivityMillis = System.currentTimeMillis();
if (currentReceiveChunkSize > 0) {
// We are currently receiving a chunk
try {
receivePacketBuffer.write(payload);
} catch (final IOException e) {
LOG.error("Failed to write packet to chunk buffer", e);
resetReceive();
return;
}
LOG.debug(
"Received {} ({}/{}) bytes for chunk at index {}",
payload.length,
receivePacketBuffer.size(),
currentReceiveChunkSize,
currentReceiveRequest.getIndex()
);
} else {
// Start of a chunk
receivePacketBuffer.reset();
LOG.debug("Got start of chunk - {} bytes", payload.length);
final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN);
buf.get(); // Discard first byte
final byte flags = buf.get();
final boolean firstChunk = (flags == FLAG_FIRST_CHUNK);
currentReceiveChunkIsLast = (flags == FLAG_LAST_CHUNK);
final byte index = buf.get();
currentReceiveChunkSize = buf.getShort();
if (index != currentReceiveRequest.getIndex()) {
LOG.warn("Unexpected V3 index {}, expected {}", index, currentReceiveRequest.getIndex());
return;
}
if (firstChunk && currentReceiveRequest.getProgress() != 0) {
LOG.warn("Got first V3 packet, but progress is {}", currentReceiveRequest.getProgress());
return;
}
LOG.debug("Starting a new chunk of {} bytes at index {}", currentReceiveChunkSize, currentReceiveRequest.getIndex());
receivePacketBuffer.write(payload, buf.position(), buf.limit() - buf.position());
}
if (receivePacketBuffer.size() >= currentReceiveChunkSize) {
// Finished a chunk
System.arraycopy(
receivePacketBuffer.toByteArray(),
0,
currentReceiveRequest.getBytes(),
currentReceiveRequest.getProgress(),
currentReceiveChunkSize
);
currentReceiveRequest.setIndex((byte) (currentReceiveRequest.getIndex() + 1));
currentReceiveRequest.setProgress(currentReceiveRequest.getProgress() + currentReceiveChunkSize);
LOG.debug("Got V3 data for session={}, progress={}/{}", currentReceiveSession, currentReceiveRequest.getProgress(), currentReceiveRequest.getSize());
final TransactionBuilder builder = mSupport.createTransactionBuilder("send ack v3 file data");
builder.write(
mSupport.getCharacteristic(HuamiService.UUID_CHARACTERISTIC_ZEPP_OS_FILE_TRANSFER_V3_RECEIVE),
new byte[]{
CMD_DATA_V3_ACK,
0x00,
(byte) (currentReceiveRequest.getIndex() - 1),
(byte) 0x01,
(byte) 0x00,
(byte) 0x00,
(byte) 0x00
}
);
builder.queue(mSupport.getQueue());
if (currentReceiveChunkIsLast) {
final byte[] data;
if (currentReceiveRequest.isCompressed()) {
data = decompress(currentReceiveRequest.getBytes());
if (data == null) {
LOG.error("Failed to decompress V3 bytes for {}", currentReceiveRequest.getFilename());
resetReceive();
return;
}
} else {
data = currentReceiveRequest.getBytes();
}
final int checksum = CheckSums.getCRC32(data);
if (checksum != currentReceiveRequest.getCrc32()) {
LOG.warn("V3 Checksum mismatch: expected {}, got {}", currentReceiveRequest.getCrc32(), checksum);
resetReceive();
return;
}
currentReceiveRequest.getCallback().onFileDownloadFinish(
currentReceiveRequest.getUrl(),
currentReceiveRequest.getFilename(),
data
);
resetReceive();
}
currentReceiveChunkSize = -1;
receivePacketBuffer.reset();
}
}
private void sendNextQueuedData() {
if (currentSendRequest == null) {
LOG.error("No ongoing V3 send request found");
return;
}
if (currentSendRequest.getProgress() >= currentSendRequest.getSize()) {
LOG.info("Finished sending {}", currentSendRequest.getUrl());
onUploadFinish(true);
return;
}
LOG.debug(
"Sending file data for session={}, progress={}, index={}",
currentSendSession,
currentSendRequest.getProgress(),
currentSendRequest.getIndex()
);
lastSendActivityMillis = System.currentTimeMillis();
writeChunk(currentSendRequest);
}
private void onUploadFinish(final boolean success) {
if (currentSendRequest == null) {
LOG.error("No request found for session {} to finish upload", currentSendSession);
return;
}
currentSendRequest.getCallback().onFileUploadFinish(success);
resetSend();
}
private void resetSend() {
currentSendSession = -1;
currentSendRequest = null;
lastSendActivityMillis = -1;
}
private void resetReceive() {
currentReceiveSession = -1;
currentReceiveRequest = null;
lastReceiveActivityMillis = -1;
currentReceiveChunkIsLast = false;
currentReceiveChunkSize = -1;
receivePacketBuffer.reset();
}
}
@@ -0,0 +1,106 @@
/* Copyright (C) 2025 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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.filetransfer;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsFileTransferService;
/**
* Wrapper class to keep track of ongoing file send requests and their progress.
*/
public class FileTransferRequest {
private final String url;
private final String filename;
private final int rawLength;
private final byte[] bytes;
private final boolean compressed;
private final int crc32;
private final int chunkSize;
private final ZeppOsFileTransferService.Callback callback;
private int progress = 0;
private byte index = 0;
public FileTransferRequest(final String url,
final String filename,
final int rawLength,
final byte[] bytes,
final boolean compressed,
final int crc32,
final int chunkSize,
final ZeppOsFileTransferService.Callback callback) {
this.url = url;
this.filename = filename;
this.rawLength = rawLength;
this.bytes = bytes;
this.compressed = compressed;
this.crc32 = crc32;
this.chunkSize = chunkSize;
this.callback = callback;
}
public String getUrl() {
return url;
}
public String getFilename() {
return filename;
}
public int getRawLength() {
return rawLength;
}
public byte[] getBytes() {
return bytes;
}
public int getSize() {
return bytes.length;
}
public boolean isCompressed() {
return compressed;
}
public int getCrc32() {
return crc32;
}
public int getChunkSize() {
return chunkSize;
}
public ZeppOsFileTransferService.Callback getCallback() {
return callback;
}
public int getProgress() {
return progress;
}
public void setProgress(final int progress) {
this.progress = progress;
}
public byte getIndex() {
return index;
}
public void setIndex(final byte index) {
this.index = index;
}
}