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