Garmin: extract buildExportPath helper in GarminUtils

Three call sites (FileTransferHandler, FitImporter, GarminSupport) each open-coded the same [TYPE]/[YEAR]/[TYPE]_[timestamp]_[suffix] filename shape and re-created their own SimpleDateFormat. Hoist the formatters (now thread-safe DateTimeFormatter) and the path builder into GarminUtils as buildExportPath(type, instant, suffix, ext).

FitImporter's null-type fallback now uses "NULL" consistently for both directory and filename, fixing a latent NPE.
This commit is contained in:
Ingvar Stepanyan
2026-06-06 21:26:51 +02:00
committed by José Rebelo
parent 5b375b2bb8
commit 1cfcd0772c
4 changed files with 101 additions and 79 deletions
@@ -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
@@ -16,7 +16,6 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
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<File> 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<File> 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<File> 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;
@@ -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;
}
@@ -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");
}
}