From 9da149a868a91f91874ca06a88776b6f64d3a499 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 31 Aug 2023 11:38:05 -0400 Subject: [PATCH] Convert DateUtils to kotlin, improve perf with caching. --- .../components/ConversationItemFooter.java | 2 +- .../conversation/ConversationMessage.java | 3 +- .../database/model/MessageRecord.java | 3 +- .../viewer/page/StoryViewerPageFragment.kt | 2 +- .../securesms/util/DateUtils.java | 346 --------------- .../thoughtcrime/securesms/util/DateUtils.kt | 398 ++++++++++++++++++ 6 files changed, 402 insertions(+), 352 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java index 369c600b51..8ca833d53c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java @@ -326,7 +326,7 @@ public class ConversationItemFooter extends ConstraintLayout { timestamp = messageRecord.getDateSent(); } } - String date = DateUtils.getSimpleRelativeTimeSpanString(getContext(), locale, timestamp); + String date = DateUtils.getDatelessRelativeTimeSpanString(getContext(), locale, timestamp); if (displayMode != ConversationItemDisplayMode.DETAILED && messageRecord.isEditMessage() && messageRecord.isLatestRevision()) { date = getContext().getString(R.string.ConversationItem_edited_timestamp_footer, date); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java index d3351814e6..4228f037e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java @@ -28,7 +28,6 @@ import java.security.MessageDigest; import java.util.Collections; import java.util.List; import java.util.Locale; -import java.util.Objects; /** * A view level model used to pass arbitrary message related information needed @@ -206,7 +205,7 @@ public class ConversationMessage { } String formattedDate = MessageRecordUtil.isScheduled(messageRecord) ? DateUtils.getOnlyTimeString(context, Locale.getDefault(), ((MediaMmsMessageRecord) messageRecord).getScheduledDate()) - : DateUtils.getSimpleRelativeTimeSpanString(context, Locale.getDefault(), messageRecord.getTimestamp()); + : DateUtils.getDatelessRelativeTimeSpanString(context, Locale.getDefault(), messageRecord.getTimestamp()); return new ConversationMessage(messageRecord, styledAndMentionBody != null ? styledAndMentionBody : mentionsUpdate != null ? mentionsUpdate.getBody() : body, mentionsUpdate != null ? mentionsUpdate.getMentions() : null, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index 6c59d61aaf..f708c08dcd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -74,7 +74,6 @@ import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.Set; -import java.util.UUID; import java.util.function.Consumer; import java.util.function.Function; @@ -366,7 +365,7 @@ public abstract class MessageRecord extends DisplayRecord { } protected @NonNull String getCallDateString(@NonNull Context context) { - return DateUtils.getSimpleRelativeTimeSpanString(context, Locale.getDefault(), getDateSent()); + return DateUtils.getDatelessRelativeTimeSpanString(context, Locale.getDefault(), getDateSent()); } protected static @NonNull UpdateDescription fromRecipient(@NonNull Recipient recipient, diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt index 690319781a..ac94aaa78c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt @@ -908,7 +908,7 @@ class StoryViewerPageFragment : } private fun presentDate(date: TextView, storyPost: StoryPost) { - val formattedDate = DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), storyPost.dateInMilliseconds) + val formattedDate = DateUtils.getBriefRelativeTimeSpanString(requireContext(), Locale.getDefault(), storyPost.dateInMilliseconds) if (date.text != formattedDate) { date.text = formattedDate } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java deleted file mode 100644 index 6c6c91bb2f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java +++ /dev/null @@ -1,346 +0,0 @@ -/* - * Copyright (C) 2014 Open Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.thoughtcrime.securesms.util; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.os.Build; -import android.text.format.DateFormat; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.R; - -import java.text.DateFormatSymbols; -import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.util.Calendar; -import java.util.Date; -import java.util.Locale; -import java.util.concurrent.TimeUnit; - -/** - * Utility methods to help display dates in a nice, easily readable way. - */ -public class DateUtils extends android.text.format.DateUtils { - - @SuppressWarnings("unused") - private static final String TAG = Log.tag(DateUtils.class); - private static final ThreadLocal DATE_FORMAT = new ThreadLocal<>(); - private static final ThreadLocal BRIEF_EXACT_FORMAT = new ThreadLocal<>(); - private static final long MAX_RELATIVE_TIMESTAMP = TimeUnit.MINUTES.toMillis(3); - private static final int HALF_A_YEAR_IN_DAYS = 182; - - private static boolean isWithin(final long millis, final long span, final TimeUnit unit) { - return System.currentTimeMillis() - millis <= unit.toMillis(span); - } - - private static boolean isWithinAbs(final long millis, final long span, final TimeUnit unit) { - return Math.abs(System.currentTimeMillis() - millis) <= unit.toMillis(span); - } - - private static boolean isYesterday(final long when) { - return DateUtils.isToday(when + TimeUnit.DAYS.toMillis(1)); - } - - private static int convertDelta(final long millis, TimeUnit to) { - return (int) to.convert(System.currentTimeMillis() - millis, TimeUnit.MILLISECONDS); - } - - private static String getFormattedDateTime(long time, String template, Locale locale) { - final String localizedPattern = getLocalizedPattern(template, locale); - return setLowercaseAmPmStrings(new SimpleDateFormat(localizedPattern, locale), locale).format(new Date(time)); - } - - public static String getBriefRelativeTimeSpanString(final Context c, final Locale locale, final long timestamp) { - if (isWithin(timestamp, 1, TimeUnit.MINUTES)) { - return c.getString(R.string.DateUtils_just_now); - } else if (isWithin(timestamp, 1, TimeUnit.HOURS)) { - int mins = convertDelta(timestamp, TimeUnit.MINUTES); - return c.getResources().getString(R.string.DateUtils_minutes_ago, mins); - } else if (isWithin(timestamp, 1, TimeUnit.DAYS)) { - int hours = convertDelta(timestamp, TimeUnit.HOURS); - return c.getResources().getQuantityString(R.plurals.hours_ago, hours, hours); - } else if (isWithin(timestamp, 6, TimeUnit.DAYS)) { - return getFormattedDateTime(timestamp, "EEE", locale); - } else if (isWithin(timestamp, 365, TimeUnit.DAYS)) { - return getFormattedDateTime(timestamp, "MMM d", locale); - } else { - return getFormattedDateTime(timestamp, "MMM d, yyyy", locale); - } - } - - public static String getExtendedRelativeTimeSpanString(final Context c, final Locale locale, final long timestamp) { - if (isWithin(timestamp, 1, TimeUnit.MINUTES)) { - return c.getString(R.string.DateUtils_just_now); - } else if (isWithin(timestamp, 1, TimeUnit.HOURS)) { - int mins = (int)TimeUnit.MINUTES.convert(System.currentTimeMillis() - timestamp, TimeUnit.MILLISECONDS); - return c.getResources().getString(R.string.DateUtils_minutes_ago, mins); - } else { - StringBuilder format = new StringBuilder(); - if (isWithin(timestamp, 6, TimeUnit.DAYS)) format.append("EEE "); - else if (isWithin(timestamp, 365, TimeUnit.DAYS)) format.append("MMM d, "); - else format.append("MMM d, yyyy, "); - - if (DateFormat.is24HourFormat(c)) format.append("HH:mm"); - else format.append("hh:mm a"); - - return getFormattedDateTime(timestamp, format.toString(), locale); - } - } - - public static String getSimpleRelativeTimeSpanString(final Context context, final Locale locale, final long timestamp) { - if (isWithin(timestamp, 1, TimeUnit.MINUTES)) { - return context.getString(R.string.DateUtils_just_now); - } else if (isWithin(timestamp, 1, TimeUnit.HOURS)) { - int mins = (int) TimeUnit.MINUTES.convert(System.currentTimeMillis() - timestamp, TimeUnit.MILLISECONDS); - return context.getResources().getString(R.string.DateUtils_minutes_ago, mins); - } else { - return getOnlyTimeString(context, locale, timestamp); - } - } - - /** - * Formats a given timestamp as just the time. - * - * For example: - * For 12 hour locale: 7:23 pm - * For 24 hour locale: 19:23 - */ - public static String getOnlyTimeString(final Context context, final Locale locale, final long timestamp) { - String format = DateFormat.is24HourFormat(context) ? "HH:mm" : "hh:mm a"; - return getFormattedDateTime(timestamp, format, locale); - } - - public static String getTimeString(final Context c, final Locale locale, final long timestamp) { - StringBuilder format = new StringBuilder(); - - if (isSameDay(System.currentTimeMillis(), timestamp)) format.append(""); - else if (isWithinAbs(timestamp, 6, TimeUnit.DAYS)) format.append("EEE "); - else if (isWithinAbs(timestamp, 364, TimeUnit.DAYS)) format.append("MMM d, "); - else format.append("MMM d, yyyy, "); - - if (DateFormat.is24HourFormat(c)) format.append("HH:mm"); - else format.append("hh:mm a"); - - return getFormattedDateTime(timestamp, format.toString(), locale); - } - - /** - * Formats the passed timestamp based on the current time at a day precision. - * - * For example: - * - Today - * - Wed - * - Mon - * - Jan 31 - * - Feb 4 - * - Jan 12, 2033 - */ - public static String getDayPrecisionTimeString(Context context, Locale locale, long timestamp) { - SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd"); - - if (simpleDateFormat.format(System.currentTimeMillis()).equals(simpleDateFormat.format(timestamp))) { - return context.getString(R.string.DeviceListItem_today); - } else { - String format; - - if (isWithinAbs(timestamp, 6, TimeUnit.DAYS)) { - format = "EEE "; - } else if (isWithinAbs(timestamp, 365, TimeUnit.DAYS)) { - format = "MMM d"; - } else { - format = "MMM d, yyy"; - } - - return getFormattedDateTime(timestamp, format, locale); - } - } - - public static String getDayPrecisionTimeSpanString(Context context, Locale locale, long timestamp) { - SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd"); - - if (simpleDateFormat.format(System.currentTimeMillis()).equals(simpleDateFormat.format(timestamp))) { - return context.getString(R.string.DeviceListItem_today); - } else { - String format; - - if (isWithin(timestamp, 6, TimeUnit.DAYS)) format = "EEE "; - else if (isWithin(timestamp, 365, TimeUnit.DAYS)) format = "MMM d"; - else format = "MMM d, yyy"; - - return getFormattedDateTime(timestamp, format, locale); - } - } - - public static SimpleDateFormat getDetailedDateFormatter(Context context, Locale locale) { - String dateFormatPattern; - - if (DateFormat.is24HourFormat(context)) { - dateFormatPattern = getLocalizedPattern("MMM d, yyyy HH:mm:ss zzz", locale); - } else { - dateFormatPattern = getLocalizedPattern("MMM d, yyyy hh:mm:ss a zzz", locale); - } - - return new SimpleDateFormat(dateFormatPattern, locale); - } - - public static String getConversationDateHeaderString(@NonNull Context context, - @NonNull Locale locale, - long timestamp) - { - if (isToday(timestamp)) { - return context.getString(R.string.DateUtils_today); - } else if (isYesterday(timestamp)) { - return context.getString(R.string.DateUtils_yesterday); - } else if (isWithin(timestamp, HALF_A_YEAR_IN_DAYS, TimeUnit.DAYS)) { - return formatDateWithDayOfWeek(locale, timestamp); - } else { - return formatDateWithYear(locale, timestamp); - } - } - - public static String getScheduledMessagesDateHeaderString(@NonNull Context context, - @NonNull Locale locale, - long timestamp) - { - if (isToday(timestamp)) { - return context.getString(R.string.DateUtils_today); - } else if (isWithinAbs(timestamp, HALF_A_YEAR_IN_DAYS, TimeUnit.DAYS)) { - return formatDateWithDayOfWeek(locale, timestamp); - } else { - return formatDateWithYear(locale, timestamp); - } - } - - public static String getScheduledMessageDateString(@NonNull Context context, @NonNull Locale locale, long timestamp) { - String dayModifier; - if (isToday(timestamp)) { - Calendar calendar = Calendar.getInstance(locale); - if (calendar.get(Calendar.HOUR_OF_DAY) >= 19) { - dayModifier = context.getString(R.string.DateUtils_tonight); - } else { - dayModifier = context.getString(R.string.DateUtils_today); - } - } else { - dayModifier = context.getString(R.string.DateUtils_tomorrow); - } - String format = DateFormat.is24HourFormat(context) ? "HH:mm" : "hh:mm a"; - String time = getFormattedDateTime(timestamp, format, locale); - - return context.getString(R.string.DateUtils_schedule_at, dayModifier, time); - } - - public static String formatDateWithDayOfWeek(@NonNull Locale locale, long timestamp) { - return getFormattedDateTime(timestamp, "EEE, MMM d", locale); - } - - public static String formatDateWithYear(@NonNull Locale locale, long timestamp) { - return getFormattedDateTime(timestamp, "MMM d, yyyy", locale); - } - - public static String formatDate(@NonNull Locale locale, long timestamp) { - return getFormattedDateTime(timestamp, "EEE, MMM d, yyyy", locale); - } - - public static String formatDateWithMonthAndDay(@NonNull Locale locale, long timestamp) { - return getFormattedDateTime(timestamp, "MMMM dd", locale); - } - - public static String formatDateWithoutDayOfWeek(@NonNull Locale locale, long timestamp) { - return getFormattedDateTime(timestamp, "MMM d yyyy", locale); - } - - public static boolean isSameDay(long t1, long t2) { - String d1 = getDateFormat().format(new Date(t1)); - String d2 = getDateFormat().format(new Date(t2)); - - return d1.equals(d2); - } - - public static boolean isSameExtendedRelativeTimestamp(long second, long first) { - return second - first < MAX_RELATIVE_TIMESTAMP; - } - - private static String getLocalizedPattern(String template, Locale locale) { - return DateFormat.getBestDateTimePattern(locale, template); - } - - private static @NonNull SimpleDateFormat setLowercaseAmPmStrings(@NonNull SimpleDateFormat format, @NonNull Locale locale) { - DateFormatSymbols symbols = new DateFormatSymbols(locale); - - symbols.setAmPmStrings(new String[] { "am", "pm"}); - format.setDateFormatSymbols(symbols); - - return format; - } - - /** - * e.g. 2020-09-04T19:17:51Z - * https://www.iso.org/iso-8601-date-and-time-format.html - * - * Note: SDK_INT == 0 check needed to pass unit tests due to JVM date parser differences. - * - * @return The timestamp if able to be parsed, otherwise -1. - */ - @SuppressLint({ "ObsoleteSdkInt", "NewApi" }) - public static long parseIso8601(@Nullable String date) { - SimpleDateFormat format; - if (Build.VERSION.SDK_INT == 0 || Build.VERSION.SDK_INT >= 24) { - format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX", Locale.getDefault()); - } else { - format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()); - } - - if (Util.isEmpty(date)) { - return -1; - } - - try { - return format.parse(date).getTime(); - } catch (ParseException e) { - Log.w(TAG, "Failed to parse date.", e); - return -1; - } - } - - @SuppressLint("SimpleDateFormat") - private static SimpleDateFormat getDateFormat() { - SimpleDateFormat format = DATE_FORMAT.get(); - - if (format == null) { - format = new SimpleDateFormat("yyyyMMdd"); - DATE_FORMAT.set(format); - } - - return format; - } - - @SuppressLint("SimpleDateFormat") - private static SimpleDateFormat getBriefExactFormat() { - SimpleDateFormat format = BRIEF_EXACT_FORMAT.get(); - - if (format == null) { - format = new SimpleDateFormat(); - BRIEF_EXACT_FORMAT.set(format); - } - - return format; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt new file mode 100644 index 0000000000..7be0c4b33d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt @@ -0,0 +1,398 @@ +/* + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.util + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.text.format.DateFormat +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import java.text.DateFormatSymbols +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.concurrent.TimeUnit +import kotlin.math.abs +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit + +/** + * Utility methods to help display dates in a nice, easily readable way. + */ +object DateUtils : android.text.format.DateUtils() { + private val TAG = Log.tag(DateUtils::class.java) + private val MAX_RELATIVE_TIMESTAMP = 3.minutes.inWholeMilliseconds + private const val HALF_A_YEAR_IN_DAYS = 182 + + private val sameDayDateFormat: SimpleDateFormat by lazy { SimpleDateFormat("yyyyMMdd") } + + private val localizedTemplateCache: MutableMap = mutableMapOf() + private val dateFormatCache: MutableMap = mutableMapOf() + private val dateFormatSymbolsCache: MutableMap = mutableMapOf() + + private var is24HourDateCache: Is24HourDateEntry? = null + + /** + * A relative timestamp to use in space-constrained areas, like the conversation list. + */ + @JvmStatic + fun getBriefRelativeTimeSpanString(c: Context, locale: Locale, timestamp: Long): String { + return when { + timestamp.isWithin(1.minutes) -> { + c.getString(R.string.DateUtils_just_now) + } + timestamp.isWithin(1.hours) -> { + val minutes = timestamp.convertDeltaTo(DurationUnit.MINUTES) + c.resources.getString(R.string.DateUtils_minutes_ago, minutes) + } + timestamp.isWithin(1.days) -> { + val hours = timestamp.convertDeltaTo(DurationUnit.HOURS) + c.resources.getQuantityString(R.plurals.hours_ago, hours, hours) + } + timestamp.isWithin(6.days) -> { + timestamp.toDateString("EEE", locale) + } + timestamp.isWithin(365.days) -> { + timestamp.toDateString("MMM d", locale) + } + else -> { + timestamp.toDateString("MMM d, yyyy", locale) + } + } + } + + /** + * Similar to [getBriefRelativeTimeSpanString], except this will include additional time information in longer formats. + */ + @JvmStatic + fun getExtendedRelativeTimeSpanString(context: Context, locale: Locale, timestamp: Long): String { + return when { + timestamp.isWithin(1.minutes) -> { + context.getString(R.string.DateUtils_just_now) + } + timestamp.isWithin(1.hours) -> { + val minutes = timestamp.convertDeltaTo(DurationUnit.MINUTES) + context.resources.getString(R.string.DateUtils_minutes_ago, minutes) + } + else -> { + val format = StringBuilder() + + if (timestamp.isWithin(6.days)) { + format.append("EEE ") + } else if (timestamp.isWithin(365.days)) { + format.append("MMM d, ") + } else { + format.append("MMM d, yyyy, ") + } + + if (context.is24HourFormat()) { + format.append("HH:mm") + } else { + format.append("hh:mm a") + } + + timestamp.toDateString(format.toString(), locale) + } + } + } + + /** + * Returns a relative time string that will only use the time of day for longer intervals. The assumption is that it would be used in a context + * that communicates the date elsewhere. + */ + @JvmStatic + fun getDatelessRelativeTimeSpanString(context: Context, locale: Locale, timestamp: Long): String { + return when { + timestamp.isWithin(1.minutes) -> { + context.getString(R.string.DateUtils_just_now) + } + timestamp.isWithin(1.hours) -> { + val minutes = timestamp.convertDeltaTo(DurationUnit.MINUTES) + context.resources.getString(R.string.DateUtils_minutes_ago, minutes) + } + else -> { + getOnlyTimeString(context, locale, timestamp) + } + } + } + + /** + * Formats a given timestamp as just the time. + * + * For example: + * For 12 hour locale: 7:23 pm + * For 24 hour locale: 19:23 + */ + @JvmStatic + fun getOnlyTimeString(context: Context, locale: Locale, timestamp: Long): String { + val format = if (context.is24HourFormat()) "HH:mm" else "hh:mm a" + return timestamp.toDateString(format, locale) + } + + /** + * If on the same day, will return just the time. Otherwise it'll include relative date info. + */ + @JvmStatic + fun getTimeString(context: Context, locale: Locale, timestamp: Long): String { + val format = StringBuilder() + + if (isSameDay(System.currentTimeMillis(), timestamp)) { + format.append("") + } else if (timestamp.isWithinAbs(6.days)) { + format.append("EEE ") + } else if (timestamp.isWithinAbs(364.days)) { + format.append("MMM d, ") + } else { + format.append("MMM d, yyyy, ") + } + + if (context.is24HourFormat()) { + format.append("HH:mm") + } else { + format.append("hh:mm a") + } + + return timestamp.toDateString(format.toString(), locale) + } + + /** + * Formats the passed timestamp based on the current time at a day precision. + * + * For example: + * - Today + * - Wed + * - Mon + * - Jan 31 + * - Feb 4 + * - Jan 12, 2033 + */ + fun getDayPrecisionTimeString(context: Context, locale: Locale, timestamp: Long): String { + return if (isSameDay(System.currentTimeMillis(), timestamp)) { + context.getString(R.string.DeviceListItem_today) + } else { + val format: String = when { + timestamp.isWithinAbs(6.days) -> "EEE " + timestamp.isWithinAbs(365.days) -> "MMM d" + else -> "MMM d, yyy" + } + timestamp.toDateString(format, locale) + } + } + + @JvmStatic + fun getDayPrecisionTimeSpanString(context: Context, locale: Locale, timestamp: Long): String { + return if (isSameDay(System.currentTimeMillis(), timestamp)) { + context.getString(R.string.DeviceListItem_today) + } else { + val format: String = when { + timestamp.isWithin(6.days) -> "EEE " + timestamp.isWithin(365.days) -> "MMM d" + else -> "MMM d, yyy" + } + + timestamp.toDateString(format, locale) + } + } + + @JvmStatic + fun getDetailedDateFormatter(context: Context, locale: Locale): SimpleDateFormat { + val dateFormatPattern: String = if (context.is24HourFormat()) { + "MMM d, yyyy HH:mm:ss zzz".localizeTemplate(locale) + } else { + "MMM d, yyyy hh:mm:ss a zzz".localizeTemplate(locale) + } + return SimpleDateFormat(dateFormatPattern, locale) + } + + @JvmStatic + fun getConversationDateHeaderString( + context: Context, + locale: Locale, + timestamp: Long + ): String { + return if (isToday(timestamp)) { + context.getString(R.string.DateUtils_today) + } else if (isYesterday(timestamp)) { + context.getString(R.string.DateUtils_yesterday) + } else if (timestamp.isWithin(HALF_A_YEAR_IN_DAYS.days)) { + formatDateWithDayOfWeek(locale, timestamp) + } else { + formatDateWithYear(locale, timestamp) + } + } + + @JvmStatic + fun getScheduledMessagesDateHeaderString(context: Context, locale: Locale, timestamp: Long): String { + return if (isToday(timestamp)) { + context.getString(R.string.DateUtils_today) + } else if (timestamp.isWithinAbs(HALF_A_YEAR_IN_DAYS.days)) { + formatDateWithDayOfWeek(locale, timestamp) + } else { + formatDateWithYear(locale, timestamp) + } + } + + fun getScheduledMessageDateString(context: Context, locale: Locale, timestamp: Long): String { + val dayModifier: String = if (isToday(timestamp)) { + val calendar = Calendar.getInstance(locale) + if (calendar[Calendar.HOUR_OF_DAY] >= 19) { + context.getString(R.string.DateUtils_tonight) + } else { + context.getString(R.string.DateUtils_today) + } + } else { + context.getString(R.string.DateUtils_tomorrow) + } + val format = if (context.is24HourFormat()) "HH:mm" else "hh:mm a" + val time = timestamp.toDateString(format, locale) + return context.getString(R.string.DateUtils_schedule_at, dayModifier, time) + } + + fun formatDateWithDayOfWeek(locale: Locale, timestamp: Long): String { + return timestamp.toDateString("EEE, MMM d", locale) + } + + fun formatDateWithYear(locale: Locale, timestamp: Long): String { + return timestamp.toDateString("MMM d, yyyy", locale) + } + + @JvmStatic + fun formatDate(locale: Locale, timestamp: Long): String { + return timestamp.toDateString("EEE, MMM d, yyyy", locale) + } + + @JvmStatic + fun formatDateWithoutDayOfWeek(locale: Locale, timestamp: Long): String { + return timestamp.toDateString("MMM d yyyy", locale) + } + + /** + * True if the two timestamps occur on the same day, otherwise false. + */ + @JvmStatic + fun isSameDay(t1: Long, t2: Long): Boolean { + val d1 = sameDayDateFormat.format(Date(t1)) + val d2 = sameDayDateFormat.format(Date(t2)) + return d1 == d2 + } + + @JvmStatic + fun isSameExtendedRelativeTimestamp(second: Long, first: Long): Boolean { + return second - first < MAX_RELATIVE_TIMESTAMP + } + + /** + * e.g. 2020-09-04T19:17:51Z + * https://www.iso.org/iso-8601-date-and-time-format.html + * + * Note: SDK_INT == 0 check needed to pass unit tests due to JVM date parser differences. + * + * @return The timestamp if able to be parsed, otherwise -1. + */ + @JvmStatic + @SuppressLint("ObsoleteSdkInt", "NewApi") + fun parseIso8601(date: String?): Long { + val format: SimpleDateFormat = if (Build.VERSION.SDK_INT == 0 || Build.VERSION.SDK_INT >= 24) { + "yyyy-MM-dd'T'HH:mm:ssX".toSimpleDateFormat(Locale.getDefault()) + } else { + "yyyy-MM-dd'T'HH:mm:ssZ".toSimpleDateFormat(Locale.getDefault()) + } + + return if (date.isNullOrBlank()) { + -1 + } else { + try { + format.parse(date)?.time ?: -1 + } catch (e: ParseException) { + Log.w(TAG, "Failed to parse date.", e) + -1 + } + } + } + + private fun Long.isWithin(duration: Duration): Boolean { + return System.currentTimeMillis() - this <= duration.inWholeMilliseconds + } + + private fun Long.isWithinAbs(duration: Duration): Boolean { + return abs(System.currentTimeMillis() - this) <= duration.inWholeMilliseconds + } + + private fun isYesterday(time: Long): Boolean { + return isToday(time + TimeUnit.DAYS.toMillis(1)) + } + + private fun Context.is24HourFormat(): Boolean { + is24HourDateCache?.let { + if (it.lastUpdated.isWithin(10.seconds)) { + return it.value + } + } + + val result = DateFormat.is24HourFormat(this) + is24HourDateCache = Is24HourDateEntry(result, System.currentTimeMillis()) + return result + } + + private fun Long.convertDeltaTo(unit: DurationUnit): Int { + return (System.currentTimeMillis() - this).milliseconds.toInt(unit) + } + + private fun Long.toDateString(template: String, locale: Locale): String { + return template + .localizeTemplate(locale) + .toSimpleDateFormat(locale) + .setLowercaseAmPmStrings(locale) + .format(Date(this)) + } + + private fun String.localizeTemplate(locale: Locale): String { + val key = TemplateLocale(this, locale) + return localizedTemplateCache.getOrPut(key) { + DateFormat.getBestDateTimePattern(key.locale, key.template) + } + } + + private fun String.toSimpleDateFormat(locale: Locale): SimpleDateFormat { + val key = TemplateLocale(this, locale) + return dateFormatCache.getOrPut(key) { + SimpleDateFormat(key.template, key.locale) + } + } + + private fun SimpleDateFormat.setLowercaseAmPmStrings(locale: Locale): SimpleDateFormat { + val symbols = dateFormatSymbolsCache.getOrPut(locale) { + DateFormatSymbols(locale).apply { + amPmStrings = arrayOf("am", "pm") + } + } + this.dateFormatSymbols = symbols + return this + } + + private data class TemplateLocale(val template: String, val locale: Locale) + + private data class Is24HourDateEntry(val value: Boolean, val lastUpdated: Long) +}