哪些直接用,哪些需要额外接入
引入 SDK 后,普通本地检测直接调用。额外接入只有两项:内容与签名检测插件和 Play Integrity 后端验证。
接入总表
| 功能 | 当前可用方式 | 业务需要做什么 | 不做额外接入的影响 |
|---|---|---|---|
| 普通本地检测 模拟器、设备能力、Root、Hook、调试器、无障碍、VPN/代理、SIM/eSIM、本地安装来源 | 直接可用 · 9 项 | 引入 SDK 并调用。无需插件、后端或运行时权限授权。 | 按 SDK 默认规则检测;无需填 su 路径或传感器类型。 |
工具安装checkInstalledTools | 直接可用 · 1 项 | 默认查内置 5 个包名,无需传参或配置 queries。 | 自定义名单追加到默认名单,按包名去重;自定义包名另配可见性。 |
签名校验checkSignature | 函数可用 · 需要基准 | 可选插件开启 signatureEnabled;Play 模式提供 .der 证书。也可自行传可信基准。 | 未开启且未传显式基准 → 自动扫描跳过;直接调用无基准时返回 UNKNOWN/no_baseline。 |
内容校验checkApkContent | 函数可用 · 需要基准 | 可选插件开启 contentEnabled,自动生成 assets / DEX 基准,无需 .der。也可自行传可信基准。 | 未开启且未传显式基准 → 自动扫描跳过;直接调用无基准时返回 UNKNOWN/no_baseline。 |
Play IntegrityrequestPlayIntegrityToken | 可选 · 需后端验证 | 提供 Cloud 项目编号,完成 Google 配置,适配后端验证凭证。 | 不调用就不取凭证;不影响本地检测。 |
内容与签名检测插件的两个开关默认 false。双关闭时不注册相关构建任务;自动扫描跳过这两项,不读取基准、不调度对应检测。本地安装来源 checkInstallSource 不依赖 Integrity。检测未知或未检出不等于设备安全。
已接入 SDK:直接调用
检测功能已包含在同一个 SDK 中,已接入的项目无需再添加依赖。首次接入请看主接入文档的依赖配置;这里仅说明检测 API 的调用。
SDK 发布包已开启混淆,不再附带源码 JAR。检测 API 和默认参数保持不变,宿主可继续开启 R8,无需额外添加 SDK keep 规则。内容与签名插件仍为可选,需用当前构建重新生成基准。
import com.remax.riskcontrol.sdk.*
// 由 Activity / ViewModel 持有;生命周期结束时 scanner.close()。
val scanner = RiskScanner(applicationContext)
scanner.scan(
onResult = { id, result ->
// 结果在后台回调,更新 UI 请切回主线程。
val status = result.status
},
)默认候选集合包含 12 项。只接 SDK、未传显式基准时,实际执行 10 项,签名和内容自动跳过;开启或显式提供对应基准后再执行对应项。无需初始化。同一 scanner 不要重叠扫描,独立函数请放后台线程。
SDK 的版本与仓库配置统一维护在主接入文档,无需重复配置。
两项可选接入
可选 ① 内容与签名检测插件
插件与 SDK 分开发包,目前工程仅配置本地 Maven 发布。以下插件依赖仅在需要自动生成签名/内容基准时添加,普通检测无需接入。
仅影响签名和内容两项。插件 ID 为 com.remax.riskcontrol.content-signature,配置块为 riskContentSignature;SDK 与插件需配套更新,并重新构建应用。在 settings.gradle.kts 文件开头的现有 pluginManagement.repositories 加入插件仓库:
pluginManagement {
repositories {
maven { url = uri("/实际路径/risk-control-maven") }
google()
mavenCentral()
gradlePluginPortal()
}
}在宿主 App 模块的 app/build.gradle.kts 中:
plugins {
// 保留原有 Android / Kotlin 插件。
id("com.remax.riskcontrol.content-signature") version "0.1.0"
}
riskContentSignature {
contentEnabled.set(true) // 只开启内容基准,签名默认 false。
}两个开关默认关闭,可独立开启
上方示例显式开启内容校验;只应用插件、不写配置块时,两项都关闭。Demo 为验证功能显式开启两项,不代表默认值。
| contentEnabled | signatureEnabled | 构建行为 | 自动扫描行为 |
|---|---|---|---|
| false | false | 不注册基准生成、APK/AAB 转换和导出任务;不检查证书或 APK 输出数量。 | 跳过这两项,不读取基准、不调度对应检测。 |
| true | false | 生成 assets / DEX 基准;按现有签名配置处理最终产物,不需要可信签名公钥证书。 | 只执行内容校验。 |
| false | true | 生成签名基准;保留 AGP 原有打包签名流程,不运行 DEX 转换。 | 只执行签名校验。 |
| true | true | 生成两类基准,并处理最终产物。 | 执行两项校验。 |
表中自动扫描行为以业务未传显式基准为前提。插件只在构建机运行,不会在 App 内常驻运行;其他本地检测仍按所选项目执行。
扫描回调与直接调用的区别
selected是候选集合。关闭且没有显式基准的签名 / 内容项目不触发onResult,也不出现在onComplete的结果 Map 中;不要固定等待 12 个结果。- 所选项目全被跳过时,仍在工作线程调用一次
onComplete(emptyMap())。 - 通过
ScanConfig(signature = ..., content = ...)提供显式基准,即使插件未开启也会执行对应检测。useGeneratedBaselines = false时,这两项仅使用显式基准。 - 已开启但基准缺失、损坏或不可读时仍执行并返回
UNKNOWN,不会静默跳过。 - 直接调用
checkSignature/checkApkContent仍返回结果:关闭且未传显式基准时返回UNKNOWN/no_baseline,不打开基准文件。主动调用loadGeneratedBaselines仍会读取文件。
如需签名基准,另开 signatureEnabled,选择一种来源:
| 模式 | 配置 |
|---|---|
buildSigning | 使用当前变体已有签名配置,无需 .der。 |
playAppSigning(默认) | 指定 Play 应用签名公钥证书路径;缺失或无效会构建失败。 |
riskContentSignature {
contentEnabled.set(true)
signatureEnabled.set(true)
certificateFiles.setFrom(rootProject.file("certificates/play-app-signing.der"))
}证书在 Play Console 的 Play 应用签名页面下载“应用签名密钥证书”,不要选上传证书;放到 CI 可读的项目路径。下载指引。自行签名分发改用 signingMode.set("buildSigning"),不要配置外部证书。
CI 按当前变体的开关选择输出目录
至少开启一项:使用插件导出的最终产物。
app/build/outputs/risk-control/<变体名>/apk/*.apk
app/build/outputs/risk-control/<变体名>/bundle/*.aab
两项都关闭:使用 AGP 原生输出目录。APK 的子目录取决于 flavor / buildType 配置。
app/build/outputs/apk/
app/build/outputs/bundle/<变体名>/*.aab
// 无 flavor 的 debug APK 示例:
app/build/outputs/apk/debug/*.apk
// dev flavor 的 release APK 示例:
app/build/outputs/apk/dev/release/*.apk
从开启切换为关闭无需 clean,新原生产物不会携带旧生成基准。双关闭后不会刷新旧的 outputs/risk-control 目录,CI 不要继续从旧导出目录取包。
内容开启时复用现有签名配置重新签署;.der 不替代上传密钥。只开签名时不执行 DEX 转换或额外重签。需要加固时先查看下方兼容说明。
旧插件 ID com.remax.riskcontrol.baseline 和配置块 riskBaseline 已被新名称替换,没有保留旧 ID 别名。插件实现坐标为 com.remax.riskcontrol:risk-control-content-signature-plugin:0.1.0。
按变体配置(开发签名 / Play 签名)
riskContentSignature {
onVariants {
when (name) {
"devDebug", "devRelease" -> {
contentEnabled.set(true)
signatureEnabled.set(true)
signingMode.set("buildSigning")
certificateFiles.setFrom(emptyList<Any>())
}
"prodRelease" -> {
contentEnabled.set(true)
signatureEnabled.set(true)
signingMode.set("playAppSigning")
certificateFiles.setFrom(rootProject.file("certificates/play-app-signing.der"))
}
}
}
}示例假设已有 dev/prod flavor,其他变体保持默认关闭。name 是完整变体名,buildType 仅为 debug / release。setFrom 替换继承的证书;非空证书集合优先于构建签名,所以 buildSigning 分支清空集合。
可选 ② Play Integrity → 独立取凭证,需要后端验证
独立于内容与签名检测插件和本地扫描。Google 依赖已随 SDK 自动传递;业务提供项目编号、完成 Google 配置,并让后端解密验证凭证与请求绑定。
import com.remax.riskcontrol.sdk.integrity.PlayIntegrityClient
val client = PlayIntegrityClient(applicationContext, cloudProjectNumber)
client.requestPlayIntegrityToken()
.addOnSuccessListener { result ->
// 将 token 与 requestContext 等请求绑定信息交给业务后端验证。
// 取得 token 不等于校验通过,SDK 不会自动请求业务后端。
}
.addOnFailureListener { error -> /* 本次未取得凭证 */ }复用 client,可在主线程发起,默认 listener 在主线程回调。requestHash 由 SDK 自动生成,无需 Firebase JSON;不额外申请用户权限。
用到时再展开
完整 API 参考:参数、返回字段、异常与示例
适用于 SDK 0.1.7;以下均为公开 API。常规检测无需额外初始化、危险权限或插件。签名 / 内容仅在需要基准时配置;Integrity 独立异步取凭证。使用 import com.remax.riskcontrol.sdk.*,Integrity 类型位于子包 .integrity。
① 检测调用、并发与规则参数
下面 12 个函数均同步返回 CheckResult,必须在后台调用。除特殊参数外,只需 session: DetectionSession;默认规则已经内置。
| CheckId | 函数签名(均返回 CheckResult) | 结果 id / scope |
|---|---|---|
EMULATOR | checkEmulator(session: DetectionSession) | emulatorknown_environment_features |
DEVICE_CAPABILITIES | checkDeviceCapabilities(session: DetectionSession) | device_capabilitiesapp_visible_devices |
ROOT | checkRoot(session: DetectionSession) | rootvisible_root_related_artifacts |
HOOK | checkHook(session: DetectionSession, classLoader: ClassLoader = session.context.classLoader) | hookcurrent_process_known_traces |
DEBUGGER | checkDebugger(session: DetectionSession) | debuggercurrent_process |
ACCESSIBILITY | checkAccessibility(session: DetectionSession) | accessibilityenabled_services |
INSTALLED_TOOLS | checkInstalledTools(session: DetectionSession, targets: List<ToolTarget> = defaultToolTargets()) | installed_toolscaller_visible_packages |
VPN_PROXY | checkVpnProxy(session: DetectionSession) | vpn_proxyapp_default_network |
SIM_NETWORK | checkSimNetwork(session: DetectionSession) | sim_networkobserved_logical_slots_and_default_network |
INSTALL_SOURCE | checkInstallSource(session: DetectionSession) | install_sourcehost_install_or_update_record |
SIGNATURE | checkSignature(session: DetectionSession, baseline: SignatureBaseline? = 生成基准) | signaturehost_package_signers |
APK_CONTENT | checkApkContent(session: DetectionSession, baseline: ContentBaseline? = 生成基准) | apk_contentbaseline_named_entries_and_dex_inventory |
“生成基准”为默认行为说明,不是 Kotlin 表达式:省略 baseline 时读取本轮已启用的对应生成基准;显式传 null 则本次无基准,返回 UNKNOWN / no_baseline。checkHook 的 classLoader 可指定宿主用于加载类的 ClassLoader;默认使用应用的。CheckId.title 是中文显示名称,不能把枚举名当作 CheckResult.id。
会话、扫描与手动分发
DetectionSession(context: Context, rules: DetectionRules = DetectionRules())
RiskScanner(context: Context)
// RiskScanner 实例方法
fun scan(
config: ScanConfig = ScanConfig(),
selected: Set<CheckId> = CheckId.entries.toSet(),
onResult: (CheckId, CheckResult) -> Unit,
onComplete: (Map<CheckId, CheckResult>) -> Unit = {}
): Unit
fun close(): Unit
// 顶层函数
fun executeCheck(id: CheckId, session: DetectionSession,
config: ScanConfig = ScanConfig()): CheckResult
fun defaultToolTargets(): List<ToolTarget>
| 接口 / 字段 | 类型与默认值 | 含义与约束 |
|---|---|---|
DetectionSession.context / rules | Context / DetectionRules | 保存 applicationContext 和规则快照;本轮懒加载并复用进程、网络、包信息与生成基准。每轮新建,可供同轮多项并发共享;不要长期复用后期待网络等信息实时刷新。 |
scan.config / selected | 默认配置 / 全部 12 项 | 自动跳过未启用且未传显式基准的签名 / 内容项;已启用但基准损坏时仍执行并返回 UNKNOWN。selected 空集抛 IllegalArgumentException。 |
scan.onResult / onComplete | 逐项结果 / 完成结果 Map | 后台回调;逐项可能并发、顺序不保证,完成 Map 按枚举排序且只包含实际执行项。全部跳过时完成回调为空 Map。UI 需切主线程,回调不要抛异常。 |
scan 返回 / 并发限制 | Unit | 立即返回;3 个普通工作线程 + 1 个内容检查线程。同 scanner 扫描中再次扫描,或关闭后调用,抛 IllegalStateException;不会自动排队后续轮次。 |
close() | Unit | 幂等;取消任务并关闭线程池。取消时不保证继续回调,已在执行的回调可能尚未结束。生命周期结束时调用。 |
executeCheck(...) | CheckResult | 同步分发,不执行 scanner 的跳过逻辑。规则来自 session;config.tools / signature / content / useGeneratedBaselines 控制对应检查。config.rules 不会替换已有 session 的规则。 |
defaultToolTargets() | List<ToolTarget> | 返回新列表,包含下方内置 5 个包名。自定义名单始终与内置合并,同名采用调用方的 visibilityDeclared;空列表也保留内置项。 |
ToolTarget | packageName: String;visibilityDeclared: Boolean | 内置精确查询包名默认 true,其他默认 false。true 是调用方确认 Manifest 精确 queries 已覆盖,不能靠设 true 获得权限;宿主删掉内置 queries 后应传 false。不可见且未查到返回 UNKNOWN。 |
可恢复的读取失败一般转为子项 UNKNOWN;线程中断、生命周期取消或宿主回调异常不保证转换成完整 CheckResult。
ScanConfig:全部字段
| 接口 / 字段 | 类型与默认值 | 含义与约束 |
|---|---|---|
rules | DetectionRules = DetectionRules() | 用于本轮新建 DetectionSession。 |
tools | List<ToolTarget> = defaultToolTargets() | 工具安装检测名单,自动合并内置;不改变 rootTools。 |
signature | SignatureBaseline? = null | 显式签名基准优先;null 时再按开关决定是否使用生成基准。 |
content | ContentBaseline? = null | 显式内容基准优先;null 时再按开关决定是否使用生成基准。 |
useGeneratedBaselines | Boolean = true | 是否使用插件生成基准;false 仍可用显式 signature / content。与直接 checkSignature(..., null) 的含义不同。 |
DetectionRules:全部字段与默认规则
| 接口 / 字段 | 类型与默认值 | 含义与约束 |
|---|---|---|
emulatorPaths | List<String> | 默认 /dev/qemu_pipe、/dev/socket/qemud、/system/lib/libc_malloc_debug_qemu.so。 |
rootPaths | List<String> | 默认 /system/bin/su、/system/xbin/su、/sbin/su、/su/bin/su、/system/bin/.ext/.su。 |
rootTools | List<ToolTarget> | 默认 ToolTarget("com.topjohnwu.magisk");与 tools 独立。 |
hookClasses | List<String> | 默认 de.robv.android.xposed.XposedBridge、de.robv.android.xposed.XC_MethodHook。 |
hookModules | Set<String> | 默认 libfrida-gadget.so、libfrida-agent.so、libxposed_art.so、liblsposed.so、libedxposed.so。 |
sensorTypes | List<Int> | 默认 [1, 4, 5, 6]:加速度计、陀螺仪、光照、气压。使用 Android Sensor 类型常量。 |
规则字段传入后替换该字段默认值;要追加请基于默认集合 copy。清空规则并不证明环境安全。工具安装名单合并是单独规则,不代表其他集合也会自动合并。
import android.content.Context
import com.remax.riskcontrol.sdk.*
// 在后台线程执行;每轮新建 session。
fun customRootCheck(context: Context, extraSuPath: String): CheckResult {
val defaults = DetectionRules()
val config = ScanConfig(rules = defaults.copy(
rootPaths = defaults.rootPaths + extraSuPath
))
val session = DetectionSession(context, config.rules)
return executeCheck(CheckId.ROOT, session, config)
}
② 手动基准与生成基准返回值
优先使用可选插件自动生成。手动基准用于已有可信 CI / 发布系统的宿主;不能把用户设备当前包的摘要直接作为可信基准。SHA-256 为 64 位十六进制,大小写和证书摘要中的冒号会被规范化。
SignatureBaseline(
packageName: String,
allowedSignerSets: List<Set<String>>,
allowedRotationAncestors: Set<String> = emptySet()
)
ContentBaseline(
packageName: String, versionCode: Long,
files: List<ContentFile>, dexModules: List<DexModule> = emptyList()
)
ContentFile(apk: String = "base", entry: String, sha256: String)
DexModule(apk: String, files: Map<String, String>,
required: Boolean = apk == "base", matchByDigest: Boolean = false)
| 接口 / 字段 | 类型与默认值 | 含义与约束 |
|---|---|---|
SignatureBaseline.packageName | String | 目标应用包名,必须与当前宿主一致;不一致为 INCONSISTENT。 |
allowedSignerSets | List<Set<String>> | 外层是多套允许的签名方案;每个内层集合是完整的当前签名者证书 SHA-256 集合,不是任选其中一个。 |
allowedRotationAncestors | Set<String> = emptySet() | 可接受的签名轮换祖先证书摘要;只接受 Android 9+ 系统验证的单签名者历史链,不放宽多签名者集合匹配。 |
ContentBaseline.packageName / versionCode | String / Long | 可信构建的包名与版本号;不匹配返回 UNKNOWN / baseline_for_different_package_or_version,不用旧版本基准判篡改。 |
ContentBaseline.files | List<ContentFile> | 需检查的具体 ZIP 条目;可以为空,但 files 与 dexModules 不能同时为空。 |
ContentFile.apk / entry / sha256 | String / String / String | apk 为 base 或安装后的 splitName,不是磁盘 APK 文件名;entry 为 ZIP 内相对路径,例如 assets/config.json;sha256 是可信文件摘要。路径不得含绝对路径、反斜杠或 . / .. 段。 |
ContentBaseline.dexModules | List<DexModule> = emptyList() | 声明完整 DEX 清单;为空时仅检查 files,不能检查额外新增 DEX。非空时还会检查所有已安装 APK 中的意外 DEX;必须恰有一个 required=true 的 base。 |
DexModule.apk / files | String / Map<String, String> | 模块名和根目录 DEX 文件名 → SHA-256,例如 classes.dex;不支持嵌套 DEX 路径。 |
DexModule.required | Boolean = (apk == "base") | 必须安装的模块缺失为 INCONSISTENT;可选模块未安装为 UNKNOWN 子项且 affectsSummary=false。 |
DexModule.matchByDigest | Boolean = false | false 比较文件名及摘要;true 按摘要多重集合比较,允许融合后的 DEX 重命名,但数量必须一致。 |
基准无效为 UNKNOWN / invalid_baseline;没有基准为 UNKNOWN / no_baseline。内容基准最多 32 个模块,assets / 命名文件与 DEX 合计最多 64 个;模块和文件标识不得重复。单文件读取上限 64 MiB、总计 128 MiB,超限 / 无法读取返回 UNKNOWN。
import android.content.Context
import com.remax.riskcontrol.sdk.*
// 所有 expected / trusted 值由可信构建系统提供,不从当前安装包反向生成。
// 示例假设应用只有 base,且其完整 DEX 清单只有 classes.dex。
fun checkWithTrustedBaselines(
context: Context, expectedPackage: String, expectedVersionCode: Long,
trustedCertificateSha256: String, trustedDexSha256: String
): Pair<CheckResult, CheckResult> {
val signature = SignatureBaseline(
packageName = expectedPackage,
allowedSignerSets = listOf(setOf(trustedCertificateSha256))
)
val content = ContentBaseline(
packageName = expectedPackage,
versionCode = expectedVersionCode,
files = emptyList(),
dexModules = listOf(DexModule(
apk = "base", files = mapOf("classes.dex" to trustedDexSha256)
))
)
val session = DetectionSession(context)
return checkSignature(session, signature) to checkApkContent(session, content)
}
生成基准的诊断返回
| 接口 / 字段 | 类型与默认值 | 含义与约束 |
|---|---|---|
loadGeneratedBaselines(context) | GeneratedBaselines | 同步读取 assets/risk-control/baseline.properties,放后台执行。显式调用时会尝试读取文件,即使两项开关关闭。 |
GeneratedBaselines.signature / content | SignatureBaseline? / ContentBaseline? | 各自解析成功的基准;未生成该节或该节无效为 null。一节无效不必然丢弃另一节。构造默认均 null。 |
GeneratedBaselines.reason | String = "" | 成功或有效文件中未启用某节时可为空;读取失败 generated_baseline_unavailable:*;整体格式错误 invalid_generated_baseline;分节错误 invalid_generated_signature_baseline / invalid_generated_content_baseline,可用分号连接。异常类名后缀仅供诊断。 |
③ CheckResult、Finding 与 facts 字段字典
| 接口 / 字段 | 类型与默认值 | 含义与约束 |
|---|---|---|
CheckResult.id | String | 检测标识,见函数表;不同于 CheckId 枚举名。 |
CheckResult.scope | String | 该检查覆盖范围,见函数表;不是登录身份 scope。 |
CheckResult.findings | List<Finding> | 所有独立子项,包含不参与汇总的诊断项。相同 ID 可有多条。 |
CheckResult.facts | Map<String, String> | 本次可读取的事实,字段可缺失,所有值都是字符串;缺失不等于 false / 0。 |
CheckResult.observedAtMillis / durationMillis | Long / Long | 检查开始的 Unix 毫秒 / 单调时钟测得的耗时毫秒。session 缓存事实可能早于该检查开始。 |
CheckResult.status | Status;只读计算属性 | 只汇总 affectsSummary=true 的子项;不等于取第一项。 |
Finding.id / status | String / Status | 子项 ID 和结果。固定 ID / 动态 ID 构造函数见现有子项表。 |
Finding.reason | String = "" | 未知 / 失败原因;空字符串表示未附说明,不必然是成功。 |
Finding.evidence | Map<String, String> = emptyMap() | 该子项的证据键值;按检查而异,字段可缺失;不能把字符串证据当统一安全结论。 |
Finding.affectsSummary | Boolean = true | false 时只展示诊断,不改变顶层 status。 |
finding(id) / findingsById(id) / findingsByPrefix(prefix) | Finding? / List<Finding> / List<Finding> | 精确首项(缺失 null)/ 全部精确项 / 全部前缀项(缺失空列表);动态前缀建议带冒号。 |
条件类:DETECTED > UNKNOWN > NOT_DETECTED;校验类:INCONSISTENT > UNKNOWN > CONSISTENT。分类类(GOOGLE_PLAY / OTHER、PRESENT / ABSENT / NOT_APPLICABLE)须一致;必需子项未知、没有有效汇总项或混合不同结果族均为 UNKNOWN。NOT_DETECTED 只是本次未检出,UNKNOWN 不能当作未安装 / 无卡 / 校验通过。
facts 字段字典
| 检查 | 可返回的键 | 值与解释 |
|---|---|---|
| 模拟器 | fingerprint, model, manufacturer, hardware, product, brand, device, abis | Android Build 字段;abis 为逗号分隔的支持 ABI。 |
| 设备能力 | visibleSensorCount, visibleCameraCount, visibleAudioInputCount, audioInputTypes | 计数字符串;audioInputTypes 为 Android AudioDeviceInfo.type 数字的逗号列表。均为 API 可见设备,不等于实际物理数量。 |
| Root / Hook / 调试器 | interpretation(Root) | Root 可带解释文本;Hook / 调试器主要读取 findings / evidence,没有固定 facts 数值字段。 |
| 无障碍 | enabledServiceCount, service.{index}, service.{index}.eventPackages | 数量、服务组件 / ID、监听包名的逗号列表;all_packages 表示未限定。不能由此证明自动点击。 |
| 工具安装 | interpretation | 解释文本;每个包的判断在 findings 中。 |
| VPN / 代理 | transports, networkObservedAt | 当前默认网络的 wifi / cellular / ethernet / vpn 逗号列表;网络采样 Unix 毫秒。不是所有连接的实际路由证明。 |
| SIM 与网络 | telephonyCapable, activeModemCount, eSimSupported | 电话能力布尔、当前调制解调器数量;eSimSupported 仅表示 eSIM 硬件能力,不代表有已启用配置。 |
| SIM 状态 | defaultSimState, slot.{slot}.state | Android TelephonyManager.SIM_STATE_* 整数的字符串,slot 为逻辑槽索引,不等于物理卡槽。 |
| SIM 运营商 | defaultSimMccMnc, defaultSimCountry | 默认 SIM 的 MCC/MNC、国家 ISO 字符串;拿不到为 UNKNOWN,不能据此认定无卡。 |
| SIM 汇总 | simPresence, absenceCoverageComplete, simReady, transports | simPresence 为 Status 名;absenceCoverageComplete 表示是否充分覆盖无卡证明;simReady 为 true / false / UNKNOWN,未就绪不等于没卡;transports 同网络项。实体卡 / 已启用 eSIM 都可作为有卡证据。 |
| 本地安装来源 | initiatingPackage, installerPackage, record | 发起方(Android 11+)、安装器包名,拿不到为 UNKNOWN;record 为 RECORDED_GOOGLE_PLAY / RECORDED_OTHER / UNKNOWN。不是 Google 验证结论。 |
| 签名 | currentSignerSha256, multipleSigners | 排序后的当前证书 SHA-256 逗号列表;是否多签名者 true / false。 |
| 内容 | bytesRead, coveredEntries, dexModules, installedApks | 累计读取字节、基准覆盖的 apk:entry 列表、基准模块列表、实际安装容器列表;后 3 项逗号分隔。coveredEntries 不表示所有文件已成功验证。 |
| 解释文本 | interpretation | 部分检查附带的可读范围说明,不作为机器协议字段判断。 |
逗号列表解析时去除空白;异常、提前返回、系统版本限制会导致部分键不存在。业务分支优先使用 Status、FindingIds 和明确的公开模型,不依赖英文解释或异常类名。FindingIds.TARGETS 是保留常量,当前工具检测不输出该子项。
④ Play Integrity:全部返回字段、请求绑定与错误
PlayIntegrityClient(context: Context, cloudProjectNumber: Long)
fun requestPlayIntegrityToken(): Task<IntegrityTokenResult>
复用同一应用 / 项目的 client 以共享预热;每次请求仍生成新 requestId 与凭证。cloudProjectNumber 是 Google Cloud 数字项目编号,不是 projectId 字符串;≤ 0 在请求时返回失败 Task。业务无需传 requestHash,也无需额外申请用户权限。仅调用成功表示取得凭证,不能当作完整性验证通过。
| 接口 / 字段 | 类型与默认值 | 含义与约束 |
|---|---|---|
IntegrityTokenResult.token | String | Google 返回的加密凭证,供业务后端验证;SDK 不解密、不自动上传,也不提供最终风险结论。 |
cloudProjectNumber | Long | 本次使用的项目编号;后端仍应使用自身可信配置,不盲信客户端提交值。 |
requestContext | IntegrityRequestContext | 请求绑定上下文,须与 token 一同交给后端。 |
durationMillis | Long | 成功请求总耗时(含需要的预热),单位毫秒。 |
requestHash | String | 等于 requestContext.requestHash;不是服务端 nonce。 |
IntegrityRequestContext.packageName | String | 真实 Android 应用包名,不是登录接口 bundleId。 |
requestId / createdAtMillis | String / Long | SDK 新生成的 UUID / 客户端 Unix 毫秒。 |
requestHash / canonicalPayload() | String / String | 规范文本的 UTF-8 SHA-256 小写十六进制 / 规范文本本身。 |
后端如何匹配本次请求
risk-control/environment/v1
{packageName}
{requestId}
{createdAtMillis}
上面 4 行以 LF 换行,末尾不加换行,时间为十进制整数。后端按传入上下文重建文本并计算 SHA-256,再与 Google 解密结果的 requestDetails.requestHash 比较;同时核验预期包名、时效和业务要求的应用 / 设备等结果。不要只比较客户端传来的两个 hash。上下文由客户端生成,不能代替服务端挑战、登录授权或业务防重放策略;后端接口由业务实现。
失败、超时与线程
| 接口 / 字段 | 类型与默认值 | 含义与约束 |
|---|---|---|
IntegrityTokenException.stage | IntegrityStage | CONFIGURATION:本地项目参数;PREPARE:预热;REQUEST:取凭证。 |
reason | String | 本地 cloud_project_number_missing_or_invalid、cancelled、client_error;Google 错误对应名称,未知码为 GOOGLE_ERROR_数字。 |
googleErrorCode | Int? | Google 数字错误码;本地异常通常 null。可结合 stage 分析,异常 cause 可供诊断。 |
90 秒截止时间 | TimeoutException | 返回的 Task 最多等待 90 秒;超时不一定包装为 IntegrityTokenException。已提交 Google 的操作不能被强制停止,晚到的预热不会继续发起已过期请求。 |
重试 / 回调 | 异步 Task | 仅 provider 失效(-19)时自动重新预热并请求一次;其他配置 / 网络失败不无限自动重试。后续显式调用可重新尝试。可主线程发起,默认 listener 在主线程执行,重活切后台。 |
| Google 错误码 | reason |
|---|---|
-1 | API_NOT_AVAILABLE |
-2 | PLAY_STORE_NOT_FOUND |
-3 | NETWORK_ERROR |
-5 | APP_NOT_INSTALLED |
-6 | PLAY_SERVICES_NOT_FOUND |
-7 | APP_UID_MISMATCH |
-8 | TOO_MANY_REQUESTS |
-9 | CANNOT_BIND_TO_SERVICE |
-12 | GOOGLE_SERVER_UNAVAILABLE |
-14 | PLAY_STORE_VERSION_OUTDATED |
-15 | PLAY_SERVICES_VERSION_OUTDATED |
-16 | CLOUD_PROJECT_NUMBER_IS_INVALID |
-17 | REQUEST_HASH_TOO_LONG |
-18 | CLIENT_TRANSIENT_ERROR |
-19 | INTEGRITY_TOKEN_PROVIDER_INVALID |
-100 | INTERNAL_ERROR |
import com.remax.riskcontrol.sdk.integrity.*
import java.util.concurrent.TimeoutException
// client 由宿主持有并复用;onToken 交给业务自己的后端调用层。
fun obtainIntegrityToken(
client: PlayIntegrityClient,
onToken: (IntegrityTokenResult) -> Unit,
onFailure: (String) -> Unit
) {
client.requestPlayIntegrityToken()
.addOnSuccessListener { result ->
// 同时传 token 与 requestContext,不只传 cloudProjectNumber。
onToken(result)
}
.addOnFailureListener { error ->
val description = when (error) {
is IntegrityTokenException ->
"${error.stage}: ${error.reason}, code=${error.googleErrorCode}"
is TimeoutException -> "本次取凭证超时"
else -> "取凭证失败:${error.message}"
}
onFailure(description)
}
}
⑤ 插件全部配置、路径与默认值
插件为 app 模块级可选接入,仅影响签名与内容基准。插件版本 0.1.0 当前仅配置本地 Maven 发布,不能假定 GitHub Packages 已有插件;SDK 0.1.7 的远端发布不代表插件已远端发布。具体引入见插件接入。
| 接口 / 字段 | 类型与默认值 | 含义与约束 |
|---|---|---|
contentEnabled | Property<Boolean>;false | 启用 assets + 最终 DEX 内容基准;不开启不执行对应生成 / 改包流程。 |
signatureEnabled | Property<Boolean>;false | 独立启用签名基准。两项都 false 时不校验证书,不施加插件多 APK 输出限制。 |
signingMode | Property<String>;"playAppSigning" | 仅 buildSigning / playAppSigning;前者取当前变体签名,后者必须提供 Play 应用签名公钥证书。 |
certificateFiles | ConfigurableFileCollection;空 | X.509 DER / PEM 公钥证书文件,非 keystore、非私钥。最多 16 个不同证书摘要;每个证书作为一套单签名者方案,不表示多签名者组合。非空时优先于 buildSigning 的 keystore。 |
certificateFiles 路径 | rootProject.file(...) / file(...) | 分别相对工程根目录 / 当前 app 模块;证书需随项目或 CI 可信输入提供。setFrom 替换继承集合,from 追加。开启签名但缺失 / 无效时构建报错;Play 模式缺文件会提示到 Console 下载应用签名证书。 |
assetIncludes | ListProperty<String>;["**"] | Ant 风格 glob,相对合并后的 assets 根目录;例如 config/**,不要加 assets/ 前缀。 |
assetExcludes | ListProperty<String>;[] | 从 includes 中排除匹配路径;两者仅控制 assets,不影响自动生成的完整 DEX 清单。 |
onVariants { variant -> ... } | Action<in RiskVariantBaseline> | 先继承全局配置,再按注册顺序应用回调;变体间配置独立。variant.name 是完整名如 devRelease,variant.buildType 是 release;均为 String。variant 支持以上同名选项。 |
// app/build.gradle.kts;先按上方说明应用插件。
riskContentSignature {
// 默认保持关闭;仅为指定变体开启。
onVariants { variant ->
if (variant.name == "prodRelease") {
variant.contentEnabled.set(true)
variant.assetIncludes.set(listOf("config/**", "rules/*.json"))
variant.assetExcludes.set(listOf("config/debug/**"))
variant.signatureEnabled.set(true)
variant.signingMode.set("playAppSigning")
variant.certificateFiles.setFrom(
rootProject.file("certificates/play-app-signing.der")
)
}
}
}
自动基准写入 assets/risk-control/baseline.properties,该路径保留给插件,冲突会报错。使用公开 AGP Artifacts API 接入 APK / AAB;已验证 AGP 8.13.2 / Gradle 8.13 / JDK 17,不承诺未来版本无需适配。构建 API 或产物约束不兼容时应构建失败,不会静默生成“通过”基准。最终导出目录为 app/build/outputs/risk-control/<variant>/apk 或 bundle。
运行时是否跳过取决于开关:未启用且未显式传基准的扫描项跳过;已启用但基准缺失 / 损坏仍执行并返回 UNKNOWN。插件不保证适配加固改写,详情见加固影响;多 APK、部分动态功能交付等限制见检查范围。
12 个函数与返回值
| 检测项 | 函数 | 顶层 status | 含义 |
|---|---|---|---|
| 模拟器 | checkEmulator | DETECTED / NOT_DETECTED / UNKNOWN | 检出已知模拟器特征 |
| 设备能力 | checkDeviceCapabilities | DETECTED / NOT_DETECTED / UNKNOWN | 检出指定能力缺失 |
| Root | checkRoot | DETECTED / NOT_DETECTED / UNKNOWN | 检出可见 su 痕迹 |
| Hook | checkHook | DETECTED / NOT_DETECTED / UNKNOWN | 检出框架类、模块或调用栈痕迹 |
| 调试器 | checkDebugger | DETECTED / NOT_DETECTED / UNKNOWN | 检出调试连接、等待或 native tracer |
| 无障碍 | checkAccessibility | DETECTED / NOT_DETECTED / UNKNOWN | 存在已启用服务 |
| 工具安装 | checkInstalledTools | DETECTED / NOT_DETECTED / UNKNOWN | 至少一个目标工具已安装;默认内置名单,可传 targets 追加 |
| VPN / 代理 | checkVpnProxy | DETECTED / NOT_DETECTED / UNKNOWN | 存在默认网络 VPN 或代理 |
| SIM / eSIM | checkSimNetwork | PRESENT / ABSENT / UNKNOWN / NOT_APPLICABLE | 有卡 / 确认无卡 / 未知 / 无电话能力;不再汇总网络组合条件。 |
| 安装来源 | checkInstallSource | GOOGLE_PLAY / OTHER / UNKNOWN | 系统记录:Play / 其他安装方 / 来源未知;不是 Google 官方验证。 |
| 签名 | checkSignature | CONSISTENT / INCONSISTENT / UNKNOWN | 包名与签名符合基准 / 不符合 / 无法判断。 |
| 内容 | checkApkContent | CONSISTENT / INCONSISTENT / UNKNOWN | 覆盖范围内一致 / 已发现不一致 / 无法判断。 |
| 检测类型 | 返回值 |
|---|---|
| 特征 / 条件检测 | DETECTED / NOT_DETECTED / UNKNOWN |
| 签名 / 内容 | CONSISTENT / INCONSISTENT / UNKNOWN |
| SIM / eSIM | PRESENT / ABSENT / UNKNOWN / NOT_APPLICABLE |
| 本地安装来源 | GOOGLE_PLAY / OTHER / UNKNOWN |
所有本地函数返回 CheckResult;子项在 findings,原因在 finding.reason,事实在 facts。CONSISTENT 是与基准一致,INCONSISTENT 是不一致。UNKNOWN 不能当作通过;特征检出也不直接认定作弊。
独立子项:常量、取法、返回含义(完整参考)
每个检测函数返回一个 CheckResult,独立子项放在 result.findings: List<Finding> 中。子项不是另外一个需要执行的函数;先执行所属检测,再读取结果即可。
| 字段 | 业务如何使用 |
|---|---|
result.status | 本检测项的顶层结论,只汇总 affectsSummary=true 的子项。 |
finding.id | 用 FindingIds 常量或生成方法查询;批量查询使用 *_PREFIX 常量,无需手写 ID。 |
finding.status | 按所属子项的语义判断,见下方逐项表格。 |
finding.reason | 未知、不可用等原因;空字符串不等于“成功”。 |
finding.evidence | 子项证据,Map<String, String>;键可能不存在。文件缺失等不一致详情可能在 evidence["reason"]。 |
finding.affectsSummary | true:参与顶层汇总;false:仅辅助信息,业务仍可单独读取。 |
result.facts | 检测事实信息,Map<String, String>;不是子项状态,缺失或 UNKNOWN 不能当作否定值。 |
检测参数与结果查询参数不是一回事
正常检测无需业务填写 su 路径、传感器类型或 DEX 文件名。su 路径、传感器类型来自 SDK 默认规则;DEX 文件名来自插件生成或业务手动提供的可信基准。没有内容基准时返回未知,不会拿当前安装包自行生成可信基准。动态 ID 方法的参数只用于精确取结果,不能增加检查对象。
import com.remax.riskcontrol.sdk.*
// 后台线程调用。使用 SDK 默认规则,不传路径、传感器类型或 DEX 文件名。
val session = DetectionSession(applicationContext)
val root = checkRoot(session)
val suItems = root.findingsByPrefix(FindingIds.SU_ARTIFACT_PREFIX)
val hardware = checkDeviceCapabilities(session)
val sensors = hardware.findingsByPrefix(FindingIds.SENSOR_MISSING_PREFIX)
// 内容依赖可信基准:可选插件生成,或业务手动传入。
val content = checkApkContent(session)
val dexItems = content.findingsByPrefix(FindingIds.DEX_INVENTORY_PREFIX)
// 无基准时 content.status=UNKNOWN,dexItems 可能为空;不能当作校验通过。统一入口:FindingIds + 结果查询方法
导入 com.remax.riskcontrol.sdk.* 后即可使用。固定 ID 提供常量,动态 ID 提供生成方法和前缀常量;SDK 产生结果时也使用同一套定义。底层 Finding.id 仍为字符串,方便 JSON / 日志输出,其值和检测行为不变。
| 取法 | 调用示例 | 返回 |
|---|---|---|
| 单个固定子项 | result.finding(FindingIds.NO_SIM_AND_WIFI) | Finding?;未输出时为 null。 |
| 单个动态子项 | result.finding(FindingIds.sensorMissing(1)) | Finding?;参数为传感器类型数字。 |
| 相同 ID 的全部结果 | result.findingsById(FindingIds.entryMismatch("base", "classes.dex")) | List<Finding>;保留所有阶段的重复 ID。 |
| 动态家族的全部结果 | result.findingsByPrefix(FindingIds.SU_ARTIFACT_PREFIX) | List<Finding>;前缀已包含分隔符,避免相似 ID 混入。 |
| 检测级异常 | result.finding(result.id) | Finding?;只在检测级异常分支出现。 |
finding 只取首条;没有结果返回 null,列表查询没有结果返回空列表,均不伪造 NOT_DETECTED。需要处理重复内容校验结果时用 findingsById。此封装只针对子项 ID;包名、路径等仍是业务入参,facts / evidence 的键仍按文档读取。
可选:精确查询用的动态 ID 参数,以及无需参数的家族前缀
| 精确 ID 生成 | 批量查询前缀 |
|---|---|
FindingIds.qemuFile(path) | FindingIds.QEMU_FILE_PREFIX |
FindingIds.sensorMissing(type) | FindingIds.SENSOR_MISSING_PREFIX |
FindingIds.suArtifact(path) | FindingIds.SU_ARTIFACT_PREFIX |
FindingIds.managerInstalled(packageName) | FindingIds.MANAGER_INSTALLED_PREFIX |
FindingIds.frameworkClass(className) | FindingIds.FRAMEWORK_CLASS_PREFIX |
FindingIds.eventScopeIncludesHost(index) | FindingIds.EVENT_SCOPE_INCLUDES_HOST_PREFIX |
FindingIds.simSlot(slot) | FindingIds.SIM_SLOT_PREFIX |
FindingIds.entryMismatch(apk, entry) | FindingIds.ENTRY_MISMATCH_PREFIX |
FindingIds.archive(apk) | FindingIds.ARCHIVE_PREFIX |
FindingIds.dexModuleMissing(apk) | FindingIds.DEX_MODULE_MISSING_PREFIX |
FindingIds.dexModule(apk) | FindingIds.DEX_MODULE_PREFIX |
FindingIds.dexInventory(apk) | FindingIds.DEX_INVENTORY_PREFIX |
工具安装结果使用 FindingIds.installedTool(packageName),ID 本身就是包名,没有统一家族前缀;查全部指定工具可遍历该结果的 findings,并按包名定位;FindingIds.TARGETS 为保留常量,当前不输出该子项。
固定 ID:以“无卡且 Wi-Fi”为例
import com.remax.riskcontrol.sdk.*
// 在业务后台线程执行;若使用 scanner,直接在回调中读取 result。
val result = checkSimNetwork(DetectionSession(applicationContext))
val item = result.finding(FindingIds.NO_SIM_AND_WIFI)
// 缺少子项时按未知处理,不能当作 NOT_DETECTED。
val status = item?.status ?: Status.UNKNOWN
val reason = item?.reason ?: "finding_unavailable"
when (status) {
Status.DETECTED -> { /* 已确认无卡,且当前默认网络是 Wi-Fi */ }
Status.NOT_DETECTED -> { /* 必要信息已知,组合条件不成立 */ }
Status.NOT_APPLICABLE -> { /* 设备无电话能力 */ }
Status.UNKNOWN -> { /* 信息不足,查看 reason */ }
else -> { /* 非该子项的预期状态,按未知处理 */ }
}
// result.status 是 PRESENT / ABSENT / UNKNOWN / NOT_APPLICABLE,
// 不要用它代替 no_sim_and_wifi 的状态。| SIM 状态 | 默认网络是否 Wi-Fi | no_sim_and_wifi |
|---|---|---|
ABSENT | 是 | DETECTED |
ABSENT | 否 | NOT_DETECTED |
PRESENT | 是或否 | NOT_DETECTED |
PRESENT / ABSENT | 读不到 | UNKNOWN |
UNKNOWN | 任意 | UNKNOWN |
NOT_APPLICABLE | 任意 | NOT_APPLICABLE |
例如:result.status=ABSENT,no_sim_and_wifi=UNKNOWN,表示已确认无卡,但网络信息不足。即便顶层有明确结论,也要看自己关心的子项,不能用顶层状态代替所有子项。
动态 ID:取全部相关子项
import com.remax.riskcontrol.sdk.*
// 在后台线程调用。rootPaths 可通过 DetectionRules 自定义。
val result = checkRoot(DetectionSession(applicationContext))
val suItems = result.findingsByPrefix(FindingIds.SU_ARTIFACT_PREFIX)
val suStatus = when {
suItems.any { it.status == Status.DETECTED } -> Status.DETECTED
suItems.isEmpty() || suItems.any { it.status != Status.NOT_DETECTED } -> Status.UNKNOWN
else -> Status.NOT_DETECTED
}
// 固定包名可以精确读取;它是辅助子项,不决定 Root 顶层状态。
val manager = result.finding(FindingIds.managerInstalled("com.topjohnwu.magisk"))
val managerStatus = manager?.status ?: Status.UNKNOWN表中 SDK 方法用于精确查询时才需要传具体参数;通常直接用对应 *_PREFIX 取整组即可。实际 ID 栏的 {path}、{type}、{className}、{index}、{packageName}、{apk}、{entry} 是 JSON / 日志占位说明,不要手动拼接。例如 sensor_missing:1、qemu_file:/dev/qemu_pipe、entry_mismatch:base:classes.dex。动态子项可能有多条;内容校验同一文件也可能出现在多个检查阶段,不要默认所有 ID 都唯一,重复 ID 用 findingsById,动态家族用 findingsByPrefix,并检查每条结果。
在并发扫描回调中直接读取
import com.remax.riskcontrol.sdk.*
// 由 Activity / ViewModel 持有,生命周期结束时 scanner.close()。
val scanner = RiskScanner(applicationContext)
scanner.scan(
selected = setOf(CheckId.SIM_NETWORK, CheckId.SIGNATURE),
onResult = { id, result ->
when (id) {
CheckId.SIM_NETWORK -> {
val item = result.finding(FindingIds.NO_SIM_AND_WIFI)
val status = item?.status ?: Status.UNKNOWN
// 使用 status;更新 UI 时切回主线程。
}
CheckId.SIGNATURE -> {
val item = result.finding(FindingIds.SIGNATURE_MISMATCH)
when (item?.status ?: Status.UNKNOWN) {
Status.CONSISTENT -> { /* 签名符合基准;包名另看 package_name_mismatch */ }
Status.INCONSISTENT -> { /* 签名不符合基准 */ }
else -> { /* 无法判断;查看 item?.reason */ }
}
}
else -> Unit
}
},
)12 项检测的子项清单
“参与汇总”只说明是否影响该函数的顶层 status,不代表业务必须拦截。特征 DETECTED 也不自动等于作弊。子项因配置、执行分支或读取异常可能缺失,缺失不等于否定结果。
模拟器 · checkEmulator
| SDK 写法 / 实际 ID | 参与汇总 | 判断内容 / 证据 | 返回值含义 |
|---|---|---|---|
FindingIds.KNOWN_BUILD_COMBINATIONknown_build_combination | 是 | 已知模拟器 Build 组合特征;命中规则在 evidence["rules"]。 | DETECTED:条件成立;NOT_DETECTED:未检出;UNKNOWN:无法判断。 |
FindingIds.qemuFile(path)qemu_file:{path} | 是 | 每个配置的 QEMU 路径是否存在;path 是完整路径。 | DETECTED:条件成立;NOT_DETECTED:未检出;UNKNOWN:无法判断。 |
FindingIds.TRANSLATION_LAYER_HINTtranslation_layer_hint | 否 | 当前进程出现已知 ABI 翻译层模块,仅为辅助线索。 | DETECTED:条件成立;NOT_DETECTED:未检出;UNKNOWN:无法判断。 |
设备能力 · checkDeviceCapabilities
| SDK 写法 / 实际 ID | 参与汇总 | 判断内容 / 证据 | 返回值含义 |
|---|---|---|---|
FindingIds.sensorMissing(type)sensor_missing:{type} | 是 | 指定类型传感器不可见;DETECTED 表示缺少,不是存在。type 是 Android sensor type 数字。 | DETECTED:条件成立;NOT_DETECTED:未检出;UNKNOWN:无法判断。 |
FindingIds.NO_VISIBLE_CAMERAno_visible_camera | 是 | 没有 API 可见的摄像头;不等于物理镜头总数。 | DETECTED:条件成立;NOT_DETECTED:未检出;UNKNOWN:无法判断。 |
FindingIds.NO_VISIBLE_AUDIO_INPUTno_visible_audio_input | 是 | 没有 API 可见的音频输入设备。 | DETECTED:条件成立;NOT_DETECTED:未检出;UNKNOWN:无法判断。 |
Root · checkRoot
| SDK 写法 / 实际 ID | 参与汇总 | 判断内容 / 证据 | 返回值含义 |
|---|---|---|---|
FindingIds.suArtifact(path)su_artifact:{path} | 是 | 每个配置的 su 路径是否存在。 | DETECTED:条件成立;NOT_DETECTED:未检出;UNKNOWN:无法判断。 |
FindingIds.managerInstalled(packageName)manager_installed:{packageName} | 否 | 指定 Root 管理工具是否可见且已安装;安装不证明获得 Root。 | DETECTED:条件成立;NOT_DETECTED:未检出;UNKNOWN:无法判断。 |
FindingIds.WRITABLE_SYSTEM_MOUNT_HINTwritable_system_mount_hint | 否 | 观察到受保护系统目录的可写挂载;evidence["mounts"] 给出目录。 | DETECTED:条件成立;NOT_DETECTED:未检出;UNKNOWN:无法判断。 |
Hook · checkHook
| SDK 写法 / 实际 ID | 参与汇总 | 判断内容 / 证据 | 返回值含义 |
|---|---|---|---|
FindingIds.frameworkClass(className)framework_class:{className} | 是 | 当前 ClassLoader 能否找到指定框架类;不执行类初始化。 | DETECTED:条件成立;NOT_DETECTED:未检出;UNKNOWN:无法判断。 |
FindingIds.FRAMEWORK_MODULEframework_module | 是 | 当前进程中有已知 Hook 模块;evidence["modules"] 给出模块名。 | DETECTED:条件成立;NOT_DETECTED:未检出;UNKNOWN:无法判断。 |
FindingIds.FRAMEWORK_STACKframework_stack | 是 | 当前调用栈有已知框架类;evidence["classes"] 给出类名。 | DETECTED:条件成立;NOT_DETECTED:未检出;UNKNOWN:无法判断。 |
调试器 · checkDebugger
| SDK 写法 / 实际 ID | 参与汇总 | 判断内容 / 证据 | 返回值含义 |
|---|---|---|---|
FindingIds.JAVA_CONNECTEDjava_connected | 是 | Java 调试器已连接。 | DETECTED:条件成立;NOT_DETECTED:未检出;UNKNOWN:无法判断。 |
FindingIds.JAVA_WAITINGjava_waiting | 是 | 当前进程正在等待 Java 调试器。 | DETECTED:条件成立;NOT_DETECTED:未检出;UNKNOWN:无法判断。 |
FindingIds.NATIVE_TRACERnative_tracer | 是 | TracerPid > 0;evidence["tracerPid"] 给出读取值。 | DETECTED:条件成立;NOT_DETECTED:未检出;UNKNOWN:无法判断。 |
FindingIds.DEBUGGABLE_BUILDdebuggable_build | 否 | 宿主声明 debuggable;不表示当前有调试器,也不表示重打包。 | DETECTED:条件成立;NOT_DETECTED:未检出;UNKNOWN:无法判断。 |
无障碍 · checkAccessibility
| SDK 写法 / 实际 ID | 参与汇总 | 判断内容 / 证据 | 返回值含义 |
|---|---|---|---|
FindingIds.SERVICE_ENABLEDservice_enabled | 是 | 至少一个无障碍服务已启用,不代表正在自动点击。 | DETECTED:条件成立;NOT_DETECTED:未检出;UNKNOWN:无法判断。 |
FindingIds.eventScopeIncludesHost(index)event_scope_includes_host:{index} | 否 | 该服务订阅的事件包范围包含宿主,或为所有包;index 对应 facts["service.{index}"]。仅在有服务时出现。 | DETECTED:条件成立;NOT_DETECTED:未检出;UNKNOWN:无法判断。 |
工具安装 · checkInstalledTools
| SDK 写法 / 实际 ID | 参与汇总 | 判断内容 / 证据 | 返回值含义 |
|---|---|---|---|
FindingIds.installedTool(packageName){packageName} | 是 | ID 就是传入的包名,没有额外前缀;可见范围内判断是否安装。 | DETECTED:条件成立;NOT_DETECTED:未检出;UNKNOWN:无法判断。 包名不合法或可见性未确认且查不到时为 UNKNOWN。 |
VPN / 代理 · checkVpnProxy
| SDK 写法 / 实际 ID | 参与汇总 | 判断内容 / 证据 | 返回值含义 |
|---|---|---|---|
FindingIds.DEFAULT_NETWORK_VPNdefault_network_vpn | 是 | 当前默认网络声明 VPN transport。 | DETECTED:条件成立;NOT_DETECTED:未检出;UNKNOWN:无法判断。 |
FindingIds.DEFAULT_HTTP_PROXYdefault_http_proxy | 是 | 默认 HTTP / PAC 代理配置存在。 | DETECTED:条件成立;NOT_DETECTED:未检出;UNKNOWN:无法判断。 |
FindingIds.VIRTUAL_INTERFACE_HINTvirtual_interface_hint | 否 | 可见的活动网卡名符合 tun / tap / ppp / wg 规则,仅为辅助线索;evidence["interfaces"] 给出网卡名。 | DETECTED:条件成立;NOT_DETECTED:未检出;UNKNOWN:无法判断。 |
SIM / eSIM · checkSimNetwork
| SDK 写法 / 实际 ID | 参与汇总 | 判断内容 / 证据 | 返回值含义 |
|---|---|---|---|
FindingIds.SIM_PRESENCEsim_presence | 是 | 决定该函数顶层卡状态,兼容实体卡与已启用 eSIM。 | PRESENT:有卡;ABSENT:完整覆盖下确认无卡;UNKNOWN:证据不足;NOT_APPLICABLE:无电话能力。 |
FindingIds.NO_SIM_AND_WIFIno_sim_and_wifi | 否 | “确认无卡”与“默认网络是 Wi-Fi”的组合条件。 | DETECTED:无卡且 Wi-Fi;NOT_DETECTED:必要输入已知且组合不成立;UNKNOWN:输入不足;NOT_APPLICABLE:无电话能力。 |
安装来源 · checkInstallSource
| SDK 写法 / 实际 ID | 参与汇总 | 判断内容 / 证据 | 返回值含义 |
|---|---|---|---|
FindingIds.INSTALLER_SOURCEinstaller_source | 是 | 系统记录的安装方;包名在 facts["installerPackage"],并非 Google 官方验证。 | GOOGLE_PLAY:记录为 com.android.vending;OTHER:记录为其他非空包名;UNKNOWN:来源不可用。 |
签名 · checkSignature
| SDK 写法 / 实际 ID | 参与汇总 | 判断内容 / 证据 | 返回值含义 |
|---|---|---|---|
FindingIds.PACKAGE_NAME_MISMATCHpackage_name_mismatch | 是 | 宿主包名与可信基准是否一致;仅在基准有效后检查。 | CONSISTENT:与基准一致;INCONSISTENT:不一致;UNKNOWN:无法判断。 |
FindingIds.SIGNATURE_MISMATCHsignature_mismatch | 是 | 签名集合与可信基准是否一致。虽然 ID 带 mismatch,状态仍直接表达一致性。 | CONSISTENT:与基准一致;INCONSISTENT:不一致;UNKNOWN:无法判断。 没有基准时 UNKNOWN/no_baseline。 |
内容 · checkApkContent
| SDK 写法 / 实际 ID | 参与汇总 | 判断内容 / 证据 | 返回值含义 |
|---|---|---|---|
FindingIds.entryMismatch(apk, entry)entry_mismatch:{apk}:{entry} | 是 | 指定容器内文件是否存在且摘要一致。apk 为 base 或 split 名;entry 为包内完整路径。 | CONSISTENT:与基准一致;INCONSISTENT:不一致;UNKNOWN:无法判断。 文件缺失是 INCONSISTENT,详情可能在 evidence["reason"]。 |
FindingIds.dexInventory(apk)dex_inventory:{apk} | 是 | DEX 文件清单与摘要检查;安装时合并模块可能按摘要多重集合比较。 | CONSISTENT:与基准一致;INCONSISTENT:不一致;UNKNOWN:无法判断。 evidence 可含 added / missing / duplicate 或 comparison。 |
FindingIds.dexModuleMissing(apk)dex_module_missing:{apk} | 是 | 基准要求必需的 DEX 容器,但安装包中不存在;仅缺失时出现。 | 仅 INCONSISTENT。没有此子项不等于整个内容校验通过。 |
FindingIds.dexModule(apk)dex_module:{apk} | 否 | 可选功能模块未安装;只在这种情况下出现。 | 仅 UNKNOWN,reason=module_not_installed,不影响内容汇总。 |
FindingIds.CONTENT_MISMATCHcontent_mismatch | 是 | 内容检查前置条件不满足;没有基准、基准无效或包名 / 版本不匹配时出现。 | 仅 UNKNOWN;reason 为 no_baseline、invalid_baseline 或 baseline_for_different_package_or_version。 |
仅在异常时出现的诊断子项
| 所属检测 | SDK 写法 / 实际 ID | 参与汇总 | 返回含义 |
|---|---|---|---|
| 设备能力 | FindingIds.SENSORSFindingIds.CAMERASFindingIds.AUDIO_INPUTS | 是 | 仅 UNKNOWN:对应系统服务或枚举调用失败。成功时输出具体能力子项,不输出同名“成功”项。 |
| SIM / eSIM | FindingIds.SIM_READFindingIds.SIM_DEFAULTFindingIds.simSlot(slot)FindingIds.SIM_OPERATOR | 否 | 仅 UNKNOWN:总体读取、默认状态、指定卡槽或运营商信息失败;主结论仍由 sim_presence 决定。 |
| 内容 | FindingIds.archive(apk)archive:{apk} | 是 | 仅 UNKNOWN:对应 ZIP 容器读取失败。 |
| 所有检测 | result.finding(result.id) | 是 | 未被内部探针捕获的常规异常使用检测 ID 记录 UNKNOWN。例如 root、sim_network、signature、apk_content。查看 reason 获取具体原因。 |
不要假设每次都有全部子项。使用 finding / findingsById / findingsByPrefix,缺失时保留未知;必要时遍历该结果的所有 UNKNOWN 子项排查。不要把 UNKNOWN、NOT_APPLICABLE 或空列表当作“正常”。线程中断等仍可能向调用方传播;SDK 无法运行时也无法保证返回结果。
排查原因与证据
// result 是任意检测的返回值;以下只读取结果,不会再次检测。
val diagnostics = result.findings
.filter { it.status == Status.UNKNOWN }
.map { "${it.id}: ${it.reason}" }
val changedFiles = result.findingsByPrefix(FindingIds.ENTRY_MISMATCH_PREFIX)
.filter { it.status == Status.INCONSISTENT }
changedFiles.forEach { item ->
val detail = item.evidence["reason"] // 如 entry_missing;也可能为空。
val actualDigest = item.evidence["actualSha256"]
// 按业务需要消费证据,不要根据 reason 是否为空判断一致性。
}宿主加固:对签名与内容的影响
当前插件没有通用的加固后基准生成适配。加固不必然让两项检测同时失效。以下结果以 SDK 仍能正常运行、相关数据可读取为前提;SDK 不会自动区分“合法加固”与“恶意修改”。
| 加固后的实际情况 | 签名检测 | 内容检测 |
|---|---|---|
| 最终签名符合基准,受检内容也未改变 | CONSISTENT | CONSISTENT(仅覆盖基准范围) |
| 最终签名符合基准,但加固改写 / 加密 / 替换了原 DEX 或受检 assets;仍保留旧基准 | CONSISTENT | INCONSISTENT:摘要或 DEX 清单不一致。合法加固也会命中,不能直接当作弊。 |
| 重新签名,最终安装包签名不在允许基准中 | INCONSISTENT:signature_mismatch | 独立比较受检内容;仅改变签名不会必然命中内容检测。 |
| 已开启对应检测,但基准资产被删除、不可读取或生成基准格式损坏 | 对应基准不可用的项:UNKNOWN / no_baseline | 对应基准不可用的项:UNKNOWN / no_baseline |
| 基准可用,但系统签名信息或包文件读取失败 | UNKNOWN,原因如 signing_info_unavailable、read_error:* | 受影响子项 UNKNOWN,原因如 read_error:*;已发现其他不一致时汇总仍为 INCONSISTENT。 |
| SDK 被破坏,无法加载 / 调用,或宿主无法启动 | 无法保证有 CheckResult 返回 | 无法保证有 CheckResult 返回,不承诺自动返回 UNKNOWN。 |
CONSISTENT 仅表示本次可检查范围内未发现不一致,不等于全包安全。内容子项包括 entry_mismatch:...、dex_inventory:...;未知原因在 findings[].reason。生成基准加载错误可用后台调用 loadGeneratedBaselines(context) 查看。汇总优先级:INCONSISTENT > UNKNOWN > CONSISTENT。
加固项目如何接入
| 检测项 | 当前可采用的接入方式 |
|---|---|
| 签名 | 以用户最终安装包的签名为准。同证书重签可继续使用原基准;换证书则配置新的可信证书。Play 分发用 Play 最终应用签名证书,不能拿加固商或上传证书代替。前提是加固保留基准且 SDK 可正常运行。 |
| 内容 | 加固改写受检文件后,原基准会过期。需要在可信 CI 中针对加固后最终受检内容生成新基准,并确保写入基准后不再改写这些内容、之后完成最终签署。APK / AAB、壳格式和分包流程须按加固方案适配验证;当前插件不能直接保证此流程。 |
| 尚未适配加固内容 | 可以先保持 contentEnabled=false,签名和其他项按需使用。不手动传内容基准时,自动扫描跳过内容项;直接调用内容检查仍返回 UNKNOWN / no_baseline。已开启但基准损坏的检查不会被跳过;跳过或未知都不等于校验通过。 |
不能在用户手机上读取当前加固包,再把它自身的摘要当可信基准。仅把插件任务移动到加固任务之后也不保证兼容;需确认产物输入、基准写入、最终签名及 AAB 分包均匹配。即使完成适配,落盘的壳 / 加密文件校验也不等于运行时解密代码或内存校验。
工具包名与内容检查范围
// 后台线程直接调用:默认查 SDK 内置工具。
val tools = checkInstalledTools(DetectionSession(applicationContext))
// 自定义名单:自动追加到内置名单(先在宿主 Manifest 配好该包名的 queries)。
val config = ScanConfig(tools = listOf(ToolTarget("com.example.tool", visibilityDeclared = true)))
// 不传或传空列表都保留内置名单;相同包名只查一次。内容校验开启后,默认覆盖 base assets 和最终 DEX,不覆盖所有编译资源、native library 或运行时内存。可用 assetIncludes / assetExcludes 缩小 assets 范围。当前 assets + DEX 最多 64 个文件,单文件 64 MiB、合计 128 MiB。插件启用时,多 APK 输出暂不支持;内容开启时,部分动态功能交付和特殊签名重签暂不支持,构建会明确报错。两项都关闭时不施加插件的多 APK 输出限制。
默认工具查询包名:com.topjohnwu.magisk、de.robv.android.xposed.installer、org.lsposed.manager、org.autojs.autojs、org.autojs.autojs6。这些具体包名是实现时补充的初始名单;原需求文档列举了相关工具名称,并未提供这份完整包名清单,也尚未全部覆盖原文提到的工具。自定义包名需宿主声明精确 queries,再传 ToolTarget(packageName, visibilityDeclared = true);不可见且查不到返回未知。传入名单自动与内置名单合并,按包名去重;空列表仍查内置名单。同名包采用业务传入的 visibilityDeclared,便于宿主移除 queries 时显式设为 false。Root 默认管理工具名单与 tools 参数独立。