feat(kiosk): add android kiosk terminal mode support
完整实现收银终端的Android专用Kiosk模式支持,包括: - 添加原生设备管理与Kiosk模式核心逻辑 - 新增Flutter设置页的Kiosk相关配置与控制UI - 更新README添加部署命令 - 补充Android权限与组件声明
This commit is contained in:
1 parent
94f8c5e67b
commit
a0ad3a06ff
14 files changed
+557
-37
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
+10
@@ -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>
|
||||
Reference in new issue
Block a user