From aeadbbd276f199ccc7392373c0d40b00989c6f41 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 23:00:03 +0000 Subject: [PATCH] Make TimeAgoFormatter + CalendarTimeFormat thread-safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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>. 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. --- .../amethyst/ui/note/TimeAgoFormatter.kt | 75 ++++++++++--------- .../loggedIn/calendars/CalendarTimeFormat.kt | 70 ++++++++--------- .../amethyst/commons/util/TimeAgoFormatter.kt | 60 ++++++++------- 3 files changed, 106 insertions(+), 99 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt index 1491014263..6e3305263f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt @@ -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>() -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt index 91140aecd0..2738041aa9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt @@ -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>() -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( diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt index 3577b46bf5..43f6d5270c 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt @@ -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>() -// 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) } }