mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
Garmin: New sync protocol (experimental)
This commit is contained in:
+1
@@ -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;
|
||||
}
|
||||
|
||||
+75
@@ -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)
|
||||
}
|
||||
}
|
||||
+119
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
}
|
||||
+7
@@ -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());
|
||||
|
||||
+218
-29
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+34
@@ -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;
|
||||
|
||||
+106
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+44
-32
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+5
-27
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
+2
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user