实况通知 · IslandSDK 接入文档

把实况通知
接进你的应用

IslandSDK 让第三方应用把自己的实况内容交给实况通知宿主,由宿主落到系统「岛」上。绑定、心跳、重连、限流与幂等重试都在 SDK 内部完成,你只需要调用 postCustom()。

14章节
21代码示例
26数据表
1协议版本
1.1.0SDK 版本
IslandSDK(模块 noticeflow-sdk,包名 com.spark.noticeflow.sdk)1.1.0**(SDK 与本文档共用同一版本号,从 1.0.0 起)noticeflow-sdk 模块(com.spark.noticeflow:noticeflow-sdk)SdkConstants.API_VERSION = 1

本文档面向第三方接入方开发者;宿主侧应用版本号请以 getHostInfo() 返回值或宿主 app/build.gradle.kts 为准。

DemoActivity.kt
// 1) 绑定宿主(进程内单例)
val bound = client.bind()
if (!bound) return
client.startHeartbeat()

// 2) 自检:未授权 / 总开关关闭会返回 false
val ping = client.ping()
if (!ping.success) return

// 3) 上岛:宿主负责路由到超级岛或实况通知
val post = client.postCustom(
    SdkPostRequest(
        sessionId = "order_20261011_001",
        title = "外卖配送",
        text = "骑手已取餐,正在配送",
        capsule = "配送中",
        progress = 0,
        progressMax = 100,
        timeoutSec = 1800,
    )
)

示例:绑定 → 自检 → 上岛(摘自 2.4 完整可编译示例)

1概述与工作原理#

实况通知 IslandSDK 让第三方应用把自定义的实况会话内容交给 实况通知宿主,由宿主落到系统级「岛」上展示(安卓实况通知 Live Update 或超级岛)。

text
┌──────────────────┐   自定义 Binder / Parcel 协议   ┌──────────────────────────────┐
│  第三方 App       │  (非 AIDL,手写 transact)     │  实况通知宿主              │
│                  │ ─────────────────────────────▶ │  .sdk.host.SdkBridgeService  │
│ NoticeFlowClient │                                │   ├─ SdkWhitelistManager     │
│  (SDK 单例)     │ ◀───────────────────────────── │   ├─ SdkRateLimitPolicy      │
│                  │      SdkPostResult 等          │   ├─ SdkSessionRegistry      │
└──────────────────┘                                │   └─ IslandRouter            │
                                                    │        ├─ 超级岛(hyper)     │
                                                    │        └─ 实况通知(live_update)
                                                    └──────────────────────────────┘

关键点:

  1. 通信方式是自定义 Binder 协议,不是 AIDL 生成接口。所有请求的第一个 Parcel 字段是调用方包名;宿主回复统一以 writeNoException() 开头,随后写业务数据。
  2. 上岛由宿主引擎路由决定,接入方无需感知差异。宿主内部按「用户选择的引擎 + 机型是否支持超级岛 + Shizuku 是否授权 + 焦点通知权限是否具备」在超级岛与实况通知之间二选一(绝不双发);任一条件不满足即回落实况通知,保证「切过去却没有岛」不会发生。
  3. 接入方只面对 NoticeFlowClient:绑定、心跳、自动重连、请求串行化、本地限流、幂等重试、本地会话快照都在 SDK 内部完成。
  4. 内容由宿主校验并兜底裁剪: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 行,推荐)#

kotlin
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 仓库。可选两种方式:

方式一:源码模块依赖(推荐,当前主用)

kotlin
// 第三方工程 settings.gradle.kts
include(":noticeflow-sdk")
project(":noticeflow-sdk").projectDir = file("../NoticeFlow/noticeflow-sdk")
kotlin
// 第三方 app/build.gradle.kts
dependencies {
    implementation(project(":noticeflow-sdk"))
}

方式二:本地 Maven 仓库(宿主工程执行一次)

bash
# 在 实况通知宿主工程根目录执行
./gradlew :noticeflow-sdk:publishToMavenLocal
kotlin
// 第三方 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,则必须显式声明:

xml
<uses-permission android:name="com.spark.noticeflow.sdk.permission.ACCESS" />

宿主侧对应声明为:

xml
<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):

  1. 总开关:通过 IslandSDK 便捷调用超级岛 / 实况通知(默认关闭;关闭时所有外部调用返回 CODE_ERROR_SDK_DISABLED)。
  2. 请求的应用列表:第三方应用第一次调用 SDK(包括 ping() 自检)后,宿主会记录其包名, 即使尚未授权也会显示在这里 —— 列表项标题为应用名、副标题为包名,右侧开关即该应用的授权状态: - 打开开关 → 加入白名单(allow(packageName)),该应用之后可正常上岛; - 关闭开关 → 移出白名单(disallow(packageName)),其请求返回 CODE_ERROR_UNAUTHORIZED; - 已卸载但仍在列表里的应用会标注「(已卸载)」,可直接关掉开关清理。

不再提供独立的「对外 SDK 管理」页面:总开关与每个应用的授权开关就是全部配置项。 限流、活跃会话上限等策略使用宿主内置默认值(见第 6 章),无需也不支持在应用内调整。 请求列表持久化、最多保留 50 个包名,按最近请求时间排序;记录动作发生在授权检查之前, 所以未授权应用也一定能出现在列表里,用户不需要先去别处查包名。

2.4 初始化与使用(完整可编译示例)#

绑定 → 自检 → 推送 → 更新 → 关闭 → 解绑的完整流程(lifecycleScope 来自 androidx.lifecycle:lifecycle-runtime-ktx,也可换成任意 CoroutineScope):

kotlin
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)

生命周期建议

kotlin
// 应用级初始化(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 本包名最近是否被宿主限流拒绝过

用法示例:

kotlin
// 推荐在真正发起业务调用前先做一次「宿主是否可用」判断
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()#

kotlin
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 授权流程#

  1. 权限:宿主声明 com.spark.noticeflow.sdk.permission.ACCESS(protectionLevel = normal),并把它挂在 SdkBridgeService 上。接入方依赖 SDK 后自动获得该权限声明(见 2.2)。
  2. 总开关:用户在 实况通知 功能页 → 「IslandSDK 接入」卡片打开总开关(「通过 IslandSDK 便捷调用超级岛 / 实况通知」)。默认关闭;关闭时所有外部调用返回 CODE_ERROR_SDK_DISABLED(6)。
  3. 单应用开关:接入方第一次调用后出现在同一张卡的「请求的应用」列表里,用户打开该应用右侧开关即完成授权。默认白名单为空(仅宿主自身放行);未打开开关的调用返回 CODE_ERROR_UNAUTHORIZED(2)。

5.2 防伪校验#

宿主不使用请求里自称的包名做鉴权,而是按 Binder 调用方真实 UID 解析:

text
Binder.getCallingUid()
  → PackageManager.getPackagesForUid(uid)   // 该 UID 的真实包名集合
  → 校验自称包名 ∈ 真实包名集合             // 不一致 → CODE_ERROR_UNAUTHORIZED
  → SdkWhitelistManager.isAllowed(生效包名)  // 不在白名单 → CODE_ERROR_UNAUTHORIZED

因此伪造 clientPackage 无法绕过授权。宿主自身进程(uid == Process.myUid())直接放行,用于 debug 自检。

5.3 接入方自查顺序#

遇到失败时按这个顺序排查,可以用一条调用同时拿到全部线索:

kotlin
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 回调里做一次对账:
kotlin
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 说未授权#

  1. 宿主是否已开启「对外 SDK 总开关」(否则错误码是 CODE_ERROR_SDK_DISABLED,message 为 "SDK is disabled")。
  2. 白名单是否包含当前应用的真实包名(注意 applicationId 与 flavor 后缀,例如 com.example.app.debug)。
  3. 在 UI 上提示用户到 实况通知 功能页 → 「IslandSDK 接入」卡片的「请求的应用」列表里打开本应用开关(调用一次 SDK 后即可出现)。
  4. 用 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)但用户看不到岛#

按以下顺序排查,这些都是宿主侧的用户设置/系统能力问题,接入方无法绕过:

  1. 通知使用权:宿主需要通知权限才能显示实况通知(Android 13+ 的 POST_NOTIFICATIONS、Android 16 的推广通知 POST_PROMOTED_NOTIFICATIONS)。用户关闭后宿主无法上岛。
  2. 焦点通知权限(澎湃/小米):超级岛依赖焦点通知;缺失时宿主会自动回落到实况通知,而不是报错。
  3. 超级岛机型限制:仅部分小米/澎湃机型支持超级岛;不支持时宿主回落实况通知。
  4. Shizuku 授权:澎湃模式下需要 Shizuku 授权,未授权则回落。
  5. 内容为空:title、text、capsule、图标全部为空时发布管线会拒绝(返回 CODE_ERROR_INVALID_CONTENT)。
  6. 会话被用户关闭:用户点过岛上的「关闭」后,该会话不再重新上岛(内容真正变化时按新消息处理)。
  7. 锁屏隐藏:宿主若开启了「锁屏隐藏全部实况通知」,锁屏期间不会发布。

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 接入」列表里没有我的应用?#

按以下顺序排查:

  1. 宿主版本:请求方登记是宿主 3.7.0+ 的行为;旧宿主只会显示已授权应用。用 getHostInfo().versionName 确认。
  2. 是否真的发起了 Binder 调用:只有 bind() 成功并至少完成一次事务(ping() 即可)才会被登记。bind() 失败说明 SdkBridgeService 未导出、action 不匹配或包可见性不足,见 Q1。
  3. 总开关:SDK 总开关关闭时,authorize() 会在读取 UID 前提前返回,此时不会登记请求方;先打开总开关再调用一次。
  4. 列表容量:列表最多保留最近 50 个包名,长期测试会挤掉旧条目;重新调用一次即可回到列表。
  5. 包名一致性:请求里自称的包名必须与调用方 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() 区分「能力缺失」与「未授权」

推荐的能力探测写法:

kotlin
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. 协议只增不改:事务码 1 ~ 6 为 v1 既有方法(PING / GET_CAPABILITIES / POST_CUSTOM / UPDATE / DISMISS / IS_ALIVE),7 ~ 10 为扩充方法(GET_HOST_INFO / DISMISS_ALL / GET_ACTIVE_SESSIONS / GET_STATS)。已有事务码的入参与出参顺序不会变更。
  2. 数据模型只增字段:SdkPostRequest、SdkHostInfo、SdkStats 等 Parcelable 新增字段时,旧客户端的请求仍可被新宿主接受;旧宿主返回的对象缺少的新字段按默认值处理。
  3. 错误码只增不改:CODE_OK ~ CODE_ERROR_RATE_LIMITED(0 ~ 7)语义固定;新错误码(如 8、9)以新增常量方式加入。
  4. 能力探测优先:判断「宿主是否支持某功能」时,优先用 getCapabilities() + getHostInfo().apiVersion,不要根据宿主应用版本号硬编码分支。
  5. 不依赖内部实现类:接入方只应使用 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+)。

三种写法#

kotlin
// 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

示例:按会话覆盖澎湃旋钮#

kotlin
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,抓完再还原。
bash
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 撤岛成功 / 会话不存在按幂等成功
bash
adb logcat -s SdkBridgeService:I NoticeFlowSdk:V

A.3 宿主端到端自检#

宿主 debug 构建提供端到端自检(宿主内部使用 SDK 客户端走真实 Binder IPC:bind → ping → post → update → dismiss):

bash
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 返回失败/空」)。
本页由 SDK_GUIDE.md 自动生成(v1.1.0 · 2026-10-24);如与宿主实际行为不一致,以 getHostInfo() 返回的版本与宿主代码为准。