diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt index 7499c717c1..6e218e60c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt @@ -61,8 +61,6 @@ import com.vitorpamplona.amethyst.ui.components.ShareMediaAction import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext // Hard ceiling on the inline thumbnail bitmap, in pixels. Prevents OOM on very tall/large pages. private const val THUMBNAIL_MAX_DIM_PX = 1600 @@ -158,12 +156,12 @@ private fun LoadedPdfPreviewCard( value = try { PdfFetcher - .fetchSnapshot(content.url, { Amethyst.instance.diskCache }) { url -> - accountViewModel.httpClientBuilder.okHttpClientForPreview(url) - }.use { snapshot -> - withContext(Dispatchers.IO) { - renderFirstPage(snapshot.data.toFile(), targetWidthPx) - } + .useSnapshot( + url = content.url, + diskCache = { Amethyst.instance.diskCache }, + okHttpClient = { url -> accountViewModel.httpClientBuilder.okHttpClientForPreview(url) }, + ) { snapshot -> + renderFirstPage(snapshot.data.toFile(), targetWidthPx) }.also { result -> // Same cache the image and video paths use, so a PDF that has been rendered // once lays out at its real shape on every later visit instead of growing diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt index 20e6408a72..4376d12716 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt @@ -82,6 +82,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -223,7 +224,14 @@ private fun PdfViewerContent( val handleForDispose = handleState DisposableEffect(handleForDispose) { onDispose { - handleForDispose?.close() + // Off the main thread: closing the cache snapshot takes Coil's global DiskLruCache + // lock, which its cleanup pass holds across bursts of unlinks. Under the render mutex, + // so a page render still in flight finishes before the renderer is closed under it. + handleForDispose?.let { handle -> + Amethyst.instance.applicationIOScope.launch { + handle.mutex.withLock { handle.close() } + } + } } } diff --git a/commons/plans/2026-08-30-commons-migration-sweep.md b/commons/plans/2026-08-30-commons-migration-sweep.md index d24a6a3134..bf9c67a45d 100644 --- a/commons/plans/2026-08-30-commons-migration-sweep.md +++ b/commons/plans/2026-08-30-commons-migration-sweep.md @@ -1439,7 +1439,7 @@ functional bugs. The follow-ups: | What | To | The blocker, and how it went | |---|---|---| -| `ScreenLayout` tier logic, `CappedScreenContent`, the pane widths | `commonsUI/…/ui/layouts/ScreenLayout.kt` | `material3-window-size-class` (app-only): its width breakpoints (Medium ≥ 600dp, Expanded ≥ 840dp) are inlined, and `ScreenLayoutTest`'s 17 cases pass against them. `LocalConfiguration` stays in the app, which asks the shared `rememberScreenLayoutSpec(widthDp, heightDp)`, so Desktop can supply its own window size. | +| `ScreenLayout` tier logic, `CappedScreenContent`, the pane widths | `commonsUI/…/ui/layouts/ScreenLayout.kt` | `material3-window-size-class` (app-only): its width breakpoints (Medium ≥ 600dp, Expanded ≥ 840dp) are inlined, and `ScreenLayoutTest` (18 cases, now in commonsUI jvmTest) passes against them. `LocalConfiguration` stays in the app, which asks the shared `rememberScreenLayoutSpec(widthDp, heightDp)`, so Desktop can supply its own window size. | | `animatedViewerChromeInset`, `rememberViewerControlsVisibility` | `commonsUI/…/ui/components/ViewerChrome.kt` | They sat in a file full of Android window code. Only `ImmersiveSystemBarsEffect` (Window/insets controller) stays. | | `SlidingCarousel` | `commonsUI/…/ui/components` | needed the inset above | | `AudioWaveformReadOnly` | `commonsUI/…/ui/components` | two enums from the Android-only audiowaveform library, now local enums with the same values | @@ -1472,12 +1472,13 @@ Desktop still has its own `ToggleableTimeAgoText` and the older `commons/…/uti A transitive-blocker sweep of the 1,385 files under `amethyst/…/ui/`, after this round: -- **80 files have no blocker left.** Many are headless and belong in `commons`, not `commonsUI`: - - filter assemblers and `*LastRead`; - - `NewMessageTagger`, `SplitConversor`, `PubKeyFormatter`, `SettingsCatalog`; - - the Tor status/dialog VM; - - `ChatBubbleLayout` + `ChatGroupPosition` + `JumboEmoji` + `NewDateOrSubjectDivisor` + `AutoScrollToNewest`. - - The chat bubble set is the next obvious batch: Desktop's `ui/chats/ChatBubbleLayout.kt` is an older fork of it. +- **80 files have no blocker left.** + - Many are headless and belong in `commons`, not `commonsUI`: the filter assemblers and + `*LastRead`, `NewMessageTagger`, `SplitConversor`, `PubKeyFormatter`, `SettingsCatalog`, + and the Tor status/dialog VM. + - The Compose ones include the chat bubble set (`ChatBubbleLayout`, `ChatGroupPosition`, + `JumboEmoji`, `NewDateOrSubjectDivisor`, `AutoScrollToNewest`). It moved to `commonsUI` + in the follow-up below. Desktop's `ui/chats/ChatBubbleLayout.kt` is an older fork of it. - **`AccountViewModel` is the wall:** - 892 files touch it, and 310 touch nothing else app-side. Yet swapping it for an interface frees only 76 files by itself, because the rest call hub composables that are blocked themselves. - The ui files use 210 distinct members of it. The note renderers use 28. @@ -1515,12 +1516,40 @@ A transitive-blocker sweep of the 1,385 files under `amethyst/…/ui/`, after th - `PdfFetcher` takes the disk cache as a provider, read on the IO dispatcher, so a PDF card composing on a cold start doesn't build the app's lazy cache on the main thread. - The two DM lists resolve `TimeAgoLabels` once per list instead of once per row. - - The iOS `DateSkeletonFormatter` rebuilds on a locale or time-zone change, like the - other actuals. + - The iOS `DateSkeletonFormatter` rebuilds on a locale change, as Android's does. (The + JVM one also keys on the time zone; iOS caches its system zone until reset, so keying on + it there would cost lookups and still not notice.) - `ScreenLayoutTest` pins the inclusive 600dp boundary. - Two stale KDoc links and a same-package import are fixed. - The generated baseline profile has its stable-name entries repointed: `NowProviderKt`, and the five theme functions now in `AmethystThemeKt`. Its R8 lambda entries were already stale before this round (it still lists - `MarkDownStyleOnDark` under `ThemeKt`), so **regenerate the profile** after these - moves. + `MarkDownStyleOnDark` under `ThemeKt`), and the new theme root + (`AmethystThemeKt;->AmethystMaterialTheme`, `isDarkTheme`) has no entries at all, so + **regenerate the profile** after these moves. + +### Second audit, and one Desktop decision + +- **Fixed:** + - `PdfPreviewCard` closed its cache snapshot on the main thread, after `withContext(IO)` + returned. That was older than this branch. Closing takes the same global DiskLruCache + lock as opening, so `PdfFetcher.useSnapshot` now fetches, runs the block and closes, all + in one IO block, leaving no suspension point for cancellation to leak through. + - The PDF viewer's `onDispose` now closes its handle on the app IO scope, under the + render mutex. + - JVM and iOS built a time-of-day formatter per feed item; they now share one cached + instance. `NSDateFormatter` is costly to build. + - `timeAgoShort` no longer allocates an unused fallback lambda per tick on Android. + - The iOS skeleton formatter keys on the locale only, like Android. iOS caches its system + zone until reset, so keying on the zone cost lookups and still never saw a change. + - New tests: `JumboEmojiTest` (counts, ZWJ/skin tone/flag/keycap sequences, bubble + shapes). `ScreenLayoutTest` moved to commonsUI jvmTest, beside the code it tests. +- **Desktop needs `jdk.localedata` before it uses these formatters.** + - `desktopApp/build.gradle.kts` `nativeDistributions.modules(...)` doesn't include it, + so the packaged runtime carries only en/root CLDR data. + - `DateTimeFormatter.ofLocalizedPattern` then gives en-GB `Jan 5, 2024` and de-DE + `2024 Jan 5`, and the JDK tests (full runtime) can't see it. + - Desktop's existing `java.time` formatting (`DesktopScheduleAtPicker`) already has the + same gap. + - Adding the module costs about +28 MB to the unpacked runtime. That is a packaging + call, left for the Desktop phase. diff --git a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.android.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.android.kt index 1aa0e916ea..385e7335e5 100644 --- a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.android.kt +++ b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.android.kt @@ -60,12 +60,11 @@ actual fun rememberTimeOfDayFormatter(): (epochMillis: Long) -> String { return remember(context) { { epochMillis -> DateFormat.getTimeFormat(context).format(Date(epochMillis)) } } } -actual fun relativeTimeSpanShort( +actual fun relativeTimeSpanShortOrNull( epochMillis: Long, nowMillis: Long, nowLabel: String, - fallback: () -> String, -): String { +): String? { val humanReadable = DateUtils .getRelativeTimeSpanString( diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.kt index 36bfd747a6..94711c83d9 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.kt @@ -25,7 +25,8 @@ import androidx.compose.runtime.Composable /** * Formats instants with a Unicode LDML date skeleton ("yMMMd", "MMMd", "yMMM"), letting the * platform pick the default locale's field order ("MMM d, y" in en-US, "d MMM y" in en-GB). - * Safe to call from any thread. Rebuilds itself when the default locale changes. + * Safe to call from any thread. Rebuilds itself when the default locale changes (the JVM actual + * also on a time-zone change; Android and iOS keep the zone they were built with). */ expect class DateSkeletonFormatter( skeleton: String, @@ -37,20 +38,19 @@ expect class DateSkeletonFormatter( expect fun calendarYearAndDay(epochMillis: Long): Int /** - * A time-of-day formatter ("14:32" / "2:32 PM") that follows the platform's own 12/24-hour - * preference, which on Android is a system setting rather than a locale property. + * A time-of-day formatter ("14:32" / "2:32 PM"). Android and iOS follow the system 12/24-hour + * setting; the JVM follows the locale. Identity-stable, so it can key a `remember`, and cheap to + * call for every item in a feed: platforms whose formatter is costly share one cached instance. */ @Composable expect fun rememberTimeOfDayFormatter(): (epochMillis: Long) -> String /** * The platform's abbreviated relative span ("5 min. ago", "Yesterday"), [nowLabel] for an - * instant that rounds to now or lies ahead, and [fallback] where the platform has no such - * formatter. + * instant that rounds to now or lies ahead, or null where the platform has no such formatter. */ -expect fun relativeTimeSpanShort( +expect fun relativeTimeSpanShortOrNull( epochMillis: Long, nowMillis: Long, nowLabel: String, - fallback: () -> String, -): String +): String? diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/TimeAgoFormatter.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/TimeAgoFormatter.kt index e0a8137456..649b149804 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/TimeAgoFormatter.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/TimeAgoFormatter.kt @@ -375,5 +375,5 @@ fun timeAgoShort( labels: TimeAgoLabels, ): String { if (time == null) return " " - return relativeTimeSpanShort(time * 1000, TimeUtils.nowMillis(), labels.now) { timeAgoWith(time, labels, prefix = "") } + return relativeTimeSpanShortOrNull(time * 1000, TimeUtils.nowMillis(), labels.now) ?: timeAgoWith(time, labels, prefix = "") } diff --git a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.ios.kt b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.ios.kt index e64f02c89d..3a03a0d9e7 100644 --- a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.ios.kt +++ b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.ios.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.commons.ui.note import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import platform.Foundation.NSCalendar import platform.Foundation.NSCalendarUnitDay import platform.Foundation.NSCalendarUnitYear @@ -30,10 +29,8 @@ import platform.Foundation.NSDateFormatter import platform.Foundation.NSDateFormatterNoStyle import platform.Foundation.NSDateFormatterShortStyle import platform.Foundation.NSLocale -import platform.Foundation.NSTimeZone import platform.Foundation.currentLocale import platform.Foundation.dateWithTimeIntervalSince1970 -import platform.Foundation.defaultTimeZone import platform.Foundation.localeIdentifier import kotlin.concurrent.Volatile @@ -41,7 +38,9 @@ private fun dateOf(epochMillis: Long) = NSDate.dateWithTimeIntervalSince1970(epo /** * NSDateFormatter is thread-safe for formatting on iOS 7+; the template picks the locale's order. - * Rebuilt when the current locale or time zone changes, like the other actuals. + * Rebuilt when the current locale changes, as on Android. The time zone is the one in effect when + * it was built: iOS caches the system zone until `NSTimeZone.resetSystemTimeZone()`, so keying + * on it would cost two lookups per call and still not notice a change. */ actual class DateSkeletonFormatter actual constructor( private val skeleton: String, @@ -55,7 +54,7 @@ actual class DateSkeletonFormatter actual constructor( actual fun format(epochMillis: Long): String { val locale = NSLocale.currentLocale - val key = locale.localeIdentifier + "|" + NSTimeZone.defaultTimeZone.name + val key = locale.localeIdentifier val formatter = cached?.takeIf { it.key == key }?.formatter ?: NSDateFormatter() @@ -75,21 +74,22 @@ actual fun calendarYearAndDay(epochMillis: Long): Int { return year * 1000 + day } -@Composable -actual fun rememberTimeOfDayFormatter(): (epochMillis: Long) -> String = - remember { - val formatter = - NSDateFormatter().apply { - dateStyle = NSDateFormatterNoStyle - timeStyle = NSDateFormatterShortStyle - } - val format: (Long) -> String = { epochMillis -> formatter.stringFromDate(dateOf(epochMillis)) } - format +// NSDateFormatter is costly to build, so every feed item shares this one instead of each +// composing its own. It follows the system 12/24-hour setting (the short time style does). +private val timeOfDayFormatter by lazy { + NSDateFormatter().apply { + dateStyle = NSDateFormatterNoStyle + timeStyle = NSDateFormatterShortStyle } +} -actual fun relativeTimeSpanShort( +private val timeOfDay: (Long) -> String = { epochMillis -> timeOfDayFormatter.stringFromDate(dateOf(epochMillis)) } + +@Composable +actual fun rememberTimeOfDayFormatter(): (epochMillis: Long) -> String = timeOfDay + +actual fun relativeTimeSpanShortOrNull( epochMillis: Long, nowMillis: Long, nowLabel: String, - fallback: () -> String, -): String = fallback() +): String? = null diff --git a/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/pdf/PdfFetcher.kt b/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/pdf/PdfFetcher.kt index 5af7e879e5..f211373bb8 100644 --- a/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/pdf/PdfFetcher.kt +++ b/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/pdf/PdfFetcher.kt @@ -42,39 +42,63 @@ object PdfFetcher { url: String, diskCache: () -> DiskCache, okHttpClient: (String) -> OkHttpClient, - ): DiskCache.Snapshot = - withContext(Dispatchers.IO) { - val diskCache = diskCache() - // Covers the cache-hit fast path too, not just the download below it. openSnapshot() - // contends on the global DiskLruCache lock, which Coil's cleanup pass holds across a - // burst of unlink syscalls (see DeferredDeleteFileSystem) — calling it from a caller - // that happens to be on the main thread stalls the frame for that whole burst, and the - // hit path is exactly the one a feed takes when a PDF card scrolls back into view. - diskCache.openSnapshot(url)?.let { return@withContext it } + ): DiskCache.Snapshot = withContext(Dispatchers.IO) { openOrDownload(url, diskCache, okHttpClient) } - val editor = diskCache.openEditor(url) ?: throw IOException("Unable to open cache editor for $url") - try { - val request = - Request - .Builder() - .url(url) - .get() - .build() + /** The fetch itself. Callers run it on the IO dispatcher. */ + private suspend fun openOrDownload( + url: String, + diskCache: () -> DiskCache, + okHttpClient: (String) -> OkHttpClient, + ): DiskCache.Snapshot { + val cache = diskCache() + // Covers the cache-hit fast path too, not just the download below it. openSnapshot() + // contends on the global DiskLruCache lock, which Coil's cleanup pass holds across a + // burst of unlink syscalls (see DeferredDeleteFileSystem) — calling it from a caller + // that happens to be on the main thread stalls the frame for that whole burst, and the + // hit path is exactly the one a feed takes when a PDF card scrolls back into view. + cache.openSnapshot(url)?.let { return it } - okHttpClient(url).newCall(request).executeAsync().use { response -> - if (!response.isSuccessful) { - throw IOException("PDF download failed: ${response.code}") - } - diskCache.fileSystem.write(editor.data) { - val bytes = writeAll(response.body.source()) - if (bytes == 0L) throw IOException("PDF download failed: empty response body") - } + val editor = cache.openEditor(url) ?: throw IOException("Unable to open cache editor for $url") + try { + val request = + Request + .Builder() + .url(url) + .get() + .build() + + okHttpClient(url).newCall(request).executeAsync().use { response -> + if (!response.isSuccessful) { + throw IOException("PDF download failed: ${response.code}") + } + cache.fileSystem.write(editor.data) { + val bytes = writeAll(response.body.source()) + if (bytes == 0L) throw IOException("PDF download failed: empty response body") } - - editor.commitAndOpenSnapshot() ?: throw IOException("Unable to commit cache editor for $url") - } catch (t: Throwable) { - runCatching { editor.abort() } - throw t } + + return editor.commitAndOpenSnapshot() ?: throw IOException("Unable to commit cache editor for $url") + } catch (t: Throwable) { + runCatching { editor.abort() } + throw t + } + } + + /** + * Fetches like [fetchSnapshot], runs [block] on the snapshot, and closes it, all on the IO + * dispatcher. Prefer this to `fetchSnapshot(...).use { }` from a main-thread caller: there the + * close would run on main, and closing takes the same global DiskLruCache lock as opening. + * The snapshot is created by the last non-suspending step of the fetch and handed straight + * to `use`, inside one `withContext`, so a cancellation cannot land between the two and leak + * it open (which returning through [fetchSnapshot]'s own `withContext` could). + */ + suspend fun useSnapshot( + url: String, + diskCache: () -> DiskCache, + okHttpClient: (String) -> OkHttpClient, + block: (DiskCache.Snapshot) -> T, + ): T = + withContext(Dispatchers.IO) { + openOrDownload(url, diskCache, okHttpClient).use(block) } } diff --git a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.jvm.kt b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.jvm.kt index 18b5273f35..3ad02fe674 100644 --- a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.jvm.kt +++ b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.jvm.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.commons.ui.note import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -58,17 +57,31 @@ actual class DateSkeletonFormatter actual constructor( } } -@Composable -actual fun rememberTimeOfDayFormatter(): (epochMillis: Long) -> String = - remember { - val formatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withZone(ZoneId.systemDefault()) - val format: (Long) -> String = { epochMillis -> formatter.format(Instant.ofEpochMilli(epochMillis)) } - format - } +private class CachedTimeOfDay( + val locale: Locale, + val zone: ZoneId, + val formatter: DateTimeFormatter, +) -actual fun relativeTimeSpanShort( +@Volatile private var cachedTimeOfDay: CachedTimeOfDay? = null + +/** One formatter for every item, rebuilt only when the locale or zone changes. */ +private val timeOfDay: (Long) -> String = { epochMillis -> + val locale = Locale.getDefault() + val zone = ZoneId.systemDefault() + val formatter = + cachedTimeOfDay?.takeIf { it.locale == locale && it.zone == zone }?.formatter + ?: DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale).withZone(zone).also { + cachedTimeOfDay = CachedTimeOfDay(locale, zone, it) + } + formatter.format(Instant.ofEpochMilli(epochMillis)) +} + +@Composable +actual fun rememberTimeOfDayFormatter(): (epochMillis: Long) -> String = timeOfDay + +actual fun relativeTimeSpanShortOrNull( epochMillis: Long, nowMillis: Long, nowLabel: String, - fallback: () -> String, -): String = fallback() +): String? = null diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/JumboEmojiTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/JumboEmojiTest.kt new file mode 100644 index 0000000000..1872d94557 --- /dev/null +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/JumboEmojiTest.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chats.ui + +import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeMe +import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeMeTop +import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeThemBottom +import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeThemMiddle +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +// Emoji are written as escapes so the file stays unambiguous on disk (see CLAUDE.md). +class JumboEmojiTest { + private val grin = "\uD83D\uDE00" // U+1F600 + private val thumbsUpMedium = "\uD83D\uDC4D\uD83C\uDFFD" // U+1F44D + skin tone U+1F3FD + private val family = "\uD83D\uDC69\u200D\uD83D\uDC67" // woman ZWJ girl: draws as one + private val flagUs = "\uD83C\uDDFA\uD83C\uDDF8" // two regional indicators + private val keycapOne = "1\uFE0F\u20E3" // '1' + VS16 + combining keycap + private val redHeart = "\u2764\uFE0F" // U+2764 + VS16 + + @Test + fun countsUpToThreeEmoji() { + assertEquals(1, jumboEmojiCount(grin)) + assertEquals(2, jumboEmojiCount(grin + redHeart)) + assertEquals(3, jumboEmojiCount(grin + grin + grin)) + } + + @Test + fun moreThanThreeIsNotJumbo() = assertEquals(0, jumboEmojiCount(grin + grin + grin + grin)) + + @Test + fun anyTextMakesItNotJumbo() { + assertEquals(0, jumboEmojiCount("hi $grin")) + assertEquals(0, jumboEmojiCount("${grin}a")) + } + + @Test + fun whitespaceIsIgnored() = assertEquals(2, jumboEmojiCount(" $grin \n $grin ")) + + @Test + fun emptyOrBlankIsZero() { + assertEquals(0, jumboEmojiCount("")) + assertEquals(0, jumboEmojiCount(" ")) + } + + @Test + fun modifiersAndZwjSequencesCountAsOne() { + assertEquals(1, jumboEmojiCount(thumbsUpMedium)) + assertEquals(1, jumboEmojiCount(family)) + assertEquals(1, jumboEmojiCount(redHeart)) + } + + @Test + fun flagsNeedBothRegionalIndicators() { + assertEquals(1, jumboEmojiCount(flagUs)) + assertEquals(0, jumboEmojiCount("\uD83C\uDDFA")) // half a flag + } + + @Test + fun keycapsNeedTheirMark() { + assertEquals(1, jumboEmojiCount(keycapOne)) + assertEquals(0, jumboEmojiCount("1")) // a bare digit is text + assertEquals(0, jumboEmojiCount("1 $grin")) + } + + @Test + fun bubbleShapeFollowsSideAndPosition() { + assertSame(ChatBubbleShapeMe, chatBubbleShapeFor(true, ChatGroupPosition.SINGLE)) + assertSame(ChatBubbleShapeMeTop, chatBubbleShapeFor(true, ChatGroupPosition.TOP)) + assertSame(ChatBubbleShapeThemMiddle, chatBubbleShapeFor(false, ChatGroupPosition.MIDDLE)) + assertSame(ChatBubbleShapeThemBottom, chatBubbleShapeFor(false, ChatGroupPosition.BOTTOM)) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayoutTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/ScreenLayoutTest.kt similarity index 93% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayoutTest.kt rename to commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/ScreenLayoutTest.kt index 5c99632843..a52dd5f1eb 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayoutTest.kt +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/ScreenLayoutTest.kt @@ -18,11 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.layouts +package com.vitorpamplona.amethyst.commons.ui.layouts -import com.vitorpamplona.amethyst.commons.ui.layouts.NavigationStyle -import com.vitorpamplona.amethyst.commons.ui.layouts.decideNavigationStyle -import com.vitorpamplona.amethyst.commons.ui.layouts.hasRoomForNotificationPanel import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue