diff --git a/README.md b/README.md
index 9473dca..33ec1f2 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@ flutter pub run build_runner build --delete-conflicting-outputs~
## flutter打包
```
-flutter build apk --build-name 1.3.1 --build-number 131
+flutter build apk --build-name 1.3.2 --build-number 132
--build-name为版本号(展示)
--build-number为版本号(内部比较)
```
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 76ac970..f02da1a 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -70,6 +70,7 @@
android:exported="true">
+
diff --git a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/ApkUpdateManager.kt b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/ApkUpdateManager.kt
new file mode 100644
index 0000000..ea69e3e
--- /dev/null
+++ b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/ApkUpdateManager.kt
@@ -0,0 +1,194 @@
+package com.example.pj_supermarket_cloud
+
+import android.content.Context
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import android.os.Build
+import java.io.File
+import java.security.MessageDigest
+
+/**
+ * 校验并通过 Root 静默覆盖安装当前应用 APK。
+ *
+ * 安装前强制校验包名、签名和版本码,Root 仅用于执行 `pm install -r`,不会卸载重装、
+ * 绕过签名或清理应用数据,从而保留本地数据库与 Device Owner 状态。
+ */
+object ApkUpdateManager {
+ /** 待更新状态使用的原生偏好文件,应用替换后仍随数据目录保留。 */
+ private const val PREFERENCES_NAME = "apk_update_runtime"
+
+ /** 是否存在等待新版本启动确认的更新。 */
+ private const val PENDING_KEY = "pending"
+
+ /** 后台目标版本码。 */
+ private const val EXPECTED_VERSION_CODE_KEY = "expected_version_code"
+
+ /** 下载到应用私有目录的 APK 绝对路径。 */
+ private const val APK_PATH_KEY = "apk_path"
+
+ /** 安装命令最长允许 3 分钟,超时后按失败处理。 */
+ private const val INSTALL_TIMEOUT_MILLIS = 180_000L
+
+ /**
+ * 校验 APK 是否可安全覆盖当前应用。
+ *
+ * @param context 当前应用上下文
+ * @param apkPath 应用私有目录中的 APK 绝对路径
+ * @param expectedVersionCode 服务端声明的目标版本码
+ * @return MethodChannel 可直接返回的结构化校验结果
+ */
+ fun validate(context: Context, apkPath: String, expectedVersionCode: Long): Map {
+ val file = File(apkPath)
+ if (!file.isFile || file.length() <= 0L) {
+ return failure("APK_NOT_FOUND", "安装包不存在或为空")
+ }
+ val archive = packageArchiveInfo(context, apkPath)
+ ?: return failure("APK_INVALID", "无法解析安装包,请重新下载")
+ val current = currentPackageInfo(context)
+ val archiveVersionCode = versionCode(archive)
+ val currentVersionCode = versionCode(current)
+ if (archive.packageName != context.packageName) {
+ return failure("PACKAGE_MISMATCH", "安装包包名与当前应用不一致")
+ }
+ if (archiveVersionCode != expectedVersionCode) {
+ return failure("VERSION_MISMATCH", "安装包版本码与后台目标版本不一致")
+ }
+ if (archiveVersionCode <= currentVersionCode) {
+ return failure("VERSION_NOT_HIGHER", "目标版本不高于当前版本,已拒绝重复或降级安装")
+ }
+ if (signerDigests(archive).intersect(signerDigests(current)).isEmpty()) {
+ return failure("SIGNATURE_MISMATCH", "安装包签名与当前应用不一致")
+ }
+ return mapOf(
+ "valid" to true,
+ "code" to "OK",
+ "message" to "安装包校验通过",
+ "packageName" to archive.packageName,
+ "versionCode" to archiveVersionCode,
+ "versionName" to archive.versionName
+ )
+ }
+
+ /**
+ * 重新校验 Root 与 APK 后执行覆盖安装;待更新状态先于命令写入,保证进程被替换时可恢复。
+ */
+ fun install(context: Context, apkPath: String, expectedVersionCode: Long): Map {
+ val validation = validate(context, apkPath, expectedVersionCode)
+ if (validation["valid"] != true) return validation
+ val root = RootCommandRunner.checkRoot()
+ if (root["available"] != true) {
+ return failure("ROOT_UNAVAILABLE", root["message"]?.toString() ?: "Root 不可用")
+ }
+
+ preferences(context).edit()
+ .putBoolean(PENDING_KEY, true)
+ .putLong(EXPECTED_VERSION_CODE_KEY, expectedVersionCode)
+ .putString(APK_PATH_KEY, apkPath)
+ .commit()
+ val command = RootCommandRunner.execute(
+ "pm install -r ${RootCommandRunner.quoteShell(apkPath)}",
+ INSTALL_TIMEOUT_MILLIS
+ )
+ val success = command.exitCode == 0 && command.output.contains("Success", ignoreCase = true)
+ if (!success) {
+ clearPending(context, deleteApk = true)
+ val message = when {
+ command.timedOut -> "APK 安装超时,请检查系统安装服务"
+ command.output.isNotBlank() -> command.output
+ else -> "pm install -r 执行失败(退出码 ${command.exitCode ?: -1})"
+ }
+ return failure("INSTALL_FAILED", message)
+ }
+ return mapOf(
+ "valid" to true,
+ "code" to "INSTALL_STARTED",
+ "message" to "安装命令已成功执行,等待新版本启动确认",
+ "versionCode" to expectedVersionCode
+ )
+ }
+
+ /**
+ * 应用启动时确认待更新版本并清理私有 APK;版本未提升时保留明确失败结果并终止恢复。
+ */
+ fun consumePending(context: Context): Map {
+ val prefs = preferences(context)
+ if (!prefs.getBoolean(PENDING_KEY, false)) {
+ return mapOf("pending" to false, "success" to false)
+ }
+ val expected = prefs.getLong(EXPECTED_VERSION_CODE_KEY, -1L)
+ val current = versionCode(currentPackageInfo(context))
+ val success = expected > 0L && current >= expected
+ val message = if (success) {
+ "自动更新完成,当前 Build $current"
+ } else {
+ "自动更新未完成,当前 Build $current,目标 Build $expected"
+ }
+ clearPending(context, deleteApk = true)
+ return mapOf(
+ "pending" to true,
+ "success" to success,
+ "message" to message,
+ "currentVersionCode" to current,
+ "expectedVersionCode" to expected
+ )
+ }
+
+ /** 读取当前安装应用的包信息与签名证书。 */
+ @Suppress("DEPRECATION")
+ private fun currentPackageInfo(context: Context): PackageInfo =
+ context.packageManager.getPackageInfo(context.packageName, signatureFlags())
+
+ /** 从 APK 文件解析包信息与签名证书;文件损坏或格式不支持时返回空。 */
+ @Suppress("DEPRECATION")
+ private fun packageArchiveInfo(context: Context, path: String): PackageInfo? =
+ context.packageManager.getPackageArchiveInfo(path, signatureFlags())
+
+ /** 根据 Android 版本选择签名信息标志,兼容项目最低 Android 7。 */
+ @Suppress("DEPRECATION")
+ private fun signatureFlags(): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ PackageManager.GET_SIGNING_CERTIFICATES
+ } else {
+ PackageManager.GET_SIGNATURES
+ }
+
+ /** 统一读取 64 位版本码,Android 9 以下回退旧整型字段。 */
+ @Suppress("DEPRECATION")
+ private fun versionCode(info: PackageInfo): Long = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ info.longVersionCode
+ } else {
+ info.versionCode.toLong()
+ }
+
+ /** 计算 APK 当前签名者 SHA-256 摘要集合,用交集兼容多签名包。 */
+ @Suppress("DEPRECATION")
+ private fun signerDigests(info: PackageInfo): Set {
+ val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ info.signingInfo?.apkContentsSigners ?: emptyArray()
+ } else {
+ info.signatures ?: emptyArray()
+ }
+ return signatures.map { signature ->
+ MessageDigest.getInstance("SHA-256")
+ .digest(signature.toByteArray())
+ .joinToString("") { byte -> "%02x".format(byte) }
+ }.toSet()
+ }
+
+ /** 构造统一失败结构,避免 Flutter 解析不同异常文本。 */
+ private fun failure(code: String, message: String): Map = mapOf(
+ "valid" to false,
+ "code" to code,
+ "message" to message
+ )
+
+ /** 清理待更新偏好,并按调用场景删除应用私有目录中的 APK。 */
+ private fun clearPending(context: Context, deleteApk: Boolean) {
+ val prefs = preferences(context)
+ if (deleteApk) prefs.getString(APK_PATH_KEY, null)?.let { File(it).delete() }
+ prefs.edit().clear().commit()
+ }
+
+ /** 返回只归当前应用所有的原生待更新偏好。 */
+ private fun preferences(context: Context) =
+ context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
+}
diff --git a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/KioskBootReceiver.kt b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/KioskBootReceiver.kt
index 6801ad7..bd1e3d5 100644
--- a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/KioskBootReceiver.kt
+++ b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/KioskBootReceiver.kt
@@ -5,12 +5,16 @@ import android.content.Context
import android.content.Intent
/**
- * 仅在应用已经是 Device Owner 时响应开机广播,避免普通安装包无条件打断系统启动流程。
+ * 开机时仅在应用已经是 Device Owner 时拉起;应用包替换后始终拉起新版本,并按现有
+ * Device Owner 与自动进入配置恢复 Kiosk。
*/
class KioskBootReceiver : BroadcastReceiver() {
+ /** 仅处理开机完成和本应用包替换事件,并按 Device Owner 状态决定是否拉起主界面。 */
override fun onReceive(context: Context, intent: Intent) {
- if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
- if (!KioskModeManager.prepareForBoot(context)) return
+ val action = intent.action
+ if (action != Intent.ACTION_BOOT_COMPLETED && action != Intent.ACTION_MY_PACKAGE_REPLACED) return
+ if (action == Intent.ACTION_BOOT_COMPLETED && !KioskModeManager.prepareForBoot(context)) return
+ if (action == Intent.ACTION_MY_PACKAGE_REPLACED) KioskModeManager.prepareForBoot(context)
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
diff --git a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/KioskModeManager.kt b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/KioskModeManager.kt
index 95f0df4..abbcc95 100644
--- a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/KioskModeManager.kt
+++ b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/KioskModeManager.kt
@@ -7,6 +7,8 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Build
+import android.view.View
+import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
@@ -18,9 +20,19 @@ import androidx.core.view.WindowInsetsControllerCompat
* 都不视为 Kiosk 可用,避免界面把“看起来全屏”误报为“无法退出”。
*/
object KioskModeManager {
+ /** Kiosk 原生配置使用的应用私有偏好文件。 */
private const val PREFERENCES_NAME = "kiosk_runtime"
+
+ /** 是否在进入 App 时自动恢复 Lock Task 的持久化键。 */
private const val AUTO_ENTER_ENABLED_KEY = "auto_enter_enabled"
+ /** Root 执行 DPM 命令的最长等待时间。 */
+ private const val DPM_TIMEOUT_MILLIS = 30_000L
+
+ /** 厂商系统栏被边缘手势唤出后重新隐藏的等待时间,避免与手势动画同帧争用。 */
+ private const val SYSTEM_BAR_REHIDE_DELAY_MILLIS = 250L
+
+ /** 返回 Flutter 设置页和心跳共用的实时 Kiosk 能力与运行状态。 */
fun status(context: Context): Map {
val policyManager = policyManager(context)
val deviceOwner = policyManager.isDeviceOwnerApp(context.packageName)
@@ -105,27 +117,79 @@ object KioskModeManager {
}
/**
- * 保存“进入 App 自动开启”配置。关闭配置只影响后续进入,当前 Kiosk 会继续运行,
- * 仍需通过管理密码出口返回桌面,避免一个普通开关绕过终端退出保护。
+ * 保存 Kiosk 自动进入配置并立即同步当前 Activity 的 Lock Task 状态。
+ *
+ * 设置页本身已经受管理员密码保护,因此关闭开关时直接退出当前 Lock Task 但仍停留在
+ * App 内;开启时立即补齐策略并进入,避免持久化值与当前系统状态需要多次切换才一致。
*/
fun setAutoEnterEnabled(activity: Activity, enabled: Boolean): Map {
if (!policyManager(activity).isDeviceOwnerApp(activity.packageName)) return status(activity)
- preferences(activity).edit().putBoolean(AUTO_ENTER_ENABLED_KEY, enabled).apply()
+ preferences(activity).edit().putBoolean(AUTO_ENTER_ENABLED_KEY, enabled).commit()
if (enabled) {
enterIfAvailable(activity)
+ } else {
+ exitCurrentKiosk(activity)
+ }
+ if (isKioskActive(activity) != enabled) {
+ // 系统状态切换失败时回滚持久化值,避免界面显示关闭但设备仍处于 Lock Task。
+ preferences(activity).edit().putBoolean(AUTO_ENTER_ENABLED_KEY, !enabled).commit()
}
return status(activity)
}
+ /**
+ * 仅在用户主动触发后通过 Root 配置当前应用为 Device Owner。
+ *
+ * 本方法不会删除系统账户、移除其他 Owner 或修改设备策略文件;Android 拒绝时按常见
+ * 限制返回稳定错误编码,调用方可给管理员明确处理建议。
+ */
+ fun provisionDeviceOwnerWithRoot(context: Context): Map {
+ val policyManager = policyManager(context)
+ if (policyManager.isDeviceOwnerApp(context.packageName)) {
+ return provisionResult(true, "ALREADY_OWNER", "当前应用已经是 Device Owner")
+ }
+ val root = RootCommandRunner.checkRoot()
+ if (root["available"] != true) {
+ return provisionResult(false, "ROOT_UNAVAILABLE", root["message"]?.toString() ?: "Root 不可用")
+ }
+ val component = adminComponent(context).flattenToShortString()
+ val command = RootCommandRunner.execute(
+ "dpm set-device-owner --user 0 ${RootCommandRunner.quoteShell(component)}",
+ DPM_TIMEOUT_MILLIS
+ )
+ if (policyManager.isDeviceOwnerApp(context.packageName)) {
+ return provisionResult(true, "OK", "Device Owner 配置成功")
+ }
+
+ val output = command.output.lowercase()
+ val (code, message) = when {
+ command.timedOut -> "DPM_TIMEOUT" to "DPM 命令执行超时,请检查厂商系统策略"
+ "account" in output -> "ACCOUNT_EXISTS" to "设备存在系统账户,Android 不允许配置 Device Owner;请在设备初始化部署阶段处理账户后重试"
+ "already" in output && ("owner" in output || "profile" in output) ->
+ "OWNER_EXISTS" to "设备已存在其他 Device Owner 或 Profile Owner,当前应用不会自动移除它"
+ "system user" in output || "primary user" in output || "user 0" in output && "not" in output ->
+ "NON_PRIMARY_USER" to "当前不在 Android 主用户(user 0)环境,无法配置 Device Owner"
+ "unknown command" in output || "not found" in output || "unsupported" in output ->
+ "COMMAND_UNSUPPORTED" to "当前系统不支持 dpm set-device-owner 命令"
+ else -> "OEM_RESTRICTED" to (command.output.ifBlank {
+ "Android 或厂商策略拒绝配置 Device Owner,请检查设备初始化状态"
+ })
+ }
+ return provisionResult(false, code, message)
+ }
+
+ /** 窗口重新获得焦点时恢复 Kiosk 系统栏策略,防止临时系统界面留下可退出入口。 */
fun reapplySystemBars(activity: Activity) {
if (isKioskActive(activity)) hideSystemBars(activity)
}
+ /** 判断 Android 当前是否处于完全锁定的 Lock Task 模式。 */
fun isKioskActive(context: Context): Boolean {
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
return activityManager.lockTaskModeState == ActivityManager.LOCK_TASK_MODE_LOCKED
}
+ /** 为当前 Device Owner 配置 Lock Task 白名单、系统栏策略和长按电源菜单能力。 */
private fun ensureLockTaskPolicy(context: Context): Boolean {
val policyManager = policyManager(context)
if (!policyManager.isDeviceOwnerApp(context.packageName)) return false
@@ -135,7 +199,8 @@ object KioskModeManager {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
policyManager.setLockTaskFeatures(
adminComponent(context),
- DevicePolicyManager.LOCK_TASK_FEATURE_NONE
+ // 仅放行系统全局操作菜单,使管理员仍能长按电源关机;主页和最近任务继续禁用。
+ DevicePolicyManager.LOCK_TASK_FEATURE_GLOBAL_ACTIONS
)
}
policyManager.isLockTaskPermitted(context.packageName)
@@ -144,29 +209,147 @@ object KioskModeManager {
}
}
+ /**
+ * 应用 Kiosk 系统栏策略,并监听厂商 SystemUI 与输入法可见性变化。
+ *
+ * Android 沉浸模式允许边缘手势短暂唤出系统栏;RK3568 固件不会按标准行为自动收回,
+ * 因此在动画结束后再次应用策略。输入法显示期间仅恢复导航栏,使系统输入法保留收起
+ * 键盘入口;输入法关闭后再隐藏全部系统栏,不能为了键盘操作永久暴露系统导航。
+ */
private fun hideSystemBars(activity: Activity) {
+ val decorView = activity.window.decorView
+ var imeVisible = isInputMethodVisible(activity)
+ var rehidePosted = false
+
+ /**
+ * 厂商 SystemUI 完成边缘手势动画后再恢复沉浸模式;同一轮 Insets 与旧版可见性
+ * 回调只排队一次,并在执行前复核 Kiosk 和输入法状态,避免误隐藏键盘导航入口。
+ */
+ fun scheduleSystemBarRehide() {
+ if (rehidePosted) return
+ rehidePosted = true
+ decorView.postDelayed(
+ {
+ rehidePosted = false
+ if (isKioskActive(activity) && !isInputMethodVisible(activity)) {
+ applyKioskSystemBars(activity, false)
+ }
+ },
+ SYSTEM_BAR_REHIDE_DELAY_MILLIS
+ )
+ }
+
+ ViewCompat.setOnApplyWindowInsetsListener(decorView) { _, insets ->
+ val nextImeVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
+ val systemBarsVisible =
+ insets.isVisible(WindowInsetsCompat.Type.statusBars()) ||
+ insets.isVisible(WindowInsetsCompat.Type.navigationBars())
+ if (imeVisible != nextImeVisible) {
+ imeVisible = nextImeVisible
+ decorView.post {
+ if (isKioskActive(activity)) {
+ applyKioskSystemBars(activity, nextImeVisible)
+ }
+ }
+ } else if (!nextImeVisible && systemBarsVisible) {
+ // RK3568 的 Android 11 不一定触发旧版可见性回调,必须直接监听 Insets。
+ scheduleSystemBarRehide()
+ }
+ // 不消费 Insets,Flutter 仍需获得准确的 viewInsets 以完成键盘避让布局。
+ insets
+ }
+ @Suppress("DEPRECATION")
+ decorView.setOnSystemUiVisibilityChangeListener {
+ if (!isKioskActive(activity)) return@setOnSystemUiVisibilityChangeListener
+ // 保留旧回调兼容 RK 厂商固件中的混合 SystemUI 实现。
+ scheduleSystemBarRehide()
+ }
+ applyKioskSystemBars(activity, imeVisible)
+ ViewCompat.requestApplyInsets(decorView)
+ }
+
+ /**
+ * 根据输入法状态应用 AndroidX Insets 与 Android 11 厂商固件仍依赖的系统栏标记。
+ *
+ * Lock Task 继续负责禁用 Home 与最近任务;输入法可见时恢复的导航栏仅提供系统返回/
+ * 收起键盘入口,状态栏始终保持隐藏。
+ */
+ @Suppress("DEPRECATION")
+ private fun applyKioskSystemBars(activity: Activity, imeVisible: Boolean) {
+ val decorView = activity.window.decorView
+ decorView.systemUiVisibility = if (imeVisible) {
+ View.SYSTEM_UI_FLAG_FULLSCREEN or
+ View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
+ View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ } else {
+ View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
+ View.SYSTEM_UI_FLAG_FULLSCREEN or
+ View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
+ View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
+ View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
+ View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
+ }
WindowCompat.setDecorFitsSystemWindows(activity.window, false)
WindowCompat.getInsetsController(activity.window, activity.window.decorView).apply {
- hide(WindowInsetsCompat.Type.systemBars())
+ hide(WindowInsetsCompat.Type.statusBars())
+ if (imeVisible) {
+ show(WindowInsetsCompat.Type.navigationBars())
+ } else {
+ hide(WindowInsetsCompat.Type.navigationBars())
+ }
systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
+ /** 返回当前输入法是否实际占用窗口,空 Insets 视为键盘未显示。 */
+ private fun isInputMethodVisible(activity: Activity): Boolean =
+ ViewCompat.getRootWindowInsets(activity.window.decorView)
+ ?.isVisible(WindowInsetsCompat.Type.ime()) == true
+
+ /** 关闭当前 Lock Task 但停留在 App 内,并立即恢复可用的系统栏。 */
+ private fun exitCurrentKiosk(activity: Activity) {
+ try {
+ if (isKioskActive(activity)) activity.stopLockTask()
+ } catch (_: Exception) {
+ // 后续实时状态会向 Flutter 暴露关闭失败,不能在这里伪造成功。
+ }
+ try {
+ policyManager(activity).setStatusBarDisabled(adminComponent(activity), false)
+ } catch (_: Exception) {
+ // 部分厂商在 Lock Task 切换帧内暂时拒绝策略变更,系统栏恢复仍继续执行。
+ }
+ showSystemBars(activity)
+ }
+
+ /** 管理员关闭或退出 Kiosk 时移除自动重隐藏监听,并恢复正常窗口边距与系统栏。 */
+ @Suppress("DEPRECATION")
private fun showSystemBars(activity: Activity) {
+ val decorView = activity.window.decorView
+ ViewCompat.setOnApplyWindowInsetsListener(decorView, null)
+ decorView.setOnSystemUiVisibilityChangeListener(null)
+ decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
WindowCompat.setDecorFitsSystemWindows(activity.window, true)
- WindowCompat.getInsetsController(activity.window, activity.window.decorView)
+ WindowCompat.getInsetsController(activity.window, decorView)
.show(WindowInsetsCompat.Type.systemBars())
}
+ /** 返回 Manifest 已注册的设备管理员组件。 */
private fun adminComponent(context: Context) = ComponentName(context, KioskDeviceAdminReceiver::class.java)
+ /** 获取系统 DevicePolicyManager,所有 Owner 判断以其实时结果为准。 */
private fun policyManager(context: Context) =
context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
+ /** 读取进入 App 自动恢复 Kiosk 的原生持久化配置,旧设备默认开启。 */
private fun isAutoEnterEnabled(context: Context) =
preferences(context).getBoolean(AUTO_ENTER_ENABLED_KEY, true)
+ /** 返回仅归当前应用所有的 Kiosk 偏好存储。 */
private fun preferences(context: Context) =
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
+
+ /** 构造 MethodChannel 可直接返回的 DPM 配置结果。 */
+ private fun provisionResult(success: Boolean, code: String, message: String): Map =
+ mapOf("success" to success, "code" to code, "message" to message)
}
diff --git a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt
index 79b7e13..b0ce61b 100644
--- a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt
@@ -10,6 +10,7 @@ import android.os.Build
import android.os.Debug
import android.os.Process
import android.os.StatFs
+import android.os.SystemClock
import android.telephony.TelephonyManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
@@ -20,12 +21,29 @@ import io.flutter.plugins.facediscern.session.FaceRecognitionSessionManager
import io.flutter.plugin.common.MethodChannel
import com.rex.flutter_subscreen_plugin.FlutterSubScreenProvider
import com.rex.flutter_subscreen_plugin.FlutterSubscreenPlugin
+import java.util.concurrent.Executors
+/**
+ * 终端主屏 Flutter Activity,集中注册人脸、副屏、运行状态、Root/Kiosk 和 APK 更新通道。
+ */
class MainActivity : FlutterActivity() {
+ /** 保存进程级运行时基准,Activity 重建时不重置 App 运行时长。 */
+ companion object {
+ /** 进程首次加载 Activity 类时的单调时钟,用于心跳报告当前 App 运行时长。 */
+ private val processStartedAtMillis = SystemClock.elapsedRealtime()
+ }
+
+ /** Flutter 运行环境、Root/Kiosk 与 APK 操作共用的 MethodChannel 名称。 */
private val runtimeChannel = "biz_xfj/runtime_environment"
+
+ /** 主屏向独立副屏 Engine 转发轻量消息的 MethodChannel 名称。 */
private val subScreenChannel = "screen_plugin_sub_channel"
+ /** Root、DPM 与 APK 安装均在后台串行执行,避免阻塞 Flutter 主线程和 Android UI。 */
+ private val privilegedExecutor = Executors.newSingleThreadExecutor()
+
+ /** 注册项目原生插件和运行时通道;耗时特权命令始终转交后台执行器。 */
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
@@ -45,10 +63,22 @@ class MainActivity : FlutterActivity() {
when (call.method) {
"getRuntimeEnvironment" -> {
val displayManager = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
+ val packageInfo = packageManager.getPackageInfo(packageName, 0)
+ @Suppress("DEPRECATION")
+ val apkVersionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ packageInfo.longVersionCode
+ } else {
+ packageInfo.versionCode.toLong()
+ }
result.success(
mapOf(
"isEmulator" to isEmulator(),
- "displayCount" to displayManager.displays.size
+ "displayCount" to displayManager.displays.size,
+ // 系统和安装包版本来自当前进程实际运行环境,不能使用后台目标版本代替。
+ "androidVersion" to Build.VERSION.RELEASE,
+ "androidSdkInt" to Build.VERSION.SDK_INT,
+ "apkVersionName" to (packageInfo.versionName ?: ""),
+ "apkVersionCode" to apkVersionCode
)
)
}
@@ -79,6 +109,28 @@ class MainActivity : FlutterActivity() {
}
}
"getKioskStatus" -> result.success(KioskModeManager.status(this))
+ "checkRootAccess" -> runPrivileged(result) {
+ RootCommandRunner.checkRoot()
+ }
+ "enableKioskWithRoot" -> {
+ privilegedExecutor.execute {
+ val provision = try {
+ KioskModeManager.provisionDeviceOwnerWithRoot(applicationContext)
+ } catch (error: Exception) {
+ mapOf(
+ "success" to false,
+ "code" to "DPM_FAILED",
+ "message" to (error.message ?: "Device Owner 配置失败")
+ )
+ }
+ runOnUiThread {
+ if (provision["success"] == true) {
+ KioskModeManager.setAutoEnterEnabled(this, true)
+ }
+ result.success(provision + ("status" to KioskModeManager.status(this)))
+ }
+ }
+ }
"setKioskAutoEnterEnabled" -> {
val enabled = call.argument("enabled")
if (enabled == null) {
@@ -88,6 +140,31 @@ class MainActivity : FlutterActivity() {
}
}
"exitKioskToHome" -> result.success(KioskModeManager.exitToHome(this))
+ "validateApk" -> {
+ val path = call.argument("path")
+ val expectedVersionCode = call.argument("expectedVersionCode")?.toLong()
+ if (path.isNullOrBlank() || expectedVersionCode == null) {
+ result.error("INVALID_ARGUMENT", "缺少 APK 路径或目标版本码", null)
+ } else {
+ runPrivileged(result) {
+ ApkUpdateManager.validate(applicationContext, path, expectedVersionCode)
+ }
+ }
+ }
+ "installApkWithRoot" -> {
+ val path = call.argument("path")
+ val expectedVersionCode = call.argument("expectedVersionCode")?.toLong()
+ if (path.isNullOrBlank() || expectedVersionCode == null) {
+ result.error("INVALID_ARGUMENT", "缺少 APK 路径或目标版本码", null)
+ } else {
+ runPrivileged(result) {
+ ApkUpdateManager.install(applicationContext, path, expectedVersionCode)
+ }
+ }
+ }
+ "consumePendingUpdate" -> runPrivileged(result) {
+ ApkUpdateManager.consumePending(applicationContext)
+ }
else -> result.notImplemented()
}
}
@@ -95,11 +172,13 @@ class MainActivity : FlutterActivity() {
GeneratedPluginRegistrant.registerWith(flutterEngine)
}
+ /** Activity 回到可交互状态时按原生持久化配置恢复 Kiosk。 */
override fun onPostResume() {
super.onPostResume()
KioskModeManager.enterIfAvailable(this)
}
+ /** 销毁主 Activity 时同步释放副屏 Engine 和特权命令线程,避免持有失效资源。 */
override fun onDestroy() {
val subScreenProvider = FlutterSubScreenProvider.instance
try {
@@ -115,10 +194,12 @@ class MainActivity : FlutterActivity() {
}
}
} finally {
+ privilegedExecutor.shutdownNow()
super.onDestroy()
}
}
+ /** 系统报告低内存时释放当前人脸识别会话的高占用资源。 */
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
if (level >= TRIM_MEMORY_RUNNING_LOW) {
@@ -127,12 +208,14 @@ class MainActivity : FlutterActivity() {
}
}
+ /** 窗口重新获得焦点时恢复 Kiosk 系统栏隐藏策略。 */
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) KioskModeManager.reapplySystemBars(this)
}
@Suppress("DEPRECATION")
+ /** Kiosk 运行时拦截系统返回键,普通模式继续使用 FlutterActivity 默认行为。 */
override fun onBackPressed() {
// 当前 FlutterActivity 基线没有 OnBackPressedDispatcher,Kiosk 运行时直接拦截系统返回。
if (KioskModeManager.isKioskActive(this)) return
@@ -189,6 +272,8 @@ class MainActivity : FlutterActivity() {
activityManager.getMemoryInfo(memoryInfo)
val storage = StatFs(filesDir.absolutePath)
return mapOf(
+ "systemUptimeSeconds" to SystemClock.elapsedRealtime() / 1_000L,
+ "appUptimeSeconds" to (SystemClock.elapsedRealtime() - processStartedAtMillis).coerceAtLeast(0L) / 1_000L,
"processCpuTimeMs" to Process.getElapsedCpuTime(),
"logicalProcessorCount" to Runtime.getRuntime().availableProcessors().coerceAtLeast(1),
"memoryTotalBytes" to memoryInfo.totalMem,
@@ -200,6 +285,7 @@ class MainActivity : FlutterActivity() {
}
@Suppress("DEPRECATION")
+ /** 将蜂窝网络制式归一为心跳接口使用的 2g/3g/4g/5g/mobile 编码。 */
private fun mobileNetworkType(): String {
return try {
val telephony = getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
@@ -222,4 +308,20 @@ class MainActivity : FlutterActivity() {
}
}
+ /**
+ * 在专用后台线程执行高延迟原生动作,并始终切回主线程完成 MethodChannel 回调。
+ */
+ private fun runPrivileged(result: MethodChannel.Result, action: () -> Any?) {
+ privilegedExecutor.execute {
+ try {
+ val payload = action()
+ runOnUiThread { result.success(payload) }
+ } catch (error: Exception) {
+ runOnUiThread {
+ result.error("PRIVILEGED_OPERATION_FAILED", error.message, null)
+ }
+ }
+ }
+ }
+
}
diff --git a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/RootCommandRunner.kt b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/RootCommandRunner.kt
new file mode 100644
index 0000000..9fbf30e
--- /dev/null
+++ b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/RootCommandRunner.kt
@@ -0,0 +1,191 @@
+package com.example.pj_supermarket_cloud
+
+import android.os.SystemClock
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+
+/**
+ * 统一执行需要 Root 的短生命周期 shell 命令。
+ *
+ * 所有调用方必须显式给出超时;标准错误并入标准输出并限制返回长度,避免子进程管道阻塞
+ * 或把不可控的系统输出长期保留在 Flutter 层。
+ */
+object RootCommandRunner {
+ /** Root 检查固定超时,超过 5 秒按不可用处理。 */
+ private const val ROOT_CHECK_TIMEOUT_MILLIS = 5_000L
+
+ /** 返回给上层的最大诊断字符数。 */
+ private const val MAX_OUTPUT_LENGTH = 2_000
+
+ /** 并发读取子进程输出,防止输出缓冲区写满后命令无法退出。 */
+ private val outputExecutor = Executors.newCachedThreadPool()
+
+ /**
+ * 当前进程已经确认可用的 su 参数格式;首次调用时为空,由只读 Root 检查或语法回退确定。
+ */
+ @Volatile
+ private var preferredInvocationMode: SuInvocationMode? = null
+
+ /**
+ * Android 设备现场存在两类常见 su 参数协议,枚举值只描述进程参数格式,不改变业务命令。
+ */
+ private enum class SuInvocationMode {
+ /** Magisk、KernelSU 和 SuperSU 常见的 `su -c ` 格式。 */
+ OPTION_C,
+
+ /** AOSP userdebug 与部分厂商固件要求显式 UID 的 `su 0 sh -c ` 格式。 */
+ UID_ZERO_SHELL
+ }
+
+ /**
+ * 一次 Root 命令执行结果。
+ *
+ * @property exitCode 进程退出码;超时或无法启动时为空
+ * @property output 合并并截断后的标准输出与错误输出
+ * @property timedOut 是否因超过调用方限制被终止
+ */
+ data class CommandResult(
+ val exitCode: Int?,
+ val output: String,
+ val timedOut: Boolean
+ )
+
+ /**
+ * 使用设备支持的 su 参数格式执行命令并轮询退出状态,兼容项目最低 Android 版本。
+ *
+ * 部分厂商 su 会把 `-c` 当成 UID 并返回 `invalid uid/gid '-c'`。仅识别到这个明确的
+ * 参数语法错误时才回退到 `su 0 sh -c`,因此不会把可能已经执行过的安装或 DPM 命令
+ * 重复执行;两次尝试共享调用方给出的总超时。
+ *
+ * @param command 传给 Root shell 的完整命令
+ * @param timeoutMillis 两种参数格式合计允许的最大执行时间,单位毫秒
+ * @return 包含退出码、诊断输出与超时状态的结果
+ */
+ fun execute(command: String, timeoutMillis: Long): CommandResult {
+ val normalizedTimeout = timeoutMillis.coerceAtLeast(1L)
+ val preferred = preferredInvocationMode
+ if (preferred != null) {
+ return executeWithMode(command, normalizedTimeout, preferred)
+ }
+
+ val startedAt = SystemClock.elapsedRealtime()
+ val optionResult = executeWithMode(command, normalizedTimeout, SuInvocationMode.OPTION_C)
+ if (!isOptionCSyntaxRejected(optionResult)) {
+ if (!optionResult.timedOut && optionResult.exitCode != null) {
+ preferredInvocationMode = SuInvocationMode.OPTION_C
+ }
+ return optionResult
+ }
+
+ val elapsed = SystemClock.elapsedRealtime() - startedAt
+ val remaining = (normalizedTimeout - elapsed).coerceAtLeast(1L)
+ val uidResult = executeWithMode(command, remaining, SuInvocationMode.UID_ZERO_SHELL)
+ if (!uidResult.timedOut && uidResult.exitCode != null) {
+ preferredInvocationMode = SuInvocationMode.UID_ZERO_SHELL
+ }
+ return uidResult
+ }
+
+ /**
+ * 按指定 su 参数协议启动一次子进程;本方法不进行协议回退,避免特权命令重复执行。
+ *
+ * @param command 传给 Root shell 的完整命令
+ * @param timeoutMillis 本次进程允许执行的最长时间,单位毫秒
+ * @param mode 本次使用的 su 参数协议
+ * @return 单次进程的退出码、诊断输出和超时状态
+ */
+ private fun executeWithMode(
+ command: String,
+ timeoutMillis: Long,
+ mode: SuInvocationMode
+ ): CommandResult {
+ var process: Process? = null
+ return try {
+ val processCommand = when (mode) {
+ SuInvocationMode.OPTION_C -> listOf("su", "-c", command)
+ SuInvocationMode.UID_ZERO_SHELL -> listOf("su", "0", "sh", "-c", command)
+ }
+ val runningProcess = ProcessBuilder(processCommand)
+ .redirectErrorStream(true)
+ .start()
+ process = runningProcess
+ val outputFuture = outputExecutor.submit {
+ runningProcess.inputStream.bufferedReader().use { it.readText() }
+ }
+ val deadline = SystemClock.elapsedRealtime() + timeoutMillis
+ var exitCode: Int? = null
+ while (SystemClock.elapsedRealtime() <= deadline) {
+ exitCode = try {
+ runningProcess.exitValue()
+ } catch (_: IllegalThreadStateException) {
+ null
+ }
+ if (exitCode != null) break
+ Thread.sleep(50)
+ }
+ val timedOut = exitCode == null
+ if (timedOut) runningProcess.destroy()
+ val output = try {
+ outputFuture.get(500, TimeUnit.MILLISECONDS)
+ } catch (_: Exception) {
+ ""
+ }
+ CommandResult(exitCode, sanitize(output), timedOut)
+ } catch (error: Exception) {
+ process?.destroy()
+ CommandResult(null, sanitize(error.message ?: "无法执行 su 命令"), false)
+ }
+ }
+
+ /**
+ * 判断标准 `su -c` 是否被设备误当成 UID/GID 或未知选项。
+ *
+ * 只有命中明确参数错误才允许换协议重试;授权拒绝、命令失败和超时都原样返回,避免
+ * DPM 或 APK 安装等具有副作用的命令被执行两次。
+ */
+ private fun isOptionCSyntaxRejected(result: CommandResult): Boolean {
+ if (result.timedOut || result.exitCode == null || result.exitCode == 0) return false
+ val output = result.output.lowercase()
+ val mentionsOptionC = "'-c'" in output || "\"-c\"" in output || " -c" in output
+ return mentionsOptionC && (
+ "invalid uid/gid" in output ||
+ "invalid uid" in output ||
+ "unknown option" in output ||
+ "unrecognized option" in output ||
+ "illegal option" in output
+ )
+ }
+
+ /**
+ * 真实执行 Root shell 的 `id` 检查;不能只根据 su 文件是否存在进行判断。
+ */
+ fun checkRoot(): Map {
+ val command = execute("id", ROOT_CHECK_TIMEOUT_MILLIS)
+ val available = command.exitCode == 0 && Regex("(^|\\s)uid=0([\\s(]|$)")
+ .containsMatchIn(command.output)
+ val message = when {
+ command.timedOut -> "Root 授权超时(5秒),请检查 su 授权策略"
+ available -> "已通过 su 执行 id 确认 uid=0"
+ command.exitCode == null -> command.output.ifBlank { "未找到 su 或 Root 命令无法启动" }
+ command.output.isBlank() -> "su 返回退出码 ${command.exitCode},未确认 uid=0"
+ else -> command.output
+ }
+ return mapOf(
+ "available" to available,
+ "exitCode" to (command.exitCode ?: -1),
+ "timedOut" to command.timedOut,
+ "message" to message
+ )
+ }
+
+ /**
+ * 使用单引号安全包裹 shell 路径,避免空格或特殊字符改变安装命令含义。
+ */
+ fun quoteShell(value: String): String = "'${value.replace("'", "'\\''")}'"
+
+ /** 将多行命令输出压缩为界面可读摘要并限制长度。 */
+ private fun sanitize(output: String): String {
+ val compact = output.trim().replace(Regex("\\s+"), " ")
+ return if (compact.length <= MAX_OUTPUT_LENGTH) compact else compact.take(MAX_OUTPUT_LENGTH) + "..."
+ }
+}
diff --git a/lib/common/api/app_api.dart b/lib/common/api/app_api.dart
index b7f6791..66bd2b4 100644
--- a/lib/common/api/app_api.dart
+++ b/lib/common/api/app_api.dart
@@ -1,13 +1,24 @@
+import 'package:dio/dio.dart' as dio;
+
import 'package:pj_supermarket_cloud/common/index.dart';
+/// 终端 App 配置与版本接口入口,统一复用 HttpManage 的鉴权、租户和错误处理。
abstract class AppApi {
- /// 获取所有版本列表
+ ///
+ /// 分页获取匹配 App 编码和平台的已发布版本。
+ ///
+ /// [pa] 包含 appUniqueCode、platform、current 和 size;[silent] 为 true 时后台任务
+ /// 只记录异常,不弹业务 Toast。
static Future openCardList(
- dynamic pa,
- ) async {
+ dynamic pa, {
+ bool silent = false,
+ }) async {
final res = await HttpManage().dio.get(
"/integrated/app-version/ad/page",
queryParameters: pa,
+ options: dio.Options(
+ extra: {if (silent) HttpManage.silentRequestKey: true},
+ ),
);
if (res.data == null) return null;
return ServiceAppVersionPage.fromJson(res.data);
diff --git a/lib/common/api/hard_api.dart b/lib/common/api/hard_api.dart
index 35df05b..e017711 100644
--- a/lib/common/api/hard_api.dart
+++ b/lib/common/api/hard_api.dart
@@ -3,6 +3,21 @@ import 'package:dio/dio.dart' as dio;
import 'package:pj_supermarket_cloud/common/index.dart';
abstract class HardApi {
+ /// 静默上报食堂、超市 Android 终端最新运行状态;后台失败不得弹出业务 Toast。
+ static Future terminalHeartbeat({
+ required String hardId,
+ required Map data,
+ }) async {
+ await HttpManage().dio.post(
+ '/hard/hard-api/ad/terminal-heartbeat',
+ data: data,
+ options: dio.Options(
+ headers: {'DEVICE-ID': hardId},
+ extra: {HttpManage.silentRequestKey: true},
+ ),
+ );
+ }
+
/// 获取硬件配置信息
static Future bind(dynamic pa) async {
final res = await HttpManage().dio.post(
diff --git a/lib/common/models/database/database_models.dart b/lib/common/models/database/database_models.dart
index b15fef4..e4ccf28 100644
--- a/lib/common/models/database/database_models.dart
+++ b/lib/common/models/database/database_models.dart
@@ -119,6 +119,16 @@ class SysSetting extends Equatable {
@JsonKey(defaultValue: false)
bool manualHardwareInput;
+ /// Root 最近一次真实命令检查状态;旧配置缺失时按未检查处理。
+ @JsonKey(defaultValue: RootAccessStatus.unknown)
+ RootAccessStatus rootAccessStatus;
+
+ /// Root 最近一次检查时间;旧配置或从未检查时为空。
+ DateTime? rootAccessCheckedAt;
+
+ /// Root 检查诊断摘要;可用时说明确认结果,不可用时说明拒绝、超时等原因。
+ String? rootAccessDiagnostic;
+
SysSetting({
required this.sysType,
this.androidId,
@@ -130,6 +140,9 @@ class SysSetting extends Equatable {
this.hardGroupId,
this.tenantList,
this.manualHardwareInput = false,
+ this.rootAccessStatus = RootAccessStatus.unknown,
+ this.rootAccessCheckedAt,
+ this.rootAccessDiagnostic,
this.buttonRadius = SettingButtonRadius.standard,
this.cardRadius = SettingCardRadius.standard,
this.buttonSize = SettingButtonSize.standard,
@@ -178,6 +191,9 @@ class SysSetting extends Equatable {
defaultPayDisplayType,
tenantList,
manualHardwareInput,
+ rootAccessStatus,
+ rootAccessCheckedAt?.millisecondsSinceEpoch,
+ rootAccessDiagnostic,
];
}
diff --git a/lib/common/models/database/database_models.g.dart b/lib/common/models/database/database_models.g.dart
index e555469..e065553 100644
--- a/lib/common/models/database/database_models.g.dart
+++ b/lib/common/models/database/database_models.g.dart
@@ -19,6 +19,13 @@ SysSetting _$SysSettingFromJson(Map json) => SysSetting(
?.map((e) => ServiceTenant.fromJson(e as Map))
.toList(),
manualHardwareInput: json['manualHardwareInput'] as bool? ?? false,
+ rootAccessStatus: $enumDecodeNullable(
+ _$RootAccessStatusEnumMap, json['rootAccessStatus']) ??
+ RootAccessStatus.unknown,
+ rootAccessCheckedAt: json['rootAccessCheckedAt'] == null
+ ? null
+ : DateTime.parse(json['rootAccessCheckedAt'] as String),
+ rootAccessDiagnostic: json['rootAccessDiagnostic'] as String?,
buttonRadius: $enumDecodeNullable(
_$SettingButtonRadiusEnumMap, json['buttonRadius']) ??
SettingButtonRadius.standard,
@@ -118,6 +125,9 @@ Map _$SysSettingToJson(SysSetting instance) =>
_$OrderPayDisplayTypeEnumMap[instance.defaultPayDisplayType],
'tenantList': instance.tenantList,
'manualHardwareInput': instance.manualHardwareInput,
+ 'rootAccessStatus': _$RootAccessStatusEnumMap[instance.rootAccessStatus]!,
+ 'rootAccessCheckedAt': instance.rootAccessCheckedAt?.toIso8601String(),
+ 'rootAccessDiagnostic': instance.rootAccessDiagnostic,
};
const _$SysTypeEnumMap = {
@@ -125,6 +135,12 @@ const _$SysTypeEnumMap = {
SysType.CANTEEN: 'CANTEEN',
};
+const _$RootAccessStatusEnumMap = {
+ RootAccessStatus.unknown: 'unknown',
+ RootAccessStatus.available: 'available',
+ RootAccessStatus.unavailable: 'unavailable',
+};
+
const _$SettingButtonRadiusEnumMap = {
SettingButtonRadius.compact: 'compact',
SettingButtonRadius.standard: 'standard',
diff --git a/lib/common/models/index.dart b/lib/common/models/index.dart
index 62a5fb6..a7edac7 100644
--- a/lib/common/models/index.dart
+++ b/lib/common/models/index.dart
@@ -25,6 +25,7 @@ export './manage/usb_facility_manage.dart';
export './manage/terminal_device_status_models.dart';
export './manage/hardware_input_history_models.dart';
export './manage/scheduled_task_models.dart';
+export './manage/root_access_models.dart';
export './encryption/encryption_tool.dart';
diff --git a/lib/common/models/manage/root_access_models.dart b/lib/common/models/manage/root_access_models.dart
new file mode 100644
index 0000000..def016d
--- /dev/null
+++ b/lib/common/models/manage/root_access_models.dart
@@ -0,0 +1,49 @@
+/// Android Root 权限的持久化检查状态,枚举名写入本机 sysJson,不能随界面文案调整。
+enum RootAccessStatus {
+ /// 尚未完成过有效检测,包含旧版本配置升级后的默认状态。
+ unknown,
+
+ /// `su -c id` 在超时内成功返回且输出确认当前 UID 为 0。
+ available,
+
+ /// Root 被拒绝、命令不存在、执行超时或返回结果未确认 UID 为 0。
+ unavailable,
+}
+
+/// 单次 Root 检查结果,原生层负责命令执行,Flutter 负责持久化和界面反馈。
+class RootAccessCheckResult {
+ /// 创建一份带检查时间与诊断摘要的 Root 结果。
+ const RootAccessCheckResult({
+ required this.status,
+ required this.checkedAt,
+ required this.diagnostic,
+ });
+
+ /// 本次检查得出的可用状态。
+ final RootAccessStatus status;
+
+ /// Flutter 收到原生检查结果的本地时间。
+ final DateTime checkedAt;
+
+ /// 可供运维排查的简短摘要,不保存完整命令输出或敏感环境信息。
+ final String diagnostic;
+
+ /// Root 是否已经通过真实命令验证。
+ bool get available => status == RootAccessStatus.available;
+
+ /// 从原生 MethodChannel 结构化结果创建检查记录,未知编码按不可用处理。
+ factory RootAccessCheckResult.fromMap(Map? data) {
+ final available = data?['available'] == true;
+ final diagnostic = data?['message']?.toString().trim();
+ return RootAccessCheckResult(
+ status:
+ available ? RootAccessStatus.available : RootAccessStatus.unavailable,
+ checkedAt: DateTime.now(),
+ diagnostic: diagnostic?.isNotEmpty == true
+ ? diagnostic!
+ : available
+ ? '已确认 uid=0'
+ : '未获得 Root 权限',
+ );
+ }
+}
diff --git a/lib/common/models/manage/scheduled_task_models.dart b/lib/common/models/manage/scheduled_task_models.dart
index 2315920..496cc9e 100644
--- a/lib/common/models/manage/scheduled_task_models.dart
+++ b/lib/common/models/manage/scheduled_task_models.dart
@@ -16,6 +16,9 @@ enum ScheduledTaskType {
/// 打印机、电子秤、读卡器等终端硬件状态检查。
deviceStatus,
+ /// 检查并通过 Root 静默安装最高的已发布 Android APK 版本。
+ autoUpdate,
+
/// 服务端设备配置同步。
configSync,
diff --git a/lib/common/models/manage/scheduled_task_models.g.dart b/lib/common/models/manage/scheduled_task_models.g.dart
index a090826..122ef09 100644
--- a/lib/common/models/manage/scheduled_task_models.g.dart
+++ b/lib/common/models/manage/scheduled_task_models.g.dart
@@ -28,6 +28,7 @@ const _$ScheduledTaskTypeEnumMap = {
ScheduledTaskType.printCheck: 'printCheck',
ScheduledTaskType.broadcastCheck: 'broadcastCheck',
ScheduledTaskType.deviceStatus: 'deviceStatus',
+ ScheduledTaskType.autoUpdate: 'autoUpdate',
ScheduledTaskType.configSync: 'configSync',
ScheduledTaskType.classifySync: 'classifySync',
ScheduledTaskType.productSync: 'productSync',
diff --git a/lib/common/models/page/app_version_enum_models.dart b/lib/common/models/page/app_version_enum_models.dart
index d01fbe9..4455cf6 100644
--- a/lib/common/models/page/app_version_enum_models.dart
+++ b/lib/common/models/page/app_version_enum_models.dart
@@ -1,28 +1,59 @@
import 'package:pj_supermarket_cloud/common/models/base/base_models.dart';
+import 'package:pj_supermarket_cloud/common/models/database/database_enum_models.dart';
+/// App 版本平台协议枚举,编码与后端 AppPlatformEnum 持久化值保持一致。
enum AppPlatformEnum implements BaseEnum {
+ /// 普通 Android 手机 App 平台编码。
android("1", "安卓"),
+
+ /// 普通 iOS 手机 App 平台编码。
ios("2", "苹果"),
+
+ /// 超市 Android 收银终端平台编码。
+ supermarketAndroid("3", "超市 Android 终端"),
+
+ /// 食堂 Android 消费终端平台编码。
+ canteenAndroid("4", "食堂 Android 终端"),
;
+ /// 后端版本记录使用的平台编码,不得随展示文案调整。
@override
final String code;
+
+ /// 终端版本列表使用的平台中文名称。
@override
final String title;
+ /// 创建一项固定平台编码与展示名称的映射。
const AppPlatformEnum(this.code, this.title);
+
+ /// 根据本机业务类型返回后台版本接口使用的精确终端平台编码。
+ static String terminalCode(SysType sysType) => switch (sysType) {
+ SysType.SUPERMARKET => supermarketAndroid.code,
+ SysType.CANTEEN => canteenAndroid.code,
+ };
}
+/// App 版本发布状态,编码来源于后端版本管理接口。
enum AppVersionStatusEnum implements BaseEnum {
+ /// 已发布,可供匹配平台的终端查询和更新。
published("1", "已发布"),
+
+ /// 未发布,仅供后台维护,不应出现在终端版本列表。
unpublished("2", "未发布"),
+
+ /// 已下架,不再提供终端下载。
offShelf("3", "已下架"),
;
+ /// 后端版本记录使用的发布状态编码。
@override
final String code;
+
+ /// 设置页使用的发布状态中文名称。
@override
final String title;
+ /// 创建一项固定发布状态编码与展示名称的映射。
const AppVersionStatusEnum(this.code, this.title);
}
diff --git a/lib/common/utils/app/app_device_status_tool.dart b/lib/common/utils/app/app_device_status_tool.dart
index af7e638..4e98b54 100644
--- a/lib/common/utils/app/app_device_status_tool.dart
+++ b/lib/common/utils/app/app_device_status_tool.dart
@@ -93,6 +93,15 @@ abstract final class AppDeviceStatusTool {
required Duration deviceTimeout,
required int generation,
}) async {
+ // Root 不可用是正常设备状态,检查与持久化失败只记录告警,不中断后续外围硬件探测。
+ if (Get.isRegistered()) {
+ try {
+ final rootResult = await AppRuntimeTool.checkRootAccess();
+ await AppSettingTool.setRootAccessResult(rootResult);
+ } catch (e, stack) {
+ logger.w('|$_tag|Root状态检查或持久化失败error:|$e|stack:|$stack|');
+ }
+ }
final needsApp = printerProbe == null ||
steelyardProbe == null ||
cardReaderProbe == null;
diff --git a/lib/common/utils/app/app_runtime_tool.dart b/lib/common/utils/app/app_runtime_tool.dart
index e722f6b..d474536 100644
--- a/lib/common/utils/app/app_runtime_tool.dart
+++ b/lib/common/utils/app/app_runtime_tool.dart
@@ -1,19 +1,46 @@
import 'package:flutter/services.dart';
+import 'package:pj_supermarket_cloud/common/models/manage/root_access_models.dart';
+
/// 当前 Android 设备的运行环境。
class AppRuntimeEnvironment {
+ /// 是否命中 Android 虚拟机特征;通道不可用时按真机降级,避免阻塞应用启动。
final bool isEmulator;
+
+ /// Android DisplayManager 当前识别的显示屏数量,最少按单屏处理。
final int displayCount;
+ /// 当前 Android 系统发行版本,例如 `11`;原生字段不可用时为空字符串。
+ final String androidVersion;
+
+ /// 当前 Android API Level,例如 Android 11 对应 30;原生字段不可用时为 0。
+ final int androidSdkInt;
+
+ /// 当前进程实际安装 APK 的展示版本,不使用后台待更新版本代替。
+ final String apkVersionName;
+
+ /// 当前进程实际安装 APK 的内部版本码;原生字段不可用时为 0。
+ final int apkVersionCode;
+
+ /// 创建一份由 Android 原生运行时采集的不可变环境快照。
const AppRuntimeEnvironment({
required this.isEmulator,
required this.displayCount,
+ required this.androidVersion,
+ required this.androidSdkInt,
+ required this.apkVersionName,
+ required this.apkVersionCode,
});
+ /// 从 MethodChannel 返回值解析环境快照,缺失版本字段使用可安全展示的空值。
factory AppRuntimeEnvironment.fromMap(Map? data) {
return AppRuntimeEnvironment(
isEmulator: data?['isEmulator'] == true,
displayCount: (data?['displayCount'] as num?)?.toInt() ?? 1,
+ androidVersion: data?['androidVersion']?.toString() ?? '',
+ androidSdkInt: (data?['androidSdkInt'] as num?)?.toInt() ?? 0,
+ apkVersionName: data?['apkVersionName']?.toString() ?? '',
+ apkVersionCode: (data?['apkVersionCode'] as num?)?.toInt() ?? 0,
);
}
}
@@ -87,6 +114,100 @@ class AppKioskStatus {
}
}
+/// Root 执行 DPM 后返回的结构化 Kiosk 启用结果。
+class AppKioskProvisionResult {
+ /// 创建一份包含稳定错误编码、可操作说明和最新 Kiosk 状态的结果。
+ const AppKioskProvisionResult({
+ required this.success,
+ required this.code,
+ required this.message,
+ required this.status,
+ });
+
+ /// DPM 是否成功配置当前应用为 Device Owner 并进入 Kiosk。
+ final bool success;
+
+ /// 原生层稳定错误编码,用于区分账户、Owner、用户和厂商限制。
+ final String code;
+
+ /// 面向终端管理员展示的中文处理建议。
+ final String message;
+
+ /// 命令执行完成后重新读取的 Kiosk 实时状态。
+ final AppKioskStatus status;
+
+ /// 从原生结构化返回值恢复结果,通道字段缺失时使用安全失败值。
+ factory AppKioskProvisionResult.fromMap(Map? data) {
+ return AppKioskProvisionResult(
+ success: data?['success'] == true,
+ code: data?['code']?.toString() ?? 'UNKNOWN',
+ message: data?['message']?.toString() ?? 'Kiosk 启用失败,请检查设备策略',
+ status: AppKioskStatus.fromMap(data?['status'] as Map?),
+ );
+ }
+}
+
+/// Android APK 校验或 Root 安装命令的结构化结果。
+class AppApkOperationResult {
+ /// 创建一份供自动更新流程判断的原生操作结果。
+ const AppApkOperationResult({
+ required this.success,
+ required this.code,
+ required this.message,
+ this.versionCode,
+ });
+
+ /// APK 校验通过或安装命令成功执行时为 true。
+ final bool success;
+
+ /// 原生稳定结果编码,例如 SIGNATURE_MISMATCH 或 INSTALL_STARTED。
+ final String code;
+
+ /// 面向任务日志或人工操作展示的中文摘要。
+ final String message;
+
+ /// 原生 APK 实际版本码;返回数据不含版本时为空。
+ final int? versionCode;
+
+ /// 从 MethodChannel 返回映射创建操作结果。
+ factory AppApkOperationResult.fromMap(Map? data) {
+ return AppApkOperationResult(
+ success: data?['valid'] == true,
+ code: data?['code']?.toString() ?? 'UNKNOWN',
+ message: data?['message']?.toString() ?? '原生 APK 操作失败',
+ versionCode: (data?['versionCode'] as num?)?.toInt(),
+ );
+ }
+}
+
+/// 应用启动时消费的待更新恢复结果。
+class AppUpdateRecoveryResult {
+ /// 创建一份待更新确认结果。
+ const AppUpdateRecoveryResult({
+ required this.pending,
+ required this.success,
+ required this.message,
+ });
+
+ /// 原生层是否存在上次安装前持久化的待更新记录。
+ final bool pending;
+
+ /// 当前安装版本是否达到待更新目标版本码。
+ final bool success;
+
+ /// 启动确认结果摘要,用于完成任务记录。
+ final String message;
+
+ /// 从 MethodChannel 返回映射创建恢复结果。
+ factory AppUpdateRecoveryResult.fromMap(Map? data) {
+ return AppUpdateRecoveryResult(
+ pending: data?['pending'] == true,
+ success: data?['success'] == true,
+ message: data?['message']?.toString() ?? '没有待确认的自动更新',
+ );
+ }
+}
+
class AppNetworkSnapshot {
final bool connected;
final String type;
@@ -115,6 +236,12 @@ class AppNetworkSnapshot {
/// CPU 使用率需要比较两次 [processCpuTimeMillis],原生层只返回累计值,避免在平台通道内
/// 持有采样定时器;内存和存储同时保留总量,便于顶栏给出百分比并在 Tooltip 展示明细。
class AppPerformanceSnapshot {
+ /// Android 系统自本次开机后的运行时长,单位为秒。
+ final int systemUptimeSeconds;
+
+ /// 当前应用进程自启动后的运行时长,单位为秒。
+ final int appUptimeSeconds;
+
/// Android 进程从启动至采样时累计消耗的 CPU 时间,单位为毫秒。
final int processCpuTimeMillis;
@@ -138,6 +265,8 @@ class AppPerformanceSnapshot {
/// 创建一份原生平台已经完成单位换算的资源快照。
const AppPerformanceSnapshot({
+ required this.systemUptimeSeconds,
+ required this.appUptimeSeconds,
required this.processCpuTimeMillis,
required this.logicalProcessorCount,
required this.systemMemoryTotalBytes,
@@ -149,7 +278,9 @@ class AppPerformanceSnapshot {
/// 创建平台通道不可用时的空快照,百分比 Getter 将稳定返回 0。
const AppPerformanceSnapshot.empty()
- : processCpuTimeMillis = 0,
+ : systemUptimeSeconds = 0,
+ appUptimeSeconds = 0,
+ processCpuTimeMillis = 0,
logicalProcessorCount = 1,
systemMemoryTotalBytes = 0,
systemMemoryAvailableBytes = 0,
@@ -161,6 +292,8 @@ class AppPerformanceSnapshot {
factory AppPerformanceSnapshot.fromMap(Map? data) {
int value(String key) => (data?[key] as num?)?.toInt() ?? 0;
return AppPerformanceSnapshot(
+ systemUptimeSeconds: value('systemUptimeSeconds'),
+ appUptimeSeconds: value('appUptimeSeconds'),
processCpuTimeMillis: value('processCpuTimeMs'),
logicalProcessorCount:
value('logicalProcessorCount').clamp(1, 256).toInt(),
@@ -200,6 +333,10 @@ abstract class AppRuntimeTool {
static AppRuntimeEnvironment _environment = const AppRuntimeEnvironment(
isEmulator: false,
displayCount: 1,
+ androidVersion: '',
+ androidSdkInt: 0,
+ apkVersionName: '',
+ apkVersionCode: 0,
);
static bool _initialized = false;
static bool _manualHardwareInput = false;
@@ -209,6 +346,28 @@ abstract class AppRuntimeTool {
static int get displayCount => _environment.displayCount;
+ /// 返回设置页使用的 Android 发行版本与 API Level,通道不可用时明确显示未知。
+ static String get androidVersionLabel {
+ final version = _environment.androidVersion.trim();
+ final sdk = _environment.androidSdkInt;
+ if (version.isNotEmpty && sdk > 0) return 'Android $version(API $sdk)';
+ if (version.isNotEmpty) return 'Android $version';
+ if (sdk > 0) return 'Android API $sdk';
+ return '未知';
+ }
+
+ /// 返回当前实际安装 APK 的展示版本与 Build,字段缺失时仍保留已知部分。
+ static String get apkVersionLabel {
+ final versionName = _environment.apkVersionName.trim();
+ final versionCode = _environment.apkVersionCode;
+ if (versionName.isNotEmpty && versionCode > 0) {
+ return '$versionName(Build $versionCode)';
+ }
+ if (versionName.isNotEmpty) return versionName;
+ if (versionCode > 0) return 'Build $versionCode';
+ return '未知';
+ }
+
/// 虚拟机始终使用手动输入;真机可在设置中主动开启手动输入模式。
static bool get useManualHardwareInput => isEmulator || _manualHardwareInput;
@@ -231,6 +390,10 @@ abstract class AppRuntimeTool {
_environment = const AppRuntimeEnvironment(
isEmulator: false,
displayCount: 1,
+ androidVersion: '',
+ androidSdkInt: 0,
+ apkVersionName: '',
+ apkVersionCode: 0,
);
_kioskStatus = const AppKioskStatus.unavailable();
}
@@ -263,6 +426,97 @@ abstract class AppRuntimeTool {
return _kioskStatus;
}
+ /// 用户确认后通过 Root 请求 Android 配置当前应用为 Device Owner,并立即进入 Kiosk。
+ static Future enableKioskWithRoot() async {
+ try {
+ final data = await _channel.invokeMapMethod(
+ 'enableKioskWithRoot',
+ );
+ final provision = AppKioskProvisionResult.fromMap(data);
+ _kioskStatus = provision.status;
+ return provision;
+ } on PlatformException catch (error) {
+ return AppKioskProvisionResult(
+ success: false,
+ code: error.code,
+ message: error.message ?? 'Kiosk 启用失败,请稍后重试',
+ status: _kioskStatus,
+ );
+ }
+ }
+
+ /// 执行带原生 5 秒超时的 `su -c id`,只有退出码为零且输出确认 uid=0 才返回可用。
+ static Future checkRootAccess() async {
+ try {
+ final data = await _channel.invokeMapMethod(
+ 'checkRootAccess',
+ );
+ return RootAccessCheckResult.fromMap(data);
+ } on PlatformException catch (error) {
+ return RootAccessCheckResult(
+ status: RootAccessStatus.unavailable,
+ checkedAt: DateTime.now(),
+ diagnostic: error.message ?? 'Root 检查通道不可用',
+ );
+ }
+ }
+
+ /// 由 Android PackageManager 校验 APK 包名、签名和目标版本码。
+ static Future validateApk({
+ required String path,
+ required int expectedVersionCode,
+ }) async {
+ try {
+ final data = await _channel.invokeMapMethod(
+ 'validateApk',
+ {'path': path, 'expectedVersionCode': expectedVersionCode},
+ );
+ return AppApkOperationResult.fromMap(data);
+ } on PlatformException catch (error) {
+ return AppApkOperationResult(
+ success: false,
+ code: error.code,
+ message: error.message ?? 'APK 校验失败',
+ );
+ }
+ }
+
+ /// 安装前由原生层再次校验 Root 与 APK,并执行不降级、不卸载数据的 `pm install -r`。
+ static Future installApkWithRoot({
+ required String path,
+ required int expectedVersionCode,
+ }) async {
+ try {
+ final data = await _channel.invokeMapMethod(
+ 'installApkWithRoot',
+ {'path': path, 'expectedVersionCode': expectedVersionCode},
+ );
+ return AppApkOperationResult.fromMap(data);
+ } on PlatformException catch (error) {
+ return AppApkOperationResult(
+ success: false,
+ code: error.code,
+ message: error.message ?? 'APK 安装失败',
+ );
+ }
+ }
+
+ /// 启动时消费原生待更新状态,确认版本后原生层同步清理私有 APK。
+ static Future consumePendingUpdate() async {
+ try {
+ final data = await _channel.invokeMapMethod(
+ 'consumePendingUpdate',
+ );
+ return AppUpdateRecoveryResult.fromMap(data);
+ } on PlatformException catch (error) {
+ return AppUpdateRecoveryResult(
+ pending: false,
+ success: false,
+ message: error.message ?? '自动更新恢复检查失败',
+ );
+ }
+ }
+
/// 只有原生层确认 Device Owner/Kiosk 可用时才允许退出到系统桌面。
static Future exitKioskToAndroidHome() async {
if (!_kioskStatus.canExitToAndroidHome) return false;
diff --git a/lib/common/utils/app/app_setting_tool.dart b/lib/common/utils/app/app_setting_tool.dart
index d0cc91f..f3821cf 100644
--- a/lib/common/utils/app/app_setting_tool.dart
+++ b/lib/common/utils/app/app_setting_tool.dart
@@ -43,6 +43,15 @@ abstract class AppSettingTool {
/// 人工硬件输入历史在现有 sysJson 扩展配置中的存储键。
static const String _hardwareInputHistoryKey = "hardwareInputHistory";
+ /// Root 状态在 sysJson 中的稳定键,只允许由局部串行更新方法写入。
+ static const String _rootAccessStatusKey = 'rootAccessStatus';
+
+ /// Root 最近检查时间在 sysJson 中的稳定键,使用 ISO-8601 字符串持久化。
+ static const String _rootAccessCheckedAtKey = 'rootAccessCheckedAt';
+
+ /// Root 诊断摘要在 sysJson 中的稳定键,不保存完整 shell 输出。
+ static const String _rootAccessDiagnosticKey = 'rootAccessDiagnostic';
+
// 自定义缓存管理器
static final CacheManager _settingCache = CacheManager(
Config(
@@ -52,8 +61,23 @@ abstract class AppSettingTool {
),
);
- /// sysJson 扩展数据的进程内写入队列,避免迁移清理与设置保存相互覆盖。
- static Future _extensionWriteQueue = Future.value();
+ /// setting 单行记录的进程内写入队列,避免 Root 检查、登录刷新和设置页保存相互覆盖。
+ static Future _settingWriteQueue = Future.value();
+
+ ///
+ /// 将 setting 的“读取最新值并写回”过程串行执行。
+ ///
+ /// setting 表把用户、系统和食堂扩展配置保存在同一行,仅读写前排队才能避免不同字段的
+ /// 并发更新互相覆盖;业务异常仍返回原调用方,后续任务会忽略前一次失败继续执行。
+ static Future _serializeSettingWrite(
+ Future Function() action,
+ ) {
+ final operation = _settingWriteQueue.catchError((_) {}).then(
+ (_) => action(),
+ );
+ _settingWriteQueue = operation.then((_) {}).catchError((_) {});
+ return operation;
+ }
static Future removeSettingCache() async {
await _settingCache.emptyCache();
@@ -99,41 +123,73 @@ abstract class AppSettingTool {
SysSetting? sysJson,
UserSetting? userJson,
CanteenExtraPriceSetting? canteenExtraPriceConfig,
- }) async {
- if (ObjectUtil.isEmpty(sysJson) &&
- ObjectUtil.isEmpty(userJson) &&
- ObjectUtil.isEmpty(canteenExtraPriceConfig)) {
- return false;
- }
- final app = Get.find();
- final settingDao = app.dataBase.getSettingDao;
- SettingEntity? setting = await settingDao.get();
- if (setting == null) {
- logger.e("|$_tag|_setSetting新增错误settingDao.get()方法返回为null");
- return false;
- }
- if (sysJson != null) {
- final current = Map.from(json.decode(setting.sysJson));
- // sysJson 允许保存不涉及数据库结构的终端 UI 扩展配置,更新业务设置时必须保留这些键。
- setting.sysJson = json.encode({...current, ...sysJson.toJson()});
- }
- if (userJson != null) {
- setting.userJson = json.encode(userJson.toJson());
- }
- if (canteenExtraPriceConfig != null) {
- setting.canteenExtraPriceConfigJson = json.encode(
- canteenExtraPriceConfig.toJson(),
- );
- }
- await settingDao.edit(setting);
+ }) =>
+ _serializeSettingWrite(() async {
+ if (ObjectUtil.isEmpty(sysJson) &&
+ ObjectUtil.isEmpty(userJson) &&
+ ObjectUtil.isEmpty(canteenExtraPriceConfig)) {
+ return false;
+ }
+ final app = Get.find();
+ final settingDao = app.dataBase.getSettingDao;
+ final setting = await settingDao.get();
+ if (setting == null) {
+ logger.e("|$_tag|_setSetting新增错误settingDao.get()方法返回为null");
+ return false;
+ }
+ if (sysJson != null) {
+ final current =
+ Map.from(json.decode(setting.sysJson));
+ final updated = sysJson.toJson();
+ // Root 状态只归后台检查写入;普通设置使用调用前快照时不能回写过期的 Root 值。
+ for (final key in const [
+ _rootAccessStatusKey,
+ _rootAccessCheckedAtKey,
+ _rootAccessDiagnosticKey,
+ ]) {
+ if (current.containsKey(key)) updated[key] = current[key];
+ }
+ // sysJson 允许保存不涉及数据库结构的终端 UI 扩展配置,更新业务设置时必须保留这些键。
+ setting.sysJson = json.encode({...current, ...updated});
+ }
+ if (userJson != null) {
+ setting.userJson = json.encode(userJson.toJson());
+ }
+ if (canteenExtraPriceConfig != null) {
+ setting.canteenExtraPriceConfigJson = json.encode(
+ canteenExtraPriceConfig.toJson(),
+ );
+ }
+ await settingDao.edit(setting);
+ await _cacheSetting(setting);
+ return true;
+ });
+
+ /// 刷新 setting 单行缓存;调用方必须已经完成数据库写入并持有串行写入队列。
+ static Future _cacheSetting(SettingEntity setting) async {
await _settingCache.putFile(
_settingKey,
Uint8List.fromList(utf8.encode(json.encode(setting.toJson()))),
fileExtension: 'json',
);
- return true;
}
+ /// 在串行队列内读取并局部修改最新 sysJson,未涉及的业务键与同一行其他列保持不变。
+ static Future _updateSysJson(
+ void Function(Map sysJson) update,
+ ) =>
+ _serializeSettingWrite(() async {
+ final app = Get.find();
+ final settingDao = app.dataBase.getSettingDao;
+ final setting = await settingDao.get();
+ if (setting == null) throw StateError('终端设置尚未初始化');
+ final sysJson = Map.from(json.decode(setting.sysJson));
+ update(sysJson);
+ setting.sysJson = json.encode(sysJson);
+ await settingDao.edit(setting);
+ await _cacheSetting(setting);
+ });
+
static Future getSysSetting() async {
final setting = await _getSetting();
return SysSetting.fromJson(json.decode(setting.sysJson));
@@ -158,6 +214,22 @@ abstract class AppSettingTool {
await _setSetting(sysJson: sysInfo);
}
+ /// 串行更新 Root 专属键,始终在实际写入前重新读取 sysJson,避免后台检查覆盖设置页并发修改。
+ static Future setRootAccessResult(RootAccessCheckResult result) =>
+ _serializeSettingWrite(() async {
+ final app = Get.find();
+ final settingDao = app.dataBase.getSettingDao;
+ final setting = await settingDao.get();
+ if (setting == null) throw StateError('终端设置尚未初始化');
+ final sysJson = Map.from(json.decode(setting.sysJson));
+ sysJson[_rootAccessStatusKey] = result.status.name;
+ sysJson[_rootAccessCheckedAtKey] = result.checkedAt.toIso8601String();
+ sysJson[_rootAccessDiagnosticKey] = result.diagnostic;
+ setting.sysJson = json.encode(sysJson);
+ await settingDao.edit(setting);
+ await _cacheSetting(setting);
+ });
+
/// 读取完整 sysJson 为可修改映射,仅供旧结构化数据迁移方法使用。
static Future