From 0b287b036415d2b02ce802fde22a60796201a849 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 30 Jul 2026 17:06:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:apk=E8=87=AA=E5=8A=A8=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- android/app/src/main/AndroidManifest.xml | 1 + .../pj_supermarket_cloud/ApkUpdateManager.kt | 194 ++++++ .../pj_supermarket_cloud/KioskBootReceiver.kt | 10 +- .../pj_supermarket_cloud/KioskModeManager.kt | 195 +++++- .../pj_supermarket_cloud/MainActivity.kt | 104 +++- .../pj_supermarket_cloud/RootCommandRunner.kt | 191 ++++++ lib/common/api/app_api.dart | 17 +- lib/common/api/hard_api.dart | 15 + .../models/database/database_models.dart | 16 + .../models/database/database_models.g.dart | 16 + lib/common/models/index.dart | 1 + .../models/manage/root_access_models.dart | 49 ++ .../models/manage/scheduled_task_models.dart | 3 + .../manage/scheduled_task_models.g.dart | 1 + .../models/page/app_version_enum_models.dart | 31 + .../utils/app/app_device_status_tool.dart | 9 + lib/common/utils/app/app_runtime_tool.dart | 256 +++++++- lib/common/utils/app/app_setting_tool.dart | 326 +++++----- lib/common/utils/app/control/app_control.dart | 6 +- lib/common/utils/index.dart | 2 + lib/common/utils/manage/http_manage.dart | 29 +- lib/common/utils/sync/sync_check_tool.dart | 17 + .../utils/sync/terminal_auto_update_tool.dart | 283 +++++++++ .../utils/sync/terminal_heartbeat_tool.dart | 168 +++++ .../utils/sync/terminal_log_upload_tool.dart | 37 +- lib/common/widgets/toast/toast_widget.dart | 108 ++-- lib/pages/root/login/controller.dart | 12 + lib/pages/root/login/view.dart | 13 +- lib/pages/terminal/setting/controller.dart | 108 +++- lib/pages/terminal/setting/view.dart | 7 - .../widgets/setting-version-setting.dart | 588 ++++++++++++------ .../setting/widgets/setting_baseInfo.dart | 93 ++- .../widgets/setting_cron_rule_dialog.dart | 2 +- .../setting/widgets/setting_sync.dart | 4 + pubspec.lock | 16 + pubspec.yaml | 2 + test/common/terminal_update_models_test.dart | 127 ++++ test/common/toast_widget_test.dart | 50 ++ 39 files changed, 2631 insertions(+), 478 deletions(-) create mode 100644 android/app/src/main/kotlin/com/example/pj_supermarket_cloud/ApkUpdateManager.kt create mode 100644 android/app/src/main/kotlin/com/example/pj_supermarket_cloud/RootCommandRunner.kt create mode 100644 lib/common/models/manage/root_access_models.dart create mode 100644 lib/common/utils/sync/terminal_auto_update_tool.dart create mode 100644 lib/common/utils/sync/terminal_heartbeat_tool.dart create mode 100644 test/common/terminal_update_models_test.dart create mode 100644 test/common/toast_widget_test.dart 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> _getSysExtensionJson() async { final setting = await _getSetting(); @@ -227,30 +299,23 @@ abstract class AppSettingTool { /// v8 已将任务与上传状态迁入独立表,导入成功后移除旧 JSON,避免形成双写数据源。 static Future clearLegacyScheduledTaskState({ required bool includeUploadState, - }) async { - final operation = _extensionWriteQueue.catchError((_) {}).then((_) 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.remove(_scheduledTaskConfigsKey); - sysJson.remove(_scheduledTaskRecordsKey); - if (includeUploadState) { - sysJson.remove(_terminalLogUploadStatesKey); - sysJson.remove(_terminalLogUploadSummaryKey); - } - setting.sysJson = json.encode(sysJson); - await settingDao.edit(setting); - await _settingCache.putFile( - _settingKey, - Uint8List.fromList(utf8.encode(json.encode(setting.toJson()))), - fileExtension: 'json', - ); - }); - _extensionWriteQueue = operation; - await operation; - } + }) => + _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.remove(_scheduledTaskConfigsKey); + sysJson.remove(_scheduledTaskRecordsKey); + if (includeUploadState) { + sysJson.remove(_terminalLogUploadStatesKey); + sysJson.remove(_terminalLogUploadSummaryKey); + } + setting.sysJson = json.encode(sysJson); + await settingDao.edit(setting); + await _cacheSetting(setting); + }); static Future setUserSetting(UserSetting userInfo) async { final globalUserInfo = await getUserSetting(); @@ -290,24 +355,11 @@ abstract class AppSettingTool { static Future setTerminalMenuPreferences({ required List order, required Set hidden, - }) 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[_terminalMenuOrderKey] = order; - sysJson[_hiddenTerminalMenusKey] = hidden.toList(); - setting.sysJson = json.encode(sysJson); - await settingDao.edit(setting); - await _settingCache.putFile( - _settingKey, - Uint8List.fromList(utf8.encode(json.encode(setting.toJson()))), - fileExtension: 'json', - ); - } + }) => + _updateSysJson((sysJson) { + sysJson[_terminalMenuOrderKey] = order; + sysJson[_hiddenTerminalMenusKey] = hidden.toList(); + }); /// 读取支付方式的完整顺序;显隐仍由 SysSetting.usePayDisplayType 维护。 static Future> getPayTypeOrder() async { @@ -323,29 +375,17 @@ abstract class AppSettingTool { static Future setPayTypePreferences({ required SysSetting sysInfo, required List order, - }) async { + }) { // 在首次异步等待前生成快照,避免连续拖动时后一次修改污染本次落盘内容。 final nextSysJson = sysInfo.toJson(); final visiblePayTypes = nextSysJson['usePayDisplayType']; final defaultPayType = nextSysJson['defaultPayDisplayType']; final orderCodes = order.map((item) => item.code).toList(); - final app = Get.find(); - final settingDao = app.dataBase.getSettingDao; - final setting = await settingDao.get(); - if (setting == null) { - throw StateError('终端设置尚未初始化'); - } - final current = Map.from(json.decode(setting.sysJson)); - current['usePayDisplayType'] = visiblePayTypes; - current['defaultPayDisplayType'] = defaultPayType; - current[_payTypeOrderKey] = orderCodes; - setting.sysJson = json.encode(current); - await settingDao.edit(setting); - await _settingCache.putFile( - _settingKey, - Uint8List.fromList(utf8.encode(json.encode(setting.toJson()))), - fileExtension: 'json', - ); + return _updateSysJson((sysJson) { + sysJson['usePayDisplayType'] = visiblePayTypes; + sysJson['defaultPayDisplayType'] = defaultPayType; + sysJson[_payTypeOrderKey] = orderCodes; + }); } /// 读取收银页底部快捷操作显隐;旧终端没有配置时保持两个入口都显示。 @@ -363,22 +403,11 @@ abstract class AppSettingTool { static Future setCashierPagePreferences({ required bool showHoldAction, required bool showMoreAction, - }) 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[_cashierShowHoldActionKey] = showHoldAction; - sysJson[_cashierShowMoreActionKey] = showMoreAction; - setting.sysJson = json.encode(sysJson); - await settingDao.edit(setting); - await _settingCache.putFile( - _settingKey, - Uint8List.fromList(utf8.encode(json.encode(setting.toJson()))), - fileExtension: 'json', - ); - } + }) => + _updateSysJson((sysJson) { + sysJson[_cashierShowHoldActionKey] = showHoldAction; + sysJson[_cashierShowMoreActionKey] = showMoreAction; + }); /// 读写卡协议配置属于设备本地扩展设置,复用 sysJson 保存且不修改数据库结构。 static Future getSerialCardConfig() async { @@ -395,21 +424,10 @@ abstract class AppSettingTool { } /// 密钥只写入本机设置和串口模块,不记录到日志,也不随普通业务接口上传。 - static Future setSerialCardConfig(SerialCardConfig config) 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[_serialCardConfigKey] = config.toJson(); - setting.sysJson = json.encode(sysJson); - await settingDao.edit(setting); - await _settingCache.putFile( - _settingKey, - Uint8List.fromList(utf8.encode(json.encode(setting.toJson()))), - fileExtension: 'json', - ); - } + static Future setSerialCardConfig(SerialCardConfig config) => + _updateSysJson((sysJson) { + sysJson[_serialCardConfigKey] = config.toJson(); + }); /// 查询方式顺序和默认项属于页面交互配置,继续保存在 sysJson 中,不增加数据库字段。 static Future @@ -464,7 +482,7 @@ abstract class AppSettingTool { required bool nameMaskEnabled, required bool verificationEnabled, required ReservationVerificationDateScope verificationDateScope, - }) async { + }) { final normalized = []; for (final method in order) { if (!normalized.contains(method)) normalized.add(method); @@ -474,27 +492,15 @@ abstract class AppSettingTool { } final normalizedDefault = normalized.contains(defaultMethod) ? defaultMethod : normalized.first; - 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[_reservationQueryMethodOrderKey] = - normalized.map((method) => method.code).toList(); - sysJson[_reservationQueryDefaultMethodKey] = normalizedDefault.code; - sysJson[_reservationQueryNameMaskEnabledKey] = nameMaskEnabled; - sysJson[_reservationQueryVerificationEnabledKey] = verificationEnabled; - sysJson[_reservationQueryVerificationDateScopeKey] = - verificationDateScope.code; - setting.sysJson = json.encode(sysJson); - await settingDao.edit(setting); - await _settingCache.putFile( - _settingKey, - Uint8List.fromList(utf8.encode(json.encode(setting.toJson()))), - fileExtension: 'json', - ); + return _updateSysJson((sysJson) { + sysJson[_reservationQueryMethodOrderKey] = + normalized.map((method) => method.code).toList(); + sysJson[_reservationQueryDefaultMethodKey] = normalizedDefault.code; + sysJson[_reservationQueryNameMaskEnabledKey] = nameMaskEnabled; + sysJson[_reservationQueryVerificationEnabledKey] = verificationEnabled; + sysJson[_reservationQueryVerificationDateScopeKey] = + verificationDateScope.code; + }); } /// 读取指定“路由 + 功能键”场景最近使用的人工硬件输入,异常旧数据会被忽略。 @@ -513,28 +519,18 @@ abstract class AppSettingTool { }) async { final normalizedValue = value.trim(); if (normalizedValue.isEmpty) return; - 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)); - final store = _decodeHardwareInputHistoryStore( - sysJson[_hardwareInputHistoryKey], - ); - final nextStore = store.save( - sceneKey: sceneKey, - value: normalizedValue, - remark: remark ?? '', - updatedAt: DateTime.now(), - ); - sysJson[_hardwareInputHistoryKey] = nextStore.toJson(); - setting.sysJson = json.encode(sysJson); - await settingDao.edit(setting); - await _settingCache.putFile( - _settingKey, - Uint8List.fromList(utf8.encode(json.encode(setting.toJson()))), - fileExtension: 'json', - ); + await _updateSysJson((sysJson) { + final store = _decodeHardwareInputHistoryStore( + sysJson[_hardwareInputHistoryKey], + ); + final nextStore = store.save( + sceneKey: sceneKey, + value: normalizedValue, + remark: remark ?? '', + updatedAt: DateTime.now(), + ); + sysJson[_hardwareInputHistoryKey] = nextStore.toJson(); + }); } /// 从现有 sysJson 扩展配置读取强类型历史模型,单条异常数据不影响人工输入主流程。 diff --git a/lib/common/utils/app/control/app_control.dart b/lib/common/utils/app/control/app_control.dart index c27ac5f..b05d5b0 100644 --- a/lib/common/utils/app/control/app_control.dart +++ b/lib/common/utils/app/control/app_control.dart @@ -106,6 +106,7 @@ class AppControl extends GetxController { AppNetworkTool.closeListenNetwork(); TtsManage.dispose(); SyncCheckTool.instance.dispose(); + TerminalHeartbeatTool.stop(); } bool get printManageInitialized { @@ -238,7 +239,9 @@ class AppControl extends GetxController { if (AppNetworkTool.subscription == null) { AppNetworkTool.listenNetwork(); } - SyncCheckTool.instance.startAll(); + await SyncCheckTool.instance.startAll(); + await TerminalAutoUpdateTool.recoverPendingUpdate(); + TerminalHeartbeatTool.start(); startupFinished.value = true; startupStatusLabel.value = startupSteps @@ -294,6 +297,7 @@ class AppControl extends GetxController { if (steelyardManageInitialized) srialSteelyardManage.close(); AppNetworkTool.closeListenNetwork(); SyncCheckTool.instance.dispose(); + TerminalHeartbeatTool.stop(); } void _resetStartupSteps() { diff --git a/lib/common/utils/index.dart b/lib/common/utils/index.dart index 97a2f84..35e32d8 100644 --- a/lib/common/utils/index.dart +++ b/lib/common/utils/index.dart @@ -48,6 +48,8 @@ export './sync/sync_idle_tool.dart'; export './sync/scheduled_task_storage_tool.dart'; export './sync/file_upload_record_tool.dart'; export './sync/terminal_log_upload_tool.dart'; +export './sync/terminal_heartbeat_tool.dart'; +export './sync/terminal_auto_update_tool.dart'; export './sync/sync_config_tool.dart'; export './sync/sync_face_tool.dart'; export './sync/sync_order_tool.dart'; diff --git a/lib/common/utils/manage/http_manage.dart b/lib/common/utils/manage/http_manage.dart index 2f06700..93989fb 100644 --- a/lib/common/utils/manage/http_manage.dart +++ b/lib/common/utils/manage/http_manage.dart @@ -11,6 +11,8 @@ import 'package:pj_supermarket_cloud/common/index.dart'; const ignoreMsg = ["账号未登录"]; class HttpManage { + /// 后台心跳、版本检查等旁路请求禁止弹 Toast 时写入 Dio RequestOptions.extra 的稳定键。 + static const String silentRequestKey = 'silentRequest'; static const _tag = "http管理"; static const _requestStartAtKey = "_requestStartAt"; static const _maxLogLength = 1000; @@ -55,6 +57,7 @@ class HttpManage { return handler.next(options); }, onResponse: (e, handler) async { + final silent = e.requestOptions.extra[silentRequestKey] == true; // 先判断是不是登录接口 late ServiceBase serviceData; if (e.requestOptions.path.contains(Constants.oauth2AppApiUrl)) { @@ -64,14 +67,14 @@ class HttpManage { } if (serviceData.code == 401 && ObjectUtil.isNotEmpty(serviceData.msg)) { - if (!ignoreMsg.contains(serviceData.msg)) { + if (!silent && !ignoreMsg.contains(serviceData.msg)) { Toast.error(msg: serviceData.msg ?? "服务器未知错误"); } final isLogin = await _autoLogin(); if (!isLogin) { Get.offAndToNamed(RouteNames.login); } - } else if (serviceData.code != 0) { + } else if (serviceData.code != 0 && !silent) { Toast.error(msg: serviceData.msg ?? "服务器未知错误"); } // 续一下token @@ -82,14 +85,27 @@ class HttpManage { options: e.requestOptions, response: serviceData.data, ); + if (silent && serviceData.code != 0) { + handler.reject( + DioException( + requestOptions: e.requestOptions, + response: e, + type: DioExceptionType.badResponse, + error: serviceData.msg ?? '后台请求失败', + ), + ); + return; + } e.data = serviceData.data; handler.next(e); }, onError: (e, handler) async { + final silent = e.requestOptions.extra[silentRequestKey] == true; _logError(e); - if (e.type == DioExceptionType.connectionTimeout || - e.type == DioExceptionType.sendTimeout || - e.type == DioExceptionType.receiveTimeout) { + if (!silent && + (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.sendTimeout || + e.type == DioExceptionType.receiveTimeout)) { Toast.error(msg: "网络请求超时"); EasyLoading.dismiss(); } @@ -101,12 +117,11 @@ class HttpManage { if (!isLogin) { Get.offAndToNamed(RouteNames.login); } - handler.next(e); } } if (response != null && response.data != null) { ServiceBase data = ServiceBase.fromJson(response.data); - if (data.code != 0) { + if (data.code != 0 && !silent) { Toast.error(msg: data.msg ?? "服务器未知错误"); EasyLoading.dismiss(); handler.next(e); diff --git a/lib/common/utils/sync/sync_check_tool.dart b/lib/common/utils/sync/sync_check_tool.dart index 6de6e9a..3417910 100644 --- a/lib/common/utils/sync/sync_check_tool.dart +++ b/lib/common/utils/sync/sync_check_tool.dart @@ -270,6 +270,12 @@ class SyncCheckTool { cron: '0 * * * * *', idleOnly: false, ), + ScheduledTaskConfig( + type: ScheduledTaskType.autoUpdate, + enabled: true, + cron: '0 30 0 * * *', + idleOnly: true, + ), ScheduledTaskConfig( type: ScheduledTaskType.configSync, enabled: false, @@ -728,6 +734,11 @@ class SyncCheckTool { case ScheduledTaskType.deviceStatus: await AppDeviceStatusTool.refreshAll(); return const _ScheduledTaskActionResult.success('设备状态检查完成'); + case ScheduledTaskType.autoUpdate: + final update = await TerminalAutoUpdateTool.run(); + return update.skipped + ? _ScheduledTaskActionResult.skipped(update.message) + : _ScheduledTaskActionResult.success(update.message); case ScheduledTaskType.configSync: await SyncConfigTool.autoSyncSysInfo(); return const _ScheduledTaskActionResult.success('设备配置同步完成'); @@ -767,6 +778,7 @@ class SyncCheckTool { ScheduledTaskType.classifySync, ScheduledTaskType.productSync, ScheduledTaskType.offlineOrderSync, + ScheduledTaskType.autoUpdate, ScheduledTaskType.terminalLogUpload, ScheduledTaskType.expiredLogCleanup, }.contains(type); @@ -833,6 +845,11 @@ class SyncCheckTool { sourceType: ScheduledTaskSourceType.plugin, sourceName: '终端硬件插件', ), + ScheduledTaskType.autoUpdate => const _ScheduledTaskDescriptor( + title: 'APK 自动更新', + sourceType: ScheduledTaskSourceType.http, + sourceName: '版本接口、私有下载目录与 Root 安装服务', + ), ScheduledTaskType.configSync => const _ScheduledTaskDescriptor( title: '设备配置同步', sourceType: ScheduledTaskSourceType.http, diff --git a/lib/common/utils/sync/terminal_auto_update_tool.dart b/lib/common/utils/sync/terminal_auto_update_tool.dart new file mode 100644 index 0000000..4b1c7b4 --- /dev/null +++ b/lib/common/utils/sync/terminal_auto_update_tool.dart @@ -0,0 +1,283 @@ +import 'dart:io'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 自动更新检查的业务结果,未执行安装的正常条件以跳过状态返回。 +class TerminalAutoUpdateResult { + /// 创建一次自动更新检查结果。 + const TerminalAutoUpdateResult({ + required this.skipped, + required this.message, + }); + + /// 是否因无更新、未绑定或平台/网络条件不满足而安全跳过。 + final bool skipped; + + /// 写入任务最近结果和日志的中文摘要。 + final String message; +} + +/// 自动更新候选版本评估结果,区分可安装目标和不满足发布策略的安全跳过原因。 +class TerminalAutoUpdateDecision { + /// 创建一份包含最高优先级强制更新版本的评估结果。 + const TerminalAutoUpdateDecision.install(ServiceAppVersion this.target) + : skippedMessage = ''; + + /// 创建一份不执行安装并保留明确业务原因的评估结果。 + const TerminalAutoUpdateDecision.skip(this.skippedMessage) : target = null; + + /// 同平台、已发布、强制更新且版本码高于本机的最高版本;不满足时为空。 + final ServiceAppVersion? target; + + /// 没有安装目标时写入任务记录的原因;存在目标时为空字符串。 + final String skippedMessage; + + /// 是否已经选出允许自动安装的目标版本。 + bool get hasTarget => target != null; +} + +/// Android Root APK 全自动更新工具。 +abstract final class TerminalAutoUpdateTool { + /// 自动更新诊断日志使用的稳定模块标签。 + static const String _tag = '终端自动更新'; + + /// 应用私有支持目录下的固定子目录,更新确认或失败后删除 APK 文件。 + static const String _downloadDirectory = 'apk_updates'; + + /// 查询并安装最高的已发布新版本;安全前置条件不满足时不产生任何系统变更。 + static Future run() async { + if (!Platform.isAndroid) { + return const TerminalAutoUpdateResult( + skipped: true, + message: '非 Android 平台,本次跳过', + ); + } + final setting = await AppSettingTool.getSysSetting(); + if (setting.serviceId == null || setting.serviceId!.isEmpty) { + return const TerminalAutoUpdateResult( + skipped: true, + message: '终端尚未绑定,本次跳过', + ); + } + if (!AppNetworkTool.isAutoNetwork()) { + return const TerminalAutoUpdateResult( + skipped: true, + message: '网络不可用,本次跳过', + ); + } + + final package = await PackageInfo.fromPlatform(); + final currentVersionCode = int.tryParse(package.buildNumber) ?? 0; + final platform = AppPlatformEnum.terminalCode(setting.sysType); + final page = await AppApi.openCardList( + { + 'appUniqueCode': Constants.deviceUniqueCode, + 'platform': platform, + 'current': 1, + 'size': 8, + }, + silent: true, + ); + final decision = evaluateForcedUpdate( + versions: page?.records ?? const [], + currentVersionCode: currentVersionCode, + appUniqueCode: Constants.deviceUniqueCode, + platform: platform, + ); + if (!decision.hasTarget) { + return TerminalAutoUpdateResult( + skipped: true, + message: decision.skippedMessage, + ); + } + final target = decision.target!; + final targetVersionCode = target.versionCode!; + final downloadUrl = _resolveDownloadUrl(target.downloadUrl); + if (downloadUrl == null) { + throw StateError('最新版本缺少 APK 下载地址'); + } + + // 只有已经选出符合发布策略的新版本才检查 Root,避免无更新时产生无意义的授权动作。 + final root = await AppRuntimeTool.checkRootAccess(); + await AppSettingTool.setRootAccessResult(root); + if (!root.available) { + throw ScheduledTaskDependencyException('Root 不可用:${root.diagnostic}'); + } + + final task = DownloadTask( + url: downloadUrl, + filename: 'pj_xfj_build_$targetVersionCode.apk', + directory: _downloadDirectory, + baseDirectory: BaseDirectory.applicationSupport, + updates: Updates.statusAndProgress, + retries: 2, + ); + final apkPath = await task.filePath(); + final apkFile = File(apkPath); + if (await apkFile.exists()) await apkFile.delete(); + try { + // 进度回调频率由下载插件控制,按 10% 节点限流后写日志,避免大 APK 刷屏。 + var nextProgressToLog = 0; + final download = await FileDownloader().download( + task, + onProgress: (progress) { + if (!progress.isFinite || progress < 0 || progress > 1) return; + final percentage = (progress * 100).floor().clamp(0, 100).toInt(); + while (nextProgressToLog <= percentage && nextProgressToLog <= 100) { + logger.i( + '|$_tag|APK下载进度target:$targetVersionCode|progress:$nextProgressToLog%|', + ); + nextProgressToLog += 10; + } + }, + ); + if (download.status != TaskStatus.complete || !await apkFile.exists()) { + throw StateError('APK 下载失败(${download.status.name})'); + } + final validation = await AppRuntimeTool.validateApk( + path: apkPath, + expectedVersionCode: targetVersionCode, + ); + if (!validation.success) { + throw StateError('${validation.code}:${validation.message}'); + } + + // 必须先让服务端保留计划更新原因;请求失败时不进入可能导致进程退出的安装步骤。 + await TerminalHeartbeatTool.report( + scene: TerminalHeartbeatScene.autoUpdate, + throwOnError: true, + ); + final install = await AppRuntimeTool.installApkWithRoot( + path: apkPath, + expectedVersionCode: targetVersionCode, + ); + if (!install.success) { + throw StateError('${install.code}:${install.message}'); + } + return TerminalAutoUpdateResult( + skipped: false, + message: 'Build $targetVersionCode 安装命令已提交,等待新版本启动确认', + ); + } catch (error, stack) { + if (await apkFile.exists()) await apkFile.delete(); + logger + .e('|$_tag|更新失败target:$targetVersionCode|error:$error|stack:$stack|'); + rethrow; + } + } + + /// + /// 按自动任务协议选择版本码最高的强制更新版本。 + /// + /// 版本码只负责判断升级顺序;`status=1` 和 `isForce=1` 分别控制是否已经发布、是否允许 + /// 无人工确认自动安装。存在更高版本但策略不满足时返回具体原因,不能误报“已是最新”。 + static TerminalAutoUpdateDecision evaluateForcedUpdate({ + required Iterable versions, + required int currentVersionCode, + required String appUniqueCode, + required String platform, + }) { + final higherVersions = versions + .where( + (item) => + item.appUniqueCode == appUniqueCode && + item.platform == platform && + (item.versionCode ?? 0) > currentVersionCode, + ) + .toList() + ..sort(_compareVersionCodeDescending); + if (higherVersions.isEmpty) { + return const TerminalAutoUpdateDecision.skip('当前已是最新版本'); + } + + final publishedVersions = higherVersions + .where( + (item) => item.status == AppVersionStatusEnum.published.code, + ) + .toList(); + if (publishedVersions.isEmpty) { + return TerminalAutoUpdateDecision.skip( + '发现更高版本 ${_versionLabel(higherVersions.first)},但未处于已发布状态,本次跳过', + ); + } + + final forcedVersions = publishedVersions + .where((item) => item.isForce == true) + .toList() + ..sort(_compareVersionCodeDescending); + if (forcedVersions.isEmpty) { + return TerminalAutoUpdateDecision.skip( + '发现已发布的新版本 ${_versionLabel(publishedVersions.first)},但未标记为强制更新,本次跳过', + ); + } + return TerminalAutoUpdateDecision.install(forcedVersions.first); + } + + /// 按内部版本码倒序排列候选项,缺失版本码的异常数据按 0 处理。 + static int _compareVersionCodeDescending( + ServiceAppVersion left, + ServiceAppVersion right, + ) => + (right.versionCode ?? 0).compareTo(left.versionCode ?? 0); + + /// 组合任务日志使用的展示版本和内部 Build,缺失展示版本时仍保留可诊断的版本码。 + static String _versionLabel(ServiceAppVersion version) { + final versionName = version.versionName?.trim(); + final build = version.versionCode ?? 0; + return versionName == null || versionName.isEmpty + ? 'Build $build' + : '$versionName(Build $build)'; + } + + /// 启动时确认原生待更新记录,完成任务快照及被包替换中断的 running 日志。 + static Future recoverPendingUpdate() async { + final recovery = await AppRuntimeTool.consumePendingUpdate(); + if (!recovery.pending) return; + final type = ScheduledTaskType.autoUpdate; + final now = DateTime.now(); + final record = ScheduledTaskRecord( + lastRunAt: SyncCheckTool.instance.records[type]?.lastRunAt ?? now, + lastSuccessAt: recovery.success + ? now + : SyncCheckTool.instance.records[type]?.lastSuccessAt, + success: recovery.success, + message: recovery.message, + ); + SyncCheckTool.instance.records[type] = record; + await ScheduledTaskStorageTool.saveRecord( + type, + record, + recovery.success + ? ScheduledTaskRunStatus.success + : ScheduledTaskRunStatus.failed, + ); + final latest = await ScheduledTaskStorageTool.getLatestLog(type); + if (latest?.status == ScheduledTaskRunStatus.running.name) { + await ScheduledTaskStorageTool.finishLog( + latest!.finish( + nextStatus: recovery.success + ? ScheduledTaskRunStatus.success.name + : ScheduledTaskRunStatus.failed.name, + nextMessage: recovery.message, + at: now, + nextErrorType: recovery.success ? null : 'UpdateRecoveryFailed', + nextErrorMessage: recovery.success ? null : recovery.message, + ), + ); + } + if (recovery.success) await AppRuntimeTool.refreshKioskStatus(); + } + + /// 将后台文件键转换为实际下载地址,绝对 HTTP 地址保持不变。 + static String? _resolveDownloadUrl(String? source) { + final value = source?.trim(); + if (value == null || value.isEmpty) return null; + if (value.startsWith('http://') || value.startsWith('https://')) { + return value; + } + return '${Constants.ossUrl}/api/admin/sys-file/oss/file?fileName=$value'; + } +} diff --git a/lib/common/utils/sync/terminal_heartbeat_tool.dart b/lib/common/utils/sync/terminal_heartbeat_tool.dart new file mode 100644 index 0000000..5a88515 --- /dev/null +++ b/lib/common/utils/sync/terminal_heartbeat_tool.dart @@ -0,0 +1,168 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 终端心跳场景编码,与后端 TerminalHeartbeatSceneEnum 名称保持一致。 +enum TerminalHeartbeatScene { + /// 普通在线心跳,服务端收到后清除计划更新断开原因。 + online('ONLINE'), + + /// 自动更新安装前心跳,服务端在重启期间保留“自动更新重启”原因。 + autoUpdate('AUTO_UPDATE'); + + /// 写入心跳接口的固定协议编码。 + final String code; + + /// 创建一项跨端固定心跳场景。 + const TerminalHeartbeatScene(this.code); +} + +/// 自动更新前心跳失败的可展示异常,隐藏 Dio 协议细节并保留明确恢复动作。 +class TerminalHeartbeatException implements Exception { + /// 使用已经脱敏和限长的终端操作提示创建异常。 + const TerminalHeartbeatException(this.message); + + /// 可写入任务记录并直接展示给终端操作人员的短中文说明。 + final String message; + + /// 返回不含请求地址、堆栈和 Dio 实现细节的安全摘要。 + @override + String toString() => message; +} + +/// 食堂、超市 Android 终端心跳管理器。 +abstract final class TerminalHeartbeatTool { + /// 心跳诊断日志使用的稳定模块标签。 + static const String _tag = '终端心跳'; + + /// 服务端业务提示的最大展示长度,避免异常响应再次撑满 Toast 和任务列表。 + static const int _maxServiceMessageLength = 120; + + /// 终端在线阈值为 180 秒,客户端每 60 秒上报一次以容忍两次短暂失败。 + static const Duration _interval = Duration(seconds: 60); + + /// 当前进程唯一心跳计时器,应用控制器关闭时释放。 + static Timer? _timer; + + /// 启动独立心跳并立即上报一次;重复调用只会重建同一个计时器。 + static void start() { + _timer?.cancel(); + unawaited(report()); + _timer = Timer.periodic(_interval, (_) => unawaited(report())); + } + + /// 停止进程内心跳计时器。 + static void stop() { + _timer?.cancel(); + _timer = null; + } + + /// + /// 上报当前 APK、运行环境、Kiosk、Root 与外围硬件最新快照。 + /// + /// [throwOnError] 只供自动更新前的强一致场景使用;普通后台心跳失败仅写日志, + /// 不弹 Toast,也不影响收银流程。 + static Future report({ + TerminalHeartbeatScene scene = TerminalHeartbeatScene.online, + bool throwOnError = false, + }) async { + try { + if (!AppNetworkTool.isAutoNetwork()) return false; + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null || hardId.isEmpty) return false; + final package = await PackageInfo.fromPlatform(); + final network = await AppRuntimeTool.getNetworkSnapshot(); + final performance = await AppRuntimeTool.getPerformanceSnapshot(); + final kiosk = await AppRuntimeTool.refreshKioskStatus(); + final devices = AppDeviceStatusTool.status.value; + final versionCode = int.tryParse(package.buildNumber); + await HardApi.terminalHeartbeat( + hardId: hardId, + data: { + 'apkVersionName': package.version, + 'apkVersionCode': versionCode, + 'scene': scene.code, + 'systemUptimeSeconds': performance.systemUptimeSeconds, + 'appUptimeSeconds': performance.appUptimeSeconds, + 'networkType': network.type, + 'availableStorageBytes': performance.storageAvailableBytes, + 'rootStatus': setting.rootAccessStatus.name, + 'deviceOwner': kiosk.deviceOwner, + 'kioskActive': kiosk.active, + 'deviceStatus': { + 'printer': devices.printerAvailable, + 'steelyard': devices.steelyardAvailable, + 'cardReader': devices.cardReaderAvailable, + 'subScreen': devices.subScreenAvailable, + }, + }, + ); + return true; + } catch (error, stack) { + logger.w('|$_tag|上报失败scene:${scene.code}|error:$error|stack:$stack|'); + if (throwOnError) { + throw TerminalHeartbeatException(_toUserMessage(error)); + } + return false; + } + } + + /// 将心跳异常转换为包含原因和恢复动作的短提示,原始异常仅保留在诊断日志中。 + static String _toUserMessage(Object error) { + final serviceMessage = _extractServiceMessage(error); + if (serviceMessage != null && + (serviceMessage.contains('设备不存在') || + serviceMessage.contains('不属于当前租户'))) { + return '更新状态上报失败:设备不存在或不属于当前租户,请在“设置 > 基本信息”重新绑定设备'; + } + if (serviceMessage != null) { + return '更新状态上报失败:$serviceMessage'; + } + if (error is DioException) { + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return '更新状态上报超时,请检查网络后重试'; + case DioExceptionType.connectionError: + case DioExceptionType.badCertificate: + return '更新状态上报失败:无法连接服务器,请检查网络后重试'; + case DioExceptionType.badResponse: + final statusCode = error.response?.statusCode; + return statusCode == null + ? '更新状态上报失败:服务端返回异常,请稍后重试' + : '更新状态上报失败:服务端返回 HTTP $statusCode,请稍后重试'; + case DioExceptionType.cancel: + return '更新状态上报已取消,请重新执行任务'; + case DioExceptionType.unknown: + break; + } + } + return '更新状态上报失败,请稍后重试'; + } + + /// 从统一响应或静默请求拒绝原因中提取服务端业务提示,并压缩空白与限制长度。 + static String? _extractServiceMessage(Object error) { + if (error is! DioException) return null; + final data = error.response?.data; + Object? rawMessage; + if (data is Map) rawMessage = data['msg']; + if (rawMessage == null && + error.type == DioExceptionType.badResponse && + error.error is String) { + rawMessage = error.error; + } + final message = rawMessage?.toString().trim().replaceAll( + RegExp(r'\s+'), + ' ', + ); + if (message == null || message.isEmpty) return null; + return message.length <= _maxServiceMessageLength + ? message + : '${message.substring(0, _maxServiceMessageLength)}...'; + } +} diff --git a/lib/common/utils/sync/terminal_log_upload_tool.dart b/lib/common/utils/sync/terminal_log_upload_tool.dart index 6ee9a22..5820b64 100644 --- a/lib/common/utils/sync/terminal_log_upload_tool.dart +++ b/lib/common/utils/sync/terminal_log_upload_tool.dart @@ -35,7 +35,8 @@ class TerminalLogUploadTool { /// 扫描并上传终端日志,返回本次处理统计。 /// /// [currentOnly] 为真时只处理 Logger 当前写入文件,供 MQTT 实时拉取使用;每个文件都先生成 - /// 脱敏副本并通过预检确认是否需要上传,任一文件失败不会阻断后续文件处理。 + /// 脱敏副本并通过预检确认是否需要上传。空的脱敏副本会记为已处理并跳过网络请求,任一文件失败 + /// 不会阻断后续文件处理。 static Future upload({ bool currentOnly = false, }) async { @@ -94,24 +95,30 @@ class TerminalLogUploadTool { final sanitizedHash = (await md5.bind(temporaryFile.openRead()).first).toString(); final sanitizedSize = await temporaryFile.length(); - final needUpload = await _preflight( - hardId: hardId, - relativePath: item.relativePath, - fileSize: sanitizedSize, - fileHash: sanitizedHash, - ); String? serverFileId; - if (needUpload) { - serverFileId = await _uploadFile( + if (sanitizedSize == 0) { + // 空日志没有可供后台查看的内容,本机仍保存成功快照,避免定时任务反复处理同一空文件。 + skipped++; + logger.i('|终端日志上传|空日志无需上传path:${item.relativePath}|'); + } else { + final needUpload = await _preflight( hardId: hardId, relativePath: item.relativePath, - modifiedAt: stat.modified, - appVersion: appVersion, - file: temporaryFile, + fileSize: sanitizedSize, + fileHash: sanitizedHash, ); - succeeded++; - } else { - skipped++; + if (needUpload) { + serverFileId = await _uploadFile( + hardId: hardId, + relativePath: item.relativePath, + modifiedAt: stat.modified, + appVersion: appVersion, + file: temporaryFile, + ); + succeeded++; + } else { + skipped++; + } } final uploadedAt = DateTime.now(); lastSuccessAt = _latest(lastSuccessAt, uploadedAt); diff --git a/lib/common/widgets/toast/toast_widget.dart b/lib/common/widgets/toast/toast_widget.dart index db5bc9c..e8d57f1 100644 --- a/lib/common/widgets/toast/toast_widget.dart +++ b/lib/common/widgets/toast/toast_widget.dart @@ -4,61 +4,61 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:forui/forui.dart'; import 'package:get/get.dart'; +import 'package:pj_supermarket_cloud/common/style/index.dart'; + +/// 应用级轻量反馈入口,统一约束横屏终端上的提示宽度和文本行数。 class Toast { - static Widget _successTotal({ + /// Toast 最多占当前视口宽度的比例,给横屏和窄屏两侧保留稳定安全间距。 + static const double _maxWidthFactor = 0.82; + + /// Toast 最多展示的文本行数,完整异常仍由业务日志和任务记录保存。 + static const int _maxMessageLines = 3; + + /// 构建带语义颜色、图标和可换行文本的通用 Toast 内容。 + static Widget _content({ required BuildContext context, - String msg = "操作成功", + required String msg, + required IconData icon, + required Color backgroundColor, + required Color foregroundColor, }) { final theme = FTheme.of(context); - List widgets = [ - const Icon( - Icons.error, + final maxWidth = MediaQuery.sizeOf(context).width * _maxWidthFactor; + final content = [ + Icon( + icon, size: 35, - color: Colors.black, + color: foregroundColor, ), SizedBox(width: 10.w), - Text(msg, style: theme.typography.xl.copyWith(color: Colors.black)), + Text( + msg, + maxLines: _maxMessageLines, + overflow: TextOverflow.ellipsis, + softWrap: true, + style: theme.typography.xl.copyWith(color: foregroundColor), + ).flexible(), ]; - return Container( - padding: EdgeInsets.symmetric(horizontal: 30.w, vertical: 15.h), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(25.0), - color: Colors.greenAccent, - ), - child: widgets.toRow( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - ), - ); - } - - static Widget _errorTotal({ - required BuildContext context, - String msg = "操作失败", - }) { - final theme = FTheme.of(context); - List widgets = [ - const Icon( - Icons.error, - size: 35, - color: Colors.white, - ), - SizedBox(width: 10.w), - Text(msg, style: theme.typography.xl.copyWith(color: Colors.white)), - ]; - return Container( - padding: EdgeInsets.symmetric(horizontal: 30.w, vertical: 15.h), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(25.0), - color: Colors.red, - ), - child: widgets.toRow( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - ), + final toast = content + .toRow( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + ) + .padding(horizontal: 30.w, vertical: 15.h) + .decorated( + color: backgroundColor, + borderRadius: BorderRadius.circular(25), + ) + .constrained(maxWidth: maxWidth); + return Semantics( + container: true, + liveRegion: true, + label: msg, + child: ExcludeSemantics(child: toast), ); } + /// 调用平台原生 Toast,供不需要自定义图标布局的简单反馈使用。 static Future base({ required String msg, Color backgroundColor = Colors.black, @@ -75,28 +75,36 @@ class Toast { ); } - static success({String msg = "操作成功"}) { - final context = Get.context!; + /// 在顶部展示成功提示,长文案自动换行且不会撑出视口。 + static void success({String msg = "操作成功"}) { + final context = Get.overlayContext ?? Get.context!; final fToast = FToast(); fToast.init(context); fToast.showToast( gravity: ToastGravity.TOP, - child: _successTotal( + child: _content( context: context, msg: msg, + icon: Icons.check_circle, + backgroundColor: XfjDesignTokens.success, + foregroundColor: Colors.white, ), ); } - static error({String msg = "操作失败"}) { - final context = Get.context!; + /// 在顶部展示失败提示,详细异常由调用方日志保存而不直接铺满界面。 + static void error({String msg = "操作失败"}) { + final context = Get.overlayContext ?? Get.context!; final fToast = FToast(); fToast.init(context); fToast.showToast( gravity: ToastGravity.TOP, - child: _errorTotal( + child: _content( context: context, msg: msg, + icon: Icons.error, + backgroundColor: XfjDesignTokens.danger, + foregroundColor: Colors.white, ), ); } diff --git a/lib/pages/root/login/controller.dart b/lib/pages/root/login/controller.dart index c789386..bc9632c 100644 --- a/lib/pages/root/login/controller.dart +++ b/lib/pages/root/login/controller.dart @@ -61,6 +61,18 @@ class LoginController extends GetxController { _initData(); } + /// 登录页面的 Widget 子树完全卸载后释放表单与租户选择监听。 + /// + /// 调用时机由页面内的局部 GetBuilder 管理,不能由路由 Binding 提前触发,否则退场动画 + /// 中尚未卸载的 FTextField 仍可能处理触摸选区并访问已经释放的输入控制器。 + @override + void onClose() { + tenantC.dispose(); + usernameC.dispose(); + passwordC.dispose(); + super.onClose(); + } + Future doLogin(bool visibleMsg, {bool automatic = false}) async { if (automatic && _manualInputStarted) return; if (!_validateForm()) return; diff --git a/lib/pages/root/login/view.dart b/lib/pages/root/login/view.dart index 9d11a02..17a012d 100644 --- a/lib/pages/root/login/view.dart +++ b/lib/pages/root/login/view.dart @@ -7,15 +7,22 @@ import 'package:pj_supermarket_cloud/common/index.dart'; import 'controller.dart'; -class LoginPage extends GetView { +/// 终端登录页面,每次进入页面都创建独立且仅归当前 Widget 子树所有的 Controller。 +class LoginPage extends StatelessWidget { + /// 创建登录页面,表单控制器随页面子树完成退场后统一释放。 const LoginPage({super.key}); + /// 使用局部 GetBuilder 避免 GetX 在路由退场动画完成前提前销毁输入控制器。 @override Widget build(BuildContext context) { return GetBuilder( init: LoginController(), + global: false, id: 'login', - builder: (_) => FScaffold( + // GetX 的路由 Binding 会在路由移除时立即执行 onClose,但退场动画中的输入框仍可能处理手势。 + // 局部 GetBuilder 在整个子树卸载后再关闭 Controller,可同时避免重复登录复用旧实例。 + dispose: (state) => state.controller?.onDelete(), + builder: (loginController) => FScaffold( contentPad: false, resizeToAvoidBottomInset: true, content: ColoredBox( @@ -43,7 +50,7 @@ class LoginPage extends GetView { key: const ValueKey('login-form-panel'), flex: keyboardVisible ? 1 : 9, child: _LoginFormPanel( - controller: controller, + controller: loginController, compact: keyboardVisible, ), ), diff --git a/lib/pages/terminal/setting/controller.dart b/lib/pages/terminal/setting/controller.dart index fd33858..0972c68 100644 --- a/lib/pages/terminal/setting/controller.dart +++ b/lib/pages/terminal/setting/controller.dart @@ -30,6 +30,18 @@ class SettingController extends GetxController with WidgetsBindingObserver { /// Android Kiosk 当前能力与运行状态,应用恢复前台时刷新。 final kioskStatus = AppRuntimeTool.kioskStatus.obs; + /// Root 手动刷新是否正在执行,用于禁用重复点击并展示局部进度。 + final rootRefreshing = false.obs; + + /// Root 一键配置 Device Owner 是否正在执行,命令期间禁止重复提交。 + final kioskProvisioning = false.obs; + + /// Kiosk 开关是否正在同步原生 Lock Task,防止响应返回前被连续点击形成状态反转。 + final kioskSwitching = false.obs; + + /// 最近一次 Root 配置 Kiosk 的失败处理建议;成功或重新加载设置后清空。 + final kioskProvisionMessage = ''.obs; + /// 收银支付方式的界面排序,初始顺序来自枚举并由本地持久化覆盖。 final payTypeOrder = [ ...OrderPayDisplayType.values, @@ -338,6 +350,75 @@ class SettingController extends GetxController with WidgetsBindingObserver { kioskStatus.value = await AppRuntimeTool.refreshKioskStatus(); } + /// 立即执行真实 Root 命令并局部持久化结果;失败状态也作为有效检查结果保存。 + Future refreshRootAccess({bool showFeedback = true}) async { + if (rootRefreshing.value) return; + rootRefreshing.value = true; + try { + final result = await AppRuntimeTool.checkRootAccess(); + await AppSettingTool.setRootAccessResult(result); + final setting = sysSetting.value; + if (setting != null) { + setting.rootAccessStatus = result.status; + setting.rootAccessCheckedAt = result.checkedAt; + setting.rootAccessDiagnostic = result.diagnostic; + sysSetting.refresh(); + } + if (showFeedback) { + if (result.available) { + Toast.success(msg: 'Root 权限可用'); + } else { + Toast.error(msg: result.diagnostic); + } + } + } catch (e, stack) { + logger.e('|$_tag|刷新Root状态失败error:|$e|stack:|$stack|'); + if (showFeedback) Toast.error(msg: 'Root 状态检查失败,请稍后重试'); + } finally { + rootRefreshing.value = false; + } + } + + /// 经用户二次确认后通过 Root 配置 Device Owner;不删除账户、不移除其他 Owner 或修改系统策略文件。 + Future enableKioskWithRoot() async { + final setting = sysSetting.value; + if (setting == null || kioskProvisioning.value) return; + if (setting.rootAccessStatus != RootAccessStatus.available) { + kioskProvisionMessage.value = 'Root 不可用,请先刷新 Root 状态并完成授权'; + return; + } + final confirmed = await appMessageDialog( + Get.context!, + '将使用 Root 把当前应用配置为 Android Device Owner,并立即进入 Kiosk。该操作不会删除账户或移除其他 Device Owner,系统拒绝时只会返回原因。是否继续?', + ); + if (!confirmed) return; + + kioskProvisioning.value = true; + kioskProvisionMessage.value = ''; + EasyLoading.show( + status: '正在启用专用设备模式...', + maskType: EasyLoadingMaskType.black, + ); + try { + final result = await AppRuntimeTool.enableKioskWithRoot(); + kioskStatus.value = result.status; + if (result.success) { + kioskProvisionMessage.value = ''; + Toast.success(msg: 'Kiosk 已启用并自动进入'); + } else { + kioskProvisionMessage.value = result.message; + Toast.error(msg: result.message); + } + } catch (e, stack) { + logger.e('|$_tag|Root启用Kiosk失败error:|$e|stack:|$stack|'); + kioskProvisionMessage.value = '启用失败,请稍后重试并检查系统 Device Owner 状态'; + Toast.error(msg: kioskProvisionMessage.value); + } finally { + EasyLoading.dismiss(); + kioskProvisioning.value = false; + } + } + /// 保存全局字体倍率并立即应用到运行中主题。 Future setGlobalFontSize(SettingGlobalFontSize fontSize) async { final setting = sysSetting.value; @@ -550,6 +631,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { syncFailedKeys.remove('deviceStatus'); try { await AppDeviceStatusTool.refreshAll(); + sysSetting.value = await AppSettingTool.getSysSetting(); Toast.success(msg: '设备状态检查完成'); } on TerminalDeviceCheckCancelled { // 用户主动取消不属于检查失败,保留上一次已经确认的设备状态。 @@ -806,19 +888,23 @@ class SettingController extends GetxController with WidgetsBindingObserver { Toast.success(msg: enabled ? '已设置为主控手动输入模式' : '已恢复真实硬件模式'); } - /// 该配置只控制进入 App 时是否自动锁定;关闭后仍需通过管理密码退出当前 Kiosk。 + /// 立即启停当前 Kiosk,并同步保存以后进入 App 时是否自动恢复 Lock Task。 Future setKioskAutoEnterEnabled(bool enabled) async { - if (!kioskStatus.value.available) return; - final status = await AppRuntimeTool.setKioskAutoEnterEnabled(enabled); - kioskStatus.value = status; - final applied = status.autoEnterEnabled == enabled && - (!enabled || status.active || status.lockTaskPermitted); - if (!applied) { - Toast.error(msg: 'Kiosk 自动开启配置失败,请检查设备策略'); - return; + if (!kioskStatus.value.available || kioskSwitching.value) return; + kioskSwitching.value = true; + try { + final status = await AppRuntimeTool.setKioskAutoEnterEnabled(enabled); + kioskStatus.value = status; + final applied = + status.autoEnterEnabled == enabled && status.active == enabled; + if (!applied) { + Toast.error(msg: enabled ? 'Kiosk 未能立即开启,请检查设备策略' : 'Kiosk 未能立即关闭,请重试'); + return; + } + Toast.success(msg: enabled ? 'Kiosk 已立即开启' : 'Kiosk 已立即关闭'); + } finally { + kioskSwitching.value = false; } - Toast.success( - msg: enabled ? '每次进入 App 将自动开启 Kiosk' : '已关闭进入 App 自动开启 Kiosk'); } /// 管理密码验证由按钮组件负责,此处只处理原生退出结果与失败反馈。 diff --git a/lib/pages/terminal/setting/view.dart b/lib/pages/terminal/setting/view.dart index 6e17eff..010b6c7 100644 --- a/lib/pages/terminal/setting/view.dart +++ b/lib/pages/terminal/setting/view.dart @@ -110,13 +110,6 @@ class _SettingMenu extends StatelessWidget { ), ], ), - SizedBox(height: 8.h), - Text( - bound ? '部分设置修改后会自动同步到本机服务。' : '请先在基本信息中绑定设备后使用业务设置。', - style: theme.typography.sm.copyWith( - color: XfjDesignTokens.muted, - ), - ), ], ), ), diff --git a/lib/pages/terminal/setting/widgets/setting-version-setting.dart b/lib/pages/terminal/setting/widgets/setting-version-setting.dart index 2876a3d..c5f7f4e 100644 --- a/lib/pages/terminal/setting/widgets/setting-version-setting.dart +++ b/lib/pages/terminal/setting/widgets/setting-version-setting.dart @@ -1,40 +1,69 @@ -import 'dart:convert'; +import 'dart:async'; import 'dart:io'; +import 'dart:math' as math; import 'package:background_downloader/background_downloader.dart'; import 'package:common_utils/common_utils.dart'; import 'package:ducafe_ui_core/ducafe_ui_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:flutter_html/flutter_html.dart'; import 'package:forui/forui.dart'; import 'package:get/get.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; import 'setting_components.dart'; +/// 版本管理页面控制器,负责终端平台精确查询、当前版本识别和人工下载。 class VersionManageController extends GetxController { + /// 版本页诊断日志使用的稳定模块标签。 static const String _tag = '版本管理 Controller'; + /// 当前服务端分页页码,从 1 开始。 final currentPage = 1.obs; - final totalPage = 0.obs; - final pageSize = 3.obs; + + /// 服务端返回的版本总条数。 + final total = 0.obs; + + /// 紧凑列表固定每页展示 8 条。 + final pageSize = 8.obs; + + /// 当前页中与本机 App 编码、终端平台精确匹配的已发布版本。 final versionList = [].obs; + + /// 当前是否正在读取版本列表。 final isLoading = false.obs; + + /// 列表加载失败时展示的可恢复错误摘要。 final errorMessage = ''.obs; + /// 当前安装 APK 的内部版本码,用于标记“当前版本”并禁止误判升级。 + final currentVersionCode = 0.obs; + + /// 初始化当前安装版本后加载第一页数据。 @override void onInit() { super.onInit(); - getVersionList(); + unawaited(_initialize()); } + /// 先读取本机 Build,再请求终端平台版本列表,避免首帧标识跳变。 + Future _initialize() async { + final package = await PackageInfo.fromPlatform(); + currentVersionCode.value = int.tryParse(package.buildNumber) ?? 0; + await getVersionList(); + } + + /// 切换页码或刷新当前页。 Future doPage({int? page}) async { if (page != null) currentPage.value = page; await getVersionList(showLoading: true); } + /// 根据本机 SysType 传递精确平台编码,只读取服务端返回的已发布版本。 Future getVersionList({bool showLoading = false}) async { isLoading.value = true; errorMessage.value = ''; @@ -42,24 +71,39 @@ class VersionManageController extends GetxController { EasyLoading.show(status: '查询中...', maskType: EasyLoadingMaskType.black); } try { + final setting = await AppSettingTool.getSysSetting(); final data = await AppApi.openCardList({ 'appUniqueCode': Constants.deviceUniqueCode, + 'platform': AppPlatformEnum.terminalCode(setting.sysType), 'current': currentPage.value, 'size': pageSize.value, }); - logger.d('|$_tag|获取版本列表成功:${json.encode(data)}'); versionList.assignAll(data?.records ?? const []); - totalPage.value = data?.total ?? 0; - } catch (e, stack) { + total.value = data?.total ?? 0; + } catch (error, stack) { versionList.clear(); errorMessage.value = '版本列表加载失败,请检查网络后重试'; - logger.e('|$_tag|获取版本列表失败error:|$e|stack:|$stack|'); + logger.e('|$_tag|获取版本列表失败error:|$error|stack:|$stack|'); } finally { isLoading.value = false; if (showLoading) EasyLoading.dismiss(); } } + /// 打开右侧版本详情抽屉,列表本身不承载更新说明和下载操作。 + Future openDetails( + BuildContext context, + ServiceAppVersion item, + ) async { + await _showVersionDrawer( + context, + item: item, + currentVersionCode: currentVersionCode.value, + onDownload: () => doDownload(item.downloadUrl ?? ''), + ); + } + + /// 人工下载继续保存到系统下载目录,不参与 Root 自动安装流程。 Future doDownload(String url) async { if (url.isEmpty) { Toast.error(msg: '当前版本没有可用下载地址'); @@ -85,8 +129,7 @@ class VersionManageController extends GetxController { } final ext = AppFileTool.getFileExtension(downUrl); final useExt = ObjectUtil.isEmpty(ext) ? '.apk' : ext; - final now = DateTime.now(); - final fileName = 'pj_xfj_${now.millisecondsSinceEpoch}$useExt'; + final fileName = 'pj_xfj_${DateTime.now().millisecondsSinceEpoch}$useExt'; final task = DownloadTask( url: downUrl, filename: fileName, @@ -116,10 +159,10 @@ class VersionManageController extends GetxController { EasyLoading.showError('移动到下载目录失败'); return; } - logger.i('|$_tag|下载完成:$movedPath'); + logger.i('|$_tag|人工下载完成:$movedPath'); EasyLoading.showSuccess('下载完成'); - } catch (e, stack) { - logger.e('|$_tag|下载失败error:|$e|stack:|$stack|'); + } catch (error, stack) { + logger.e('|$_tag|下载失败error:|$error|stack:|$stack|'); EasyLoading.showError('下载失败'); } finally { EasyLoading.dismiss(); @@ -127,85 +170,88 @@ class VersionManageController extends GetxController { } } +/// 构建每页 8 条的紧凑版本列表,点击行后从右侧展开完整详情。 Widget settingVersionSetting(BuildContext context) { final controller = Get.isRegistered() ? Get.find() : Get.put(VersionManageController()); - return Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - SettingActionButton( - label: '刷新版本列表', - icon: FAssets.icons.refreshCw, - primary: true, - onTap: controller.doPage, + return [ + SettingActionButton( + label: '刷新版本列表', + icon: FAssets.icons.refreshCw, + primary: true, + onTap: controller.doPage, + ).alignRight(), + Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + if (controller.errorMessage.isNotEmpty) { + return SettingEmpty(message: controller.errorMessage.value); + } + if (controller.versionList.isEmpty) { + return const SettingEmpty(message: '暂无已发布版本'); + } + return SettingGroup( + title: '已发布版本', + child: List.generate( + controller.versionList.length, + (index) { + final item = controller.versionList[index]; + return _VersionRow( + item: item, + latest: controller.currentPage.value == 1 && index == 0, + current: item.versionCode == controller.currentVersionCode.value, + onTap: () => controller.openDetails(context, item), + ); + }, + growable: false, + ).toColumn( + separator: const Divider( + height: 1, + color: XfjDesignTokens.line, ), - ], - ), - SizedBox(height: 20.h), - Expanded( - child: Obx(() { - if (controller.isLoading.value) { - return const Center(child: CircularProgressIndicator()); - } - if (controller.errorMessage.isNotEmpty) { - return SettingEmpty(message: controller.errorMessage.value); - } - if (controller.versionList.isEmpty) { - return const SettingEmpty(message: '暂无版本信息'); - } - return GridView.builder( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: XfjDesignTokens.cardGap.w, - mainAxisSpacing: XfjDesignTokens.cardGap.h, - childAspectRatio: 1.25, - ), - itemCount: controller.versionList.length, - itemBuilder: (context, index) => _VersionCard( - item: controller.versionList[index], - recommended: index == 0, - onDownload: () => controller.doDownload( - controller.versionList[index].downloadUrl ?? '', - ), - ), - ); - }), - ), - SizedBox(height: XfjDesignTokens.cardGap.h), - Obx( - () => appPage( - context, - pageNo: controller.currentPage.value, - pageSize: controller.pageSize.value, - total: controller.totalPage.value, - onPageChanged: (pageNo) => controller.doPage(page: pageNo), ), + ); + }).expanded(), + Obx( + () => appPage( + context, + pageNo: controller.currentPage.value, + pageSize: controller.pageSize.value, + total: controller.total.value, + onPageChanged: (pageNo) => controller.doPage(page: pageNo), ), - SizedBox(height: XfjDesignTokens.cardGap.h), - SettingNotice( - text: '下载需要 Android 存储权限。下载完成后仍需按照终端发布流程确认版本来源并执行安装。', - background: Color(0xFFFFF9E8), - borderColor: Color(0x4DF2A817), - icon: FAssets.icons.triangleAlert, - ), - ], + ), + ].toColumnSpace( + crossAxisAlignment: CrossAxisAlignment.stretch, + space: XfjDesignTokens.cardGap.h, ); } -class _VersionCard extends StatelessWidget { - const _VersionCard({ +/// 单条版本只展示版本名、Build、平台、发布状态和当前/最新标识。 +class _VersionRow extends StatelessWidget { + /// 创建一个具有稳定高度和完整点击热区的版本行。 + const _VersionRow({ required this.item, - required this.recommended, - required this.onDownload, + required this.latest, + required this.current, + required this.onTap, }); + /// 服务端版本记录。 final ServiceAppVersion item; - final bool recommended; - final VoidCallback onDownload; + /// 是否为第一页版本码最高记录。 + final bool latest; + + /// 是否与当前安装 Build 相同。 + final bool current; + + /// 打开右侧详情抽屉的回调。 + final VoidCallback onTap; + + /// 构建固定列宽的紧凑扫描行。 @override Widget build(BuildContext context) { final theme = FTheme.of(context); @@ -214,129 +260,279 @@ class _VersionCard extends StatelessWidget { final status = AppVersionStatusEnum.values.fromCode(item.status ?? '')?.title ?? '未知状态'; - return Container( - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: XfjDesignTokens.surface, - border: Border.all(color: XfjDesignTokens.line), - borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), - boxShadow: XfjDesignTokens.surfaceShadow, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Container( - padding: EdgeInsets.all(20.w), - decoration: const BoxDecoration( - border: Border(bottom: BorderSide(color: XfjDesignTokens.line)), - ), - child: Row( - children: [ - Container( - width: 52.w, - height: 52.w, - alignment: Alignment.center, - decoration: BoxDecoration( - color: XfjDesignTokens.primarySoft, - borderRadius: BorderRadius.circular( - XfjDesignTokens.controlRadius, - ), - ), - child: FIcon( - FAssets.icons.rocket, - color: XfjDesignTokens.primary, - size: 28.sp, - ), - ), - SizedBox(width: 14.w), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '版本 ${item.versionName ?? '--'}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.typography.xl.copyWith( - color: XfjDesignTokens.ink, - fontWeight: FontWeight.w800, - ), - ), - SizedBox(height: 4.h), - Text( - '$platform · Build ${item.versionCode ?? '--'}', - style: theme.typography.sm.copyWith( - color: XfjDesignTokens.muted, - ), - ), - ], - ), - ), - Container( - padding: EdgeInsets.symmetric( - horizontal: 10.w, - vertical: 5.h, - ), - decoration: BoxDecoration( - color: recommended - ? XfjDesignTokens.primarySoft - : XfjDesignTokens.control, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - recommended ? '推荐更新' : status, - style: theme.typography.sm.copyWith( - color: recommended - ? XfjDesignTokens.primary - : XfjDesignTokens.muted, - ), - ), - ), - ], + return InkWell( + onTap: onTap, + child: [ + [ + Text( + item.versionName ?? '--', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, ), ), - Expanded( - child: Padding( - padding: EdgeInsets.all(20.w), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '更新内容', - style: theme.typography.base.copyWith( - color: XfjDesignTokens.ink, - fontWeight: FontWeight.w700, - ), - ), - SizedBox(height: 10.h), - Expanded( - child: Text( - item.updateContent ?? '暂无更新内容', - maxLines: 5, - overflow: TextOverflow.ellipsis, - style: theme.typography.sm.copyWith( - color: XfjDesignTokens.muted, - height: 1.5, - ), - ), - ), - SizedBox(height: 12.h), - SizedBox( - width: double.infinity, - child: SettingActionButton( - label: '下载 APK', - icon: FAssets.icons.arrowDownToLine, - primary: recommended, - enabled: ObjectUtil.isNotEmpty(item.downloadUrl), - onTap: onDownload, - ), - ), - ], - ), + Text( + 'Build ${item.versionCode ?? '--'}', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], ), ), - ], - ), + ] + .toColumnSpace( + crossAxisAlignment: CrossAxisAlignment.start, + space: 4.h, + ) + .expanded(flex: 3), + Text(platform, style: theme.typography.sm).expanded(flex: 2), + _versionBadge(status, XfjDesignTokens.success), + [ + if (current) _versionBadge('当前', XfjDesignTokens.primary), + if (latest) _versionBadge('最新', XfjDesignTokens.warning), + if (!current && !latest) + Text('--', + style: + theme.typography.sm.copyWith(color: XfjDesignTokens.muted)), + ] + .toRowSpace(mainAxisSize: MainAxisSize.min, space: 8.w) + .expanded(flex: 2), + FIcon( + FAssets.icons.chevronRight, + color: XfjDesignTokens.muted, + size: 20.sp, + ), + ] + .toRowSpace(space: 18.w) + .padding(horizontal: 20.w, vertical: 10.h) + .constrained(minHeight: 68.h), ); } } + +/// 构建同时带文字与语义色的版本状态标签,避免只依靠颜色表达状态。 +Widget _versionBadge(String label, Color color) => Text( + label, + maxLines: 1, + style: TextStyle( + color: color, + fontSize: 13.sp, + fontWeight: FontWeight.w700, + ), + ).padding(horizontal: 10.w, vertical: 5.h).decorated( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ); + +/// 从右侧展示完整版本信息与静态 HTML 更新内容,遮罩点击和关闭图标均可退出。 +Future _showVersionDrawer( + BuildContext context, { + required ServiceAppVersion item, + required int currentVersionCode, + required VoidCallback onDownload, +}) async { + await showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: '关闭版本详情', + barrierColor: Colors.black.withValues(alpha: 0.45), + transitionDuration: const Duration(milliseconds: 220), + pageBuilder: (dialogContext, _, __) { + final width = math.min(MediaQuery.sizeOf(context).width * 0.52, 960.w); + return Align( + alignment: Alignment.centerRight, + child: Material( + color: XfjDesignTokens.surface, + child: SafeArea( + child: _VersionDetail( + item: item, + currentVersionCode: currentVersionCode, + onClose: () => Navigator.of(dialogContext).pop(), + onDownload: onDownload, + ).width(width), + ), + ), + ); + }, + transitionBuilder: (_, animation, __, child) => SlideTransition( + position: Tween( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)), + child: child, + ), + ); +} + +/// 右侧抽屉内容,详情与 HTML 使用单一滚动区域,底部下载操作保持可见。 +class _VersionDetail extends StatelessWidget { + /// 创建版本详情内容。 + const _VersionDetail({ + required this.item, + required this.currentVersionCode, + required this.onClose, + required this.onDownload, + }); + + /// 服务端版本记录。 + final ServiceAppVersion item; + + /// 当前安装版本码,用于详情状态说明。 + final int currentVersionCode; + + /// 关闭抽屉回调。 + final VoidCallback onClose; + + /// 人工下载 APK 回调。 + final VoidCallback onDownload; + + /// 构建标题、元数据、静态 HTML 与底部操作。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final platform = + AppPlatformEnum.values.fromCode(item.platform ?? '')?.title ?? '未知平台'; + final status = + AppVersionStatusEnum.values.fromCode(item.status ?? '')?.title ?? + '未知状态'; + return [ + [ + [ + Text( + '版本 ${item.versionName ?? '--'}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xl2.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + Text( + 'Build ${item.versionCode ?? '--'}', + style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted), + ), + ] + .toColumnSpace( + crossAxisAlignment: CrossAxisAlignment.start, + space: 4.h, + ) + .expanded(), + IconButton( + tooltip: '关闭版本详情', + onPressed: onClose, + icon: FIcon(FAssets.icons.x, size: 22.sp), + ), + ] + .toRowSpace(space: 12.w) + .padding(horizontal: 24.w, vertical: 18.h) + .decorated( + border: + const Border(bottom: BorderSide(color: XfjDesignTokens.line)), + ), + [ + _detailLine(context, '平台', platform), + _detailLine(context, '状态', status), + _detailLine(context, 'App 编码', item.appUniqueCode ?? '--'), + _detailLine(context, '强制更新', item.isForce == true ? '是' : '否'), + _detailLine(context, '静默更新', item.isSilent == true ? '是' : '否'), + _detailLine( + context, + '本机状态', + item.versionCode == currentVersionCode ? '当前版本' : '非当前版本', + ), + Text( + '更新内容', + style: theme.typography.lg.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ).paddingOnly(top: 16.h, bottom: 6.h), + Html( + data: ObjectUtil.isEmpty(item.updateContent) + ? '

暂无更新内容

' + : item.updateContent!, + shrinkWrap: true, + // 只允许静态文本排版标签;a 标签保留文字但回调不执行任何外链跳转。 + onlyRenderTheseTags: const { + 'html', + 'body', + 'div', + 'span', + 'p', + 'br', + 'ul', + 'ol', + 'li', + 'strong', + 'b', + 'em', + 'i', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'blockquote', + 'a', + }, + doNotRenderTheseTags: const { + 'script', + 'iframe', + 'object', + 'embed', + 'form', + 'input', + 'button', + }, + onLinkTap: (_, __, ___) {}, + style: { + 'body': Style( + color: XfjDesignTokens.ink, + fontSize: FontSize(15.sp), + lineHeight: const LineHeight(1.6), + margin: Margins.zero, + ), + 'p': Style(margin: Margins.only(bottom: 10.h)), + 'li': Style(margin: Margins.only(bottom: 6.h)), + }, + ), + ] + .toColumn(crossAxisAlignment: CrossAxisAlignment.stretch) + .padding(horizontal: 24.w, vertical: 18.h) + .scrollable() + .expanded(), + SettingActionButton( + label: '下载 APK', + icon: FAssets.icons.arrowDownToLine, + primary: true, + enabled: ObjectUtil.isNotEmpty(item.downloadUrl), + onTap: onDownload, + ).alignRight().padding(horizontal: 24.w, vertical: 16.h).decorated( + border: const Border(top: BorderSide(color: XfjDesignTokens.line)), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch); + } +} + +/// 构建抽屉元数据键值行,长值允许换行且不会挤压标签。 +Widget _detailLine(BuildContext context, String label, String value) { + final theme = FTheme.of(context); + return [ + Text( + label, + style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted), + ).width(130.w), + Text( + value, + textAlign: TextAlign.right, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ).expanded(), + ] + .toRowSpace(crossAxisAlignment: CrossAxisAlignment.start, space: 16.w) + .padding(vertical: 8.h); +} diff --git a/lib/pages/terminal/setting/widgets/setting_baseInfo.dart b/lib/pages/terminal/setting/widgets/setting_baseInfo.dart index 1f09d37..e552f56 100644 --- a/lib/pages/terminal/setting/widgets/setting_baseInfo.dart +++ b/lib/pages/terminal/setting/widgets/setting_baseInfo.dart @@ -70,6 +70,7 @@ Future _bindServiceDialog(BuildContext context, SysSetting sys) async { final controller = Get.find(); await controller.getSetting(); Toast.success(msg: '绑定成功'); + unawaited(TerminalHeartbeatTool.report()); if (dialogContext.mounted) Navigator.of(dialogContext).pop(); unawaited(_refreshMqAfterBinding()); } catch (e, stack) { @@ -151,19 +152,78 @@ Widget settingBaseInfo(BuildContext context) { ? 'Android 虚拟机' : 'Android 真实设备', ), + SettingInfoRow( + icon: FAssets.icons.smartphone, + label: 'Android 版本', + value: AppRuntimeTool.androidVersionLabel, + ), + SettingInfoRow( + icon: FAssets.icons.package, + label: 'APK 版本', + value: AppRuntimeTool.apkVersionLabel, + ), + SettingInfoRow( + icon: sys.rootAccessStatus == RootAccessStatus.available + ? FAssets.icons.shieldCheck + : FAssets.icons.shieldAlert, + label: 'Root 权限', + subtitle: _rootAccessDescription(sys), + value: _rootAccessLabel(sys.rootAccessStatus), + trailing: Tooltip( + message: '重新检查 Root 权限', + child: IconButton( + tooltip: '重新检查 Root 权限', + onPressed: controller.rootRefreshing.value + ? null + : controller.refreshRootAccess, + icon: controller.rootRefreshing.value + ? SizedBox.square( + dimension: 20.w, + child: const CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : FIcon( + FAssets.icons.refreshCw, + color: XfjDesignTokens.primary, + size: 20.sp, + ), + ), + ), + ), SettingInfoRow( icon: controller.kioskStatus.value.available ? FAssets.icons.shieldCheck : FAssets.icons.circleAlert, label: '专用设备 / Kiosk', - subtitle: controller.kioskStatus.value.description, + subtitle: controller.kioskProvisionMessage.isNotEmpty + ? controller.kioskProvisionMessage.value + : controller.kioskStatus.value.description, value: controller.kioskStatus.value.statusLabel, - trailing: FSwitch( - semanticLabel: '进入 App 自动开启 Kiosk', - value: controller.kioskStatus.value.autoEnterEnabled, - enabled: controller.kioskStatus.value.available, - onChange: controller.setKioskAutoEnterEnabled, - ), + trailing: controller.kioskStatus.value.available + ? FSwitch( + semanticLabel: '立即开启或关闭 Kiosk', + value: controller.kioskStatus.value.autoEnterEnabled, + enabled: !controller.kioskSwitching.value, + onChange: controller.setKioskAutoEnterEnabled, + ) + : Tooltip( + message: + sys.rootAccessStatus == RootAccessStatus.available + ? '使用 Root 配置 Device Owner 并进入 Kiosk' + : 'Root 不可用,无法自动配置 Device Owner', + child: SettingActionButton( + label: controller.kioskProvisioning.value + ? '正在启用' + : '启用 Kiosk', + icon: FAssets.icons.shieldCheck, + primary: true, + enabled: sys.rootAccessStatus == + RootAccessStatus.available && + !controller.kioskProvisioning.value, + onTap: controller.enableKioskWithRoot, + ), + ), ), SettingInfoRow( icon: FAssets.icons.keyboard, @@ -237,6 +297,25 @@ Widget settingBaseInfo(BuildContext context) { }); } +/// 将 Root 持久化状态转换为基本信息页短标签。 +String _rootAccessLabel(RootAccessStatus status) => switch (status) { + RootAccessStatus.unknown => '未检查', + RootAccessStatus.available => '可用', + RootAccessStatus.unavailable => '不可用', + }; + +/// 组合 Root 最近检查时间和诊断摘要,旧配置保持明确的未检查提示。 +String _rootAccessDescription(SysSetting sys) { + final checkedAt = sys.rootAccessCheckedAt; + final checkedLabel = checkedAt == null + ? '尚未执行真实 Root 命令检查' + : '最近检查 ${DateUtil.formatDate(checkedAt, format: DateFormats.full)}'; + final diagnostic = sys.rootAccessDiagnostic?.trim(); + return diagnostic == null || diagnostic.isEmpty + ? checkedLabel + : '$checkedLabel · $diagnostic'; +} + /// 左侧菜单只允许从手柄直接拖动,并提供上下移动按钮作为精确操作和无障碍替代。 class _TerminalMenuEditor extends StatelessWidget { const _TerminalMenuEditor(); diff --git a/lib/pages/terminal/setting/widgets/setting_cron_rule_dialog.dart b/lib/pages/terminal/setting/widgets/setting_cron_rule_dialog.dart index 6c0bb0e..1120f09 100644 --- a/lib/pages/terminal/setting/widgets/setting_cron_rule_dialog.dart +++ b/lib/pages/terminal/setting/widgets/setting_cron_rule_dialog.dart @@ -256,7 +256,7 @@ class _PresetButton extends StatelessWidget { /// 点击后把该预设写入父弹窗输入框的回调。 final VoidCallback onTap; - /// 构建固定高度预设项,长名称和表达式均以单行省略保护布局。 + /// 构建带最小触控高度的预设项,内容可随字体缩放增高以避免文字溢出。 @override Widget build(BuildContext context) { final theme = FTheme.of(context); diff --git a/lib/pages/terminal/setting/widgets/setting_sync.dart b/lib/pages/terminal/setting/widgets/setting_sync.dart index 028a18f..f13c9d3 100644 --- a/lib/pages/terminal/setting/widgets/setting_sync.dart +++ b/lib/pages/terminal/setting/widgets/setting_sync.dart @@ -459,6 +459,10 @@ Widget _summaryValue( title: '设备状态检查', icon: FAssets.icons.hardDrive ), + ScheduledTaskType.autoUpdate => ( + title: 'APK 自动更新', + icon: FAssets.icons.packageCheck + ), ScheduledTaskType.configSync => ( title: '设备配置同步', icon: FAssets.icons.settings diff --git a/pubspec.lock b/pubspec.lock index 0a0c0de..4299a67 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -467,6 +467,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.0.5" + flutter_html: + dependency: "direct main" + description: + name: flutter_html + sha256: "38a2fd702ffdf3243fb7441ab58aa1bc7e6922d95a50db76534de8260638558d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.0" flutter_libserialport: dependency: "direct main" description: @@ -738,6 +746,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "5.1.1" + list_counter: + dependency: transitive + description: + name: list_counter + sha256: c447ae3dfcd1c55f0152867090e67e219d42fe6d4f2807db4bbe8b8d69912237 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.2" logger: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index afa2b00..5c08961 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,6 +25,8 @@ dependencies: ducafe_ui_core: ^1.0.6 forui: ^0.9.1+1 forui_assets: ^0.9.1 + # 版本详情抽屉仅渲染后台发布的静态 HTML 更新说明 + flutter_html: 3.0.0 # 吐司弹框 fluttertoast: ^8.2.5 # loading diff --git a/test/common/terminal_update_models_test.dart b/test/common/terminal_update_models_test.dart new file mode 100644 index 0000000..1e437fd --- /dev/null +++ b/test/common/terminal_update_models_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 验证 Root、终端平台和自动更新默认配置的纯模型兼容行为。 +void main() { + test('旧终端设置缺少 Root 字段时恢复为未检查', () { + final setting = SysSetting.fromJson(const { + 'sysType': 'CANTEEN', + 'androidId': 'android-test-id', + 'sqlAddress': '', + }); + + expect(setting.rootAccessStatus, RootAccessStatus.unknown); + expect(setting.rootAccessCheckedAt, isNull); + expect(setting.rootAccessDiagnostic, isNull); + }); + + test('Root 原生检查结果只在确认 available 时标记可用', () { + final available = RootAccessCheckResult.fromMap(const { + 'available': true, + 'message': '已通过 su -c id 确认 uid=0', + }); + final unavailable = RootAccessCheckResult.fromMap(const { + 'available': false, + 'message': 'Root 授权超时', + }); + + expect(available.status, RootAccessStatus.available); + expect(available.available, isTrue); + expect(unavailable.status, RootAccessStatus.unavailable); + expect(unavailable.available, isFalse); + }); + + test('终端业务类型映射到独立 Android 版本平台', () { + expect( + AppPlatformEnum.terminalCode(SysType.SUPERMARKET), + AppPlatformEnum.supermarketAndroid.code, + ); + expect( + AppPlatformEnum.terminalCode(SysType.CANTEEN), + AppPlatformEnum.canteenAndroid.code, + ); + }); + + test('APK 自动更新默认每天零点三十分且仅空闲执行', () { + final config = SyncCheckTool.defaultConfigs.singleWhere( + (item) => item.type == ScheduledTaskType.autoUpdate, + ); + + expect(config.enabled, isTrue); + expect(config.cron, '0 30 0 * * *'); + expect(config.idleOnly, isTrue); + }); + + test('APK 自动更新只选择最高的已发布强制更新版本', () { + final decision = TerminalAutoUpdateTool.evaluateForcedUpdate( + versions: [ + ServiceAppVersion( + appUniqueCode: 'terminal-app', + platform: '4', + versionCode: 132, + versionName: '1.3.2', + status: AppVersionStatusEnum.published.code, + isForce: true, + ), + ServiceAppVersion( + appUniqueCode: 'terminal-app', + platform: '4', + versionCode: 140, + versionName: '1.4.0', + status: AppVersionStatusEnum.published.code, + isForce: true, + ), + ], + currentVersionCode: 1, + appUniqueCode: 'terminal-app', + platform: '4', + ); + + expect(decision.target?.versionCode, 140); + expect(decision.hasTarget, isTrue); + }); + + test('APK 自动更新不会把未发布的更高版本误报为最新', () { + final decision = TerminalAutoUpdateTool.evaluateForcedUpdate( + versions: [ + ServiceAppVersion( + appUniqueCode: 'terminal-app', + platform: '4', + versionCode: 132, + versionName: '1.3.2', + status: AppVersionStatusEnum.unpublished.code, + isForce: false, + ), + ], + currentVersionCode: 1, + appUniqueCode: 'terminal-app', + platform: '4', + ); + + expect(decision.hasTarget, isFalse); + expect(decision.skippedMessage, contains('未处于已发布状态')); + expect(decision.skippedMessage, isNot(contains('已是最新版本'))); + }); + + test('APK 自动更新跳过未标记强制更新的已发布版本', () { + final decision = TerminalAutoUpdateTool.evaluateForcedUpdate( + versions: [ + ServiceAppVersion( + appUniqueCode: 'terminal-app', + platform: '4', + versionCode: 132, + versionName: '1.3.2', + status: AppVersionStatusEnum.published.code, + isForce: false, + ), + ], + currentVersionCode: 1, + appUniqueCode: 'terminal-app', + platform: '4', + ); + + expect(decision.hasTarget, isFalse); + expect(decision.skippedMessage, contains('未标记为强制更新')); + }); +} diff --git a/test/common/toast_widget_test.dart b/test/common/toast_widget_test.dart new file mode 100644 index 0000000..89eba55 --- /dev/null +++ b/test/common/toast_widget_test.dart @@ -0,0 +1,50 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 验证公共 Toast 在终端横屏和大字号设置下不会因长异常文案产生布局溢出。 +void main() { + testWidgets('1280x720 大字号下超长错误提示自动换行且不溢出', (tester) async { + await tester.binding.setSurfaceSize(const Size(1280, 720)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final baseTheme = FThemes.zinc.light; + final terminalTheme = FThemeData.inherit( + colorScheme: baseTheme.colorScheme, + typography: baseTheme.typography.scale(sizeScalar: 1.4), + style: baseTheme.style, + ); + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(1920, 1080), + minTextAdapt: true, + splitScreenMode: true, + builder: (_, __) => GetMaterialApp( + home: const Scaffold(), + builder: (_, child) => FTheme( + data: terminalTheme, + child: child!, + ), + ), + ), + ); + + const message = + 'DioException [bad response]: The server returned HTTP 500. ' + '设备不存在或不属于当前租户,请在基本信息重新绑定设备后再次执行自动更新。' + '完整异常和堆栈应保留在任务日志中,界面只显示可恢复的操作提示。'; + Toast.error(msg: message); + await tester.pump(); + + expect(find.text(message), findsOneWidget); + expect(tester.getSize(find.text(message)).width, lessThan(1280 * 0.82)); + expect(tester.takeException(), isNull); + + await tester.pump(const Duration(seconds: 4)); + Get.reset(); + }); +}