Flutter 三方库 device_trust 的 OpenHarmony 适配实战
本文记录了将开源 Flutter 三方库
device_trust适配到 OpenHarmony / HarmonyOS 平台的完整过程,
包含适配思路、代码改动对照、关键决策和踩坑复盘。这个库的特殊性在于:它不提供任何功能,只做判断。它要回答的问题是
「当前设备是否可信」——是否被 Root、是否运行在模拟器、是否被 Hook、是否有调试器附着。
这类「安全姿态检测」高度依赖平台底层能力,因此在鸿蒙上的适配,本质上是一次
平台能力映射的完整实践。
项目地址: AtomGit/oh-flutter/device_trust
开发工具: 华为云码道
一、背景
1.1 三方库简介
device_trust 是一个 Flutter 社区使用的设备可信度检测插件,提供以下能力:
- Root / 越狱检测 —— 判断设备是否被提权(Android Root / iOS 越狱 / 鸿蒙同类风险)
- 模拟器检测 —— 判断应用是否运行在模拟器或虚拟环境中
- Hook / Frida 检测 —— 判断是否存在动态注入框架迹象
- 调试器检测 —— 判断进程是否正处于被调试状态
- 开发者模式 / 调试通道检测 —— 判断设备是否开启了开发调试相关开关
该三方库最初支持 Android、iOS 两个平台:
- Android 侧使用 Kotlin + C++(JNI),通过
Runtime.exec、/proc文件读取、系统属性访问等手段采集信号 - iOS 侧使用 Swift + Objective-C++,通过文件系统探测、URL Scheme 检测、
sysctl等手段采集信号
本次任务将其适配到 OpenHarmony / HarmonyOS 平台。
项目地址:https://atomgit.com/oh-flutter/device_trust
发布版本:3.1.0-ohos-1.0.0-beta.1
1.2 适配目标
| 维度 | 要求 |
|---|---|
| 功能一致性 | 六项信号(Root / 模拟器 / 开发者模式 / 调试通道 / Hook / 调试器)全部实现,语义与 Android/iOS 对齐 |
| Dart 层零改动 | 通道名 device_trust、方法名 getDeviceTrustReport、线格式 [version, flags, details] 及六项掩码完全不变 |
| 性能 | 检测总耗时控制在毫秒级;不引入 native 编译链路 |
| 工程规范 | 补齐双语鸿蒙 README(兼容性/权限/接口/阈值/踩坑)、module.json5 配置、oh-package.json5 声明 |
1.3 先说清这个库的特殊性
在动手之前,有必要说明为什么这个库的适配值得单独写一篇。
它不是「把某个功能搬到鸿蒙」——它没有 UI、没有业务逻辑、不产生数据。它做的全部事情是
采集信号 → 判定风险。而这恰恰是最依赖平台底层的一项工作:
| 检测维度 | Android 手段 | iOS 手段 | 鸿蒙侧需要什么 |
|---|---|---|---|
| Root / 越狱 | su 二进制、/proc/mounts、Build.TAGS | 越狱文件路径、URL Scheme | 文件系统探测 + 挂载表读取 |
| 模拟器 | Build.* 属性、QEMU 特征文件 | targetEnvironment(simulator) | 设备属性读取 |
| Hook / Frida | 端口扫描、/proc/self/maps、TracerPid | dyld 镜像检测 | 端口探测 + /proc 读取 |
| 调试器 | Debug.isDebuggerConnected() | sysctl | 系统调试状态接口 |
| 开发者模式 | Settings.Global | 不适用 | 系统设置读取 |
可以看到,几乎每一项都依赖「平台是否愿意把底层信息暴露给应用」。这就决定了:
适配的难点不在代码写法,而在逐项确认鸿蒙提供了什么、缺什么、缺的部分怎么办。
在正式动手前,我先对鸿蒙 SDK 26.0.0 做了一次能力普查,结论如下:
| 检索项 | 结果 | 影响 |
|---|---|---|
@ohos.deviceInfo | ✅ 可用 | 提供 brand / productModel / hardwareModel / buildType / deviceType 等 |
@ohos.settings | ✅ 可用 | 提供 getValueSync,含 DEVELOPMENT_SETTINGS_STATUS / DEBUGGER_WAITING |
@ohos.hidebug | ✅ 可用 | 提供 isDebugState(),对应调试器检测 |
@ohos.file.fs | ✅ 可用 | 可读取 /proc 文件(受沙箱限制) |
@ohos.net.socket | ✅ 可用 | 提供 TCP 连接,可用于端口探测 |
systemParameter(系统属性读取) | ❌ 不在公共 SDK | Android 的 ro.debuggable / ro.secure 检查无法移植 |
| 条码/安全等专用 Kit | ❌ 无对应物 | 需自行用基础能力组合实现 |
这次普查直接决定了后续两条技术路线:
- 能用系统能力的,直接用(设备属性、系统设置、调试状态)
- 系统没提供的,用基础能力组合实现(文件探测替代属性读取、TCP 连接替代端口扫描 API)
- 实在无法实现的,明确声明不支持(不硬造,也不假装做了)
二、适配路线图
整个适配分为 5 个阶段:
第 1 阶段:项目初始化 ── 生成 ohos/ 平台骨架
第 2 阶段:原生实现 ── ArkTS 实现六项信号采集(本次重心)
第 3 阶段:三方库注册 ── pubspec.yaml 声明 ohos 平台
第 4 阶段:示例工程 ── example/ohos/ 宿主工程 + 构建验证
第 5 阶段:真机验证 ── 链路验证 + 数据真实性验证
其中 第 2 阶段是绝对重心,可再分成四条子线:
第 2 阶段(原生实现)
├─ 2.1 整体架构对比 三端结构比对、确定线格式
├─ 2.2 通道与权限 建立 Dart 契约(本库无需权限)
├─ 2.3 六项信号实现 逐项映射平台能力
└─ 2.4 阈值与降级策略 多信号判定、失败静默降级
一个值得先说的数字对比:Android 侧 810 行 Kotlin(另有 C++ 层),iOS 侧 329 行 Swift,
而鸿蒙侧 486 行 ArkTS 完成了同类工作。这个比例本身就反映了平台能力的差异——
鸿蒙侧不需要写 Runtime.exec 的进程管理代码(用文件探测替代),但也少了 C++ 层的深度检测。
三、逐步适配过程
第 1 阶段:项目初始化
使用 Flutter 命令行生成 OHOS 平台骨架:
flutter create . --template=plugin --platforms=ohos
该命令自动生成 ohos/ 目录,以及示例工程的 example/ohos/。
目录结构:
ohos/
├── index.ets # 模块入口,导出插件类
├── oh-package.json5 # 包配置(本库无外部依赖)
├── build-profile.json5 # 构建配置
└── src/main/
├── module.json5 # HAR 模块配置(本库无需权限)
└── ets/components/plugin/
└── DeviceTrustPlugin.ets # 原生插件实现(核心,486 行)
关键配置文件:
index.ets(入口导出文件)
import DeviceTrustPlugin from './src/main/ets/components/plugin/DeviceTrustPlugin';
export default DeviceTrustPlugin;
oh-package.json5(包配置 —— 注意本库没有外部依赖)
{
"name": "device_trust",
"version": "1.0.0",
"description": "Heuristic device integrity signals for Flutter on HarmonyOS.",
"main": "index.ets",
"license": "MIT",
"dependencies": {}
}
注 1:
@ohos/flutter_ohos由 Flutter 引擎在构建时自动链接,无需在dependencies中显式声明。注 2:与 qr_mobile_vision 那类需要引入解码器的场景不同,
本库所需的全部能力(deviceInfo/settings/hidebug/file.fs/net.socket)都是系统自带,
这也是它适配难度相对可控的原因。
module.json5(HAR 模块配置)
{
"module": {
"name": "device_trust",
"type": "har",
"deviceTypes": ["default", "tablet"]
}
}
注意这里没有
requestPermissions。本库不申请任何权限,原因是它只读取设备自身的公开属性与进程状态,
不触碰用户隐私数据(通讯录、位置、相册等)。这是本库在权限设计上值得称道的一点,
适配时也应保持这一特性——不要为了「多检测一些」而引入权限。
第 2 阶段:原生实现(核心)
这是适配的核心工作。Android 侧用 810 行 Kotlin + C++ 完成的事,在鸿蒙侧要用 ArkTS 重新实现,
且需要逐项确认平台能力是否可用。
2.1 整体架构对比
首先判断插件类型。device_trust 属于 方法调用型,且是其中最简单的一类:
- 只有一个方法:
getDeviceTrustReport - 无事件流、无反向回调、无生命周期依赖
- 调用即返回,无状态
三端架构对照:
Android (Kotlin + C++) OHOS (ArkTS)
──────────────────────── ────────────────────────
class DeviceTrustPlugin export default class DeviceTrustPlugin
: FlutterPlugin, implements FlutterPlugin,
MethodCallHandler MethodCallHandler
┌─ 通道 ──────────────┐ ┌─ 通道 ──────────────┐
│ MethodChannel │ │ MethodChannel │
│ "device_trust" │ │ "device_trust" │ ← 契约不变
└─────────────────────┘ └─────────────────────┘
┌─ 信号采集 ──────────┐ ┌─ 信号采集 ──────────┐
│ object DeviceTrust │ │ this.checkXxx() │
│ 6 个 checkXxx() │ │ 6 个 checkXxx() │
│ + C++ JNI 深层检查 │ │ (纯 ArkTS,无 JNI)│
└─────────────────────┘ └─────────────────────┘
线格式(三端统一):
[WIRE_FORMAT_VERSION, flags, details]
↑ 固定为 1 ↑ 位掩码 ↑ 明细 Map
这是本库一个很聪明的设计:原生侧不返回普通 Map,而是返回三元数组,
把六个布尔值压缩成一个 flags 位掩码。好处是:
- 传输体积小(一个 int 代替六个 bool)
- 协议版本可演进(第一个元素即版本号)
- Dart 侧解析逻辑统一,且能向前兼容旧格式
Dart 侧对应的解析逻辑(上游代码,本次未改动):
// lib/device_trust_method_channel.dart(未改动)
final version = result[0];
final flags = result[1];
final details = result[2];
if (version is! int ||
version != _reportProtocolVersion ||
flags is! int ||
flags < 0 ||
details is! Map<Object?, Object?> ||
details.keys.any((key) => key is! String)) {
throw _invalidReportPayload();
}
适配要点:鸿蒙侧必须返回完全相同的三元数组,且
version必须是int(值为1),
flags必须是非负整数。Dart 侧对类型校验很严格,任何一个元素类型不对都会抛
INVALID_REPORT_PAYLOAD。这一点在第六章的验证中会重点确认。
六项信号与掩码(协议常量,三端一致):
| 信号 | 掩码 | Android 语义 | iOS 语义 | 鸿蒙语义 |
|---|---|---|---|---|
rootedOrJailbroken | 1 | 设备已 Root | 设备已越狱 | 已提权/系统被改动 |
emulator | 2 | 运行在模拟器 | 运行在模拟器 | 运行在模拟器/虚拟环境 |
devModeEnabled | 4 | 开发者模式开启 | 不适用(恒 false) | 开发者模式开启 |
adbEnabled | 8 | ADB 开启 | 不适用(恒 false) | 调试通道开启 |
fridaSuspected | 16 | 检测到 Frida/Hook | 检测到 Frida/Hook | 检测到 Frida/Hook |
debuggerAttached | 32 | 调试器附着 | 调试器附着 | 调试器附着 |
2.2 通道注册与权限
通道注册(三端几乎一致):
| 平台 | 代码 |
|---|---|
| Android | MethodChannel(binding.binaryMessenger, "device_trust") |
| iOS | FlutterMethodChannel(name: "device_trust", binaryMessenger: ...) |
| OHOS | new MethodChannel(binding.getBinaryMessenger(), "device_trust") |
onAttachedToEngine(binding: FlutterPluginBinding): void {
this.channel = new MethodChannel(binding.getBinaryMessenger(), CHANNEL_NAME);
this.channel.setMethodCallHandler(this);
}
onDetachedFromEngine(binding: FlutterPluginBinding): void {
if (this.channel != null) {
this.channel.setMethodCallHandler(null);
this.channel = null;
}
}
方法分发:
onMethodCall(call: MethodCall, result: MethodResult): void {
if (call.method === 'getDeviceTrustReport') {
// 报告构建是异步的(端口探测需等待),这里用 Promise 链返回
this.buildWirePayload()
.then((payload: Array<Object>) => {
result.success(payload);
})
.catch((err: BusinessError) => {
hilog.error(DOMAIN, TAG, `buildReport failed: ${err.code} ${err.message}`);
result.error('DEVICE_TRUST_ERROR', err.message, null);
});
} else {
result.notImplemented();
}
}
与 Android 侧的对比:
// Android(Kotlin)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getDeviceTrustReport" -> {
try {
val report = DeviceTrust.buildReport(appContext)
result.success(report.toWirePayload())
} catch (e: Exception) {
result.error("DEVICE_TRUST_ERROR", e.message, null)
}
}
else -> result.notImplemented()
}
}
差异点:Android 侧
buildReport是同步的,可以直接try/catch包裹;
而鸿蒙侧因为端口探测必须异步等待(见 2.3 与第八章踩坑复盘),
buildWirePayload变成了async方法,因此要用 Promise 链处理成功与失败分支。错误码
DEVICE_TRUST_ERROR保持一致——这是 Dart 侧可能依赖的契约。
权限:申请「无权限」
如前所述,本库不申请任何权限。这一点在鸿蒙侧同样成立:
// module.json5 —— 注意没有 requestPermissions 字段
{
"module": {
"name": "device_trust",
"type": "har",
"deviceTypes": ["default", "tablet"]
}
}
这带来一个额外好处:鸿蒙侧的权限模型要求「应用侧也要声明并带
reason资源」,否则权限申请会静默失败。
本库既然不需要权限,就完全避开了这个坑。
2.3 六项信号的实现对照
信号 1:Root / 越狱检测
Android 实现(6 类信号,810 行中的一部分):
private fun checkRootSignals(context: Context, details: MutableMap<String, Any?>): Int {
var signals = 0
// 1. Build.TAGS 含 "test-keys"
val hasTestKeys = Build.TAGS?.contains("test-keys") == true
details["buildTestKeys"] = hasTestKeys
if (hasTestKeys) signals++
// 2. su 二进制存在
val suExists = checkSuBinary()
details["suExists"] = suExists
if (suExists) signals++
// 3. 执行 "which su"
val whichSuResult = executeWhichSu()
details["whichSu"] = whichSuResult
if (whichSuResult != null && whichSuResult.isNotEmpty()) signals++
// 4. 危险系统属性(ro.debuggable / ro.secure)
val dangerousProps = checkDangerousProps()
details["dangerousProps"] = dangerousProps
if (dangerousProps.isNotEmpty()) signals++
// 5. /proc/mounts 中系统分区被挂载为 rw
val rwMounts = checkRwMounts()
details["rwMounts"] = rwMounts
if (rwMounts) signals++
// 6. 已知 Root 包(Magisk、SuperSU 等)
val rootPackages = checkKnownRootPackages(context)
details["knownRootPackages"] = rootPackages
if (rootPackages.isNotEmpty()) signals++
details["rootSignals"] = signals
return signals
}
OHOS 实现(4 类信号):
private checkRootSignals(details: Map<string, Object>): boolean {
let signals: number = 0;
// 1) su 二进制存在(多路径探测,等价 Android 的 checkSuBinary)
const suExists: boolean = this.checkFileExists(SU_PATHS);
details.set('suExists', suExists);
if (suExists) {
signals++;
}
// 2) 根环境特征文件(等价 Android 的已知 Root 包检测思路)
const rootFiles: string[] = this.checkExistingPaths(ROOT_INDICATOR_PATHS);
details.set('knownRootFiles', rootFiles);
if (rootFiles.length > 0) {
signals++;
}
// 3) /proc/mounts 中系统分区被挂载为 rw(与 Android 逻辑一致)
const rwMounts: boolean = this.checkRwMounts();
details.set('rwMounts', rwMounts);
if (rwMounts) {
signals++;
}
// 4) 系统构建类型:root/log 版本通常允许更高权限
const buildType: string = this.safeString(() => deviceInfo.buildType);
details.set('buildType', buildType);
if (buildType === 'root' || buildType === 'log') {
signals++;
}
details.set('rootSignals', signals);
// 与 Android 侧一致:至少 1 个强信号即判定
return signals >= 1;
}
能力差异说明:
| Android 信号 | 鸿蒙侧 | 原因 |
|---|---|---|
Build.TAGS test-keys | ❌ 无对应 | 鸿蒙无此构建标记概念 |
| su 二进制 | ✅ fs.accessSync() | 文件探测,能力等价 |
which su 命令执行 | ❌ 未实现 | 鸿蒙未提供进程执行接口,且无必要(文件探测已覆盖) |
ro.debuggable / ro.secure | ❌ 无对应 | 公共 SDK 未开放系统属性读取接口 |
/proc/mounts rw 挂载 | ✅ fs.readTextSync() | 能力等价 |
| 已知 Root 包遍历 | ⚠️ 改为特征文件 | 鸿蒙包管理与 Android 不同,改探测文件更直接 |
| —— | ✅ 新增 buildType | 鸿蒙特有信号,作为补充 |
关于「无法实现」的态度:
ro.debuggable这类检查在鸿蒙侧确实做不到,
我的处理是明确不实现,并在 README 中声明,而不是用一个语义相近但不同的检查去充数。
后者会让使用者误以为该能力存在,反而更危险。
信号 2:模拟器检测
Android 实现(10 类信号):
private fun checkEmulatorSignals(details: MutableMap<String, Any?>): Int {
var signals = 0
val indicators = mutableListOf<String>()
var strongIndicator = false
// 强信号 1:QEMU 属性
val qemu = getSystemProperty("ro.kernel.qemu")
if (qemu == "1") {
indicators.add("strong:qemu=1"); signals++; strongIndicator = true
}
// 强信号 2:QEMU 特征文件
val qemuFiles = listOf("/init.goldfish.rc", "/sys/qemu_trace", "/dev/qemu_pipe", "/dev/socket/qemud")
qemuFiles.forEach { path ->
if (File(path).exists()) {
indicators.add("strong:file:$path"); signals++; strongIndicator = true
}
}
// 常规信号:Build 属性(8 项:FINGERPRINT / MODEL / MANUFACTURER / HARDWARE / PRODUCT / BOARD / BRAND / DEVICE)
if (Build.FINGERPRINT.contains("generic", ignoreCase = true) || ...) { ... }
// ... 其余 7 项同理
}
OHOS 实现:
private checkEmulatorSignals(details: Map<string, Object>): boolean {
let signals: number = 0;
const indicators: string[] = [];
let strongIndicator: boolean = false;
const brand: string = this.safeString(() => deviceInfo.brand);
const model: string = this.safeString(() => deviceInfo.productModel);
const hardware: string = this.safeString(() => deviceInfo.hardwareModel);
const deviceType: string = this.safeString(() => deviceInfo.deviceType);
// 强信号:productModel 明确为 emulator(OpenHarmony 模拟器特征)
if (model.toLowerCase() === 'emulator') {
indicators.push(`strong:model=${model}`);
signals++;
strongIndicator = true;
}
// 常规信号:品牌/型号/硬件中的虚拟化关键词
const markers: string[] = ['emulator', 'simulator', 'virtual', 'sdk', 'aosp', 'genymotion', 'vbox'];
if (this.containsAny(model, markers)) { indicators.push(`model:${model}`); signals++; }
if (this.containsAny(brand, markers)) { indicators.push(`brand:${brand}`); signals++; }
if (this.containsAny(hardware, markers)) { indicators.push(`hardware:${hardware}`); signals++; }
details.set('emulatorIndicators', indicators);
details.set('emulatorSignals', signals);
details.set('emulatorStrong', strongIndicator);
details.set('deviceBrand', brand);
details.set('deviceModel', model);
details.set('deviceHardware', hardware);
details.set('deviceType', deviceType);
// 与 Android 侧一致:强信号,或至少 2 个常规信号
return strongIndicator || signals >= 2;
}
能力差异说明:
| Android 信号 | 鸿蒙侧 | 原因 |
|---|---|---|
ro.kernel.qemu 属性 | ❌ 无对应 | 系统属性接口未开放 |
| QEMU 特征文件 | ⚠️ 未实现 | 鸿蒙模拟器无 goldenfish 等特征文件 |
Build.FINGERPRINT 等 8 项 | ⚠️ 简化为 3 项 | 鸿蒙 deviceInfo 提供的字段不同(brand / productModel / hardwareModel) |
| —— | ✅ 新增「强信号」判定 | 型号明确为 emulator 时直接定性 |
一个设计取舍:Android 侧检查 8 个
Build字段,鸿蒙侧只有 3 个可用字段。
我没有为了「凑数量」而引入语义不明确的字段,而是保持判定逻辑一致(强信号 OR ≥2 常规信号)。
这样即使信号数量少,误报控制策略是相同的。
信号 3 & 4:开发者模式与调试通道
这是两端差异最大的一项。
Android 实现:
private fun checkDeveloperMode(context: Context, details: MutableMap<String, Any?>): Boolean {
return try {
val devEnabled = Settings.Global.getInt(
context.contentResolver,
Settings.Global.DEVELOPMENT_SETTINGS_ENABLED,
0
) == 1
details["devSettingsEnabled"] = devEnabled
devEnabled
} catch (e: Exception) {
details["devSettingsError"] = e.message
false
}
}
private fun checkAdbEnabled(context: Context, details: MutableMap<String, Any?>): Boolean {
return try {
val adbEnabled = Settings.Global.getInt(
context.contentResolver,
Settings.Global.ADB_ENABLED,
0
) == 1
details["adbEnabled"] = adbEnabled
adbEnabled
} catch (e: Exception) {
details["adbEnabledError"] = e.message
false
}
}
OHOS 实现:
private checkDeveloperMode(details: Map<string, Object>): boolean {
const context = this.ability;
if (context == null) {
details.set('devSettingsError', 'ability is null');
return false;
}
try {
const value: string = settings.getValueSync(
context, settings.general.DEVELOPMENT_SETTINGS_STATUS, '0');
const enabled: boolean = value === '1';
details.set('devSettingsEnabled', enabled);
return enabled;
} catch (err) {
const e = err as BusinessError;
details.set('devSettingsError', `${e.code} ${e.message}`);
return false;
}
}
private checkAdbEnabled(details: Map<string, Object>): boolean {
const context = this.ability;
if (context == null) {
details.set('adbEnabledError', 'ability is null');
return false;
}
try {
// 鸿蒙侧无 ADB,对应能力为调试通道;DEBUGGER_WAITING 表示等待调试器接入
const value: string = settings.getValueSync(
context, settings.general.DEBUGGER_WAITING, '0');
const enabled: boolean = value === '1';
details.set('adbEnabled', enabled);
return enabled;
} catch (err) {
const e = err as BusinessError;
details.set('adbEnabledError', `${e.code} ${e.message}`);
return false;
}
}
关键差异:
| 项 | Android | OHOS |
|---|---|---|
| 访问方式 | Settings.Global.getInt(contentResolver, key, default) | settings.getValueSync(context, key, default) |
| 返回值类型 | Int | String(需自行比较 '1') |
| 开发者模式键 | Settings.Global.DEVELOPMENT_SETTINGS_ENABLED | settings.general.DEVELOPMENT_SETTINGS_STATUS |
| 调试通道键 | Settings.Global.ADB_ENABLED | settings.general.DEBUGGER_WAITING |
| 命名空间 | Settings.Global | settings.general(注意是 general,不是 display) |
| 异常处理 | try/catch 返回 false | 同样 try/catch 返回 false |
两处必须注意的地方:
命名空间是
settings.general。我最初凭直觉写成了settings.display,导致 ArkTS 编译报
Property 'DEVELOPMENT_SETTINGS_STATUS' does not exist on type 'typeof display'。
正确位置需从 SDK 的.d.ts中逐行确认(见第八章踩坑复盘)。返回值是 String 不是 Int。Android 侧
getInt直接拿到数字,鸿蒙侧getValueSync返回字符串,
需要value === '1'比较。若照搬 Android 的== 1写法会恒为false(类型不匹配),
而且不会报错——这是个容易埋下的静默 bug。
语义映射的说明:鸿蒙没有 ADB,
adbEnabled无法一比一对应。
我选择映射到DEBUGGER_WAITING(调试通道),并在 README 中明确写出这是语义映射而非等价。
这样做的好处是:使用者知道该字段在鸿蒙上的确切含义,不会误以为它在检测真实 ADB。
信号 5:Hook / Frida 检测
Android 实现(3 类信号 + native 层):
private fun checkHookSignals(details: MutableMap<String, Any?>): Boolean {
var signals = 0
// 1. Frida 端口扫描(27042, 27043)
val openPorts = scanFridaPorts()
details["fridaPortsOpen"] = openPorts
if (openPorts.isNotEmpty()) signals++
// 2. /proc/self/maps 可疑模块
val suspiciousMaps = scanProcSelfMaps()
details["suspiciousMaps"] = suspiciousMaps
if (suspiciousMaps.isNotEmpty()) signals++
// 3. TracerPid 检查
val tracerPid = checkTracerPid()
details["tracerPid"] = tracerPid
if (tracerPid > 0) signals++
details["kotlinHookSignals"] = signals
return signals >= 2 // 至少 2 个信号
}
OHOS 实现(3 类信号,全部纯 ArkTS):
private async checkHookSignals(details: Map<string, Object>): Promise<boolean> {
let signals: number = 0;
// 1) /proc/self/maps 可疑模块
const suspiciousMaps: string[] = this.scanProcSelfMaps();
details.set('suspiciousMaps', suspiciousMaps);
if (suspiciousMaps.length > 0) {
signals++;
}
// 2) TracerPid(被调试/注入时会大于 0)
const tracerPid: number = this.checkTracerPid();
details.set('tracerPid', tracerPid);
if (tracerPid > 0) {
signals++;
}
// 3) Frida 端口扫描(异步:必须 await,否则字段在序列化前尚未写入)
const openPorts: number[] = await this.scanFridaPorts();
details.set('fridaPortsOpen', openPorts);
if (openPorts.length > 0) {
signals++;
}
details.set('kotlinHookSignals', signals);
// 与 Android 侧一致:至少 2 个信号才判定
return signals >= 2;
}
/proc/self/maps 扫描(两端逻辑一致):
private scanProcSelfMaps(): string[] {
const suspicious: string[] = [];
try {
const content: string = fs.readTextSync('/proc/self/maps');
const lines: string[] = content.split('\n');
for (const line of lines) {
const lower: string = line.toLowerCase();
for (const keyword of HOOK_KEYWORDS) { // frida, gum-js-loop, substrate, xposed, lsposed
if (lower.includes(keyword)) {
suspicious.push(line.trim());
break;
}
}
}
} catch (err) {
// /proc 在沙箱内可能不可读,失败即视为无信号
}
return suspicious;
}
TracerPid 读取(两端逻辑一致):
private checkTracerPid(): number {
try {
const content: string = fs.readTextSync('/proc/self/status');
const lines: string[] = content.split('\n');
for (const line of lines) {
if (line.startsWith('TracerPid:')) {
const parts: string[] = line.split(':');
if (parts.length >= 2) {
const parsed: number = Number.parseInt(parts[1].trim(), 10);
return Number.isNaN(parsed) ? 0 : parsed;
}
}
}
} catch (err) {
// 读取失败视为无信号
}
return 0;
}
端口探测(实现方式差异最大的一项):
private async scanFridaPorts(): Promise<number[]> {
const openPorts: number[] = [];
for (const port of FRIDA_PORTS) { // 27042, 27043
try {
const tcp: socket.TCPSocket = socket.constructTCPSocketInstance();
const options: socket.TCPConnectOptions = {
address: { address: '127.0.0.1', port: port },
timeout: PORT_PROBE_TIMEOUT_MS, // 300ms
};
await tcp.connect(options);
// 连接成功说明端口在监听
openPorts.push(port);
try {
tcp.close();
} catch (err) {
// 忽略关闭异常
}
} catch (err) {
// 端口未监听或超时,视为无信号
}
}
return openPorts;
}
对照 Android 侧:
private fun scanFridaPorts(): List<Int> {
val openPorts = mutableListOf<Int>()
FRIDA_PORTS.forEach { port ->
try {
val socket = Socket()
socket.connect(InetSocketAddress("127.0.0.1", port), 15) // 15ms 超时
socket.close()
openPorts.add(port)
} catch (e: Exception) {
// 端口关闭或超时
}
}
return openPorts
}
这里有一个必须处理的异步问题:Android 的
Socket.connect是阻塞式的,写完就能立即拿到结果;
而鸿蒙的TCPSocket.connect返回 Promise,是异步的。我最初的实现犯了错:用「回调补写 details」的方式处理端口结果,
导致报告在回调完成前就已经序列化发出,fridaPortsOpen字段被静默丢失。
修复方式是把整条链路改为async/await(详见第八章踩坑复盘)。另外超时值也不同:Android 用 15ms(阻塞式,超时即表示端口关闭),
鸿蒙用 300ms(异步连接,需要给网络栈留出响应时间)。这是根据实测调整的。
能力差异说明:
| Android 信号 | 鸿蒙侧 | 原因 |
|---|---|---|
| Frida 端口扫描 | ✅ net.socket TCP 连接 | 能力等价,但需异步处理 |
/proc/self/maps | ✅ fs.readTextSync | 能力等价 |
TracerPid | ✅ fs.readTextSync | 能力等价 |
| C++ 层:RWX 段检测 | ❌ 未实现 | 需 native 层,未引入 NDK |
| C++ 层:文件描述符扫描 | ❌ 未实现 | 同上 |
C++ 层:dladdr 符号校验 | ❌ 未实现 | 同上 |
信号 6:调试器检测
Android 实现:
private fun checkDebugger(details: MutableMap<String, Any?>): Boolean {
val isAttached = Debug.isDebuggerConnected() || Debug.waitingForDebugger()
details["debuggerConnected"] = isAttached
return isAttached
}
OHOS 实现:
private checkDebugger(details: Map<string, Object>): boolean {
let attached: boolean = false;
try {
attached = hidebug.isDebugState();
} catch (err) {
const e = err as BusinessError;
details.set('debuggerError', `${e.code} ${e.message}`);
}
details.set('debuggerConnected', attached);
return attached;
}
| 项 | Android | OHOS |
|---|---|---|
| API | Debug.isDebuggerConnected() / Debug.waitingForDebugger() | hidebug.isDebugState() |
| 返回 | boolean | boolean |
| 差异 | 两个 API 取「或」 | 单一 API 覆盖两种状态 |
这是六项信号中映射最干净的一项:语义一致、返回类型一致、调用方式一致。
说明鸿蒙在「调试状态」这个基础能力上是完备的。
2.4 阈值与降级策略
多信号阈值设计
本库的核心设计哲学是不用单信号判定。原因很实际:
开发者自己的手机上,开发者模式和调试通道本来就是开着的。
如果单信号就报警,等于把所有开发者设备都误判为「不可信」。
因此各信号采用阈值判定,鸿蒙侧与 Android 侧保持一致的阈值:
| 信号 | 判定条件 | 设计理由 |
|---|---|---|
rootedOrJailbroken | 4 类信号中至少 1 个命中 | Root 属严重风险,敏感度高,单信号即可疑 |
emulator | 强信号,或至少 2 个常规信号 | 强信号(型号明确为 emulator)可信度高;常规信号需多个佐证 |
fridaSuspected | 3 类信号中至少 2 个命中 | Hook 检测误报成本高,要求多个独立信号交叉印证 |
devModeEnabled | DEVELOPMENT_SETTINGS_STATUS == 1 | 单一明确开关,无需阈值 |
adbEnabled | DEBUGGER_WAITING == 1 | 同上 |
debuggerAttached | hidebug.isDebugState() == true | 系统权威判断,无需阈值 |
失败静默降级
检测类代码有一个特殊要求:不能因为某一项检测失败而导致整个报告失败。
Android 侧的做法是每项检查都 try/catch,失败就返回 false 并把错误写入 details。
鸿蒙侧沿用了这一策略:
private checkDeveloperMode(details: Map<string, Object>): boolean {
const context = this.ability;
if (context == null) {
details.set('devSettingsError', 'ability is null');
return false; // ← 不抛异常,降级为 false
}
try {
const value: string = settings.getValueSync(
context, settings.general.DEVELOPMENT_SETTINGS_STATUS, '0');
const enabled: boolean = value === '1';
details.set('devSettingsEnabled', enabled);
return enabled;
} catch (err) {
const e = err as BusinessError;
details.set('devSettingsError', `${e.code} ${e.message}`);
return false; // ← 记录错误原因,仍返回 false
}
}
同样地,/proc 读取失败时:
try {
const content: string = fs.readTextSync('/proc/self/maps');
// ...
} catch (err) {
// /proc 在沙箱内可能不可读,失败即视为无信号
}
这套策略的三层价值:
- 不崩:任一检测项异常都不会导致
getReport()抛异常 - 可查:失败原因写入
details(如devSettingsError),便于定位 - 可解释:返回
false而非null,语义上表示「未检测到该风险」,符合调用方预期
一个反问值得记录:检测失败时返回
false,会不会掩盖真实风险?
严格说会——如果/proc/self/maps因沙箱限制读不到,Hook 检测能力就是下降的。
但这是平台限制而非实现缺陷,正确做法是:返回false保证功能可用,
同时把失败原因写入details,让使用者自行判断可信度。
这也是我在 README 的「遗留问题」中明确写出/proc可读性的原因。
第 3 阶段:三方库注册
在 pubspec.yaml 中添加 OHOS 平台注册:
flutter:
plugin:
platforms:
android:
package: com.mikoloy.device_trust
pluginClass: DeviceTrustPlugin
ios:
pluginClass: DeviceTrustPlugin
ohos: # ← 新增
pluginClass: DeviceTrustPlugin # ← 对应 index.ets 默认导出
Flutter 的 OHOS 引擎在构建时会读取 pubspec.yaml 中的 ohos 配置,
自动加载 ohos/index.ets 中导出的插件类。
修改量统计:
pubspec.yaml仅新增 2 行。Dart 层、Android 层、iOS 层代码零改动。
第 4 阶段:示例工程创建与构建验证
在 example/ 目录下生成 OHOS 宿主工程(注意:在 example 目录执行,而非插件根目录):
cd example
flutter create . --platforms=ohos
该命令自动生成 example/ohos/,包含签名配置、SDK 版本、测试模块与 Flutter 运行时资源:
example/ohos/
├── AppScope/app.json5 # 应用配置(bundleName 等)
├── build-profile.json5 # 项目构建配置(signingConfigs、SDK 版本)
├── hvigor/hvigor-config.json5 # 构建工具配置
├── entry/
│ ├── build-profile.json5
│ ├── src/main/
│ │ ├── module.json5 # entry 模块配置
│ │ ├── ets/
│ │ │ ├── entryability/EntryAbility.ets # Ability 生命周期
│ │ │ ├── pages/Index.ets # Flutter 容器页
│ │ │ └── plugins/GeneratedPluginRegistrant.ets # 自动注册插件
│ │ └── resources/rawfile/flutter_assets/ # Flutter 运行时资源
│ └── src/ohosTest/ # 测试目录
构建验证:
cd example
flutter build hap --debug --target-platform ohos-arm64
成功产出:
✓ Built build/ohos/hap/entry-default-signed.hap
签名提示:首次构建会提示「请通过 DevEco Studio 配置调试签名」。
需在example/ohos/build-profile.json5的signingConfigs中填入调试证书,
或通过 DevEco Studio 的File → Project Structure → Signing Configs自动生成。
第 5 阶段:真机验证
本阶段的验证策略与证据详见第六章。

四、完整代码对照
4.1 三端检测能力对照总表
这张表是本次适配最重要的一张表——它清晰地展示了同一个检测库在三个平台上的能力差异。
| 检测项 | Android | iOS | HarmonyOS | 说明 |
|---|---|---|---|---|
| su 二进制探测 | ✅ | — | ✅ | 文件系统探测,三端思路一致 |
| Root 特征文件 | ✅ 包遍历 | ✅ 路径探测 | ✅ 路径探测 | 手段不同,效果相近 |
/proc/mounts rw 挂载 | ✅ | ❌ | ✅ | 意外地,鸿蒙保留了 /proc |
Build.TAGS test-keys | ✅ | — | ❌ | 鸿蒙无此概念 |
ro.debuggable 属性 | ✅ | — | ❌ 无对应 | 公共 SDK 未开放系统属性读取 |
| 越狱 URL Scheme | — | ✅ | — | iOS 特有 |
| 沙箱逃逸探测 | — | ✅ | — | iOS 特有 |
buildType 判定 | ❌ | — | ✅ | 鸿蒙特有信号 |
| 模拟器属性检测 | ✅ 8 个字段 | ✅ targetEnvironment | ⚠️ 3 个字段 | 鸿蒙可用字段较少 |
| 开发者模式 | ✅ Settings.Global | ❌ 不适用 | ✅ settings.general | 键名与命名空间均不同 |
| 调试通道 | ✅ ADB_ENABLED | ❌ 不适用 | ⚠️ DEBUGGER_WAITING | 语义映射,非等价 |
| Frida 端口探测 | ✅ | ✅ | ✅ | 鸿蒙需异步处理 |
/proc/self/maps | ✅ | — | ✅ | 能力等价 |
TracerPid | ✅ | — | ✅ | 能力等价 |
| C++ 层 RWX 段检测 | ✅ | — | ❌ | 需 native 层 |
| C++ 层 fd 扫描 | ✅ | — | ❌ | 同上 |
dladdr 符号校验 | ✅ | — | ❌ | 同上 |
dyld 镜像检测 | — | ✅ | — | iOS 特有 |
| 调试器检测 | ✅ Debug API | ✅ sysctl | ✅ hidebug.isDebugState() | 三端都有权威接口 |
| 检测项合计 | 约 12 项 | 约 6 项 | 约 10 项 |
一个反直觉的发现:鸿蒙侧的可实现检测项数量(约 10 项)多于 iOS(约 6 项),
接近 Android。主要原因是鸿蒙基于 Linux 内核,保留了/proc文件系统与文件权限模型,
这使得「读/proc」「探测文件」这类 Linux 风格的检测手段可以直接使用。而 iOS 因为沙箱与系统封闭性,能用的手段反而更少——它主要靠 URL Scheme 与路径探测。
4.2 三端实现规模对照
| 维度 | Android | iOS | HarmonyOS |
|---|---|---|---|
| 语言 | Kotlin + C++(JNI) | Swift + Objective-C++ | 纯 ArkTS |
| 主实现行数 | 810 行 | 329 行 | 486 行 |
| 是否需 native 编译 | ✅ 需要(NDK) | ✅ 需要 | ❌ 不需要 |
| 权限需求 | 无 | 无 | 无 |
| 外部依赖 | com.google.mlkit 等 | 无 | 无 |
| 构建产物 | AAR + .so | Framework | HAR(纯 ArkTS) |
工程复杂度的一个重要结论:鸿蒙侧的 486 行纯 ArkTS 实现,在整个适配过程中
没有引入任何 native 编译链路——不需要 NDK、不需要 CMake、不需要配置.so打包。
这显著降低了适配与后续维护成本(对比 Android 侧需要维护 C++ 代码与交叉编译配置)。代价是失去了 C++ 层的深度检测能力(RWX 段、fd 扫描、
dladdr校验),
但考虑到这些都是锦上添花的增强项(核心的 maps 扫描与端口探测已覆盖),
这个取舍是合理的。
4.3 关键 ArkTS 语法差异
| Java / Kotlin 语法 | ArkTS 语法 | 备注 |
|---|---|---|
import android.os.Build; | import { deviceInfo } from '@kit.BasicServicesKit'; | ArkTS 从 Kit 统一入口导入 |
Build.MODEL | deviceInfo.productModel | 字段名不同,需逐一对照 |
Settings.Global.getInt(cr, k, 0) | settings.getValueSync(ctx, k, '0') | 返回值从 Int 变为 String |
new HashMap<String, Object>() | new Map<string, Object>() | 泛型语法不同 |
map.put(k, v) | map.set(k, v) | Map 方法名不同 |
File(path).exists() | fs.accessSync(path) | 不存在时抛异常,需 try/catch |
file.readText() | fs.readTextSync(path) | 同步读取 |
try { } catch (Exception e) { } | try { } catch (err) { const e = err as BusinessError; } | 需显式类型断言 |
Debug.isDebuggerConnected() | hidebug.isDebugState() | 语义一致 |
socket.connect(addr, timeout) | await tcp.connect({ address, timeout }) | 阻塞式变 Promise |
String.format("...%s", v) | `...${v}` | 模板字符串 |
listOf(a, b) | const x: string[] = [a, b] | 数组字面量 |
4.4 一处必须警惕的「同名不同义」
鸿蒙 settings.getValueSync 与 Android Settings.Global.getInt 看似对应,实则有两个隐藏差异:
// Android:返回 Int,直接与 1 比较
val devEnabled = Settings.Global.getInt(
context.contentResolver,
Settings.Global.DEVELOPMENT_SETTINGS_ENABLED,
0
) == 1
// OHOS:返回 String,必须与 '1' 比较
const value: string = settings.getValueSync(
context, settings.general.DEVELOPMENT_SETTINGS_STATUS, '0');
const enabled: boolean = value === '1';
两个差异点:
| 差异 | Android | OHOS | 踩坑风险 |
|---|---|---|---|
| 返回类型 | Int | String | 若照搬 == 1 写法,类型不匹配导致恒为 false,且不报错 |
| 命名空间 | Settings.Global | settings.general | 写成 settings.display 会编译失败 |
第一个差异尤其危险:它不会编译失败,只会静默返回错误结果。
如果一个检测项因为类型不匹配而恒为false,表现就是「这个检测永远检测不出问题」——
在开发阶段几乎不可能发现,只有在真机上遇到真实风险设备时才会暴露。这也是为什么第八章的验证策略中,我特别强调要确认「
details中读到了真实设备值」——
这能证明检测逻辑确实在执行,而不是走了某个恒假的短路分支。
五、关键决策说明
决策 1:保持通道名与线格式完全不变
Dart 侧 MethodChannel('device_trust') 已固定,且对返回格式做了严格的类型校验:
// lib/device_trust_method_channel.dart(未改动)
static const int _reportProtocolVersion = 1;
static const MethodChannel _channel = MethodChannel('device_trust');
final version = result[0];
final flags = result[1];
final details = result[2];
if (version is! int ||
version != _reportProtocolVersion ||
flags is! int ||
flags < 0 ||
details is! Map<Object?, Object?> ||
details.keys.any((key) => key is! String)) {
throw _invalidReportPayload();
}
这意味着鸿蒙侧必须精确满足四个条件:
- 返回三元数组(不是普通 Map)
version必须是int(值为1)flags必须是非负整数details必须是 String 键的 Map
维护策略:六项掩码与协议版本号抽为常量,并加注释说明「这是协议的一部分,不可更改」。
决策 2:Dart 层零改动
Dart API 通过 MethodChannel.invokeMethod('getDeviceTrustReport') 调用,
返回结果由 DeviceTrustReport.fromMap 解析。鸿蒙侧只要返回相同结构,
Dart 层与其它平台代码完全不受影响。
维护策略:任何新增检测能力都应通过扩充 details 实现,不改变既有字段的语义。
决策 3:不引入 native 层(放弃 C++ 深度检测)
Android 侧有一个 C++ 层,负责三项更深度的检测:
/proc/self/maps中的 RWX(可读可写可执行)内存段/proc/self/fd中的 Frida 相关文件描述符dladdr符号校验(检查getpid是否指向预期库)
鸿蒙侧的取舍:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 纯 ArkTS 实现 ✅ | 无 NDK 依赖、无 CMake 配置、无需交叉编译、构建简单、维护成本低 | 失去 RWX 段、fd 扫描、dladdr 三项深度检测 |
| 引入 C++ 层 | 检测更深度 | 需搭建完整 native 构建链路,工程复杂度陡增;且鸿蒙应用是否允许此类底层访问仍需验证 |
选择纯 ArkTS。理由:
- 核心检测已覆盖 —— Hook 检测的三个关键信号(maps 扫描、TracerPid、端口探测)都已实现,
丢失的是「进一步佐证」的增强项 - 成本收益不成比例 —— 为三个增强项引入整套 NDK 构建链路,维护负担远超收益
- 明示优于隐瞒 —— 缺失的能力在 README 的「遗留问题」中明确列出,使用者可自行判断
维护策略:若后续有明确需求,可在 ohos/ 下新增 src/main/cpp/ 目录接入 native 层,
不影响现有 ArkTS 代码结构。
决策 4:adbEnabled 采用语义映射而非强行等价
鸿蒙没有 ADB 概念,这一项无法一比一对应。
| 方案 | 优点 | 缺点 |
|---|---|---|
映射到 DEBUGGER_WAITING ✅ | 语义上最接近(都是「调试通道开启」);有真实的系统设置项支撑 | 与 Android 的 ADB 语义不完全相同 |
恒返回 false | 绝无误报 | 完全丢失该维度的信号 |
| 用其它设置项拼凑 | 看起来「实现了」 | 语义偏差更大,误导使用者 |
选择映射到 DEBUGGER_WAITING,并在 README 中明确标注这是语义映射:
注意:
devModeEnabled与adbEnabled在 iOS 上恒为false(该平台无对应概念);
鸿蒙侧adbEnabled对应的是"调试通道"而非 Android 的 ADB,实现上映射到DEBUGGER_WAITING。
维护策略:若鸿蒙后续提供 ADB 等价能力,替换该处实现即可。
决策 5:端口探测改为异步等待(修复了一个真实缺陷)
这是本次适配中最有价值的一个决策,因为它源于一个真机上才暴露的 bug。
问题现象:真机跑通后,details 中缺少 fridaPortsOpen 字段。
根因分析:
// ❌ 最初的错误实现:回调补写 details
private checkHookSignals(details: Map<string, Object>): boolean {
// ...(同步检测部分)
const suspected: boolean = signals >= 2;
// 端口扫描是异步的,结果通过回调补写 details
this.scanFridaPorts((openPorts: number[]) => {
details.set('fridaPortsOpen', openPorts); // ← 这里写入时,报告可能已经发出
});
return suspected; // ← 同步返回,报告立即序列化
}
buildWirePayload 是同步的,它调用 checkHookSignals 后立即序列化 details。
而端口扫描的 Promise 尚未 resolve,回调还没执行——字段写入发生在序列化之后,被静默丢弃。
修复后:
// ✅ 正确实现:await 等待扫描完成
private async buildWirePayload(): Promise<Array<Object>> {
// ...
// 端口扫描为异步操作,必须等待完成后再序列化,否则该字段会被静默丢弃
const fridaSuspected: boolean = await this.checkHookSignals(details);
// ...
}
private async checkHookSignals(details: Map<string, Object>): Promise<boolean> {
// ...
// 3) Frida 端口扫描(异步:必须 await,否则字段在序列化前尚未写入)
const openPorts: number[] = await this.scanFridaPorts();
details.set('fridaPortsOpen', openPorts);
if (openPorts.length > 0) {
signals++;
}
// ...
}
同时 onMethodCall 也要相应改为 Promise 链:
onMethodCall(call: MethodCall, result: MethodResult): void {
if (call.method === 'getDeviceTrustReport') {
this.buildWirePayload()
.then((payload: Array<Object>) => {
result.success(payload);
})
.catch((err: BusinessError) => {
result.error('DEVICE_TRUST_ERROR', err.message, null);
});
} else {
result.notImplemented();
}
}
关于 Android 为什么没这个问题:Android 的 Socket.connect 是阻塞式的,
scanFridaPorts 执行完就一定有结果,所以 checkHookSignals 可以是同步的。
而鸿蒙的 TCPSocket.connect 返回 Promise——这是移植时必须重新审视同步性的地方。
维护策略:今后若新增任何异步检测项,都要确认它已被 await,而非通过回调补写 details。
决策 6:检测失败一律静默降级为 false
检测类代码的核心要求是不能因为单项失败而导致整体失败。
try {
const value: string = settings.getValueSync(
context, settings.general.DEVELOPMENT_SETTINGS_STATUS, '0');
// ...
} catch (err) {
const e = err as BusinessError;
details.set('devSettingsError', `${e.code} ${e.message}`); // ← 记录原因
return false; // ← 降级不抛异常
}
三层设计:
| 层面 | 做法 | 价值 |
|---|---|---|
| 不崩 | 所有检测项 try/catch | 任一异常都不影响 getReport() 返回 |
| 可查 | 失败原因写入 details | 使用者可定位为何某信号不可用 |
| 可解释 | 返回 false 而非 null | 语义上表示「未检测到该风险」,符合调用方预期 |
维护策略:新增检测项时,错误信息统一以 <信号名>Error 的 key 写入 details。
决策 7:尺寸/类型在入口处收敛
鸿蒙侧有两处类型差异(settings 返回 String、deviceInfo 字段可能抛异常),
处理方式是在读取处立即收敛,不让差异扩散到判定逻辑:
private safeString(read: () => string): string {
try {
const value: string = read();
return value != null ? value : '';
} catch (err) {
return ''; // ← 统一降级为空串
}
}
调用处:
const brand: string = this.safeString(() => deviceInfo.brand);
const model: string = this.safeString(() => deviceInfo.productModel);
维护策略:所有可能抛异常的字段读取都走 safeString,保证判定逻辑只处理字符串。
六、测试与验证
6.1 测试环境
| 项目 | 版本 |
|---|---|
| Flutter | 3.47.5-ohos-1.0.0(channel oh-3.47-dev) |
| Dart | 3.13.3 |
| HarmonyOS SDK | 6.0.0(26) |
| IDE | DevEco Studio 26.0.0 |
| 设备型号 | ALN-AL00(HUAWEI,硬件版本 HL1CMSM) |
| 设备 ROM | OpenHarmony-7.0.0.105(API 26) |
| 设备架构 | arm64-v8a |
| 设备类型 | 真机(非模拟器) |
版本获取方式:
| 版本项 | 获取方式 |
|---|---|
| Flutter / Dart | flutter --version |
| HarmonyOS SDK | 读取 example/ohos/build-profile.json5 的 compatibleSdkVersion / targetSdkVersion |
| IDE | /usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" /Applications/DevEco-Studio.app/Contents/Info.plist |
| 设备 ROM | hdc shell param get const.ohos.fullname |
| 设备 API | hdc shell param get const.ohos.apiversion |
| 设备架构 | hdc shell param get const.product.cpu.abilist |
| 设备型号 | hdc shell param get const.product.model |
6.2 验证策略:为什么不能只看「信号是否为 true」
这类检测库的验证有个特殊困难:在正常设备上,所有信号本来就应该是 false。
于是出现一个陷阱:如果只验证「信号为 false」,那么一个完全没实现的空实现也能通过验证——
因为返回全 false 恰恰是预期结果。
因此本次采用了三层验证策略,逐层排除「假通过」的可能:
| 层级 | 验证目标 | 能否证明「真的在工作」 |
|---|---|---|
| L1 构建验证 | 代码能编译、能产出 HAP | ❌ 只证明语法正确 |
| L2 链路验证 | 插件注册、方法返回、Dart 解析成功 | ⚠️ 部分证明(能返回说明逻辑在跑) |
| L3 数据真实性验证 | details 中是否含真实设备值 | ✅ 关键证据 |
L3 是本次验证的核心设计。原理是:
如果检测逻辑真的在读取设备,
details中必然包含只有真实设备才有的值(如品牌、型号、硬件版本)。
如果实现是空的或短路了,这些字段就只能是空串或默认值。
这是一个无法伪造的验证点。
6.3 L1~L2:构建与链路验证
$ cd example
$ flutter build hap --debug --target-platform ohos-arm64
✓ Built build/ohos/hap/entry-default-signed.hap
$ hdc install -r entry-default-signed.hap
install bundle successfully.
$ hdc shell aa start -a EntryAbility -b com.example.my
start ability successfully.
插件注册与方法调用日志:
FlutterEngineCxnRegistry --> Adding plugin: DeviceTrustPlugin
DartMessenger --> Setting handler for channel 'device_trust'
DartMessenger --> Received message from Dart over channel 'device_trust'
DeviceTrustPlugin: report: root=false emu=false dev=false adb=false hook=false dbg=false
这一层证明了:
- ✅ 插件被引擎正确注册(
Adding plugin: DeviceTrustPlugin) - ✅ 通道 handler 挂载成功(
Setting handler for channel 'device_trust') - ✅ Dart 发起调用并到达原生(
Received message from Dart) - ✅ 原生完成检测并返回(
report: root=false ...)
6.4 L3:数据真实性验证(关键)
第 6.3 节的日志只能证明「方法被调用了」,不能证明检测逻辑真的读了设备——
这条日志是插件自己打印的,理论上一个返回固定值的实现也能打出同样的日志。
于是我进一步通过 UI 层级导出验证 Dart 侧实际渲染的内容:
$ hdc shell uitest dumpLayout -p /data/local/tmp/dt_ui.json
$ hdc file recv /data/local/tmp/dt_ui.json /tmp/dt_ui.json
从导出的 UI 层级中提取文本,得到 details 的完整 JSON:
{
"suExists": false,
"knownRootFiles": [],
"rwMounts": false,
"buildType": "default",
"rootSignals": 0,
"emulatorIndicators": [],
"emulatorSignals": 0,
"emulatorStrong": false,
"deviceBrand": "HUAWEI",
"deviceModel": "ALN-AL00",
"deviceHardware": "HL1CMSM",
"deviceType": "phone",
"devSettingsEnabled": false,
"adbEnabled": false,
"suspiciousMaps": [],
"tracerPid": 0,
"fridaPortsOpen": [],
"kotlinHookSignals": 0,
"debuggerConnected": false,
"platform": "ohos",
"totalTimeMs": 88
}
这一层证明了三件关键的事:
1. 检测逻辑真的在读取设备(无法伪造)
"deviceBrand": "HUAWEI",
"deviceModel": "ALN-AL00",
"deviceHardware": "HL1CMSM",
"deviceType": "phone",
这四个值只可能来自真实设备读取——deviceInfo.brand / productModel / hardwareModel / deviceType。
一个空实现绝不可能返回这四个值。
这实际上验证了 2.3 节中「模拟器检测」的核心数据源是否工作:
既然能读到ALN-AL00这样真实的型号,说明读取链路是通的,
那么当它读到emulator时也会正确判定。这一条把「读不读得到」与「判定对不对」解耦了。
2. Dart 侧完整解析并渲染
六项信号全部显示在界面上,说明 Dart 侧的严格校验全部通过:
version is int && version == 1✅flags is int && flags >= 0✅(值为0)details is Map<Object?, Object?>✅details.keys.every((key) => key is String)✅
若任何一个类型不符,Dart 会抛 INVALID_REPORT_PAYLOAD,界面上会显示错误而不是信号值。
这里验证了一个重要细节:ArkTS 的 number 会被正确编码为 Dart 的 int。
鸿蒙引擎的StandardMessageCodec用Number.isInteger(value)判断,
整数走INT32分支,因此 Dart 侧收到的是int而非double。
这一点如果搞错,flags is! int就会成立,整个报告解析失败。
3. 竞态修复生效(决策 5 的验证)
"fridaPortsOpen": [],
修复前该字段完全缺失;修复后正常出现(空数组表示两个端口都未监听,这是正常设备的预期结果)。
这一项是本次适配中「验证驱动修复」的典型案例:
如果只验证「六项信号是否正确」,这个 bug 永远不会被发现——
因为它只是一个 details 字段缺失,不影响任何信号的判定结果,也不产生任何错误日志。
6.5 验证要点清单
| # | 验证项 | 结果 |
|---|---|---|
| 1 | 构建产出 HAP | ✅ ✓ Built entry-default-signed.hap(98 MB) |
| 2 | ArkTS 编译错误 | ✅ 0 错误 |
| 3 | 插件自动注册 | ✅ Adding plugin: DeviceTrustPlugin |
| 4 | 通道 handler 挂载 | ✅ Setting handler for channel 'device_trust' |
| 5 | 方法调用到达原生 | ✅ Received message from Dart over channel 'device_trust' |
| 6 | 原生完成检测 | ✅ report: root=false emu=false ... |
| 7 | Dart 侧解析成功 | ✅ UI 渲染出全部六项信号(严格类型校验全通过) |
| 8 | details 含真实设备值 | ✅ HUAWEI / ALN-AL00 / HL1CMSM / phone |
| 9 | 六项信号语义正确 | ✅ 健康设备全部 false,符合预期 |
| 10 | flags 类型与取值 | ✅ 0(int,非负) |
| 11 | fridaPortsOpen 字段存在 | ✅ 竞态修复生效 |
| 12 | 检测耗时 | ✅ totalTimeMs: 88(毫秒级) |
| 13 | 同步屏障正确性 | ✅ 报告在异步扫描完成后才返回 |
6.6 关于「未验证项」的说明
需要明确说明本次没有验证的部分,避免读者误判验证完备程度:
| 未验证项 | 原因 | 影响 |
|---|---|---|
信号为 true 的正例 | 真机是健康设备,六项信号本就应为 false | 验证了「链路通 + 真实读取」,未验证「风险设备上能否正确报警」 |
| 模拟器场景 | 尝试连接模拟器失败(hdc tconn 127.0.0.1:5555 无响应) | 模拟器判定逻辑已实现但未实测 |
| Root 设备场景 | 无可用 Root 设备 | Root 判定逻辑已实现但未实测 |
/proc 沙箱可读性 | 在 ALN-AL00 上可读,其它 ROM 未知 | 部分 ROM 若限制读取,Hook 检测能力会下降 |
settings 读取权限 | 在 ALN-AL00 上可读 | 部分设备可能受权限限制(已在 details 中做错误记录) |
L3 验证的价值正在于此:虽然无法验证正例,但通过「能读到真实设备值」证明了读取链路完整。
这比单纯看「信号是 false」提供了强得多的证据——
但它确实不能替代在真实风险设备上的验证。这一点在 README 的「遗留问题」中已明确写出。
七、运行效果
适配完成后,在 OpenHarmony 真机上运行示例工程:
cd example
flutter run -d 192.168.1.5:35095
示例界面(example/lib/main.dart,上游代码未改动)包含:
- 顶部按钮:
Get DeviceTrust Report—— 点按触发检测 - Summary 区:六项信号逐行展示(图标 + 标签 + 布尔值)
- Policy (Example) 区:综合判定结果(
Device Status: OK或风险提示) - Details (JSON) 区:
details完整内容
真机实测结果:
| 界面项 | 显示内容 |
|---|---|
| Rooted/Jailbroken | false |
| Emulator/Simulator | false |
| Frida Suspected | false |
| Debugger Attached | false |
| Dev Mode Enabled | false |
| ADB Enabled | false |
| Device Status | OK |
No major security concerns detected |
关于截图的说明:本文以真机日志 + UI 层级导出作为功能验证证据。
相比之下,日志与导出的 JSON 比截图更精确——它们能证明具体字段值,
而不只是「界面上显示了什么」。第 6.4 节引用的detailsJSON 即来自 UI 层级导出。
八、遗留问题与改进方向
8.1 踩坑复盘
以下记录本次适配中遇到的问题,均有明确现象与日志证据,不是推测。
| # | 踩坑点 | 现象 / 报错 | 根因与解法 |
|---|---|---|---|
| 1 | settings 命名空间写错 | ArkTS 编译失败:Property 'DEVELOPMENT_SETTINGS_STATUS' does not exist on type 'typeof display' | 根因:凭直觉把常量写在 settings.display 下。解法:从 SDK .d.ts 逐行定位,确认 DEVELOPMENT_SETTINGS_STATUS 与 DEBUGGER_WAITING 都属于 settings.general。教训: namespace 归属必须查 SDK 定义,不能按语义猜(这两个键从语义看确实更像 display)。 |
| 2 | settings 返回值类型误判 | 无报错,但 devModeEnabled / adbEnabled 恒为 false | 根因:Android 的 Settings.Global.getInt 返回 Int,而鸿蒙 settings.getValueSync 返回 String。若照搬 == 1 写法,类型不匹配恒假。解法:改用 value === '1' 比较。危险性:这个 bug 不会编译失败、不会运行报错,只会让检测永远返回「未发现风险」——是最难发现的一类 bug。 |
| 3 | 端口探测竞态导致字段丢失 | details 中缺少 fridaPortsOpen 字段(无任何错误日志) | 根因:鸿蒙 TCPSocket.connect 返回 Promise(Android 的 Socket.connect 是阻塞式)。我最初用「回调补写 details」处理,导致写入发生在报告序列化之后,字段被静默丢弃。解法:把 buildWirePayload / checkHookSignals / scanFridaPorts 整链路改为 async/await,确保扫描完成后再序列化。教训:移植异步 API 时必须重新审视同步性——Android 的同步写法在鸿蒙上可能变成竞态。 |
| 4 | 端口探测超时值不合理 | 首版探测结果不可靠 | 根因:照搬 Android 的 15ms 超时。但 Android 是阻塞式 connect,15ms 足够判定;鸿蒙是异步连接,网络栈响应需要更多时间。解法:调整为 300ms。 教训:超时值需按实现机制调整,不能直接搬。 |
| 5 | 构建停在签名配置 | 请通过 DevEco Studio 打开 ohos 工程后配置调试签名,无 HAP 产出 | 根因:example/ohos/build-profile.json5 的 signingConfigs 为空数组。解法:填入调试证书( certpath / profile / storeFile / keyAlias 等),并确保 bundleName 与签名 profile 内的 bundleName 一致(否则报 00303074 Configuration error)。注意:验证完成后需还原签名配置,不应提交到仓库。 |
额外记录一处「非踩坑但值得说明」的设计点:
| 项 | 说明 |
|---|---|
adbEnabled 的语义映射 | 鸿蒙无 ADB,映射到 DEBUGGER_WAITING。这不是 bug,但是必须向使用者说明的语义差异,已在 README 中标注。 |
8.2 为什么第 2、3 个坑特别危险
这两个坑有一个共同特征:静默失效,没有任何报错。值得单独讨论。
| 坑 | 编译期 | 运行期 | 可观测性 |
|---|---|---|---|
| 命名空间写错 | ❌ 报错(好事) | — | 容易发现 |
| 返回值类型误判 | ✅ 通过 | ✅ 无异常 | 极难发现 |
| 端口探测竞态 | ✅ 通过 | ✅ 无异常 | 极难发现 |
它们的表现都是「检测项永远返回 false / 字段永远为空」——
而这在正常设备上恰好是预期结果,所以测试时看起来完全正常。
这类 bug 的排查方法,我在本次适配中实际用到的是:
- 逐字段核对
details—— 不是只看六项布尔值,而是把details的每个 key 都对一遍。
竞态 bug(坑 3)就是这样发现的:fridaPortsOpen不在列表里。 - 确认关键字段有「不可能伪造」的真实值 ——
deviceBrand: HUAWEI这类值能证明读取链路是通的,
从而排除「整条链路短路」的可能。 - 对每个平台 API 都查阅 SDK 定义确认签名 —— 包括返回类型与命名空间,
不依赖「语义上应该对应」的直觉。
一条可复用的经验:检测类代码的正确性,不能靠「结果符合预期」来判断
——因为预期结果就是「什么都没检测到」。
必须验证「检测逻辑确实执行了」,而details里的真实设备值是最可靠的证据。
8.3 已知问题
-
无系统属性读取能力 —— Android 侧的
ro.debuggable/ro.secure检查在鸿蒙侧无对应实现,
因为公共 SDK 未开放系统属性读取接口(systemParameter不在公开 API 中)。 -
未引入 native 深度检测 —— Android 侧 C++ 层的三项检查(RWX 段、文件描述符扫描、
dladdr符号校验)在鸿蒙侧未实现。 -
模拟器可用字段较少 —— Android 侧检查 8 个
Build字段,鸿蒙侧只有 3 个可用
(brand/productModel/hardwareModel)。判定逻辑保持一致,但信号基数较少。 -
/proc可读性依赖 ROM —— 部分 ROM 可能限制应用读取/proc/self/maps与/proc/self/status。
读取失败时视为无信号(不崩溃),但会降低 Hook 检测能力。 -
设置项读取可能受限 ——
devModeEnabled/adbEnabled在部分设备上可能因系统限制读取失败,
此时返回false并在details中记录devSettingsError/adbEnabledError。 -
未验证风险设备正例 —— 本次在健康真机上完成验证,未覆盖 Root 设备与模拟器场景。
8.4 未来优化
- 引入 native 层 —— 若需对齐 Android 的检测深度,可新增
src/main/cpp/实现 RWX 段与 fd 扫描。 - 增加鸿蒙特有信号 —— 鸿蒙有自身的完整性机制(如
deviceAttest设备证明),
可探索接入以增强检测可信度。 - 端口探测优化 —— 当前串行探测两个端口(各 300ms),可改为并行以缩短耗时。
- 检测耗时优化 —— 当前
totalTimeMs: 88,其中绝大部分是端口探测等待。
可考虑「先做同步检测,若已能定性则跳过端口探测」的分级策略。 - 误报回归测试 —— 建立多机型 / 多系统版本的检测结果基线,监控误报率变化。
九、总结
9.1 核心路径:三步走
将一个 Flutter 三方库适配到 OHOS 平台,核心路径可以概括为 三步走:
1. 找对应 ── 找到 OHOS 对每个 Android 原生 API 的等价实现
2. 保契约 ── 确保方法通道名、方法名、返回值结构完全一致
3. 补缺口 ── 对于 OHOS 不提供的 API,用合理方案弥补
本次适配中,这三步的具体情况:
| 步骤 | 本次情况 | 难度 |
|---|---|---|
| 找对应 | 大部分检测能力有对应物(文件探测、/proc 读取、调试状态),且鸿蒙基于 Linux 保留了 /proc,可用手段多于 iOS | 🟡 中等(需逐项验证) |
| 保契约 | 通道名、方法名、线格式照搬,pubspec.yaml 仅改 2 行 | 🟢 简单 |
| 补缺口 | 系统属性读取无对应(明确声明不支持);端口探测需从阻塞改为异步(踩坑重点) | 🟡 中等 |
与前一篇适配(qr_mobile_vision)的关键区别:
| 维度 | qr_mobile_vision | device_trust |
|---|---|---|
| 缺口性质 | 核心能力整体缺失(鸿蒙无条码解码 API) | 局部能力缺失(少数检测项无法实现) |
| 应对方式 | 引入第三方解码器,从零实现解码链路 | 逐项映射,缺的明确声明 |
| 主要难点 | 解码器集成 + 性能/内存调优 | 同步性差异(阻塞 vs Promise) |
| 验证难点 | 需物理取景(相机对不准) | 无法验证正例(健康设备本就全 false) |
两篇对照可以看出:三方库适配的难点各不相同,取决于「目标平台缺什么」以及「这个东西怎么验证」。
9.2 适配成果
| 指标 | 数值 |
|---|---|
新增 ohos/ 实现 | 486 行纯 ArkTS |
| 三端规模对比 | Android 810 行 / iOS 329 行 / OHOS 486 行 |
pubspec.yaml 修改 | 2 行 |
| Dart 层改动 | 0 行 |
| Android / iOS 代码改动 | 0 行 |
| native 依赖 | 无需 NDK / CMake |
| 外部依赖 | 无 |
| 权限需求 | 无 |
| 新增文件 | 43 个(含 example/ohos/ 宿主工程) |
| 修复的真实缺陷 | 3 个(命名空间、返回值类型、异步竞态) |
Dart 层和其他平台的代码完全不受影响 —— 新增一个平台,其他平台一行不动。
9.3 一句话经验
检测类代码的适配,难点不在「怎么写」,而在「平台愿不愿意把信息给你」,
以及「你怎么证明它真的在工作」。前半句决定了功能边界——鸿蒙提供了
/proc与文件系统探测能力,
但没有系统属性读取接口,于是能做什么、不能做什么就清晰了。
对做不到的部分,明确声明比勉强实现更诚实。后半句决定了验证策略——当「全部返回 false」既是正确结果、也是失败结果时,
唯一的出路是验证过程而非结果:用details里的真实设备值证明读取链路是通的。
9.4 给后来者的建议
如果你是第一次做 OHOS 插件适配,本次经验中最值得直接借鉴的是:
- 先做能力普查 —— 在写代码前,逐项确认目标平台提供了什么。这决定了整个适配的工作量与功能边界。
- 每个 API 都要查 SDK 定义确认签名 —— 特别是返回类型与命名空间。
坑 1(命名空间)与坑 2(返回类型)都源于「按语义猜」而非「查定义」。 - 移植异步 API 时必须重新审视同步性 —— Android 的阻塞式调用在鸿蒙上可能是 Promise。
坑 3 就是这样埋下的:一个回调补写,导致字段静默丢失。 - 逐字段核对
details,不要只看信号值 —— 静默 bug 只会在明细里露出马脚。 - 验证「读取链路是否通」,而不只是「结果是否正确」 ——
用不可伪造的真实设备值(品牌、型号)来证明逻辑确实执行了。 - 缺失的能力要明确声明 —— 在 README 里写清「不支持 X,原因是 Y」,
好过用语义相近的实现去充数。
参考文档
- device_trust 官方仓库(GitHub)
- device_trust 鸿蒙适配仓库(AtomGit)
- Flutter OH 适配框架 flutter_flutter
- HarmonyOS deviceInfo API
- HarmonyOS settings API
- HarmonyOS hidebug API(isDebugState)
- HarmonyOS Socket API(TCPSocket)
- 配套阅读:Flutter 三方库 qr_mobile_vision 的 OpenHarmony 适配实战
开源协议
本项目遵循 MIT License,与上游 device_trust 保持一致。
本文基于
device_trust3.1.0 →3.1.0-ohos-1.0.0-beta.1的实际适配过程撰写。
所有踩坑记录均来自真机调试(ALN-AL00 / OpenHarmony-7.0.0.105 / API 26 / arm64-v8a),
附带日志与 UI 层级导出证据,非推测。本文也如实标明了未验证的部分:本次未在 Root 设备或模拟器上验证「风险信号能否正确置为 true」,
验证覆盖的是「链路打通 + 真实读取设备属性」。如果你的设备或 ROM 表现不同,
欢迎在仓库提 Issue 反馈,帮助生态覆盖更多机型。
更多推荐

所有评论(0)