From d9ba7187ca7123de20bc2eb08bfc60a90abd3314 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 15:17:51 +0000 Subject: [PATCH 1/5] fix(chats): make ChatroomLazyKey Bundle-storable LazyColumn keys are persisted in a SaveableStateHolder, which on Android must be Bundle-storable (primitives, Parcelable, or Serializable). The recent perf rework wrapped keys in plain data classes, which Kotlin does not auto-mark Serializable, so opening a public chat crashed with: java.lang.IllegalArgumentException: Type of the key PublicChannelLazyKey(channelId=...) is not supported. Mark the sealed interface Serializable, and decompose RoomId/ChatroomKey fields (which live in quartz commonMain and can't depend on the JVM-only Serializable interface) into String primitives. Each variant still wraps a stable per-chatroom identity, so reorders move rows instead of recreating them. --- .../chats/rooms/feed/ChatroomListFeedView.kt | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index b852165f1..8db59aef7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -48,13 +48,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderC import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent -import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import java.io.Serializable @Composable fun ChatroomListFeedView( @@ -139,10 +138,11 @@ private fun FeedLoaded( } // Stable per-chatroom key — derived from chatroom identity, not the latest -// message id, so reorders move the row instead of recreating it. Uses a -// sealed wrapper around an existing String/RoomId/ChatroomKey to avoid the -// StringBuilder + concatenation allocations of a typed-prefix string key. -private sealed interface ChatroomLazyKey +// message id, so reorders move the row instead of recreating it. Compose +// stores LazyColumn item keys in a SaveableStateHolder, which on Android +// requires Bundle-storable types, so each variant is Serializable and only +// holds primitives. +private sealed interface ChatroomLazyKey : Serializable private data class MarmotChatroomLazyKey( val groupId: HexKey, @@ -153,11 +153,12 @@ private data class PublicChannelLazyKey( ) : ChatroomLazyKey private data class EphemeralChannelLazyKey( - val roomId: RoomId, + val id: String, + val relayUrl: String, ) : ChatroomLazyKey private data class PrivateChatLazyKey( - val key: ChatroomKey, + val key: String, ) : ChatroomLazyKey private data class FallbackChatroomLazyKey( @@ -186,12 +187,12 @@ private fun chatroomLazyKey( } is EphemeralChatEvent -> { - event.roomId()?.let { EphemeralChannelLazyKey(it) } + event.roomId()?.let { EphemeralChannelLazyKey(it.id, it.relayUrl.url) } ?: FallbackChatroomLazyKey(item.idHex) } is ChatroomKeyable -> { - PrivateChatLazyKey(event.chatroomKey(myPubKey)) + PrivateChatLazyKey(event.chatroomKey(myPubKey).users.sorted().joinToString(",")) } else -> { From af6b8c8a522b1aaf11e0015eeef48d923c629e7c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 27 Apr 2026 11:29:28 -0400 Subject: [PATCH 2/5] Removes old MLKit labeler because of the terrible labels it was creating. --- amethyst/build.gradle | 1 - .../service/ai/MLKitImageLabelService.kt | 45 ++----------------- gradle/libs.versions.toml | 2 - 3 files changed, 4 insertions(+), 44 deletions(-) diff --git a/amethyst/build.gradle b/amethyst/build.gradle index fb4ddb6d8..ae6d0f09e 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -349,7 +349,6 @@ dependencies { // On-device alt-text suggestions: genai image description (preferred, descriptive sentences) // with image-labeling as a keyword-join fallback for devices without AICore. playImplementation libs.google.mlkit.genai.image.description - playImplementation libs.google.mlkit.image.labeling // PushNotifications playImplementation platform(libs.firebase.bom) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt index 25d044b34..57d734845 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt @@ -29,14 +29,8 @@ import com.google.mlkit.genai.imagedescription.ImageDescriber import com.google.mlkit.genai.imagedescription.ImageDescriberOptions import com.google.mlkit.genai.imagedescription.ImageDescription import com.google.mlkit.genai.imagedescription.ImageDescriptionRequest -import com.google.mlkit.vision.common.InputImage -import com.google.mlkit.vision.label.ImageLabeler -import com.google.mlkit.vision.label.ImageLabeling -import com.google.mlkit.vision.label.defaults.ImageLabelerOptions import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import kotlin.coroutines.resume /** * Unified alt-text suggestion service. @@ -47,15 +41,12 @@ import kotlin.coroutines.resume class MLKitImageLabelService( private val context: Context, ) { - private var labeler: ImageLabeler? = null private var describer: ImageDescriber? = null // FeatureStatus is an Int enum. Cached per-instance — describer availability does not flip // mid-session in practice, and one composer mount only needs to ask AICore once. @Volatile private var cachedGenAiStatus: Int? = null - private fun ensureLabeler(): ImageLabeler = labeler ?: ImageLabeling.getClient(ImageLabelerOptions.DEFAULT_OPTIONS).also { labeler = it } - private fun ensureDescriber(): ImageDescriber? = describer ?: try { @@ -66,26 +57,7 @@ class MLKitImageLabelService( null } - suspend fun labelImage(uri: Uri): List> = - withContext(Dispatchers.IO) { - try { - val image = InputImage.fromFilePath(context, uri) - val client = ensureLabeler() - suspendCancellableCoroutine { cont -> - client - .process(image) - .addOnSuccessListener { labels -> - cont.resume(labels.map { it.text to it.confidence }) - }.addOnFailureListener { - cont.resume(emptyList()) - } - } - } catch (_: Exception) { - emptyList() - } - } - - suspend fun suggestAltText(uri: Uri): String? = describeWithGenAi(uri) ?: labelKeywords(uri) + suspend fun suggestAltText(uri: Uri): String? = describeWithGenAi(uri) private suspend fun describeWithGenAi(uri: Uri): String? = withContext(Dispatchers.IO) { @@ -100,20 +72,13 @@ class MLKitImageLabelService( .runInference(request) .get() .description - ?.trim() - ?.takeIf { it.isNotEmpty() } + .trim() + .takeIf { it.isNotEmpty() } } catch (_: Exception) { null } } - private suspend fun labelKeywords(uri: Uri): String? { - val labels = labelImage(uri) - val confident = labels.filter { it.second >= MIN_CONFIDENCE }.map { it.first } - if (confident.isEmpty()) return null - return confident.take(MAX_LABELS).joinToString(", ") - } - // Two-pass decode keeps a 12 MP camera shot from blowing past 40 MB of ARGB_8888 — the // on-device describer downscales internally anyway, so a ~1024 px input is plenty. private fun loadDownscaledBitmap(uri: Uri): Bitmap? = @@ -136,14 +101,12 @@ class MLKitImageLabelService( ): Int { if (width <= 0 || height <= 0) return 1 var sample = 1 - var maxDim = maxOf(width, height) + val maxDim = maxOf(width, height) while (maxDim / sample > target) sample *= 2 return sample } fun close() { - labeler?.close() - labeler = null describer?.close() describer = null cachedGenAiStatus = null diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1dc29b301..c51f30efd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,7 +41,6 @@ genaiProofreading = "1.0.0-beta1" genaiPrompt = "1.0.0-beta2" genaiRewriting = "1.0.0-beta1" genaiImageDescription = "1.0.0-beta1" -imageLabeling = "16.0.0" languageId = "17.0.6" lifecycleRuntimeKtx = "2.10.0" lightcompressor-enhanced = "2.2.1" @@ -151,7 +150,6 @@ google-mlkit-genai-proofreading = { group = "com.google.mlkit", name = "genai-pr google-mlkit-genai-prompt = { group = "com.google.mlkit", name = "genai-prompt", version.ref = "genaiPrompt" } google-mlkit-genai-rewriting = { group = "com.google.mlkit", name = "genai-rewriting", version.ref = "genaiRewriting" } google-mlkit-genai-image-description = { group = "com.google.mlkit", name = "genai-image-description", version.ref = "genaiImageDescription" } -google-mlkit-image-labeling = { group = "com.google.android.gms", name = "play-services-mlkit-image-labeling", version.ref = "imageLabeling" } google-mlkit-language-id = { group = "com.google.mlkit", name = "language-id", version.ref = "languageId" } google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" } jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } From 1992fa747635461523dc3102d0124c3880781a9a Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 27 Apr 2026 15:32:52 +0000 Subject: [PATCH 3/5] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-hu-rHU/strings.xml | 2 ++ amethyst/src/main/res/values-zh-rCN/strings.xml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index c431ea5e9..af42404b5 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -586,6 +586,8 @@ Közvetlen webcím használata Tartalmi leírás Egy kék csónak a fehér homokos tengerparton naplementekor + LLM által javasolt, szerkessze bátran + LLM-javaslatok eltüntetése Zap-típus Zap-típus minden lehetőséghez Nyilvános diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 601d642a1..c206e74c9 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -586,6 +586,8 @@ 使用直接链接 内容描述 日落时分,白色沙滩上的蓝色小船 + AI建议的,编辑我 + 忽略 AI 建议 打闪类型 所有选项的打闪类型 公开 From 298fcb7185834cb08bc6d58cf75da34444378b1f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 15:36:34 +0000 Subject: [PATCH 4/5] style: spotlessApply --- .../loggedIn/chats/rooms/feed/ChatroomListFeedView.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index 8db59aef7..801d80555 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -192,7 +192,13 @@ private fun chatroomLazyKey( } is ChatroomKeyable -> { - PrivateChatLazyKey(event.chatroomKey(myPubKey).users.sorted().joinToString(",")) + PrivateChatLazyKey( + event + .chatroomKey(myPubKey) + .users + .sorted() + .joinToString(","), + ) } else -> { From 4c6d6eb4b46208485a7ef551e2b844e2c3fff897 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 15:47:19 +0000 Subject: [PATCH 5/5] perf(chats): hold Set in PrivateChatLazyKey instead of joined String The previous fix sorted + joined the user pubkeys into a comma-separated String to keep the key Bundle-storable. That brought back the StringBuilder + char[] + String + sorted-List allocation per visible chatroom that the typed-key rework had eliminated. ChatroomKey.users is often a kotlinx PersistentOrderedSet, which isn't java.io.Serializable, so we can't just hold the Set reference as-is. Copying into a HashSet costs one HashMap allocation per key, much less than the joined-String path, and Set equality stays order-independent so two equivalent chatrooms still hash to the same bucket. --- .../chats/rooms/feed/ChatroomListFeedView.kt | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index 801d80555..300f36eda 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -158,7 +158,7 @@ private data class EphemeralChannelLazyKey( ) : ChatroomLazyKey private data class PrivateChatLazyKey( - val key: String, + val users: HashSet, ) : ChatroomLazyKey private data class FallbackChatroomLazyKey( @@ -192,13 +192,10 @@ private fun chatroomLazyKey( } is ChatroomKeyable -> { - PrivateChatLazyKey( - event - .chatroomKey(myPubKey) - .users - .sorted() - .joinToString(","), - ) + // ChatroomKey.users may be a kotlinx PersistentOrderedSet, which + // is not Serializable. Copy into a HashSet so the key survives + // Bundle round-trips; Set equality stays order-independent. + PrivateChatLazyKey(HashSet(event.chatroomKey(myPubKey).users)) } else -> {