diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/FileTransferHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/FileTransferHandler.java index 34b3e4e4fc..1e542ded3b 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/FileTransferHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/FileTransferHandler.java @@ -19,6 +19,7 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin; import android.content.Intent; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import org.slf4j.Logger; @@ -29,10 +30,8 @@ import java.io.File; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; -import java.text.SimpleDateFormat; import java.util.Date; import java.util.HashSet; -import java.util.Locale; import java.util.Set; import nodomain.freeyourgadget.gadgetbridge.GBApplication; @@ -209,7 +208,10 @@ public CreateFileMessage initiateUpload(byte[] fileAsByteArray, FileType.FILETYP final File parentFile = outputFile.getParentFile(); parentFile.mkdirs(); FileUtils.copyStreamToFile(new ByteArrayInputStream(currentlyDownloading.dataHolder.array()), outputFile); - outputFile.setLastModified(currentlyDownloading.directoryEntry.fileDate.getTime()); + final Date fileDate = currentlyDownloading.directoryEntry.fileDate; + if (fileDate != null) { + outputFile.setLastModified(fileDate.getTime()); + } } catch (final IOException e) { LOG.error("Failed to save file", e); return; // do not signal file as saved @@ -244,7 +246,13 @@ public CreateFileMessage initiateUpload(byte[] fileAsByteArray, FileType.FILETYP final int specificFlags = reader.readByte();//7 final int fileFlags = reader.readByte();//8 final int fileSize = reader.readInt();//12 - final Date fileDate = new Date(GarminTimeUtils.garminTimestampToJavaMillis(reader.readInt()));//16 + // Wire 0 is the watch's "no date" sentinel; surface + // it as null so {@link DirectoryEntry#fileDate} stays + // unambiguous (no real activity recorded at the + // Garmin epoch). + final int wireTimestamp = reader.readInt();//16 + final Date fileDate = wireTimestamp == 0 ? null + : new Date(GarminTimeUtils.garminTimestampToJavaMillis(wireTimestamp)); final DirectoryEntry directoryEntry = new DirectoryEntry(fileIndex, filetype, fileNumber, specificFlags, fileFlags, fileSize, fileDate); if (directoryEntry.filetype == null) { // discard unsupported files @@ -434,18 +442,19 @@ public CreateFileMessage initiateUpload(byte[] fileAsByteArray, FileType.FILETYP } public static class DirectoryEntry { - private static final SimpleDateFormat SDF_FULL = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ROOT); - private static final SimpleDateFormat SDF_YEAR = new SimpleDateFormat("yyyy", Locale.ROOT); - private final int fileIndex; private final FileType.FILETYPE filetype; private final int fileNumber; private final int specificFlags; private final int fileFlags; private final int fileSize; + /** Null for entries whose wire timestamp was the watch's + * "no date" sentinel (0). Drives the missing-date fallback + * shape in {@link #getOutputPath}. */ + @Nullable private final Date fileDate; - public DirectoryEntry(int fileIndex, FileType.FILETYPE filetype, int fileNumber, int specificFlags, int fileFlags, int fileSize, Date fileDate) { + public DirectoryEntry(int fileIndex, FileType.FILETYPE filetype, int fileNumber, int specificFlags, int fileFlags, int fileSize, @Nullable Date fileDate) { this.fileIndex = fileIndex; this.filetype = filetype; this.fileNumber = fileNumber; @@ -463,6 +472,7 @@ public CreateFileMessage initiateUpload(byte[] fileAsByteArray, FileType.FILETYP return filetype; } + @Nullable public Date getFileDate() { return fileDate; } @@ -474,34 +484,18 @@ public CreateFileMessage initiateUpload(byte[] fileAsByteArray, FileType.FILETYP /** * Builds the output path. * Format: [FILE_TYPE]/[YEAR]/[FILE_TYPE]_[yyyy-MM-dd_HH-mm-ss]_[INDEX].[fit/bin] + * (or [FILE_TYPE]/[FILE_TYPE]_[INDEX].[fit/bin] when the file has no valid date) */ public String getOutputPath() { - // [FILE_TYPE]/ - final StringBuilder sb = new StringBuilder(getFiletype().name()); - sb.append(File.separator); - - // If we have a valid date, place the file inside a folder for each year - // [YEAR]/ - if (fileDate.getTime() != GarminTimeUtils.GARMIN_TIME_EPOCH * 1000L) { - sb.append(SDF_YEAR.format(fileDate)); - sb.append(File.separator); - } - - // Finally, the filename - sb.append(getFileName()); - return sb.toString(); + return GarminUtils.buildExportPath(getFiletype(), fileDate, + String.valueOf(getFileIndex()), + getFiletype().isFitFile() ? "fit" : "bin"); } - /** - * [FILE_TYPE]_[yyyy-MM-dd_HH-mm-ss]_[INDEX].[fit/bin] - */ + /** Just the basename of {@link #getOutputPath()}. */ public String getFileName() { - final StringBuilder sb = new StringBuilder(getFiletype().name()); - if (fileDate.getTime() != GarminTimeUtils.GARMIN_TIME_EPOCH * 1000L) { - sb.append("_").append(SDF_FULL.format(fileDate)); - } - sb.append("_").append(getFileIndex()).append(getFiletype().isFitFile() ? ".fit" : ".bin"); - return sb.toString(); + final String path = getOutputPath(); + return path.substring(path.lastIndexOf(File.separator) + 1); } @NonNull 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 fc78ad599a..e1b1d36bad 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 @@ -16,7 +16,6 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin; -import android.annotation.SuppressLint; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothGatt; import android.bluetooth.BluetoothGattCharacteristic; @@ -46,7 +45,6 @@ import java.io.File; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; -import java.text.SimpleDateFormat; import java.time.Instant; import java.time.LocalTime; import java.time.ZoneId; @@ -1178,14 +1176,9 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC return true; } - // Legacy filename 1, before we had per-type/year folder - @SuppressLint("SimpleDateFormat") final SimpleDateFormat legacyDateFormat = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ROOT); - final StringBuilder sbLegacy1 = new StringBuilder(entry.getFiletype().name()); - if (entry.getFileDate().getTime() != GarminTimeUtils.GARMIN_TIME_EPOCH * 1000L) { - sbLegacy1.append("_").append(legacyDateFormat.format(entry.getFileDate())); - } - sbLegacy1.append("_").append(entry.getFileIndex()).append(entry.getFiletype().isFitFile() ? ".fit" : ".bin"); - final String legacyName1 = sbLegacy1.toString(); + // Legacy filename 1, before we had per-type/year folder. + // Same shape as DirectoryEntry's basename. + final String legacyName1 = entry.getFileName(); final Optional legacyFile1 = getFile(legacyName1); if (legacyFile1.isPresent()) { if (legacyFile1.get().length() == 0) { @@ -1195,18 +1188,24 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC return true; } - // Legacy filename 2 - final String legacyName2 = entry.getFiletype().name() + "_" + - entry.getFileIndex() + "_" + - legacyDateFormat.format(entry.getFileDate()) + - (entry.getFiletype().isFitFile() ? ".fit" : ".bin"); - final Optional legacyFile2 = getFile(legacyName2); - if (legacyFile2.isPresent()) { - if (legacyFile2.get().length() == 0) { - LOG.warn("Legacy file 2 {} is empty", legacyName2); - return false; + // Legacy filename 2: [TYPE]_[INDEX]_[timestamp].[fit/bin] + // (timestamp segment swapped vs filename 1). Skipped when the + // entry has no date — there's no plausible legacy file to + // match against without a timestamp segment. + if (entry.getFileDate() != null) { + final String legacyTimestamp = GarminUtils.FILENAME_TIMESTAMP_FORMAT.format(entry.getFileDate().toInstant()); + final String legacyName2 = entry.getFiletype().name() + "_" + + entry.getFileIndex() + "_" + + legacyTimestamp + + (entry.getFiletype().isFitFile() ? ".fit" : ".bin"); + final Optional legacyFile2 = getFile(legacyName2); + if (legacyFile2.isPresent()) { + if (legacyFile2.get().length() == 0) { + LOG.warn("Legacy file 2 {} is empty", legacyName2); + return false; + } + return true; } - return true; } return false; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminUtils.java index 7895039a1c..e50cba687d 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminUtils.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminUtils.java @@ -3,6 +3,14 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin; import android.location.Location; import android.os.Build; +import androidx.annotation.Nullable; + +import java.io.File; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Date; +import java.util.Locale; + import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiCore; public final class GarminUtils { @@ -13,6 +21,47 @@ public final class GarminUtils { /** Watch lat/lon resolution: 180° spans the signed-32-bit range, so 1 semicircle = 180/2^31 degrees. */ public static final double SEMICIRCLE_DEGREES = 180.0D / 0x80000000L; + /** Sortable, locale-independent timestamp embedded in Garmin file/GPX names. */ + public static final DateTimeFormatter FILENAME_TIMESTAMP_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss", Locale.ROOT) + .withZone(ZoneId.systemDefault()); + + /** Year subdirectory used in the device's writable export tree. */ + public static final DateTimeFormatter FILENAME_YEAR_FORMAT = + DateTimeFormatter.ofPattern("yyyy", Locale.ROOT) + .withZone(ZoneId.systemDefault()); + + /** + * Build the standard Garmin export path: + * {@code [TYPE]/[yyyy]/[TYPE]_[yyyy-MM-dd_HH-mm-ss]_[suffix].[extension]}. + * Both the year subfolder and the timestamp body are skipped when + * {@code date} is null, yielding the missing-date fallback shape + * {@code [TYPE]/[TYPE]_[suffix].[extension]}. + * Suffix is opaque — callers pass the FIT file index, our UUID + * hex prefix, etc.; pass {@code ""} to omit the segment entirely. + */ + public static String buildExportPath(@Nullable final FileType.FILETYPE type, @Nullable final Date date, + final String suffix, final String extension) { + // Type can be null when a FIT file's file_id.type field is missing + // or unrecognised; fall back to a fixed placeholder so both the + // directory and filename segments stay consistent. + final String typeName = type != null ? type.name() : "NULL"; + final StringBuilder sb = new StringBuilder(); + sb.append(typeName).append(File.separator); + if (date != null) { + sb.append(FILENAME_YEAR_FORMAT.format(date.toInstant())).append(File.separator); + } + sb.append(typeName); + if (date != null) { + sb.append('_').append(FILENAME_TIMESTAMP_FORMAT.format(date.toInstant())); + } + if (!suffix.isEmpty()) { + sb.append('_').append(suffix); + } + sb.append('.').append(extension); + return sb.toString(); + } + public static double semicirclesToDegrees(final int semicircles) { return semicircles * SEMICIRCLE_DEGREES; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitImporter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitImporter.java index 3361212541..84f66a99f3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitImporter.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitImporter.java @@ -99,6 +99,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.FitActivityTrackProvider; import nodomain.freeyourgadget.gadgetbridge.model.MetricSample; import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.FileType; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminUtils; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.exception.FitParseException; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionHrvStatus; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionSleepStage; @@ -951,32 +952,11 @@ public class FitImporter { } 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(File.separator); - - // 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.separator); - } - - // [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(); + // FitFileId.getTimeCreated() is a boxed Long because the FIT + // schema marks the field optional; null-check before unboxing. + final Date created = fileId.getTimeCreated() != null + ? new Date(fileId.getTimeCreated() * 1000L) + : null; + return GarminUtils.buildExportPath(fileId.getType(), created, "", "fit"); } }