Android 设备锁定:Kiosk Mode 实战

这本质上是一个"Android 设备所有权管理"问题。本文记录从零实现的过程。


整体方案

让目标 app 通过 ADB 命令成为 Device Owner,接管设备的 launcher、Kiosk 锁定、状态栏控制等高级权限。逾期时:

  1. 后端接口返回 type=5(逾期状态)
  2. DataFetchService 轮询时收到,启动 OverdueLockActivity
  3. OverdueLockActivity 拉起后立刻调 startLockTask() 进入 Kiosk 模式
  4. 通过 addPersistentPreferredActivity() 把 Activity 注册为持久首选 launcher
  5. 用户任何按键都被拦截,HOME 键按下后系统又跳回锁定页
  6. 即使电视被拔电重启,开机后 launcher 仍是锁定页(无法绕开)

完整流程图

bash 复制代码
┌─────────────────────────────────────────────────────────────┐
│  1. 创建 DeviceAdminReceiver 子类(Device Owner 凭证)     │
│  2. AndroidManifest 注册 receiver + 锁定 Activity          │
│  3. 创建 res/xml/device_admin_policies.xml                 │
│  4. adb shell dpm set-device-owner com.xx/.AdminReceiver   │
│  5. DataFetchService 轮询时收到 type=5                     │
│  6. 启动 OverdueLockActivity:                              │
│     - 调 addPersistentPreferredActivity 设自己为 launcher   │
│     - 调 setLockTaskPackages + startLockTask 进入 Kiosk     │
│  7. 用户任何操作都被拦截,按 HOME 跳回锁定页                │
│  8. 用户付款后,后端返回 type=6                             │
│  9. unlockAndExit():stopLockTask + 还原 launcher           │
└─────────────────────────────────────────────────────────────┘

第一步:Device Owner 凭证

Device Owner 是 Android 5.0+ 引入的设备管理角色,比普通 Device Admin 权限更大:可以接管 launcher、禁用状态栏、隐藏 app 等。

要让一个 app 成为 Device Owner,普通安装方式不行------必须通过 ADB 命令授予。

1.1 创建 KioskAdminReceiver

java 复制代码
package com.example.kiosk;
​
import android.app.admin.DeviceAdminReceiver;
import android.content.Context;
import android.content.Intent;
import android.util.Log;
​
public class KioskAdminReceiver extends DeviceAdminReceiver {
    private static final String TAG = "KioskAdminReceiver";
​
    @Override
    public void onEnabled(Context context, Intent intent) {
        Log.d(TAG, "Device Owner 已激活");
    }
​
    @Override
    public CharSequence onDisableRequested(Context context, Intent intent) {
        // 返回 CharSequence 给用户看禁用提示
        return "禁用设备管理将无法使用分期付款电视功能";
    }
​
    @Override
    public void onDisabled(Context context, Intent intent) {
        Log.d(TAG, "Device Owner 已禁用");
    }
}

⚠️ 注意 :onDisableRequested 的返回类型是 CharSequence,不是 void。这是 Android 框架提供的提示文字给用户确认。

1.2 创建策略文件 res/xml/device_admin_policies.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<device-admin>
    <uses-policies>
        <force-lock />      <!-- 允许锁屏 -->
        <wipe-data />       <!-- 允许远程擦除数据 -->
        <reset-password />  <!-- 允许重置密码 -->
    </uses-policies>
</device-admin>

1.3 AndroidManifest 注册

ini 复制代码
<receiver
    android:name=".KioskAdminReceiver"
    android:label="@string/app_name"
    android:description="@string/kiosk_admin_description"
    android:permission="android.permission.BIND_DEVICE_ADMIN"
    android:exported="true">
    <meta-data
        android:name="android.app.device_admin"
        android:resource="@xml/device_admin_policies" />
    <intent-filter>
        <action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
    </intent-filter>
</receiver>

注意 android:permission="android.permission.BIND_DEVICE_ADMIN" 是必须的------只有系统能用这个权限调 receiver。


第二步:锁定 Activity

2.1 创建 OverdueLockActivity.java

scss 复制代码
package com.example.kiosk;
​
import android.app.Activity;
import android.app.admin.DevicePolicyManager;
import android.content.ComponentName;
import android.content.Intent;
import android.content.IntentFilter;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
import android.view.KeyEvent;
import android.view.WindowManager;
import android.widget.Button;
import android.widget.TextView;
​
public class OverdueLockActivity extends Activity {
    private static final String TAG = "OverdueLockActivity";
    public static final String EXTRA_MESSAGE = "message";
    public static final String EXTRA_PHONE = "phone";
​
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
​
        // 全屏 + 锁屏之上显示 + 关闭 Keyguard + 保持屏幕常亮
        getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED);
        getWindow().addFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD);
        getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
        getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
​
        setContentView(R.layout.activity_overdue_lock);
​
        // 显示后端消息
        TextView tvMessage = findViewById(R.id.tv_message);
        Intent intent = getIntent();
        if (intent != null) {
            String msg = intent.getStringExtra(EXTRA_MESSAGE);
            if (msg != null && !msg.isEmpty()) {
                tvMessage.setText(msg);
            }
        }
​
        // 联系客服
        Button btnCall = findViewById(R.id.btn_call);
        btnCall.setOnClickListener(v -> {
            String phone = intent != null ? intent.getStringExtra(EXTRA_PHONE) : null;
            if (phone == null || phone.isEmpty()) phone = "4000000000"; // 默认客服电话占位
            Intent dial = new Intent(Intent.ACTION_DIAL, Uri.parse("tel:" + phone));
            dial.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            try {
                startActivity(dial);
            } catch (Exception e) {
                Log.e(TAG, "dial failed", e);
            }
        });
​
        // 我已付款 → 解锁
        Button btnUnlock = findViewById(R.id.btn_unlock);
        btnUnlock.setOnClickListener(v -> unlockAndExit());
​
        // 接管设备:Device Owner 模式下进入 Kiosk 并设为 launcher
        DevicePolicyManager dpm = (DevicePolicyManager) getSystemService(DEVICE_POLICY_SERVICE);
        if (dpm.isDeviceOwnerApp(getPackageName())) {
            ComponentName adminComponent = new ComponentName(this, KioskAdminReceiver.class);
            ComponentName ourComponent = new ComponentName(this, OverdueLockActivity.class);
​
            // 1. 设置 Lock Task 白名单
            dpm.setLockTaskPackages(adminComponent, new String[]{getPackageName()});
​
            // 2. 把本 Activity 设为持久首选 launcher(关键)
            IntentFilter homeFilter = new IntentFilter(Intent.ACTION_MAIN);
            homeFilter.addCategory(Intent.CATEGORY_HOME);
            homeFilter.addCategory(Intent.CATEGORY_DEFAULT);
            dpm.addPersistentPreferredActivity(adminComponent, homeFilter, ourComponent);
​
            // 3. 进入 Lock Task 模式
            try {
                startLockTask();
            } catch (IllegalStateException e) {
                Log.d(TAG, "already in lock task mode");
            }
        }
    }
​
    /**
     * 解除锁定:从 Kiosk 退出并还原 launcher
     */
    public void unlockAndExit() {
        DevicePolicyManager dpm = (DevicePolicyManager) getSystemService(DEVICE_POLICY_SERVICE);
        if (dpm.isDeviceOwnerApp(getPackageName())) {
            try {
                stopLockTask();
            } catch (Exception ignored) {}
​
            try {
                ComponentName adminComponent = new ComponentName(this, KioskAdminReceiver.class);
                dpm.clearPackagePersistentPreferredActivities(adminComponent, getPackageName());
            } catch (Exception e) {
                Log.e(TAG, "clear preferred activity failed", e);
            }
        }
​
        Intent home = new Intent(Intent.ACTION_MAIN);
        home.addCategory(Intent.CATEGORY_HOME);
        home.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        startActivity(home);
​
        finish();
    }
​
    @Override
    public boolean onKeyDown(int keyCode, KeyEvent event) {
        // 拦截所有按键,设备完全锁死
        return true;
    }
​
    @Override
    public void onBackPressed() {
        // 拦截返回键
    }
​
    @Override
    protected void onNewIntent(Intent intent) {
        super.onNewIntent(intent);
        setIntent(intent);
        TextView tvMessage = findViewById(R.id.tv_message);
        if (intent != null) {
            String msg = intent.getStringExtra(EXTRA_MESSAGE);
            if (msg != null && !msg.isEmpty()) {
                tvMessage.setText(msg);
            }
        }
    }
}

2.2 锁定页布局 res/layout/activity_overdue_lock.xml

ini 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#000000"
    android:gravity="center"
    android:orientation="vertical"
    android:padding="32dp">
​
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="设备已锁定"
        android:textColor="#FF5252"
        android:textSize="36sp"
        android:textStyle="bold" />
​
    <TextView
        android:id="@+id/tv_message"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="24dp"
        android:gravity="center"
        android:text="您的分期付款已逾期,请联系运营商续期"
        android:textColor="#FFFFFF"
        android:textSize="20sp" />
​
    <Button
        android:id="@+id/btn_call"
        android:layout_width="240dp"
        android:layout_height="56dp"
        android:layout_marginTop="48dp"
        android:background="#FF5252"
        android:text="联系客服"
        android:textColor="#FFFFFF"
        android:textSize="20sp" />
​
    <Button
        android:id="@+id/btn_unlock"
        android:layout_width="240dp"
        android:layout_height="56dp"
        android:layout_marginTop="24dp"
        android:background="#424242"
        android:text="我已付款,解锁"
        android:textColor="#FFFFFF"
        android:textSize="18sp" />
</LinearLayout>

2.3 Manifest 注册 OverdueLockActivity

ini 复制代码
<activity
    android:name=".OverdueLockActivity"
    android:launchMode="singleTask"
    android:excludeFromRecents="true"
    android:taskAffinity=""
    android:theme="@style/Theme.TransparentDialog">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.HOME" />
        <category android:name="android.intent.category.DEFAULT" />
    </intent-filter>
</activity>

<category android:name="android.intent.category.HOME" /> 让系统把 Activity 当作 launcher 候选。


第三步:业务触发

在 DataFetchService 轮询接口结果里加 type=5 分支:

scss 复制代码
} else if (value.getType() == 5) {
    // 逾期未付款 → 锁定屏幕
    Intent lockIntent = new Intent(getApplicationContext(), OverdueLockActivity.class);
    lockIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
    lockIntent.putExtra(OverdueLockActivity.EXTRA_MESSAGE, value.getMessage());
    startActivity(lockIntent);
    Logger.d("HttpHandler", "设备已锁定,逾期未付款");
}

第四步:设为 Device Owner

bash 复制代码
# 1. 卸载旧版(普通方式装的 APK 不能直接升为 Device Owner)
adb uninstall com.example.kiosk
​
# 2. 装新 APK
adb install app-debug.apk
​
# 3. 设为设备所有者(这一步必须无界面直接成功)
adb shell dpm set-device-owner com.example.kiosk/.KioskAdminReceiver

成功输出:

vbnet 复制代码
Success: Device owner set to package com.example.kiosk

关键 API 详解

addPersistentPreferredActivity(锁定)

scss 复制代码
IntentFilter filter = new IntentFilter(Intent.ACTION_MAIN);
filter.addCategory(Intent.CATEGORY_HOME);
filter.addCategory(Intent.CATEGORY_DEFAULT);
dpm.addPersistentPreferredActivity(adminComponent, filter, ourComponent);

⚠️ 第二个参数是 IntentFilter,不是 Intent ------很多人写错。IntentFilter 用于声明匹配规则,Intent 是具体的发送意图。

效果:HOME 键按下时,系统会优先启动 ourComponent,且该规则重启后仍生效。

clearPackagePersistentPreferredActivities(解锁)

ini 复制代码
dpm.clearPackagePersistentPreferredActivities(adminComponent, getPackageName());

清除"持久首选 Activity"配置,让系统回到默认 launcher。

startLockTask / stopLockTask

scss 复制代码
startLockTask();   // 进入 Kiosk
stopLockTask();    // 退出 Kiosk

进入 Kiosk 后:

操作 行为
HOME 键 切到 launcher(即我们的锁定页)
最近任务键 拦截
状态栏下拉 拦截
返回键 默认能退出,需自己拦截
电源键关机 仍能关(系统行为)

WindowManager.LayoutParams 锁屏相关 flag

arduino 复制代码
FLAG_SHOW_WHEN_LOCKED       // 显示在锁屏之上
FLAG_DISMISS_KEYGUARD       // 关闭 keyguard
FLAG_KEEP_SCREEN_ON         // 保持屏幕常亮
FLAG_FULLSCREEN             // 全屏

物理攻击防御分析

攻击方式 防御效果
按 HOME 切桌面 ✅ 拦截(HOME → launcher → 锁定页)
按最近任务键 ✅ Kiosk 拦截
状态栏下拉 ✅ Kiosk 拦截
物理返回键 ✅ onKeyDown 拦截
电源键关机 ❌ 系统行为,不能拦
拔电强制关机 ❌ 物理攻击,不能拦
重启 ⚠️ 重启后 launcher 仍是锁定页(持久首选)
ADB 卸载 app ✅ Device Owner 不能直接卸,需 dpm remove-active-admin
ADB am force-stop ⚠️ 进程被杀掉会重启,系统会自动拉起 launcher(即锁定页)
系统设置卸载 ✅ Device Owner app 在设置里看不到"卸载"按钮

最坏情况:用户拔电 → 开机 → 还是锁定页 → 必须付款。


成为 Device Owner 后还能做:

scss 复制代码
// 禁用状态栏
dpm.setStatusBarDisabled(adminComponent, true);  // API 23+,API 30+ 需配合 Lock Task feature

// 隐藏其他 app
dpm.setApplicationHidden(adminComponent, "com.android.settings", true);

// 远程擦除
dpm.wipeData(0);  // 远程恢复出厂设置

// 远程锁屏
dpm.lockNow();  // 立即锁屏

总结

关键点 内容
必备条件 Device Owner(dpm set-device-owner)
核心 API startLockTask + addPersistentPreferredActivity
拦截 HOME 不能直接拦截,但通过成为 launcher 绕开
重启防御 持久首选 launcher 重启后仍生效
物理攻击 拔电拦不住,但开机后仍在锁定页

核心难点在于理解 Android 设备管理的权限层级(普通应用 → Device Admin → Device Owner → 系统 app),以及 Kiosk Mode 与 launcher 接管的关系。

相关推荐
张伯毅1 小时前
External Shuffle Service(spark3_shuffle):原理、配置与排障
java·开发语言·spark·yarn
我不会插花弄玉1 小时前
9. 表的内连和外连【由浅入深-MySQL】
android·数据库·mysql
嘟哩DuliDuli1 小时前
剧本节点输入归一化:文本来源、版本与下游引用
android·人工智能·安全·ai·软件工程
high一马平川1 小时前
# SystemUI Dagger2 精读 · 第 2 章 @Inject:1200 处构造注入,和 1 处例外
android·java·kotlin
小陈从不会敲代码1 小时前
JavaEE面试复习(多线程-初阶)
java·面试·java-ee
周厉1 小时前
酒店PMS系统设计:房型房价管理与动态价格优先级实现(Java/MySQL)
java·数据库·pms酒店系统
一碗白开水一2 小时前
Day 5 · 弹性 | 微观
java·前端·数据库
迪飞特科技2 小时前
大模型接入业务系统:API、本地部署、RAG、智能体四种路线怎么选
java·数据库·人工智能·知识图谱·deepseek·本地化大模型
vipxieliang2 小时前
ValidX 验证器缓存机制:如何提升验证性能
java·spring boot