Aggregate statistics are now computed in the database.

This commit is contained in:
Dennis Guse
2025-11-05 20:10:42 +01:00
parent ca64f2c749
commit 221b61c7f1
12 changed files with 197 additions and 304 deletions
@@ -1,12 +1,12 @@
package de.dennisguse.opentracks.ui.aggregatedStatistics; package de.dennisguse.opentracks.ui.aggregatedStatistics;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import android.content.Context; import android.content.Context;
import androidx.test.core.app.ApplicationProvider; import androidx.test.core.app.ApplicationProvider;
import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.junit.runners.JUnit4; import org.junit.runners.JUnit4;
@@ -16,6 +16,9 @@ import java.time.Instant;
import java.util.List; import java.util.List;
import de.dennisguse.opentracks.R; import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.AggregatedStatistic;
import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.data.TrackSelection;
import de.dennisguse.opentracks.data.models.ActivityType; import de.dennisguse.opentracks.data.models.ActivityType;
import de.dennisguse.opentracks.data.models.AltitudeExtremities; import de.dennisguse.opentracks.data.models.AltitudeExtremities;
import de.dennisguse.opentracks.data.models.AltitudeGainLoss; import de.dennisguse.opentracks.data.models.AltitudeGainLoss;
@@ -29,7 +32,7 @@ public class AggregatedStatisticsTest {
private final Context context = ApplicationProvider.getApplicationContext(); private final Context context = ApplicationProvider.getApplicationContext();
private static Track createTrack(Context context, Distance totalDistance, Duration totalTime, String activityTypeLocalized) { private static void createTrack(Context context, Distance totalDistance, Duration totalTime, String activityTypeLocalized) {
Track track = new Track(new Statistics( Track track = new Track(new Statistics(
Instant.ofEpochMilli(1000), Instant.ofEpochMilli(1000),
Instant.ofEpochMilli(1000).plus(totalTime), Instant.ofEpochMilli(1000).plus(totalTime),
@@ -44,199 +47,128 @@ public class AggregatedStatisticsTest {
)); ));
track.setActivityType(ActivityType.findByLocalizedString(context, activityTypeLocalized)); track.setActivityType(ActivityType.findByLocalizedString(context, activityTypeLocalized));
track.setActivityTypeLocalized(activityTypeLocalized); track.setActivityTypeLocalized(activityTypeLocalized);
return track;
new ContentProviderUtils(context).insertTrack(track);
}
@Before
public void setUp() {
new ContentProviderUtils(context).deleteAllTracks(context);
} }
@Test @Test
public void testAggregate() { public void testAggregate_empty() {
// when
List<AggregatedStatistic> result = new ContentProviderUtils(context).getAggregatedStatisticsForTracks(new TrackSelection());
// then
assertEquals(List.of(), result);
}
@Test
public void testAggregate_oneBikingTrack() {
// given // given
// 10km in 40 minutes. Distance totalDistance = Distance.ofKilometer(10);
Distance totalDistance = Distance.of(10000); Duration totalTime = Duration.ofMinutes(40);
Duration totalTime = Duration.ofMillis(2400000);
String biking = context.getString(R.string.activity_type_biking); String biking = context.getString(R.string.activity_type_biking);
Track track = createTrack(context, totalDistance, totalTime, biking);
createTrack(context, totalDistance, totalTime, biking);
// when // when
AggregatedStatistics aggregatedStatistics = new AggregatedStatistics(List.of(track)); List<AggregatedStatistic> result = new ContentProviderUtils(context).getAggregatedStatisticsForTracks(new TrackSelection());
// then // then
assertEquals(1, aggregatedStatistics.getCount()); assertEquals(List.of(
assertNotNull(aggregatedStatistics.get(biking)); new AggregatedStatistic("biking", 1, Duration.ofMinutes(40), Distance.ofKilometer(10), Speed.of(50))
assertEquals(1, aggregatedStatistics.get(biking).getCountTracks()); ), result);
assertEquals(totalDistance, aggregatedStatistics.get(biking).totalDistance);
assertEquals(totalTime, aggregatedStatistics.get(biking).totalMovingTime);
}
@Test
public void testAggregate_mountainBiking() {
// given
// 10km in 40 minutes.
Distance totalDistance = Distance.of(10000);
Duration totalTime = Duration.ofMillis(2400000);
String mountainBiking = context.getString(R.string.activity_type_mountain_biking);
Track track = createTrack(context, totalDistance, totalTime, mountainBiking);
// when
AggregatedStatistics aggregatedStatistics = new AggregatedStatistics(List.of(track));
// then
assertNotNull(aggregatedStatistics.get(mountainBiking));
}
@Test
public void testAggregate_trailRunning() {
// given
// 10km in 40 minutes.
Distance totalDistance = Distance.of(10000);
Duration totalTime = Duration.ofMillis(2400000);
String trailRunning = context.getString(R.string.activity_type_trail_running);
Track track = createTrack(context, totalDistance, totalTime, trailRunning);
// when
AggregatedStatistics aggregatedStatistics = new AggregatedStatistics(List.of(track));
// then
assertNotNull(aggregatedStatistics.get(trailRunning));
} }
@Test @Test
public void testAggregate_twoBikingTracks() { public void testAggregate_twoBikingTracks() {
// given // given
// 10km in 40 minutes. Distance totalDistance = Distance.ofKilometer(10);
Distance totalDistance = Distance.of(10000); Duration totalTime = Duration.ofMinutes(40);
Duration totalTime = Duration.ofMillis(2400000);
String biking = context.getString(R.string.activity_type_biking); String biking = context.getString(R.string.activity_type_biking);
List<Track> tracks = List.of(createTrack(context, totalDistance, totalTime, biking), createTrack(context, totalDistance, totalTime, biking));
createTrack(context, totalDistance, totalTime, biking);
createTrack(context, totalDistance, totalTime, biking);
// when // when
AggregatedStatistics aggregatedStatistics = new AggregatedStatistics(tracks); List<AggregatedStatistic> result = new ContentProviderUtils(context).getAggregatedStatisticsForTracks(new TrackSelection());
// then // then
assertEquals(1, aggregatedStatistics.getCount()); assertEquals(List.of(
assertNotNull(aggregatedStatistics.get(biking)); new AggregatedStatistic("biking", 2, Duration.ofMinutes(80), Distance.ofKilometer(20), Speed.of(50))
assertEquals(2, aggregatedStatistics.get(biking).getCountTracks()); ), result);
assertEquals(totalDistance.multipliedBy(2), aggregatedStatistics.get(biking).totalDistance);
assertEquals(totalTime.multipliedBy(2), aggregatedStatistics.get(biking).totalMovingTime);
}
@Test
public void testAggregate_threeDifferentTracks() {
// given
// 10km in 40 minutes.
Distance totalDistance = Distance.of(10000);
String biking = context.getString(R.string.activity_type_biking);
String running = context.getString(R.string.activity_type_running);
String walking = context.getString(R.string.activity_type_walking);
Duration totalTime = Duration.ofMillis(2400000);
List<Track> tracks = List.of(
createTrack(context, totalDistance, totalTime, biking),
createTrack(context, totalDistance, totalTime, running),
createTrack(context, totalDistance, totalTime, walking)
);
// when
AggregatedStatistics aggregatedStatistics = new AggregatedStatistics(tracks);
// then
assertEquals(3, aggregatedStatistics.getCount());
assertNotNull(aggregatedStatistics.get(biking));
assertNotNull(aggregatedStatistics.get(running));
assertNotNull(aggregatedStatistics.get(walking));
assertEquals(1, aggregatedStatistics.get(biking).getCountTracks());
assertEquals(1, aggregatedStatistics.get(running).getCountTracks());
assertEquals(1, aggregatedStatistics.get(walking).getCountTracks());
{
assertEquals(totalDistance, aggregatedStatistics.get(biking).totalDistance);
assertEquals(totalTime, aggregatedStatistics.get(biking).totalMovingTime);
}
{
assertEquals(totalDistance, aggregatedStatistics.get(running).totalDistance);
assertEquals(totalTime, aggregatedStatistics.get(running).totalMovingTime);
}
{
assertEquals(totalDistance, aggregatedStatistics.get(walking).totalDistance);
assertEquals(totalTime, aggregatedStatistics.get(walking).totalMovingTime);
}
} }
@Test @Test
public void testAggregate_severalTracksWithSeveralActivities() { public void testAggregate_severalTracksWithSeveralActivities() {
// given // given
// 10km in 40 minutes. Distance totalDistance = Distance.ofKilometer(10);
Distance totalDistance = Distance.of(10000); Duration totalTime = Duration.ofMinutes(40);
Duration totalTime = Duration.ofMillis(2400000);
String biking = context.getString(R.string.activity_type_biking); String biking = context.getString(R.string.activity_type_biking);
String running = context.getString(R.string.activity_type_running); String running = context.getString(R.string.activity_type_running);
String walking = context.getString(R.string.activity_type_walking); String walking = context.getString(R.string.activity_type_walking);
String driving = context.getString(R.string.activity_type_driving); String driving = context.getString(R.string.activity_type_driving);
List<Track> tracks = List.of(
createTrack(context, totalDistance, totalTime, biking),
createTrack(context, totalDistance, totalTime, running),
createTrack(context, totalDistance, totalTime, walking),
createTrack(context, totalDistance, totalTime, biking),
createTrack(context, totalDistance, totalTime, running),
createTrack(context, totalDistance, totalTime, walking),
createTrack(context, totalDistance, totalTime, biking),
createTrack(context, totalDistance, totalTime, biking),
createTrack(context, totalDistance, totalTime, biking),
createTrack(context, totalDistance, totalTime, driving)
);
createTrack(context, totalDistance, totalTime, biking);
createTrack(context, totalDistance, totalTime, running);
createTrack(context, totalDistance, totalTime, walking);
createTrack(context, totalDistance, totalTime, biking);
createTrack(context, totalDistance, totalTime, running);
createTrack(context, totalDistance, totalTime, walking);
createTrack(context, totalDistance, totalTime, biking);
createTrack(context, totalDistance, totalTime, biking);
createTrack(context, totalDistance, totalTime, biking);
createTrack(context, totalDistance, totalTime, driving);
// when // when
AggregatedStatistics aggregatedStatistics = new AggregatedStatistics(tracks); List<AggregatedStatistic> result = new ContentProviderUtils(context).getAggregatedStatisticsForTracks(new TrackSelection());
// then // then
// 4 sports. assertEquals(List.of(
assertEquals(4, aggregatedStatistics.getCount()); new AggregatedStatistic("biking", 5, Duration.ofMinutes(200), Distance.ofKilometer(50), Speed.of(50)),
new AggregatedStatistic("walking", 2, Duration.ofMinutes(80), Distance.ofKilometer(20), Speed.of(50)),
new AggregatedStatistic("running", 2, Duration.ofMinutes(80), Distance.ofKilometer(20), Speed.of(50)),
new AggregatedStatistic("driving", 1, Duration.ofMinutes(40), Distance.ofKilometer(10), Speed.of(50))
), result);
}
@Test
public void testAggregate_severalTracksWithSeveralActivities_TrackSelection() {
// given
Distance totalDistance = Distance.ofKilometer(10);
Duration totalTime = Duration.ofMinutes(40);
// There is a map for every sport. String biking = context.getString(R.string.activity_type_biking);
assertNotNull(aggregatedStatistics.get(biking)); String running = context.getString(R.string.activity_type_running);
assertNotNull(aggregatedStatistics.get(running)); String walking = context.getString(R.string.activity_type_walking);
assertNotNull(aggregatedStatistics.get(walking)); String driving = context.getString(R.string.activity_type_driving);
assertNotNull(aggregatedStatistics.get(driving));
// Number of tracks by sport. createTrack(context, totalDistance, totalTime, biking);
assertEquals(5, aggregatedStatistics.get(biking).getCountTracks()); // Biking. createTrack(context, totalDistance, totalTime, running);
assertEquals(2, aggregatedStatistics.get(running).getCountTracks()); // Running. createTrack(context, totalDistance, totalTime, walking);
assertEquals(2, aggregatedStatistics.get(walking).getCountTracks()); // Walking. createTrack(context, totalDistance, totalTime, biking);
assertEquals(1, aggregatedStatistics.get(driving).getCountTracks()); // Driving. createTrack(context, totalDistance, totalTime, running);
createTrack(context, totalDistance, totalTime, walking);
createTrack(context, totalDistance, totalTime, biking);
createTrack(context, totalDistance, totalTime, biking);
createTrack(context, totalDistance, totalTime, biking);
createTrack(context, totalDistance, totalTime, driving);
// Biking. TrackSelection trackSelection = new TrackSelection();
{ trackSelection.addActivityType("biking");
assertEquals(totalDistance.multipliedBy(5), aggregatedStatistics.get(biking).totalDistance); trackSelection.addActivityType("driving");
assertEquals(totalTime.multipliedBy(5), aggregatedStatistics.get(biking).totalMovingTime);
}
// Running. // when
{ List<AggregatedStatistic> result = new ContentProviderUtils(context).getAggregatedStatisticsForTracks(trackSelection);
assertEquals(totalDistance.multipliedBy(2), aggregatedStatistics.get(running).totalDistance);
assertEquals(totalTime.multipliedBy(2), aggregatedStatistics.get(running).totalMovingTime);
}
// Walking. // then
{ assertEquals(List.of(
assertEquals(totalDistance.multipliedBy(2), aggregatedStatistics.get(walking).totalDistance); new AggregatedStatistic("biking", 5, Duration.ofMinutes(200), Distance.ofKilometer(50), Speed.of(50)),
assertEquals(totalTime.multipliedBy(2), aggregatedStatistics.get(walking).totalMovingTime); new AggregatedStatistic("driving", 1, Duration.ofMinutes(40), Distance.ofKilometer(10), Speed.of(50))
} ), result);
// Driving.
{
assertEquals(totalDistance, aggregatedStatistics.get(driving).totalDistance);
assertEquals(totalTime, aggregatedStatistics.get(driving).totalMovingTime);
}
// Check order
{
assertEquals(biking, aggregatedStatistics.getItem(0).getActivityTypeLocalized());
assertEquals(driving, aggregatedStatistics.getItem(3).getActivityTypeLocalized());
}
} }
} }
@@ -0,0 +1,26 @@
package de.dennisguse.opentracks.data;
import androidx.annotation.NonNull;
import java.time.Duration;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Speed;
public record AggregatedStatistic(
@NonNull
String activityTypeLocalized,
int countTracks,
@NonNull
Duration totalMovingTime,
@NonNull
Distance totalDistance,
@NonNull
Speed maxSpeed
) {
public Speed getAverageMovingSpeed() {
return Speed.of(totalDistance, totalMovingTime);
}
}
@@ -66,6 +66,7 @@ import de.dennisguse.opentracks.util.FileUtils;
* *
* @author Leif Hendrik Wilden * @author Leif Hendrik Wilden
*/ */
//TODO Check if do {} while can be replaced by while (cursor.moveToNext)
public class ContentProviderUtils { public class ContentProviderUtils {
private static final String TAG = ContentProviderUtils.class.getSimpleName(); private static final String TAG = ContentProviderUtils.class.getSimpleName();
@@ -80,6 +81,7 @@ public class ContentProviderUtils {
private final ContentResolver contentResolver; private final ContentResolver contentResolver;
//TODO Check if this can be used in more than one place. If not remove.
public interface ContentProviderSelectionInterface { public interface ContentProviderSelectionInterface {
SelectionData buildSelection(); SelectionData buildSelection();
} }
@@ -200,21 +202,7 @@ public class ContentProviderUtils {
return tracks; return tracks;
} }
public List<Track> getTracks(ContentProviderSelectionInterface selection) { //TODO Implement someting like TrackPointIterator: just as a cursor
SelectionData selectionData = selection.buildSelection();
ArrayList<Track> tracks = new ArrayList<>();
try (Cursor cursor = getTrackCursor(selectionData.selection(), selectionData.selectionArgs(), TracksColumns._ID)) {
if (cursor != null && cursor.moveToFirst()) {
tracks.ensureCapacity(cursor.getCount());
do {
tracks.add(createTrack(cursor));
} while (cursor.moveToNext());
}
}
return tracks;
}
public Cursor searchTracks(String searchQuery) { public Cursor searchTracks(String searchQuery) {
// Needed, because MARKER_COUNT is a virtual column and has to be explicitly requested. // Needed, because MARKER_COUNT is a virtual column and has to be explicitly requested.
// Used only be TrackListAdapter // Used only be TrackListAdapter
@@ -311,6 +299,8 @@ public class ContentProviderUtils {
values.put(TracksColumns.ACTIVITY_TYPE, track.getActivityType() != null ? track.getActivityType().getId() : null); values.put(TracksColumns.ACTIVITY_TYPE, track.getActivityType() != null ? track.getActivityType().getId() : null);
values.put(TracksColumns.ACTIVITY_TYPE_LOCALIZED, track.getActivityTypeLocalized()); values.put(TracksColumns.ACTIVITY_TYPE_LOCALIZED, track.getActivityTypeLocalized());
values.put(TracksColumns.STARTTIME_OFFSET, track.getZoneOffset().getTotalSeconds()); values.put(TracksColumns.STARTTIME_OFFSET, track.getZoneOffset().getTotalSeconds());
//TODO Duplicates createContentValues(Statistics trackStatistics)
if (trackStatistics.startTime() != null) { if (trackStatistics.startTime() != null) {
values.put(TracksColumns.STARTTIME, trackStatistics.startTime().toEpochMilli()); values.put(TracksColumns.STARTTIME, trackStatistics.startTime().toEpochMilli());
} }
@@ -897,4 +887,22 @@ public class ContentProviderUtils {
} }
return sensorStatistics; return sensorStatistics;
} }
@NonNull
public List<AggregatedStatistic> getAggregatedStatisticsForTracks(ContentProviderSelectionInterface selection) {
SelectionData data = selection.buildSelection();
try (Cursor cursor = contentResolver.query(TracksColumns.CONTENT_URI_AGGREGATED_STATISTICS, null, data.selection(), data.selectionArgs(), null)) {
ArrayList<AggregatedStatistic> aggregatedStatistics = new ArrayList<>(cursor.getCount());
while (cursor.moveToNext()) {
aggregatedStatistics.add(new AggregatedStatistic(
cursor.getString(cursor.getColumnIndexOrThrow(TracksColumns.ACTIVITY_TYPE_LOCALIZED)),
cursor.getInt(cursor.getColumnIndexOrThrow(TracksColumns.TRACK_COUNT)),
Duration.ofMillis(cursor.getLong(cursor.getColumnIndexOrThrow(TracksColumns.MOVINGTIME))),
Distance.of(cursor.getFloat(cursor.getColumnIndexOrThrow(TracksColumns.TOTALDISTANCE))),
Speed.of(cursor.getFloat(cursor.getColumnIndexOrThrow(TracksColumns.MAXSPEED)))
));
}
return aggregatedStatistics;
}
}
} }
@@ -30,7 +30,6 @@ import android.text.TextUtils;
import android.util.Log; import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import java.util.Arrays; import java.util.Arrays;
@@ -65,7 +64,7 @@ public class CustomContentProvider extends ContentProvider {
* Finally, it ignores manual pause (SEGMENT_START_MANUAL). * Finally, it ignores manual pause (SEGMENT_START_MANUAL).
*/ */
private final String SENSOR_STATS_QUERY = private final String SENSOR_STATS_QUERY =
"WITH time_select as " + "WITH time_select AS " +
"(SELECT t1." + TrackPointsColumns.TIME + " * (t1." + TrackPointsColumns.TYPE + " NOT IN (" + TrackPoint.Type.SEGMENT_START_MANUAL.type_db + ")) time_value " + "(SELECT t1." + TrackPointsColumns.TIME + " * (t1." + TrackPointsColumns.TYPE + " NOT IN (" + TrackPoint.Type.SEGMENT_START_MANUAL.type_db + ")) time_value " +
"FROM " + TrackPointsColumns.TABLE_NAME + " t1 " + "FROM " + TrackPointsColumns.TABLE_NAME + " t1 " +
"WHERE t1." + TrackPointsColumns._ID + " > t." + TrackPointsColumns._ID + " AND t1." + TrackPointsColumns.TRACKID + " = ? ORDER BY _id LIMIT 1) " + "WHERE t1." + TrackPointsColumns._ID + " > t." + TrackPointsColumns._ID + " AND t1." + TrackPointsColumns.TRACKID + " = ? ORDER BY _id LIMIT 1) " +
@@ -101,6 +100,7 @@ public class CustomContentProvider extends ContentProvider {
uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TracksColumns.CONTENT_URI.getPath(), UrlType.TRACKS.ordinal()); uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TracksColumns.CONTENT_URI.getPath(), UrlType.TRACKS.ordinal());
uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TracksColumns.CONTENT_URI_SENSOR_STATS.getPath() + "/#", UrlType.TRACKS_SENSOR_STATS.ordinal()); uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TracksColumns.CONTENT_URI_SENSOR_STATS.getPath() + "/#", UrlType.TRACKS_SENSOR_STATS.ordinal());
uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TracksColumns.CONTENT_URI_AGGREGATED_STATISTICS.getPath(), UrlType.TRACKS_AGGREGATED_STATISTICS.ordinal());
uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TracksColumns.CONTENT_URI.getPath() + "/*", UrlType.TRACKS_BY_ID.ordinal()); uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TracksColumns.CONTENT_URI.getPath() + "/*", UrlType.TRACKS_BY_ID.ordinal());
uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, MarkerColumns.CONTENT_URI.getPath(), UrlType.MARKERS.ordinal()); uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, MarkerColumns.CONTENT_URI.getPath(), UrlType.MARKERS.ordinal());
@@ -223,6 +223,8 @@ public class CustomContentProvider extends ContentProvider {
public Cursor query(@NonNull Uri url, String[] projection, String selection, String[] selectionArgs, String sort) { public Cursor query(@NonNull Uri url, String[] projection, String selection, String[] selectionArgs, String sort) {
SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder(); SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
String sortOrder = null; String sortOrder = null;
String groupBy = null;
//TODO queryBuilder.appendWhere() should use selectionArgs?
switch (getUrlType(url)) { switch (getUrlType(url)) {
case TRACKPOINTS -> { case TRACKPOINTS -> {
queryBuilder.setTables(TrackPointsColumns.TABLE_NAME); queryBuilder.setTables(TrackPointsColumns.TABLE_NAME);
@@ -249,9 +251,23 @@ public class CustomContentProvider extends ContentProvider {
queryBuilder.appendWhere(TracksColumns._ID + " IN (" + TextUtils.join(SQL_LIST_DELIMITER, ContentProviderUtils.parseTrackIdsFromUri(url)) + ")"); queryBuilder.appendWhere(TracksColumns._ID + " IN (" + TextUtils.join(SQL_LIST_DELIMITER, ContentProviderUtils.parseTrackIdsFromUri(url)) + ")");
} }
case TRACKS_SENSOR_STATS -> { case TRACKS_SENSOR_STATS -> {
//TODO Replace with SQLQueryBuilder?
long trackId = ContentUris.parseId(url); long trackId = ContentUris.parseId(url);
return db.rawQuery(SENSOR_STATS_QUERY, new String[]{String.valueOf(trackId), String.valueOf(trackId)}); return db.rawQuery(SENSOR_STATS_QUERY, new String[]{String.valueOf(trackId), String.valueOf(trackId)});
} }
case TRACKS_AGGREGATED_STATISTICS -> {
queryBuilder.setTables(TracksColumns.TABLE_NAME);
projection = new String[]{
"COUNT(*) AS " + TracksColumns.TRACK_COUNT,
TracksColumns.ACTIVITY_TYPE_LOCALIZED,
"SUM(" + TracksColumns.MOVINGTIME + ") AS " + TracksColumns.MOVINGTIME,
"SUM(" + TracksColumns.TOTALDISTANCE + ") AS " + TracksColumns.TOTALDISTANCE,
"MAX(" + TracksColumns.MAXSPEED + ") AS " + TracksColumns.MAXSPEED
};
sortOrder = TracksColumns.TRACK_COUNT + " DESC";
groupBy = TracksColumns.ACTIVITY_TYPE_LOCALIZED;
}
case MARKERS -> { case MARKERS -> {
queryBuilder.setTables(MarkerColumns.TABLE_NAME); queryBuilder.setTables(MarkerColumns.TABLE_NAME);
sortOrder = sort != null ? sort : MarkerColumns.DEFAULT_SORT_ORDER; sortOrder = sort != null ? sort : MarkerColumns.DEFAULT_SORT_ORDER;
@@ -266,7 +282,7 @@ public class CustomContentProvider extends ContentProvider {
} }
default -> throw new IllegalArgumentException("Unknown url " + url); default -> throw new IllegalArgumentException("Unknown url " + url);
} }
Cursor cursor = queryBuilder.query(db, projection, selection, selectionArgs, null, null, sortOrder); Cursor cursor = queryBuilder.query(db, projection, selection, selectionArgs, groupBy, null, sortOrder);
cursor.setNotificationUri(getContext().getContentResolver(), url); cursor.setNotificationUri(getContext().getContentResolver(), url);
return cursor; return cursor;
} }
@@ -379,14 +395,14 @@ public class CustomContentProvider extends ContentProvider {
throw new SQLException("Failed to insert a marker " + url); throw new SQLException("Failed to insert a marker " + url);
} }
@VisibleForTesting private enum UrlType {
enum UrlType {
TRACKPOINTS, TRACKPOINTS,
TRACKPOINTS_BY_ID, TRACKPOINTS_BY_ID,
TRACKPOINTS_BY_TRACKID, TRACKPOINTS_BY_TRACKID,
TRACKS, TRACKS,
TRACKS_BY_ID, TRACKS_BY_ID,
TRACKS_SENSOR_STATS, TRACKS_SENSOR_STATS,
TRACKS_AGGREGATED_STATISTICS,
MARKERS, MARKERS,
MARKERS_BY_ID, MARKERS_BY_ID,
MARKERS_BY_TRACKID MARKERS_BY_TRACKID
@@ -19,6 +19,8 @@ public record Distance(double distance_m) {
return Distance.of((double) distance_m); return Distance.of((double) distance_m);
} }
} }
@Deprecated //TODO Remove
public static Distance of(String distance_m) { public static Distance of(String distance_m) {
return of(Float.parseFloat(distance_m)); return of(Float.parseFloat(distance_m));
} }
@@ -20,6 +20,7 @@ public record Speed(double speed_mps) {
return new Speed(speed_mps); return new Speed(speed_mps);
} }
@Deprecated //TODO Remove
public static Speed of(String speed_mps) { public static Speed of(String speed_mps) {
return of(Float.parseFloat(speed_mps)); return of(Float.parseFloat(speed_mps));
} }
@@ -35,9 +35,6 @@ public record Statistics(
} }
public Speed getAverageSpeed() { public Speed getAverageSpeed() {
if (totalTime.isZero()) {
return Speed.ZERO;
}
return Speed.of(totalDistance, totalTime); return Speed.of(totalDistance, totalTime);
} }
@@ -31,6 +31,7 @@ public interface TracksColumns extends BaseColumns {
String TABLE_NAME = "tracks"; String TABLE_NAME = "tracks";
Uri CONTENT_URI = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/" + TABLE_NAME); Uri CONTENT_URI = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/" + TABLE_NAME);
Uri CONTENT_URI_SENSOR_STATS = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/" + TABLE_NAME + "/sensorstats"); Uri CONTENT_URI_SENSOR_STATS = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/" + TABLE_NAME + "/sensorstats");
Uri CONTENT_URI_AGGREGATED_STATISTICS = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/" + TABLE_NAME + "/aggregated/");
String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.de.dennisguse.track"; String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.de.dennisguse.track";
String CONTENT_ITEMTYPE = "vnd.android.cursor.item/vnd.de.dennisguse.track"; String CONTENT_ITEMTYPE = "vnd.android.cursor.item/vnd.de.dennisguse.track";
String DEFAULT_SORT_ORDER = _ID; String DEFAULT_SORT_ORDER = _ID;
@@ -46,7 +47,9 @@ public interface TracksColumns extends BaseColumns {
String STARTTIME_OFFSET = "time_offset"; // in plus/minus in seconds String STARTTIME_OFFSET = "time_offset"; // in plus/minus in seconds
String STARTTIME = "time_start"; // track start time String STARTTIME = "time_start"; // track start time
String STOPTIME = "time_stop"; // track stop time String STOPTIME = "time_stop"; // track stop time
String MARKER_COUNT = "markerCount"; // the numbers of markers (virtual column) String MARKER_COUNT = "markerCount"; // the numbers of markers (virtual column)
String TRACK_COUNT = "trackCount"; // the numbers of markers (virtual column)
String TOTALDISTANCE = "distance"; // total distance String TOTALDISTANCE = "distance"; // total distance
String TOTALTIME = "duration_total"; String TOTALTIME = "duration_total";
@@ -1,81 +0,0 @@
package de.dennisguse.opentracks.ui.aggregatedStatistics;
import android.util.Pair;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Speed;
import de.dennisguse.opentracks.data.models.Track;
public class AggregatedStatistics {
private final List<AggregatedStatistic> dataList;
public AggregatedStatistics(@NonNull List<Track> tracks) {
Map<String, List<Track>> groupedByActivityType = tracks.stream().collect(Collectors.groupingBy(Track::getActivityTypeLocalized));
List<Pair<String, List<Track>>> groupedByActivityTypeList = new ArrayList<>();
groupedByActivityType.forEach((activityTypeLocalized, trackList) -> groupedByActivityTypeList.add(new Pair<>(activityTypeLocalized, trackList)));
dataList = groupedByActivityTypeList.parallelStream()
.map(i -> new AggregatedStatistic(i.first, i.second))
.sorted((o1, o2) -> {
if (o1.getCountTracks() == o2.getCountTracks()) {
return o1.getActivityTypeLocalized().compareTo(o2.getActivityTypeLocalized());
}
return (o1.getCountTracks() < o2.getCountTracks() ? 1 : -1);
}).toList();
}
public int getCount() {
return dataList.size();
}
@VisibleForTesting
public AggregatedStatistic get(String activityTypeLocalized) {
return dataList.stream().filter(i -> i.getActivityTypeLocalized().equals(activityTypeLocalized)).findFirst().get();
}
public AggregatedStatistic getItem(int position) {
return dataList.get(position);
}
public static class AggregatedStatistic {
final String activityTypeLocalized;
final int countTracks;
final Duration totalMovingTime;
final Distance totalDistance;
final Speed avgMovingSpeed;
final Speed maxSpeed;
public AggregatedStatistic(String activityTypeLocalized, List<Track> tracks) {
this.activityTypeLocalized = activityTypeLocalized;
this.countTracks = tracks.size();
totalMovingTime = tracks.stream().map(t -> t.getStatistics().movingTime()).reduce(Duration.ZERO, Duration::plus);
totalDistance = tracks.stream().map(t -> t.getStatistics().totalDistance()).reduce(Distance.ZERO, Distance::plus);
maxSpeed = tracks.stream().map(t -> t.getStatistics().maxSpeed()).max((o1, o2) -> (int) (o1.speed_mps() - o2.speed_mps())).orElse(Speed.ZERO);
avgMovingSpeed = Speed.of(totalDistance, totalMovingTime);
}
public String getActivityTypeLocalized() {
return activityTypeLocalized;
}
public int getCountTracks() {
return countTracks;
}
}
}
@@ -55,12 +55,7 @@ public class AggregatedStatisticsActivity extends AbstractActivity implements Fi
viewModel = new ViewModelProvider(this).get(AggregatedStatisticsModel.class); viewModel = new ViewModelProvider(this).get(AggregatedStatisticsModel.class);
viewModel.getAggregatedStats(selection).observe(this, aggregatedStatistics -> { viewModel.getAggregatedStats(selection).observe(this, aggregatedStatistics -> {
if ((aggregatedStatistics == null || aggregatedStatistics.getCount() == 0) && !selection.isEmpty()) { adapter.swapData(aggregatedStatistics);
viewBinding.aggregatedStatsEmptyView.setText(getString(R.string.aggregated_stats_filter_no_results));
}
if (aggregatedStatistics != null) {
adapter.swapData(aggregatedStatistics);
}
checkListEmpty(); checkListEmpty();
}); });
@@ -71,6 +66,9 @@ public class AggregatedStatisticsActivity extends AbstractActivity implements Fi
if (adapter.getItemCount() == 0) { if (adapter.getItemCount() == 0) {
viewBinding.aggregatedStatsList.setVisibility(View.GONE); viewBinding.aggregatedStatsList.setVisibility(View.GONE);
viewBinding.aggregatedStatsEmptyView.setVisibility(View.VISIBLE); viewBinding.aggregatedStatsEmptyView.setVisibility(View.VISIBLE);
if (!selection.isEmpty()) {
viewBinding.aggregatedStatsEmptyView.setText(getString(R.string.aggregated_stats_filter_no_results));
}
} else { } else {
viewBinding.aggregatedStatsList.setVisibility(View.VISIBLE); viewBinding.aggregatedStatsList.setVisibility(View.VISIBLE);
viewBinding.aggregatedStatsEmptyView.setVisibility(View.GONE); viewBinding.aggregatedStatsEmptyView.setVisibility(View.GONE);
@@ -8,10 +8,10 @@ import android.view.ViewGroup;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import de.dennisguse.opentracks.R; import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.AggregatedStatistic;
import de.dennisguse.opentracks.data.models.ActivityType; import de.dennisguse.opentracks.data.models.ActivityType;
import de.dennisguse.opentracks.data.models.DistanceFormatter; import de.dennisguse.opentracks.data.models.DistanceFormatter;
import de.dennisguse.opentracks.data.models.SpeedFormatter; import de.dennisguse.opentracks.data.models.SpeedFormatter;
@@ -22,10 +22,10 @@ import de.dennisguse.opentracks.util.StringUtils;
public class AggregatedStatisticsAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> { public class AggregatedStatisticsAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private AggregatedStatistics aggregatedStatistics; private List<AggregatedStatistic> aggregatedStatistics;
private final Context context; private final Context context;
public AggregatedStatisticsAdapter(Context context, AggregatedStatistics aggregatedStatistics) { public AggregatedStatisticsAdapter(Context context, @NonNull List<AggregatedStatistic> aggregatedStatistics) {
this.context = context; this.context = context;
this.aggregatedStatistics = aggregatedStatistics; this.aggregatedStatistics = aggregatedStatistics;
} }
@@ -40,9 +40,9 @@ public class AggregatedStatisticsAdapter extends RecyclerView.Adapter<RecyclerVi
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
ViewHolder viewHolder = (ViewHolder) holder; ViewHolder viewHolder = (ViewHolder) holder;
AggregatedStatistics.AggregatedStatistic aggregatedStatistic = aggregatedStatistics.getItem(position); AggregatedStatistic aggregatedStatistic = aggregatedStatistics.get(position);
String type = aggregatedStatistic.getActivityTypeLocalized(); String type = aggregatedStatistic.activityTypeLocalized();
if (ActivityType.findByLocalizedString(context, type).isShowSpeedPreferred()) { if (ActivityType.findByLocalizedString(context, type).isShowSpeedPreferred()) {
viewHolder.setSpeed(aggregatedStatistic); viewHolder.setSpeed(aggregatedStatistic);
} else { } else {
@@ -52,23 +52,18 @@ public class AggregatedStatisticsAdapter extends RecyclerView.Adapter<RecyclerVi
@Override @Override
public int getItemCount() { public int getItemCount() {
if (aggregatedStatistics == null) { return aggregatedStatistics.size();
return 0;
}
return aggregatedStatistics.getCount();
} }
public void swapData(AggregatedStatistics aggregatedStatistics) { public void swapData(List<AggregatedStatistic> aggregatedStatistics) {
this.aggregatedStatistics = aggregatedStatistics; this.aggregatedStatistics = aggregatedStatistics;
this.notifyDataSetChanged(); this.notifyDataSetChanged();
} }
public List<String> getCategories() { public List<String> getCategories() {
List<String> categories = new ArrayList<>(); return aggregatedStatistics.stream()
for (int i = 0; i < aggregatedStatistics.getCount(); i++) { .map(AggregatedStatistic::activityTypeLocalized)
categories.add(aggregatedStatistics.getItem(i).getActivityTypeLocalized()); .toList();
}
return categories;
} }
private class ViewHolder extends RecyclerView.ViewHolder { private class ViewHolder extends RecyclerView.ViewHolder {
@@ -81,38 +76,38 @@ public class AggregatedStatisticsAdapter extends RecyclerView.Adapter<RecyclerVi
this.viewBinding = viewBinding; this.viewBinding = viewBinding;
} }
public void setSpeed(AggregatedStatistics.AggregatedStatistic aggregatedStatistic) { public void setSpeed(AggregatedStatistic aggregatedStatistic) {
setCommonValues(aggregatedStatistic); setCommonValues(aggregatedStatistic);
SpeedFormatter formatter = SpeedFormatter.Builder().setUnit(unitSystem).setReportSpeedOrPace(reportSpeed).build(context); SpeedFormatter formatter = SpeedFormatter.Builder().setUnit(unitSystem).setReportSpeedOrPace(reportSpeed).build(context);
{ {
Pair<String, String> parts = formatter.getSpeedParts(aggregatedStatistic.avgMovingSpeed); Pair<String, String> parts = formatter.getSpeedParts(aggregatedStatistic.getAverageMovingSpeed());
viewBinding.aggregatedStatsAvgRate.setText(parts.first); viewBinding.aggregatedStatsAvgRate.setText(parts.first);
viewBinding.aggregatedStatsAvgRateUnit.setText(parts.second); viewBinding.aggregatedStatsAvgRateUnit.setText(parts.second);
viewBinding.aggregatedStatsAvgRateLabel.setText(context.getString(R.string.stats_average_moving_speed)); viewBinding.aggregatedStatsAvgRateLabel.setText(context.getString(R.string.stats_average_moving_speed));
} }
{ {
Pair<String, String> parts = formatter.getSpeedParts(aggregatedStatistic.maxSpeed); Pair<String, String> parts = formatter.getSpeedParts(aggregatedStatistic.maxSpeed());
viewBinding.aggregatedStatsMaxRate.setText(parts.first); viewBinding.aggregatedStatsMaxRate.setText(parts.first);
viewBinding.aggregatedStatsMaxRateUnit.setText(parts.second); viewBinding.aggregatedStatsMaxRateUnit.setText(parts.second);
viewBinding.aggregatedStatsMaxRateLabel.setText(context.getString(R.string.stats_max_speed)); viewBinding.aggregatedStatsMaxRateLabel.setText(context.getString(R.string.stats_max_speed));
} }
} }
public void setPace(AggregatedStatistics.AggregatedStatistic aggregatedStatistic) { public void setPace(AggregatedStatistic aggregatedStatistic) {
setCommonValues(aggregatedStatistic); setCommonValues(aggregatedStatistic);
SpeedFormatter formatter = SpeedFormatter.Builder().setUnit(unitSystem).setReportSpeedOrPace(reportSpeed).build(context); SpeedFormatter formatter = SpeedFormatter.Builder().setUnit(unitSystem).setReportSpeedOrPace(reportSpeed).build(context);
{ {
Pair<String, String> parts = formatter.getSpeedParts(aggregatedStatistic.avgMovingSpeed); Pair<String, String> parts = formatter.getSpeedParts(aggregatedStatistic.getAverageMovingSpeed());
viewBinding.aggregatedStatsAvgRate.setText(parts.first); viewBinding.aggregatedStatsAvgRate.setText(parts.first);
viewBinding.aggregatedStatsAvgRateUnit.setText(parts.second); viewBinding.aggregatedStatsAvgRateUnit.setText(parts.second);
viewBinding.aggregatedStatsAvgRateLabel.setText(context.getString(R.string.stats_average_moving_pace)); viewBinding.aggregatedStatsAvgRateLabel.setText(context.getString(R.string.stats_average_moving_pace));
} }
{ {
Pair<String, String> parts = formatter.getSpeedParts(aggregatedStatistic.maxSpeed); Pair<String, String> parts = formatter.getSpeedParts(aggregatedStatistic.maxSpeed());
viewBinding.aggregatedStatsMaxRate.setText(parts.first); viewBinding.aggregatedStatsMaxRate.setText(parts.first);
viewBinding.aggregatedStatsMaxRateUnit.setText(parts.second); viewBinding.aggregatedStatsMaxRateUnit.setText(parts.second);
viewBinding.aggregatedStatsMaxRateLabel.setText(R.string.stats_fastest_pace); viewBinding.aggregatedStatsMaxRateLabel.setText(R.string.stats_fastest_pace);
@@ -120,27 +115,27 @@ public class AggregatedStatisticsAdapter extends RecyclerView.Adapter<RecyclerVi
} }
//TODO Check preference handling. //TODO Check preference handling.
private void setCommonValues(AggregatedStatistics.AggregatedStatistic aggregatedStatistic) { private void setCommonValues(AggregatedStatistic aggregatedStatistic) {
String activityType = aggregatedStatistic.getActivityTypeLocalized(); String activityType = aggregatedStatistic.activityTypeLocalized();
reportSpeed = PreferencesUtils.isReportSpeed(activityType); reportSpeed = PreferencesUtils.isReportSpeed(activityType);
unitSystem = PreferencesUtils.getUnitSystem(); unitSystem = PreferencesUtils.getUnitSystem();
viewBinding.activityIcon.setImageResource(getIcon(aggregatedStatistic)); viewBinding.activityIcon.setImageResource(getIcon(aggregatedStatistic));
viewBinding.aggregatedStatsTypeLabel.setText(activityType); viewBinding.aggregatedStatsTypeLabel.setText(activityType);
viewBinding.aggregatedStatsNumTracks.setText(StringUtils.valueInParentheses(String.valueOf(aggregatedStatistic.getCountTracks()))); viewBinding.aggregatedStatsNumTracks.setText(StringUtils.valueInParentheses(String.valueOf(aggregatedStatistic.countTracks())));
Pair<String, String> parts = DistanceFormatter.Builder() Pair<String, String> parts = DistanceFormatter.Builder()
.setUnit(unitSystem) .setUnit(unitSystem)
.build(context).getDistanceParts(aggregatedStatistic.totalDistance); .build(context).getDistanceParts(aggregatedStatistic.totalDistance());
viewBinding.aggregatedStatsDistance.setText(parts.first); viewBinding.aggregatedStatsDistance.setText(parts.first);
viewBinding.aggregatedStatsDistanceUnit.setText(parts.second); viewBinding.aggregatedStatsDistanceUnit.setText(parts.second);
viewBinding.aggregatedStatsTime.setText(StringUtils.formatElapsedTime(aggregatedStatistic.totalMovingTime)); viewBinding.aggregatedStatsTime.setText(StringUtils.formatElapsedTime(aggregatedStatistic.totalMovingTime()));
} }
private int getIcon(AggregatedStatistics.AggregatedStatistic aggregatedStatistic) { private int getIcon(AggregatedStatistic aggregatedStatistic) {
String localizedActivityType = aggregatedStatistic.getActivityTypeLocalized(); String localizedActivityType = aggregatedStatistic.activityTypeLocalized();
return ActivityType.findByLocalizedString(context, localizedActivityType) return ActivityType.findByLocalizedString(context, localizedActivityType)
.getIconDrawableId(); .getIconDrawableId();
} }
@@ -10,19 +10,19 @@ import androidx.lifecycle.MutableLiveData;
import java.util.List; import java.util.List;
import de.dennisguse.opentracks.data.AggregatedStatistic;
import de.dennisguse.opentracks.data.ContentProviderUtils; import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.data.TrackSelection; import de.dennisguse.opentracks.data.TrackSelection;
import de.dennisguse.opentracks.data.models.Track;
public class AggregatedStatisticsModel extends AndroidViewModel { public class AggregatedStatisticsModel extends AndroidViewModel {
private MutableLiveData<AggregatedStatistics> aggregatedStats; private MutableLiveData<List<AggregatedStatistic>> aggregatedStats;
public AggregatedStatisticsModel(@NonNull Application application) { public AggregatedStatisticsModel(@NonNull Application application) {
super(application); super(application);
} }
public LiveData<AggregatedStatistics> getAggregatedStats(@Nullable TrackSelection selection) { public LiveData<List<AggregatedStatistic>> getAggregatedStats(@Nullable TrackSelection selection) {
if (aggregatedStats == null) { if (aggregatedStats == null) {
aggregatedStats = new MutableLiveData<>(); aggregatedStats = new MutableLiveData<>();
loadAggregatedStats(selection); loadAggregatedStats(selection);
@@ -41,11 +41,7 @@ public class AggregatedStatisticsModel extends AndroidViewModel {
private void loadAggregatedStats(TrackSelection selection) { private void loadAggregatedStats(TrackSelection selection) {
new Thread(() -> { new Thread(() -> {
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(getApplication().getApplicationContext()); ContentProviderUtils contentProviderUtils = new ContentProviderUtils(getApplication().getApplicationContext());
List<Track> tracks = selection != null ? contentProviderUtils.getTracks(selection) : contentProviderUtils.getTracks(); aggregatedStats.postValue(contentProviderUtils.getAggregatedStatisticsForTracks(selection));
AggregatedStatistics aggregatedStatistics = new AggregatedStatistics(tracks);
aggregatedStats.postValue(aggregatedStatistics);
}).start(); }).start();
} }
} }