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); + } +}