From 374959f340fb83a047bab3453c290f73e58e1be8 Mon Sep 17 00:00:00 2001 From: Dennis Guse Date: Tue, 19 Apr 2022 18:57:28 +0200 Subject: [PATCH] Add CSV as export format (TrackPoints only). Fixes #999. --- .../io/file/importer/ExportImportTest.java | 35 +++ src/androidTest/res/raw/csv_export.csv | 11 + .../opentracks/data/ShareContentProvider.java | 7 +- .../opentracks/io/file/TrackFileFormat.java | 18 ++ .../io/file/exporter/CSVTrackExporter.java | 202 ++++++++++++++++++ .../ImportExportSettingsFragment.java | 3 +- 6 files changed, 273 insertions(+), 3 deletions(-) create mode 100644 src/androidTest/res/raw/csv_export.csv create mode 100644 src/main/java/de/dennisguse/opentracks/io/file/exporter/CSVTrackExporter.java diff --git a/src/androidTest/java/de/dennisguse/opentracks/io/file/importer/ExportImportTest.java b/src/androidTest/java/de/dennisguse/opentracks/io/file/importer/ExportImportTest.java index 32f4d0071..3c369c958 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/io/file/importer/ExportImportTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/io/file/importer/ExportImportTest.java @@ -16,6 +16,7 @@ import androidx.preference.PreferenceManager; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.LargeTest; +import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.rule.GrantPermissionRule; import androidx.test.rule.ServiceTestRule; @@ -27,15 +28,19 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mockito; +import java.io.BufferedReader; import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; import de.dennisguse.opentracks.R; import de.dennisguse.opentracks.content.data.TestDataUtil; @@ -426,6 +431,36 @@ public class ExportImportTest { assertNull(trackImported); } + @LargeTest + @Test + public void csv_export_only() throws TimeoutException, IOException { + setUp(); + + // given + Track track = contentProviderUtils.getTrack(trackId); + + TrackExporter trackExporter = TrackFileFormat.CSV.createTrackExporter(context); + + // when + // 1. export + trackExporter.writeTrack(track, context.getContentResolver().openOutputStream(tmpFileUri)); + contentProviderUtils.deleteTrack(context, trackId); + + // then + InputStream expected = InstrumentationRegistry.getInstrumentation().getContext().getResources().openRawResource(de.dennisguse.opentracks.debug.test.R.raw.csv_export); + String expectedText = new BufferedReader(new InputStreamReader(expected, StandardCharsets.UTF_8)) + .lines() + .collect(Collectors.joining("\n")); + + + InputStream actual = context.getContentResolver().openInputStream(tmpFileUri); + String actualText = new BufferedReader(new InputStreamReader(actual, StandardCharsets.UTF_8)) + .lines() + .collect(Collectors.joining("\n")); + + assertEquals(expectedText, actualText); + } + private void assertMarkers() { assertEquals(markers.size(), contentProviderUtils.getMarkers(importTrackId).size()); diff --git a/src/androidTest/res/raw/csv_export.csv b/src/androidTest/res/raw/csv_export.csv new file mode 100644 index 000000000..4d99dd5d6 --- /dev/null +++ b/src/androidTest/res/raw/csv_export.csv @@ -0,0 +1,11 @@ +#time,trackpoint_type,latitude,longitude,altitude,accuracy_horizontal,speed,altitude_gain,altitude_loss,sensor_distance,heartrate,cadence,power +"2020-02-02T03:02:02+01:00","SEGMENT_START_MANUAL",,,,,,,,,,, +"2020-02-02T03:02:03+01:00","TRACKPOINT",3,3,10,10,54,1,1,,,, +"2020-02-02T03:02:04+01:00","SENSORPOINT",,,,,54,1,1,10,66,3,50 +"2020-02-02T03:02:05+01:00","TRACKPOINT",3,3,10,10,18,0,0,2,66,3,50 +"2020-02-02T03:02:06+01:00","SEGMENT_END_MANUAL",,,,,,,,,,, +"2020-02-02T03:02:20+01:00","SEGMENT_START_MANUAL",,,,,,,,,,, +"2020-02-02T03:02:21+01:00","TRACKPOINT",3,3,10,10,54,0,0,,,, +"2020-02-02T03:02:22+01:00","SEGMENT_START_AUTOMATIC",3,3,10,10,54,0,0,,,, +"2020-02-02T03:02:23+01:00","TRACKPOINT",3,3,10,10,54,0,0,,,, +"2020-02-02T03:02:24+01:00","SEGMENT_END_MANUAL",,,,,,,,,,, \ No newline at end of file diff --git a/src/main/java/de/dennisguse/opentracks/data/ShareContentProvider.java b/src/main/java/de/dennisguse/opentracks/data/ShareContentProvider.java index fd59b5707..e99f4aa39 100644 --- a/src/main/java/de/dennisguse/opentracks/data/ShareContentProvider.java +++ b/src/main/java/de/dennisguse/opentracks/data/ShareContentProvider.java @@ -53,8 +53,7 @@ public class ShareContentProvider extends CustomContentProvider { private static final int URI_KMZ_WITH_TRACKDETAIL_AND_SENSORDATA = 6; private static final int URI_KMZ_WITH_TRACKDETAIL_SENSORDATA_AND_PICTURES = 7; - - private static final int URI_SHARE_PICTURE = 8; + private static final int URI_CSV = 8; private static final UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH); private static final String TRACKID_DELIMITER = "_"; @@ -66,6 +65,7 @@ public class ShareContentProvider extends CustomContentProvider { uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TracksColumns.TABLE_NAME + "/" + TrackFileFormat.KMZ_WITH_TRACKDETAIL_AND_SENSORDATA.getName() + "/*/*", URI_KMZ_WITH_TRACKDETAIL_AND_SENSORDATA); uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TracksColumns.TABLE_NAME + "/" + TrackFileFormat.KMZ_WITH_TRACKDETAIL_AND_SENSORDATA_AND_PICTURES.getName() + "/*/*", URI_KMZ_WITH_TRACKDETAIL_SENSORDATA_AND_PICTURES); + uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TracksColumns.TABLE_NAME + "/" + TrackFileFormat.CSV.getName() + "/*/*", URI_CSV); } /** @@ -146,6 +146,9 @@ public class ShareContentProvider extends CustomContentProvider { case URI_KMZ_WITH_TRACKDETAIL_SENSORDATA_AND_PICTURES: return TrackFileFormat.KMZ_WITH_TRACKDETAIL_AND_SENSORDATA_AND_PICTURES; + case URI_CSV: + return TrackFileFormat.CSV; + default: throw new RuntimeException("Could not derive TrackFileFormat from Uri " + uri); } diff --git a/src/main/java/de/dennisguse/opentracks/io/file/TrackFileFormat.java b/src/main/java/de/dennisguse/opentracks/io/file/TrackFileFormat.java index 8a52cae79..607cfc984 100644 --- a/src/main/java/de/dennisguse/opentracks/io/file/TrackFileFormat.java +++ b/src/main/java/de/dennisguse/opentracks/io/file/TrackFileFormat.java @@ -6,6 +6,7 @@ import java.util.Locale; import de.dennisguse.opentracks.R; import de.dennisguse.opentracks.data.ContentProviderUtils; +import de.dennisguse.opentracks.io.file.exporter.CSVTrackExporter; import de.dennisguse.opentracks.io.file.exporter.GPXTrackExporter; import de.dennisguse.opentracks.io.file.exporter.KMLTrackExporter; import de.dennisguse.opentracks.io.file.exporter.KmzTrackExporter; @@ -100,6 +101,23 @@ public enum TrackFileFormat { public String getExtension() { return "gpx"; } + }, + + CSV { + @Override + public TrackExporter createTrackExporter(Context context) { + return new CSVTrackExporter(new ContentProviderUtils(context)); + } + + @Override + public String getMimeType() { + return "text/csv"; + } + + @Override + public String getExtension() { + return "csv"; + } }; private static final String MIME_KMZ = "application/vnd.google-earth.kmz"; diff --git a/src/main/java/de/dennisguse/opentracks/io/file/exporter/CSVTrackExporter.java b/src/main/java/de/dennisguse/opentracks/io/file/exporter/CSVTrackExporter.java new file mode 100644 index 000000000..c3e2c6346 --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/io/file/exporter/CSVTrackExporter.java @@ -0,0 +1,202 @@ +/* + * Copyright 2011 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +package de.dennisguse.opentracks.io.file.exporter; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import java.io.OutputStream; +import java.io.PrintWriter; +import java.text.NumberFormat; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.function.Function; + +import de.dennisguse.opentracks.data.ContentProviderUtils; +import de.dennisguse.opentracks.data.TrackPointIterator; +import de.dennisguse.opentracks.data.models.Marker; +import de.dennisguse.opentracks.data.models.Track; +import de.dennisguse.opentracks.data.models.TrackPoint; +import de.dennisguse.opentracks.util.StringUtils; + +/** + * Exports the {@link TrackPoint} into a CSV. + * * decimal separator: . + * * column separator: , + *

+ * NOTE: + * * {@link Track} data is not exported. + * * {@link Marker} data is not exported. + */ +public class CSVTrackExporter implements TrackExporter { + + private static final String TAG = CSVTrackExporter.class.getSimpleName(); + + private static final NumberFormat ALTITUDE_FORMAT = NumberFormat.getInstance(Locale.US); + private static final NumberFormat COORDINATE_FORMAT = NumberFormat.getInstance(Locale.US); + private static final NumberFormat SPEED_FORMAT = NumberFormat.getInstance(Locale.US); + private static final NumberFormat DISTANCE_FORMAT = NumberFormat.getInstance(Locale.US); + private static final NumberFormat HEARTRATE_FORMAT = NumberFormat.getInstance(Locale.US); + private static final NumberFormat CADENCE_FORMAT = NumberFormat.getInstance(Locale.US); + private static final NumberFormat POWER_FORMAT = NumberFormat.getInstance(Locale.US); + + static { + ALTITUDE_FORMAT.setMaximumFractionDigits(1); + ALTITUDE_FORMAT.setGroupingUsed(false); + + COORDINATE_FORMAT.setMaximumFractionDigits(6); + COORDINATE_FORMAT.setMaximumIntegerDigits(3); + COORDINATE_FORMAT.setGroupingUsed(false); + + SPEED_FORMAT.setMaximumFractionDigits(2); + SPEED_FORMAT.setGroupingUsed(false); + + HEARTRATE_FORMAT.setMaximumFractionDigits(0); + HEARTRATE_FORMAT.setGroupingUsed(false); + + CADENCE_FORMAT.setMaximumFractionDigits(0); + CADENCE_FORMAT.setGroupingUsed(false); + + POWER_FORMAT.setMaximumFractionDigits(0); + POWER_FORMAT.setGroupingUsed(false); + } + + private final ContentProviderUtils contentProviderUtils; + + private PrintWriter printWriter; + + public CSVTrackExporter(ContentProviderUtils contentProviderUtils) { + this.contentProviderUtils = contentProviderUtils; + } + + @Override + public boolean writeTrack(Track track, @NonNull OutputStream outputStream) { + return writeTrack(new Track[]{track}, outputStream); + } + + @Override + public boolean writeTrack(Track[] tracks, @NonNull OutputStream outputStream) { + List columns = Collections.unmodifiableList(Arrays.asList( + new Column("time", null), + new Column("trackpoint_type", t -> quote(t.getType().name())), + new Column("latitude", t -> t.hasLocation() ? COORDINATE_FORMAT.format(t.getLatitude()) : ""), + new Column("longitude", t -> t.hasLocation() ? COORDINATE_FORMAT.format(t.getLatitude()) : ""), + new Column("altitude", t -> t.hasAltitude() ? COORDINATE_FORMAT.format(t.getAltitude().toM()) : ""), + new Column("accuracy_horizontal", t -> t.hasHorizontalAccuracy() ? DISTANCE_FORMAT.format(t.getHorizontalAccuracy().toM()) : ""), + + new Column("speed", t -> t.hasSpeed() ? SPEED_FORMAT.format(t.getSpeed().toKMH()) : ""), + new Column("altitude_gain", t -> t.hasAltitudeGain() ? DISTANCE_FORMAT.format(t.getAltitudeGain()) : ""), + new Column("altitude_loss", t -> t.hasAltitudeLoss() ? DISTANCE_FORMAT.format(t.getAltitudeLoss()) : ""), + new Column("sensor_distance", t -> t.hasSensorDistance() ? DISTANCE_FORMAT.format(t.getSensorDistance().toM()) : ""), + new Column("heartrate", t -> t.hasHeartRate() ? HEARTRATE_FORMAT.format(t.getHeartRate().getBPM()) : ""), + new Column("cadence", t -> t.hasCadence() ? CADENCE_FORMAT.format(t.getCadence().getRPM()) : ""), + new Column("power", t -> t.hasPower() ? ALTITUDE_FORMAT.format(t.getPower().getW()) : "") + )); + + try { + prepare(outputStream); + + boolean headerWritten = false; + + for (Track track : tracks) { + columns.get(0).extractor = t -> quote(StringUtils.formatDateTimeIso8601(t.getTime(), track.getZoneOffset())); + + if (!headerWritten) { + writeHeader(columns); + headerWritten = true; + } + + writeTrackPoints(columns, track); + } + + close(); + + return true; + } catch (InterruptedException e) { + Log.e(TAG, "Thread interrupted", e); + return false; + } + } + + private void writeTrackPoints(List columns, Track track) throws InterruptedException { + try (TrackPointIterator trackPointIterator = contentProviderUtils.getTrackPointLocationIterator(track.getId(), null)) { + while (trackPointIterator.hasNext()) { + if (Thread.interrupted()) throw new InterruptedException(); + + TrackPoint trackPoint = trackPointIterator.next(); + + switch (trackPoint.getType()) { + case SEGMENT_START_MANUAL: + case SEGMENT_END_MANUAL: + case SEGMENT_START_AUTOMATIC: + case SENSORPOINT: + case TRACKPOINT: + writeTrackPoint(columns, trackPoint); + break; + default: + throw new RuntimeException("Exporting this TrackPoint type is not implemented: " + trackPoint.getType()); + } + } + } + } + + public void prepare(OutputStream outputStream) { + this.printWriter = new PrintWriter(outputStream); + } + + public void close() { + if (printWriter != null) { + printWriter.flush(); + printWriter = null; + } + } + + public void writeHeader(List columns) { + if (printWriter != null) { + String columnNames = columns.stream().map(c -> c.columnName) + .reduce((s, s2) -> s + "," + s2) + .orElseThrow(() -> new RuntimeException("No columns defined")); + printWriter.println("#" + columnNames); + } + } + + public void writeTrackPoint(List columns, TrackPoint trackPoint) { + if (printWriter != null) { + String columnNames = columns.stream().map(c -> c.extractor.apply(trackPoint)) + .reduce((s, s2) -> s + "," + s2) + .orElseThrow(() -> new RuntimeException("No columns defined")); + printWriter.println(columnNames); + } + } + + private static class Column { + final String columnName; + Function extractor; + + Column(String columnName, Function extractor) { + this.columnName = columnName; + this.extractor = extractor; + } + } + + private static String quote(String content) { + return '"' + content + '"'; + } +} diff --git a/src/main/java/de/dennisguse/opentracks/settings/ImportExportSettingsFragment.java b/src/main/java/de/dennisguse/opentracks/settings/ImportExportSettingsFragment.java index ed66fd03c..c76b65461 100644 --- a/src/main/java/de/dennisguse/opentracks/settings/ImportExportSettingsFragment.java +++ b/src/main/java/de/dennisguse/opentracks/settings/ImportExportSettingsFragment.java @@ -45,7 +45,8 @@ public class ImportExportSettingsFragment extends PreferenceFragmentCompat { TrackFileFormat.KMZ_WITH_TRACKDETAIL_AND_SENSORDATA_AND_PICTURES, TrackFileFormat.KMZ_WITH_TRACKDETAIL_AND_SENSORDATA, TrackFileFormat.KML_WITH_TRACKDETAIL_AND_SENSORDATA, - TrackFileFormat.GPX + TrackFileFormat.GPX, + TrackFileFormat.CSV }; String[] entries = new String[trackFileFormats.length]; String[] entryValues = new String[trackFileFormats.length];