Merge pull request #2582 from vitorpamplona/claude/fix-translation-rendering-9fgk4

fix(translation): bug, perf and jitter overhaul of rich-text translation
This commit is contained in:
Vitor Pamplona
2026-04-26 10:00:23 -04:00
committed by GitHub
7 changed files with 792 additions and 388 deletions
@@ -22,10 +22,17 @@ package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Immutable
/**
* The current translation state for a piece of content.
*
* `sourceLang` and `targetLang` are non-null only when an actual translation took place;
* a no-op (same language, undetected, blocklisted) keeps both null and `result` equal to the
* original content. The user-facing "show original" toggle is derived live from
* `AccountLanguagePreferences.preferenceBetween(...)` and is not stored here.
*/
@Immutable
data class TranslationConfig(
val result: String?,
val result: String,
val sourceLang: String?,
val targetLang: String?,
val showOriginal: Boolean,
)
@@ -31,11 +31,9 @@ import com.google.mlkit.nl.translate.Translation
import com.google.mlkit.nl.translate.Translator
import com.google.mlkit.nl.translate.TranslatorOptions
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector
import kotlinx.coroutines.CancellationException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.regex.Pattern
@Immutable
data class ResultOrError(
@@ -45,21 +43,16 @@ data class ResultOrError(
)
object LanguageTranslatorService {
var executorService: ExecutorService = Executors.newCachedThreadPool()
private val executorService: ExecutorService =
Executors.newFixedThreadPool(maxOf(2, Runtime.getRuntime().availableProcessors() / 2))
private val options =
private val identificationOptions =
LanguageIdentificationOptions
.Builder()
.setExecutor(executorService)
.setConfidenceThreshold(0.6f)
.build()
private val languageIdentification = LanguageIdentification.getClient(options)
val lnRegex: Pattern = Pattern.compile("\\blnbc[a-z0-9]+\\b", Pattern.CASE_INSENSITIVE)
val tagRegex: Pattern =
Pattern.compile(
"(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)",
Pattern.CASE_INSENSITIVE,
)
private val languageIdentification = LanguageIdentification.getClient(identificationOptions)
private val translators =
object : LruCache<TranslatorOptions, Translator>(3) {
@@ -75,8 +68,20 @@ object LanguageTranslatorService {
}
}
private data class InFlightKey(
val text: String,
val translateTo: String,
val dontTranslateFrom: Set<String>,
)
// Coalesces concurrent translation requests for the same (content, settings) — the same note
// shown in N composables (reposts, notifications) only fires one ML Kit pipeline.
private val inFlight = ConcurrentHashMap<InFlightKey, Task<ResultOrError>>()
fun clear() {
translators.evictAll()
inFlight.clear()
TranslationsCache.clear()
}
fun identifyLanguage(text: String): Task<String> = languageIdentification.identifyLanguage(text)
@@ -107,108 +112,51 @@ object LanguageTranslatorService {
return translator.downloadModelIfNeeded().onSuccessTask(executorService) {
checkNotInMainThread()
val tasks = mutableListOf<Task<String>>()
val dict = lnDictionary(text) + urlDictionary(text) + tagDictionary(text)
val dict = TranslationDictionary.build(text)
val encoded = TranslationDictionary.encode(text, dict)
for (paragraph in encodeDictionary(text, dict).split("\n")) {
tasks.add(translator.translate(paragraph))
}
Tasks.whenAll(tasks).continueWith(executorService) {
checkNotInMainThread()
val results: MutableList<String> = ArrayList()
for (task in tasks) {
val fixedText =
task.result.replace("# [", "#[") // fixes tags that always return with a space
results.add(decodeDictionary(fixedText, dict))
}
ResultOrError(results.joinToString("\n"), source, target)
translator.translate(encoded).continueWith(executorService) { task ->
task.exception?.let { throw it }
ResultOrError(TranslationDictionary.decode(task.result, dict), source, target)
}
}
}
private fun encodeDictionary(
text: String,
dict: Map<String, String>,
): String {
var newText = text
for (pair in dict) {
newText = newText.replace(pair.value, pair.key, true)
}
return newText
}
private fun decodeDictionary(
text: String,
dict: Map<String, String>,
): String {
var newText = text
for (pair in dict) {
newText = newText.replace(pair.key, pair.value, true)
}
return newText
}
private fun tagDictionary(text: String): Map<String, String> {
val matcher = tagRegex.matcher(text)
val returningList = mutableMapOf<String, String>()
var counter = 0
while (matcher.find()) {
try {
val tag = matcher.group()
val short = "C$counter"
counter++
returningList.put(short, tag)
} catch (e: Exception) {
if (e is CancellationException) throw e
}
}
return returningList
}
private fun lnDictionary(text: String): Map<String, String> {
val matcher = lnRegex.matcher(text)
val returningList = mutableMapOf<String, String>()
var counter = 0
while (matcher.find()) {
try {
val lnInvoice = matcher.group()
val short = "A$counter"
counter++
returningList.put(short, lnInvoice)
} catch (e: Exception) {
if (e is CancellationException) throw e
}
}
return returningList
}
private fun urlDictionary(text: String): Map<String, String> {
val urlsInText = UrlDetector(text).detect()
var counter = 0
return urlsInText
.filter { !it.originalUrl.contains("") && !it.originalUrl.contains("") }
.associate {
counter++
"B$counter" to it.originalUrl
}
}
fun autoTranslate(
text: String,
dontTranslateFrom: Set<String>,
translateTo: String,
): Task<ResultOrError> =
identifyLanguage(text).onSuccessTask(executorService) {
if (it.equals(translateTo, true)) {
Tasks.forCanceled()
} else if (it != "und" && !dontTranslateFrom.contains(it)) {
translate(text, it, translateTo)
} else {
Tasks.forCanceled()
): Task<ResultOrError> {
if (!TranslationDictionary.isWorthTranslating(text)) return Tasks.forCanceled()
return dedupe(InFlightKey(text, translateTo, dontTranslateFrom)) {
identifyLanguage(text).onSuccessTask(executorService) { detected ->
translateOrSkip(text, detected, dontTranslateFrom, translateTo)
}
}
}
private fun translateOrSkip(
text: String,
detected: String,
dontTranslateFrom: Set<String>,
translateTo: String,
): Task<ResultOrError> =
when {
detected == "und" -> Tasks.forCanceled()
detected.equals(translateTo, ignoreCase = true) -> Tasks.forCanceled()
detected in dontTranslateFrom -> Tasks.forCanceled()
else -> translate(text, detected, translateTo)
}
private inline fun dedupe(
key: InFlightKey,
factory: () -> Task<ResultOrError>,
): Task<ResultOrError> {
inFlight[key]?.let { return it }
val candidate = factory()
// putIfAbsent guards against a racing caller: keep the winner, drop the loser.
val winner = inFlight.putIfAbsent(key, candidate) ?: candidate
winner.addOnCompleteListener(executorService) { inFlight.remove(key, winner) }
return winner
}
}
@@ -0,0 +1,124 @@
/*
* 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.service.lang
import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector
import java.util.regex.Pattern
/**
* Pure-JVM helpers that protect non-translatable substrings (URLs, Lightning invoices, NIP-19
* references, NIP-08 positional references) by swapping them with single Unicode Private Use Area
* codepoints around a translator. Extracted out of [LanguageTranslatorService] so the round-trip
* can be unit-tested without ML Kit / Android runtime.
*/
internal object TranslationDictionary {
// Range U+E000..U+F8FF gives 6400 placeholder slots. PUA codepoints don't appear in normal
// user text, the translator has no rule for them so it passes them through, and using one
// codepoint per placeholder means the translator can't split or reorder it.
const val PLACEHOLDER_BASE: Int = 0xE000
const val PLACEHOLDER_LIMIT: Int = 0xF8FF - PLACEHOLDER_BASE
// Texts shorter than this, or with no letter codepoints (emoji-only, punctuation), are skipped
// before any ML Kit work — language identification is unreliable on them.
private const val MIN_TRANSLATABLE_LENGTH = 4
val lnRegex: Pattern = Pattern.compile("\\blnbc[a-z0-9]+\\b", Pattern.CASE_INSENSITIVE)
val tagRegex: Pattern =
Pattern.compile(
"(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)",
Pattern.CASE_INSENSITIVE,
)
// Legacy NIP-08 positional references like #[0]. Translators tend to insert a space inside the
// brackets ("# [0]"), so we shield them via the placeholder dictionary.
val nip08RefRegex: Pattern = Pattern.compile("#\\[\\d+]")
fun isWorthTranslating(text: String): Boolean {
if (text.length < MIN_TRANSLATABLE_LENGTH) return false
for (cp in text.codePoints()) {
if (Character.isLetter(cp)) return true
}
return false
}
fun build(text: String): Map<String, String> {
val dict = LinkedHashMap<String, String>()
var counter = 0
fun addUnique(value: String) {
if (value.isEmpty()) return
if (counter > PLACEHOLDER_LIMIT) return
if (dict.containsValue(value)) return
dict[placeholder(counter++)] = value
}
lnRegex.forEachMatch(text, ::addUnique)
tagRegex.forEachMatch(text, ::addUnique)
nip08RefRegex.forEachMatch(text, ::addUnique)
for (url in UrlDetector(text).detect()) {
val original = url.originalUrl
// The URL detector greedily includes Chinese full-width punctuation; skip those false hits.
if (original.contains('') || original.contains('。')) continue
addUnique(original)
}
return dict
}
private inline fun Pattern.forEachMatch(
text: String,
block: (String) -> Unit,
) {
val matcher = matcher(text)
while (matcher.find()) block(matcher.group())
}
fun encode(
text: String,
dict: Map<String, String>,
): String {
if (dict.isEmpty()) return text
var newText = text
// Replace longest values first so a URL prefix never clobbers a longer URL or tag.
for ((token, original) in dict.entries.sortedByDescending { it.value.length }) {
newText = newText.replace(original, token, ignoreCase = false)
}
return newText
}
fun decode(
text: String?,
dict: Map<String, String>,
): String? {
if (text == null || dict.isEmpty()) return text
var newText: String = text
for ((token, original) in dict) {
newText = newText.replace(token, original, ignoreCase = false)
}
return newText
}
fun placeholder(index: Int): String {
require(index in 0..PLACEHOLDER_LIMIT) { "placeholder index $index out of range" }
return String(Character.toChars(PLACEHOLDER_BASE + index))
}
}
@@ -24,14 +24,34 @@ import android.util.LruCache
import com.vitorpamplona.amethyst.ui.components.TranslationConfig
object TranslationsCache {
val cache = LruCache<String, TranslationConfig>(100)
private const val MAX_ENTRIES = 500
fun get(content: String): TranslationConfig = cache.get(content) ?: TranslationConfig(content, null, null, false)
// Keying on the language settings as well prevents serving stale translations after the user
// changes "Translate to" or "Don't translate from".
private data class Key(
val content: String,
val translateTo: String,
val dontTranslateFrom: Set<String>,
)
private val cache = LruCache<Key, TranslationConfig>(MAX_ENTRIES)
fun get(
content: String,
translateTo: String,
dontTranslateFrom: Set<String>,
): TranslationConfig? = cache.get(Key(content, translateTo, dontTranslateFrom))
fun set(
content: String,
translateTo: String,
dontTranslateFrom: Set<String>,
config: TranslationConfig,
) {
cache.put(content, config)
cache.put(Key(content, translateTo, dontTranslateFrom), config)
}
fun clear() {
cache.evictAll()
}
}
@@ -20,51 +20,28 @@
*/
package com.vitorpamplona.amethyst.ui.components
import android.content.res.Resources
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.os.ConfigurationCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.lang.ResultOrError
import com.vitorpamplona.amethyst.service.lang.TranslationsCache
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.MaxWidthPaddingTop5dp
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Locale
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.tasks.await
import kotlin.coroutines.coroutineContext
@Composable
fun TranslatableRichTextViewer(
@@ -107,281 +84,109 @@ fun TranslatableRichTextViewer(
accountViewModel: AccountViewModel,
displayText: @Composable (String) -> Unit,
) {
var translatedTextState by translateAndWatchLanguageChanges(content, id, accountViewModel)
val languages = accountViewModel.account.settings.syncedSettings.languages
val translateTo by languages.translateTo.collectAsStateWithLifecycle()
val dontTranslateFrom by languages.dontTranslateFrom.collectAsStateWithLifecycle()
val languagePreferences by languages.languagePreferences.collectAsStateWithLifecycle()
CrossfadeIfEnabled(targetState = translatedTextState, accountViewModel = accountViewModel) {
RenderTextWithTranslateOptions(
translatedTextState = it,
content = content,
translationMessageModifier = translationMessageModifier,
accountViewModel = accountViewModel,
displayText = displayText,
)
val translatedTextState =
remember(id, content, translateTo, dontTranslateFrom) {
mutableStateOf(
TranslationsCache.get(content, translateTo, dontTranslateFrom)
?: TranslationConfig(content, null, null),
)
}
LaunchedEffect(content, translateTo, dontTranslateFrom) {
try {
translatedTextState.value = translateAndCache(content, translateTo, dontTranslateFrom)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// Transient ML Kit / network failure — keep showing the original. Do not cache: a
// one-off failure shouldn't block future attempts on the same text.
}
}
RenderTextWithTranslateOptions(
translatedTextState = translatedTextState.value,
content = content,
languagePreferences = languagePreferences,
translationMessageModifier = translationMessageModifier,
accountViewModel = accountViewModel,
displayText = displayText,
)
}
@Composable
private fun RenderTextWithTranslateOptions(
translatedTextState: TranslationConfig,
content: String,
languagePreferences: Map<String, String>,
translationMessageModifier: Modifier = MaxWidthPaddingTop5dp,
accountViewModel: AccountViewModel,
displayText: @Composable (String) -> Unit,
) {
var showOriginal by
remember(translatedTextState) { mutableStateOf(translatedTextState.showOriginal) }
val source = translatedTextState.sourceLang
val target = translatedTextState.targetLang
val translationOccurred = source != null && target != null && source != target
val toBeViewed by
remember(translatedTextState) {
derivedStateOf { if (showOriginal) content else translatedTextState.result ?: content }
val storedPreference = if (translationOccurred) languagePreferences["$source,$target"] else null
var showOriginal by
remember(translatedTextState, storedPreference) {
mutableStateOf(storedPreference == source)
}
val toBeViewed = if (showOriginal || !translationOccurred) content else translatedTextState.result
Column {
displayText(toBeViewed)
if (
translatedTextState.sourceLang != null &&
translatedTextState.targetLang != null &&
translatedTextState.sourceLang != translatedTextState.targetLang
) {
TranslationMessage(
translatedTextState.sourceLang,
translatedTextState.targetLang,
translationMessageModifier,
accountViewModel,
) {
showOriginal = it
}
if (translationOccurred) {
TranslationStatusBar(
source = source,
target = target,
modifier = translationMessageModifier,
accountViewModel = accountViewModel,
) { showOriginal = it }
}
}
}
@Composable
private fun TranslationMessage(
source: String,
target: String,
modifier: Modifier = MaxWidthPaddingTop5dp,
accountViewModel: AccountViewModel,
onChangeWhatToShow: (Boolean) -> Unit,
) {
var langSettingsPopupExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Row(
modifier = modifier,
) {
val textColor = MaterialTheme.colorScheme.lessImportantLink
Text(
text =
buildAnnotatedString {
appendLink(stringRes(R.string.translations_auto), textColor) { langSettingsPopupExpanded = !langSettingsPopupExpanded }
append(" ${stringRes(R.string.translations_translated_from)} ")
appendLink(Locale.forLanguageTag(source).displayName, textColor) { onChangeWhatToShow(true) }
append(" ${stringRes(R.string.translations_to)} ")
appendLink(Locale.forLanguageTag(target).displayName, textColor) { onChangeWhatToShow(false) }
},
style =
LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.32f),
fontSize = Font14SP,
),
overflow = TextOverflow.Visible,
maxLines = 3,
)
DropdownMenu(
expanded = langSettingsPopupExpanded,
onDismissRequest = { langSettingsPopupExpanded = false },
) {
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (source in accountViewModel.dontTranslateFrom()) {
Icon(
symbol = MaterialSymbols.Check,
contentDescription = null,
modifier = Modifier.size(24.dp),
)
} else {
Spacer(modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.size(10.dp))
Text(
stringRes(
R.string.translations_never_translate_from_lang,
Locale.forLanguageTag(source).displayName,
),
)
}
},
onClick = {
accountViewModel.toggleDontTranslateFrom(source)
langSettingsPopupExpanded = false
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (accountViewModel.account.settings.preferenceBetween(source, target) == source) {
Icon(
symbol = MaterialSymbols.Check,
contentDescription = null,
modifier = Modifier.size(24.dp),
)
} else {
Spacer(modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.size(10.dp))
Text(
stringRes(
R.string.translations_show_in_lang_first,
Locale.forLanguageTag(source).displayName,
),
)
}
},
onClick = {
accountViewModel.prefer(source, target, source)
langSettingsPopupExpanded = false
},
)
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (accountViewModel.account.settings.syncedSettings.languages
.preferenceBetween(source, target) == target
) {
Icon(
symbol = MaterialSymbols.Check,
contentDescription = null,
modifier = Modifier.size(24.dp),
)
} else {
Spacer(modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.size(10.dp))
Text(
stringRes(
R.string.translations_show_in_lang_first,
Locale.forLanguageTag(target).displayName,
),
)
}
},
onClick = {
scope.launch(Dispatchers.IO) {
accountViewModel.prefer(source, target, target)
langSettingsPopupExpanded = false
}
},
)
HorizontalDivider(thickness = DividerThickness)
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().configuration)
for (i in 0 until languageList.size()) {
languageList.get(i)?.let { lang ->
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (accountViewModel.account.settings.translateToContains(lang.language)) {
Icon(
symbol = MaterialSymbols.Check,
contentDescription = null,
modifier = Modifier.size(24.dp),
)
} else {
Spacer(modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.size(10.dp))
Text(
stringRes(
R.string.translations_always_translate_to_lang,
lang.displayName,
),
)
}
},
onClick = {
langSettingsPopupExpanded = false
accountViewModel.updateTranslateTo(lang.language)
},
)
}
}
}
}
}
@Composable
fun translateAndWatchLanguageChanges(
/**
* Returns the translation for [content] under the current language settings, hitting the cache
* first and falling back to ML Kit. ML Kit's "no translation needed" cancellation (same language,
* undetected, blocklisted) is bridged into a no-op [TranslationConfig] that is itself cached, so
* the same text scrolling back into view doesn't re-run language identification.
*/
private suspend fun translateAndCache(
content: String,
id: String,
accountViewModel: AccountViewModel,
): MutableState<TranslationConfig> {
val translatedTextState = remember(id) { mutableStateOf(TranslationsCache.get(content)) }
translateTo: String,
dontTranslateFrom: Set<String>,
): TranslationConfig {
TranslationsCache.get(content, translateTo, dontTranslateFrom)?.let { return it }
TranslateAndWatchLanguageChanges(
content,
accountViewModel,
) { result ->
if (
!translatedTextState.value.result.equals(result.result, true) ||
translatedTextState.value.sourceLang != result.sourceLang ||
translatedTextState.value.targetLang != result.targetLang
) {
TranslationsCache.set(content, result)
translatedTextState.value = result
val noOp = TranslationConfig(content, null, null)
val raw =
try {
LanguageTranslatorService.autoTranslate(content, dontTranslateFrom, translateTo).await()
} catch (e: CancellationException) {
// If our coroutine is the cancelled one, propagate; otherwise it's ML Kit signalling
// "no translation needed" — cache the no-op and return it.
coroutineContext.ensureActive()
return noOp.also { TranslationsCache.set(content, translateTo, dontTranslateFrom, it) }
}
}
coroutineContext.ensureActive()
return translatedTextState
val config = raw.toTranslationConfig(content) ?: noOp
TranslationsCache.set(content, translateTo, dontTranslateFrom, config)
return config
}
@Composable
fun TranslateAndWatchLanguageChanges(
content: String,
accountViewModel: AccountViewModel,
onTranslated: (TranslationConfig) -> Unit,
) {
LaunchedEffect(Unit) {
// This takes some time. Launches as a Composition scope to make sure this gets cancel if this
// item gets out of view.
withContext(Dispatchers.IO) {
LanguageTranslatorService
.autoTranslate(
content,
accountViewModel.dontTranslateFrom(),
accountViewModel.translateTo(),
).addOnCompleteListener { task ->
if (task.isSuccessful && !content.equals(task.result.result, true)) {
if (task.result.sourceLang != null && task.result.targetLang != null) {
val preference =
accountViewModel.account.settings.preferenceBetween(
task.result.sourceLang!!,
task.result.targetLang!!,
)
val newConfig =
TranslationConfig(
result = task.result.result,
sourceLang = task.result.sourceLang,
targetLang = task.result.targetLang,
showOriginal = preference == task.result.sourceLang,
)
onTranslated(newConfig)
}
}
}
}
}
private fun ResultOrError.toTranslationConfig(content: String): TranslationConfig? {
val translated = result ?: return null
val source = sourceLang ?: return null
val target = targetLang ?: return null
if (source == target || translated == content) return null
return TranslationConfig(translated, source, target)
}
@@ -0,0 +1,217 @@
/*
* 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.ui.components
import android.content.res.Resources
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.os.ConfigurationCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.MaxWidthPaddingTop5dp
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import java.util.Locale
/**
* The "Auto-translated from X to Y" footer shown beneath translated rich text. Tapping the source
* or target labels toggles which version is displayed; tapping "Auto-translated" opens the
* per-language preferences dropdown.
*/
@Composable
internal fun TranslationStatusBar(
source: String,
target: String,
modifier: Modifier = MaxWidthPaddingTop5dp,
accountViewModel: AccountViewModel,
onShowOriginalChange: (Boolean) -> Unit,
) {
var dropdownExpanded by remember { mutableStateOf(false) }
val sourceDisplay = remember(source) { Locale.forLanguageTag(source).displayName }
val targetDisplay = remember(target) { Locale.forLanguageTag(target).displayName }
Row(modifier = modifier) {
TranslationStatusText(
sourceDisplay = sourceDisplay,
targetDisplay = targetDisplay,
onAutoLabelClick = { dropdownExpanded = !dropdownExpanded },
onSourceLabelClick = { onShowOriginalChange(true) },
onTargetLabelClick = { onShowOriginalChange(false) },
)
if (dropdownExpanded) {
LangSettingsDropdown(
source = source,
target = target,
sourceDisplay = sourceDisplay,
targetDisplay = targetDisplay,
accountViewModel = accountViewModel,
onDismiss = { dropdownExpanded = false },
)
}
}
}
@Composable
private fun TranslationStatusText(
sourceDisplay: String,
targetDisplay: String,
onAutoLabelClick: () -> Unit,
onSourceLabelClick: () -> Unit,
onTargetLabelClick: () -> Unit,
) {
val textColor = MaterialTheme.colorScheme.lessImportantLink
val autoLabel = stringRes(R.string.translations_auto)
val translatedFromLabel = stringRes(R.string.translations_translated_from)
val toLabel = stringRes(R.string.translations_to)
Text(
text =
buildAnnotatedString {
appendLink(autoLabel, textColor, onAutoLabelClick)
append(" $translatedFromLabel ")
appendLink(sourceDisplay, textColor, onSourceLabelClick)
append(" $toLabel ")
appendLink(targetDisplay, textColor, onTargetLabelClick)
},
style =
LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.32f),
fontSize = Font14SP,
),
overflow = TextOverflow.Visible,
maxLines = 3,
)
}
@Composable
private fun LangSettingsDropdown(
source: String,
target: String,
sourceDisplay: String,
targetDisplay: String,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
) {
val deviceLocales = rememberDeviceLocales()
val settings = accountViewModel.account.settings
val preferenceForPair = settings.preferenceBetween(source, target)
DropdownMenu(expanded = true, onDismissRequest = onDismiss) {
LangMenuItem(
checked = source in accountViewModel.dontTranslateFrom(),
label = stringRes(R.string.translations_never_translate_from_lang, sourceDisplay),
onClick = {
accountViewModel.toggleDontTranslateFrom(source)
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
LangMenuItem(
checked = preferenceForPair == source,
label = stringRes(R.string.translations_show_in_lang_first, sourceDisplay),
onClick = {
accountViewModel.prefer(source, target, source)
onDismiss()
},
)
LangMenuItem(
checked = preferenceForPair == target,
label = stringRes(R.string.translations_show_in_lang_first, targetDisplay),
onClick = {
accountViewModel.prefer(source, target, target)
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
for (lang in deviceLocales) {
LangMenuItem(
checked = settings.translateToContains(lang.language),
label = stringRes(R.string.translations_always_translate_to_lang, lang.displayName),
onClick = {
onDismiss()
accountViewModel.updateTranslateTo(lang.language)
},
)
}
}
}
@Composable
private fun rememberDeviceLocales(): List<Locale> =
remember {
val list = ConfigurationCompat.getLocales(Resources.getSystem().configuration)
(0 until list.size()).mapNotNull { list.get(it) }
}
@Composable
private fun LangMenuItem(
checked: Boolean,
label: String,
onClick: () -> Unit,
) {
DropdownMenuItem(
text = { CheckmarkRow(checked, label) },
onClick = onClick,
)
}
@Composable
private fun CheckmarkRow(
checked: Boolean,
label: String,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (checked) {
Icon(
symbol = MaterialSymbols.Check,
contentDescription = null,
modifier = Modifier.size(24.dp),
)
} else {
Spacer(modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.size(10.dp))
Text(label)
}
}
@@ -0,0 +1,283 @@
/*
* 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.service.lang
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
class TranslationDictionaryTest {
// ----- isWorthTranslating -----
@Test
fun `short text is not worth translating`() {
assertFalse(TranslationDictionary.isWorthTranslating(""))
assertFalse(TranslationDictionary.isWorthTranslating("a"))
assertFalse(TranslationDictionary.isWorthTranslating("ab"))
assertFalse(TranslationDictionary.isWorthTranslating("abc"))
}
@Test
fun `letterless text is not worth translating`() {
assertFalse(TranslationDictionary.isWorthTranslating("123456"))
assertFalse(TranslationDictionary.isWorthTranslating("!!!!!!"))
assertFalse(TranslationDictionary.isWorthTranslating(" "))
// Emoji-only.
assertFalse(TranslationDictionary.isWorthTranslating("😊😊😊"))
}
@Test
fun `text with at least one letter is worth translating`() {
assertTrue(TranslationDictionary.isWorthTranslating("Hello"))
assertTrue(TranslationDictionary.isWorthTranslating("a123"))
assertTrue(TranslationDictionary.isWorthTranslating("你好世界"))
// Mixed emoji + letters.
assertTrue(TranslationDictionary.isWorthTranslating("😊 hi"))
}
// ----- placeholder -----
@Test
fun `placeholder is a single Unicode Private Use Area codepoint`() {
val p0 = TranslationDictionary.placeholder(0)
val p1 = TranslationDictionary.placeholder(1)
assertEquals(1, p0.codePointCount(0, p0.length))
assertEquals(1, p1.codePointCount(0, p1.length))
assertEquals(0xE000, p0.codePointAt(0))
assertEquals(0xE001, p1.codePointAt(0))
assertNotEquals(p0, p1)
}
@Test
fun `placeholder rejects out of range index`() {
try {
TranslationDictionary.placeholder(-1)
fail("expected IllegalArgumentException for negative index")
} catch (_: IllegalArgumentException) {
// expected
}
try {
TranslationDictionary.placeholder(TranslationDictionary.PLACEHOLDER_LIMIT + 1)
fail("expected IllegalArgumentException for index past limit")
} catch (_: IllegalArgumentException) {
// expected
}
}
// ----- build -----
@Test
fun `build empty dictionary for plain text`() {
val dict = TranslationDictionary.build("Just plain text with no special tokens")
assertTrue(dict.isEmpty())
}
@Test
fun `build picks up a single URL`() {
val text = "Have you seen this https://t.me/mygroup yet?"
val dict = TranslationDictionary.build(text)
assertEquals(1, dict.size)
assertTrue("dict should contain the URL value", dict.containsValue("https://t.me/mygroup"))
}
@Test
fun `build picks up nostr NIP-19 references`() {
val text = "see nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy here"
val dict = TranslationDictionary.build(text)
assertEquals(1, dict.size)
assertTrue(dict.containsValue("nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy"))
}
@Test
fun `build picks up Lightning invoices`() {
val invoice =
"lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjs"
val dict = TranslationDictionary.build("Pay me: $invoice please")
assertEquals(1, dict.size)
assertTrue(dict.containsValue(invoice))
}
@Test
fun `build picks up legacy NIP-08 positional references`() {
val text = "Have you seen this, #[0]"
val dict = TranslationDictionary.build(text)
assertEquals(1, dict.size)
assertTrue(dict.containsValue("#[0]"))
}
@Test
fun `build deduplicates repeated occurrences of the same value`() {
val text = "https://a.com and again https://a.com"
val dict = TranslationDictionary.build(text)
assertEquals(1, dict.size)
}
@Test
fun `build collects multiple distinct tokens`() {
val text =
"ln: lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3 url: https://a.com " +
"ref: nostr:nevent1qqsabcdefghjklmnpqrstuvwxyz023456789 nip08: #[0]"
val dict = TranslationDictionary.build(text)
// We expect at least one entry per category. Exact count depends on the regexes' bech32-charset
// truncation behaviour; the contract we care about is that each distinct kind is captured.
assertTrue(dict.values.any { it.startsWith("lnbc") })
assertTrue("https://a.com" in dict.values)
assertTrue(dict.values.any { it.startsWith("nostr:nevent1") })
assertTrue("#[0]" in dict.values)
}
@Test
fun `build rejects URLs with Chinese full-width punctuation false-positives`() {
// The URL detector greedily includes and 。 — those substrings are not real URLs.
val text = "看 http://x.com,再见。"
val dict = TranslationDictionary.build(text)
for (value in dict.values) {
assertFalse("URL with or 。 should be skipped: $value", value.contains('') || value.contains('。'))
}
}
// ----- encode / decode round-trip -----
@Test
fun `encode replaces dictionary values with placeholders and decode restores them`() {
val text = "Have you seen this https://t.me/mygroup ?"
val dict = TranslationDictionary.build(text)
val encoded = TranslationDictionary.encode(text, dict)
assertFalse("URL must be removed from encoded text", encoded.contains("https://t.me/mygroup"))
assertTrue("encoded text must contain the placeholder", encoded.codePoints().anyMatch { it in 0xE000..0xF8FF })
val decoded = TranslationDictionary.decode(encoded, dict)
assertEquals(text, decoded)
}
@Test
fun `round-trip preserves nostr references through a simulated translation`() {
val text = "Have you seen this, #[0] and nostr:nevent1qqsabcdefgh023456?"
val dict = TranslationDictionary.build(text)
val encoded = TranslationDictionary.encode(text, dict)
// Simulate a translator: rewrite the surrounding English to Portuguese, but pass placeholders through unchanged.
val translated = encoded.replace("Have you seen this", "Você já viu isso").replace("and", "e")
val decoded = TranslationDictionary.decode(translated, dict)!!
assertTrue("decoded must contain #[0]", decoded.contains("#[0]"))
assertTrue("decoded must contain the nostr ref", decoded.contains("nostr:nevent1qqsabcdefgh023456"))
assertFalse("decoded must not leak placeholder codepoints", decoded.codePoints().anyMatch { it in 0xE000..0xF8FF })
}
@Test
fun `round-trip preserves multiple URLs of differing lengths`() {
val text =
"short https://a.co and " +
"long https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg end"
val dict = TranslationDictionary.build(text)
val encoded = TranslationDictionary.encode(text, dict)
val decoded = TranslationDictionary.decode(encoded, dict)
assertEquals(text, decoded)
}
@Test
fun `encode replaces longer values first to avoid prefix collisions`() {
// If "https://a.co" was replaced before "https://a.co/long", the longer URL would be partially clobbered.
val text = "long https://a.co/long short https://a.co end"
val dict = TranslationDictionary.build(text)
val encoded = TranslationDictionary.encode(text, dict)
// Both URLs must be fully replaced — no leftover http:// fragments.
assertFalse("no leftover URL fragment in encoded text", encoded.contains("https://"))
val decoded = TranslationDictionary.decode(encoded, dict)
assertEquals(text, decoded)
}
@Test
fun `decode does not corrupt user text containing the old B0 C0 A0 placeholders`() {
// Regression for the pre-rewrite bug: old placeholders "B0", "C0", "A0" collided with arbitrary
// user content. The new PUA placeholders are invisible codepoints that cannot occur in normal text,
// so a sentence mentioning "B0" or "C0" should round-trip unchanged when there's nothing to replace.
val text = "Pricing tier B0 vs C0 vs A0 — see https://docs.example.com/tiers"
val dict = TranslationDictionary.build(text)
val encoded = TranslationDictionary.encode(text, dict)
val decoded = TranslationDictionary.decode(encoded, dict)!!
assertTrue(decoded.contains("B0"))
assertTrue(decoded.contains("C0"))
assertTrue(decoded.contains("A0"))
assertEquals(text, decoded)
}
@Test
fun `case sensitive replacement preserves user text that differs only in case`() {
// The pre-rewrite implementation used ignoreCase=true, which could mangle user text that looked
// like a URL placeholder in a different case. With case-sensitive replacement this can't happen.
val text = "Visit HTTPS://A.COM/Path then revisit https://a.com/Path"
val dict = TranslationDictionary.build(text)
val encoded = TranslationDictionary.encode(text, dict)
val decoded = TranslationDictionary.decode(encoded, dict)
assertEquals(text, decoded)
}
@Test
fun `encode is no-op when dictionary is empty`() {
val text = "Plain text without anything special"
assertEquals(text, TranslationDictionary.encode(text, emptyMap()))
}
@Test
fun `decode handles null input`() {
assertNull(TranslationDictionary.decode(null, mapOf("a" to "b")))
}
@Test
fun `decode is no-op when dictionary is empty`() {
val text = "anything goes"
assertEquals(text, TranslationDictionary.decode(text, emptyMap()))
}
@Test
fun `mixed content from real-world test cases round-trips`() {
val text =
"Hi there! \n How are you doing? \n https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg"
val dict = TranslationDictionary.build(text)
val encoded = TranslationDictionary.encode(text, dict)
val decoded = TranslationDictionary.decode(encoded, dict)
assertEquals(text, decoded)
}
@Test
fun `complex real-world post round-trips`() {
// Mirrors TranslationsTest#testHttp: URL + emoji + multiple NIP-19 references.
val text =
"https://m.primal.net/MdDd.png \nRunning... 😁 " +
"nostr:npub126ntw5mnermmj0znhjhgdk8lh2af72sm8qfzq48umdlnhaj9kuns3le9ll " +
"nostr:npub1getal6ykt05fsz5nqu4uld09nfj3y3qxmv8crys4aeut53unfvlqr80nfm"
val dict = TranslationDictionary.build(text)
val encoded = TranslationDictionary.encode(text, dict)
val decoded = TranslationDictionary.decode(encoded, dict)
assertEquals(text, decoded)
// And every special token must have been replaced in the encoded form.
assertFalse(encoded.contains("https://m.primal.net/MdDd.png"))
assertFalse(encoded.contains("nostr:npub126ntw5mnermmj0znhjhgdk8lh2af72sm8qfzq48umdlnhaj9kuns3le9ll"))
assertFalse(encoded.contains("nostr:npub1getal6ykt05fsz5nqu4uld09nfj3y3qxmv8crys4aeut53unfvlqr80nfm"))
}
}