Retry notification language refreshes on every configuration signal

This commit is contained in:
evgeny committed 2026-10-28 14:43:27 +01:00
1 parent 5360ee9ff1
commit fd183a43fe
1 file changed
+86 -28
@@ -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<String> by lazy {
/** Нормализованные BCP47-теги в порядке XML, кэшируются только после успешного чтения; ошибка логируется/пробрасывается. */
val supportedTags: List<String> by lazy { readSupportedTags() }
/** Прочитать/проверить каталог атомарно и закрыть parser также при ошибке; lazy-провал допускает следующую попытку. */
internal fun readSupportedTags(): List<String> = 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
}
}
}