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:
Claude
2026-05-28 23:00:03 +00:00
parent c63c48a778
commit aeadbbd276
3 changed files with 106 additions and 99 deletions
@@ -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,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(
@@ -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)
}
}