fix(translation): bug, perf and jitter overhaul of rich-text translation

Fixes a cluster of issues in TranslatableRichTextViewer + LanguageTranslatorService
that caused stale translations, redundant ML Kit work, and visible jitter on every
note that scrolls into view.

Bugs fixed
- Effect now actually re-runs when "Translate to" / "Don't translate from" change.
  Previously LaunchedEffect(Unit) snapshotted the settings once and ignored
  subsequent updates.
- Translation cache now keys on (content, translateTo, dontTranslateFrom) instead
  of just content, so changing the target language no longer serves a stale
  translation in the wrong language.
- Cancelled / "no translation needed" outcomes are now cached, so language
  identification no longer re-runs on every recomposition / scroll-back of text in
  the user's own language or in the don't-translate set.
- ML Kit Tasks are now awaited via kotlinx.coroutines.tasks.await with
  ensureActive() checks; cancelling the composable's coroutine no longer races
  against an in-flight callback that mutates Compose state after disposal.
- Encoded placeholders no longer collide with arbitrary user text. Replaced the
  old "B0/C0/A0" tokens (which a user could legitimately type) with single
  Unicode Private Use Area codepoints, and made replacement case-sensitive so
  e.g. "b0" in body text is no longer rewritten on decode.
- Translation pipeline propagates failures: continueWith now rethrows
  task.exception instead of silently calling .result on a failed sub-task.
- buildDictionary protects legacy NIP-08 references (#[N]) via the placeholder
  table, replacing the fragile post-translation "# [" -> "#[" string fix.

Performance
- LanguageTranslatorService de-duplicates concurrent translation requests for the
  same (text, settings) via an in-flight ConcurrentHashMap, so reposts /
  notifications / threads sharing the same content fire one ML Kit pipeline
  instead of N.
- executorService is now a private bounded fixed pool sized on
  availableProcessors() / 2 instead of a publicly-mutable unbounded cached pool
  that could spawn dozens of threads under heavy scroll.
- Skip ML Kit entirely for texts shorter than 4 chars or with no letter
  codepoints (emoji-only, punctuation) — language identification is unreliable
  there anyway.
- Translation cache bumped from 100 to 500 entries to cover long threads /
  long-form articles.
- Single-call translation (one ML Kit call for the whole text) preserves
  sentence-level context across paragraphs that the old per-line split discarded.

Jitter
- Removed CrossfadeIfEnabled around the rich-text body. The old code rendered two
  full RichTextViewer trees (and re-parsed URLs / hashtags / NIP-19 references
  twice) during the ~300ms crossfade whenever a translation arrived. Body now
  swaps directly; only the translation toggle hint sits below.
- Replaced derivedStateOf around a trivial ternary with a plain expression.
- Locale.forLanguageTag(...).displayName memoized per source/target tag so the
  CLDR display-name lookup doesn't run on every recomposition of the
  "Translated from X to Y" hint.
- Device-locale list lifted out of the dropdown render loop and remembered, so
  ConfigurationCompat.getLocales no longer fires per recomposition while the
  language menu is open.
- Dropdown body is only composed when expanded — it was already cheap inside
  Material3's DropdownMenu, but skipping the wrapper composition entirely is
  measurably tighter.

The exposed API (LanguageTranslatorService.autoTranslate / .translate /
.identifyLanguage / .clear, ResultOrError) is unchanged; TranslatableRichTextViewer's
two public composables keep their signatures, so the ~30 call sites and the
existing TranslationsTest don't need any updates. TranslationConfig drops the
showOriginal field — that toggle is now derived live from
AccountLanguagePreferences.preferenceBetween(...) so changing the user's
language preference is reflected immediately without invalidating the cache.

https://claude.ai/code/session_0153e2sVbAijKxinQYa6cNx5
This commit is contained in:
Claude
2026-04-26 04:12:35 +00:00
parent c24e676004
commit 24b8fa12b4
4 changed files with 352 additions and 326 deletions
@@ -22,10 +22,17 @@ package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Immutable 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 @Immutable
data class TranslationConfig( data class TranslationConfig(
val result: String?, val result: String,
val sourceLang: String?, val sourceLang: String?,
val targetLang: String?, val targetLang: String?,
val showOriginal: Boolean,
) )
@@ -32,7 +32,7 @@ import com.google.mlkit.nl.translate.Translator
import com.google.mlkit.nl.translate.TranslatorOptions import com.google.mlkit.nl.translate.TranslatorOptions
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector 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.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.regex.Pattern import java.util.regex.Pattern
@@ -45,15 +45,28 @@ data class ResultOrError(
) )
object LanguageTranslatorService { object LanguageTranslatorService {
var executorService: ExecutorService = Executors.newCachedThreadPool() // Texts shorter than this, or with no letters at all (emoji-only, punctuation), are skipped
// before any ML Kit work — language identification is unreliable on them anyway.
private const val MIN_TRANSLATABLE_LENGTH = 4
private val options = // Single Unicode Private Use Area codepoint per placeholder. PUA chars don't appear in normal
// user text, the translator has no rule for them so it passes them through, and using one
// codepoint (instead of bracketed digits) means the translator can't split or reorder the
// placeholder. Range U+E000..U+F8FF gives 6400 slots, far more than any single note needs.
private const val PLACEHOLDER_BASE = 0xE000
private const val PLACEHOLDER_LIMIT = 0xF8FF - PLACEHOLDER_BASE
private val executorService: ExecutorService =
Executors.newFixedThreadPool(maxOf(2, Runtime.getRuntime().availableProcessors() / 2))
private val identificationOptions =
LanguageIdentificationOptions LanguageIdentificationOptions
.Builder() .Builder()
.setExecutor(executorService) .setExecutor(executorService)
.setConfidenceThreshold(0.6f) .setConfidenceThreshold(0.6f)
.build() .build()
private val languageIdentification = LanguageIdentification.getClient(options) private val languageIdentification = LanguageIdentification.getClient(identificationOptions)
val lnRegex: Pattern = Pattern.compile("\\blnbc[a-z0-9]+\\b", Pattern.CASE_INSENSITIVE) val lnRegex: Pattern = Pattern.compile("\\blnbc[a-z0-9]+\\b", Pattern.CASE_INSENSITIVE)
val tagRegex: Pattern = val tagRegex: Pattern =
Pattern.compile( Pattern.compile(
@@ -61,6 +74,10 @@ object LanguageTranslatorService {
Pattern.CASE_INSENSITIVE, 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 instead of post-fixing.
val nip08RefRegex: Pattern = Pattern.compile("#\\[\\d+]")
private val translators = private val translators =
object : LruCache<TranslatorOptions, Translator>(3) { object : LruCache<TranslatorOptions, Translator>(3) {
override fun create(options: TranslatorOptions): Translator = Translation.getClient(options) override fun create(options: TranslatorOptions): Translator = Translation.getClient(options)
@@ -75,8 +92,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() { fun clear() {
translators.evictAll() translators.evictAll()
inFlight.clear()
TranslationsCache.clear()
} }
fun identifyLanguage(text: String): Task<String> = languageIdentification.identifyLanguage(text) fun identifyLanguage(text: String): Task<String> = languageIdentification.identifyLanguage(text)
@@ -107,108 +136,108 @@ object LanguageTranslatorService {
return translator.downloadModelIfNeeded().onSuccessTask(executorService) { return translator.downloadModelIfNeeded().onSuccessTask(executorService) {
checkNotInMainThread() checkNotInMainThread()
val tasks = mutableListOf<Task<String>>() val dict = buildDictionary(text)
val dict = lnDictionary(text) + urlDictionary(text) + tagDictionary(text) val encoded = encodeWithDictionary(text, dict)
for (paragraph in encodeDictionary(text, dict).split("\n")) { translator.translate(encoded).continueWith(executorService) { task ->
tasks.add(translator.translate(paragraph)) task.exception?.let { throw it }
} ResultOrError(decodeWithDictionary(task.result, dict), source, target)
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)
} }
} }
} }
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( fun autoTranslate(
text: String, text: String,
dontTranslateFrom: Set<String>, dontTranslateFrom: Set<String>,
translateTo: String, translateTo: String,
): Task<ResultOrError> = ): Task<ResultOrError> {
identifyLanguage(text).onSuccessTask(executorService) { if (!isWorthTranslating(text)) return Tasks.forCanceled()
if (it.equals(translateTo, true)) {
Tasks.forCanceled() val key = InFlightKey(text, translateTo, dontTranslateFrom)
} else if (it != "und" && !dontTranslateFrom.contains(it)) { inFlight[key]?.let { return it }
translate(text, it, translateTo)
} else { val task =
Tasks.forCanceled() identifyLanguage(text).onSuccessTask(executorService) { detected ->
when {
detected == "und" -> Tasks.forCanceled()
detected.equals(translateTo, ignoreCase = true) -> Tasks.forCanceled()
detected in dontTranslateFrom -> Tasks.forCanceled()
else -> translate(text, detected, translateTo)
}
} }
// putIfAbsent guards against a racing caller: keep the winner, drop the loser.
val winner = inFlight.putIfAbsent(key, task) ?: task
winner.addOnCompleteListener(executorService) { inFlight.remove(key, winner) }
return winner
}
private fun isWorthTranslating(text: String): Boolean {
if (text.length < MIN_TRANSLATABLE_LENGTH) return false
// Cheap scan; bail as soon as we see one letter codepoint.
for (cp in text.codePoints()) {
if (Character.isLetter(cp)) return true
} }
return false
}
private fun buildDictionary(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
}
val lnMatcher = lnRegex.matcher(text)
while (lnMatcher.find()) addUnique(lnMatcher.group())
val tagMatcher = tagRegex.matcher(text)
while (tagMatcher.find()) addUnique(tagMatcher.group())
val nip08Matcher = nip08RefRegex.matcher(text)
while (nip08Matcher.find()) addUnique(nip08Matcher.group())
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 fun placeholder(index: Int): String {
require(index in 0..PLACEHOLDER_LIMIT) { "placeholder index $index out of range" }
return String(Character.toChars(PLACEHOLDER_BASE + index))
}
private fun encodeWithDictionary(
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
}
private fun decodeWithDictionary(
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
}
} }
@@ -24,14 +24,34 @@ import android.util.LruCache
import com.vitorpamplona.amethyst.ui.components.TranslationConfig import com.vitorpamplona.amethyst.ui.components.TranslationConfig
object TranslationsCache { 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( fun set(
content: String, content: String,
translateTo: String,
dontTranslateFrom: Set<String>,
config: TranslationConfig, config: TranslationConfig,
) { ) {
cache.put(content, config) cache.put(Key(content, translateTo, dontTranslateFrom), config)
}
fun clear() {
cache.evictAll()
} }
} }
@@ -34,11 +34,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -47,13 +45,13 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.os.ConfigurationCompat import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.lang.TranslationsCache 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.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -61,9 +59,9 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.MaxWidthPaddingTop5dp import com.vitorpamplona.amethyst.ui.theme.MaxWidthPaddingTop5dp
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext import kotlinx.coroutines.tasks.await
import java.util.Locale import java.util.Locale
@Composable @Composable
@@ -107,51 +105,109 @@ fun TranslatableRichTextViewer(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
displayText: @Composable (String) -> Unit, 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) { val translatedTextState =
RenderTextWithTranslateOptions( remember(id, content, translateTo, dontTranslateFrom) {
translatedTextState = it, mutableStateOf(
content = content, TranslationsCache.get(content, translateTo, dontTranslateFrom)
translationMessageModifier = translationMessageModifier, ?: TranslationConfig(content, null, null),
accountViewModel = accountViewModel, )
displayText = displayText, }
)
LaunchedEffect(content, translateTo, dontTranslateFrom) {
TranslationsCache.get(content, translateTo, dontTranslateFrom)?.let {
translatedTextState.value = it
return@LaunchedEffect
}
val noOp = TranslationConfig(content, null, null)
try {
val task = LanguageTranslatorService.autoTranslate(content, dontTranslateFrom, translateTo)
// ML Kit cancels the task to signal "no translation needed" (same language, "und",
// blocklisted). await() bridges that into a CancellationException; cache the no-op so
// we don't re-run language identification next time the same text scrolls into view.
val raw =
try {
task.await()
} catch (e: CancellationException) {
coroutineContext.ensureActive()
TranslationsCache.set(content, translateTo, dontTranslateFrom, noOp)
translatedTextState.value = noOp
return@LaunchedEffect
}
coroutineContext.ensureActive()
val translated = raw.result
val source = raw.sourceLang
val target = raw.targetLang
val newConfig =
if (
translated != null &&
source != null &&
target != null &&
source != target &&
translated != content
) {
TranslationConfig(translated, source, target)
} else {
noOp
}
TranslationsCache.set(content, translateTo, dontTranslateFrom, newConfig)
translatedTextState.value = newConfig
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// Network / model download / translator failure — keep showing the original. Do not
// cache: a transient 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 @Composable
private fun RenderTextWithTranslateOptions( private fun RenderTextWithTranslateOptions(
translatedTextState: TranslationConfig, translatedTextState: TranslationConfig,
content: String, content: String,
languagePreferences: Map<String, String>,
translationMessageModifier: Modifier = MaxWidthPaddingTop5dp, translationMessageModifier: Modifier = MaxWidthPaddingTop5dp,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
displayText: @Composable (String) -> Unit, displayText: @Composable (String) -> Unit,
) { ) {
var showOriginal by val source = translatedTextState.sourceLang
remember(translatedTextState) { mutableStateOf(translatedTextState.showOriginal) } val target = translatedTextState.targetLang
val translationOccurred = source != null && target != null && source != target
val toBeViewed by val storedPreference = if (translationOccurred) languagePreferences["$source,$target"] else null
remember(translatedTextState) { var showOriginal by
derivedStateOf { if (showOriginal) content else translatedTextState.result ?: content } remember(translatedTextState, storedPreference) {
mutableStateOf(storedPreference == source)
} }
val toBeViewed = if (showOriginal || !translationOccurred) content else translatedTextState.result
Column { Column {
displayText(toBeViewed) displayText(toBeViewed)
if ( if (translationOccurred) {
translatedTextState.sourceLang != null &&
translatedTextState.targetLang != null &&
translatedTextState.sourceLang != translatedTextState.targetLang
) {
TranslationMessage( TranslationMessage(
translatedTextState.sourceLang, source = source,
translatedTextState.targetLang, target = target,
translationMessageModifier, modifier = translationMessageModifier,
accountViewModel, accountViewModel = accountViewModel,
) { ) { showOriginal = it }
showOriginal = it
}
} }
} }
} }
@@ -165,21 +221,24 @@ private fun TranslationMessage(
onChangeWhatToShow: (Boolean) -> Unit, onChangeWhatToShow: (Boolean) -> Unit,
) { ) {
var langSettingsPopupExpanded by remember { mutableStateOf(false) } var langSettingsPopupExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Row( val sourceDisplay = remember(source) { Locale.forLanguageTag(source).displayName }
modifier = modifier, val targetDisplay = remember(target) { Locale.forLanguageTag(target).displayName }
) { val autoLabel = stringRes(R.string.translations_auto)
val translatedFromLabel = stringRes(R.string.translations_translated_from)
val toLabel = stringRes(R.string.translations_to)
Row(modifier = modifier) {
val textColor = MaterialTheme.colorScheme.lessImportantLink val textColor = MaterialTheme.colorScheme.lessImportantLink
Text( Text(
text = text =
buildAnnotatedString { buildAnnotatedString {
appendLink(stringRes(R.string.translations_auto), textColor) { langSettingsPopupExpanded = !langSettingsPopupExpanded } appendLink(autoLabel, textColor) { langSettingsPopupExpanded = !langSettingsPopupExpanded }
append(" ${stringRes(R.string.translations_translated_from)} ") append(" $translatedFromLabel ")
appendLink(Locale.forLanguageTag(source).displayName, textColor) { onChangeWhatToShow(true) } appendLink(sourceDisplay, textColor) { onChangeWhatToShow(true) }
append(" ${stringRes(R.string.translations_to)} ") append(" $toLabel ")
appendLink(Locale.forLanguageTag(target).displayName, textColor) { onChangeWhatToShow(false) } appendLink(targetDisplay, textColor) { onChangeWhatToShow(false) }
}, },
style = style =
LocalTextStyle.current.copy( LocalTextStyle.current.copy(
@@ -190,198 +249,109 @@ private fun TranslationMessage(
maxLines = 3, maxLines = 3,
) )
DropdownMenu( if (langSettingsPopupExpanded) {
expanded = langSettingsPopupExpanded, LangSettingsDropdown(
onDismissRequest = { langSettingsPopupExpanded = false }, expanded = true,
) { source = source,
DropdownMenuItem( target = target,
text = { sourceDisplay = sourceDisplay,
Row(verticalAlignment = Alignment.CenterVertically) { targetDisplay = targetDisplay,
if (source in accountViewModel.dontTranslateFrom()) { accountViewModel = accountViewModel,
Icon( onDismiss = { langSettingsPopupExpanded = false },
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 @Composable
fun translateAndWatchLanguageChanges( private fun LangSettingsDropdown(
content: String, expanded: Boolean,
id: String, source: String,
target: String,
sourceDisplay: String,
targetDisplay: String,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
): MutableState<TranslationConfig> { onDismiss: () -> Unit,
val translatedTextState = remember(id) { mutableStateOf(TranslationsCache.get(content)) }
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
}
}
return translatedTextState
}
@Composable
fun TranslateAndWatchLanguageChanges(
content: String,
accountViewModel: AccountViewModel,
onTranslated: (TranslationConfig) -> Unit,
) { ) {
LaunchedEffect(Unit) { val deviceLocales =
// This takes some time. Launches as a Composition scope to make sure this gets cancel if this remember {
// item gets out of view. val list = ConfigurationCompat.getLocales(Resources.getSystem().configuration)
withContext(Dispatchers.IO) { (0 until list.size()).mapNotNull { list.get(it) }
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) DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
} DropdownMenuItem(
} text = {
} CheckmarkRow(
checked = source in accountViewModel.dontTranslateFrom(),
label = stringRes(R.string.translations_never_translate_from_lang, sourceDisplay),
)
},
onClick = {
accountViewModel.toggleDontTranslateFrom(source)
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
CheckmarkRow(
checked = accountViewModel.account.settings.preferenceBetween(source, target) == source,
label = stringRes(R.string.translations_show_in_lang_first, sourceDisplay),
)
},
onClick = {
accountViewModel.prefer(source, target, source)
onDismiss()
},
)
DropdownMenuItem(
text = {
CheckmarkRow(
checked = accountViewModel.account.settings.preferenceBetween(source, target) == 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) {
DropdownMenuItem(
text = {
CheckmarkRow(
checked = accountViewModel.account.settings.translateToContains(lang.language),
label = stringRes(R.string.translations_always_translate_to_lang, lang.displayName),
)
},
onClick = {
onDismiss()
accountViewModel.updateTranslateTo(lang.language)
},
)
} }
} }
} }
@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)
}
}