fix: 与系统应用语言页同步(framework 唯一权威源)+ widget 文本跟随最新偏好(Codex review + 审计)

This commit is contained in:
WaterRain 2026-08-02 00:12:35 +00:00
parent f463751fb1
commit 2e4e5ce449
No known key found for this signature in database
3 changed files with 66 additions and 12 deletions

View File

@ -1,6 +1,7 @@
package com.rainy.token.ui.settings
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -230,8 +231,11 @@ private fun LanguageDialog(onDismiss: () -> Unit) {
.clickable {
LocaleManager.saveLocale(context, code)
onDismiss()
// 重建 Activity 让资源按新语言重新解析
(context as? Activity)?.recreate()
// Android 13+:framework 设置语言后系统自动重建 Activity;
// 旧系统才需要手动 recreate 让资源按新语言重新解析
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
(context as? Activity)?.recreate()
}
}
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically

View File

@ -15,6 +15,7 @@ import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.data.cache.balanceCacheDataStore
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.ui.components.normalizeWindowLabel
import com.rainy.token.util.LocaleManager
import kotlinx.coroutines.runBlocking
import java.text.SimpleDateFormat
import java.util.Date
@ -57,15 +58,19 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
) {
var selectedHasCachedData = false
// 每次渲染都用最新语言偏好重新包装 context:Application 层的 wrap 在进程启动时固定,
// 应用内切换语言后不会自动更新,必须在这里按当前偏好重建,否则 widget 文本停留在旧语言
val localized = LocaleManager.wrapContext(context)
for (widgetId in appWidgetIds) {
val views = RemoteViews(context.packageName, R.layout.widget_opencode_go)
// 署名动态设置:与行标签同源(跟随应用内语言),
// 避免 XML 静态文本由宿主进程按系统语言渲染造成中英混搭
views.setTextViewText(R.id.widget_brand, context.getString(R.string.widget_brand))
views.setTextViewText(R.id.widget_brand, localized.getString(R.string.widget_brand))
// DeepSeek 余额标签同样动态设置(静态 XML 文本由 Launcher 按系统语言解析)
views.setTextViewText(R.id.widget_ds_label, context.getString(R.string.widget_deepseek_balance))
views.setTextViewText(R.id.widget_ds_label, localized.getString(R.string.widget_deepseek_balance))
// 点击左上角品牌 → 打开 APP;点击其它内容区域 → 切换服务;刷新按钮单独刷新。
val openAppIntent = Intent(context, MainActivity::class.java).apply {
@ -102,7 +107,7 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
views.setTextViewText(R.id.widget_switch, shortName(selectedService))
views.setTextViewText(
R.id.widget_service_title,
context.getString(R.string.widget_service_quota, selectedService.displayName)
localized.getString(R.string.widget_service_quota, selectedService.displayName)
)
views.setImageViewResource(R.id.widget_logo, widgetLogo(selectedService))
// Ollama logo 是正方形,XML 默认 22x12 是给宽扁 logo 的
@ -116,20 +121,20 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
val cached = cache.get(selectedService)
if (cached != null) {
selectedHasCachedData = true
populateServiceRows(views, context, selectedService, cached.balance)
populateServiceRows(views, localized, selectedService, cached.balance)
val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
val timeText = sdf.format(Date(cached.fetchedAt))
views.setTextViewText(
R.id.widget_updated,
context.getString(R.string.widget_updated_at, timeText)
localized.getString(R.string.widget_updated_at, timeText)
)
} else {
setEmptyState(views, context)
setEmptyState(views, localized)
views.setTextViewText(R.id.widget_switch, shortName(selectedService))
views.setTextViewText(
R.id.widget_service_title,
context.getString(R.string.widget_service_quota, selectedService.displayName)
localized.getString(R.string.widget_service_quota, selectedService.displayName)
)
}
@ -143,7 +148,7 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
views.setTextViewText(R.id.widget_ds_amount, "—")
}
} catch (_: Exception) {
setEmptyState(views, context)
setEmptyState(views, localized)
}
}
@ -378,12 +383,14 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
}
fun showRefreshing(context: Context) {
// 与 onUpdate 同理:用最新语言偏好包装,避免进程启动后切换语言不生效
val localized = LocaleManager.wrapContext(context)
val appWidgetManager = AppWidgetManager.getInstance(context)
val component = ComponentName(context, OpenCodeGoWidgetProvider::class.java)
val ids = appWidgetManager.getAppWidgetIds(component)
ids.forEach { id ->
val views = RemoteViews(context.packageName, R.layout.widget_opencode_go)
views.setTextViewText(R.id.widget_updated, context.getString(R.string.widget_refreshing))
views.setTextViewText(R.id.widget_updated, localized.getString(R.string.widget_refreshing))
appWidgetManager.partiallyUpdateAppWidget(id, views)
}
}

View File

@ -2,6 +2,8 @@ package com.rainy.token.util
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import android.os.LocaleList
import java.util.Locale
/**
@ -19,21 +21,62 @@ import java.util.Locale
*
* 在 [Application] 与 [MainActivity] 的 attachBaseContext 中调用 [wrapContext]
* 应用偏好;切换语言后由设置页保存偏好并 recreate Activity 触发重新解析。
*
* Android 13+(TIRAMISU)与系统"应用语言"页同步:读写统一走 framework 的
* [android.app.LocaleManager],私有 SharedPreferences 仅在 API < 33 或
* framework 未设置时作为兜底——避免应用内偏好与系统级设置互相覆盖造成双源冲突。
*/
object LocaleManager {
private const val PREFS_NAME = "locale_prefs"
private const val KEY_LOCALE = "locale"
/** 当前保存的语言代码;null = 跟随系统 */
/** 当前生效的语言代码;null = 跟随系统 */
fun getLocaleCode(context: Context): String? {
// Android 13+:framework 是唯一权威源(系统"应用语言"页与应用内选择共用)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val locales = context.getSystemService(android.app.LocaleManager::class.java)?.applicationLocales
if (locales != null && !locales.isEmpty) {
for (i in 0 until locales.size()) {
val l = locales.get(i)
when {
l.language == "zh" && l.script != "Hant" -> return "zh"
l.language == "zh" && l.script == "Hant" -> return "zh-Hant"
l.language == "en" -> return "en"
}
}
return null // 系统选了不支持的第三方语言 → 回退默认英文资源
}
// framework 未设置(跟随系统):一次性迁移旧 SharedPreferences 偏好到
// framework 后清除,避免升级设备上过期偏好复活(framework 才是唯一权威源)
migrateLegacyPrefs(context)
return null
}
// API < 33:回退私有偏好
val code = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(KEY_LOCALE, null) ?: return null
return code.takeIf { it == "zh" || it == "zh-Hant" || it == "en" }
}
/** 把 API < 33 时代写入的私有偏好一次性迁移到 framework 并清除 */
private fun migrateLegacyPrefs(context: Context) {
// 方法体内守卫:lint 据此识别 API33 调用(@RequiresApi 注解在本环境解析异常)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val code = prefs.getString(KEY_LOCALE, null) ?: return
context.getSystemService(android.app.LocaleManager::class.java)
?.setApplicationLocales(LocaleList.forLanguageTags(code))
prefs.edit().remove(KEY_LOCALE).apply()
}
/** 保存语言偏好;传 null 表示跟随系统 */
fun saveLocale(context: Context, code: String?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Android 13+:写入系统"应用语言",framework 生效并自动重建 Activity
context.getSystemService(android.app.LocaleManager::class.java)
?.setApplicationLocales(LocaleList.forLanguageTags(code ?: ""))
return
}
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putString(KEY_LOCALE, code)