From 0d3cf4ce0d56ec756f1a3b1ff19cbef6cf95275d Mon Sep 17 00:00:00 2001 From: Dennis Guse Date: Tue, 3 May 2022 20:06:29 +0200 Subject: [PATCH] Configurable filename for export: used for all automatic exports after recording, exporting all, and sharing tracks. Fixes #512. --- .gitignore | 1 - .../io/file/TrackFilenameGeneratorTest.java | 60 ++++++++++ .../io/file/TrackFilenameGeneratorTest2.java | 44 ++++++++ .../io/file/TrackFilenameGenerator.java | 105 ++++++++++++++++++ .../ImportExportSettingsFragment.java | 20 ++++ .../opentracks/settings/PreferencesUtils.java | 11 ++ .../opentracks/share/ShareUtils.java | 7 +- .../opentracks/util/ExportUtils.java | 6 +- src/main/res/values/settings.xml | 3 + src/main/res/values/strings.xml | 1 + src/main/res/xml/settings_import_export.xml | 4 + 11 files changed, 253 insertions(+), 9 deletions(-) create mode 100644 src/androidTest/java/de/dennisguse/opentracks/io/file/TrackFilenameGeneratorTest.java create mode 100644 src/androidTest/java/de/dennisguse/opentracks/io/file/TrackFilenameGeneratorTest2.java create mode 100644 src/main/java/de/dennisguse/opentracks/io/file/TrackFilenameGenerator.java diff --git a/.gitignore b/.gitignore index 6a84c6d12..503308ed9 100644 --- a/.gitignore +++ b/.gitignore @@ -60,7 +60,6 @@ captures/ .idea/encodings.xml .idea/jsLibraryMappings.xml .idea/libraries/ -.idea/misc.xml .idea/modules.xml .idea/scopes/scope_settings.xml .idea/shelf/ diff --git a/src/androidTest/java/de/dennisguse/opentracks/io/file/TrackFilenameGeneratorTest.java b/src/androidTest/java/de/dennisguse/opentracks/io/file/TrackFilenameGeneratorTest.java new file mode 100644 index 000000000..b7dc5fbae --- /dev/null +++ b/src/androidTest/java/de/dennisguse/opentracks/io/file/TrackFilenameGeneratorTest.java @@ -0,0 +1,60 @@ +package de.dennisguse.opentracks.io.file; + +import static org.junit.Assert.assertEquals; + +import androidx.test.core.app.ApplicationProvider; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collection; +import java.util.TimeZone; +import java.util.UUID; + +import de.dennisguse.opentracks.R; +import de.dennisguse.opentracks.TimezoneRule; +import de.dennisguse.opentracks.data.models.Track; + +@RunWith(Parameterized.class) +public class TrackFilenameGeneratorTest { + + @Rule + public TimezoneRule timezoneRule = new TimezoneRule(TimeZone.getTimeZone("Europe/Berlin")); + + @Parameterized.Parameters + public static Collection data() { + return Arrays.asList(new String[][]{ + {"{uuid}_{name}", "0000fee0_Best Track.gpx" }, + {"{name}_{uuid}", "Best Track_0000fee0.gpx" }, + {"{time}_{name}", "2020-02-02T02_02_02Z_Best Track.gpx" }, + {ApplicationProvider.getApplicationContext().getString(R.string.export_filename_format_default), "2020-02-02T02_02_02Z_Best Track.gpx" }, + }); + } + + private final TrackFilenameGenerator subject; + private final String expected; + + public TrackFilenameGeneratorTest(String template, String expected) { + this.subject = new TrackFilenameGenerator(template); + this.expected = expected; + } + + @Test + public void testFilenameTemplate() { + // given + Track track = new Track(); + track.setName("Best Track"); + track.setUuid(UUID.fromString("0000fee0-0000-1000-8000-00805f9b34fb")); + track.getTrackStatistics().setStartTime(Instant.parse("2020-02-02T02:02:02Z")); + + // when + String filename = subject.format(track, TrackFileFormat.GPX); + + // then + assertEquals(expected, filename); + } +} \ No newline at end of file diff --git a/src/androidTest/java/de/dennisguse/opentracks/io/file/TrackFilenameGeneratorTest2.java b/src/androidTest/java/de/dennisguse/opentracks/io/file/TrackFilenameGeneratorTest2.java new file mode 100644 index 000000000..47ba0f256 --- /dev/null +++ b/src/androidTest/java/de/dennisguse/opentracks/io/file/TrackFilenameGeneratorTest2.java @@ -0,0 +1,44 @@ +package de.dennisguse.opentracks.io.file; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collection; +import java.util.UUID; + +import de.dennisguse.opentracks.data.models.Track; + +//TODO Merge with TrackFilenameGeneratorTest whenever Junit5 gets available. +//https://github.com/android/android-test/issues/224 +@RunWith(Parameterized.class) +public class TrackFilenameGeneratorTest2 { + + @Parameterized.Parameters + public static Collection data() { + return Arrays.asList( + "{name}_{starime}", + "{name", + "name}"); + } + + private final TrackFilenameGenerator subject; + + public TrackFilenameGeneratorTest2(String template) { + this.subject = new TrackFilenameGenerator(template); + } + + @Test(expected = TrackFilenameGenerator.TemplateInvalidException.class) + public void testFilenameTemplate() { + // given + Track track = new Track(); + track.setName("Best Track"); + track.setUuid(UUID.fromString("0000fee0-0000-1000-8000-00805f9b34fb")); + track.getTrackStatistics().setStartTime(Instant.parse("2020-02-02T02:02:02Z")); + + // when + String filename = subject.format(track, TrackFileFormat.GPX); + } +} \ No newline at end of file diff --git a/src/main/java/de/dennisguse/opentracks/io/file/TrackFilenameGenerator.java b/src/main/java/de/dennisguse/opentracks/io/file/TrackFilenameGenerator.java new file mode 100644 index 000000000..91a2baf7e --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/io/file/TrackFilenameGenerator.java @@ -0,0 +1,105 @@ +package de.dennisguse.opentracks.io.file; + +import androidx.annotation.NonNull; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import de.dennisguse.opentracks.data.models.Track; +import de.dennisguse.opentracks.util.FileUtils; + +public class TrackFilenameGenerator { + + public static final String UUID_KEY = "{uuid}"; + public static final String TRACKNAME_KEY = "{name}"; + public static final String CATEGORY_KEY = "{category}"; + public static final String STARTTIME_KEY = "{time}"; + + public static String getAllOptions() { + return Stream.of(UUID_KEY, TRACKNAME_KEY, CATEGORY_KEY, STARTTIME_KEY) + .collect(Collectors.joining(", ")); + } + + private final String template; + + public TrackFilenameGenerator(@NonNull String template) { + this.template = template; + } + + public String format(@NonNull Track track, @NonNull TrackFileFormat trackFileFormat) { + Map values = new HashMap<>(); + + values.put(UUID_KEY, track.getUuid().toString().substring(0, 8)); + values.put(TRACKNAME_KEY, track.getName()); + values.put(CATEGORY_KEY, track.getCategory()); + values.put(STARTTIME_KEY, track.getStartTime().toString()); + + return FileUtils.sanitizeFileName(format(template, values)) + "." + trackFileFormat.getExtension(); + } + + private static String format(String template, Map values) { + StringBuilder templateCompiler = new StringBuilder(template); + List valueList = new ArrayList<>(); + + Matcher keyMatcher = Pattern + .compile("\\{(\\w+)\\}") + .matcher(template); + + while (keyMatcher.find()) { + String key = keyMatcher.group(); + + if (!values.containsKey(key)) { + throw new TemplateInvalidException(key); + } + + int index = templateCompiler.indexOf(key); + if (index != -1) { + templateCompiler.replace(index, index + key.length(), "%s"); + valueList.add(values.get(key)); + } + } + + String templateCompiled = templateCompiler.toString(); + if (templateCompiled.contains("{") || templateCompiled.contains("}")) { + throw new TemplateInvalidException(template); + } + + return String.format(templateCompiled, valueList.toArray()); + } + + public String getTemplate() { + return template; + } + + public boolean isValid() { + try { + getExample(); + return !template.isEmpty(); + } catch (TemplateInvalidException e) { + return false; + } + } + + public String getExample() { + Track track = new Track(); + track.setName("Berlin"); + track.setUuid(UUID.fromString("fefefefefe-0000-1000-8000-00805f9b34fb")); + track.getTrackStatistics().setStartTime(Instant.ofEpochMilli(0)); + + return format(track, TrackFileFormat.GPX); + } + + public static class TemplateInvalidException extends RuntimeException { + public TemplateInvalidException(String invalidTemplate) { + super(invalidTemplate); + } + } +} diff --git a/src/main/java/de/dennisguse/opentracks/settings/ImportExportSettingsFragment.java b/src/main/java/de/dennisguse/opentracks/settings/ImportExportSettingsFragment.java index c76b65461..9122264fe 100644 --- a/src/main/java/de/dennisguse/opentracks/settings/ImportExportSettingsFragment.java +++ b/src/main/java/de/dennisguse/opentracks/settings/ImportExportSettingsFragment.java @@ -4,6 +4,7 @@ import android.net.Uri; import android.os.Bundle; import androidx.documentfile.provider.DocumentFile; +import androidx.preference.EditTextPreference; import androidx.preference.ListPreference; import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; @@ -12,16 +13,20 @@ import java.util.Locale; import de.dennisguse.opentracks.R; import de.dennisguse.opentracks.io.file.TrackFileFormat; +import de.dennisguse.opentracks.io.file.TrackFilenameGenerator; import de.dennisguse.opentracks.util.IntentUtils; public class ImportExportSettingsFragment extends PreferenceFragmentCompat { + private static final String TAG = ImportExportSettingsFragment.class.getSimpleName(); + @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { addPreferencesFromResource(R.xml.settings_import_export); setExportTrackFileFormatOptions(); setExportDirectorySummary(); + setFilenameTemplate(); } @Override @@ -77,4 +82,19 @@ public class ImportExportSettingsFragment extends PreferenceFragmentCompat { return directoryUri + (directory.canWrite() ? "" : getString(R.string.export_dir_not_writable)); }); } + + private void setFilenameTemplate() { + EditTextPreference preference = findPreference(getString(R.string.export_filename_format_key)); + preference.setOnBindEditTextListener(t -> { + t.setHint(getString(R.string.export_filename_format_default)); + }); + preference.setDialogMessage(TrackFilenameGenerator.getAllOptions()); + + preference.setOnPreferenceChangeListener((p, newValue) -> new TrackFilenameGenerator(newValue.toString()).isValid()); + + preference.setSummaryProvider(p -> + PreferencesUtils.getTrackFileformatGenerator() + .getExample() + ); + } } diff --git a/src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java b/src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java index 4d6d1e1c1..f67d365fa 100644 --- a/src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java +++ b/src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java @@ -44,6 +44,7 @@ import de.dennisguse.opentracks.data.models.Distance; import de.dennisguse.opentracks.data.models.DistanceFormatter; import de.dennisguse.opentracks.data.models.Speed; import de.dennisguse.opentracks.io.file.TrackFileFormat; +import de.dennisguse.opentracks.io.file.TrackFilenameGenerator; import de.dennisguse.opentracks.ui.customRecordingLayout.CsvLayoutUtils; import de.dennisguse.opentracks.ui.customRecordingLayout.Layout; import de.dennisguse.opentracks.util.TrackIconUtils; @@ -573,6 +574,16 @@ public class PreferencesUtils { return getBoolean(R.string.post_workout_export_enabled_key, INSTANT_POST_WORKOUT_EXPORT_DEFAULT) && isDefaultExportDirectoryUri(); } + public static TrackFilenameGenerator getTrackFileformatGenerator() { + String DEFAULT = getString(R.string.export_filename_format_default, null); + TrackFilenameGenerator generator = new TrackFilenameGenerator(getString(R.string.export_filename_format_key, DEFAULT)); + if (generator.isValid()) { + return generator; + } else { + return new TrackFilenameGenerator(DEFAULT); + } + } + public static TrackFileFormat getExportTrackFileFormat() { final String TRACKFILEFORMAT_NAME_DEFAULT = getString(R.string.export_trackfileformat_default, null); String trackFileFormatName = getString(R.string.export_trackfileformat_key, TRACKFILEFORMAT_NAME_DEFAULT); diff --git a/src/main/java/de/dennisguse/opentracks/share/ShareUtils.java b/src/main/java/de/dennisguse/opentracks/share/ShareUtils.java index 8cf4a62e3..ebb7abf23 100644 --- a/src/main/java/de/dennisguse/opentracks/share/ShareUtils.java +++ b/src/main/java/de/dennisguse/opentracks/share/ShareUtils.java @@ -15,8 +15,8 @@ import de.dennisguse.opentracks.data.ContentProviderUtils; import de.dennisguse.opentracks.data.ShareContentProvider; import de.dennisguse.opentracks.data.models.Marker; import de.dennisguse.opentracks.data.models.Track; +import de.dennisguse.opentracks.io.file.TrackFileFormat; import de.dennisguse.opentracks.settings.PreferencesUtils; -import de.dennisguse.opentracks.util.FileUtils; public class ShareUtils { @@ -54,8 +54,9 @@ public class ShareUtils { continue; } - String trackName = FileUtils.sanitizeFileName(track.getName()); - Pair uriAndMime = ShareContentProvider.createURI(trackId, trackName, PreferencesUtils.getExportTrackFileFormat()); + TrackFileFormat format = PreferencesUtils.getExportTrackFileFormat(); + String trackName = PreferencesUtils.getTrackFileformatGenerator().format(track, format); + Pair uriAndMime = ShareContentProvider.createURI(trackId, trackName, format); uris.add(uriAndMime.first); mime = uriAndMime.second; diff --git a/src/main/java/de/dennisguse/opentracks/util/ExportUtils.java b/src/main/java/de/dennisguse/opentracks/util/ExportUtils.java index f6a07e200..6a87701cf 100644 --- a/src/main/java/de/dennisguse/opentracks/util/ExportUtils.java +++ b/src/main/java/de/dennisguse/opentracks/util/ExportUtils.java @@ -108,7 +108,7 @@ public class ExportUtils { } private static Uri getExportDocumentFileUri(Context context, Track track, TrackFileFormat trackFileFormat, DocumentFile directory) { - String exportFileName = getExportFileNameForTrack(track, trackFileFormat.getExtension()); + String exportFileName = PreferencesUtils.getTrackFileformatGenerator().format(track, trackFileFormat); Uri exportDocumentFileUri = findFile(context, directory.getUri(), exportFileName); if (exportDocumentFileUri == null) { final DocumentFile file = directory.createFile(trackFileFormat.getMimeType(), exportFileName); @@ -119,10 +119,6 @@ public class ExportUtils { return exportDocumentFileUri; } - private static String getExportFileNameForTrack(Track track, String trackFileFormatExtension) { - return track.getUuid().toString().substring(0, 8) + "_" + FileUtils.sanitizeFileName(track.getName()) + "." + trackFileFormatExtension; - } - private static Uri findFile(Context context, Uri directoryUri, String exportFileName) { final ContentResolver resolver = context.getContentResolver(); final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(directoryUri, DocumentsContract.getDocumentId(directoryUri)); diff --git a/src/main/res/values/settings.xml b/src/main/res/values/settings.xml index 222a41864..cda15f4b0 100644 --- a/src/main/res/values/settings.xml +++ b/src/main/res/values/settings.xml @@ -48,6 +48,9 @@ instantExportEnabled false + instantExportFilename + {time}_{name} + settingsImport settingsExport diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index e6c6d745b..60dfce470 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -371,6 +371,7 @@ limitations under the License. Advanced Export/sharing file format + Format of the filename By distance By time diff --git a/src/main/res/xml/settings_import_export.xml b/src/main/res/xml/settings_import_export.xml index 88dd01ede..598816609 100644 --- a/src/main/res/xml/settings_import_export.xml +++ b/src/main/res/xml/settings_import_export.xml @@ -43,6 +43,10 @@ android:key="@string/export_trackfileformat_key" android:title="@string/settings_default_trackfileformat" app:useSimpleSummaryProvider="true" /> + \ No newline at end of file