Garmin: Upload workout fit files

This commit is contained in:
José Rebelo 2024-10-26 21:03:58 +01:00
parent 8b4f1aa1d7
commit ba82de99d3
6 changed files with 311 additions and 1 deletions

View File

@ -34,7 +34,6 @@ import nodomain.freeyourgadget.gadgetbridge.entities.GarminBodyEnergySampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminEventSampleDao; import nodomain.freeyourgadget.gadgetbridge.entities.GarminEventSampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminHrvSummarySampleDao; import nodomain.freeyourgadget.gadgetbridge.entities.GarminHrvSummarySampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminHrvValueSampleDao; import nodomain.freeyourgadget.gadgetbridge.entities.GarminHrvValueSampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminRespiratoryRateSample;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminSleepStageSampleDao; import nodomain.freeyourgadget.gadgetbridge.entities.GarminSleepStageSampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminSpo2SampleDao; import nodomain.freeyourgadget.gadgetbridge.entities.GarminSpo2SampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminStressSampleDao; import nodomain.freeyourgadget.gadgetbridge.entities.GarminStressSampleDao;
@ -343,10 +342,14 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator {
@Nullable @Nullable
@Override @Override
public InstallHandler findInstallHandler(Uri uri, Context context) { 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); final GarminGpxRouteInstallHandler garminGpxRouteInstallHandler = new GarminGpxRouteInstallHandler(uri, context);
if (garminGpxRouteInstallHandler.isValid()) if (garminGpxRouteInstallHandler.isValid())
return garminGpxRouteInstallHandler; return garminGpxRouteInstallHandler;
return null; return null;
} }
} }

View File

@ -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 <https://www.gnu.org/licenses/>. */
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<FitFileId> 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<FitCourse> 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<FitWorkout> 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;
}
}

View File

@ -36,6 +36,7 @@ import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.devices.PendingFileProvider; import nodomain.freeyourgadget.gadgetbridge.devices.PendingFileProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator; 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.GarminGpxRouteInstallHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminPreferences; import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminPreferences;
import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability; import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability;
@ -821,6 +822,17 @@ public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommuni
@Override @Override
public void onInstallApp(Uri uri) { 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()); final GarminGpxRouteInstallHandler garminGpxRouteInstallHandler = new GarminGpxRouteInstallHandler(uri, getContext());
if (garminGpxRouteInstallHandler.isValid()) { if (garminGpxRouteInstallHandler.isValid()) {
communicator.sendMessage("upload course file", fileTransferHandler.initiateUpload(garminGpxRouteInstallHandler.getGpxRouteFileConverter().getConvertedFile().getOutgoingMessage(), FileType.FILETYPE.DOWNLOAD_COURSE).getOutgoingMessage()); communicator.sendMessage("upload course file", fileTransferHandler.initiateUpload(garminGpxRouteInstallHandler.getGpxRouteFileConverter().getConvertedFile().getOutgoingMessage(), FileType.FILETYPE.DOWNLOAD_COURSE).getOutgoingMessage());

View File

@ -179,6 +179,15 @@ public class GlobalFITMessage {
new FieldDefinitionPrimitive(253, BaseType.UINT32, "timestamp", FieldDefinitionFactory.FIELD.TIMESTAMP) 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( public static GlobalFITMessage COURSE = new GlobalFITMessage(31, "COURSE", Arrays.asList(
new FieldDefinitionPrimitive(4, BaseType.ENUM, "sport"), new FieldDefinitionPrimitive(4, BaseType.ENUM, "sport"),
new FieldDefinitionPrimitive(5, BaseType.STRING, 16, "name") new FieldDefinitionPrimitive(5, BaseType.STRING, 16, "name")
@ -397,6 +406,7 @@ public class GlobalFITMessage {
put(20, RECORD); put(20, RECORD);
put(21, EVENT); put(21, EVENT);
put(23, DEVICE_INFO); put(23, DEVICE_INFO);
put(26, WORKOUT);
put(31, COURSE); put(31, COURSE);
put(49, FILE_CREATOR); put(49, FILE_CREATOR);
put(55, MONITORING); put(55, MONITORING);

View File

@ -37,6 +37,8 @@ public class FitRecordDataFactory {
return new FitEvent(recordDefinition, recordHeader); return new FitEvent(recordDefinition, recordHeader);
case 23: case 23:
return new FitDeviceInfo(recordDefinition, recordHeader); return new FitDeviceInfo(recordDefinition, recordHeader);
case 26:
return new FitWorkout(recordDefinition, recordHeader);
case 31: case 31:
return new FitCourse(recordDefinition, recordHeader); return new FitCourse(recordDefinition, recordHeader);
case 49: case 49:

View File

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