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;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import android.content.Context;
import androidx.test.core.app.ApplicationProvider;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
@@ -16,6 +16,9 @@ import java.time.Instant;
import java.util.List;
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.AltitudeExtremities;
import de.dennisguse.opentracks.data.models.AltitudeGainLoss;
@@ -29,7 +32,7 @@ public class AggregatedStatisticsTest {
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(
Instant.ofEpochMilli(1000),
Instant.ofEpochMilli(1000).plus(totalTime),
@@ -44,199 +47,128 @@ public class AggregatedStatisticsTest {
));
track.setActivityType(ActivityType.findByLocalizedString(context, activityTypeLocalized));
track.setActivityTypeLocalized(activityTypeLocalized);
return track;
new ContentProviderUtils(context).insertTrack(track);
}
@Before
public void setUp() {
new ContentProviderUtils(context).deleteAllTracks(context);
}
@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
// 10km in 40 minutes.
Distance totalDistance = Distance.of(10000);
Duration totalTime = Duration.ofMillis(2400000);
Distance totalDistance = Distance.ofKilometer(10);
Duration totalTime = Duration.ofMinutes(40);
String biking = context.getString(R.string.activity_type_biking);
Track track = createTrack(context, totalDistance, totalTime, biking);
createTrack(context, totalDistance, totalTime, biking);
// when
AggregatedStatistics aggregatedStatistics = new AggregatedStatistics(List.of(track));
List<AggregatedStatistic> result = new ContentProviderUtils(context).getAggregatedStatisticsForTracks(new TrackSelection());
// then
assertEquals(1, aggregatedStatistics.getCount());
assertNotNull(aggregatedStatistics.get(biking));
assertEquals(1, aggregatedStatistics.get(biking).getCountTracks());
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));
assertEquals(List.of(
new AggregatedStatistic("biking", 1, Duration.ofMinutes(40), Distance.ofKilometer(10), Speed.of(50))
), result);
}
@Test
public void testAggregate_twoBikingTracks() {
// given
// 10km in 40 minutes.
Distance totalDistance = Distance.of(10000);
Duration totalTime = Duration.ofMillis(2400000);
Distance totalDistance = Distance.ofKilometer(10);
Duration totalTime = Duration.ofMinutes(40);
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
AggregatedStatistics aggregatedStatistics = new AggregatedStatistics(tracks);
List<AggregatedStatistic> result = new ContentProviderUtils(context).getAggregatedStatisticsForTracks(new TrackSelection());
// then
assertEquals(1, aggregatedStatistics.getCount());
assertNotNull(aggregatedStatistics.get(biking));
assertEquals(2, aggregatedStatistics.get(biking).getCountTracks());
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);
}
assertEquals(List.of(
new AggregatedStatistic("biking", 2, Duration.ofMinutes(80), Distance.ofKilometer(20), Speed.of(50))
), result);
}
@Test
public void testAggregate_severalTracksWithSeveralActivities() {
// given
// 10km in 40 minutes.
Distance totalDistance = Distance.of(10000);
Duration totalTime = Duration.ofMillis(2400000);
Distance totalDistance = Distance.ofKilometer(10);
Duration totalTime = Duration.ofMinutes(40);
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);
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
AggregatedStatistics aggregatedStatistics = new AggregatedStatistics(tracks);
List<AggregatedStatistic> result = new ContentProviderUtils(context).getAggregatedStatisticsForTracks(new TrackSelection());
// then
// 4 sports.
assertEquals(4, aggregatedStatistics.getCount());
assertEquals(List.of(
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.
assertNotNull(aggregatedStatistics.get(biking));
assertNotNull(aggregatedStatistics.get(running));
assertNotNull(aggregatedStatistics.get(walking));
assertNotNull(aggregatedStatistics.get(driving));
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);
String driving = context.getString(R.string.activity_type_driving);
// Number of tracks by sport.
assertEquals(5, aggregatedStatistics.get(biking).getCountTracks()); // Biking.
assertEquals(2, aggregatedStatistics.get(running).getCountTracks()); // Running.
assertEquals(2, aggregatedStatistics.get(walking).getCountTracks()); // Walking.
assertEquals(1, aggregatedStatistics.get(driving).getCountTracks()); // 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);
// Biking.
{
assertEquals(totalDistance.multipliedBy(5), aggregatedStatistics.get(biking).totalDistance);
assertEquals(totalTime.multipliedBy(5), aggregatedStatistics.get(biking).totalMovingTime);
}
TrackSelection trackSelection = new TrackSelection();
trackSelection.addActivityType("biking");
trackSelection.addActivityType("driving");
// Running.
{
assertEquals(totalDistance.multipliedBy(2), aggregatedStatistics.get(running).totalDistance);
assertEquals(totalTime.multipliedBy(2), aggregatedStatistics.get(running).totalMovingTime);
}
// when
List<AggregatedStatistic> result = new ContentProviderUtils(context).getAggregatedStatisticsForTracks(trackSelection);
// Walking.
{
assertEquals(totalDistance.multipliedBy(2), aggregatedStatistics.get(walking).totalDistance);
assertEquals(totalTime.multipliedBy(2), aggregatedStatistics.get(walking).totalMovingTime);
}
// 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());
}
// then
assertEquals(List.of(
new AggregatedStatistic("biking", 5, Duration.ofMinutes(200), Distance.ofKilometer(50), Speed.of(50)),
new AggregatedStatistic("driving", 1, Duration.ofMinutes(40), Distance.ofKilometer(10), Speed.of(50))
), result);
}
}
@@ -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
*/
//TODO Check if do {} while can be replaced by while (cursor.moveToNext)
public class ContentProviderUtils {
private static final String TAG = ContentProviderUtils.class.getSimpleName();
@@ -80,6 +81,7 @@ public class ContentProviderUtils {
private final ContentResolver contentResolver;
//TODO Check if this can be used in more than one place. If not remove.
public interface ContentProviderSelectionInterface {
SelectionData buildSelection();
}
@@ -200,21 +202,7 @@ public class ContentProviderUtils {
return tracks;
}
public List<Track> getTracks(ContentProviderSelectionInterface selection) {
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;
}
//TODO Implement someting like TrackPointIterator: just as a cursor
public Cursor searchTracks(String searchQuery) {
// Needed, because MARKER_COUNT is a virtual column and has to be explicitly requested.
// 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_LOCALIZED, track.getActivityTypeLocalized());
values.put(TracksColumns.STARTTIME_OFFSET, track.getZoneOffset().getTotalSeconds());
//TODO Duplicates createContentValues(Statistics trackStatistics)
if (trackStatistics.startTime() != null) {
values.put(TracksColumns.STARTTIME, trackStatistics.startTime().toEpochMilli());
}
@@ -897,4 +887,22 @@ public class ContentProviderUtils {
}
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 androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import java.util.Arrays;
@@ -65,7 +64,7 @@ public class CustomContentProvider extends ContentProvider {
* Finally, it ignores manual pause (SEGMENT_START_MANUAL).
*/
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 " +
"FROM " + TrackPointsColumns.TABLE_NAME + " t1 " +
"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_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, 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) {
SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
String sortOrder = null;
String groupBy = null;
//TODO queryBuilder.appendWhere() should use selectionArgs?
switch (getUrlType(url)) {
case TRACKPOINTS -> {
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)) + ")");
}
case TRACKS_SENSOR_STATS -> {
//TODO Replace with SQLQueryBuilder?
long trackId = ContentUris.parseId(url);
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 -> {
queryBuilder.setTables(MarkerColumns.TABLE_NAME);
sortOrder = sort != null ? sort : MarkerColumns.DEFAULT_SORT_ORDER;
@@ -266,7 +282,7 @@ public class CustomContentProvider extends ContentProvider {
}
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);
return cursor;
}
@@ -379,14 +395,14 @@ public class CustomContentProvider extends ContentProvider {
throw new SQLException("Failed to insert a marker " + url);
}
@VisibleForTesting
enum UrlType {
private enum UrlType {
TRACKPOINTS,
TRACKPOINTS_BY_ID,
TRACKPOINTS_BY_TRACKID,
TRACKS,
TRACKS_BY_ID,
TRACKS_SENSOR_STATS,
TRACKS_AGGREGATED_STATISTICS,
MARKERS,
MARKERS_BY_ID,
MARKERS_BY_TRACKID
@@ -19,6 +19,8 @@ public record Distance(double distance_m) {
return Distance.of((double) distance_m);
}
}
@Deprecated //TODO Remove
public static Distance of(String distance_m) {
return of(Float.parseFloat(distance_m));
}
@@ -20,6 +20,7 @@ public record Speed(double speed_mps) {
return new Speed(speed_mps);
}
@Deprecated //TODO Remove
public static Speed of(String speed_mps) {
return of(Float.parseFloat(speed_mps));
}
@@ -35,9 +35,6 @@ public record Statistics(
}
public Speed getAverageSpeed() {
if (totalTime.isZero()) {
return Speed.ZERO;
}
return Speed.of(totalDistance, totalTime);
}
@@ -31,6 +31,7 @@ public interface TracksColumns extends BaseColumns {
String TABLE_NAME = "tracks";
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_AGGREGATED_STATISTICS = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/" + TABLE_NAME + "/aggregated/");
String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.de.dennisguse.track";
String CONTENT_ITEMTYPE = "vnd.android.cursor.item/vnd.de.dennisguse.track";
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 = "time_start"; // track start time
String STOPTIME = "time_stop"; // track stop time
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 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.getAggregatedStats(selection).observe(this, aggregatedStatistics -> {
if ((aggregatedStatistics == null || aggregatedStatistics.getCount() == 0) && !selection.isEmpty()) {
viewBinding.aggregatedStatsEmptyView.setText(getString(R.string.aggregated_stats_filter_no_results));
}
if (aggregatedStatistics != null) {
adapter.swapData(aggregatedStatistics);
}
adapter.swapData(aggregatedStatistics);
checkListEmpty();
});
@@ -71,6 +66,9 @@ public class AggregatedStatisticsActivity extends AbstractActivity implements Fi
if (adapter.getItemCount() == 0) {
viewBinding.aggregatedStatsList.setVisibility(View.GONE);
viewBinding.aggregatedStatsEmptyView.setVisibility(View.VISIBLE);
if (!selection.isEmpty()) {
viewBinding.aggregatedStatsEmptyView.setText(getString(R.string.aggregated_stats_filter_no_results));
}
} else {
viewBinding.aggregatedStatsList.setVisibility(View.VISIBLE);
viewBinding.aggregatedStatsEmptyView.setVisibility(View.GONE);
@@ -8,10 +8,10 @@ import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.List;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.AggregatedStatistic;
import de.dennisguse.opentracks.data.models.ActivityType;
import de.dennisguse.opentracks.data.models.DistanceFormatter;
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> {
private AggregatedStatistics aggregatedStatistics;
private List<AggregatedStatistic> aggregatedStatistics;
private final Context context;
public AggregatedStatisticsAdapter(Context context, AggregatedStatistics aggregatedStatistics) {
public AggregatedStatisticsAdapter(Context context, @NonNull List<AggregatedStatistic> aggregatedStatistics) {
this.context = context;
this.aggregatedStatistics = aggregatedStatistics;
}
@@ -40,9 +40,9 @@ public class AggregatedStatisticsAdapter extends RecyclerView.Adapter<RecyclerVi
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
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()) {
viewHolder.setSpeed(aggregatedStatistic);
} else {
@@ -52,23 +52,18 @@ public class AggregatedStatisticsAdapter extends RecyclerView.Adapter<RecyclerVi
@Override
public int getItemCount() {
if (aggregatedStatistics == null) {
return 0;
}
return aggregatedStatistics.getCount();
return aggregatedStatistics.size();
}
public void swapData(AggregatedStatistics aggregatedStatistics) {
public void swapData(List<AggregatedStatistic> aggregatedStatistics) {
this.aggregatedStatistics = aggregatedStatistics;
this.notifyDataSetChanged();
}
public List<String> getCategories() {
List<String> categories = new ArrayList<>();
for (int i = 0; i < aggregatedStatistics.getCount(); i++) {
categories.add(aggregatedStatistics.getItem(i).getActivityTypeLocalized());
}
return categories;
return aggregatedStatistics.stream()
.map(AggregatedStatistic::activityTypeLocalized)
.toList();
}
private class ViewHolder extends RecyclerView.ViewHolder {
@@ -81,38 +76,38 @@ public class AggregatedStatisticsAdapter extends RecyclerView.Adapter<RecyclerVi
this.viewBinding = viewBinding;
}
public void setSpeed(AggregatedStatistics.AggregatedStatistic aggregatedStatistic) {
public void setSpeed(AggregatedStatistic aggregatedStatistic) {
setCommonValues(aggregatedStatistic);
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.aggregatedStatsAvgRateUnit.setText(parts.second);
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.aggregatedStatsMaxRateUnit.setText(parts.second);
viewBinding.aggregatedStatsMaxRateLabel.setText(context.getString(R.string.stats_max_speed));
}
}
public void setPace(AggregatedStatistics.AggregatedStatistic aggregatedStatistic) {
public void setPace(AggregatedStatistic aggregatedStatistic) {
setCommonValues(aggregatedStatistic);
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.aggregatedStatsAvgRateUnit.setText(parts.second);
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.aggregatedStatsMaxRateUnit.setText(parts.second);
viewBinding.aggregatedStatsMaxRateLabel.setText(R.string.stats_fastest_pace);
@@ -120,27 +115,27 @@ public class AggregatedStatisticsAdapter extends RecyclerView.Adapter<RecyclerVi
}
//TODO Check preference handling.
private void setCommonValues(AggregatedStatistics.AggregatedStatistic aggregatedStatistic) {
String activityType = aggregatedStatistic.getActivityTypeLocalized();
private void setCommonValues(AggregatedStatistic aggregatedStatistic) {
String activityType = aggregatedStatistic.activityTypeLocalized();
reportSpeed = PreferencesUtils.isReportSpeed(activityType);
unitSystem = PreferencesUtils.getUnitSystem();
viewBinding.activityIcon.setImageResource(getIcon(aggregatedStatistic));
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()
.setUnit(unitSystem)
.build(context).getDistanceParts(aggregatedStatistic.totalDistance);
.build(context).getDistanceParts(aggregatedStatistic.totalDistance());
viewBinding.aggregatedStatsDistance.setText(parts.first);
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) {
String localizedActivityType = aggregatedStatistic.getActivityTypeLocalized();
private int getIcon(AggregatedStatistic aggregatedStatistic) {
String localizedActivityType = aggregatedStatistic.activityTypeLocalized();
return ActivityType.findByLocalizedString(context, localizedActivityType)
.getIconDrawableId();
}
@@ -10,19 +10,19 @@ import androidx.lifecycle.MutableLiveData;
import java.util.List;
import de.dennisguse.opentracks.data.AggregatedStatistic;
import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.data.TrackSelection;
import de.dennisguse.opentracks.data.models.Track;
public class AggregatedStatisticsModel extends AndroidViewModel {
private MutableLiveData<AggregatedStatistics> aggregatedStats;
private MutableLiveData<List<AggregatedStatistic>> aggregatedStats;
public AggregatedStatisticsModel(@NonNull Application application) {
super(application);
}
public LiveData<AggregatedStatistics> getAggregatedStats(@Nullable TrackSelection selection) {
public LiveData<List<AggregatedStatistic>> getAggregatedStats(@Nullable TrackSelection selection) {
if (aggregatedStats == null) {
aggregatedStats = new MutableLiveData<>();
loadAggregatedStats(selection);
@@ -41,11 +41,7 @@ public class AggregatedStatisticsModel extends AndroidViewModel {
private void loadAggregatedStats(TrackSelection selection) {
new Thread(() -> {
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(getApplication().getApplicationContext());
List<Track> tracks = selection != null ? contentProviderUtils.getTracks(selection) : contentProviderUtils.getTracks();
AggregatedStatistics aggregatedStatistics = new AggregatedStatistics(tracks);
aggregatedStats.postValue(aggregatedStatistics);
aggregatedStats.postValue(contentProviderUtils.getAggregatedStatisticsForTracks(selection));
}).start();
}
}