mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
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:
committed by
José Rebelo
parent
5b375b2bb8
commit
1cfcd0772c
+25
-31
@@ -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
|
||||
|
||||
+20
-21
@@ -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;
|
||||
|
||||
+49
@@ -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;
|
||||
}
|
||||
|
||||
+7
-27
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user