本文记录了将开源 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、TracerPiddyld 镜像检测端口探测 + /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(系统属性读取)❌ 不在公共 SDKAndroid 的 ro.debuggable / ro.secure 检查无法移植
条码/安全等专用 Kit❌ 无对应物需自行用基础能力组合实现

这次普查直接决定了后续两条技术路线:

  1. 能用系统能力的,直接用(设备属性、系统设置、调试状态)
  2. 系统没提供的,用基础能力组合实现(文件探测替代属性读取、TCP 连接替代端口扫描 API)
  3. 实在无法实现的,明确声明不支持(不硬造,也不假装做了)

二、适配路线图

整个适配分为 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 语义鸿蒙语义
rootedOrJailbroken1设备已 Root设备已越狱已提权/系统被改动
emulator2运行在模拟器运行在模拟器运行在模拟器/虚拟环境
devModeEnabled4开发者模式开启不适用(恒 false)开发者模式开启
adbEnabled8ADB 开启不适用(恒 false)调试通道开启
fridaSuspected16检测到 Frida/Hook检测到 Frida/Hook检测到 Frida/Hook
debuggerAttached32调试器附着调试器附着调试器附着
2.2 通道注册与权限

通道注册(三端几乎一致):

平台代码
AndroidMethodChannel(binding.binaryMessenger, "device_trust")
iOSFlutterMethodChannel(name: "device_trust", binaryMessenger: ...)
OHOSnew 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;
  }
}

关键差异:

项AndroidOHOS
访问方式Settings.Global.getInt(contentResolver, key, default)settings.getValueSync(context, key, default)
返回值类型IntString(需自行比较 '1')
开发者模式键Settings.Global.DEVELOPMENT_SETTINGS_ENABLEDsettings.general.DEVELOPMENT_SETTINGS_STATUS
调试通道键Settings.Global.ADB_ENABLEDsettings.general.DEBUGGER_WAITING
命名空间Settings.Globalsettings.general(注意是 general,不是 display)
异常处理try/catch 返回 false同样 try/catch 返回 false

两处必须注意的地方:

  1. 命名空间是 settings.general。我最初凭直觉写成了 settings.display,导致 ArkTS 编译报
    Property 'DEVELOPMENT_SETTINGS_STATUS' does not exist on type 'typeof display'。
    正确位置需从 SDK 的 .d.ts 中逐行确认(见第八章踩坑复盘)。

  2. 返回值是 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;
}
项AndroidOHOS
APIDebug.isDebuggerConnected() / Debug.waitingForDebugger()hidebug.isDebugState()
返回booleanboolean
差异两个 API 取「或」单一 API 覆盖两种状态

这是六项信号中映射最干净的一项:语义一致、返回类型一致、调用方式一致。
说明鸿蒙在「调试状态」这个基础能力上是完备的。

2.4 阈值与降级策略
多信号阈值设计

本库的核心设计哲学是不用单信号判定。原因很实际:

开发者自己的手机上,开发者模式和调试通道本来就是开着的。
如果单信号就报警,等于把所有开发者设备都误判为「不可信」。

因此各信号采用阈值判定,鸿蒙侧与 Android 侧保持一致的阈值:

信号判定条件设计理由
rootedOrJailbroken4 类信号中至少 1 个命中Root 属严重风险,敏感度高,单信号即可疑
emulator强信号,或至少 2 个常规信号强信号(型号明确为 emulator)可信度高;常规信号需多个佐证
fridaSuspected3 类信号中至少 2 个命中Hook 检测误报成本高,要求多个独立信号交叉印证
devModeEnabledDEVELOPMENT_SETTINGS_STATUS == 1单一明确开关,无需阈值
adbEnabledDEBUGGER_WAITING == 1同上
debuggerAttachedhidebug.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 在沙箱内可能不可读,失败即视为无信号
}

这套策略的三层价值:

  1. 不崩:任一检测项异常都不会导致 getReport() 抛异常
  2. 可查:失败原因写入 details(如 devSettingsError),便于定位
  3. 可解释:返回 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 三端检测能力对照总表

这张表是本次适配最重要的一张表——它清晰地展示了同一个检测库在三个平台上的能力差异。

检测项AndroidiOSHarmonyOS说明
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 三端实现规模对照

维度AndroidiOSHarmonyOS
语言Kotlin + C++(JNI)Swift + Objective-C++纯 ArkTS
主实现行数810 行329 行486 行
是否需 native 编译✅ 需要(NDK)✅ 需要❌ 不需要
权限需求无无无
外部依赖com.google.mlkit 等无无
构建产物AAR + .soFrameworkHAR(纯 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.MODELdeviceInfo.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';

两个差异点:

差异AndroidOHOS踩坑风险
返回类型IntString若照搬 == 1 写法,类型不匹配导致恒为 false,且不报错
命名空间Settings.Globalsettings.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();
}

这意味着鸿蒙侧必须精确满足四个条件:

  1. 返回三元数组(不是普通 Map)
  2. version 必须是 int(值为 1)
  3. flags 必须是非负整数
  4. 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。理由:

  1. 核心检测已覆盖 —— Hook 检测的三个关键信号(maps 扫描、TracerPid、端口探测)都已实现,
    丢失的是「进一步佐证」的增强项
  2. 成本收益不成比例 —— 为三个增强项引入整套 NDK 构建链路,维护负担远超收益
  3. 明示优于隐瞒 —— 缺失的能力在 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 测试环境

项目版本
Flutter3.47.5-ohos-1.0.0(channel oh-3.47-dev)
Dart3.13.3
HarmonyOS SDK6.0.0(26)
IDEDevEco Studio 26.0.0
设备型号ALN-AL00(HUAWEI,硬件版本 HL1CMSM)
设备 ROMOpenHarmony-7.0.0.105(API 26)
设备架构arm64-v8a
设备类型真机(非模拟器)

版本获取方式:

版本项获取方式
Flutter / Dartflutter --version
HarmonyOS SDK读取 example/ohos/build-profile.json5 的 compatibleSdkVersion / targetSdkVersion
IDE/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" /Applications/DevEco-Studio.app/Contents/Info.plist
设备 ROMhdc shell param get const.ohos.fullname
设备 APIhdc 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)
2ArkTS 编译错误✅ 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 ...
7Dart 侧解析成功✅ UI 渲染出全部六项信号(严格类型校验全通过)
8details 含真实设备值✅ HUAWEI / ALN-AL00 / HL1CMSM / phone
9六项信号语义正确✅ 健康设备全部 false,符合预期
10flags 类型与取值✅ 0(int,非负)
11fridaPortsOpen 字段存在✅ 竞态修复生效
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/Jailbrokenfalse
Emulator/Simulatorfalse
Frida Suspectedfalse
Debugger Attachedfalse
Dev Mode Enabledfalse
ADB Enabledfalse
Device StatusOK
No major security concerns detected

关于截图的说明:本文以真机日志 + UI 层级导出作为功能验证证据。
相比之下,日志与导出的 JSON 比截图更精确——它们能证明具体字段值,
而不只是「界面上显示了什么」。第 6.4 节引用的 details JSON 即来自 UI 层级导出。


八、遗留问题与改进方向

8.1 踩坑复盘

以下记录本次适配中遇到的问题,均有明确现象与日志证据,不是推测。

#踩坑点现象 / 报错根因与解法
1settings 命名空间写错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)。
2settings 返回值类型误判无报错,但 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 的排查方法,我在本次适配中实际用到的是:

  1. 逐字段核对 details —— 不是只看六项布尔值,而是把 details 的每个 key 都对一遍。
    竞态 bug(坑 3)就是这样发现的:fridaPortsOpen 不在列表里。
  2. 确认关键字段有「不可能伪造」的真实值 —— deviceBrand: HUAWEI 这类值能证明读取链路是通的,
    从而排除「整条链路短路」的可能。
  3. 对每个平台 API 都查阅 SDK 定义确认签名 —— 包括返回类型与命名空间,
    不依赖「语义上应该对应」的直觉。

一条可复用的经验:检测类代码的正确性,不能靠「结果符合预期」来判断
——因为预期结果就是「什么都没检测到」。
必须验证「检测逻辑确实执行了」,而 details 里的真实设备值是最可靠的证据。

8.3 已知问题

  1. 无系统属性读取能力 —— Android 侧的 ro.debuggable / ro.secure 检查在鸿蒙侧无对应实现,
    因为公共 SDK 未开放系统属性读取接口(systemParameter 不在公开 API 中)。

  2. 未引入 native 深度检测 —— Android 侧 C++ 层的三项检查(RWX 段、文件描述符扫描、
    dladdr 符号校验)在鸿蒙侧未实现。

  3. 模拟器可用字段较少 —— Android 侧检查 8 个 Build 字段,鸿蒙侧只有 3 个可用
    (brand / productModel / hardwareModel)。判定逻辑保持一致,但信号基数较少。

  4. /proc 可读性依赖 ROM —— 部分 ROM 可能限制应用读取 /proc/self/maps 与 /proc/self/status。
    读取失败时视为无信号(不崩溃),但会降低 Hook 检测能力。

  5. 设置项读取可能受限 —— devModeEnabled / adbEnabled 在部分设备上可能因系统限制读取失败,
    此时返回 false 并在 details 中记录 devSettingsError / adbEnabledError。

  6. 未验证风险设备正例 —— 本次在健康真机上完成验证,未覆盖 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_visiondevice_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 插件适配,本次经验中最值得直接借鉴的是:

  1. 先做能力普查 —— 在写代码前,逐项确认目标平台提供了什么。这决定了整个适配的工作量与功能边界。
  2. 每个 API 都要查 SDK 定义确认签名 —— 特别是返回类型与命名空间。
    坑 1(命名空间)与坑 2(返回类型)都源于「按语义猜」而非「查定义」。
  3. 移植异步 API 时必须重新审视同步性 —— Android 的阻塞式调用在鸿蒙上可能是 Promise。
    坑 3 就是这样埋下的:一个回调补写,导致字段静默丢失。
  4. 逐字段核对 details,不要只看信号值 —— 静默 bug 只会在明细里露出马脚。
  5. 验证「读取链路是否通」,而不只是「结果是否正确」 ——
    用不可伪造的真实设备值(品牌、型号)来证明逻辑确实执行了。
  6. 缺失的能力要明确声明 —— 在 README 里写清「不支持 X,原因是 Y」,
    好过用语义相近的实现去充数。

参考文档


开源协议

本项目遵循 MIT License,与上游 device_trust 保持一致。


本文基于 device_trust 3.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 反馈,帮助生态覆盖更多机型。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐