Garmin: New sync protocol (experimental)

This commit is contained in:
José Rebelo
2025-08-11 22:06:05 +02:00
committed by José Rebelo
parent 111b705963
commit 7f20bf698b
19 changed files with 830 additions and 91 deletions
@@ -223,6 +223,7 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator {
developer.add(R.xml.devicesettings_import_activity_files);
developer.add(R.xml.devicesettings_keep_activity_data_on_device);
developer.add(R.xml.devicesettings_fetch_unknown_files);
developer.add(R.xml.devicesettings_new_sync_protocol);
return deviceSpecificSettings;
}
@@ -0,0 +1,75 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiEcgService
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.FileDownloadedDeviceEvent
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils
import org.slf4j.LoggerFactory
import java.io.ByteArrayInputStream
import java.io.File
class EcgServiceHandler(val deviceSupport: GarminSupport) {
fun handle(ecgService: GdiEcgService.EcgService): GdiEcgService.EcgService? {
if (true) {
LOG.error("EcgServiceHandler is disabled")
return null
}
if (!ecgService.hasFileTransfer()) {
LOG.warn("No file transfer in ecg service")
return null
}
val fileTransfer = ecgService.fileTransfer
return when {
fileTransfer.hasTransferStartReq() -> handleTransferStartReq(fileTransfer.transferStartReq)
fileTransfer.hasEcgFile() -> handleEcgFile(fileTransfer.ecgFile)
else -> {
LOG.warn("Unhandled ecg service: {}", ecgService)
return null
}
}
}
private fun handleTransferStartReq(transferStartReq: GdiEcgService.EcgTransferStartReq): GdiEcgService.EcgService? {
LOG.debug("Got transfer start request")
val compressedTransferStartAck: GdiEcgService.EcgTransferStartAck? =
GdiEcgService.EcgTransferStartAck.newBuilder()
.setUnk2(1)
.build()
return GdiEcgService.EcgService.newBuilder().setFileTransfer(
GdiEcgService.EcgFileTransfer.newBuilder()
.setTransferStartAck(compressedTransferStartAck)
).build()
}
private fun handleEcgFile(ecgFile: GdiEcgService.EcgFile): GdiEcgService.EcgService? {
if (!ecgFile.name.lowercase().endsWith(".zip")) {
LOG.warn("File is not zip: {}", ecgFile.name)
return null
}
LOG.debug("Handling ecg zip file: {}", ecgFile.name)
val outputEcgFile: File
try {
val deviceDir: File? = deviceSupport.writableExportDirectory
val compressedDir = File(deviceDir, "COMPRESSED")
compressedDir.mkdirs()
outputEcgFile = File(compressedDir, ecgFile.name)
FileUtils.copyStreamToFile(ByteArrayInputStream(ecgFile.data.toByteArray()), outputEcgFile)
} catch (e: Exception) {
LOG.error("Failed to handle ecg zip file", e)
return null // do not signal file as saved
}
val fileDownloadedDeviceEvent = FileDownloadedDeviceEvent()
fileDownloadedDeviceEvent.localPath = outputEcgFile.absolutePath
deviceSupport.evaluateGBDeviceEvent(fileDownloadedDeviceEvent)
// TODO mark as synced
return null
}
companion object {
private val LOG = LoggerFactory.getLogger(EcgServiceHandler::class.java)
}
}
@@ -0,0 +1,119 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiFileSyncService
import nodomain.freeyourgadget.gadgetbridge.util.protobuf.buildWith
import org.slf4j.LoggerFactory
class FileSyncServiceHandler(val deviceSupport: GarminSupport) {
private var nextPageId: Int? = null
fun handle(fileSyncService: GdiFileSyncService.FileSyncService): GdiFileSyncService.FileSyncService? {
return when {
fileSyncService.hasNewFileNotification() -> handleNewFileNotification(fileSyncService.newFileNotification)
fileSyncService.hasFileListResponse() -> handleFileListResponse(fileSyncService.fileListResponse)
fileSyncService.hasFileResponse() -> handleFileResponse(fileSyncService.fileResponse)
else -> {
LOG.warn("Unhandled file sync service: {}", fileSyncService)
return null
}
}
}
private fun handleNewFileNotification(newFileNotification: GdiFileSyncService.NewFileNotification): GdiFileSyncService.FileSyncService? {
LOG.debug("Got new file notification: {}, ignoring", newFileNotification)
//deviceSupport.addFileToDownloadList(newFileNotification.file)
return null
}
private fun handleFileResponse(fileResponse: GdiFileSyncService.FileResponse): GdiFileSyncService.FileSyncService? {
LOG.debug("Got file response: {}", fileResponse)
deviceSupport.downloadFileFromServiceV2(fileResponse.handle)
return null
}
private fun handleFileListResponse(fileListResponse: GdiFileSyncService.FileListResponse): GdiFileSyncService.FileSyncService? {
LOG.debug(
"Handling file list response with {} files, nextPageId={}",
fileListResponse.fileList.size,
fileListResponse.nextPageId
)
nextPageId = fileListResponse.nextPageId
// Only the first entry for a type seems to contain the type name, so keep track of them
val codeMap: MutableMap<Int?, String?> = HashMap()
for (file in fileListResponse.fileList) {
if (!file.hasType() || !file.type.hasCode()) {
LOG.warn("Ignoring file with unknown type: {}", file)
continue
}
if (file.type.hasName()) {
codeMap.put(file.type.code, file.type.name)
}
val typeName = codeMap[file.type.code]
if (typeName == null) {
LOG.warn("No type name found for {}", file)
continue
}
if (!FILE_TYPES_TO_PROCESS.contains(typeName)) {
LOG.warn("Ignoring file type: {}", typeName)
continue
}
LOG.debug("Adding to download: {}/{} ({})", file.id.id1, file.id.id2, typeName)
deviceSupport.addFileToDownloadList(file)
}
return null
}
fun requestFileList(): GdiFileSyncService.FileSyncService {
LOG.debug("Requesting file list starting at page {}", nextPageId)
return GdiFileSyncService.FileSyncService.newBuilder().buildWith {
fileListRequest = GdiFileSyncService.FileListRequest.newBuilder().buildWith {
startPageId = nextPageId ?: 0
flags1 = GdiFileSyncService.FileId.newBuilder().setId1(42405).setId2(42405).build()
flags2 = GdiFileSyncService.FileId.newBuilder().setId1(42405).setId2(42405).build()
}
}
}
fun requestFile(fileToRequest: GdiFileSyncService.File): GdiFileSyncService.FileSyncService {
LOG.debug("Requesting file: {}/{}", fileToRequest.id.id1, fileToRequest.id.id2)
return GdiFileSyncService.FileSyncService.newBuilder().buildWith {
fileRequest = GdiFileSyncService.FileRequest.newBuilder().buildWith {
file = fileToRequest
unk2 = 24
unk3 = 0
unk4 = 0
unk5 = 15
}
}
}
fun markSynced(syncFile: GdiFileSyncService.File): GdiFileSyncService.FileSyncService {
return GdiFileSyncService.FileSyncService.newBuilder().buildWith {
fileSetFlags = GdiFileSyncService.FileSetFlags.newBuilder().buildWith {
file = syncFile.id
flags = GdiFileSyncService.FileId.newBuilder().setId1(42405).setId2(42405).build()
}
}
}
companion object {
private val LOG = LoggerFactory.getLogger(FileSyncServiceHandler::class.java)
private val FILE_TYPES_TO_PROCESS: Set<String?> = setOf(
"FIT_TYPE_4", // ACTIVITY
"FIT_TYPE_32", // MONITOR
"FIT_TYPE_44", // METRICS
"FIT_TYPE_41", // CHANGELOG
"FIT_TYPE_68", // HRV_STATUS
"FIT_TYPE_49", // SLEEP
"FIT_TYPE_61", // ECG
"FIT_TYPE_73", // SKIN_TEMP
)
}
}
@@ -0,0 +1,35 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
import androidx.annotation.Nullable;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiFileSyncService;
public class FileToDownload {
private final FileTransferHandler.DirectoryEntry directoryEntry;
private final GdiFileSyncService.File syncFile;
public FileToDownload(final FileTransferHandler.DirectoryEntry directoryEntry) {
this.directoryEntry = directoryEntry;
this.syncFile = null;
}
public FileToDownload(final GdiFileSyncService.File syncFile) {
this.directoryEntry = null;
this.syncFile = syncFile;
}
@Nullable
public FileTransferHandler.DirectoryEntry getDirectoryEntry() {
return directoryEntry;
}
@Nullable
public GdiFileSyncService.File getSyncFile() {
return syncFile;
}
public long getSize() {
return directoryEntry != null ? directoryEntry.getFileSize() :
syncFile != null ? syncFile.getSize() : -1;
}
}
@@ -168,6 +168,13 @@ public CreateFileMessage initiateUpload(byte[] fileAsByteArray, FileType.FILETYP
private void parseDirectoryEntries() {
LOG.debug("Parsing directory entries for {}", currentlyDownloading.directoryEntry);
if (deviceSupport.newSyncProtocol()) {
// Signal to the support class that we got the directory - but ignore the entries
// Well request them using the new sync protocol
deviceSupport.addFileToDownloadList(currentlyDownloading.directoryEntry);
return;
}
if ((currentlyDownloading.getDataSize() % 16) != 0)
throw new IllegalArgumentException("Invalid directory data length");
final GarminByteBufferReader reader = new GarminByteBufferReader(currentlyDownloading.dataHolder.array());
@@ -20,8 +20,12 @@ import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.text.SimpleDateFormat;
import java.time.LocalTime;
import java.util.ArrayList;
@@ -64,6 +68,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.weather.Weather;
import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiCore;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiDeviceStatus;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiFileSyncService;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiFindMyWatch;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiInstalledAppsService;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiSettingsService;
@@ -80,10 +85,12 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.WeatherRequestDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitAsyncProcessor;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitFile;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitImporter;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.GpxRouteFileConverter;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.PredefinedLocalMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.exception.FitParseException;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionAlarmLabel;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitAlarmSettings;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitDeviceSettings;
@@ -98,6 +105,8 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SetF
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SupportedFileTypesMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SystemEventMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.NotificationSubscriptionStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils;
import nodomain.freeyourgadget.gadgetbridge.util.CompressionUtils;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.MediaManager;
@@ -113,7 +122,8 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
private final ProtocolBufferHandler protocolBufferHandler;
private final NotificationsHandler notificationsHandler;
private final FileTransferHandler fileTransferHandler;
private final Queue<FileTransferHandler.DirectoryEntry> filesToDownload;
private final Queue<FileToDownload> filesToDownload;
private FileToDownload currentlyDownloading;
private final List<MessageHandler> messageHandlers;
private final List<FileType> supportedFileTypeList = new ArrayList<>();
private ICommunicator communicator;
@@ -161,12 +171,35 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
}
public void addFileToDownloadList(FileTransferHandler.DirectoryEntry directoryEntry) {
filesToDownload.add(directoryEntry);
if (newSyncProtocol()) {
if (directoryEntry.getFiletype() == FileType.FILETYPE.DIRECTORY) {
LOG.debug("Got directory entry, syncing with new protocol");
sendOutgoingMessage(
"request file list",
protocolBufferHandler.prepareProtobufRequest(
GdiSmartProto.Smart.newBuilder().setFileSyncService(
protocolBufferHandler.getFileSyncServiceHandler().requestFileList()
).build()
)
);
return;
}
LOG.warn("Ignoring directory entry {} in new sync protocol", directoryEntry.getFileName());
return;
}
filesToDownload.add(new FileToDownload(directoryEntry));
if (directoryEntry.getFiletype() != FileType.FILETYPE.DIRECTORY) {
transferNotification.incrementTotalSize(directoryEntry.getFileSize());
}
}
public void addFileToDownloadList(GdiFileSyncService.File file) {
filesToDownload.add(new FileToDownload(file));
if (file.hasSize()) {
transferNotification.incrementTotalSize(file.getSize());
}
}
@Override
public boolean useAutoConnect() {
return false;
@@ -348,32 +381,55 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
} else if (deviceEvent instanceof SupportedFileTypesDeviceEvent) {
this.supportedFileTypeList.clear();
this.supportedFileTypeList.addAll(((SupportedFileTypesDeviceEvent) deviceEvent).getSupportedFileTypes());
} else if (deviceEvent instanceof FileDownloadedDeviceEvent) {
final FileTransferHandler.DirectoryEntry entry = ((FileDownloadedDeviceEvent) deviceEvent).directoryEntry;
final String filename = entry.getFileName();
LOG.debug("FILE DOWNLOAD COMPLETE {}", filename);
transferNotification.incrementTotalProgress(entry.getFileSize());
} else if (deviceEvent instanceof FileDownloadedDeviceEvent fileDownloadedDeviceEvent) {
final FileTransferHandler.DirectoryEntry entry = fileDownloadedDeviceEvent.directoryEntry;
if (entry != null) {
final String filename = entry.getFileName();
LOG.debug("FILE DOWNLOAD COMPLETE {}", filename);
transferNotification.incrementTotalProgress(entry.getFileSize());
if (entry.getFiletype().isFitFile()) {
try (DBHandler handler = GBApplication.acquireDB()) {
final DaoSession session = handler.getDaoSession();
final PendingFileProvider pendingFileProvider = new PendingFileProvider(gbDevice, session);
pendingFileProvider.addPendingFile(fileDownloadedDeviceEvent.localPath);
} catch (final Exception e) {
GB.toast(getContext(), "Error saving pending file", Toast.LENGTH_LONG, GB.ERROR, e);
}
}
if (!getKeepActivityDataOnDevice()) { // delete file from watch upon successful download
sendOutgoingMessage("archive file " + entry.getFileIndex(), new SetFileFlagsMessage(entry.getFileIndex(), SetFileFlagsMessage.FileFlags.ARCHIVE));
}
} else if (fileDownloadedDeviceEvent.localPath != null) {
LOG.debug("ZIP DOWNLOAD COMPLETE {}", fileDownloadedDeviceEvent.localPath);
final File zipFile = new File(fileDownloadedDeviceEvent.localPath);
if (gbDevice.isBusy()) {
transferNotification.incrementTotalProgress(zipFile.length());
}
if (entry.getFiletype().isFitFile()) {
try (DBHandler handler = GBApplication.acquireDB()) {
final DaoSession session = handler.getDaoSession();
final PendingFileProvider pendingFileProvider = new PendingFileProvider(gbDevice, session);
pendingFileProvider.addPendingFile(((FileDownloadedDeviceEvent) deviceEvent).localPath);
pendingFileProvider.addPendingFile(fileDownloadedDeviceEvent.localPath);
} catch (final Exception e) {
GB.toast(getContext(), "Error saving pending file", Toast.LENGTH_LONG, GB.ERROR, e);
}
} else {
LOG.error("Got invalid FileDownloadedDeviceEvent");
}
if (!getKeepActivityDataOnDevice()) { // delete file from watch upon successful download
sendOutgoingMessage("archive file " + entry.getFileIndex(), new SetFileFlagsMessage(entry.getFileIndex(), SetFileFlagsMessage.FileFlags.ARCHIVE));
}
currentlyDownloading = null;
} else {
super.evaluateGBDeviceEvent(deviceEvent);
}
}
/** @noinspection BooleanMethodIsAlwaysInverted*/
/**
* @noinspection BooleanMethodIsAlwaysInverted
*/
private boolean getKeepActivityDataOnDevice() {
return getDevicePrefs().getBoolean("keep_activity_data_on_device", false);
}
@@ -387,9 +443,15 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
// FIXME respect dataTypes?
// We initiate download here even in the new sync protocol so that the watch "flushes" the data
// otherwise we might get incomplete monitor files
sendOutgoingMessage("fetch recorded data", fileTransferHandler.initiateDownload());
}
public boolean newSyncProtocol() {
return getDevicePrefs().getBoolean("new_sync_protocol", false);
}
@Override
public void onNotification(final NotificationSpec notificationSpec) {
sendOutgoingMessage("send notification " + notificationSpec.getId(), notificationsHandler.onNotification(notificationSpec));
@@ -636,42 +698,58 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
}
private void processDownloadQueue() {
if (!filesToDownload.isEmpty() && !fileTransferHandler.isDownloading()) {
if (!filesToDownload.isEmpty() && currentlyDownloading == null) {
if (!gbDevice.isBusy()) {
isBusyFetching = true;
transferNotification.start(
R.string.busy_task_fetch_activity_data,
0,
filesToDownload.stream().mapToLong(FileTransferHandler.DirectoryEntry::getFileSize).sum()
filesToDownload.stream().mapToLong(FileToDownload::getSize).sum()
);
getDevice().setBusyTask(R.string.busy_task_fetch_activity_data, getContext());
getDevice().sendDeviceUpdateIntent(getContext());
}
while (!filesToDownload.isEmpty()) {
final FileTransferHandler.DirectoryEntry directoryEntry = filesToDownload.remove();
if (alreadyDownloaded(directoryEntry)) {
LOG.debug("File: {} already downloaded, not downloading again.", directoryEntry.getFileName());
if (!getKeepActivityDataOnDevice()) { // delete file from watch if already downloaded
sendOutgoingMessage("archive file " + directoryEntry.getFileIndex(), new SetFileFlagsMessage(directoryEntry.getFileIndex(), SetFileFlagsMessage.FileFlags.ARCHIVE));
currentlyDownloading = filesToDownload.remove();
if (currentlyDownloading.getDirectoryEntry() != null) {
FileTransferHandler.DirectoryEntry directoryEntry = currentlyDownloading.getDirectoryEntry();
if (alreadyDownloaded(directoryEntry)) {
LOG.debug("File: {} already downloaded, not downloading again.", directoryEntry.getFileName());
if (!getKeepActivityDataOnDevice()) { // delete file from watch if already downloaded
currentlyDownloading = null;
sendOutgoingMessage("archive file " + directoryEntry.getFileIndex(), new SetFileFlagsMessage(directoryEntry.getFileIndex(), SetFileFlagsMessage.FileFlags.ARCHIVE));
}
if (directoryEntry.getFiletype() != FileType.FILETYPE.DIRECTORY) {
transferNotification.incrementTotalProgress(directoryEntry.getFileSize());
}
continue;
}
final DownloadRequestMessage downloadRequestMessage = fileTransferHandler.downloadDirectoryEntry(directoryEntry);
LOG.debug("Will download file: {}", directoryEntry.getOutputPath());
if (directoryEntry.getFiletype() != FileType.FILETYPE.DIRECTORY) {
transferNotification.incrementTotalProgress(directoryEntry.getFileSize());
transferNotification.setChunkProgress(0);
}
continue;
sendOutgoingMessage("download file " + directoryEntry.getFileIndex(), downloadRequestMessage);
} else if (currentlyDownloading.getSyncFile() != null) {
LOG.debug("Will download file: {}/{}", currentlyDownloading.getSyncFile().getId().getId1(), currentlyDownloading.getSyncFile().getId().getId2());
sendOutgoingMessage(
"request file",
protocolBufferHandler.prepareProtobufRequest(
GdiSmartProto.Smart.newBuilder().setFileSyncService(
protocolBufferHandler.getFileSyncServiceHandler().requestFile(currentlyDownloading.getSyncFile())
).build()
)
);
}
final DownloadRequestMessage downloadRequestMessage = fileTransferHandler.downloadDirectoryEntry(directoryEntry);
LOG.debug("Will download file: {}", directoryEntry.getOutputPath());
if (directoryEntry.getFiletype() != FileType.FILETYPE.DIRECTORY) {
transferNotification.setChunkProgress(0);
}
sendOutgoingMessage("download file " + directoryEntry.getFileIndex(), downloadRequestMessage);
return;
}
}
if (filesToDownload.isEmpty() && !fileTransferHandler.isDownloading() && isBusyFetching) {
if (filesToDownload.isEmpty() && currentlyDownloading == null && isBusyFetching) {
final List<File> filesToProcess;
try (DBHandler handler = GBApplication.acquireDB()) {
final DaoSession session = handler.getDaoSession();
@@ -1133,6 +1211,116 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
communicator.onEnableRealtimeSteps(enable);
}
public void downloadFileFromServiceV2(final int fileHandle) {
LOG.warn("Requesting file service V2 handle={}", fileHandle);
if (!(communicator instanceof CommunicatorV2 communicatorV2)) {
LOG.error("Communicator is not V2");
return;
}
communicatorV2.startTransfer(new CommunicatorV2.ServiceCallback() {
private final ByteArrayOutputStream baos = new ByteArrayOutputStream();
private boolean started = false;
private CommunicatorV2.ServiceWriter writer;
@Override
public void onConnect(final CommunicatorV2.ServiceWriter writer) {
this.writer = writer;
final ByteBuffer buf = ByteBuffer.allocate(7).order(ByteOrder.LITTLE_ENDIAN);
buf.put((byte) 0x00);
buf.put((byte) 0x00);
buf.put((byte) fileHandle);
buf.put((byte) 0x00);
buf.put((byte) 0x00);
buf.put((byte) 0x00);
writer.write("request file", buf.array());
}
@Override
public void onClose() {
if (baos.size() == 0) {
LOG.warn("File transfer closed with 0 bytes in the buffer");
return;
}
LOG.debug("Attempting to inflate {} bytes", baos.size());
final byte[] inflated = CompressionUtils.INSTANCE.inflate(baos.toByteArray());
if (inflated == null) {
if (currentlyDownloading != null && currentlyDownloading.getSyncFile() != null) {
currentlyDownloading = null;
}
return;
}
LOG.debug("Inflated to {} bytes", inflated.length);
final File file;
try {
file = File.createTempFile("activity-files-import", ".fit", getContext().getCacheDir());
file.deleteOnExit();
FileUtils.copyStreamToFile(new ByteArrayInputStream(inflated), file);
} catch (final IOException e) {
LOG.error("Failed to create temp file for activity file", e);
if (currentlyDownloading != null && currentlyDownloading.getSyncFile() != null) {
currentlyDownloading = null;
}
return;
}
LOG.debug("Dumped inflated bytes to {}", file.getAbsolutePath());
try {
final FitImporter fitImporter = new FitImporter(getContext(), gbDevice);
fitImporter.importFile(file);
} catch (final FitParseException e) {
LOG.error("Inflated not fit file??", e);
if (currentlyDownloading != null && currentlyDownloading.getSyncFile() != null) {
currentlyDownloading = null;
}
return;
} catch (IOException e) {
LOG.error("Failed to read fit file", e);
if (currentlyDownloading != null && currentlyDownloading.getSyncFile() != null) {
currentlyDownloading = null;
}
return;
}
if (!getKeepActivityDataOnDevice()) { // delete file from watch upon successful download
sendOutgoingMessage(
"mark file as synced",
protocolBufferHandler.prepareProtobufRequest(
GdiSmartProto.Smart.newBuilder().setFileSyncService(
protocolBufferHandler.getFileSyncServiceHandler().markSynced(currentlyDownloading.getSyncFile())
).build()
)
);
}
LOG.debug("New file sync success");
if (currentlyDownloading != null && currentlyDownloading.getSyncFile() != null) {
transferNotification.incrementTotalProgress(currentlyDownloading.getSyncFile().getSize());
currentlyDownloading = null;
}
}
@Override
public void onMessage(final byte[] value) {
if (!started) {
if (!ArrayUtils.equals(new byte[]{0,0,0}, value, 0)) {
LOG.error("Got unexpected first message");
return;
}
started = true;
return;
}
LOG.debug("Buffering {} bytes", value.length);
baos.write(value, 0, value.length);
transferNotification.setChunkProgress(baos.size());
}
});
}
@Override
public void onTestNewFunction() {
parseAllFitFilesFromStorage();
@@ -1201,4 +1389,5 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
}
});
}
}
@@ -39,6 +39,7 @@ import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiCalendarService;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiCore;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiDataTransferService;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiDeviceStatus;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiFileSyncService;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiFindMyWatch;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiHttpService;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiNotificationsService;
@@ -46,6 +47,7 @@ import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiSettingsService;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiInstalledAppsService;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiSmartProto;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiSmsNotification;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiEcgService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http.DataTransferHandler;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http.HttpHandler;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage;
@@ -66,6 +68,8 @@ public class ProtocolBufferHandler implements MessageHandler {
private int lastProtobufRequestId;
private final HttpHandler httpHandler;
private final DataTransferHandler dataTransferHandler;
private final FileSyncServiceHandler fileSyncServiceHandler;
private final EcgServiceHandler ecgServiceHandler;
private final Map<GdiSmsNotification.SmsNotificationService.CannedListType, String[]> cannedListTypeMap = new HashMap<>();
@@ -74,6 +78,8 @@ public class ProtocolBufferHandler implements MessageHandler {
chunkedFragmentsMap = new HashMap<>();
httpHandler = new HttpHandler(deviceSupport);
dataTransferHandler = new DataTransferHandler();
fileSyncServiceHandler = new FileSyncServiceHandler(deviceSupport);
ecgServiceHandler = new EcgServiceHandler(deviceSupport);
}
private int getNextProtobufRequestId() {
@@ -191,6 +197,30 @@ public class ProtocolBufferHandler implements MessageHandler {
}
}
}
if (smart.hasFileSyncService()) {
processed = true;
if (deviceSupport.getDevicePrefs().getBoolean("new_sync_protocol", false)) {
processed = true;
final GdiFileSyncService.FileSyncService response = fileSyncServiceHandler.handle(smart.getFileSyncService());
if (response != null) {
return prepareProtobufResponse(GdiSmartProto.Smart.newBuilder().setFileSyncService(response).build(), message.getRequestId());
}
} else {
LOG.warn("Ignoring file sync service - new sync protocol is disabled");
}
}
if (smart.hasEcgService()) {
processed = true;
if (deviceSupport.getDevicePrefs().getBoolean("new_sync_protocol", false)) {
processed = true;
final GdiEcgService.EcgService response = ecgServiceHandler.handle(smart.getEcgService());
if (response != null) {
return prepareProtobufResponse(GdiSmartProto.Smart.newBuilder().setEcgService(response).build(), message.getRequestId());
}
} else {
LOG.warn("Ignoring zip transfer service - new sync protocol is disabled");
}
}
if (processed) {
message.setStatusMessage(new ProtobufStatusMessage(
message.getMessageType(),
@@ -627,6 +657,10 @@ public class ProtocolBufferHandler implements MessageHandler {
return prepareProtobufRequest(smart);
}
public FileSyncServiceHandler getFileSyncServiceHandler() {
return fileSyncServiceHandler;
}
private class ProtobufFragment {
private final byte[] fragmentBytes;
private final int totalLength;
@@ -17,6 +17,8 @@ import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
@@ -51,6 +53,10 @@ public class CommunicatorV2 implements ICommunicator {
private final GarminSupport mSupport;
private final Map<Integer, Service> serviceByHandle = new HashMap<>();
private final Map<Service, Integer> handleByService = new HashMap<>();
private final Map<Service, ServiceCallback> serviceCallbacks = new HashMap<>();
private int gfdiHandle = 0;
public int maxWriteSize = 20;
public final CobsCoDec cobsCoDec;
@@ -153,12 +159,48 @@ public class CommunicatorV2 implements ICommunicator {
} else if (this.realtimeHrvHandle == handle) {
processRealtimeHrv(message);
} else {
LOG.warn("Got message for unknown handle {}: {}", handle, GB.hexdump(value));
final Service service = serviceByHandle.get(handle & 0xff);
if (service != null) {
final ServiceCallback serviceCallback = serviceCallbacks.get(service);
if (serviceCallback != null) {
serviceCallback.onMessage(Arrays.copyOfRange(value, 1, value.length));
} else {
LOG.warn("Got message for {}, but no callback found", service);
}
} else {
LOG.warn("Got message for unknown service on handle {}: {}", handle, GB.hexdump(value));
}
}
return true;
}
public void startTransfer(final ServiceCallback callback) {
final Service service;
if (!handleByService.containsKey(Service.FILE_TRANSFER_2)) {
service = Service.FILE_TRANSFER_2;
} else if (!handleByService.containsKey(Service.FILE_TRANSFER_4)) {
service = Service.FILE_TRANSFER_4;
} else if (!handleByService.containsKey(Service.FILE_TRANSFER_6)) {
service = Service.FILE_TRANSFER_6;
} else if (!handleByService.containsKey(Service.FILE_TRANSFER_A)) {
service = Service.FILE_TRANSFER_A;
} else if (!handleByService.containsKey(Service.FILE_TRANSFER_C)) {
service = Service.FILE_TRANSFER_C;
} else if (!handleByService.containsKey(Service.FILE_TRANSFER_E)) {
service = Service.FILE_TRANSFER_E;
} else {
LOG.error("No file transfer services available");
callback.onClose();
return;
}
serviceCallbacks.put(service, callback);
mSupport.createTransactionBuilder("start file transfer")
.write(characteristicSend, registerService(service, false))
.queue();
}
@Override
public void onHeartRateTest() {
realtimeHrOneShot = true;
@@ -235,6 +277,9 @@ public class CommunicatorV2 implements ICommunicator {
final int reliable = message.get();
LOG.debug("Got register response for {}, handle={}, reliable={}", registeredService, handle, reliable);
serviceByHandle.put(handle, registeredService);
handleByService.put(registeredService, handle);
switch (registeredService) {
case GFDI:
this.gfdiHandle = handle;
@@ -260,6 +305,20 @@ public class CommunicatorV2 implements ICommunicator {
case REALTIME_HRV:
this.realtimeHrvHandle = handle;
break;
case FILE_TRANSFER_2:
case FILE_TRANSFER_4:
case FILE_TRANSFER_6:
case FILE_TRANSFER_A:
case FILE_TRANSFER_C:
case FILE_TRANSFER_E:
final ServiceCallback serviceCallback = serviceCallbacks.get(registeredService);
if (serviceCallback == null) {
LOG.error("Got file transfer registration, but got no callback");
closeService(registeredService, handle);
break;
} else {
serviceCallback.onConnect(new ServiceWriter(handle));
}
}
break;
}
@@ -292,8 +351,25 @@ public class CommunicatorV2 implements ICommunicator {
case REALTIME_HRV:
this.realtimeHrvHandle = 0;
break;
case FILE_TRANSFER_2:
case FILE_TRANSFER_4:
case FILE_TRANSFER_6:
case FILE_TRANSFER_A:
case FILE_TRANSFER_C:
case FILE_TRANSFER_E:
final ServiceCallback serviceCallback = serviceCallbacks.get(service);
if (serviceCallback == null) {
LOG.error("Got file transfer registration, but got no callback");
} else {
serviceCallback.onClose();
}
break;
}
}
serviceByHandle.remove(handle);
handleByService.remove(service);
serviceCallbacks.remove(service);
break;
}
case CLOSE_ALL_RESP:
@@ -475,6 +551,12 @@ public class CommunicatorV2 implements ICommunicator {
REALTIME_SPO2(19),
REALTIME_BODY_BATTERY(20),
REALTIME_RESPIRATION(21),
FILE_TRANSFER_2(0x2018),
FILE_TRANSFER_4(0x4018),
FILE_TRANSFER_6(0x6018),
FILE_TRANSFER_A(0xa018),
FILE_TRANSFER_C(0xc018),
FILE_TRANSFER_E(0xe018),
;
private final short code;
@@ -498,4 +580,27 @@ public class CommunicatorV2 implements ICommunicator {
return null;
}
}
public interface ServiceCallback {
void onConnect(ServiceWriter writer);
void onClose();
void onMessage(byte[] value);
}
public class ServiceWriter {
private final int handle;
private ServiceWriter(final int handle) {
this.handle = handle;
}
public void write(final String taskName, final byte[] value) {
final ByteBuffer buf = ByteBuffer.allocate(value.length + 1);
buf.put((byte) handle);
buf.put(value);
mSupport.createTransactionBuilder(taskName)
.write(characteristicSend, buf.array())
.queue();
}
}
}
@@ -148,16 +148,14 @@ public class FitImporter {
final Long ts = record.getComputedTimestamp();
if (record instanceof FitFileId) {
final FitFileId newFileId = (FitFileId) record;
if (record instanceof FitFileId newFileId) {
LOG.debug("File ID: {}", newFileId);
if (fileId != null) {
// Should not happen
LOG.warn("Already had a file ID: {}", fileId);
}
fileId = newFileId;
} else if (record instanceof FitStressLevel) {
final FitStressLevel stressRecord = (FitStressLevel) record;
} else if (record instanceof FitStressLevel stressRecord) {
final Integer stress = stressRecord.getStressLevelValue();
if (stress != null && stress >= 0) {
LOG.trace("Stress at {}: {}", ts, stress);
@@ -175,16 +173,14 @@ public class FitImporter {
sample.setEnergy(energy);
bodyEnergySamples.add(sample);
}
} else if (record instanceof FitSleepDataInfo) {
final FitSleepDataInfo newFitSleepDataInfo = (FitSleepDataInfo) record;
} else if (record instanceof FitSleepDataInfo newFitSleepDataInfo) {
LOG.debug("Sleep Data Info: {}", newFitSleepDataInfo);
if (fitSleepDataInfo != null) {
// Should not happen
LOG.warn("Already had sleep data info: {}", fitSleepDataInfo);
}
fitSleepDataInfo = newFitSleepDataInfo;
} else if (record instanceof FitSleepDataRaw) {
final FitSleepDataRaw fitSleepDataRaw = (FitSleepDataRaw) record;
} else if (record instanceof FitSleepDataRaw fitSleepDataRaw) {
//LOG.debug("Sleep Data Raw: {}", fitSleepDataRaw);
fitSleepDataRawSamples.add(fitSleepDataRaw);
} else if (record instanceof FitSleepStats) {
@@ -207,8 +203,7 @@ public class FitImporter {
sample.setTimestamp(ts * 1000L);
sample.setStage(stage.getId());
sleepStageSamples.add(sample);
} else if (record instanceof FitNap) {
final FitNap nap = (FitNap) record;
} else if (record instanceof FitNap nap) {
if (nap.getStartTimestamp() == null || nap.getEndTimestamp() == null) {
continue;
}
@@ -217,9 +212,8 @@ public class FitImporter {
sample.setTimestamp(nap.getStartTimestamp() * 1000L);
sample.setEndTimestamp(nap.getEndTimestamp() * 1000L);
napSamples.add(sample);
} else if (record instanceof FitMonitoring) {
} else if (record instanceof FitMonitoring monitoringRecord) {
LOG.trace("Monitoring at {}: {}", ts, record);
final FitMonitoring monitoringRecord = (FitMonitoring) record;
final Long currentMonitoringTimestamp = monitoringRecord.computeTimestamp(lastMonitoringTimestamp);
if (!activitySamplesPerTimestamp.containsKey(currentMonitoringTimestamp)) {
activitySamplesPerTimestamp.put(currentMonitoringTimestamp, new ArrayList<>());
@@ -257,8 +251,7 @@ public class FitImporter {
sample.setTimestamp(ts * 1000L);
sample.setRespiratoryRate(respiratoryRate);
respiratoryRateSamples.add(sample);
} else if (record instanceof FitEvent) {
final FitEvent event = (FitEvent) record;
} else if (record instanceof FitEvent event) {
if (event.getEvent() == null) {
LOG.warn("Event in {} is null", event);
continue;
@@ -288,8 +281,7 @@ public class FitImporter {
// handled in workout parser
} else if (record instanceof FitUserProfile) {
// handled in workout parser
} else if (record instanceof FitHrvSummary) {
final FitHrvSummary hrvSummary = (FitHrvSummary) record;
} else if (record instanceof FitHrvSummary hrvSummary) {
LOG.trace("HRV summary at {}: {}", ts, record);
final GarminHrvSummarySample sample = new GarminHrvSummarySample();
sample.setTimestamp(ts * 1000L);
@@ -316,8 +308,7 @@ public class FitImporter {
sample.setStatusNum(status.getId());
}
hrvSummarySamples.add(sample);
} else if (record instanceof FitHrvValue) {
final FitHrvValue hrvValue = (FitHrvValue) record;
} else if (record instanceof FitHrvValue hrvValue) {
if (hrvValue.getValue() == null) {
LOG.warn("HRV value at {} is null", ts);
continue;
@@ -327,8 +318,7 @@ public class FitImporter {
sample.setTimestamp(ts * 1000L);
sample.setValue(Math.round(hrvValue.getValue()));
hrvValueSamples.add(sample);
} else if (record instanceof FitMonitoringInfo) {
final FitMonitoringInfo monitoringInfo = (FitMonitoringInfo) record;
} else if (record instanceof FitMonitoringInfo monitoringInfo) {
if (monitoringInfo.getRestingMetabolicRate() == null) {
continue;
}
@@ -337,8 +327,7 @@ public class FitImporter {
sample.setTimestamp(ts * 1000L);
sample.setRestingMetabolicRate(monitoringInfo.getRestingMetabolicRate());
restingMetabolicRateSamples.add(sample);
} else if (record instanceof FitTrainingLoad) {
final FitTrainingLoad trainingLoad = (FitTrainingLoad) record;
} else if (record instanceof FitTrainingLoad trainingLoad) {
LOG.trace("Training load at {}: {}", ts, record);
if (trainingLoad.getTrainingLoadAcute() != null) {
final GenericTrainingLoadAcuteSample sample = new GenericTrainingLoadAcuteSample();
@@ -352,8 +341,7 @@ public class FitImporter {
sample.setValue(trainingLoad.getTrainingLoadChronic());
trainingLoadChronicSamples.add(sample);
}
} else if (record instanceof FitMonitoringHrData) {
final FitMonitoringHrData monitoringHrData = (FitMonitoringHrData) record;
} else if (record instanceof FitMonitoringHrData monitoringHrData) {
if (monitoringHrData.getRestingHeartRate() == null) {
LOG.warn("Resting HR at {} is null", ts);
continue;
@@ -390,14 +378,7 @@ public class FitImporter {
try {
final File exportDirectory = gbDevice.getDeviceCoordinator().getWritableExportDirectory(gbDevice);
if (!file.getAbsolutePath().startsWith(exportDirectory.getAbsolutePath())) {
final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ROOT);
final StringBuilder sb = new StringBuilder(fileId.getType().name());
if (fileId.getTimeCreated() != null && fileId.getTimeCreated() != 0) {
sb.append("_").append(SDF.format(new Date(fileId.getTimeCreated() * 1000L)));
}
sb.append(".fit");
final File exportFile = new File(exportDirectory, sb.toString());
final File exportFile = new File(exportDirectory, getFilePath(fileId));
if (exportFile.isFile()) {
// Prevent overwrite
LOG.warn("Fit file {} already exists as {}", file, exportFile);
@@ -405,6 +386,7 @@ public class FitImporter {
LOG.debug("Copying {} to {}", file, exportFile);
FileUtils.copyFile(file, exportFile);
//noinspection ResultOfMethodCallIgnored
exportFile.setLastModified(file.lastModified());
}
@@ -727,4 +709,34 @@ public class FitImporter {
final AbstractTimeSampleProvider<T> sampleProvider) {
sampleProvider.persistForDevice(context, gbDevice, samples);
}
public static String getFilePath(final FitFileId fileId) {
final SimpleDateFormat SDF_FULL = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ROOT);
final SimpleDateFormat SDF_YEAR = new SimpleDateFormat("yyyy", Locale.ROOT);
// [FILE_TYPE]/
final StringBuilder sb = new StringBuilder();
if (fileId.getType() != null) {
sb.append(fileId.getType());
} else {
sb.append("NULL");
}
sb.append("/");
// If we have a valid date, place the file inside a folder for each year
// [YEAR]/
if (fileId.getTimeCreated() != null && fileId.getTimeCreated() != 0) {
sb.append(SDF_YEAR.format(new Date(fileId.getTimeCreated() * 1000L)));
sb.append("/");
}
// [FILE_TYPE]_[yyyy-MM-dd_HH-mm-ss]_[INDEX].[fit/bin]
sb.append(fileId.getType().name());
if (fileId.getTimeCreated() != null && fileId.getTimeCreated() != 0) {
sb.append("_").append(SDF_FULL.format(new Date(fileId.getTimeCreated() * 1000L)));
}
sb.append(".fit");
return sb.toString();
}
}
@@ -12,9 +12,7 @@ import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.UUID;
import java.util.zip.DataFormatException;
import java.util.zip.Deflater;
import java.util.zip.Inflater;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService;
@@ -203,32 +201,12 @@ public abstract class ZeppOsFileTransferImpl {
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;
return switch (b) {
case 0x00 -> false;
case 0x01 -> true;
default -> null;
};
}
}
@@ -31,6 +31,7 @@ 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;
import nodomain.freeyourgadget.gadgetbridge.util.CompressionUtils;
public class ZeppOsFileTransferV2 extends ZeppOsFileTransferImpl {
private static final Logger LOG = LoggerFactory.getLogger(ZeppOsFileTransferV2.class);
@@ -182,7 +183,7 @@ public class ZeppOsFileTransferV2 extends ZeppOsFileTransferImpl {
final byte[] data;
if (request.isCompressed()) {
data = decompress(request.getBytes());
data = CompressionUtils.INSTANCE.inflate(request.getBytes());
if (data == null) {
LOG.error("Failed to decompress bytes for session={}", session);
return;
@@ -35,6 +35,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsS
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsTransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsFileTransferService;
import nodomain.freeyourgadget.gadgetbridge.util.CheckSums;
import nodomain.freeyourgadget.gadgetbridge.util.CompressionUtils;
public class ZeppOsFileTransferV3 extends ZeppOsFileTransferImpl {
private static final Logger LOG = LoggerFactory.getLogger(ZeppOsFileTransferV3.class);
@@ -362,7 +363,7 @@ public class ZeppOsFileTransferV3 extends ZeppOsFileTransferImpl {
if (currentReceiveChunkIsLast) {
final byte[] data;
if (currentReceiveRequest.isCompressed()) {
data = decompress(currentReceiveRequest.getBytes());
data = CompressionUtils.INSTANCE.inflate(currentReceiveRequest.getBytes());
if (data == null) {
LOG.error("Failed to decompress V3 bytes for {}", currentReceiveRequest.getFilename());
resetReceive();
@@ -0,0 +1,28 @@
package nodomain.freeyourgadget.gadgetbridge.util
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport
import org.slf4j.LoggerFactory
import java.io.ByteArrayOutputStream
import java.util.zip.DataFormatException
import java.util.zip.Inflater
object CompressionUtils {
private val LOG = LoggerFactory.getLogger(GarminSupport::class.java)
fun inflate(bytes: ByteArray): ByteArray? {
val inflater = Inflater()
inflater.setInput(bytes)
val baosInflated = ByteArrayOutputStream(bytes.size)
val buf = ByteArray(8096)
while (!inflater.finished()) {
try {
val count = inflater.inflate(buf)
baosInflated.write(buf, 0, count)
} catch (e: DataFormatException) {
LOG.error("Failed to inflate", e)
return null
}
}
return baosInflated.toByteArray()
}
}
@@ -23,6 +23,8 @@ import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipException;
import java.util.zip.ZipInputStream;
@@ -93,6 +95,21 @@ public class GBZipFile {
}
}
public List<String> getAllFiles() throws ZipFileException {
try (InputStream is = new ByteArrayInputStream(zipBytes); ZipInputStream zipInputStream = new ZipInputStream(is)) {
final List<String> files = new ArrayList<>();
ZipEntry zipEntry;
while ((zipEntry = zipInputStream.getNextEntry()) != null) {
files.add(zipEntry.getName());
}
return files;
} catch (ZipException e) {
throw new ZipFileException("The ZIP file might be corrupted", e);
} catch (IOException e) {
throw new ZipFileException("General IO error", e);
}
}
public boolean fileExists(final String path) throws ZipFileException {
try (InputStream is = new ByteArrayInputStream(zipBytes); ZipInputStream zipInputStream = new ZipInputStream(is)) {
ZipEntry zipEntry;
@@ -0,0 +1,42 @@
syntax = "proto2";
package garmin_vivomovehr;
option java_package = "nodomain.freeyourgadget.gadgetbridge.proto.garmin";
message EcgService {
optional EcgFileTransfer file_transfer = 1;
}
message EcgFileTransfer {
optional EcgInitRequest init_request = 3;
optional EcgInitResponse init_response = 4;
optional EcgTransferStartReq transfer_start_req = 5;
optional EcgTransferStartAck transfer_start_ack = 6;
optional EcgFile ecg_file = 7;
optional string transfer_finish_notify = 8; // ""
optional string transfer_finish_ack = 9; // ""
}
message EcgInitRequest {
optional string unk1 = 1; // uuid
}
message EcgInitResponse {
optional uint32 unk1 = 1; // 1
}
message EcgTransferStartReq {
optional string unk1 = 1; // ""
}
message EcgTransferStartAck {
optional uint32 unk2 = 2; // 1
}
message EcgFile {
optional bytes data = 1;
optional string name = 2;
optional uint32 unk3 = 3; // 0
optional uint32 unk4 = 4; // 1
}
@@ -0,0 +1,79 @@
syntax = "proto2";
package garmin_vivomovehr;
option java_package = "nodomain.freeyourgadget.gadgetbridge.proto.garmin";
message FileSyncService {
optional FileRequest file_request = 1;
optional FileResponse file_response = 2;
optional FileListRequest file_list_request = 9;
optional FileListResponse file_list_response = 10;
optional NewFileNotification new_file_notification = 12;
optional FileSetFlags file_set_flags = 15;
optional FileUpdateNotification file_update_notification = 17;
}
message NewFileNotification {
optional File file = 1;
optional uint32 unk2 = 2; // 24
optional uint32 unk3 = 3; // 0
optional uint32 unk4 = 4; // 0
optional uint32 unk5 = 5; // 15
optional uint32 unk6 = 6; // 1
}
message FileSetFlags {
optional FileId file = 1;
optional FileId flags = 2;
}
message FileUpdateNotification {
optional FileId file = 1;
optional FileId flags = 2;
}
message FileRequest {
optional File file = 1;
optional uint32 unk2 = 2; // 24
optional uint32 unk3 = 3; // 0
optional uint32 unk4 = 4; // 0
optional uint32 unk5 = 5; // 15
}
message FileResponse {
optional uint32 unk1 = 1; // 0
optional uint32 unk2 = 2; // 0
optional uint32 handle = 3; // varies
optional uint32 unk7 = 7; // 11
}
message FileListRequest {
optional uint32 start_page_id = 2; // 33737
optional FileId flags1 = 4; // 0x000000000000a5a5 / 42405
optional FileId flags2 = 5; // 0x000000000000a5a5 / 42405
}
message FileListResponse {
optional uint32 unk1 = 1; // 0
optional uint32 next_page_id = 3; // 33737, incrementiong
repeated File file = 4;
}
message File {
optional FileId id = 1;
optional FileType type = 2;
optional uint32 size = 3;
optional uint32 page_id = 5; // 33652, 33653, ... incrementing
}
message FileId {
optional fixed64 id1 = 1;
optional fixed64 id2 = 2;
}
message FileType {
optional uint32 unk1 = 1; // 0
optional string name = 2;
optional uint32 code = 3; // 8 monitor 9 sports
}
@@ -6,6 +6,7 @@ option java_package = "nodomain.freeyourgadget.gadgetbridge.proto.garmin";
import "garmin/gdi_authentication_service.proto";
import "garmin/gdi_device_status.proto";
import "garmin/gdi_file_sync_service.proto";
import "garmin/gdi_find_my_watch.proto";
import "garmin/gdi_core.proto";
import "garmin/gdi_http_service.proto";
@@ -15,6 +16,7 @@ import "garmin/gdi_sms_notification.proto";
import "garmin/gdi_calendar_service.proto";
import "garmin/gdi_settings_service.proto";
import "garmin/gdi_notifications_service.proto";
import "garmin/gdi_ecg_service.proto";
message Smart {
optional CalendarService calendar_service = 1;
@@ -26,6 +28,8 @@ message Smart {
optional CoreService core_service = 13;
optional SmsNotificationService sms_notification_service = 16;
optional AuthenticationService authenticationService = 27;
optional EcgService ecg_service = 39;
optional SettingsService settings_service = 42;
optional FileSyncService file_sync_service = 43;
optional NotificationsService notifications_service = 49;
}
+2
View File
@@ -4259,4 +4259,6 @@
<string name="bluetooth_multipoint_pair_stop">Stop pairing</string>
<string name="bluetooth_paired_devices">Paired Devices</string>
<string name="connect">Connect</string>
<string name="pref_new_sync_protocol_title">New sync protocol</string>
<string name="pref_new_sync_protocol_summary">Enable this if some information is not being fetched. Experimental and potentially unstable.</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<SwitchPreferenceCompat
android:defaultValue="false"
android:icon="@drawable/ic_refresh"
android:key="new_sync_protocol"
android:layout="@layout/preference_checkbox"
android:summary="@string/pref_new_sync_protocol_summary"
android:title="@string/pref_new_sync_protocol_title" />
</androidx.preference.PreferenceScreen>