diff --git a/README.md b/README.md index 9573240..784044a 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,11 @@ ## 生成代码命令 + ``` flutter pub run build_runner build --delete-conflicting-outputs~ ``` + ## flutter打包 + ``` flutter build apk --build-name 1.3.0 --build-number 130 --build-name为版本号(展示) @@ -10,6 +13,14 @@ flutter build apk --build-name 1.3.0 --build-number 130 ``` ## 生成log命令 + ``` dart run icons_launcher:create -``` \ No newline at end of file +``` + +### 开启Kiosk模式命令 + +``` +adb shell dpm set-device-owner \ + com.example.pj_supermarket_cloud/.KioskDeviceAdminReceiver +``` diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c7f930f..26a1735 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -12,6 +12,7 @@ + @@ -47,6 +48,29 @@ + + + + + + + + + + + + + { + val policyManager = policyManager(context) + val deviceOwner = policyManager.isDeviceOwnerApp(context.packageName) + val autoEnterEnabled = isAutoEnterEnabled(context) + return mapOf( + "deviceAdminActive" to policyManager.isAdminActive(adminComponent(context)), + "deviceOwner" to deviceOwner, + "available" to deviceOwner, + "active" to isKioskActive(context), + "lockTaskPermitted" to policyManager.isLockTaskPermitted(context.packageName), + "autoEnterEnabled" to autoEnterEnabled, + "bootAutoStartSupported" to deviceOwner + ) + } + + /** + * Activity 每次回到前台时按配置补齐白名单并进入锁定任务。管理员退出到桌面的过程 + * 不会触发新的 onPostResume;用户再次主动打开 App 时才会重新进入 Kiosk。 + */ + fun enterIfAvailable(activity: Activity) { + if (!isAutoEnterEnabled(activity) || !ensureLockTaskPolicy(activity)) return + if (!isKioskActive(activity)) { + try { + activity.startLockTask() + } catch (_: IllegalArgumentException) { + return + } catch (_: IllegalStateException) { + return + } catch (_: SecurityException) { + return + } + } + if (isKioskActive(activity)) hideSystemBars(activity) + } + + /** + * Device Owner 设备始终支持开机拉起 App;只有开启自动进入时才预先恢复 Kiosk 策略。 + */ + fun prepareForBoot(context: Context): Boolean { + if (!policyManager(context).isDeviceOwnerApp(context.packageName)) return false + if (isAutoEnterEnabled(context)) { + return ensureLockTaskPolicy(context) + } + return try { + policyManager(context).setStatusBarDisabled(adminComponent(context), false) + true + } catch (_: Exception) { + true + } + } + + /** + * 解除当前锁定并回到系统 HOME。保留 Lock Task 白名单,自动进入配置开启时,用户 + * 下次主动打开 App 会通过 onPostResume 重新锁定。 + */ + fun exitToHome(activity: Activity): Boolean { + val policyManager = policyManager(activity) + if (!policyManager.isDeviceOwnerApp(activity.packageName)) return false + + return try { + if (isKioskActive(activity)) activity.stopLockTask() + policyManager.setStatusBarDisabled(adminComponent(activity), false) + showSystemBars(activity) + + val homeIntent = Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_HOME) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED) + } + if (homeIntent.resolveActivity(activity.packageManager) != null) { + activity.startActivity(homeIntent) + } else { + activity.moveTaskToBack(true) + } + true + } catch (_: Exception) { + // 退出失败时恢复终端策略,不能把设备留在既未锁定也未回到桌面的中间状态。 + if (isAutoEnterEnabled(activity) && ensureLockTaskPolicy(activity)) { + enterIfAvailable(activity) + } + false + } + } + + /** + * 保存“进入 App 自动开启”配置。关闭配置只影响后续进入,当前 Kiosk 会继续运行, + * 仍需通过管理密码出口返回桌面,避免一个普通开关绕过终端退出保护。 + */ + 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() + if (enabled) { + enterIfAvailable(activity) + } + return status(activity) + } + + fun reapplySystemBars(activity: Activity) { + if (isKioskActive(activity)) hideSystemBars(activity) + } + + fun isKioskActive(context: Context): Boolean { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + return activityManager.lockTaskModeState == ActivityManager.LOCK_TASK_MODE_LOCKED + } + + private fun ensureLockTaskPolicy(context: Context): Boolean { + val policyManager = policyManager(context) + if (!policyManager.isDeviceOwnerApp(context.packageName)) return false + return try { + policyManager.setLockTaskPackages(adminComponent(context), arrayOf(context.packageName)) + policyManager.setStatusBarDisabled(adminComponent(context), true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + policyManager.setLockTaskFeatures( + adminComponent(context), + DevicePolicyManager.LOCK_TASK_FEATURE_NONE + ) + } + policyManager.isLockTaskPermitted(context.packageName) + } catch (_: Exception) { + false + } + } + + private fun hideSystemBars(activity: Activity) { + WindowCompat.setDecorFitsSystemWindows(activity.window, false) + WindowCompat.getInsetsController(activity.window, activity.window.decorView).apply { + hide(WindowInsetsCompat.Type.systemBars()) + systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } + + private fun showSystemBars(activity: Activity) { + WindowCompat.setDecorFitsSystemWindows(activity.window, true) + WindowCompat.getInsetsController(activity.window, activity.window.decorView) + .show(WindowInsetsCompat.Type.systemBars()) + } + + private fun adminComponent(context: Context) = ComponentName(context, KioskDeviceAdminReceiver::class.java) + + private fun policyManager(context: Context) = + context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + + private fun isAutoEnterEnabled(context: Context) = + preferences(context).getBoolean(AUTO_ENTER_ENABLED_KEY, true) + + 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/MainActivity.kt b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt index f90689a..1e25d1f 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 @@ -36,6 +36,16 @@ class MainActivity : FlutterActivity() { ) } "getNetworkSnapshot" -> result.success(getNetworkSnapshot()) + "getKioskStatus" -> result.success(KioskModeManager.status(this)) + "setKioskAutoEnterEnabled" -> { + val enabled = call.argument("enabled") + if (enabled == null) { + result.error("INVALID_ARGUMENT", "缺少 Kiosk 自动进入配置", null) + } else { + result.success(KioskModeManager.setAutoEnterEnabled(this, enabled)) + } + } + "exitKioskToHome" -> result.success(KioskModeManager.exitToHome(this)) else -> result.notImplemented() } } @@ -51,6 +61,23 @@ class MainActivity : FlutterActivity() { // ) } + override fun onPostResume() { + super.onPostResume() + KioskModeManager.enterIfAvailable(this) + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) KioskModeManager.reapplySystemBars(this) + } + + @Suppress("DEPRECATION") + override fun onBackPressed() { + // 当前 FlutterActivity 基线没有 OnBackPressedDispatcher,Kiosk 运行时直接拦截系统返回。 + if (KioskModeManager.isKioskActive(this)) return + super.onBackPressed() + } + /** * 使用 Android Build 特征识别常见虚拟机,避免在虚拟设备上初始化串口、人脸和副屏硬件。 */ diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..ee19a0c --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + 资安信收银终端设备管理 + 用于启用开机自启和专用设备锁定模式 + diff --git a/android/app/src/main/res/xml/kiosk_device_admin.xml b/android/app/src/main/res/xml/kiosk_device_admin.xml new file mode 100644 index 0000000..8b4c99b --- /dev/null +++ b/android/app/src/main/res/xml/kiosk_device_admin.xml @@ -0,0 +1,4 @@ + + + + diff --git a/lib/common/utils/app/app_runtime_tool.dart b/lib/common/utils/app/app_runtime_tool.dart index 4917696..1e4d9a8 100644 --- a/lib/common/utils/app/app_runtime_tool.dart +++ b/lib/common/utils/app/app_runtime_tool.dart @@ -18,6 +18,75 @@ class AppRuntimeEnvironment { } } +/// Android 专用设备模式的实时状态。 +class AppKioskStatus { + final bool deviceAdminActive; + final bool deviceOwner; + final bool available; + final bool active; + final bool lockTaskPermitted; + final bool autoEnterEnabled; + final bool bootAutoStartSupported; + + const AppKioskStatus({ + required this.deviceAdminActive, + required this.deviceOwner, + required this.available, + required this.active, + required this.lockTaskPermitted, + required this.autoEnterEnabled, + required this.bootAutoStartSupported, + }); + + const AppKioskStatus.unavailable() + : deviceAdminActive = false, + deviceOwner = false, + available = false, + active = false, + lockTaskPermitted = false, + autoEnterEnabled = false, + bootAutoStartSupported = false; + + factory AppKioskStatus.fromMap(Map? data) { + return AppKioskStatus( + deviceAdminActive: data?['deviceAdminActive'] == true, + deviceOwner: data?['deviceOwner'] == true, + available: data?['available'] == true, + active: data?['active'] == true, + lockTaskPermitted: data?['lockTaskPermitted'] == true, + autoEnterEnabled: data?['autoEnterEnabled'] == true, + bootAutoStartSupported: data?['bootAutoStartSupported'] == true, + ); + } + + bool get canExitToAndroidHome => available && active; + + String get statusLabel { + if (!available) return '暂不可实现'; + if (active && !autoEnterEnabled) return 'Kiosk 运行中 · 自动开启已关闭'; + if (active) return '可以实现 · Kiosk 运行中'; + if (!autoEnterEnabled) return '可以实现 · 自动开启已关闭'; + if (lockTaskPermitted) return '可以实现 · 正在进入'; + return '可以实现 · 等待策略生效'; + } + + String get description { + if (available && active && !autoEnterEnabled) { + return '当前仍在 Kiosk;管理员退出后,再次打开或回到 App 将不再自动锁定。'; + } + if (available && !autoEnterEnabled) { + return '设备具备 Device Owner 权限;开启右侧配置后,每次打开或回到 App 都会自动进入 Kiosk。'; + } + if (available) { + return '已配置 Device Owner;每次打开或回到 App 都会自动进入 Kiosk,并隐藏主页、最近任务和系统栏。'; + } + if (deviceAdminActive) { + return '当前仅激活了设备管理员,仍不能可靠禁用主页和最近任务;需要配置为 Device Owner。'; + } + return '未配置 Device Owner,普通全屏不能保证无法退出;需在恢复出厂或未初始化的设备上部署。'; + } +} + class AppNetworkSnapshot { final bool connected; final String type; @@ -53,6 +122,7 @@ abstract class AppRuntimeTool { ); static bool _initialized = false; static bool _manualHardwareInput = false; + static AppKioskStatus _kioskStatus = const AppKioskStatus.unavailable(); static bool get isEmulator => _environment.isEmulator; @@ -65,6 +135,8 @@ abstract class AppRuntimeTool { static bool get hasMultipleDisplays => displayCount >= 2; + static AppKioskStatus get kioskStatus => _kioskStatus; + static Future initializePlatform() async { if (_initialized) return; try { @@ -72,16 +144,54 @@ abstract class AppRuntimeTool { 'getRuntimeEnvironment', ); _environment = AppRuntimeEnvironment.fromMap(data); + _kioskStatus = await refreshKioskStatus(); } on PlatformException { // 原生通道异常时按单屏处理,避免错误地启动副屏。 _environment = const AppRuntimeEnvironment( isEmulator: false, displayCount: 1, ); + _kioskStatus = const AppKioskStatus.unavailable(); } _initialized = true; } + static Future refreshKioskStatus() async { + try { + final data = await _channel.invokeMapMethod( + 'getKioskStatus', + ); + _kioskStatus = AppKioskStatus.fromMap(data); + } on PlatformException { + _kioskStatus = const AppKioskStatus.unavailable(); + } + return _kioskStatus; + } + + /// 配置每次进入 App 时是否自动恢复 Kiosk,配置由 Android 原生层持久化。 + static Future setKioskAutoEnterEnabled(bool enabled) async { + try { + final data = await _channel.invokeMapMethod( + 'setKioskAutoEnterEnabled', + {'enabled': enabled}, + ); + _kioskStatus = AppKioskStatus.fromMap(data); + } on PlatformException { + // 保留原状态,设置页会据此判断本次配置没有生效。 + } + return _kioskStatus; + } + + /// 只有原生层确认 Device Owner/Kiosk 可用时才允许退出到系统桌面。 + static Future exitKioskToAndroidHome() async { + if (!_kioskStatus.canExitToAndroidHome) return false; + try { + return await _channel.invokeMethod('exitKioskToHome') ?? false; + } on PlatformException { + return false; + } + } + static Future getNetworkSnapshot() async { try { final data = await _channel.invokeMapMethod( diff --git a/lib/pages/setting/controller.dart b/lib/pages/setting/controller.dart index 39031d9..6f6c99a 100644 --- a/lib/pages/setting/controller.dart +++ b/lib/pages/setting/controller.dart @@ -1,18 +1,20 @@ import 'dart:async'; import 'package:common_utils/common_utils.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; -class SettingController extends GetxController { +class SettingController extends GetxController with WidgetsBindingObserver { static const String _tag = '设置 Controller'; final appC = Get.find(); final sysSetting = Rxn(); final userSetting = Rxn(); final manualHardwareInput = false.obs; + final kioskStatus = AppRuntimeTool.kioskStatus.obs; final cuurentMenuType = SettingMenuType.baseInfo.obs; final isLoading = true.obs; final isBound = false.obs; @@ -27,6 +29,7 @@ class SettingController extends GetxController { @override void onInit() { super.onInit(); + WidgetsBinding.instance.addObserver(this); _initialize(); } @@ -49,15 +52,22 @@ class SettingController extends GetxController { @override void onClose() { + WidgetsBinding.instance.removeObserver(this); _mqUpdateListen?.cancel(); super.onClose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) refreshKioskStatus(); + } + void doMenu(SettingMenuType type) { cuurentMenuType.value = type; } Future getSetting() async { + await refreshKioskStatus(); final setting = await AppSettingTool.getSysSetting(); sysSetting.value = setting; userSetting.value = await AppSettingTool.getUserSetting(); @@ -67,6 +77,10 @@ class SettingController extends GetxController { AppRuntimeTool.applyManualHardwareInput(setting.manualHardwareInput); } + Future refreshKioskStatus() async { + kioskStatus.value = await AppRuntimeTool.refreshKioskStatus(); + } + Future setGlobalFontSize(SettingGlobalFontSize fontSize) async { final setting = sysSetting.value; if (setting == null || setting.globalFontSize == fontSize) return; @@ -255,4 +269,40 @@ class SettingController extends GetxController { sysSetting.refresh(); Toast.success(msg: enabled ? '已设置为主控手动输入模式' : '已恢复真实硬件模式'); } + + /// 该配置只控制进入 App 时是否自动锁定;关闭后仍需通过管理密码退出当前 Kiosk。 + 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; + } + Toast.success( + msg: enabled ? '每次进入 App 将自动开启 Kiosk' : '已关闭进入 App 自动开启 Kiosk'); + } + + /// 管理密码验证由按钮组件负责,此处只处理原生退出结果与失败反馈。 + Future exitKioskToAndroidHome() async { + if (!kioskStatus.value.canExitToAndroidHome) return; + EasyLoading.show( + status: '正在退出专用设备模式...', + maskType: EasyLoadingMaskType.black, + ); + try { + final success = await AppRuntimeTool.exitKioskToAndroidHome(); + kioskStatus.value = await AppRuntimeTool.refreshKioskStatus(); + if (!success) { + Toast.error(msg: '退出 Android 桌面失败,请检查设备策略'); + } + } catch (e, stack) { + logger.e('|$_tag|退出Kiosk失败error:|$e|stack:|$stack|'); + Toast.error(msg: '退出 Android 桌面失败,请稍后重试'); + } finally { + EasyLoading.dismiss(); + } + } } diff --git a/lib/pages/setting/view.dart b/lib/pages/setting/view.dart index d44a3cf..abd72bf 100644 --- a/lib/pages/setting/view.dart +++ b/lib/pages/setting/view.dart @@ -67,51 +67,60 @@ class _SettingMenu extends StatelessWidget { const Spacer(), Obx(() { final bound = controller.isBound.value; - return Container( - padding: EdgeInsets.all(16.w), - decoration: BoxDecoration( - color: const Color(0xFFF7F8FA), - border: Border.all(color: XfjDesignTokens.line), - borderRadius: BorderRadius.circular( - XfjDesignTokens.controlRadius, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FA), + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 8.w, - height: 8.w, - decoration: BoxDecoration( - color: bound - ? XfjDesignTokens.success - : XfjDesignTokens.warning, - shape: BoxShape.circle, - ), + Row( + children: [ + Container( + width: 8.w, + height: 8.w, + decoration: BoxDecoration( + color: bound + ? XfjDesignTokens.success + : XfjDesignTokens.warning, + shape: BoxShape.circle, + ), + ), + SizedBox(width: 8.w), + Text( + bound ? '设备已绑定' : '设备未绑定', + style: theme.typography.sm.copyWith( + color: bound + ? XfjDesignTokens.success + : XfjDesignTokens.warning, + fontWeight: FontWeight.w700, + ), + ), + ], ), - SizedBox(width: 8.w), + SizedBox(height: 8.h), Text( - bound ? '设备已绑定' : '设备未绑定', + bound ? '部分设置修改后会自动同步到本机服务。' : '请先在基本信息中绑定设备后使用业务设置。', style: theme.typography.sm.copyWith( - color: bound - ? XfjDesignTokens.success - : XfjDesignTokens.warning, - fontWeight: FontWeight.w700, + color: XfjDesignTokens.muted, ), ), ], ), + ), + if (controller.kioskStatus.value.canExitToAndroidHome) ...[ SizedBox(height: 8.h), - Text( - bound ? '部分设置修改后会自动同步到本机服务。' : '请先在基本信息中绑定设备后使用业务设置。', - style: theme.typography.sm.copyWith( - color: XfjDesignTokens.muted, - ), - ), + const SettingKioskExitButton(), ], - ), + ], ); }), ], diff --git a/lib/pages/setting/widgets/index.dart b/lib/pages/setting/widgets/index.dart index 44ef472..e99d5b7 100644 --- a/lib/pages/setting/widgets/index.dart +++ b/lib/pages/setting/widgets/index.dart @@ -3,6 +3,7 @@ library; export './setting_backup.dart'; export './setting_baseInfo.dart'; export './setting_face.dart'; +export './setting_kiosk_exit_button.dart'; export './setting_pay.dart'; export './setting_product.dart'; export './setting_sync.dart'; diff --git a/lib/pages/setting/widgets/setting_baseInfo.dart b/lib/pages/setting/widgets/setting_baseInfo.dart index 35f00d4..12fbe4c 100644 --- a/lib/pages/setting/widgets/setting_baseInfo.dart +++ b/lib/pages/setting/widgets/setting_baseInfo.dart @@ -136,6 +136,20 @@ Widget settingBaseInfo(BuildContext context) { ? 'Android 虚拟机' : 'Android 真实设备', ), + SettingInfoRow( + icon: controller.kioskStatus.value.available + ? FAssets.icons.shieldCheck + : FAssets.icons.circleAlert, + label: '专用设备 / Kiosk', + subtitle: 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, + ), + ), SettingInfoRow( icon: FAssets.icons.keyboard, label: '设置为主控', diff --git a/lib/pages/setting/widgets/setting_kiosk_exit_button.dart b/lib/pages/setting/widgets/setting_kiosk_exit_button.dart new file mode 100644 index 0000000..67d6fac --- /dev/null +++ b/lib/pages/setting/widgets/setting_kiosk_exit_button.dart @@ -0,0 +1,64 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import '../controller.dart'; + +/// Kiosk 能力成立时才展示系统出口,普通全屏设备不能出现具有误导性的“退出桌面”按钮。 +class SettingKioskExitButton extends StatefulWidget { + const SettingKioskExitButton({super.key}); + + @override + State createState() => _SettingKioskExitButtonState(); +} + +class _SettingKioskExitButtonState extends State { + bool _busy = false; + + @override + Widget build(BuildContext context) { + final controller = Get.find(); + final theme = FTheme.of(context); + return Obx(() { + if (!controller.kioskStatus.value.canExitToAndroidHome) { + return const SizedBox.shrink(); + } + return SizedBox( + width: double.infinity, + child: FButton( + style: FButtonStyle.destructive, + prefix: FIcon(FAssets.icons.house, color: Colors.white, size: 20.sp), + label: Text( + '退出 Android 桌面', + style: theme.typography.sm.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + onPress: _busy ? null : () => _exitToAndroidHome(context, controller), + ), + ); + }); + } + + Future _exitToAndroidHome( + BuildContext context, + SettingController controller, + ) async { + if (_busy) return; + setState(() => _busy = true); + try { + final verified = await terminalAdminPasswordDialog( + context, + actionLabel: '退出到 Android 桌面', + ); + if (!verified) return; + await controller.exitKioskToAndroidHome(); + } finally { + if (mounted) setState(() => _busy = false); + } + } +}