Proper handling of timezone data on UI. Fixes #301.

Details:
- Date and time handled by OffsetDateTime Java class.
- Track default name with offset (for date local and ISO 8601 options).
- TrackListActivity: shows relative today's date (like Today, Yesterday, 22 Dec, 22 Dec 2021, etc).
- StatisticsRecordedFragment: always shows the original date and time. For example, if you record an activity at 20h then it always will show 20h regardless the zone are where you are.
- Added new tests.
This commit is contained in:
Román Ginés
2021-12-29 18:33:35 +01:00
committed by Román Martínez
parent 1631836327
commit 5cd6992c90
14 changed files with 196 additions and 97 deletions
@@ -326,7 +326,7 @@ public class MarkerListActivity extends AbstractActivity implements DeleteMarker
public Loader<Cursor> onCreateLoader(int arg0, Bundle arg1) {
final String[] PROJECTION = new String[]{MarkerColumns._ID,
MarkerColumns.NAME, MarkerColumns.DESCRIPTION, MarkerColumns.CATEGORY,
MarkerColumns.TIME, MarkerColumns.PHOTOURL};
MarkerColumns.TIME, MarkerColumns.PHOTOURL, MarkerColumns.TRACKID};
if (searchQuery == null) {
if (track != null) {
@@ -49,6 +49,9 @@ import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;
import java.time.Duration;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Arrays;
@@ -190,6 +193,7 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
int totalTimeIndex = cursor.getColumnIndexOrThrow(TracksColumns.TOTALTIME);
int totalDistanceIndex = cursor.getColumnIndexOrThrow(TracksColumns.TOTALDISTANCE);
int startTimeIndex = cursor.getColumnIndexOrThrow(TracksColumns.STARTTIME);
int startTimeOffsetIndex = cursor.getColumnIndexOrThrow(TracksColumns.STARTTIME_OFFSET);
int categoryIndex = cursor.getColumnIndexOrThrow(TracksColumns.CATEGORY);
int descriptionIndex = cursor.getColumnIndexOrThrow(TracksColumns.DESCRIPTION);
int markerCountIndex = cursor.getColumnIndexOrThrow(TracksColumns.MARKER_COUNT);
@@ -203,12 +207,14 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
String totalDistance = StringUtils.formatDistance(TrackListActivity.this, Distance.of(cursor.getDouble(totalDistanceIndex)), metricUnits);
int markerCount = cursor.getInt(markerCountIndex);
long startTime = cursor.getLong(startTimeIndex);
int startTimeOffset = cursor.getInt(startTimeOffsetIndex);
String category = icon != null && !icon.equals("") ? null : cursor.getString(categoryIndex);
String description = cursor.getString(descriptionIndex);
ListItemUtils.setListItem(TrackListActivity.this, view, isRecording, recordingStatus.isPaused(),
iconId, R.string.image_track, name, totalTime, totalDistance, markerCount,
startTime, true, category, description, false);
OffsetDateTime.ofInstant(Instant.ofEpochMilli(startTime), ZoneOffset.ofTotalSeconds(startTimeOffset)),
category, description, false);
}
};
viewBinding.trackList.setAdapter(resourceCursorAdapter);
@@ -515,7 +521,7 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
@Override
public Loader<Cursor> onCreateLoader(int arg0, Bundle arg1) {
final String[] PROJECTION = new String[]{TracksColumns._ID, TracksColumns.NAME,
TracksColumns.DESCRIPTION, TracksColumns.CATEGORY, TracksColumns.STARTTIME,
TracksColumns.DESCRIPTION, TracksColumns.CATEGORY, TracksColumns.STARTTIME, TracksColumns.STARTTIME_OFFSET,
TracksColumns.TOTALDISTANCE, TracksColumns.TOTALTIME, TracksColumns.ICON, TracksColumns.MARKER_COUNT};
final String sortOrder = TracksColumns.STARTTIME + " DESC";
@@ -19,9 +19,15 @@ import androidx.cursoradapter.widget.ResourceCursorAdapter;
import java.io.IOException;
import java.io.InputStream;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.content.data.MarkerColumns;
import de.dennisguse.opentracks.content.data.Track;
import de.dennisguse.opentracks.content.provider.ContentProviderUtils;
import de.dennisguse.opentracks.util.ListItemUtils;
import de.dennisguse.opentracks.util.MarkerUtils;
@@ -66,6 +72,7 @@ public class MarkerResourceCursorAdapter extends ResourceCursorAdapter implement
int categoryIndex = cursor.getColumnIndex(MarkerColumns.CATEGORY);
int descriptionIndex = cursor.getColumnIndex(MarkerColumns.DESCRIPTION);
int photoUrlIndex = cursor.getColumnIndex(MarkerColumns.PHOTOURL);
int trackIdIndex = cursor.getColumnIndex(MarkerColumns.TRACKID);
long id = cursor.getLong(idIndex);
int iconId = MarkerUtils.ICON_ID;
@@ -74,6 +81,7 @@ public class MarkerResourceCursorAdapter extends ResourceCursorAdapter implement
String category = cursor.getString(categoryIndex);
String description = cursor.getString(descriptionIndex);
String photoUrl = cursor.getString(photoUrlIndex);
long trackId = cursor.getLong(trackIdIndex);
view.setTag(String.valueOf(id));
@@ -96,7 +104,10 @@ public class MarkerResourceCursorAdapter extends ResourceCursorAdapter implement
}
}
ListItemUtils.setListItem(activity, view, false, true, iconId, R.string.image_marker, name, null, null, 0, time, false, category, description, hasPhoto);
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(context);
Track track = contentProviderUtils.getTrack(new Track.Id(trackId));
ListItemUtils.setListItem(activity, view, false, true, iconId, R.string.image_marker, name, null, null, 0,
OffsetDateTime.ofInstant(Instant.ofEpochMilli(time), track.getZoneOffset()), category, description, hasPhoto);
}
public void clear() {
@@ -22,6 +22,8 @@ import android.util.Pair;
import androidx.annotation.VisibleForTesting;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.content.data.Distance;
@@ -150,7 +152,7 @@ public class DescriptionGenerator {
}
// Recorded time
builder.append(context.getString(R.string.description_recorded_time, StringUtils.formatDateTime(context, stats.getStartTime())));
builder.append(context.getString(R.string.description_recorded_time, StringUtils.formatDateTime(OffsetDateTime.ofInstant(stats.getStartTime(), ZoneId.systemDefault()))));
builder.append(lineBreak);
return builder.toString();
@@ -82,10 +82,10 @@ public class FilterDialogFragment extends DialogFragment {
TextInputEditText dateTo = layout.findViewById(R.id.filter_date_edit_text_to);
LocalDateTime firstDayThisWeek = LocalDate.now().with(WeekFields.of(Locale.getDefault()).getFirstDayOfWeek()).atStartOfDay();
dateFrom.setText(StringUtils.formatDate(getActivity(), firstDayThisWeek));
dateFrom.setText(StringUtils.formatLocalDateTime(firstDayThisWeek));
datePickerFrom.init(firstDayThisWeek.getYear(), firstDayThisWeek.getMonthValue() - 1, firstDayThisWeek.getDayOfMonth(), (view, year, monthOfYear, dayOfMonth) -> {
LocalDateTime localDateTime = LocalDateTime.of(year, monthOfYear + 1, dayOfMonth, 0, 0, 0);
dateFrom.setText(StringUtils.formatDate(getActivity(), localDateTime));
dateFrom.setText(StringUtils.formatLocalDateTime(localDateTime));
datePickerFrom.setVisibility(View.GONE);
datePickerTo.setMinDate(localDateTime.toInstant(ZoneOffset.ofTotalSeconds(0)).toEpochMilli());
if (localDateTime.isAfter(LocalDateTime.of(datePickerTo.getYear(), datePickerTo.getMonth() + 1, datePickerTo.getDayOfMonth(), 23, 59, 59))) {
@@ -94,10 +94,10 @@ public class FilterDialogFragment extends DialogFragment {
});
LocalDateTime lastDayThisWeek = firstDayThisWeek.plusDays(6).withHour(23).withMinute(59).withSecond(59);
dateTo.setText(StringUtils.formatDate(getActivity(), lastDayThisWeek));
dateTo.setText(StringUtils.formatLocalDateTime(lastDayThisWeek));
datePickerTo.init(lastDayThisWeek.getYear(), lastDayThisWeek.getMonthValue() - 1, lastDayThisWeek.getDayOfMonth(), (view, year, monthOfYear, dayOfMonth) -> {
LocalDateTime localDateTime = LocalDateTime.of(year, monthOfYear + 1, dayOfMonth, 23, 59, 59);
dateTo.setText(StringUtils.formatDate(getActivity(), localDateTime));
dateTo.setText(StringUtils.formatLocalDateTime(localDateTime));
datePickerTo.setVisibility(View.GONE);
});
@@ -179,7 +179,7 @@ public class StatisticsRecordedFragment extends Fragment {
private void loadTrackDescription(@NonNull Track track) {
viewBinding.statsNameValue.setText(track.getName());
viewBinding.statsDescriptionValue.setText(track.getDescription());
viewBinding.statsStartDatetimeValue.setText(StringUtils.formatDateTime(getContext(), track.getTrackStatistics().getStartTime()));
viewBinding.statsStartDatetimeValue.setText(StringUtils.formatDateTime(track.getStartTime()));
}
private void updateUI() {
@@ -9,6 +9,7 @@ import android.util.Pair;
import androidx.annotation.NonNull;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import de.dennisguse.opentracks.R;
@@ -59,13 +60,12 @@ class TrackRecordingManager {
insertTrackPoint(trackId, segmentStartTrackPoint);
//TODO Pass TrackPoint
track.setName(TrackNameUtils.getTrackName(context, trackId, segmentStartTrackPoint.getTime()));
String category = PreferencesUtils.getDefaultActivity();
track.setCategory(category);
track.setIcon(TrackIconUtils.getIconValue(context, category));
track.setTrackStatistics(trackStatisticsUpdater.getTrackStatistics());
//TODO Pass TrackPoint
track.setName(TrackNameUtils.getTrackName(context, trackId, track.getStartTime()));
contentProviderUtils.updateTrack(track);
currentSegmentHasTrackPoint = false;
@@ -17,8 +17,6 @@
package de.dennisguse.opentracks.util;
import android.content.Context;
import android.graphics.BitmapFactory;
import android.text.format.DateUtils;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup.LayoutParams;
@@ -26,6 +24,9 @@ import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import de.dennisguse.opentracks.R;
/**
@@ -51,13 +52,12 @@ public class ListItemUtils {
* @param totalTime the total time value
* @param totalDistance the total distance value
* @param markerCount the marker count
* @param startTime the start time value
* @param useRelativeTime true to display relative time if appropriate
* @param offsetDateTime the start time with offset
* @param category the category value
* @param description the description value
* @param hasPhoto true if this list item has photo
*/
public static void setListItem(Context context, View view, boolean isRecording, boolean isPaused, int iconId, int iconContentDescriptionId, String name, String totalTime, String totalDistance, int markerCount, long startTime, boolean useRelativeTime, String category, String description, boolean hasPhoto) {
public static void setListItem(Context context, View view, boolean isRecording, boolean isPaused, int iconId, int iconContentDescriptionId, String name, String totalTime, String totalDistance, int markerCount, OffsetDateTime offsetDateTime, String category, String description, boolean hasPhoto) {
// Set icon
if (isRecording) {
iconId = isPaused ? R.drawable.ic_track_paused : R.drawable.ic_track_recording;
@@ -104,12 +104,16 @@ public class ListItemUtils {
setTextView(context, markerCountTextView, markerCountValue, hasPhoto);
// Set date/time
String[] dateTime = getDateTime(isRecording, context, startTime, useRelativeTime);
TextView dateTextView = view.findViewById(R.id.list_item_date);
setTextView(context, dateTextView, dateTime[0], hasPhoto);
TextView timeTextView = view.findViewById(R.id.list_item_time);
setTextView(context, timeTextView, dateTime[1], hasPhoto);
String dateValue = null;
String timeValue = null;
if (!isRecording) {
dateValue = StringUtils.formatDateTodayRelative(context, offsetDateTime);
timeValue = offsetDateTime.format(DateTimeFormatter.ofPattern("hh:mm"));
}
setTextView(context, dateTextView, dateValue, hasPhoto);
setTextView(context, timeTextView, timeValue, hasPhoto);
// Set category and description
TextView categoryDescriptionTextView = view.findViewById(R.id.list_item_category_description);
@@ -133,27 +137,6 @@ public class ListItemUtils {
params.gravity = timeDistanceTextView.getVisibility() == View.GONE && markerCountIcon.getVisibility() == View.GONE ? Gravity.TOP : Gravity.CENTER_VERTICAL;
}
private static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
// Raw height and width of image
final int height = options.outHeight;
final int width = options.outWidth;
int inSampleSize = 1;
if (height > reqHeight || width > reqWidth) {
final int halfHeight = height / 2;
final int halfWidth = width / 2;
// Calculate the largest inSampleSize value that is a power of 2 and keeps both height and width larger than the requested height and width.
while ((halfHeight / inSampleSize) >= reqHeight
&& (halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2;
}
}
return inSampleSize;
}
/**
* Gets a string for share owner, total time, and total distance.
*
@@ -177,30 +160,6 @@ public class ListItemUtils {
return builder.toString();
}
/**
* Gets the date and time as an array of two strings.
*
* @param isRecording true if recording
* @param context the context
* @param time the start time
*/
private static String[] getDateTime(boolean isRecording, Context context, long time, boolean useRelativeTime) {
if (isRecording || time == 0L) {
return new String[]{null, null};
}
boolean isToday = DateUtils.isToday(time);
int timeFlags = DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_ABBREV_ALL;
if (isToday && useRelativeTime) {
return new String[]{DateUtils.getRelativeTimeSpanString(time, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE).toString(), null};
} else if (isToday) {
return new String[]{DateUtils.formatDateTime(context, time, timeFlags), null};
}
return new String[]{DateUtils.formatDateTime(context, time, DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_ABBREV_ALL), DateUtils.formatDateTime(context, time, timeFlags)};
}
/**
* Sets a text view.
*
@@ -15,6 +15,8 @@
*/
package de.dennisguse.opentracks.util;
import static java.time.temporal.ChronoUnit.DAYS;
import android.content.Context;
import android.location.Location;
import android.text.TextUtils;
@@ -28,12 +30,14 @@ import java.math.RoundingMode;
import java.text.DecimalFormat;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.time.temporal.TemporalAccessor;
import de.dennisguse.opentracks.R;
@@ -54,18 +58,47 @@ public class StringUtils {
}
/**
* Formats the date and time based on user's phone date/time preferences.
* Formats the date and time of the OffsetDateTime (using default Locale format)
*/
public static String formatDateTime(Context context, Instant time) {
return DateUtils.formatDateTime(context, time.toEpochMilli(), DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_NUMERIC_DATE)
+ " " + DateUtils.formatDateTime(context, time.toEpochMilli(), DateUtils.FORMAT_SHOW_TIME);
public static String formatDateTime(OffsetDateTime odt) {
return odt.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM));
}
/**
* Formats the date and time based on user's phone date/time preferences.
* Formats the date and time with the offset (using default Locale format).
*/
public static String formatDate(Context context, LocalDateTime localDateTime) {
return DateUtils.formatDateTime(context, localDateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(), DateUtils.FORMAT_SHOW_DATE);
public static String formatDateTimeWithOffset(OffsetDateTime odt) {
return odt.toZonedDateTime().format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL));
}
public static String formatLocalDateTime(LocalDateTime localDateTime) {
return localDateTime.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL));
}
/**
* Formats the date relative to today date.
*/
public static String formatDateTodayRelative(Context context, OffsetDateTime odt) {
LocalDate today = LocalDateTime.ofInstant(Instant.now(), ZoneId.systemDefault()).toLocalDate();
LocalDate ld = odt.toLocalDate();
long daysBetween = DAYS.between(ld, today);
if (daysBetween == 0) {
// Today
return context.getString(R.string.generic_today);
} else if (daysBetween == 1) {
// Yesterday
return context.getString(R.string.generic_yesterday);
} else if (daysBetween < 7) {
// Name of the week day
return ld.format(DateTimeFormatter.ofPattern("EEEE"));
} else if (today.getYear() == ld.getYear()) {
// Short date without year
return ld.format(DateTimeFormatter.ofPattern("d MMM"));
} else {
// Short date with year
return ld.format(DateTimeFormatter.ofPattern("d MMM y"));
}
}
/**
@@ -18,11 +18,7 @@ package de.dennisguse.opentracks.util;
import android.content.Context;
import androidx.annotation.VisibleForTesting;
import java.text.SimpleDateFormat;
import java.time.Instant;
import java.util.Locale;
import java.time.OffsetDateTime;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.content.data.Track;
@@ -35,21 +31,17 @@ import de.dennisguse.opentracks.settings.PreferencesUtils;
*/
public class TrackNameUtils {
//TODO Could be available in java.time?
@VisibleForTesting
static final String ISO_8601_FORMAT = "yyyy-MM-dd HH:mm";
private TrackNameUtils() {
}
//TODO Should not access sharedPreferences; trackName should be an ENUM.
public static String getTrackName(Context context, Track.Id trackId, Instant startTime) {
public static String getTrackName(Context context, Track.Id trackId, OffsetDateTime startTime) {
String trackName = PreferencesUtils.getString(R.string.track_name_key, context.getString(R.string.track_name_default));
if (trackName.equals(context.getString(R.string.settings_recording_track_name_date_local_value))) {
return StringUtils.formatDateTime(context, startTime);
return StringUtils.formatDateTimeWithOffset(startTime);
} else if (trackName.equals(context.getString(R.string.settings_recording_track_name_date_iso_8601_value))) {
return new SimpleDateFormat(ISO_8601_FORMAT, Locale.US).format(startTime.toEpochMilli());
return StringUtils.formatDateTimeIso8601(startTime.toInstant(), startTime.getOffset());
} else {
return context.getString(R.string.track_name_format, trackId.getId());
}