这本质上是一个"Android 设备所有权管理"问题。本文记录从零实现的过程。
整体方案
让目标 app 通过 ADB 命令成为 Device Owner,接管设备的 launcher、Kiosk 锁定、状态栏控制等高级权限。逾期时:
- 后端接口返回
type=5(逾期状态) - DataFetchService 轮询时收到,启动
OverdueLockActivity - OverdueLockActivity 拉起后立刻调
startLockTask()进入 Kiosk 模式 - 通过
addPersistentPreferredActivity()把 Activity 注册为持久首选 launcher - 用户任何按键都被拦截,HOME 键按下后系统又跳回锁定页
- 即使电视被拔电重启,开机后 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 接管的关系。