Android SDK 0.1.1
接入文档
SDK 提供登录、设备风险采集和事件上报,负责会话缓存与上报重试。广告、提现和页面行为由宿主处理。
添加依赖
在 App 模块添加依赖,并配置下方的 GitHub Packages 仓库。
dependencies {
implementation("com.remax:risk-control:0.1.1")
}
仓库凭据
在用户目录的
~/.gradle/gradle.properties 配置拥有该仓库 /
包读取权限、包含 read:packages 的 GitHub 令牌;CI
可用同名用途的环境变量。以下均为占位符。
gpr.user=YOUR_GITHUB_USERNAME
gpr.key=YOUR_GITHUB_PACKAGES_TOKEN
Maven 仓库
合并到宿主现有的
dependencyResolutionManagement。仓库凭据只参与 Gradle
下载;SDK 的 appSecret 是另一项业务配置。
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
exclusiveContent {
forRepository {
maven {
url = uri("https://maven.pkg.github.com/toukaremax/risk-control")
credentials {
username = providers.gradleProperty("gpr.user")
.orElse(providers.environmentVariable("GITHUB_ACTOR")).orNull
password = providers.gradleProperty("gpr.key")
.orElse(providers.environmentVariable("GITHUB_TOKEN")).orNull
}
}
}
filter { includeModule("com.remax", "risk-control") }
}
}
}
传递依赖、权限与混淆
| 依赖 | 声明版本 | 用途 |
|---|---|---|
| kotlinx-coroutines-core | 1.10.2 | 挂起函数、StateFlow 和队列 |
| okhttp | 4.12.0 | HTTP 请求 |
| play:integrity | 1.6.0 | 可选 Play Integrity API |
| play-services-tasks | 18.2.0 | Play Integrity 的 Task 返回类型 |
以上依赖通过 Maven 元数据自动传递,最终版本由宿主 Gradle
冲突解析确定。AAR 合并 INTERNET、ACCESS_NETWORK_STATE
普通权限,无运行时授权弹窗。登录和事件上报无需接入内容签名插件或
Play Integrity。
SDK 发布 AAR 不预混淆,随宿主 Release 的 R8 一起处理;目前无需额外添加 SDK keep 规则。HTTP 明文地址是否允许由宿主网络安全配置决定。
初始化
每个服务 / 应用配置复用一个 RiskClient 和 ReportQueue。业务只提供身份、选择策略、在真实事件发生时调用。
下面工厂的入参均由宿主提供。示例显式沿用短剧的 UNKNOWN → 未发现风险策略;SDK 检测本身保留 UNKNOWN,其他业务应按自己的约定处理。
import android.content.Context
import com.remax.riskcontrol.sdk.Status
import com.remax.riskcontrol.sdk.client.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
class AppRisk(
context: Context,
baseUrl: String,
bundleId: String,
appSecret: String,
debugEnabled: Boolean,
parameters: suspend (String) -> LoginParameters,
onSession: (RiskSession) -> Unit = {},
) {
private val appContext = context.applicationContext
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val client = RiskClient(
context = appContext,
config = RiskClientConfig(
baseUrl = baseUrl,
bundleId = bundleId,
appSecret = appSecret,
debug = RiskDebugConfig(enabled = debugEnabled, tag = "CoreModule"),
persistSession = true,
),
loginParameters = parameters,
deviceRisk = { collectDeviceRisk(appContext).withShortDramaUnknownPolicy() },
onSession = onSession,
)
// 默认持久化;创建后自动恢复旧事件。
val reports = ReportQueue(client, applicationScope)
}
// 这是业务策略,不是 SDK 默认行为。
private fun DeviceRisk.withShortDramaUnknownPolicy(): DeviceRisk {
fun Status.resolved() = if (this == Status.UNKNOWN) Status.NOT_DETECTED else this
return copy(
root = root.resolved(), adb = adb.resolved(), emulator = emulator.resolved(),
hook = hook.resolved(), accessibility = accessibility.resolved(), vpn = vpn.resolved(),
)
}
// parameters 参数示例。countryByScope / stableDeviceId / attributionNetwork
// 来自宿主现有的身份与归因数据;不要每次生成新的 DeviceId。
val loginParameters: suspend (String) -> LoginParameters = { identityScope ->
LoginParameters(
deviceId = stableDeviceId,
country = countryByScope.getValue(identityScope),
network = attributionNetwork, // 可选:归因渠道
userAgent = existingUserAgent, // 可选:覆盖 Android 默认值
// deviceType / deviceVersion / isp / version 不传则使用 Android 默认值
// gaid / adid 不传则省略
)
}
// 在宿主协程中获取登录态:内存 → 加密磁盘缓存 → /user/login。
val session = appRisk.client.ensureSession(identityScope)
// session.userId / session.token 提供给业务接口;使用前检查权限和凭据。
// 在真实事件发生时调用,无需先手动登录,也无需自己实现重试。
appRisk.reports.report(identityScope, ReportEventType.CONTENT_START)
appRisk.reports.report(identityScope, ReportEventType.OPEN_PAYOUT_PAGE)
// 开发工具可在协程内等待本次事件结果;普通页面无需等待。
val receipt = appRisk.reports.report(identityScope, ReportEventType.CONTENT_COMPLETE)
receipt.await()
创建 RiskClient 本身不请求登录。ReportQueue 创建时会恢复待发事件;有积压事件时可能随发送触发登录。队列 scope 使用 Application 生命周期,不使用退出页面即取消的生命周期。
配置参考
可选参数省略时使用默认实现;只覆盖业务已经维护的字段。
RiskClientConfig
| 参数 | 默认值 / 要求 | 作用 |
|---|---|---|
baseUrl: String |
必填 | 后端根地址,拼接 /user/login 和 /user/report;支持根地址包含路径。 |
bundleId: String |
必填 | 服务端应用标识并参与签名,可与开发 APK 的包名不同。 |
appSecret: String |
必填 | login 签名密钥,不是 GitHub Packages 令牌。 |
debug: RiskDebugConfig |
enabled=false tag="RiskControl" |
统一控制 login、report、队列日志,可覆盖 tag。 |
persistSession: Boolean |
true | 加密保存登录态;false 时只使用内存,不影响事件持久化。 |
RiskClient 构造参数
| 参数 | 默认 / 类型 | 调用时机与用途 |
|---|---|---|
config |
必填 · RiskClientConfig | 服务配置。 |
context |
必填 · Context | 内部仅保留 applicationContext,用于设备默认值、采集、持久化。 |
loginParameters |
必填 · suspend (String) → LoginParameters | 向业务获取身份和登录字段。IO 上执行;首次校验磁盘缓存及每次真实登录调用,内存命中不调用。 |
deviceRisk |
可选 · suspend () → DeviceRisk? | 默认 collectDeviceRisk(context),真实登录时在 IO 采集。显式传 null 或返回 null 省略 RiskStat。 |
client |
可选 · OkHttpClient | 默认 connectTimeout=15 秒、readTimeout=30 秒。report 使用派生 client,关闭自动连接重试和重定向;login 保留传入配置。 |
nowEpochSeconds |
可选 · () → Long | 默认系统 Unix 秒。请求签名、Retry-After 计算、reportOnce 默认时间使用它。队列事件时间独立在入队时采集。 |
nonceSource |
可选 · () → String | 默认 UUID;每次带会话的请求产生新 Nonce。login 不使用 Nonce。 |
diagnostics |
可选 · RiskDiagnostics | 默认 Android Log.d;debug 开启时收到 tag 和文本,可接宿主日志系统。 |
onSession |
可选 · (RiskSession) → Unit | 默认空函数。恢复磁盘或新登录结果写入内存 / 状态后通知业务,包括等级 3;内存命中、登录失败不通知。 |
LoginParameters
| 参数 → 协议字段 | 默认行为 | 覆盖规则 |
|---|---|---|
deviceId → DeviceId |
必填非空 | 沿用宿主稳定设备 / 用户身份。 |
country → Country |
必填非空 | 服务端约定的地区代码。 |
deviceType → DeviceType |
厂商 + 型号 | null 使用默认值;非 null 使用传入值。 |
deviceVersion → DeviceVersion |
Android OS 版本 / API / Build | 同上。 |
isp → Isp |
系统运营商名(尽力获取) | null 尝试默认值;空串省略字段。 |
userAgent → UserAgent |
WebSettings 默认 UA / http.agent | null 尝试默认值;空串省略字段。 |
version → Version |
宿主 versionName | null 使用默认值;非 null 使用传入值。 |
gaid → Gaidadid → Adidnetwork → Network
|
不自动采集归因信息 | 由业务提供,null / 空串省略。 |
Network 是业务归因渠道,RiskStat.NetworkType
是当前设备网络类型,两者含义不同。
登录态与风控
ensureSession 表示确保有可复用结果;有缓存时不会重新请求登录。
先查找内存会话;未命中时尝试恢复磁盘缓存,仍未命中才请求登录。恢复缓存或取得新登录结果后,触发
onSession。
| 公开函数 | 返回值 | 行为 |
|---|---|---|
state(scope: String) |
StateFlow<SessionState> | 观察 LoggedOut / LoggingIn / Authenticated(session) / Restricted(riskTolerance) / Failed(reason)。 |
currentSession(scope: String) |
RiskSession? | 仅读内存;不登录、不恢复磁盘。 |
suspend ensureSession(scope: String) |
RiskSession | 同 scope 并发合并;优先内存,再恢复磁盘,最后登录。 |
suspend invalidateSession(scope: String, expected:
RiskSession)
|
Unit | 仅清除与 expected 相同实例的当前会话,同时清磁盘;旧请求不会误删较新的会话。 |
suspend refreshSession(scope: String) |
RiskSession | 主动清除缓存,再获取新登录结果。 |
RiskSession 返回值
| 字段 | 含义 |
|---|---|
scope: String |
对应的业务身份键。 |
userId: Long / token: String |
业务接口所用登录凭据。等级 3 可能缺少凭据,不能拿空凭据调用业务接口。 |
riskTolerance: Int |
原样保留服务端等级,见下表。 |
canLogin: Boolean |
riskTolerance 在 0..2 时为 true。 |
canWithdraw: Boolean |
riskTolerance 为 0 时为 true。 |
isRestored: Boolean |
true 表示从磁盘恢复,是旧决策,不是刚取得的服务端结果。 |
| 等级 | SDK 语义 | 短剧目前采用的业务策略 |
|---|---|---|
| 0 | 正常 | 允许广告和提现,清除旧限制。 |
| 1 | 限制提现 | 提现入口提示,不限制广告。 |
| 2 | 限制广告和提现 | 调用广告屏蔽 API,提现入口提示。 |
| 3 | 本次不可登录;canLogin=false | 短剧同样屏蔽广告和提现,SDK 仍保留等级 3。 |
SDK 不调用广告库、不弹 Toast、不关闭应用,也不代替业务判断提现。是否上报以是否有完整凭据为准;等级 1 / 2 仍可上报。等级 3 无 Token 时失败结束,不循环登录。
业务接口如何无感刷新
SDK 自动处理自己的 report 会话失效。提现等宿主接口继续由现有拦截逻辑识别失效,并调用以下公共函数:
// failedSession 必须是这次失败请求实际使用的会话对象。
appRisk.client.invalidateSession(identityScope, failedSession)
val refreshed = appRisk.client.ensureSession(identityScope)
// 由业务决定是否使用 refreshed 重发该业务请求,并限制恢复次数。
// 不要将所有业务错误都当成 Token 失效。
SDK 的会话失效兼容条件为 Result=201 / 406 或
HTTP 401。单独 HTTP 406
不算;这是当前客户端兼容规则。
缓存隔离与持久化细节
默认以 Android Keystore AES-GCM 加密 Token、UserId、RiskTolerance 等会话数据,存入私有 SharedPreferences。按宿主包名、规范化 baseUrl(含路径)、bundleId、appSecret 和 scope 隔离,恢复时校验 DeviceId / Country。加密不可用时只用内存,不降级明文。
SDK 不猜测 Token 有效期;久未使用的缓存可先复用,服务端返回失效后再刷新。等级 3 不保存成可恢复会话,但进程内保留决策,防止循环重登。新登录时才能拿到新风控结果。
业务广告 / 提现策略的持久化由宿主维护。短剧会单独保存限制;磁盘恢复的旧会话不会覆盖更新的业务限制。新登录结果为 0 时清除限制,失败 / 离线时不自动放行。
事件上报
普通业务全部使用 ReportQueue。SDK 负责登录、签名、持久化、串行发送和失败重试。
// scope 是应用级 CoroutineScope,store 通常省略。
val reports = ReportQueue(client = riskClient, scope = applicationScope)
val receipt: ReportReceipt = reports.report(
identityScope = identityScope,
type = ReportEventType.AD_COMPLETE,
value = revenue, // Double;默认 0.0,必须非负且有限
)
// receipt.id 是本地事件 ID;不会作为幂等键发送给后端。
// 在协程中 receipt.await() 等待确认或失败;取消等待不取消事件。
| 枚举 | EventType | 业务调用位置 / Value |
|---|---|---|
NOTHING |
0 | 空事件,按服务端用途使用。 |
CONTENT_COMPLETE |
1 | 内容完成的既有业务回调,通常 Value=0。 |
AD_COMPLETE |
2 | 短剧沿用广告收益回调,Value=收益,单位沿用服务端约定;不是广告关闭回调。 |
CONTENT_START |
4 | 播放器实际开始播放的回调;宿主避免缓冲 / 暂停恢复重复计数。 |
OPEN_PAYOUT_PAGE |
5 | 进入提现页面后,Value=0;入口被拦截未进页面不报。 |
当前不提供广告开始
AD_START / 3。开发工具可以直接调用相同公共队列,模拟内容开始、提现页打开等事件。
{
"EventType": 5,
"Value": 0,
"Timestamp": 1793318400,
"Retry": 0
}
Timestamp 是入队时记录的事件 UTC Unix
秒,重试保持原值。Retry 首次即传
0;同一事件重发递增。每次请求头 Timestamp / Nonce / Sign
会重新生成。仅登录阶段失败时,尚未发出 report,不增加该事件的
Retry。
单次调用:reportOnce
riskClient.reportOnce(
scope = identityScope,
type = ReportEventType.CONTENT_START,
value = 0.0,
timestampSeconds = occurredAtUtcSeconds, // 可省略,默认当前 UTC 秒
)
挂起函数,返回 Unit;不持久化,不做网络 / 429 定时重试,仅支持一次失效会话恢复重发。常规业务和短剧开发工具均应使用 ReportQueue。
队列与重试
同一队列按入队顺序发送。网络恢复由定时尝试发现,没有主动注册网络恢复监听。
- 第一次重试:等待 30 秒。
- 第二次重试:等待 2 分钟。
- 第三次重试:等待 5 分钟。
- 之后每 30 分钟重试一次。
前三档最多追加 20% 随机延迟;之后每 30 分钟一次。与服务端
Retry-After 取较长时间。网络失败与 HTTP 429
共用退避次数。
| 结果 / 情况 | 队列处理 |
|---|---|
| 断网、连接失败、读写超时、HTTP 429 | 保留事件、持久化下次尝试时间;队首等待期间,后续事件排队。 |
| report 会话过期 | 清除失效会话、重新登录,原事件最多立即恢复重试一次;网络 / 限流重试不重置该次数。 |
| 服务端成功 | 移除持久化记录,完成回执并记录 ack;不另外保存成功历史。 |
| 业务错误、其他 HTTP 错误(含普通 5xx)、无效 JSON、明确证书 / 主机名校验失败 | 完成失败并移出队列,继续后续事件。 |
| 风控拒绝且缺少凭据 | 失败结束,不自动循环登录。 |
| App 进程退出 | 下次初始化队列恢复;上次仍标记发送中的事件按不确定尝试保留,增加 Retry 并退避。 |
| 持久化读写失败 | 停止本轮发送并通知当前等待方,不盲目继续发请求。 |
成功口径、重复发送和生命周期边界
- 当前成功判断是 HTTP 2xx + 可解析 JSON + 解析后的 Result=0。兼容解析中缺失 / 非数字 Result 会按 0 处理,并非只接受显式 HTTP 200 / Result=0。
- 本地删除发生在成功响应后;终止类错误也会删除,但回执失败、日志为 failed。调用 report 返回回执不等于已经成功,也不等于已写盘。
- 超时可能发生在服务端已接收之后;目前后端没有事件幂等键,因此重发有重复计数可能。receipt.id 只用于本地跟踪。
- 重试由循环和挂起 delay 实现,不递归增加调用栈。没有队列容量上限或自动过期清理,长期离线持续入队会增加存储占用。
- 进程存活时按计时器重试,进程结束后等待下次启动;不创建系统后台任务。重启 / 新事件不会重置已经持久化的退避时间。
- 默认 SharedPreferences 存储按宿主包名、baseUrl(含路径)、bundleId 隔离;密钥轮换不会切换事件队列。每份存储使用一个队列实例。
RiskStat
RiskClient 默认自动拼接 RiskStat;显式关闭时才省略。检测结果保留 UNKNOWN,业务明确选择协议映射。
// 1. 省略 deviceRisk:默认 collectDeviceRisk(applicationContext)。
// 若检测结果包含 UNKNOWN,直接序列化会失败,需要业务明确处理策略。
// 2. 自定义采集 / 映射(短剧例子见快速接入)。
// deviceRisk = { collectDeviceRisk(applicationContext).withShortDramaUnknownPolicy() }
// 3. 明确不发送 RiskStat。
// deviceRisk = null
// 或 deviceRisk = { null }
| DeviceRisk 字段 | RiskStat 字段 | 含义 |
|---|---|---|
| networkType / timezone / locale | NetworkType / Timezone / Locale | 当前网络类型、时区、系统语言标记。 |
| root | Milan | Root 检测。 |
| adb | Como | ADB 开关;与调试器检测分开。 |
| emulator | Lazio | 模拟器检测。 |
| hook | Roma | Hook 检测。 |
| accessibility | Genoa | 无障碍服务检测。 |
| vpn | Verona | 默认网络 VPN;不把单纯代理当 VPN。 |
collectDeviceRisk(context: Context): DeviceRisk
是挂起函数,内部切换 IO。DeviceRisk.toJson(): JSONObject
将 DETECTED 编码为 0、NOT_DETECTED 编码为 1;遇到 UNKNOWN
拒绝序列化。它只采集登录需要的设备项,不进行完整 APK
扫描,也不请求 Play Integrity Token。
需要独立检测时可用
RiskScanner.scan() 或直接检测函数,详见
设备检测与可选插件指南 ↗。这部分是 SDK 的独立能力。
调试日志
所有 login / report 请求及响应、RiskStat 明细和队列状态,统一使用 RiskClient 的 debug 配置。
RiskClientConfig(
baseUrl = baseUrl,
bundleId = bundleId,
appSecret = appSecret,
debug = RiskDebugConfig(enabled = BuildConfig.DEBUG, tag = "CoreModule"),
)
// 可选:传给 RiskClient 的 diagnostics 参数。
val diagnostics = RiskDiagnostics { tag, message ->
android.util.Log.d(tag, "MultiCountryPayout: " + message)
}
request /user/report headers=
{ ... } body=
{
"EventType": 4,
"Value": 0,
"Timestamp": 1793318400,
"Retry": 0
}
response /user/report http=200 result=0 body=
{
"Result": 0
}
report_queue ack type=CONTENT_START retry=0
report_queue retry type=CONTENT_START reason=network login_only=false delay_ms=32000
默认 enabled=false,不传 tag 时为
RiskControl。开启后按原值打印请求头 / 正文 /
响应,包括 Token、DeviceId、UserId、Sign 等,不脱敏。JSON
两空格缩进,长消息分段编号;不改变实际请求正文和签名。appSecret
本身不是请求字段,不单独输出。
| 操作 | 线程行为 |
|---|---|
| HTTP、默认采集、会话与队列磁盘 IO | SDK 内部在 IO 执行,report 可由主线程触发。 |
| 参数校验、读内存、事件时间记录 | 在调用线程执行。 |
| loginParameters / deviceRisk | IO 上执行。 |
| onSession | 调用协程的上下文,不保证 Main;UI 更新自行切主线程或观察 StateFlow。 |
| RiskDiagnostics.log | 可能后台调用;日志回调异常不会破坏业务。 |
| 独立 checkRoot 等同步检测函数 | 调用方负责放后台;RiskScanner 使用内部工作线程。 |
辅助 API
标准接入省略 store 即可。仅兼容旧队列或有独立存储需求时覆盖。
// 显式纯内存:进程结束即丢失,不是默认行为。
val memoryOnly = ReportStore()
// 指定持久化名称;不同环境应使用不同名称。
val persistent = ReportStore.preferences(
context = applicationContext,
name = "my_app_risk_events",
legacyScope = { oldLanguageTag -> scopeByOldLanguageTag[oldLanguageTag] },
)
val reports = ReportQueue(riskClient, applicationScope, store = persistent)
// 自定义串行 JSON 数组读写:read / write 在队列 IO 中执行。
// 不要将无关 JSON 或业务对象作为队列数据写入。
val custom = ReportStore(
read = { readStoredEventsJson() },
write = { json -> persistEventsJson(json) },
legacyScope = { oldLanguageTag -> scopeByOldLanguageTag[oldLanguageTag] },
)
这些示例中的 readStoredEventsJson / persistEventsJson / scopeByOldLanguageTag 由宿主实现。每个 ReportStore 实例首次读取后在内存维护列表,不在每次 report 前重复导入。旧记录缺少 scope 才调用 legacyScope;返回 null 跳过该记录。保存后的新格式包含 scope,重启后不会再按旧字段重复迁移。旧事件没有 Timestamp 时保留缺失,不伪造发生时间。
| 辅助 API / 类型 | 用途 |
|---|---|
RiskProtocol.sign(content: String, key: String):
String
|
HMAC-SHA256,输出小写十六进制;供确需自行适配业务签名的宿主使用,login/report 已自动签名。 |
riskRetryAfterMillis(value: String?, nowMillis: Long):
Long?
|
解析 Retry-After 秒数或 HTTP 日期,返回等待毫秒;无效 / 不支持返回 null。 |
RiskServiceException |
提供 httpCode、result、serverMessage、retryAfterMillis、requestPath,以及 isExpiredSession()。 |
RiskRestrictedException |
提供 riskTolerance,表示缺少可用凭据的风控结果。 |
ReportReceipt |
id: String;suspend await(): Unit。成功确认时完成,终止失败抛异常;只是服务确认,不代表业务收益到账。 |
RiskDiagnostics |
函数接口 log(tag: String, message: String)。 |
ReportStore(read, write, legacyScope) |
read: () → String?;write: (String) → Unit;legacyScope: (String?) → String?。省略三者为纯内存存储。 |
普通 IOException、参数校验异常也可能返回给调用方;协程取消继续传播。SDK 没有对外提供手动 flush、成功历史查询、重试次数配置或队列清空函数。
更新记录
本次保持业务调用接口,升级登录 / 上报可观测性和事件投递行为。
- 统一 HTTP 日志:login / report 的请求、响应和 RiskStat 明细;JSON 格式化,长日志分段。
- 修复主线程发请求:开发工具由 Main 触发事件时,队列显式调度至 IO。
- 持久化网络重试:断网 / 超时保留事件,使用 30 秒 → 2 分钟 → 5 分钟 → 30 分钟退避。
- 恢复未确认事件:重启保留事件时间、Retry 和退避进度,继续尝试未确认的发送。