diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AppLanguage.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AppLanguage.kt index b2852269c..ff95aae4b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AppLanguage.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AppLanguage.kt @@ -1,6 +1,7 @@ package com.utun.chat.data import android.content.Context +import android.os.Looper import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatDelegate import androidx.core.content.ContextCompat @@ -11,50 +12,98 @@ import com.utun.chat.headless.HeadlessService import org.xmlpull.v1.XmlPullParser import java.util.Locale -/** Android/AppCompat хранит выбор; XML задаёт общий список для приложения и системных настроек. */ +/** Android/AppCompat хранит выбранные языки, locales_config.xml задаёт общий каталог приложения/системных настроек. + * select/applied требуют main; context/text доступны вне Activity, в том числе сервисам при холодном запуске. + * Выбор не дублируется в DataStore и не перезапускает ядро/медиа. Применение конфигурации пересоздаёт Activity. + * Каждый applied обновляет уведомления заново: одинаковый locale не доказывает успех прежней попытки или наличие нового владельца. */ object AppLanguage { - private var appliedLocales: String? = null - val supportedTags: List by lazy { + /** Нормализованные BCP47-теги в порядке XML, кэшируются только после успешного чтения; ошибка логируется/пробрасывается. */ + val supportedTags: List by lazy { readSupportedTags() } + + /** Прочитать/проверить каталог атомарно и закрыть parser также при ошибке; lazy-провал допускает следующую попытку. */ + internal fun readSupportedTags(): List = try { ChatApplication.instance.resources.getXml(R.xml.locales_config).use { parser -> - buildList { + val tags = buildList { while (parser.next() != XmlPullParser.END_DOCUMENT) { if (parser.eventType == XmlPullParser.START_TAG && parser.name == "locale") { - add(parser.getAttributeValue("http://schemas.android.com/apk/res/android", "name")) + val tag = requireNotNull(parser.getAttributeValue("http://schemas.android.com/apk/res/android", "name")) { + "locale has no android:name" + } + require(tag.isNotBlank()) { "empty locale tag" } + val locale = Locale.Builder().setLanguageTag(tag).build() + require(locale.language.isNotEmpty()) { "locale has no language: tag=$tag" } + add(locale.toLanguageTag()) } } } + require(tags.isNotEmpty()) { "locale catalog is empty" } + require(tags.distinct().size == tags.size) { "duplicate locale tags: $tags" } + LogManager.addLog("DEBUG", "LANGUAGE", "Supported language catalog loaded: $tags") + tags + } + } catch (error: Exception) { + LogManager.addLog("ERROR", "LANGUAGE", "Language catalog load failed: $error") + throw error + } + + /** Запрошенный Android/AppCompat список через запятую; пусто — следовать системе, это не effective язык ресурсов. + * Ошибка чтения логируется и передаётся вызывающему, не маскируется под системный выбор. */ + fun selectedTag(): String = try { AppCompatDelegate.getApplicationLocales().toLanguageTags() + } catch (error: Exception) { + LogManager.addLog("ERROR", "LANGUAGE", "Application language selection read failed: $error"); throw error + } + + /** На main запросить один supported tag либо пустую строку (система). + * true — уже выбран/запрос принят AppCompat, false — неподдержанный тег или ошибка; это не подтверждение смены ресурсов. */ + fun select(tag: String): Boolean { + checkMain() + return try { + if (tag.isNotEmpty() && tag !in supportedTags) { + LogManager.addLog("ERROR", "LANGUAGE", "Unsupported language tag=$tag supported=$supportedTags") + return false + } + val previous = selectedTag() + if (previous == tag) return true + AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(tag)) + LogManager.addLog("INFO", "LANGUAGE", "Language selection requested: ${previous.ifEmpty { "system" }} -> ${tag.ifEmpty { "system" }}") + true + } catch (error: Exception) { + LogManager.addLog("ERROR", "LANGUAGE", "Language selection failed: tag=$tag: $error") + false } } - fun selectedTag(): String = AppCompatDelegate.getApplicationLocales().toLanguageTags() - - fun select(tag: String) { - if (tag.isNotEmpty() && tag !in supportedTags) { - LogManager.addLog("ERROR", "LANGUAGE", "Unsupported language tag=$tag supported=$supportedTags") - return - } - val previous = selectedTag() - if (previous == tag) return - LogManager.addLog("INFO", "LANGUAGE", "Language selected: ${previous.ifEmpty { "system" }} -> ${tag.ifEmpty { "system" }}") - AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(tag)) - } - + /** Название одиночного BCP47-тега на его языке; для неизвестного названия возвращает исходный tag. */ fun nativeName(tag: String): String { val locale = Locale.forLanguageTag(tag) - return locale.getDisplayName(locale).replaceFirstChar { it.titlecase(locale) } + return locale.getDisplayName(locale).ifEmpty { tag }.replaceFirstChar { it.titlecase(locale) } } - // Application/Service также получают сохранённый язык, включая холодный запуск без Activity. - fun context(): Context = ContextCompat.getContextForLanguage(ChatApplication.instance) + /** Локализованный Context для Application/Service без Activity; ошибки Android логируются и передаются владельцу. */ + fun context(): Context = try { ContextCompat.getContextForLanguage(ChatApplication.instance) + } catch (error: Exception) { + LogManager.addLog("ERROR", "LANGUAGE", "Localized context unavailable: $error"); throw error + } - fun text(@StringRes id: Int, vararg args: Any): String = context().getString(id, *args) + /** Строка текущих ресурсов с форматированием; ошибки контекста/ресурса не подменяются текстом другого языка. */ + fun text(@StringRes id: Int, vararg args: Any): String { + val ctx = context() + return try { ctx.getString(id, *args) + } catch (error: Exception) { + LogManager.addLog("ERROR", "LANGUAGE", "Localized text unavailable: resource=$id arguments=${args.size}: $error"); throw error + } + } - /** Вызывается после применения конфигурации; обновляет только текст действующих уведомлений. */ + /** На main после сигнала конфигурации/создания Activity обновить только каналы/тексты текущих уведомлений. + * Повтор с тем же effective locale нужен после отказа, в том числе обработанного внутри владельца. + * Отказы изолированы; при ошибке чтения контекста/конфигурации сигнал логируется и пропускается до следующего вызова. */ fun applied() { - val locales = context().resources.configuration.locales.toLanguageTags() - if (locales == appliedLocales) return - appliedLocales = locales - LogManager.addLog("INFO", "LANGUAGE", "Application language applied: locales=$locales") + checkMain() + val locales = try { context().resources.configuration.locales.toLanguageTags() + } catch (error: Exception) { + LogManager.addLog("ERROR", "LANGUAGE", "Notification language configuration unavailable: $error"); return + } + LogManager.addLog("DEBUG", "LANGUAGE", "Notification language refresh requested: locales=$locales") val updates = listOf( "network" to { HeadlessService.refreshLanguage() }, "incoming call" to { IncomingCallManager.refreshLanguage() }, @@ -63,7 +112,16 @@ object AppLanguage { ) updates.forEach { (name, update) -> try { update() } - catch (e: Exception) { LogManager.addLog("ERROR", "LANGUAGE", "Notification language refresh failed: $name: $e") } + catch (e: Exception) { + LogManager.addLog("ERROR", "LANGUAGE", "Notification language refresh failed: owner=$name locales=$locales: $e") + } + } + } + + private fun checkMain() { + check(Looper.myLooper() === Looper.getMainLooper()) { + val message = "AppLanguage requires main thread: caller=${Thread.currentThread().name}" + LogManager.addLog("ERROR", "LANGUAGE", message); message } } }