diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java
index e2ea0f3c3..23b090dab 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java
@@ -34,7 +34,6 @@ import nodomain.freeyourgadget.gadgetbridge.entities.GarminBodyEnergySampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminEventSampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminHrvSummarySampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminHrvValueSampleDao;
-import nodomain.freeyourgadget.gadgetbridge.entities.GarminRespiratoryRateSample;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminSleepStageSampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminSpo2SampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminStressSampleDao;
@@ -343,10 +342,14 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator {
@Nullable
@Override
public InstallHandler findInstallHandler(Uri uri, Context context) {
+ final GarminFitFileInstallHandler fitFileInstallHandler = new GarminFitFileInstallHandler(uri, context);
+ if (fitFileInstallHandler.isValid())
+ return fitFileInstallHandler;
final GarminGpxRouteInstallHandler garminGpxRouteInstallHandler = new GarminGpxRouteInstallHandler(uri, context);
if (garminGpxRouteInstallHandler.isValid())
return garminGpxRouteInstallHandler;
+
return null;
}
}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminFitFileInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminFitFileInstallHandler.java
new file mode 100644
index 000000000..c7e4ba515
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminFitFileInstallHandler.java
@@ -0,0 +1,231 @@
+/* Copyright (C) 2024 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
+
+import android.content.Context;
+import android.net.Uri;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Optional;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.GenericItem;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.FileType;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitFile;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitCourse;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitFileId;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitWorkout;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.UriHelper;
+
+public class GarminFitFileInstallHandler implements InstallHandler {
+ private static final Logger LOG = LoggerFactory.getLogger(GarminFitFileInstallHandler.class);
+
+ protected final Context mContext;
+ private byte[] rawBytes;
+ private FitFile fitFile;
+ private FileType.FILETYPE fileType;
+
+ public GarminFitFileInstallHandler(final Uri uri, final Context context) {
+ this.mContext = context;
+
+ final UriHelper uriHelper;
+ try {
+ uriHelper = UriHelper.get(uri, context);
+ } catch (final IOException e) {
+ LOG.error("Failed to get uri", e);
+ return;
+ }
+
+ // Quickly check whether it's a valid fit file without reading the entire thing
+ try (InputStream in = new BufferedInputStream(uriHelper.openInputStream())) {
+ final byte[] header = new byte[12];
+ final int read = in.read(header);
+ if (read != header.length) {
+ throw new IOException("Not enough bytes for fit header");
+ }
+ if (BLETypeConversions.toUint32(header, 8) != FitFile.Header.MAGIC) {
+ // not fit file
+ return;
+ }
+ } catch (final Exception e) {
+ LOG.error("Failed to validate fit file", e);
+ return;
+ }
+
+ try (InputStream in = new BufferedInputStream(uriHelper.openInputStream())) {
+ rawBytes = FileUtils.readAll(in, 10 * 1024 * 1024); // 10MB
+ fitFile = FitFile.parseIncoming(rawBytes);
+
+ final Optional fitFileIdOpt = fitFile.getRecords().stream()
+ .filter(r -> r instanceof FitFileId)
+ .map(r -> (FitFileId) r)
+ .findFirst();
+
+ if (!fitFileIdOpt.isPresent()) {
+ LOG.error("Fit file has no ID");
+ return;
+ }
+
+ final FitFileId fitFileId = fitFileIdOpt.get();
+ if (fitFileId.getType() == null) {
+ LOG.error("Fit file ID has null type");
+ return;
+ }
+
+ fileType = fitFileId.getType();
+ } catch (final Exception e) {
+ LOG.error("Failed to read fit file", e);
+ }
+ }
+
+ @Override
+ public boolean isValid() {
+ return fitFile != null && fileType != null;
+ }
+
+ @Override
+ public void validateInstallation(final InstallActivity installActivity, final GBDevice device) {
+ if (fitFile == null || fileType == null) {
+ return;
+ }
+
+ if (device.isBusy()) {
+ installActivity.setInfoText(device.getBusyTask());
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ if (!(coordinator instanceof GarminCoordinator)) {
+ LOG.warn("Coordinator is not a GarminCoordinator: {}", coordinator.getClass());
+ installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+ final GarminCoordinator garminCoordinator = (GarminCoordinator) coordinator;
+ final boolean fileSupported = parseFitFile(installActivity, garminCoordinator, device);
+
+ if (!fileSupported) {
+ installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ if (!device.isInitialized()) {
+ installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_ready));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ installActivity.setInstallEnabled(true);
+ }
+
+ @Override
+ public void onStartInstall(final GBDevice device) {
+ }
+
+ public byte[] getRawBytes() {
+ return rawBytes;
+ }
+
+ public FitFile getFitFile() {
+ return fitFile;
+ }
+
+ public FileType.FILETYPE getFileType() {
+ return fileType;
+}
+
+ private boolean parseFitFile(final InstallActivity installActivity, final GarminCoordinator coordinator, final GBDevice device) {
+ final String name;
+ final int kindName;
+
+ switch (fileType) {
+ case COURSES:
+ if (!coordinator.supports(device, GarminCapability.COURSE_DOWNLOAD)) {
+ LOG.warn("Device does not support course download");
+ return false;
+ }
+ final Optional fitCourseOpt = fitFile.getRecords().stream()
+ .filter(r -> r instanceof FitCourse)
+ .map(r -> (FitCourse) r)
+ .findFirst();
+
+ if (!fitCourseOpt.isPresent()) {
+ LOG.error("Fit file has no course record");
+ return false;
+ }
+ final FitCourse fitCourse = fitCourseOpt.get();
+
+ name = String.valueOf(fitCourse.getName());
+ kindName = R.string.kind_gpx_route;
+
+ break;
+ case WORKOUTS:
+ if (!coordinator.supports(device, GarminCapability.WORKOUT_DOWNLOAD)) {
+ LOG.warn("Device does not support workout download");
+ return false;
+ }
+ final Optional fitWorkoutOpt = fitFile.getRecords().stream()
+ .filter(r -> r instanceof FitWorkout)
+ .map(r -> (FitWorkout) r)
+ .findFirst();
+
+ if (!fitWorkoutOpt.isPresent()) {
+ LOG.error("Fit file has no workout record");
+ return false;
+ }
+ final FitWorkout fitWorkout = fitWorkoutOpt.get();
+
+ name = String.valueOf(fitWorkout.getName());
+ kindName = R.string.menuitem_workout;
+
+ break;
+ default:
+ LOG.warn("Unsupported fit file type: {}", fileType);
+ return false;
+ }
+
+ final GenericItem fwItem = new GenericItem(mContext.getString(
+ R.string.installhandler_firmware_name,
+ mContext.getString(coordinator.getDeviceNameResource()),
+ mContext.getString(kindName),
+ name
+ ));
+ fwItem.setIcon(coordinator.getDefaultIconResource());
+
+ final StringBuilder builder = new StringBuilder();
+ final String kindNameString = mContext.getString(kindName);
+ builder.append(mContext.getString(R.string.fw_upgrade_notice, kindNameString));
+ installActivity.setInfoText(builder.toString());
+ installActivity.setInstallItem(fwItem);
+
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java
index 08a8d158c..cba63b400 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java
@@ -36,6 +36,7 @@ import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.devices.PendingFileProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminFitFileInstallHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminGpxRouteInstallHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminPreferences;
import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability;
@@ -821,6 +822,17 @@ public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommuni
@Override
public void onInstallApp(Uri uri) {
+ final GarminFitFileInstallHandler fitFileInstallHandler = new GarminFitFileInstallHandler(uri, getContext());
+ if (fitFileInstallHandler.isValid()) {
+ communicator.sendMessage(
+ "upload fit file",
+ fileTransferHandler.initiateUpload(
+ fitFileInstallHandler.getRawBytes(),
+ fitFileInstallHandler.getFileType()
+ ).getOutgoingMessage()
+ );
+ }
+
final GarminGpxRouteInstallHandler garminGpxRouteInstallHandler = new GarminGpxRouteInstallHandler(uri, getContext());
if (garminGpxRouteInstallHandler.isValid()) {
communicator.sendMessage("upload course file", fileTransferHandler.initiateUpload(garminGpxRouteInstallHandler.getGpxRouteFileConverter().getConvertedFile().getOutgoingMessage(), FileType.FILETYPE.DOWNLOAD_COURSE).getOutgoingMessage());
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/GlobalFITMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/GlobalFITMessage.java
index 7507f7121..5ee597c84 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/GlobalFITMessage.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/GlobalFITMessage.java
@@ -179,6 +179,15 @@ public class GlobalFITMessage {
new FieldDefinitionPrimitive(253, BaseType.UINT32, "timestamp", FieldDefinitionFactory.FIELD.TIMESTAMP)
));
+ public static GlobalFITMessage WORKOUT = new GlobalFITMessage(26, "WORKOUT", Arrays.asList(
+ new FieldDefinitionPrimitive(4, BaseType.ENUM, "sport"),
+ new FieldDefinitionPrimitive(5, BaseType.UINT32Z, "capabilities"),
+ new FieldDefinitionPrimitive(6, BaseType.UINT16, "num_valid_steps"),
+ new FieldDefinitionPrimitive(8, BaseType.STRING, "name"),
+ new FieldDefinitionPrimitive(11, BaseType.ENUM, "sub_sport"),
+ new FieldDefinitionPrimitive(17, BaseType.STRING, "notes")
+ ));
+
public static GlobalFITMessage COURSE = new GlobalFITMessage(31, "COURSE", Arrays.asList(
new FieldDefinitionPrimitive(4, BaseType.ENUM, "sport"),
new FieldDefinitionPrimitive(5, BaseType.STRING, 16, "name")
@@ -397,6 +406,7 @@ public class GlobalFITMessage {
put(20, RECORD);
put(21, EVENT);
put(23, DEVICE_INFO);
+ put(26, WORKOUT);
put(31, COURSE);
put(49, FILE_CREATOR);
put(55, MONITORING);
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecordDataFactory.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecordDataFactory.java
index 9eb722085..583bf6623 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecordDataFactory.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecordDataFactory.java
@@ -37,6 +37,8 @@ public class FitRecordDataFactory {
return new FitEvent(recordDefinition, recordHeader);
case 23:
return new FitDeviceInfo(recordDefinition, recordHeader);
+ case 26:
+ return new FitWorkout(recordDefinition, recordHeader);
case 31:
return new FitCourse(recordDefinition, recordHeader);
case 49:
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitWorkout.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitWorkout.java
new file mode 100644
index 000000000..a9fac3bf7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitWorkout.java
@@ -0,0 +1,52 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages;
+
+import androidx.annotation.Nullable;
+
+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.RecordHeader;
+
+//
+// WARNING: This class was auto-generated, please avoid modifying it directly.
+// See nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.codegen.FitCodeGen
+//
+public class FitWorkout extends RecordData {
+ public FitWorkout(final RecordDefinition recordDefinition, final RecordHeader recordHeader) {
+ super(recordDefinition, recordHeader);
+
+ final int globalNumber = recordDefinition.getGlobalFITMessage().getNumber();
+ if (globalNumber != 26) {
+ throw new IllegalArgumentException("FitWorkout expects global messages of " + 26 + ", got " + globalNumber);
+ }
+ }
+
+ @Nullable
+ public Integer getSport() {
+ return (Integer) getFieldByNumber(4);
+ }
+
+ @Nullable
+ public Long getCapabilities() {
+ return (Long) getFieldByNumber(5);
+ }
+
+ @Nullable
+ public Integer getNumValidSteps() {
+ return (Integer) getFieldByNumber(6);
+ }
+
+ @Nullable
+ public String getName() {
+ return (String) getFieldByNumber(8);
+ }
+
+ @Nullable
+ public Integer getSubSport() {
+ return (Integer) getFieldByNumber(11);
+ }
+
+ @Nullable
+ public String getNotes() {
+ return (String) getFieldByNumber(17);
+ }
+}