mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Make TimeAgoFormatter + CalendarTimeFormat thread-safe
The module-level mutable SimpleDateFormat formatters in these files were read concurrently — UI composition on the main thread, and LocalCache.justVerify calling dateFormatter() from background event- verification coroutines for failed-signature log lines. SimpleDateFormat is not thread-safe (mutable internal Calendar), and updateFormattersIfNeeded reassigned the field mid-format. Race produced corrupted timestamp strings and occasionally NumberFormatException inside format(). Replace the shared-var pattern with a small LocaleAwareFormatter that wraps a ThreadLocal<Pair<Locale, SimpleDateFormat>>. Each thread caches its own instance and rebuilds lazily when Locale.getDefault() changes — no locks, no contention, same allocation profile after warm-up. Apply the same pattern to CalendarTimeFormat for consistency; today its callers are all main-thread but the structure was identical.
This commit is contained in:
@@ -38,21 +38,35 @@ private const val YEAR_SKELETON = "yMMMd"
|
||||
private const val MONTH_SKELETON = "MMMd"
|
||||
private const val YEAR_NO_DAY_SKELETON = "yMMM"
|
||||
|
||||
private var locale: Locale = Locale.getDefault()
|
||||
private var yearFormatter = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, YEAR_SKELETON), locale)
|
||||
private var monthFormatter = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, MONTH_SKELETON), locale)
|
||||
private var yearNoDayFormatter = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, YEAR_NO_DAY_SKELETON), locale)
|
||||
/**
|
||||
* Per-thread cached [SimpleDateFormat] keyed off the current default [Locale].
|
||||
*
|
||||
* `SimpleDateFormat` is mutable and not thread-safe, and these formatters are
|
||||
* read from both the UI thread (composition) and background coroutines
|
||||
* (e.g. `LocalCache.justVerify` logging failed event verifications). A bare
|
||||
* `var` shared across threads would race on the formatter's internal Calendar.
|
||||
* Using `ThreadLocal` gives each thread its own instance — no locks, no
|
||||
* allocation per call, and we rebuild lazily on locale change.
|
||||
*/
|
||||
private class LocaleAwareFormatter(
|
||||
private val skeleton: String,
|
||||
) {
|
||||
private val cache = ThreadLocal<Pair<Locale, SimpleDateFormat>>()
|
||||
|
||||
private fun updateFormattersIfNeeded() {
|
||||
val current = Locale.getDefault()
|
||||
if (locale != current) {
|
||||
locale = current
|
||||
yearFormatter = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, YEAR_SKELETON), locale)
|
||||
monthFormatter = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, MONTH_SKELETON), locale)
|
||||
yearNoDayFormatter = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, YEAR_NO_DAY_SKELETON), locale)
|
||||
fun get(): SimpleDateFormat {
|
||||
val current = Locale.getDefault()
|
||||
val cached = cache.get()
|
||||
if (cached != null && cached.first == current) return cached.second
|
||||
val fresh = SimpleDateFormat(DateFormat.getBestDateTimePattern(current, skeleton), current)
|
||||
cache.set(current to fresh)
|
||||
return fresh
|
||||
}
|
||||
}
|
||||
|
||||
private val yearFormatter = LocaleAwareFormatter(YEAR_SKELETON)
|
||||
private val monthFormatter = LocaleAwareFormatter(MONTH_SKELETON)
|
||||
private val yearNoDayFormatter = LocaleAwareFormatter(YEAR_NO_DAY_SKELETON)
|
||||
|
||||
/**
|
||||
* Formats a Unix timestamp (seconds) as an absolute date/time string, picking the
|
||||
* granularity from how far away the timestamp is:
|
||||
@@ -71,8 +85,6 @@ fun timeAbsolute(
|
||||
if (time == null) return " "
|
||||
if (time == 0L) return prefix + stringRes(context, R.string.never)
|
||||
|
||||
updateFormattersIfNeeded()
|
||||
|
||||
val timeMs = time * 1000
|
||||
val now = Calendar.getInstance()
|
||||
val then = Calendar.getInstance().apply { timeInMillis = timeMs }
|
||||
@@ -84,8 +96,8 @@ fun timeAbsolute(
|
||||
|
||||
return when {
|
||||
sameDay -> prefix + timeOfDay
|
||||
sameYear -> prefix + monthFormatter.format(timeMs) + ", " + timeOfDay
|
||||
else -> prefix + yearFormatter.format(timeMs)
|
||||
sameYear -> prefix + monthFormatter.get().format(timeMs) + ", " + timeOfDay
|
||||
else -> prefix + yearFormatter.get().format(timeMs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,13 +122,11 @@ fun timeAgo(
|
||||
|
||||
return when {
|
||||
timeDifference > TimeUtils.ONE_YEAR -> {
|
||||
updateFormattersIfNeeded()
|
||||
prefix + yearFormatter.format(time * 1000)
|
||||
prefix + yearFormatter.get().format(time * 1000)
|
||||
}
|
||||
|
||||
timeDifference > TimeUtils.ONE_MONTH -> {
|
||||
updateFormattersIfNeeded()
|
||||
prefix + monthFormatter.format(time * 1000)
|
||||
prefix + monthFormatter.get().format(time * 1000)
|
||||
}
|
||||
|
||||
timeDifference > TimeUtils.ONE_DAY -> {
|
||||
@@ -148,13 +158,11 @@ fun timeAgoNoDot(
|
||||
|
||||
return when {
|
||||
timeDifference > TimeUtils.ONE_YEAR -> {
|
||||
updateFormattersIfNeeded()
|
||||
yearFormatter.format(time * 1000)
|
||||
yearFormatter.get().format(time * 1000)
|
||||
}
|
||||
|
||||
timeDifference > TimeUtils.ONE_MONTH -> {
|
||||
updateFormattersIfNeeded()
|
||||
monthFormatter.format(time * 1000)
|
||||
monthFormatter.get().format(time * 1000)
|
||||
}
|
||||
|
||||
timeDifference > TimeUtils.ONE_DAY -> {
|
||||
@@ -186,13 +194,11 @@ fun timeAgoNoDotNoDay(
|
||||
|
||||
return when {
|
||||
timeDifference > TimeUtils.ONE_YEAR -> {
|
||||
updateFormattersIfNeeded()
|
||||
yearNoDayFormatter.format(time * 1000)
|
||||
yearNoDayFormatter.get().format(time * 1000)
|
||||
}
|
||||
|
||||
timeDifference > TimeUtils.ONE_MONTH -> {
|
||||
updateFormattersIfNeeded()
|
||||
monthFormatter.format(time * 1000)
|
||||
monthFormatter.get().format(time * 1000)
|
||||
}
|
||||
|
||||
timeDifference > TimeUtils.ONE_DAY -> {
|
||||
@@ -224,13 +230,11 @@ fun timeAheadNoDot(
|
||||
|
||||
return when {
|
||||
timeDifference > TimeUtils.ONE_YEAR -> {
|
||||
updateFormattersIfNeeded()
|
||||
yearFormatter.format(time * 1000)
|
||||
yearFormatter.get().format(time * 1000)
|
||||
}
|
||||
|
||||
timeDifference > TimeUtils.ONE_MONTH -> {
|
||||
updateFormattersIfNeeded()
|
||||
monthFormatter.format(time * 1000)
|
||||
monthFormatter.get().format(time * 1000)
|
||||
}
|
||||
|
||||
timeDifference > TimeUtils.ONE_DAY -> {
|
||||
@@ -262,11 +266,9 @@ fun dateFormatter(
|
||||
val timeDifference = TimeUtils.now() - time
|
||||
|
||||
return if (timeDifference > TimeUtils.ONE_YEAR) {
|
||||
updateFormattersIfNeeded()
|
||||
yearFormatter.format(time * 1000)
|
||||
yearFormatter.get().format(time * 1000)
|
||||
} else if (timeDifference > TimeUtils.ONE_DAY) {
|
||||
updateFormattersIfNeeded()
|
||||
monthFormatter.format(time * 1000)
|
||||
monthFormatter.get().format(time * 1000)
|
||||
} else {
|
||||
today
|
||||
}
|
||||
@@ -348,8 +350,7 @@ fun lastSeenSentence(
|
||||
}
|
||||
}
|
||||
|
||||
updateFormattersIfNeeded()
|
||||
val dateText = yearFormatter.format(time * 1000)
|
||||
val dateText = yearFormatter.get().format(time * 1000)
|
||||
|
||||
return stringRes(context, R.string.last_seen_on_date, dateText, durationText)
|
||||
}
|
||||
|
||||
+35
-35
@@ -35,31 +35,36 @@ private const val FULL_DATE_SKELETON = "EEEEMMMMdy" // "Monday, May 28, 2026" /
|
||||
private const val MONTH_YEAR_SKELETON = "MMMMy" // "May 2026" / "Mai 2026"
|
||||
private const val WEEKDAY_SHORT_SKELETON = "EEE" // "Mon" — order doesn't matter
|
||||
|
||||
// Cached at module scope: these are called per-cell in the calendar grid (≈42 cells
|
||||
// per month render, 7 per weekday header) — allocating a fresh SimpleDateFormat on
|
||||
// every call showed up as scroll-time GC churn before caching.
|
||||
private var cachedLocale: Locale = Locale.getDefault()
|
||||
private var dayMonthFormat = build(DAY_MONTH_SKELETON, cachedLocale)
|
||||
private var fullDateFormat = build(FULL_DATE_SKELETON, cachedLocale)
|
||||
private var monthYearFormat = build(MONTH_YEAR_SKELETON, cachedLocale)
|
||||
private var weekdayShortFormat = build(WEEKDAY_SHORT_SKELETON, cachedLocale)
|
||||
/**
|
||||
* Per-thread cached [SimpleDateFormat] keyed off the current default [Locale].
|
||||
*
|
||||
* Cached because these run per cell in the calendar grid (≈42 cells per month,
|
||||
* 7 per weekday header) — allocating a fresh SimpleDateFormat each call
|
||||
* showed up as scroll-time GC churn. Per-thread because SimpleDateFormat
|
||||
* isn't thread-safe; calendars today are read on the UI thread but future
|
||||
* callers could differ. ThreadLocal gives both — no locks, no contention,
|
||||
* lazy rebuild on locale change.
|
||||
*/
|
||||
private class LocaleAwareFormatter(
|
||||
private val skeleton: String,
|
||||
) {
|
||||
private val cache = ThreadLocal<Pair<Locale, SimpleDateFormat>>()
|
||||
|
||||
private fun build(
|
||||
skeleton: String,
|
||||
locale: Locale,
|
||||
): SimpleDateFormat = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, skeleton), locale)
|
||||
|
||||
private fun refreshFormattersIfLocaleChanged() {
|
||||
val current = Locale.getDefault()
|
||||
if (cachedLocale != current) {
|
||||
cachedLocale = current
|
||||
dayMonthFormat = build(DAY_MONTH_SKELETON, current)
|
||||
fullDateFormat = build(FULL_DATE_SKELETON, current)
|
||||
monthYearFormat = build(MONTH_YEAR_SKELETON, current)
|
||||
weekdayShortFormat = build(WEEKDAY_SHORT_SKELETON, current)
|
||||
fun get(): SimpleDateFormat {
|
||||
val current = Locale.getDefault()
|
||||
val cached = cache.get()
|
||||
if (cached != null && cached.first == current) return cached.second
|
||||
val fresh = SimpleDateFormat(DateFormat.getBestDateTimePattern(current, skeleton), current)
|
||||
cache.set(current to fresh)
|
||||
return fresh
|
||||
}
|
||||
}
|
||||
|
||||
private val dayMonthFormat = LocaleAwareFormatter(DAY_MONTH_SKELETON)
|
||||
private val fullDateFormat = LocaleAwareFormatter(FULL_DATE_SKELETON)
|
||||
private val monthYearFormat = LocaleAwareFormatter(MONTH_YEAR_SKELETON)
|
||||
private val weekdayShortFormat = LocaleAwareFormatter(WEEKDAY_SHORT_SKELETON)
|
||||
|
||||
// Time format respects the user's Android 12/24-hour system setting.
|
||||
private fun timeFormat(context: Context): java.text.DateFormat = DateFormat.getTimeFormat(context)
|
||||
|
||||
@@ -81,16 +86,16 @@ private fun formatTimeRange(
|
||||
end: Long?,
|
||||
context: Context,
|
||||
): String {
|
||||
refreshFormattersIfLocaleChanged()
|
||||
val dayMonth = dayMonthFormat.get()
|
||||
val time = timeFormat(context)
|
||||
val startMs = start * 1000
|
||||
val startStr = "${dayMonthFormat.format(Date(startMs))} · ${time.format(Date(startMs))}"
|
||||
val startStr = "${dayMonth.format(Date(startMs))} · ${time.format(Date(startMs))}"
|
||||
if (end == null || end == start) return startStr
|
||||
val endMs = end * 1000
|
||||
return if (isSameDay(startMs, endMs)) {
|
||||
"$startStr – ${time.format(Date(endMs))}"
|
||||
} else {
|
||||
"$startStr – ${dayMonthFormat.format(Date(endMs))} · ${time.format(Date(endMs))}"
|
||||
"$startStr – ${dayMonth.format(Date(endMs))} · ${time.format(Date(endMs))}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,26 +103,22 @@ private fun formatDateRange(
|
||||
start: Long,
|
||||
end: Long?,
|
||||
): String {
|
||||
refreshFormattersIfLocaleChanged()
|
||||
val startStr = dayMonthFormat.format(Date(start * 1000))
|
||||
val dayMonth = dayMonthFormat.get()
|
||||
val startStr = dayMonth.format(Date(start * 1000))
|
||||
if (end == null || end == start) return startStr
|
||||
return "$startStr – ${dayMonthFormat.format(Date(end * 1000))}"
|
||||
return "$startStr – ${dayMonth.format(Date(end * 1000))}"
|
||||
}
|
||||
|
||||
fun formatLongDate(unixSeconds: Long): String {
|
||||
refreshFormattersIfLocaleChanged()
|
||||
return fullDateFormat.format(Date(unixSeconds * 1000))
|
||||
}
|
||||
fun formatLongDate(unixSeconds: Long): String = fullDateFormat.get().format(Date(unixSeconds * 1000))
|
||||
|
||||
fun formatMonthYear(
|
||||
year: Int,
|
||||
monthZeroBased: Int,
|
||||
): String {
|
||||
refreshFormattersIfLocaleChanged()
|
||||
val cal = Calendar.getInstance()
|
||||
cal.clear()
|
||||
cal.set(year, monthZeroBased, 1)
|
||||
return monthYearFormat.format(cal.time)
|
||||
return monthYearFormat.get().format(cal.time)
|
||||
}
|
||||
|
||||
fun formatTimeOfDay(
|
||||
@@ -126,13 +127,12 @@ fun formatTimeOfDay(
|
||||
): String = timeFormat(context).format(Date(unixSeconds * 1000))
|
||||
|
||||
fun formatShortWeekday(weekdayZeroBased: Int): String {
|
||||
refreshFormattersIfLocaleChanged()
|
||||
val cal = Calendar.getInstance()
|
||||
cal.clear()
|
||||
cal.firstDayOfWeek = Calendar.SUNDAY
|
||||
cal.set(Calendar.DAY_OF_WEEK, Calendar.SUNDAY)
|
||||
cal.add(Calendar.DAY_OF_YEAR, weekdayZeroBased)
|
||||
return weekdayShortFormat.format(cal.time)
|
||||
return weekdayShortFormat.get().format(cal.time)
|
||||
}
|
||||
|
||||
private fun isSameDay(
|
||||
|
||||
+33
-27
@@ -30,24 +30,36 @@ import java.util.Locale
|
||||
// Month + day without year — month name is textual so order is unambiguous across locales.
|
||||
private const val MONTH_DATE_FORMAT = "MMM d"
|
||||
|
||||
private var locale = Locale.getDefault()
|
||||
/**
|
||||
* Per-thread cached [DateFormat] keyed off the current default [Locale].
|
||||
*
|
||||
* `DateFormat`/`SimpleDateFormat` are mutable and not thread-safe. These
|
||||
* formatters can be read from UI threads (composition) and from background
|
||||
* coroutines. `ThreadLocal` gives each thread its own instance — no locks,
|
||||
* no allocation per call, and we rebuild lazily on locale change.
|
||||
*/
|
||||
private class LocaleAwareFormatter(
|
||||
private val build: (Locale) -> DateFormat,
|
||||
) {
|
||||
private val cache = ThreadLocal<Pair<Locale, DateFormat>>()
|
||||
|
||||
// Locale-aware: en-US "May 28, 2026" · en-GB "28 May 2026" · de-DE "28.05.2026" · ja-JP "2026/05/28"
|
||||
private var yearFormatter = DateFormat.getDateInstance(DateFormat.MEDIUM, locale)
|
||||
private var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
|
||||
|
||||
// Locale-aware: en-US "2:32 PM" · en-GB "14:32" · de-DE "14:32"
|
||||
private var timeOnlyFormatter = DateFormat.getTimeInstance(DateFormat.SHORT, locale)
|
||||
|
||||
private fun updateFormattersIfNeeded() {
|
||||
if (locale != Locale.getDefault()) {
|
||||
locale = Locale.getDefault()
|
||||
yearFormatter = DateFormat.getDateInstance(DateFormat.MEDIUM, locale)
|
||||
monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
|
||||
timeOnlyFormatter = DateFormat.getTimeInstance(DateFormat.SHORT, locale)
|
||||
fun get(): DateFormat {
|
||||
val current = Locale.getDefault()
|
||||
val cached = cache.get()
|
||||
if (cached != null && cached.first == current) return cached.second
|
||||
val fresh = build(current)
|
||||
cache.set(current to fresh)
|
||||
return fresh
|
||||
}
|
||||
}
|
||||
|
||||
// Locale-aware: en-US "May 28, 2026" · en-GB "28 May 2026" · de-DE "28.05.2026" · ja-JP "2026/05/28"
|
||||
private val yearFormatter = LocaleAwareFormatter { DateFormat.getDateInstance(DateFormat.MEDIUM, it) }
|
||||
private val monthFormatter = LocaleAwareFormatter { SimpleDateFormat(MONTH_DATE_FORMAT, it) }
|
||||
|
||||
// Locale-aware: en-US "2:32 PM" · en-GB "14:32" · de-DE "14:32"
|
||||
private val timeOnlyFormatter = LocaleAwareFormatter { DateFormat.getTimeInstance(DateFormat.SHORT, it) }
|
||||
|
||||
/**
|
||||
* Formats a Unix timestamp (seconds) as a human-readable time ago string.
|
||||
* Returns strings like " • 5m", " • 2h", " • Dec 12"
|
||||
@@ -68,13 +80,11 @@ fun timeAgo(
|
||||
|
||||
return when {
|
||||
timeDifference > TimeUtils.ONE_YEAR -> {
|
||||
updateFormattersIfNeeded()
|
||||
prefix + yearFormatter.format(time * 1000)
|
||||
prefix + yearFormatter.get().format(time * 1000)
|
||||
}
|
||||
|
||||
timeDifference > TimeUtils.ONE_MONTH -> {
|
||||
updateFormattersIfNeeded()
|
||||
prefix + monthFormatter.format(time * 1000)
|
||||
prefix + monthFormatter.get().format(time * 1000)
|
||||
}
|
||||
|
||||
timeDifference > TimeUtils.ONE_DAY -> {
|
||||
@@ -135,13 +145,11 @@ fun dateFormatter(
|
||||
|
||||
return when {
|
||||
timeDifference > TimeUtils.ONE_YEAR -> {
|
||||
updateFormattersIfNeeded()
|
||||
yearFormatter.format(time * 1000)
|
||||
yearFormatter.get().format(time * 1000)
|
||||
}
|
||||
|
||||
timeDifference > TimeUtils.ONE_DAY -> {
|
||||
updateFormattersIfNeeded()
|
||||
monthFormatter.format(time * 1000)
|
||||
monthFormatter.get().format(time * 1000)
|
||||
}
|
||||
|
||||
else -> {
|
||||
@@ -171,8 +179,6 @@ fun timeAbsolute(
|
||||
val prefix = if (withDot) " • " else ""
|
||||
if (time == 0L) return prefix + never
|
||||
|
||||
updateFormattersIfNeeded()
|
||||
|
||||
val timeMs = time * 1000
|
||||
val now = Calendar.getInstance()
|
||||
val then = Calendar.getInstance().apply { timeInMillis = timeMs }
|
||||
@@ -180,12 +186,12 @@ fun timeAbsolute(
|
||||
val sameYear = now.get(Calendar.YEAR) == then.get(Calendar.YEAR)
|
||||
val sameDay = sameYear && now.get(Calendar.DAY_OF_YEAR) == then.get(Calendar.DAY_OF_YEAR)
|
||||
|
||||
val timeOfDay = timeOnlyFormatter.format(Date(timeMs))
|
||||
val timeOfDay = timeOnlyFormatter.get().format(Date(timeMs))
|
||||
|
||||
return when {
|
||||
sameDay -> prefix + timeOfDay
|
||||
sameYear -> prefix + monthFormatter.format(timeMs) + ", " + timeOfDay
|
||||
else -> prefix + yearFormatter.format(timeMs)
|
||||
sameYear -> prefix + monthFormatter.get().format(timeMs) + ", " + timeOfDay
|
||||
else -> prefix + yearFormatter.get().format(timeMs)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user