فهرست منبع

refactor(更新)
- 数据库升级SQLCipher加密,调整初始化逻辑
- 优化i18n绑定适配器,支持更多控件和属性
- 移除无用代码,修复部分文本显示

周文健 2 ماه پیش
والد
کامیت
3047649db9

+ 8 - 1
app/src/main/java/com/grkj/iscs/ISCSApplication.kt

@@ -10,6 +10,8 @@ import android.util.TypedValue
 import ch.qos.logback.classic.Level
 import com.drake.statelayout.StateConfig
 import com.grkj.data.data.EventConstants
+import com.grkj.data.database.DbReadyGate
+import com.grkj.data.database.ISCSDatabase
 import com.grkj.data.di.LogicManager
 import com.grkj.iscs.common.GlobalManager
 import com.grkj.iscs.features.splash.activity.SplashActivity
@@ -36,6 +38,9 @@ import com.sik.sikcore.extension.toJson
 import com.sik.sikcore.log.LogUtils
 import com.sik.sikcore.thread.ThreadUtils
 import dagger.hilt.android.HiltAndroidApp
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
 import me.jessyan.autosize.AutoSizeConfig
 import org.greenrobot.eventbus.EventBus
 import org.greenrobot.eventbus.Subscribe
@@ -72,6 +77,7 @@ class ISCSApplication : Application() {
         if (ISCSConfig.DEBUG) {
             LogUtils.setGlobalLogLevel(Level.DEBUG)
         }
+        System.loadLibrary("sqlcipher")   // 新库必须手动加载
         I18nManager.init(
             defaultLocale = LanguageStore.resolveEffectiveLocale(this),
             initialSources = arrayOf(
@@ -90,11 +96,12 @@ class ISCSApplication : Application() {
             BleUtil.instance?.initBle(this)
         }
         AutoSizeConfig.getInstance().isCustomFragment = true
+        StateConfig.emptyLayout = com.grkj.ui_base.R.layout.layout_empty
         ThreadUtils.runOnIO {
+            DbReadyGate.await()
             ModbusBusinessManager.registerMainListener()
             LogicManager.init(this@ISCSApplication)
         }
-        StateConfig.emptyLayout = com.grkj.ui_base.R.layout.layout_empty
     }
 
     @Subscribe(threadMode = ThreadMode.MAIN)

+ 30 - 9
app/src/main/java/com/grkj/iscs/features/splash/activity/SplashActivity.kt

@@ -3,11 +3,14 @@ package com.grkj.iscs.features.splash.activity
 import android.content.Intent
 import android.view.Gravity
 import androidx.activity.viewModels
+import androidx.lifecycle.lifecycleScope
 import androidx.work.Constraints
 import androidx.work.ExistingPeriodicWorkPolicy
 import androidx.work.PeriodicWorkRequestBuilder
 import androidx.work.WorkManager
 import com.grkj.data.data.MMKVConstants
+import com.grkj.data.database.DbReadyGate
+import com.grkj.data.database.ISCSDatabase
 import com.grkj.data.database.RoomBackupWorker
 import com.grkj.iscs.R
 import com.grkj.iscs.common.GlobalManager
@@ -15,12 +18,17 @@ import com.grkj.iscs.databinding.ActivitySplashBinding
 import com.grkj.iscs.features.init.activity.InitActivity
 import com.grkj.iscs.features.login.activity.LoginActivity
 import com.grkj.iscs.features.splash.viewmodel.SplashViewModel
+import com.grkj.shared.config.Constants
 import com.grkj.ui_base.base.BaseActivity
 import com.grkj.ui_base.utils.event.StartModbusEvent
 import com.kongzue.dialogx.DialogX
 import com.kongzue.dialogx.util.TextInfo
+import com.sik.sikandroid.permission.PermissionUtils
 import com.sik.sikcore.extension.getMMKVData
 import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
 import java.util.concurrent.TimeUnit
 
 @AndroidEntryPoint
@@ -57,20 +65,33 @@ class SplashActivity : BaseActivity<ActivitySplashBinding>() {
         DialogX.menuTextInfo = dialogXTextInfo
         DialogX.okButtonTextInfo = dialogXTextInfo
         DialogX.titleTextInfo = dialogXTitleTextInfo
-        StartModbusEvent.sendStartModbusEvent()
         //todo 测试用,直接进入,不初始化
 //        viewModel.checkSysMenuAndRole().observe(this) {
 //            startActivity(Intent(this, LoginActivity::class.java))
 //            finish()
 //        }
-        viewModel.checkSysMenuAndRole().observe(this) {
-            val isAppInit = MMKVConstants.APP_INIT.getMMKVData(false)
-            if (isAppInit) {
-                startActivity(Intent(this, LoginActivity::class.java))
-                finish()
-            } else {
-                startActivity(Intent(this, InitActivity::class.java))
-                finish()
+        PermissionUtils.checkAndRequestPermissions(Constants.needPermission) {
+            logger.info("授权结果:${it}")
+            if (it) {
+                // 已有权限的话,直接预热:
+                CoroutineScope(Dispatchers.IO).launch {
+                    // 触发构建 + 迁移 + 打开;onOpen 回调里会 DbReadyGate.open()
+                    val db = ISCSDatabase.instance
+                }
+            }
+        }
+        lifecycleScope.launch {
+            DbReadyGate.await()
+            StartModbusEvent.sendStartModbusEvent()
+            viewModel.checkSysMenuAndRole().observe(this@SplashActivity) {
+                val isAppInit = MMKVConstants.APP_INIT.getMMKVData(false)
+                if (isAppInit) {
+                    startActivity(Intent(this@SplashActivity, LoginActivity::class.java))
+                    finish()
+                } else {
+                    startActivity(Intent(this@SplashActivity, InitActivity::class.java))
+                    finish()
+                }
             }
         }
     }

+ 2 - 2
app/src/main/res/layout/activity_login.xml

@@ -64,10 +64,10 @@
                 android:layout_height="wrap_content"
                 android:layout_gravity="center_horizontal"
                 android:layout_marginTop="@dimen/login_main_title_margin_top"
-                android:text="@string/loto"
                 android:textColor="@color/white"
                 android:textSize="@dimen/login_main_title_text_size"
-                android:textStyle="bold" />
+                android:textStyle="bold"
+                app:i18nKey='@{"loto"}' />
 
             <com.grkj.ui_base.widget.ShadowTextView
                 android:id="@+id/title_en"

+ 1 - 1
app/src/main/res/layout/dialog_update_workstation.xml

@@ -21,7 +21,7 @@
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:layout_weight="1"
-                android:text="@string/workstation_manage_update_workstation"
+                android:text="@string/workstation_manage_workstation_name"
                 android:textColor="@color/black"
                 android:textSize="@dimen/common_btn_text_size" />
 

+ 3 - 0
data/build.gradle.kts

@@ -49,4 +49,7 @@ dependencies {
 
     implementation("com.google.dagger:hilt-android:2.56.2")
     ksp("com.google.dagger:hilt-android-compiler:2.56.2")
+
+    implementation("net.zetetic:sqlcipher-android:4.6.0@aar")
+    implementation("androidx.sqlite:sqlite:2.4.0")
 }

+ 9 - 0
data/src/main/java/com/grkj/data/database/DbReadyGate.kt

@@ -0,0 +1,9 @@
+package com.grkj.data.database
+
+import kotlinx.coroutines.CompletableDeferred
+
+object DbReadyGate {
+    private val ready = CompletableDeferred<Unit>()
+    fun open() { if (!ready.isCompleted) ready.complete(Unit) }
+    suspend fun await() = ready.await()
+}

+ 200 - 22
data/src/main/java/com/grkj/data/database/ISCSDatabase.kt

@@ -6,19 +6,29 @@ import androidx.room.Database
 import androidx.room.Room
 import androidx.room.RoomDatabase
 import androidx.room.TypeConverters
+import androidx.sqlite.db.SupportSQLiteDatabase
 import com.grkj.data.converters.Converters
 import com.grkj.data.dao.*
 import com.grkj.data.model.dos.*
+import com.grkj.shared.config.AESConfig
 import com.grkj.shared.config.Constants
 import com.sik.sikcore.SIKCore
 import kotlinx.coroutines.Dispatchers
+import net.zetetic.database.sqlcipher.SQLiteConnection
+import net.zetetic.database.sqlcipher.SQLiteDatabaseHook
+import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
 import org.slf4j.Logger
 import org.slf4j.LoggerFactory
 import java.io.File
 import java.io.FileOutputStream
+import java.util.concurrent.Executors
+
+// 显式区分两个 SQLite,避免 IDE 导错:
+import net.zetetic.database.sqlcipher.SQLiteDatabase as CipherDB
+import android.database.sqlite.SQLiteDatabase as SysDB
 
 /**
- * 本地数据库
+ * 本地数据库(SQLCipher)
  */
 @Database(
     entities = [
@@ -52,6 +62,8 @@ abstract class ISCSDatabase : RoomDatabase() {
     companion object {
         const val DB_NAME = "iscs_database.db"
         private val logger: Logger = LoggerFactory.getLogger(ISCSDatabase::class.java)
+        // 放在 companion 里
+        private val MIGRATION_LOCK = Any()
 
         // 外部存储目录路径
         val EXTERNAL_DB_FILE: File by lazy {
@@ -62,26 +74,21 @@ abstract class ISCSDatabase : RoomDatabase() {
         val instance: ISCSDatabase by lazy {
             val context = SIKCore.getApplication()
 
-            // 检查并创建外部目录
+            // 目录
             val parentDir = EXTERNAL_DB_FILE.parentFile
-            if (parentDir != null) {
-                if (!parentDir.exists()) {
-                    val ok = parentDir.mkdirs()
-                    if (!ok) {
-                        Log.e("ISCSDatabase", "无法创建目录: ${parentDir.absolutePath}")
-                    } else {
-                        logger.info("创建目录: ${parentDir.absolutePath}")
-                    }
+            if (parentDir != null && !parentDir.exists()) {
+                if (!parentDir.mkdirs()) {
+                    Log.e("ISCSDatabase", "无法创建目录: ${parentDir.absolutePath}")
+                } else {
+                    logger.info("创建目录: ${parentDir.absolutePath}")
                 }
             }
 
-            // 若文件不存在,从 assets 拷贝
+            // 首次落地模板库(可能是明文,也可能本来就是加密库)
             if (!EXTERNAL_DB_FILE.exists()) {
                 try {
                     context.assets.open("data.db").use { input ->
-                        FileOutputStream(EXTERNAL_DB_FILE).use { output ->
-                            input.copyTo(output)
-                        }
+                        FileOutputStream(EXTERNAL_DB_FILE).use { output -> input.copyTo(output) }
                     }
                     logger.info("已从 assets 复制数据库到: ${EXTERNAL_DB_FILE.absolutePath}")
                 } catch (e: Exception) {
@@ -89,20 +96,191 @@ abstract class ISCSDatabase : RoomDatabase() {
                 }
             }
 
-            // 构建 RoomDatabase,使用外部存储路径
-            val builder = Room.databaseBuilder(
+            // 确保成为加密库(首启自动迁移)
+            ensureCiphered(EXTERNAL_DB_FILE)
+
+            // Room + SQLCipher
+            val passphrase: ByteArray = AESConfig.defaultConfig.key()
+            val hook = object : SQLiteDatabaseHook {
+                override fun preKey(connection: SQLiteConnection) { /* no-op */ }
+                override fun postKey(connection: SQLiteConnection) {
+                    // 这些 PRAGMA 都要把三个参数补全(bindArgs, cancellationSignal)
+                    connection.execute("PRAGMA foreign_keys=ON", null, null)
+                    // journal_mode 建议用 executeForString,返回当前模式字符串
+                    connection.executeForString("PRAGMA journal_mode=WAL", null, null)
+                    connection.execute("PRAGMA synchronous=NORMAL", null, null)
+                }
+            }
+            val factory = SupportOpenHelperFactory(passphrase)
+
+            val db = Room.databaseBuilder(
                 context,
                 ISCSDatabase::class.java,
                 EXTERNAL_DB_FILE.absolutePath
-            ).addMigrations(*ISCSMigrations.migrationData)
-            if (Constants.DEBUG) {
-                builder.setQueryCallback(Dispatchers.IO, { sql, args ->
-                    logger.debug("SQL: $sql, args: $args")
+            )
+                .openHelperFactory(factory)
+                .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) // ✅ 显式 WAL
+                .addMigrations(*ISCSMigrations.migrationData)
+                .addCallback(object : RoomDatabase.Callback() {
+                    override fun onOpen(db: SupportSQLiteDatabase) {
+                        // 数据库已经可读写,放行初始化
+                        DbReadyGate.open()
+                    }
                 })
-                // 不要使用 fallbackToDestructiveMigration()
+                .build()
+
+            // 强制触发实际打开(避免“懒打开”延后 onOpen 回调)
+            db.openHelper.writableDatabase
+
+            db  // ← 作为 instance 返回
+        }
+
+        /**
+         * 保证目标库为加密库:
+         * - 能用口令打开 => 已加密,返回
+         * - 系统 SQLite 能打开 => 明文;做 WAL checkpoint + 备份 + sqlcipher_export
+         * - 两边都不行 => 可能是加密但口令不匹配/文件损坏,保守返回(不覆盖原文件)
+         */
+        private fun ensureCiphered(targetDbFile: File) {
+            if (!targetDbFile.exists()) return
+            val passphrase: ByteArray = AESConfig.defaultConfig.key()
+
+            synchronized(MIGRATION_LOCK) {
+                if (tryOpenCipherDb(targetDbFile, passphrase)) {
+                    logger.info("数据库已为加密库:${targetDbFile.absolutePath}")
+                    return
+                }
+
+                if (tryOpenPlainDb(targetDbFile)) {
+                    safePlainWalCheckpoint(targetDbFile)
+
+                    val app = SIKCore.getApplication()
+                    val backup = File(targetDbFile.parentFile, "${targetDbFile.name}.bak")
+                    val tmpCipher = File(app.cacheDir, "${targetDbFile.name}.tmp.cipher") // ✅ 内部缓存
+                    if (tmpCipher.exists()) tmpCipher.delete()                            // ✅ 先清理
+
+                    try {
+                        targetDbFile.copyTo(backup, overwrite = true)
+
+                        migratePlainToCipher(
+                            plainPath = targetDbFile.absolutePath,
+                            cipherPath = tmpCipher.absolutePath,
+                            passphrase = passphrase
+                        )
+
+                        // 二次校验:确保 tmp 真是可开的加密库
+                        check(tryOpenCipherDb(tmpCipher, passphrase)) { "迁移后加密库校验失败" }
+
+                        // ✅ 安全替换(见下一个改动)
+                        safeReplaceFile(tmpCipher, targetDbFile)
+
+                        logger.info("已将明文库迁移为加密库:${targetDbFile.absolutePath}")
+                    } catch (e: Exception) {
+                        logger.error("明文→加密迁移失败,已保留原库与备份", e)
+                        if (tmpCipher.exists()) tmpCipher.delete()
+                    }
+                    return
+                }
+
+                logger.error("数据库无法识别:可能是加密口令不匹配或文件损坏,停止处理以保护数据 -> ${targetDbFile.absolutePath}")
+            }
+        }
+
+        // 用 SQLCipher 测试是否“已加密并且口令正确”
+        private fun tryOpenCipherDb(dbFile: File, passphrase: ByteArray): Boolean = try {
+            val db = net.zetetic.database.sqlcipher.SQLiteDatabase.openOrCreateDatabase(
+                dbFile, passphrase, null, null, null
+            )
+            db.close()
+            true
+        } catch (_: Throwable) { false }
+
+        // 用系统 SQLite 判定是否“明文库”
+        private fun tryOpenPlainDb(dbFile: File): Boolean = try {
+            val db = android.database.sqlite.SQLiteDatabase.openDatabase(
+                dbFile.absolutePath, null,
+                android.database.sqlite.SQLiteDatabase.OPEN_READONLY
+                        or android.database.sqlite.SQLiteDatabase.NO_LOCALIZED_COLLATORS
+            )
+            db.close()
+            true
+        } catch (_: Throwable) { false }
+
+        // 明文 → 加密(sqlcipher_export)
+        private fun migratePlainToCipher(plainPath: String, cipherPath: String, passphrase: ByteArray) {
+            val cipherDb = net.zetetic.database.sqlcipher.SQLiteDatabase.openOrCreateDatabase(
+                File(cipherPath), passphrase, null, null, null
+            )
+            try {
+                // 保险起见,确认 main 为空(可选)
+                val cur = cipherDb.rawQuery("SELECT COUNT(*) FROM sqlite_master", null)
+                if (cur.moveToFirst() && cur.getLong(0) != 0L) {
+                    cur.close()
+                    throw IllegalStateException("目标加密库非空,已中止导出:$cipherPath")
+                }
+                cur.close()
+
+                val escapedPlain = plainPath.replace("'", "''")
+                cipherDb.rawExecSQL("ATTACH DATABASE '$escapedPlain' AS plaintext KEY ''")
+                cipherDb.rawExecSQL("SELECT sqlcipher_export('main','plaintext')")
+                cipherDb.rawExecSQL("DETACH DATABASE plaintext")
+                cipherDb.rawExecSQL("VACUUM")
+            } finally {
+                cipherDb.close()
+            }
+        }
+
+
+        // 明文库做一次 WAL checkpoint,避免数据停在 -wal
+        private fun safePlainWalCheckpoint(dbFile: File) {
+            try {
+                val db = android.database.sqlite.SQLiteDatabase.openDatabase(
+                    dbFile.absolutePath, null,
+                    android.database.sqlite.SQLiteDatabase.OPEN_READWRITE
+                )
+                db.rawQuery("PRAGMA wal_checkpoint(TRUNCATE)", null).close()
+                db.execSQL("PRAGMA journal_mode=DELETE")
+                db.close()
+                File(dbFile.parent, dbFile.name + "-wal").delete()
+                File(dbFile.parent, dbFile.name + "-shm").delete()
+            } catch (_: Throwable) {
+                // 忽略:老库未启用 WAL 也无妨
             }
+        }
+
+        private fun safeReplaceFile(src: File, dst: File) {
+            if (!src.exists()) throw IllegalStateException("临时加密库不存在:${src.absolutePath}")
+            dst.parentFile?.mkdirs()
 
-            builder.build()
+            if (dst.exists()) {
+                val old = File(dst.parentFile, dst.name + ".old")
+                if (old.exists()) old.delete()
+                // 尽量挪走旧文件,失败也不紧张
+                dst.renameTo(old)
+                copyOverwrite(src, dst)
+                old.delete()
+                src.delete()
+            } else {
+                if (!src.renameTo(dst)) {
+                    copyOverwrite(src, dst)
+                    src.delete()
+                }
+            }
+            dst.setReadable(true, false)
+            dst.setWritable(true, false)
         }
+
+        private fun copyOverwrite(src: File, dst: File) {
+            java.io.FileInputStream(src).channel.use { inCh ->
+                java.io.FileOutputStream(dst).channel.use { outCh ->
+                    var pos = 0L
+                    val size = inCh.size()
+                    while (pos < size) pos += inCh.transferTo(pos, size - pos, outCh)
+                    outCh.force(true) // fsync,确保落盘
+                }
+            }
+        }
+
+
     }
 }

+ 0 - 3
shared/build.gradle.kts

@@ -53,9 +53,6 @@ dependencies {
 
     implementation(libs.androidx.core.ktx)
     compileOnly(libs.androidx.appcompat)
-    compileOnly(libs.material)
-    compileOnly(libs.androidx.activity)
-    compileOnly(libs.androidx.constraintlayout)
     api(libs.sik.extension.core)
     api(libs.sik.extension.encrypt)
     api(libs.sik.extension.image)

+ 0 - 16
shared/src/main/java/com/grkj/shared/utils/extension/Data.kt

@@ -1,16 +0,0 @@
-package com.grkj.shared.utils.extension
-
-import com.google.gson.reflect.TypeToken
-import com.sik.sikcore.data.GlobalDataTempStore
-
-/**
- * 获取复杂数据
- */
-inline fun <reified T> GlobalDataTempStore.getComplexData(
-    key: String,
-    isDeleteAfterGet: Boolean = true
-): T? {
-    val type = object : TypeToken<T>() {}.type
-    val json = this.nativeGetData(key, isDeleteAfterGet)
-    return gson.fromJson(json, type)
-}

+ 120 - 32
shared/src/main/java/com/grkj/shared/utils/i18n/databinding/I18nBindingAdapters.kt

@@ -1,57 +1,145 @@
+@file:JvmName("I18nBindingAdapters")
+
 package com.grkj.shared.utils.i18n.databinding
 
 import android.view.View
-import android.widget.TextView
-import androidx.appcompat.widget.Toolbar
 import androidx.databinding.BindingAdapter
 import androidx.lifecycle.findViewTreeLifecycleOwner
 import androidx.lifecycle.lifecycleScope
-import com.google.android.material.textfield.TextInputLayout
 import com.grkj.shared.utils.i18n.I18nManager
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.launch
+import java.lang.reflect.Method
+import java.util.WeakHashMap
+import java.util.concurrent.ConcurrentHashMap
+
+// ---- 订阅管理:无资源 id、无第三方依赖 ----
+private val i18nJobs = WeakHashMap<View, Job>()
+private val listenerAdded = WeakHashMap<View, Boolean>()
 
-/**
- * 将一个订阅 Job 挂在 View 的 tag 上,随生命周期清理,防止泄漏。
- * - 为避免 ID 冲突,请在你的 res/values/ids.xml 添加 i18n_locale_observer_job。
- */
 private fun View.observeLocale(onChange: () -> Unit) {
-    val owner = findViewTreeLifecycleOwner() ?: return
-    val keyId = resources.getIdentifier("i18n_locale_observer_job", "id", context.packageName)
-    (getTag(keyId) as? Job)?.cancel()
+    val owner = findViewTreeLifecycleOwner()
+    if (owner == null) {
+        // 还没挂到带生命周期的树上:等 attach 后再订阅一次
+        if (listenerAdded[this] != true) {
+            addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
+                override fun onViewAttachedToWindow(v: View) {
+                    v.removeOnAttachStateChangeListener(this)
+                    listenerAdded.remove(v)
+                    v.observeLocale(onChange) // 重新尝试
+                }
+                override fun onViewDetachedFromWindow(v: View) { /* no-op */ }
+            })
+            listenerAdded[this] = true
+        }
+        return
+    }
+
+    // 取消旧订阅,建立新订阅
+    i18nJobs.remove(this)?.cancel()
     val job = owner.lifecycleScope.launch {
         I18nManager.locale.collectLatest { onChange() }
     }
-    setTag(keyId, job)
+    i18nJobs[this] = job
+
+    // 确保从窗口分离时清理订阅(防泄漏)
+    if (listenerAdded[this] != true) {
+        addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
+            override fun onViewDetachedFromWindow(v: View) {
+                i18nJobs.remove(v)?.cancel()
+                v.removeOnAttachStateChangeListener(this)
+                listenerAdded.remove(v)
+            }
+            override fun onViewAttachedToWindow(v: View) { /* no-op */ }
+        })
+        listenerAdded[this] = true
+    }
+}
+
+// ---- 反射缓存,降低开销 ----
+private val methodCache = ConcurrentHashMap<Class<*>, MutableMap<String, Method?>>()
+
+private fun findSetter(view: View, method: String): Method? {
+    val cls = view.javaClass
+    val map = methodCache.getOrPut(cls) { ConcurrentHashMap() }
+    return map.getOrPut(method) {
+        // 优先 (CharSequence) 入参
+        cls.methods.firstOrNull { m ->
+            m.name == method && m.parameterTypes.size == 1 &&
+                    CharSequence::class.java.isAssignableFrom(m.parameterTypes[0])
+        } ?: cls.methods.firstOrNull { it.name == method && it.parameterTypes.size == 1 }
+    }
 }
 
-@BindingAdapter(value = ["i18nKey", "i18nArgs", "i18nCount", "i18nSelect", "i18nFallback"], requireAll = false)
-fun TextView.bindI18nText(
+private fun applyTextVia(
+    view: View,
+    value: CharSequence,
+    preferred: List<String>,
+    explicitMethod: String?
+) {
+    val candidates = buildList {
+        if (!explicitMethod.isNullOrBlank()) add(explicitMethod)
+        addAll(preferred)
+    }.distinct()
+
+    for (name in candidates) {
+        val m = findSetter(view, name) ?: continue
+        try {
+            val p = m.parameterTypes[0]
+            val arg = when {
+                p.isAssignableFrom(CharSequence::class.java) -> value
+                p == String::class.java -> value.toString()
+                else -> continue
+            }
+            m.invoke(view, arg)
+            return
+        } catch (_: Throwable) {
+            // 下一个候选
+        }
+    }
+    // 兜底:写 contentDescription
+    runCatching { findSetter(view, "setContentDescription")?.invoke(view, value) }
+}
+
+// ---- 通用 BindingAdapter:一个适配所有控件 ----
+/**
+ * 通用 i18n 绑定:
+ *  - i18nTarget: "text" | "hint" | "title" | "contentDescription"
+ *  - i18nMethod: 自定义方法名(优先级最高),如 "setSubtitle"、"setHelperText"
+ *
+ * 未指定 target/method 时,按 setText → setHint → setTitle 顺序尝试。
+ */
+@BindingAdapter(
+    value = ["i18nKey", "i18nArgs", "i18nCount", "i18nSelect", "i18nFallback", "i18nTarget", "i18nMethod"],
+    requireAll = false
+)
+fun bindI18n(
+    view: View,
     key: String?,
     args: Map<String, Any?>? = null,
     count: Number? = null,
     select: String? = null,
-    fallback: String? = null
+    fallback: String? = null,
+    target: String? = null,
+    method: String? = null
 ) {
     if (key.isNullOrBlank()) return
-    val apply = { text = I18nManager.t(key, args, count, select, fallback) }
-    apply()
-    observeLocale { apply() }
-}
 
-@BindingAdapter(value = ["i18nHintKey", "i18nHintArgs"], requireAll = false)
-fun TextInputLayout.bindI18nHint(key: String?, args: Map<String, Any?>? = null) {
-    if (key.isNullOrBlank()) return
-    val apply = { hint = I18nManager.t(key, args) }
-    apply()
-    observeLocale { apply() }
-}
+    val compute: () -> CharSequence = { I18nManager.t(key, args, count, select, fallback) }
+    val applyOnce: () -> Unit = {
+        val text = compute()
+        val preferred = when (target?.lowercase()) {
+            "text" -> listOf("setText")
+            "hint" -> listOf("setHint")
+            "title" -> listOf("setTitle")
+            "contentdescription" -> listOf("setContentDescription")
+            null, "" -> listOf("setText", "setHint", "setTitle")
+            else -> emptyList()
+        }
+        applyTextVia(view, text, preferred, method)
+    }
 
-@BindingAdapter(value = ["i18nTitleKey", "i18nTitleArgs"], requireAll = false)
-fun Toolbar.bindI18nTitle(key: String?, args: Map<String, Any?>? = null) {
-    if (key.isNullOrBlank()) return
-    val apply = { title = I18nManager.t(key, args) }
-    apply()
-    observeLocale { apply() }
-}
+    applyOnce()
+    view.observeLocale { applyOnce() }
+}