# 实况通知 IslandSDK 接入文档

> SDK 名称：**IslandSDK**（模块 `noticeflow-sdk`，包名 `com.spark.noticeflow.sdk`）
> **SDK 版本：1.1.0**（SDK 与本文档共用同一版本号，从 1.0.0 起）
> 适用 SDK：`noticeflow-sdk` 模块（`com.spark.noticeflow:noticeflow-sdk`）
> 协议版本：`SdkConstants.API_VERSION = 1`
> 最低系统版本：Android 12（API 32，与 SDK 模块 `minSdk` 一致）
> 本文档面向第三方接入方开发者；宿主侧应用版本号请以 `getHostInfo()` 返回值或宿主 `app/build.gradle.kts` 为准。

---

## 1. 概述与工作原理

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

```
┌──────────────────┐   自定义 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` 串行化，业务侧无需自己加锁。

---

## 3. API 参考

入口：`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 解析：

```
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 返回失败/空」）。 |




