feat(kiosk): add android kiosk terminal mode support

完整实现收银终端的Android专用Kiosk模式支持,包括:
- 添加原生设备管理与Kiosk模式核心逻辑
- 新增Flutter设置页的Kiosk相关配置与控制UI
- 更新README添加部署命令
- 补充Android权限与组件声明
This commit is contained in:
dzc committed 2026-07-24 09:53:51 +08:00
1 parent 94f8c5e67b
commit a0ad3a06ff
14 files changed
+557 -37

No files matched your search

+24
View File
@@ -12,6 +12,7 @@
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.SYSTEM_OVERLAY_WINDOW" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<queries>
<intent>
@@ -47,6 +48,29 @@
</intent-filter>
</activity>
<receiver
android:name=".KioskDeviceAdminReceiver"
android:description="@string/kiosk_device_admin_description"
android:exported="true"
android:label="@string/kiosk_device_admin_label"
android:permission="android.permission.BIND_DEVICE_ADMIN">
<meta-data
android:name="android.app.device_admin"
android:resource="@xml/kiosk_device_admin" />
<intent-filter>
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
</intent-filter>
</receiver>
<receiver
android:name=".KioskBootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<activity
android:name="io.flutter.plugins.facediscern.ui.FaceDiscern"
android:exported="true"
@@ -0,0 +1,19 @@
package com.example.pj_supermarket_cloud
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/**
* 仅在应用已经是 Device Owner 时响应开机广播,避免普通安装包无条件打断系统启动流程。
*/
class KioskBootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
if (!KioskModeManager.prepareForBoot(context)) return
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
context.startActivity(launchIntent)
}
}
@@ -0,0 +1,10 @@
package com.example.pj_supermarket_cloud
import android.app.admin.DeviceAdminReceiver
/**
* 专用收银终端的设备管理接收器。
*
* 该接收器只作为 Device Owner 的系统入口,应用不会在普通安装状态下自行提升权限。
*/
class KioskDeviceAdminReceiver : DeviceAdminReceiver()
@@ -0,0 +1,172 @@
package com.example.pj_supermarket_cloud
import android.app.Activity
import android.app.ActivityManager
import android.app.admin.DevicePolicyManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
/**
* 管理 Android 专用设备模式。
*
* 只有 Device Owner 才能可靠限制主页、最近任务和状态栏。普通设备管理员或沉浸式全屏
* 都不视为 Kiosk 可用,避免界面把“看起来全屏”误报为“无法退出”。
*/
object KioskModeManager {
private const val PREFERENCES_NAME = "kiosk_runtime"
private const val AUTO_ENTER_ENABLED_KEY = "auto_enter_enabled"
fun status(context: Context): Map<String, Any> {
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<String, Any> {
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)
}
@@ -36,6 +36,16 @@ class MainActivity : FlutterActivity() {
)
}
"getNetworkSnapshot" -> result.success(getNetworkSnapshot())
"getKioskStatus" -> result.success(KioskModeManager.status(this))
"setKioskAutoEnterEnabled" -> {
val enabled = call.argument<Boolean>("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 特征识别常见虚拟机,避免在虚拟设备上初始化串口、人脸和副屏硬件。
*/
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="kiosk_device_admin_label">资安信收银终端设备管理</string>
<string name="kiosk_device_admin_description">用于启用开机自启和专用设备锁定模式</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<device-admin xmlns:android="http://schemas.android.com/apk/res/android">
<uses-policies />
</device-admin>