1概述与工作原理#
实况通知 IslandSDK 让第三方应用把自定义的实况会话内容交给 实况通知宿主,由宿主落到系统级「岛」上展示(安卓实况通知 Live Update 或超级岛)。
┌──────────────────┐ 自定义 Binder / Parcel 协议 ┌──────────────────────────────┐
│ 第三方 App │ (非 AIDL,手写 transact) │ 实况通知宿主 │
│ │ ─────────────────────────────▶ │ .sdk.host.SdkBridgeService │
│ NoticeFlowClient │ │ ├─ SdkWhitelistManager │
│ (SDK 单例) │ ◀───────────────────────────── │ ├─ SdkRateLimitPolicy │
│ │ SdkPostResult 等 │ ├─ SdkSessionRegistry │
└──────────────────┘ │ └─ IslandRouter │
│ ├─ 超级岛(hyper) │
│ └─ 实况通知(live_update)
└──────────────────────────────┘
关键点:
- 通信方式是自定义 Binder 协议,不是 AIDL 生成接口。所有请求的第一个 Parcel 字段是调用方包名;宿主回复统一以
writeNoException()开头,随后写业务数据。 - 上岛由宿主引擎路由决定,接入方无需感知差异。宿主内部按「用户选择的引擎 + 机型是否支持超级岛 + Shizuku 是否授权 + 焦点通知权限是否具备」在超级岛与实况通知之间二选一(绝不双发);任一条件不满足即回落实况通知,保证「切过去却没有岛」不会发生。
- 接入方只面对
NoticeFlowClient:绑定、心跳、自动重连、请求串行化、本地限流、幂等重试、本地会话快照都在 SDK 内部完成。 - 内容由宿主校验并兜底裁剪:SDK 客户端先做本地校验/裁剪,宿主侧再做一次长度与格式兜底(防御异常客户端)。
1.1 宿主包名#
| 常量 | 值 | 说明 |
|---|---|---|
SdkConstants.HOST_PACKAGE_STANDARD |
com.spark.noticeflow |
宿主包名(唯一发布形态) |
SdkConstants.DEFAULT_HOST_PACKAGE |
com.spark.noticeflow |
bind() 不带参数时的默认宿主 |
bind(hostPackage) 可以显式指定要绑定的宿主;能不能绑上取决于该包名的应用是否安装,并且其 SdkBridgeService 是否以 SdkConstants.SERVICE_ACTION 导出。
SDK 只提供接入「实况通知」应用这一种宿主,不存在第二种宿主形态。
1.2 最简接入(3 行,推荐)#
val sdk = IslandSdk.with(context)
sdk.show("正在播放", "夜曲 · 周杰伦", progress = 30, progressMax = 100,
onClickActivity = MainActivity::class.java) // 点岛打开本应用
sdk.update(progress = 60) // 原地刷新进度
按需:sdk.hide() 撤岛、sdk.wake() 唤醒实况通知(重新上岛最近一次内容)。
| 新增能力(1.1.0) | API |
|---|---|
| 打开 Activity | show(..., onClickActivity = X::class.java);点击岛即打开指定 Activity(缺省打开调用方) |
| 岛上显示按钮 | show(..., buttonTitle = "打开", buttonActivity = X::class.java)(最多一个主按钮,可用高级 API 加第二个) |
| 唤醒实况通知 | wake():岛被系统/OEM 撤掉后,用最近一次内容重新上岛 |
为什么代码比原生接入少:原生 Live Update 需要自建 NotificationChannel、拼 Notification.Builder、申请 POST_PROMOTED_NOTIFICATIONS 权限并设置 promoted-ongoing 字段;超级岛还要 focus-api、Shizuku 授权与断网绕过。IslandSDK 把这些全部封装在宿主侧,接入方只需上面的 show/update。
2接入步骤#
2.1 依赖方式#
当前 SDK 以源码模块形式随实况通知工程提供(模块名 noticeflow-sdk),尚未发布到公开 Maven 仓库。可选两种方式:
方式一:源码模块依赖(推荐,当前主用)
// 第三方工程 settings.gradle.kts
include(":noticeflow-sdk")
project(":noticeflow-sdk").projectDir = file("../NoticeFlow/noticeflow-sdk")
// 第三方 app/build.gradle.kts
dependencies {
implementation(project(":noticeflow-sdk"))
}
方式二:本地 Maven 仓库(宿主工程执行一次)
# 在 实况通知宿主工程根目录执行
./gradlew :noticeflow-sdk:publishToMavenLocal
// 第三方 app/build.gradle.kts(坐标与版本号以宿主 build.gradle.kts 的 version 为准)
dependencies {
implementation("com.spark.noticeflow:noticeflow-sdk:1.1.0")
}
SDK 模块的 minSdk 为 32,且依赖 kotlinx-coroutines-android(api/implementation 已由模块自带)。
2.2 声明权限#
SDK 库自身 manifest 已包含该权限声明,依赖 SDK 后会自动合并,通常无需手动再写。若接入方不使用 SDK 依赖而自行实现 IPC,则必须显式声明:
<uses-permission android:name="com.spark.noticeflow.sdk.permission.ACCESS" />
宿主侧对应声明为:
<permission
android:name="com.spark.noticeflow.sdk.permission.ACCESS"
android:protectionLevel="normal"
android:label="访问实况通知 SDK"
android:description="@string/sdk_permission_desc" />
<service
android:name=".sdk.host.SdkBridgeService"
android:exported="true"
android:permission="com.spark.noticeflow.sdk.permission.ACCESS">
<intent-filter>
<action android:name="com.spark.noticeflow.sdk.BIND" />
</intent-filter>
</service>
2.3 在宿主中授权应用(必做)#
授权入口只有一个:实况通知 功能页 → 「IslandSDK 接入」独立大卡(读写 sdk_whitelist_prefs):
- 总开关:
通过 IslandSDK 便捷调用超级岛 / 实况通知(默认关闭;关闭时所有外部调用返回CODE_ERROR_SDK_DISABLED)。 - 请求的应用列表:第三方应用第一次调用 SDK(包括
ping()自检)后,宿主会记录其包名, 即使尚未授权也会显示在这里 —— 列表项标题为应用名、副标题为包名,右侧开关即该应用的授权状态: - 打开开关 → 加入白名单(allow(packageName)),该应用之后可正常上岛; - 关闭开关 → 移出白名单(disallow(packageName)),其请求返回CODE_ERROR_UNAUTHORIZED; - 已卸载但仍在列表里的应用会标注「(已卸载)」,可直接关掉开关清理。
不再提供独立的「对外 SDK 管理」页面:总开关与每个应用的授权开关就是全部配置项。 限流、活跃会话上限等策略使用宿主内置默认值(见第 6 章),无需也不支持在应用内调整。 请求列表持久化、最多保留 50 个包名,按最近请求时间排序;记录动作发生在授权检查之前, 所以未授权应用也一定能出现在列表里,用户不需要先去别处查包名。
2.4 初始化与使用(完整可编译示例)#
绑定 → 自检 → 推送 → 更新 → 关闭 → 解绑的完整流程(lifecycleScope 来自 androidx.lifecycle:lifecycle-runtime-ktx,也可换成任意 CoroutineScope):
package com.example.integrator
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import com.spark.noticeflow.sdk.ConnectionState
import com.spark.noticeflow.sdk.NoticeFlowClient
import com.spark.noticeflow.sdk.SdkConstants
import com.spark.noticeflow.sdk.SdkException
import com.spark.noticeflow.sdk.SdkPostRequest
import kotlinx.coroutines.launch
private const val TAG = "NfSdkDemo"
class DemoActivity : ComponentActivity() {
private lateinit var client: NoticeFlowClient
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 进程内单例:全应用只需一份
client = NoticeFlowClient.get(this)
// 观察连接状态(可选,便于 UI 提示)
client.addConnectionListener { state ->
when (state) {
is ConnectionState.Connected -> Log.i(TAG, "已连接宿主 ${state.hostPackage} v=${state.version}")
is ConnectionState.Binding -> Log.i(TAG, "绑定中…")
is ConnectionState.Reconnecting -> Log.w(TAG, "重连中 attempt=${state.attempt} delay=${state.nextDelayMs}ms")
is ConnectionState.Disconnected -> Log.w(TAG, "已断开 reason=${state.reason} msg=${state.message}")
ConnectionState.Idle -> Unit
}
}
// 1) 绑定宿主(可用 SdkBindOptions 调整自动重连/心跳等连接行为)
val bound = client.bind() // 等价于 bind(SdkConstants.DEFAULT_HOST_PACKAGE, SdkBindOptions())
if (!bound) {
Log.e(TAG, "bindService 返回 false:宿主未安装 / 无权限 / 服务不可见")
return
}
client.startHeartbeat() // 默认 30s 探测一次 isAlive,失败自动进入断线重连
lifecycleScope.launch {
val sessionId = "order_20261011_001"
try {
// 2) 自检
val ping = client.ping()
if (!ping.success) {
Log.e(TAG, "连通性检查失败(多为未授权/总开关关闭):${ping.message}")
return@launch
}
// 3) 新建会话(上岛)
val post = client.postCustom(
SdkPostRequest(
sessionId = sessionId,
title = "外卖配送",
text = "骑手已取餐,正在配送",
capsule = "配送中",
fullText = "骑手已取餐,正在为您配送,预计 10 分钟送达",
progress = 0,
progressMax = 100,
timeoutSec = 1800, // 30 分钟后自动撤岛;0 = 不超时
showDismissAction = true,
clickIntentUri = intentUri("com.example.integrator.MAIN"),
)
)
if (post.code != SdkConstants.CODE_OK) {
Log.e(TAG, "上岛失败 code=${post.code} msg=${post.message}")
return@launch
}
Log.i(TAG, "已上岛 hostSessionId=${post.sessionId}")
// 4) 更新会话(幂等,内部自动重试)
val updated = client.update(
SdkPostRequest(
sessionId = sessionId,
title = "外卖配送",
text = "骑手已到达楼下",
capsule = "已到达",
progress = 100,
progressMax = 100,
timeoutSec = 1800,
)
)
Log.i(TAG, "更新结果 code=${updated.code} msg=${updated.message}")
// 5) 关闭会话(幂等,内部自动重试;会话不存在也返回 OK)
val dismissed = client.dismiss(sessionId)
Log.i(TAG, "撤岛结果 code=${dismissed.code} msg=${dismissed.message}")
} catch (e: SdkException) {
// 远端/IPC 失败:code 见第 8 节错误码表
Log.e(TAG, "SDK 调用异常 code=${e.code} msg=${e.message}", e)
} catch (e: IllegalArgumentException) {
// 本地校验失败:SdkValidator.SdkValidationException(IllegalArgumentException 子类)
Log.e(TAG, "本地参数校验失败:${e.message}", e)
} finally {
client.stopHeartbeat()
client.unbind()
}
}
}
}
/** 生成符合 Intent.URI_INTENT_SCHEME 的跳转 URI(宿主用 Intent.parseUri 解析)。 */
private fun intentUri(action: String): String =
Intent(action).toUri(Intent.URI_INTENT_SCHEME)
生命周期建议
// 应用级初始化(Application 或首个使用点)
val client = NoticeFlowClient.get(context)
client.bind()
// 退出/不需要时释放
client.stopHeartbeat()
client.unbind()
NoticeFlowClient.get(context)返回进程内单例,bind()可重复调用(已连上同一宿主时直接返回true)。- 所有业务 API 都是
suspend函数;未连接时会自动尝试bind()再执行,因此即便忘记手动绑定也不会静默失败(但显式绑定能更早暴露「宿主未安装」)。 - 请求在内部被
Mutex串行化,业务侧无需自己加锁。
3API 参考#
入口:NoticeFlowClient.get(context: Context): NoticeFlowClient(单例)。
3.1 连接管理#
bind(hostPackage: String = SdkConstants.DEFAULT_HOST_PACKAGE, options: SdkBindOptions = SdkBindOptions()): Boolean
绑定宿主服务。Intent(SdkConstants.SERVICE_ACTION).setPackage(hostPackage),BIND_AUTO_CREATE。
- 返回:
true表示bindService成功发出(真正连上会通过ConnectionState.Connected/ 监听器通知);false表示bindService返回 false —— 常见原因是宿主未安装、服务不可见或缺少ACCESS权限。 - 绑定时
Intent的 action 必须等于com.spark.noticeflow.sdk.BIND,否则宿主onBind会拒绝并返回空 Binder。 - 切换宿主(传入不同
hostPackage)会先解绑旧连接。
unbind()
解绑并释放连接,停止心跳与重连。重复调用安全。调用后连接状态变为 Disconnected(DisconnectReason.MANUAL, "unbind()")。
reconnect()
手动触发重连(已连接时忽略)。内部走与自动重连相同的指数退避流程。
bindOptions: SdkBindOptions
SdkBindOptions 控制连接行为:
| 字段 | 类型 | 默认值 | 含义 |
|---|---|---|---|
autoReconnect |
Boolean |
true |
服务断开后是否自动重连(指数退避,1s 起步、上限 30s) |
heartbeatIntervalSec |
Int |
SdkConstants.DEFAULT_HEARTBEAT_INTERVAL_SEC(30) |
心跳间隔(秒) |
rateLimitCapacity |
Int |
SdkConstants.DEFAULT_RATE_LIMIT_CAPACITY(30) |
本地令牌桶容量 |
rateLimitPerMinute |
Int |
SdkConstants.DEFAULT_RATE_LIMIT_PER_MINUTE(60) |
本地令牌桶补充速率 |
maxRetries |
Int |
SdkConstants.DEFAULT_MAX_RETRIES(2) |
幂等操作最大重试次数 |
说明:当前实现中,
NoticeFlowClient内部的限流器与重试策略使用 SDK 默认参数构造;心跳间隔请使用startHeartbeat(intervalSec)显式指定。SdkBindOptions的默认值与实际默认行为一致,显式传入即可表达意图。
3.2 心跳与状态观察#
startHeartbeat(intervalSec: Int = SdkConstants.DEFAULT_HEARTBEAT_INTERVAL_SEC)
启动周期心跳:每 intervalSec 秒调用一次 isAlive(SdkConstants.HEARTBEAT_TIMEOUT_MS)(3s 超时)探测连接;失败则进入断线处理并触发自动重连。intervalSec <= 0 等价于 stopHeartbeat();重复调用会先停止旧心跳。未连接时心跳不主动探测(交由重连循环处理,避免竞争)。
stopHeartbeat()
停止心跳。
connectionState: StateFlow<ConnectionState>
可观察的连接状态流;也可用 isConnected: Boolean 直接判断当前是否已连接。
ConnectionState 取值:
| 状态 | 含义 |
|---|---|
Idle |
从未绑定过宿主 |
Binding |
已发出 bindService,等待 onServiceConnected |
Connected(hostPackage, version) |
已连接(version 首次连接时可能为 null) |
Disconnected(reason, message) |
已断开,reason 见 DisconnectReason |
Reconnecting(attempt, nextDelayMs) |
自动重连中 |
DisconnectReason 取值:MANUAL(主动解绑)、SERVICE_DISCONNECTED(宿主被回收/崩溃)、BIND_FAILED(bindService 返回 false)、REMOTE_ERROR(IPC 抛 RemoteException)、TIMEOUT(bind 等待或请求超时)、HOST_NOT_INSTALLED(宿主包名在设备上不存在)。
addConnectionListener(listener: ConnectionStateListener): () -> Unit / removeConnectionListener(listener)
注册/移除连接状态回调 onConnectionStateChanged(state: ConnectionState)。add... 返回移除函数(也可用 remove...)。
addHostChangeListener(listener: HostChangeListener): () -> Unit / removeHostChangeListener(listener)
注册/移除宿主版本变化回调 onHostChanged(hostPackage: String, version: String?)。
3.3 业务方法#
所有业务方法的 timeoutMs 默认值为 SdkConstants.DEFAULT_TIMEOUT_MS = 5000L(isAlive 默认 3000L)。超时映射为 SdkException(CODE_ERROR_SERVICE_UNAVAILABLE)。
suspend fun ping(timeoutMs: Long = SdkConstants.DEFAULT_TIMEOUT_MS): SdkPingResult
连通性 + 授权自检。宿主返回 success = (授权通过)、version = SdkConstants.API_VERSION.toString()、message(成功为 "实况通知 SDK is ready",失败为错误文案)。
- 未授权或总开关关闭时不抛异常,返回
success = false,请检查message。 - 抛出:
CODE_ERROR_SERVICE_UNAVAILABLE(bind 失败/超时/RemoteException)、CODE_ERROR_UNSUPPORTED(Parcel 协议不匹配)。
suspend fun getCapabilities(timeoutMs: Long = SdkConstants.DEFAULT_TIMEOUT_MS): List<SdkCapability>
宿主支持的能力列表。未授权或总开关关闭时返回空列表(用于能力探测时不会报错)。
- 抛出:
CODE_ERROR_UNSUPPORTED(宿主返回空对象,通常是协议不匹配或宿主版本过旧)。
suspend fun getHostInfo(timeoutMs: Long = SdkConstants.DEFAULT_TIMEOUT_MS): SdkHostInfo
宿主版本、开关、白名单、上岛模式、限流状态。即使未授权也会返回对象(此时 whitelisted = false),是排查授权问题的首选接口。
- 抛出:
CODE_ERROR_UNSUPPORTED。
suspend fun postCustom(request: SdkPostRequest, timeoutMs: Long = SdkConstants.DEFAULT_TIMEOUT_MS): SdkPostResult
推送一条自定义实况通知(新建会话)。
- 本地会先执行
SdkValidator.sanitizeRequest(request):非法sessionId/URI 抛SdkValidator.SdkValidationException,超长文本按上限裁剪。 - 本地令牌桶无令牌时直接返回
SdkPostResult(CODE_ERROR_RATE_LIMITED, null, "Local rate limit exceeded"),不走 IPC。 - 不自动重试(重复提交会产生重复会话),请由业务侧决定是否重发。
- 抛出:
CODE_ERROR_SERVICE_UNAVAILABLE、CODE_ERROR_UNSUPPORTED。 - 结果:
code == CODE_OK时sessionId为宿主完整会话 ID(sdk:<包名>:<你的sessionId>)。 - 注意:由于本地限流是「先记快照、后判令牌」的顺序,本地限流命中时该
sessionId会以PENDING状态留在client.sessions快照里(但并未真正发给宿主),用sessions.getActive()统计活跃会话时需要自行排除这种情况;以SdkPostResult.code为准判断是否真的提交成功。
suspend fun update(request: SdkPostRequest, timeoutMs: Long = SdkConstants.DEFAULT_TIMEOUT_MS): SdkPostResult
更新已有会话内容。request 类型与 postCustom 相同,用 sessionId 关联。幂等,内部按 SdkRetryPolicy 自动重试(指数退避 + 抖动)。
- 本地限流命中同样直接返回
CODE_ERROR_RATE_LIMITED。 - 抛出:
CODE_ERROR_SERVICE_UNAVAILABLE、CODE_ERROR_UNSUPPORTED。 - 注意:宿主只对「已存在的会话」执行更新;若会话已被撤下(超时/用户关闭/
dismiss),宿主可能返回CODE_ERROR_INVALID_CONTENT,此时应重新postCustom。
suspend fun dismiss(sessionId: String, clientTag: String? = null, timeoutMs: Long = SdkConstants.DEFAULT_TIMEOUT_MS): SdkPostResult
关闭会话。幂等:宿主对「会话不存在」的情况也返回 CODE_OK(message 为 "Dismissed (idempotent)")。
- 本地先校验
sessionId(非法则抛SdkValidator.SdkValidationException)。 - 自动重试(同
update)。 - 抛出:
CODE_ERROR_SERVICE_UNAVAILABLE、CODE_ERROR_UNSUPPORTED。
suspend fun dismissAll(timeoutMs: Long = SdkConstants.DEFAULT_TIMEOUT_MS): SdkDismissAllResult
批量关闭本应用在宿主上的全部会话。幂等,自动重试;本地令牌桶按 1 个令牌计费,宿主侧按 2 个令牌计费。
- 结果:
dismissedCount为宿主实际移除的会话数(取自SdkSessionRegistry)。 - 抛出:
CODE_ERROR_SERVICE_UNAVAILABLE、CODE_ERROR_UNSUPPORTED。
suspend fun getActiveSessions(timeoutMs: Long = SdkConstants.DEFAULT_TIMEOUT_MS): List<SdkSessionInfo>
查询本应用在宿主上的活跃会话。未授权时返回空列表(宿主直接写 count = 0)。自动重试。
- 抛出:
CODE_ERROR_UNSUPPORTED。
suspend fun getStats(timeoutMs: Long = SdkConstants.DEFAULT_TIMEOUT_MS): SdkStats
调用统计(活跃会话数、累计调用次数、最近调用时间、限流状态、配额上限)。用于限流/配额自诊断。不自动重试。
- 抛出:
CODE_ERROR_UNSUPPORTED。
suspend fun isAlive(timeoutMs: Long = 3_000): Boolean
宿主服务是否存活。宿主侧同样要过白名单:未授权时返回 false(避免任意应用探测宿主运行状态)。不自动重试。
3.4 本地会话快照#
client.sessions: SdkSessionManager 维护本地会话视图,不跨进程,无需查询宿主即可知道「我发过哪些会话」:
| 方法 | 说明 |
|---|---|
get(sessionId): SdkSessionStatus? |
查询单个会话快照 |
getAll(): List<SdkSessionStatus> |
全部快照(按创建顺序) |
getActive(): List<SdkSessionStatus> |
活跃会话(PENDING / POSTED / UPDATED) |
activeCount(): Int |
活跃会话数 |
addListener(listener): () -> Unit |
注册会话事件监听,返回移除函数 |
dismissAllLocal() |
只改本地快照,不调宿主(真正撤岛请调用 dismissAll()) |
pruneStale(maxAgeMillis: Long): Int |
移除超过指定时长未更新的本地记录 |
clear() |
清空本地记录 |
SessionState 流转:PENDING(已提交,未收到回复)→ POSTED(宿主已接受)→ UPDATED(已更新)→ DISMISSED(已关闭)/ FAILED(失败,lastErrorCode 记录错误码)。
3.5 数据模型字段#
SdkPostRequest(postCustom / update 共用)
| 字段 | 类型 | 默认 | 取值范围 / 上限 | 含义 |
|---|---|---|---|---|
sessionId |
String |
无(必填) | 非空,≤ MAX_SESSION_ID_LENGTH(64),字符集 ^[A-Za-z0-9_.:-]+$ |
会话唯一标识;update/dismiss 复用同一值 |
title |
String |
"" |
≤ MAX_TITLE_LENGTH(64),自动 trim |
标题;空白时宿主使用「自定义消息」 |
text |
String |
"" |
≤ MAX_TEXT_LENGTH(256),自动 trim |
正文 |
capsule |
String? |
null |
≤ MAX_TEXT_LENGTH(256);空白转 null |
胶囊文字(显示在标题旁) |
fullText |
String? |
null |
≤ MAX_FULL_TEXT_LENGTH(2048);空白转 null |
展开态完整文本;为空回退 text |
iconUri |
String? |
null |
≤ MAX_URI_LENGTH(2048),scheme 白名单 |
胶囊小图标;读取失败回退宿主默认图标 |
appIconUri |
String? |
null |
同上 | 卡片大图标;未设置时回退 iconUri |
clickIntentUri |
String? |
null |
同上 | 点击通知跳转 URI(Intent.toUri(Intent.URI_INTENT_SCHEME)) |
freeformIntentUri |
String? |
null |
同上 | 自由形式跳转 URI(与 clickIntentUri 并存时优先自由形式) |
timeoutSec |
Int |
0 |
0 ~ MAX_TIMEOUT_SEC(86400),超界裁剪 |
超时自动撤岛秒数;0 = 不超时 |
progress |
Int? |
null |
裁剪到 [0, progressMax] |
进度当前值;必须与 progressMax 同时设置才显示 |
progressMax |
Int? |
null |
裁剪到 [1, MAX_PROGRESS_MAX](10000) |
进度最大值 |
showDismissAction |
Boolean |
true |
— | 是否显示「关闭」按钮;最终是否显示还受宿主「SDK 关闭按钮」偏好否决 |
timerStartMillis |
Long? |
null |
— | 岛上系统计时器起点时间戳;null 不显示计时 |
extraActionTitle |
String? |
null |
— | 第一个额外操作按钮标题(如「换位」) |
extraActionIntentUri |
String? |
null |
URI 校验同 iconUri |
第一个额外操作按钮跳转 URI |
secondaryActionTitle |
String? |
null |
— | 第二个额外操作按钮标题(如「换源」) |
secondaryActionIntentUri |
String? |
null |
URI 校验同 iconUri |
第二个额外操作按钮跳转 URI |
notificationRoute |
String? |
null |
— | 通知路由标识(供宿主去重/分类,如 "NORMAL" / "PROGRESS") |
isMediaNotification |
Boolean |
false |
— | 是否为媒体类内容 |
marqueeEnabled |
Boolean |
false |
— | 是否允许跑马灯滚动 |
marqueeThreshold |
Int |
0 |
裁剪到 [0, MAX_FULL_TEXT_LENGTH](2048) |
从多少字开始滚动 |
apiVersion |
Int |
SdkConstants.API_VERSION(1) |
<= 宿主支持版本 |
调用方 API 版本;客户端会自动填当前版本,<= 0 也按当前版本填充 |
clientTag |
String? |
null |
— | 调用方自定义标签;为空时客户端自动填调用方包名 |
URI scheme 白名单(SdkValidator.SUPPORTED_SCHEMES):content、file、android.resource、intent、http、https。非法 scheme 直接抛 SdkValidator.SdkValidationException。
图标与跳转 URI 建议使用
content://(宿主会尝试申请持久化读权限)或file://。宿主读取失败时静默回退默认图标 / 岛不可点击,不会报错。
SdkPostResult
| 字段 | 类型 | 说明 |
|---|---|---|
code |
Int |
结果码,见第 8 节 |
sessionId |
String? |
成功的业务操作返回宿主完整会话 ID(sdk:<包名>:<你的sessionId>);失败时多为 null |
message |
String? |
描述信息(成功如 "Accepted" / "Dismissed") |
SdkPingResult
| 字段 | 类型 | 说明 |
|---|---|---|
success |
Boolean |
连通且授权通过 |
version |
String |
宿主 API 版本字符串 |
message |
String |
附加信息 |
SdkCapability
| 字段 | 类型 | 说明 |
|---|---|---|
name |
String |
能力名,见 4.1 |
supported |
Boolean |
宿主是否支持 |
SdkSessionInfo(getActiveSessions() 返回项)
| 字段 | 类型 | 说明 |
|---|---|---|
sessionId |
String |
调用方原始 sessionId(不含宿主命名空间前缀) |
hostSessionId |
String |
宿主完整会话 ID(含 sdk:<包名>: 前缀) |
title |
String |
最近一次推送的标题 |
createdAtMillis |
Long |
宿主收到 postCustom 的时刻 |
updatedAtMillis |
Long |
宿主收到 update 的时刻 |
autoDismiss |
Boolean |
是否设置了自动超时(timeoutSec > 0) |
SdkDismissAllResult
| 字段 | 类型 | 说明 |
|---|---|---|
code |
Int |
CODE_OK 表示请求被接受 |
dismissedCount |
Int |
实际关闭的会话数 |
message |
String? |
描述信息 |
SdkStats
| 字段 | 类型 | 说明 |
|---|---|---|
activeSessions |
Int |
本包名当前活跃会话数 |
totalCalls |
Long |
本包名历史调用总次数(宿主进程内计数) |
lastCallMillis |
Long |
最近一次调用时间戳(0 = 从未调用) |
rateLimited |
Boolean |
本包名是否被宿主限流拒绝过 |
maxActiveSessions |
Int |
宿主每包名活跃会话上限 |
rateLimitPerMinute |
Int |
宿主限流速率(每分钟) |
SdkSessionStatus(本地快照)
| 字段 | 类型 | 说明 |
|---|---|---|
sessionId |
String |
本地会话 ID |
state |
SessionState |
见 3.4 |
title / text |
String |
最近一次提交的内容 |
progress / progressMax |
Int? |
最近一次提交的进度 |
createdAtMillis / lastUpdatedMillis |
Long |
本地时间戳 |
lastErrorCode / lastErrorMessage |
Int? / String? |
最近一次失败的错误码与描述 |
4能力与宿主信息#
4.1 getCapabilities()#
返回 List<SdkCapability>。当前宿主(API_VERSION = 1)固定上报以下 7 项,全部 supported = true:
| 能力名 | 对应方法 |
|---|---|
SdkConstants.CAP_POST_CUSTOM("postCustom") |
postCustom() |
SdkConstants.CAP_UPDATE("update") |
update() |
SdkConstants.CAP_DISMISS("dismiss") |
dismiss() |
SdkConstants.CAP_IS_ALIVE("isAlive") |
isAlive() |
SdkConstants.CAP_DISMISS_ALL("dismissAll") |
dismissAll() |
SdkConstants.CAP_GET_ACTIVE_SESSIONS("getActiveSessions") |
getActiveSessions() |
SdkConstants.CAP_GET_HOST_INFO("getHostInfo") |
getHostInfo() |
getStats()没有独立能力名(能力名常量集合中不包含它),请以API_VERSION与getHostInfo()结果判断可用性。 未授权或总开关关闭时返回空列表,不要把它当作「宿主不支持」的唯一信号,需结合getHostInfo().whitelisted与getHostInfo().sdkEnabled判断。
4.2 getHostInfo()#
返回 SdkHostInfo:
| 字段 | 类型 | 说明 |
|---|---|---|
hostPackage |
String |
宿主包名(如实返回宿主 packageName) |
apiVersion |
Int |
宿主支持的最大 API 版本(当前 1) |
hostVersionName |
String |
宿主应用版本名(如 "3.1.0") |
hostVersionCode |
Long |
宿主应用版本号 |
sdkEnabled |
Boolean |
「对外 SDK 总开关」是否开启 |
whitelisted |
Boolean |
本包名是否在宿主白名单内 |
islandMode |
String |
宿主上岛模式:"hyper"(用户已选澎湃超级岛模式)或 "live_update"(实况通知) |
rateLimited |
Boolean |
本包名最近是否被宿主限流拒绝过 |
用法示例:
// 推荐在真正发起业务调用前先做一次「宿主是否可用」判断
suspend fun ensureReady(client: NoticeFlowClient, myPackage: String): Boolean {
val info = client.getHostInfo()
return when {
!info.sdkEnabled -> {
Log.w(TAG, "宿主未开启「对外 SDK」总开关")
false
}
!info.whitelisted -> {
Log.w(TAG, "本应用未授权:请在实况通知功能页 → IslandSDK 接入卡片中打开 $myPackage 的开关")
false
}
info.apiVersion < SdkConstants.API_VERSION -> {
Log.w(TAG, "宿主版本较旧(apiVersion=${info.apiVersion}),请提示用户升级")
false
}
else -> {
Log.i(
TAG,
"host=${info.hostPackage} v=${info.hostVersionName}(${info.hostVersionCode}) " +
"island=${info.islandMode} rateLimited=${info.rateLimited}",
)
true
}
}
}
关于 islandMode 的准确语义:它反映宿主当前的引擎偏好(hyper / live_update),不是「内容一定落在超级岛」的承诺——宿主在澎湃模式下若遇到机型不支持超级岛、Shizuku 未授权或缺少焦点通知权限,会自动回落实况通知。接入方不需要也不应该据此改变行为。
4.3 getStats()#
val stats = client.getStats()
Log.i(TAG, "active=${stats.activeSessions}/${stats.maxActiveSessions} " +
"calls=${stats.totalCalls} rateLimited=${stats.rateLimited} rate=${stats.rateLimitPerMinute}/min")
用于接入方自诊断:activeSessions 接近 maxActiveSessions 时应主动撤岛或复用会话;rateLimited = true 时应放慢提交频率。
5安全模型#
5.1 授权流程#
- 权限:宿主声明
com.spark.noticeflow.sdk.permission.ACCESS(protectionLevel = normal),并把它挂在SdkBridgeService上。接入方依赖 SDK 后自动获得该权限声明(见 2.2)。 - 总开关:用户在 实况通知 功能页 → 「IslandSDK 接入」卡片打开总开关(「通过 IslandSDK 便捷调用超级岛 / 实况通知」)。默认关闭;关闭时所有外部调用返回
CODE_ERROR_SDK_DISABLED(6)。 - 单应用开关:接入方第一次调用后出现在同一张卡的「请求的应用」列表里,用户打开该应用右侧开关即完成授权。默认白名单为空(仅宿主自身放行);未打开开关的调用返回
CODE_ERROR_UNAUTHORIZED(2)。
5.2 防伪校验#
宿主不使用请求里自称的包名做鉴权,而是按 Binder 调用方真实 UID 解析:
Binder.getCallingUid()
→ PackageManager.getPackagesForUid(uid) // 该 UID 的真实包名集合
→ 校验自称包名 ∈ 真实包名集合 // 不一致 → CODE_ERROR_UNAUTHORIZED
→ SdkWhitelistManager.isAllowed(生效包名) // 不在白名单 → CODE_ERROR_UNAUTHORIZED
因此伪造 clientPackage 无法绕过授权。宿主自身进程(uid == Process.myUid())直接放行,用于 debug 自检。
5.3 接入方自查顺序#
遇到失败时按这个顺序排查,可以用一条调用同时拿到全部线索:
val alive = client.isAlive() // 服务存活 + 白名单是否放行(未授权也返回 false)
val ping = client.ping() // success/message 明确区分「未授权」「总开关关闭」
val info = client.getHostInfo() // sdkEnabled / whitelisted / apiVersion / islandMode
6限流与配额#
SDK 侧存在两级独立限流与一级会话配额,任何一级命中都会得到 CODE_ERROR_RATE_LIMITED(7)或 CODE_ERROR_QUOTA_EXCEEDED(8)。
6.1 客户端本地令牌桶(SdkRateLimiter)#
在发起 IPC 之前本地拦截,避免瞬间打爆宿主。
| 项 | 默认值(常量) |
|---|---|
| 桶容量(突发上限) | SdkConstants.DEFAULT_RATE_LIMIT_CAPACITY = 30 |
| 补充速率 | SdkConstants.DEFAULT_RATE_LIMIT_PER_MINUTE = 60 次/分钟 |
- 算法:按经过时间线性补令牌,上限为桶容量;
tryAcquire()有令牌则消耗 1 个并放行,否则本地直接返回SdkPostResult(CODE_ERROR_RATE_LIMITED, null, "Local rate limit exceeded"),不产生 IPC。 - 每个令牌计费:
postCustom/update/dismiss各 1 个;dismissAll也按 1 个计。 - 容量与速率都为 0 时限流自动禁用(
SdkRateLimiter.enabled == false)。
6.2 宿主令牌桶(SdkRateLimitPolicy)#
按调用方包名独立分桶,与本地桶互不替代。
| 项 | 默认值(常量) | 说明 |
|---|---|---|
| 桶容量(突发上限) | SdkConstants.HOST_DEFAULT_RATE_LIMIT_CAPACITY = 20 |
固定 |
| 补充速率 | SdkConstants.HOST_DEFAULT_RATE_LIMIT_PER_MINUTE = 60 次/分钟 |
固定(宿主内置默认,无 UI 可调) |
| 开关 | 默认开启(isRateLimitEnabled 默认 true) |
固定 |
dismissAll 计费 |
2 个令牌 | — |
宿主在 postCustom / update / dismiss / dismissAll 路径上检查;命中返回 CODE_ERROR_RATE_LIMITED,message 为 "Rate limited by host, retry later"。
6.3 每应用活跃会话配额#
| 项 | 默认值(常量) | 说明 |
|---|---|---|
| 每应用活跃会话上限 | SdkConstants.HOST_DEFAULT_MAX_ACTIVE_SESSIONS = 20 |
固定(宿主内置默认,无 UI 可调) |
- 仅在新建会话(
postCustom)时检查;达到上限返回CODE_ERROR_QUOTA_EXCEEDED(8),message形如"Active session quota exceeded (max 20)"。 update不受配额限制,因此优先用update复用会话,而不是反复postCustom。- 宿主注册表还会做兜底清理:超过
SdkConstants.HOST_SESSION_STALE_MILLIS(10 分钟)未更新的条目在查询/统计时被懒清理,避免系统超时撤岛或应用卸载后残留占配额。
6.4 内容长度上限#
| 项 | 常量 | 值 | 超限行为 |
|---|---|---|---|
sessionId 长度 |
MAX_SESSION_ID_LENGTH |
64 | 客户端抛 SdkValidationException;宿主返回 CODE_ERROR_INVALID_CONTENT |
sessionId 字符集 |
SESSION_ID_PATTERN |
^[A-Za-z0-9_.:-]+$ |
客户端抛 SdkValidationException |
| 标题 | MAX_TITLE_LENGTH |
64 | 客户端裁剪 |
| 正文 / 胶囊 | MAX_TEXT_LENGTH |
256 | 客户端裁剪 |
| 展开态全文 | MAX_FULL_TEXT_LENGTH |
2048 | 客户端裁剪 |
| 进度最大值 | MAX_PROGRESS_MAX |
10000 | 裁剪到 [1, 10000],progress 同步裁剪到 [0, max] |
| URI 长度 | MAX_URI_LENGTH |
2048 | 超长抛 SdkValidationException |
| 超时秒数 | MAX_TIMEOUT_SEC |
86400 | 裁剪到 [0, 86400] |
| 滚动阈值 | MAX_FULL_TEXT_LENGTH |
2048 | 裁剪到 [0, 2048] |
宿主还有一层兜底校验:若内容(标题/正文/全文)超过上限,返回 CODE_ERROR_CONTENT_TOO_LARGE(9)。正常通过 SDK 提交时不会触发,除非绕开 SDK 自行拼装 Parcel。
7生命周期与会话语义#
7.1 sessionId 规则#
- 由接入方生成,必填、非空、≤ 64 字符、只允许
A-Z a-z 0-9 _ - . :。 - 同一个
sessionId贯穿一个会话的全生命周期:postCustom(PUBLISH)→update(UPDATE,可多次)→dismiss(DISMISS)。 - 宿主完整会话 ID 为
sdk:<调用方包名>:<你的sessionId>,会通过SdkPostResult.sessionId与SdkSessionInfo.hostSessionId返回,便于日志对照。 - 同
sessionId再次postCustom视为新建/重发,需注意内容去重逻辑。
7.2 三种操作语义#
| 操作 | 方法 | 幂等 | 本地自动重试 | 说明 |
|---|---|---|---|---|
| PUBLISH | postCustom() |
否 | 否 | 新建会话;重复提交会产生重复会话,故不自动重试 |
| UPDATE | update() |
是 | 是(默认最多 2 次) | 更新已存在会话内容 |
| DISMISS | dismiss() / dismissAll() |
是 | 是(默认最多 2 次) | 会话不存在也返回 CODE_OK |
重试策略(SdkRetryPolicy):
- 仅对可重试错误码重试:
CODE_ERROR_SERVICE_UNAVAILABLE(4,含请求超时映射)与CODE_ERROR_RATE_LIMITED(7)。 - 退避公式:第
n次重试前等待baseDelayMs * 2^n(baseDelayMs = 500ms),附 ±20% 抖动,封顶SdkConstants.RECONNECT_MAX_DELAY_MS(30s)。 - 默认最大重试次数
SdkConstants.DEFAULT_MAX_RETRIES = 2;超过次数后把最后一次的SdkException抛给调用方。
7.3 超时自动撤岛(timeoutSec)#
timeoutSec > 0时宿主为该会话设置自动关闭定时(autoDismissMillis = timeoutSec * 1000),到期由系统/宿主撤下该岛,无需接入方再调用dismiss()。timeoutSec = 0表示不超时,岛会一直保留到显式dismiss()/dismissAll()、用户手动关闭,或宿主兜底清理。- 建议:始终设置一个合理的
timeoutSec(例如导航 1800、下载按预计时长 + 余量),避免异常退出后残留常驻岛。 - 可通过
getActiveSessions()的autoDismiss字段确认某会话是否带了超时。
7.4 断开、宿主被杀与重连建议#
- 宿主进程被杀或崩溃时,
ServiceConnection.onServiceDisconnected触发ConnectionState.Disconnected(SERVICE_DISCONNECTED);autoReconnect = true(默认)时 SDK 自动指数退避重连:初始RECONNECT_BASE_DELAY_MS= 1s,退避1s * 2^attempt,上限RECONNECT_MAX_DELAY_MS= 30s;RECONNECT_MAX_ATTEMPTS = -1表示无限重试直到成功或调用unbind()。 - 心跳(
startHeartbeat())在连接存在时周期探测isAlive(),失败即进入上述断线流程,能发现「Binder 还在但宿主已无响应」的情况。 - 重连成功后,宿主侧的会话注册表会随宿主进程重启而清空,本地
SdkSessionManager快照仍在。建议在ConnectionState.Connected回调里做一次对账:
client.addConnectionListener { state ->
if (state is ConnectionState.Connected) {
scope.launch {
val local = client.sessions.getActive().map { it.sessionId }.toSet()
val remote = client.getActiveSessions().map { it.sessionId }.toSet()
// 本地认为在岛、宿主已丢失 → 重新 postCustom 补回
(local - remote).forEach { id ->
client.sessions.get(id)?.let { snap ->
client.postCustom(SdkPostRequest(sessionId = id, title = snap.title, text = snap.text))
}
}
}
}
}
- 反向建议:宿主进程重启后不要盲目重发全部历史会话,只补回业务上仍然「进行中」的会话,避免占用配额与限流额度。
7.5 并发与线程#
- 所有 IPC 在内部
Mutex下串行执行,并切到Dispatchers.IO;业务侧可在任意协程调度器上调用。 timeoutMs覆盖「等待连接 / 等待锁 / 解析回复」;Binder.transact本身是阻塞调用、不可被取消,因此宿主侧对每个事务都做了异常隔离,避免单次调用拖垮连接。
8错误码表#
错误码定义于 SdkConstants.CODE_*。业务方法通过 SdkPostResult.code 返回,或通过 SdkException.code 抛出。
| 常量 | 值 | 含义 | 排查建议 |
|---|---|---|---|
CODE_OK |
0 | 成功 | — |
CODE_ERROR_UNSUPPORTED |
1 | API 版本不兼容:请求 apiVersion 高于宿主支持版本,或 Parcel 协议不匹配 |
检查 SDK 与宿主版本是否配套;getHostInfo().apiVersion 与 SdkConstants.API_VERSION 对比;宿主版本过低时降级或提示用户升级宿主 |
CODE_ERROR_UNAUTHORIZED |
2 | 未授权:调用方包名不在宿主白名单,或自称包名与真实 UID 包名不一致 | 在实况通知功能页 → 「IslandSDK 接入」卡片中打开当前应用真实包名的开关;确认没有多包名/多进程误用;getHostInfo().whitelisted 应为 true;宿主日志 SdkBridgeService 会打印 authorize denied: ... not whitelisted |
CODE_ERROR_INVALID_CONTENT |
3 | 内容无效:sessionId 为空或超长、发布管线拒绝(空内容、会话已被用户关闭等) |
校验 sessionId;至少给 title/text/capsule/图标之一;若 update 报此码,说明会话已不存在,改用 postCustom |
CODE_ERROR_SERVICE_UNAVAILABLE |
4 | 服务不可用:bindService 返回 false、bind 等待超时、IPC 抛 RemoteException、请求超时、宿主内部异常 |
确认宿主已安装且版本匹配;重试或等待自动重连;isAlive() 辅助判断 |
CODE_ERROR_SESSION_NOT_FOUND |
5 | 会话不存在(保留码) | 当前 dismiss 对不存在的会话按幂等返回 CODE_OK,不会返回本码;如收到请视为普通失败并重新 postCustom |
CODE_ERROR_SDK_DISABLED |
6 | 宿主「IslandSDK 接入」总开关已关闭 | 引导用户在实况通知功能页 → 「IslandSDK 接入」卡片打开总开关(getHostInfo().sdkEnabled 为 false) |
CODE_ERROR_RATE_LIMITED |
7 | 触发限流:客户端本地令牌桶或宿主按包名令牌桶拒绝 | 降低提交频率(默认本地 60/min、宿主 60/min,突发 30/20);批量更新改为合并内容;getStats().rateLimited 确认是否被宿主限流 |
CODE_ERROR_QUOTA_EXCEEDED |
8 | 活跃会话数超过宿主上限(默认 20,仅 postCustom 检查) |
先 dismiss 不再需要的会话,或改用 update 复用已有会话;用 getActiveSessions() / getStats().activeSessions 自查 |
CODE_ERROR_CONTENT_TOO_LARGE |
9 | 内容超过宿主长度上限 | 标题 ≤ 64、正文/胶囊 ≤ 256、全文 ≤ 2048 字符(通过 SDK 提交会被自动裁剪,此码多见于绕开 SDK 自行拼装 Parcel) |
9常见问题(FAQ)#
Q1. bind() 返回 false / 连接状态一直是 Disconnected(BIND_FAILED)#
- 宿主未安装,或安装的不是
SdkConstants.HOST_PACKAGE_STANDARD(com.spark.noticeflow)这个包名的版本。 - 接入方缺少
com.spark.noticeflow.sdk.permission.ACCESS权限(确认 SDK 依赖被正确合并,或手动声明)。 - 宿主侧服务未导出,或以不同 action 导出(宿主实现要求 action 严格等于
com.spark.noticeflow.sdk.BIND)。 - 建议:
isAlive()预检,并在 UI 上给出「请安装/更新实况通知」的提示。
Q2. ping().success == false,message 说未授权#
- 宿主是否已开启「对外 SDK 总开关」(否则错误码是
CODE_ERROR_SDK_DISABLED,message为"SDK is disabled")。 - 白名单是否包含当前应用的真实包名(注意 applicationId 与 flavor 后缀,例如
com.example.app.debug)。 - 在 UI 上提示用户到 实况通知 功能页 → 「IslandSDK 接入」卡片的「请求的应用」列表里打开本应用开关(调用一次 SDK 后即可出现)。
- 用
getHostInfo().whitelisted做二次确认。
Q3. 返回 CODE_ERROR_RATE_LIMITED#
- 先区分是本地还是宿主限流:
message为"Local rate limit exceeded"说明是客户端本地令牌桶;"Rate limited by host, retry later"说明是宿主限流。 - 本地默认 30 突发 / 60 每分钟;宿主默认 20 突发 / 60 每分钟(
dismissAll计 2 个令牌)。 - 优化方向:合并高频更新(如进度从 100 次改为 10 次)、用
update替代重复postCustom、dismissAll替代循环dismiss。
Q4. 调用成功(CODE_OK)但用户看不到岛#
按以下顺序排查,这些都是宿主侧的用户设置/系统能力问题,接入方无法绕过:
- 通知使用权:宿主需要通知权限才能显示实况通知(Android 13+ 的
POST_NOTIFICATIONS、Android 16 的推广通知POST_PROMOTED_NOTIFICATIONS)。用户关闭后宿主无法上岛。 - 焦点通知权限(澎湃/小米):超级岛依赖焦点通知;缺失时宿主会自动回落到实况通知,而不是报错。
- 超级岛机型限制:仅部分小米/澎湃机型支持超级岛;不支持时宿主回落实况通知。
- Shizuku 授权:澎湃模式下需要 Shizuku 授权,未授权则回落。
- 内容为空:
title、text、capsule、图标全部为空时发布管线会拒绝(返回CODE_ERROR_INVALID_CONTENT)。 - 会话被用户关闭:用户点过岛上的「关闭」后,该会话不再重新上岛(内容真正变化时按新消息处理)。
- 锁屏隐藏:宿主若开启了「锁屏隐藏全部实况通知」,锁屏期间不会发布。
Q5. 内容被截断或文案与预期不符#
title超过 64 字符、text/capsule超过 256 字符、fullText超过 2048 字符会被按上限裁剪(客户端先裁剪,宿主再兜底)。title为空白时宿主显示「自定义消息」。progress与progressMax必须同时提供才显示进度条;progress会被裁剪到[0, progressMax],progressMax裁剪到[1, 10000]。
Q6. 宿主被系统杀掉后怎么办?#
- 若
SdkBindOptions.autoReconnect = true(默认),SDK 会自动无限重连(退避 1s → 30s 封顶)。监听ConnectionState:Reconnecting时提示用户「正在重连」,Connected后按 7.4 的对账逻辑补发仍然有效的会话。 - 宿主进程重启会清空宿主侧会话注册表,
getActiveSessions()此时会返回空列表,属预期行为。 - 若用户主动
unbind(),重连会被刻意停止(DisconnectReason.MANUAL);需要恢复时重新bind()。
Q7. unbind() 之后还能调用业务方法吗?#
可以,但不建议依赖这一行为:业务方法在未连接时会尝试自动 bind()。若希望彻底停止,请在 unbind() 后不要再调用业务方法,也不要在回调里重新绑定。
Q8. 我已经调用了 SDK,但功能页「IslandSDK 接入」列表里没有我的应用?#
按以下顺序排查:
- 宿主版本:请求方登记是宿主 3.7.0+ 的行为;旧宿主只会显示已授权应用。用
getHostInfo().versionName确认。 - 是否真的发起了 Binder 调用:只有
bind()成功并至少完成一次事务(ping()即可)才会被登记。bind()失败说明SdkBridgeService未导出、action 不匹配或包可见性不足,见 Q1。 - 总开关:SDK 总开关关闭时,
authorize()会在读取 UID 前提前返回,此时不会登记请求方;先打开总开关再调用一次。 - 列表容量:列表最多保留最近 50 个包名,长期测试会挤掉旧条目;重新调用一次即可回到列表。
- 包名一致性:请求里自称的包名必须与调用方 UID 的真实包名一致(
SdkBridgeService.authorize会校验),伪造包名不会进入列表。
10版本兼容#
10.1 API_VERSION 语义#
SdkConstants.API_VERSION是当前 SDK 与宿主共同支持的协议版本,当前值为1。- 客户端每次提交都会把
apiVersion = SdkConstants.API_VERSION写入请求(SdkValidator会把<= 0的值规范化为当前版本)。 - 宿主规则:
request.apiVersion > SdkConstants.API_VERSION→ 返回CODE_ERROR_UNSUPPORTED(1)。即向后兼容、向前拒绝:新客户端连旧宿主会被明确拒绝,而不是行为不确定。 ping().version与getHostInfo().apiVersion都表示宿主支持的 API 版本,可用于版本协商。
10.2 宿主版本较旧时的行为#
| 场景 | 表现 |
|---|---|
宿主支持的 apiVersion < SdkConstants.API_VERSION |
postCustom / update 返回 CODE_ERROR_UNSUPPORTED;应先提示用户升级宿主,或按旧版本能力降级 |
| 宿主不认识某个事务码(新增方法) | 回复内容缺失或解析失败,SDK 映射为 SdkException(CODE_ERROR_UNSUPPORTED, "Protocol mismatch on transact ...") |
| 宿主返回空对象(协议错位) | SDK 抛 SdkException(CODE_ERROR_UNSUPPORTED, "Host returned null ... (protocol mismatch or older host)") |
| 未授权/总开关关闭 | getCapabilities() 返回空列表、getActiveSessions() 返回空列表,不报错——需要结合 getHostInfo() 区分「能力缺失」与「未授权」 |
推荐的能力探测写法:
suspend fun negotiate(client: NoticeFlowClient): Set<String> {
val info = client.getHostInfo()
if (!info.sdkEnabled || !info.whitelisted) return emptySet()
if (info.apiVersion < SdkConstants.API_VERSION) {
// 宿主较旧:以能力列表为准做降级
Log.w(TAG, "host apiVersion=${info.apiVersion} < sdk=${SdkConstants.API_VERSION}")
}
return client.getCapabilities().filter { it.supported }.map { it.name }.toSet()
}
10.3 向后兼容约定#
- 协议只增不改:事务码
1 ~ 6为 v1 既有方法(PING / GET_CAPABILITIES / POST_CUSTOM / UPDATE / DISMISS / IS_ALIVE),7 ~ 10为扩充方法(GET_HOST_INFO / DISMISS_ALL / GET_ACTIVE_SESSIONS / GET_STATS)。已有事务码的入参与出参顺序不会变更。 - 数据模型只增字段:
SdkPostRequest、SdkHostInfo、SdkStats等 Parcelable 新增字段时,旧客户端的请求仍可被新宿主接受;旧宿主返回的对象缺少的新字段按默认值处理。 - 错误码只增不改:
CODE_OK~CODE_ERROR_RATE_LIMITED(0 ~ 7)语义固定;新错误码(如 8、9)以新增常量方式加入。 - 能力探测优先:判断「宿主是否支持某功能」时,优先用
getCapabilities()+getHostInfo().apiVersion,不要根据宿主应用版本号硬编码分支。 - 不依赖内部实现类:接入方只应使用
com.spark.noticeflow.sdk包下的公开 API(NoticeFlowClient、SdkConstants、数据模型SdkPostRequest/SdkPostResult/SdkHostInfo/SdkStats等、SdkValidator、SdkException、ConnectionState)。SDK 内的SdkClientOrchestrator及其下属子系统(连接池/熔断/发件箱等)属于宿主自用与高级场景,不构成对外兼容承诺。
上岛目标:LiveUpdate / SuperIsland / Auto(3.6.0)#
同一条通知可以由两种系统能力承载:实况通知(Live Update) 与 澎湃超级岛(Focus V3)。 你不需要自己判断机型与权限——按会话指定目标即可,宿主负责路由。
三种目标#
| 目标常量 | 值 | 行为 |
|---|---|---|
SdkConstants.ISLAND_TARGET_AUTO |
"auto" |
默认:超级岛可用则用超级岛,不可用自动降级为实况通知(澎湃优先、可降级) |
SdkConstants.ISLAND_TARGET_LIVE_UPDATE |
"live_update" |
只用实况通知 |
SdkConstants.ISLAND_TARGET_SUPER_ISLAND |
"super_island" |
只用超级岛;当前不可用时直接失败(CODE_ERROR_UNSUPPORTED),不静默降级 |
“超级岛可用” = 宿主处于澎湃模式 ∧ 机型支持 ∧ Shizuku 已授权 ∧ 已授权焦点通知权限。
可用性可先用 getCapabilities() 查询:islandHyper(当前走超级岛)、islandLiveUpdate(当前走实况通知)、
focusPermission(焦点通知权限是否已授权);能力列表中出现 islandTargetSelection 表示宿主支持按会话指定目标(3.6.0+)。
三种写法#
// 1) 极简:一行发一条岛(推荐)
val r = client.postIsland(
title = "下载中",
text = "update.zip",
progress = 42,
progressMax = 100,
target = SdkConstants.ISLAND_TARGET_AUTO,
)
// r.islandMode 告诉你实际落在哪:"super_island" 或 "live_update"
if (r.code == SdkConstants.CODE_OK) {
Log.i(TAG, "islandMode = " + r.islandMode)
}
// 2) 复用同一个请求对象切换目标
client.postSuperIsland(request) // 不支持时 r.code == CODE_ERROR_UNSUPPORTED
client.postLiveUpdate(request) // 强制实况通知
client.postAuto(request) // 等价于默认行为
// 3) 进度更新与撤岛
client.updateProgress(sessionId = "download-1", progress = 80, progressMax = 100)
client.dismissIsland("download-1")
也可以直接在请求模型上指定:
SdkPostRequest(sessionId = "id", title = "标题", islandTarget = SdkConstants.ISLAND_TARGET_SUPER_ISLAND)。
结果与错误#
SdkPostResult.islandMode:实际承载形态("super_island"/"live_update")。CODE_ERROR_UNSUPPORTED:显式要求super_island但当前不可用(或宿主版本过旧)。此时不要重试同一目标, 可提示用户开启澎湃模式 / 授权 Shizuku / 授权焦点通知权限,或改用auto。CODE_ERROR_INVALID_CONTENT:islandTarget取值非法(仅接受auto/live_update/super_island)。
选择建议#
- 普通业务(下载、外卖、赛事、计时):用
auto,用户在任何机器上都能看到岛。 - 必须超级岛(依赖澎湃大岛/小岛形态、与系统生态联动):用
super_island,失败后按自己的 UI 引导用户补齐条件。 - 已在实况通知上运行的会话:用
live_update保持一致,避免中途换形态让用户看到两条通知。 - 同一
sessionId建议全程使用同一目标;需要切换时先dismissIsland(sessionId)再重新发布。
澎湃超级岛特有字段(Focus V3)适配(3.6.0)#
宿主已完整适配 Focus V3 模板;下表列出每个字段由谁决定。null 一律表示"跟随宿主全局设置"。
| Focus V3 模板字段 | 含义 | 可控性 |
|---|---|---|
business |
通道(通知 / 进度 / 歌词 / 自定义卡片…) | 宿主自动分配(接入方不要伪造,否则岛会被顶替) |
notifyId |
岛位 id | 宿主自动(与通知 id 一致,禁止 cancel + repost 换位) |
sequence |
单调递增序号(落盘、防回退) | 宿主自动 |
aodTitle |
息屏焦点通知文案 | ✅ aodTitle(缺省 = 胶囊第一行 / 来源名) |
ticker |
OS2 状态栏 / 胶囊第二行 | ✅ ticker(缺省 = 胶囊内容,上限 40 字) |
tickerPic / aodPic |
息屏与状态栏图片 | 宿主自动 = iconUri 的图(发送者头像) |
miui.focus.pic_avatar |
大岛头像位 | ✅ iconUri |
miui.focus.pic_app / pic_island / miui.land.pic_island |
小岛 / 应用图标位 | ✅ appIconUri(缺省回退 iconUri) |
islandFirstFloat / enableFloat |
首帖贴合展开 / 更新态悬浮 | ✅ floatEnabled(缺省跟随「超级岛展开」) |
outEffectSrc="glow" |
外发光 | ✅ glowEnabled(缺省跟随「超级岛发光」) |
smallIslandArea.combinePicInfo.progressInfo |
小岛进度环 | ✅ progress / progressMax |
bigIslandArea.imageTextInfoLeft/Right.textInfo |
大岛左右文本 | ✅ title / text / fullText / capsule |
chatInfo.title/content |
消息样式两行 | ✅ title / text |
textButton.addActionInfo |
岛上按钮(最多 3 个) | ✅ extraActionTitle + extraActionIntentUri、secondaryActionTitle + secondaryActionIntentUri、showDismissAction |
sameWidthDigitInfo.timerInfo |
等宽数字计时(如「录制中 00:12」) | ✅ timerStartMillis |
示例:按会话覆盖澎湃旋钮#
client.postIsland(
title = "正在录音",
text = "会议记录.m4a",
target = SdkConstants.ISLAND_TARGET_SUPER_ISLAND,
aodTitle = "录音中", // 息屏文案(模板 aodTitle)
ticker = "会议记录.m4a", // 胶囊第二行(模板 ticker)
floatEnabled = false, // 不展开悬浮
glowEnabled = true, // 外发光
timerStartMillis = System.currentTimeMillis(), // 等宽数字计时
)
注意#
floatEnabled/glowEnabled/aodTitle/ticker仅在超级岛生效;目标为实况通知(或auto降级)时这些字段被忽略。business/notifyId/sequence由宿主维护:澎湃要求通道与序号由应用侧单调递增,接入方自造会导致岛互相顶替或序号回退。- 胶囊内容与
ticker的关系:capsule会写入胶囊内容(默认同时影响ticker);显式传ticker时以ticker为准。
附录:日志与自检#
A.1 SDK 侧日志(接入方进程)#
- SDK 统一日志 tag 为
NoticeFlowSdk(可通过SdkLogger.level调整级别,默认LEVEL_WARN;SdkLogger.isEnabled = false可完全关闭)。缓冲最近 200 条日志,可调用SdkLogger.dumpRingBuffer()取出用于问题反馈。 - 排查期间建议临时提高级别:
SdkLogger.level = SdkLogger.LEVEL_DEBUG,抓完再还原。
adb logcat -s NoticeFlowSdk:V
A.2 宿主侧日志(授权与发岛结果)#
宿主 SdkBridgeService(logcat tag SdkBridgeService)会记录每次调用的关键结论,排查授权/上岛失败时优先看它:
| 日志 | 含义 |
|---|---|
sdk authorize denied: master switch off |
宿主总开关关闭 |
sdk authorize denied: <pkg> not whitelisted (request recorded) |
应用未授权,但已被记录进功能页「请求的应用」列表 |
sdk authorize denied: claimed=... not in uid=... |
自称包名与真实 UID 不符(伪造/多包名) |
signature changed for whitelisted package ... |
同包名不同签名,按安全策略拒绝 |
sdk post ok pkg=... session=... op=... islandMode=... |
发岛/更新成功;islandMode 为实际承载形态(super_island / live_update) |
sdk post rejected pkg=... reason=... |
发布管线拒绝(空内容、会话已关闭等) |
sdk dismiss ok / sdk dismiss idempotent |
撤岛成功 / 会话不存在按幂等成功 |
adb logcat -s SdkBridgeService:I NoticeFlowSdk:V
A.3 宿主端到端自检#
宿主 debug 构建提供端到端自检(宿主内部使用 SDK 客户端走真实 Binder IPC:bind → ping → post → update → dismiss):
adb shell am broadcast -a com.spark.noticeflow.action.DEBUG_SELFTEST -e kind sdk
adb logcat -s NoticeFlow:D
# 成功输出:SDK 全链路 OK:post=0 update=0 dismiss=0(已上岛并撤下)
该自检由宿主自行发起,走宿主自身 UID(直接放行),因此不能用来验证第三方授权是否配置正确;第三方授权请用
ping()+getHostInfo()自查,或看 A.2 的授权日志。
11变更记录#
| 版本 | 变更 |
|---|---|
| 1.1.0 | 新增极简门面 IslandSdk(show/update/hide/wake,点击/按钮可直接打开 Activity),接入代码约 3 行,显著少于原生 Live Update / 超级岛;仅支持「实况通知」宿主(移除第二宿主常量与多宿主连接池);推广与文档统一使用「实况通知」名称。 |
| 1.0.0 | IslandSDK 首个对外发布版本(SDK 与本文档共用版本号)。能力:自定义 Binder 协议、postCustom/update/dismiss/dismissAll/getActiveSessions/getStats、三种上岛目标(live_update / super_island / auto)、自动重连、本地+宿主两级限流、每应用会话配额、统一错误码、日志环形缓冲。宿主侧:功能页「IslandSDK 接入」独立大卡(总开关 + 请求接入应用列表,授权开关就地切换;无独立管理页),应用内提供接入文档入口与分享推广。修复:客户端读取 Binder 回复前补 readException()(此前与宿主 writeNoException() 错位 4 字节,会导致「岛已发出但 SDK 返回失败/空」)。 |
getHostInfo() 返回的版本与宿主代码为准。