添加游戏盾代理支持

This commit is contained in:
2026-03-03 18:11:09 +08:00
parent ae4a045451
commit 8e9b816a4e
13 changed files with 517 additions and 105 deletions

View File

@@ -1,6 +1,6 @@
# Selly Live SDK 推拉流接入文档Android # Selly Live SDK 推拉流接入文档Android
> 统一 SDK 名称:**SellyCloudSDK** > 统一 SDK 名称:**SellyCloudSDK**
> 本文档适用于 Android 客户端,面向对外集成方与内部使用。 > 本文档适用于 Android 客户端,面向对外集成方与内部使用。
--- ---
@@ -19,6 +19,7 @@ Selly Live SDK 提供完整的音视频直播能力,支持 **推流(直播
- 支持视频帧处理(美颜 / 滤镜 / 水印) - 支持视频帧处理(美颜 / 滤镜 / 水印)
- 基于 **Token 的安全鉴权机制** - 基于 **Token 的安全鉴权机制**
- 支持 **RTMP H264 + AAC payload XOR 保护(可选)** - 支持 **RTMP H264 + AAC payload XOR 保护(可选)**
- 支持 **外部代理地址注入**(如洋葱盾等第三方安全代理)
--- ---
@@ -34,8 +35,8 @@ Selly Live SDK 提供完整的音视频直播能力,支持 **推流(直播
### 3.1 项目结构参考 ### 3.1 项目结构参考
- `example/`Android Demo 工程 - `example/`Android Demo 工程
- 推流示例:`example/src/main/java/com/demo/SellyCloudSDK/live/LivePushActivity.kt` - 推流示例:`example/src/main/java/com/demo/SellyCloudSDK/live/LivePushActivity.kt`
- 拉流示例:`example/src/main/java/com/demo/SellyCloudSDK/live/LivePlayActivity.kt` - 拉流示例:`example/src/main/java/com/demo/SellyCloudSDK/live/LivePlayActivity.kt`
- `example/libs/`:本地 AAR 依赖存放目录 - `example/libs/`:本地 AAR 依赖存放目录
@@ -60,9 +61,81 @@ dependencies {
--- ---
## 4. Token 鉴权机制(重点) ## 4. SDK 初始化与代理配置
### 4.1 Token 注入方式 ### 4.1 SDK 初始化
在使用任何推流 / 拉流功能前,必须先初始化 SDK
```kotlin
SellyCloudManager.initialize(
context = applicationContext,
appId = "your-app-id",
config = SellyCloudConfig(
vhost = "your-vhost",
vhostKey = "your-vhost-key",
defaultStreamId = "default-stream",
defaultLiveMode = SellyLiveMode.RTMP
)
)
```
`initialize` 参数说明:
| 参数 | 类型 | 说明 |
| ---- | ---- | ---- |
| `context` | Context | 应用上下文 |
| `appId` | String | 应用 ID权威值会覆盖 config 中的 appId |
| `config` | SellyCloudConfig? | 可选配置,不传则使用默认值 |
`SellyCloudConfig` 字段说明:
| 字段 | 类型 | 说明 |
| ---- | ---- | ---- |
| `vhost` | String | 虚拟主机 |
| `vhostKey` | String | vhost 密钥(用于鉴权签名) |
| `defaultStreamId` | String | 默认流 ID |
| `logEnabled` | Boolean | 是否启用日志,默认 true |
| `defaultLiveMode` | SellyLiveMode | 默认推拉流模式RTMP / RTC |
| `appName` | String | 应用名称,为空时自动使用 appId一般无需设置 |
> `config.appId` 无需设置SDK 内部会用 `initialize(appId=)` 参数覆盖。
### 4.2 代理地址配置(可选)
SDK 支持通过外部代理(如洋葱盾等安全加速服务)进行流媒体连接。代理地址由业务方在 SDK 外部获取,然后通过以下接口注入:
```kotlin
// 设置代理地址
SellyCloudManager.setProxyAddress("http://127.0.0.1:12345")
// 清除代理(恢复直连)
SellyCloudManager.setProxyAddress(null)
// 查询当前代理地址
val proxy = SellyCloudManager.getProxyAddress() // null 表示未设置
```
**格式要求:**
- 必须以 `http://``https://` 开头
-`null` 或空字符串表示清除代理
- 格式不合法时抛出 `IllegalArgumentException`
**生效范围:**
- 设置后对 RTMP 推拉流、RTCWHEP/WHIP播放推流、Signaling 信令连接均生效
- SDK 内部通过代理地址解析真实服务器 IP对上层透明
**时机要求:**
- 必须在推流 / 拉流 **开始之前** 设置
- 推流 / 拉流过程中修改代理地址,需停止后重新开始才能生效
> Demo 中使用 `KiwiHelper` 封装了洋葱盾 SDK 的初始化与代理地址获取流程,通过 `SellyCloudManager.setProxyAddress()` 将结果传给 SDK。详见 `example/src/main/java/com/demo/SellyCloudSDK/KiwiHelper.kt`。
---
## 5. Token 鉴权机制(重点)
### 5.1 Token 注入方式
| 场景 | 设置位置 | | 场景 | 设置位置 |
| ---- | ---- | | ---- | ---- |
@@ -76,7 +149,7 @@ dependencies {
- SDK 内部在建立连接时自动携带当前 Token - SDK 内部在建立连接时自动携带当前 Token
- 直接使用 RTMP 地址推/拉流不需要 Token可不设置 - 直接使用 RTMP 地址推/拉流不需要 Token可不设置
### 4.2 Token 设置时机(强约束) ### 5.2 Token 设置时机(强约束)
#### 推流 #### 推流
@@ -91,9 +164,9 @@ dependencies {
- `prepareToPlay()` - `prepareToPlay()`
- `play()` - `play()`
> ⚠️ 在连接建立后修改 Token不会影响当前连接。 > 在连接建立后修改 Token不会影响当前连接。
### 4.3 Token 刷新机制说明 ### 5.3 Token 刷新机制说明
- SDK **不提供自动刷新** - SDK **不提供自动刷新**
- 业务层可在任意时刻 **重新设置 token 属性** - 业务层可在任意时刻 **重新设置 token 属性**
@@ -104,7 +177,7 @@ dependencies {
2. 调用 `pusher.token = newToken` / `player.token = newToken` 2. 调用 `pusher.token = newToken` / `player.token = newToken`
3. 停止并重新开始推流 / 拉流流程 3. 停止并重新开始推流 / 拉流流程
### 4.4 RTMP Payload XOR 保护(可选) ### 5.4 RTMP Payload XOR 保护(可选)
用途: 用途:
@@ -131,9 +204,9 @@ Key 格式:
--- ---
## 5. 推流接入详解 ## 6. 推流接入详解
### 5.1 创建推流实例 ### 6.1 创建推流实例
```kotlin ```kotlin
val pusher = SellyLiveVideoPusher.initWithLiveMode( val pusher = SellyLiveVideoPusher.initWithLiveMode(
@@ -147,7 +220,7 @@ pusher.delegate = object : SellyLiveVideoPusherDelegate {
} }
``` ```
### 5.2 视频参数配置与预览 ### 6.2 视频参数配置与预览
```kotlin ```kotlin
val config = SellyLiveVideoConfiguration.defaultConfiguration().apply { val config = SellyLiveVideoConfiguration.defaultConfiguration().apply {
@@ -166,7 +239,7 @@ pusher.startRunning(
) )
``` ```
### 5.3 设置推流 Token使用 streamId 时) ### 6.3 设置推流 Token使用 streamId 时)
```kotlin ```kotlin
pusher.token = pushToken pusher.token = pushToken
@@ -183,7 +256,7 @@ pusher.setXorKey(xorKeyHex)
> 若在推流中修改 key需停止并重新开始推流后才会使用新 key。 > 若在推流中修改 key需停止并重新开始推流后才会使用新 key。
### 5.4 开始/停止推流 ### 6.4 开始/停止推流
```kotlin ```kotlin
pusher.startLiveWithStreamId(streamId) pusher.startLiveWithStreamId(streamId)
@@ -212,7 +285,7 @@ pusher.stopLive { error ->
} }
``` ```
### 5.5 常用控制接口 ### 6.5 常用控制接口
- `setMuted(true/false)`:静音 - `setMuted(true/false)`:静音
- `switchCameraPosition(...)`:切换摄像头 - `switchCameraPosition(...)`:切换摄像头
@@ -226,7 +299,7 @@ pusher.stopLive { error ->
- `setBeautyLevel(level)`:设置美颜强度 - `setBeautyLevel(level)`:设置美颜强度
- `setBitmapAsVideoSource(...)` / `restoreCameraVideoSource()`:背景图推流 - `setBitmapAsVideoSource(...)` / `restoreCameraVideoSource()`:背景图推流
### 5.6 生命周期建议 ### 6.6 生命周期建议
在宿主 Activity 中对齐生命周期: 在宿主 Activity 中对齐生命周期:
@@ -234,7 +307,7 @@ pusher.stopLive { error ->
- `onPause()``pusher.onPause()` - `onPause()``pusher.onPause()`
- `onDestroy()``pusher.release()` - `onDestroy()``pusher.release()`
### 5.7 状态与统计回调 ### 6.7 状态与统计回调
**状态枚举:** **状态枚举:**
@@ -252,7 +325,7 @@ pusher.stopLive { error ->
- rttMs - rttMs
- cpu 使用率Demo 通过 `CpuUsage` 读取) - cpu 使用率Demo 通过 `CpuUsage` 读取)
### 5.8 推流 API 速览(含 Demo 未覆盖) ### 6.8 推流 API 速览(含 Demo 未覆盖)
初始化与预览: 初始化与预览:
@@ -293,9 +366,9 @@ pusher.stopLive { error ->
--- ---
## 6. 拉流接入详解 ## 7. 拉流接入详解
### 6.1 创建播放器 ### 7.1 创建播放器
```kotlin ```kotlin
val player = SellyLiveVideoPlayer.initWithStreamId( val player = SellyLiveVideoPlayer.initWithStreamId(
@@ -323,14 +396,14 @@ val player = SellyLiveVideoPlayer.initWithStreamId(
> 使用 RTMP 加密流时,请在创建播放器时传入 `xorKeyHex`;后续如需换 key请重建播放器实例。 > 使用 RTMP 加密流时,请在创建播放器时传入 `xorKeyHex`;后续如需换 key请重建播放器实例。
### 6.2 设置拉流 Token使用 streamId 时) ### 7.2 设置拉流 Token使用 streamId 时)
```kotlin ```kotlin
player.token = playToken player.token = playToken
``` ```
> 直接使用 RTMP 地址拉流不需要 Token可不设置。 > 直接使用 RTMP 地址拉流不需要 Token可不设置。
### 6.3 播放流程 ### 7.3 播放流程
```kotlin ```kotlin
player.attachRenderView(renderContainer) player.attachRenderView(renderContainer)
@@ -351,7 +424,7 @@ player.play()
- `setRenderView(view)`:手动指定渲染 View - `setRenderView(view)`:手动指定渲染 View
- `seekBy(deltaMs)`:播放进度跳转(仅在流支持快进/回放时有效) - `seekBy(deltaMs)`:播放进度跳转(仅在流支持快进/回放时有效)
### 6.4 播放回调 ### 7.4 播放回调
```kotlin ```kotlin
player.delegate = object : SellyLiveVideoPlayerDelegate { player.delegate = object : SellyLiveVideoPlayerDelegate {
@@ -374,7 +447,7 @@ player.delegate = object : SellyLiveVideoPlayerDelegate {
- `Reconnecting` - `Reconnecting`
- `Failed` - `Failed`
### 6.5 播放 API 速览(含 Demo 未覆盖) ### 7.5 播放 API 速览(含 Demo 未覆盖)
创建与渲染: 创建与渲染:
@@ -397,7 +470,7 @@ player.delegate = object : SellyLiveVideoPlayerDelegate {
--- ---
## 7. 错误处理与重试建议 ## 8. 错误处理与重试建议
### Token 错误 ### Token 错误
@@ -414,24 +487,25 @@ player.delegate = object : SellyLiveVideoPlayerDelegate {
--- ---
## 8. 最佳实践 ## 9. 最佳实践
- 推流前先完成采集预览 - 推流前先完成采集预览
- Token 即将过期前提前刷新 - Token 即将过期前提前刷新
- 使用统计回调做质量监控 - 使用统计回调做质量监控
- 拉流失败避免无限重试 - 拉流失败避免无限重试
- 使用代理时,确保在推拉流开始前代理地址已设置完毕
--- ---
## 9. 常见问题FAQ ## 10. 常见问题FAQ
### Q1Token 可以拼接到 URL 吗? ### Q1Token 可以拼接到 URL 吗?
**A** 不可以。 **A** 不可以。
SDK 不解析 URL 中的鉴权信息,所有鉴权均通过 `token` 属性完成。 SDK 不解析 URL 中的鉴权信息,所有鉴权均通过 `token` 属性完成。
### Q2运行中修改 Token 是否生效? ### Q2运行中修改 Token 是否生效?
**A** **A**
运行中修改 Token **不会影响当前已建立的连接** 运行中修改 Token **不会影响当前已建立的连接**
**下次重连或重新启动推流 / 拉流时会使用新的 Token** **下次重连或重新启动推流 / 拉流时会使用新的 Token**
### Q3播放器出现黑屏怎么办 ### Q3播放器出现黑屏怎么办
@@ -449,3 +523,15 @@ SDK 不解析 URL 中的鉴权信息,所有鉴权均通过 `token` 属性完
- key 格式是否为合法 hex偶数长度支持 `0x` 前缀) - key 格式是否为合法 hex偶数长度支持 `0x` 前缀)
- 当前是否为 RTMP + H264 + AAC - 当前是否为 RTMP + H264 + AAC
- 变更 key 后是否已重启推流 / 重建播放器 - 变更 key 后是否已重启推流 / 重建播放器
### Q5如何接入代理/加速服务(如洋葱盾)?
**A**
SDK 本身不集成任何第三方代理 SDK。业务方需在 SDK 外部完成代理初始化与地址获取,然后通过 `SellyCloudManager.setProxyAddress(proxyUrl)` 注入。SDK 内部会自动通过代理地址解析真实服务器 IP。
示例流程:
1. 在 Application 或 Activity 中初始化代理 SDK
2. 获取本地代理地址(如 `http://127.0.0.1:12345`
3. 调用 `SellyCloudManager.setProxyAddress("http://127.0.0.1:12345")`
4. 正常进行推流 / 拉流
> Demo 中的 `KiwiHelper` 展示了洋葱盾的完整接入流程,可作为参考。

View File

@@ -8,16 +8,17 @@ SDK 核心以 `InteractiveRtcEngine` 为中心,通过 `InteractiveRtcEngineEve
## 目录 ## 目录
1. 准备工作 1. 准备工作
2. 快速开始 2. 快速开始
3. 基础通话流程 3. 基础通话流程
4. 常用功能 4. 常用功能
5. 屏幕分享 5. 屏幕分享
6. 视频帧前后处理 6. 视频帧前后处理
7. 事件回调EventHandler 7. 事件回调EventHandler
8. 通话统计 8. 通话统计
9. Token 机制 9. Token 机制
10. 常见问题FAQ 10. 代理地址配置
11. 常见问题FAQ
--- ---
@@ -38,19 +39,40 @@ SDK 核心以 `InteractiveRtcEngine` 为中心,通过 `InteractiveRtcEngineEve
## 快速开始 ## 快速开始
### 1. 创建引擎 ### 1. SDK 初始化
在使用音视频通话功能前,需先初始化 SDK
```kotlin
SellyCloudManager.initialize(
context = applicationContext,
appId = "your-app-id"
)
```
> `initialize` 的 `appId` 参数为权威值。可选传入 `SellyCloudConfig` 配置 `vhost`、`logEnabled` 等,详见推拉流文档。
### 2. 代理地址设置(可选)
若需通过代理(如洋葱盾)连接信令服务器,在创建引擎前设置:
```kotlin
SellyCloudManager.setProxyAddress("http://127.0.0.1:12345")
```
> SDK 内部通过代理地址解析真实信令服务器 IP。不设置则使用直连。详见「代理地址配置」章节。
### 3. 创建引擎
```kotlin ```kotlin
val appId = getString(R.string.signaling_app_id) val appId = getString(R.string.signaling_app_id)
val token = getString(R.string.signaling_token).takeIf { it.isNotBlank() } val token = getString(R.string.signaling_token).takeIf { it.isNotBlank() }
val kiwiRsName = getString(R.string.signaling_kiwi_rsname).trim()
val rtcEngine = InteractiveRtcEngine.create( val rtcEngine = InteractiveRtcEngine.create(
InteractiveRtcEngineConfig( InteractiveRtcEngineConfig(
context = applicationContext, context = applicationContext,
appId = appId, appId = appId,
defaultToken = token, defaultToken = token
kiwiRsName = kiwiRsName
) )
).apply { ).apply {
setEventHandler(eventHandler) setEventHandler(eventHandler)
@@ -68,9 +90,20 @@ val rtcEngine = InteractiveRtcEngine.create(
} }
``` ```
> `InteractiveRtcEngineConfig` 与默认 token 配置见 `example/src/main/java/com/demo/SellyCloudSDK/interactive/InteractiveLiveActivity.kt`。 `InteractiveRtcEngineConfig` 参数说明:
### 2. 设置本地/远端画布 | 参数 | 类型 | 说明 |
| ---- | ---- | ---- |
| `context` | Context | 应用上下文 |
| `appId` | String | 应用 ID |
| `defaultCallType` | CallType | 默认通话类型,默认 ONE_TO_ONE |
| `defaultToken` | String? | 默认 Token |
| `signalingUrlPrefix` | String | 信令 URL 前缀,默认 `ws://` |
| `signalingUrlSuffix` | String | 信令 URL 后缀,默认 `/ws/signaling` |
> 完整 Demo 见 `example/src/main/java/com/demo/SellyCloudSDK/interactive/InteractiveLiveActivity.kt`。
### 4. 设置本地/远端画布
```kotlin ```kotlin
val localRenderer = SurfaceViewRenderer(this) val localRenderer = SurfaceViewRenderer(this)
@@ -82,7 +115,7 @@ val remoteRenderer = SurfaceViewRenderer(this)
rtcEngine.setupRemoteVideo(InteractiveVideoCanvas(remoteRenderer, remoteUserId)) rtcEngine.setupRemoteVideo(InteractiveVideoCanvas(remoteRenderer, remoteUserId))
``` ```
### 3. 加入通话 ### 5. 加入通话
```kotlin ```kotlin
val options = InteractiveChannelMediaOptions(callType = CallType.ONE_TO_ONE) val options = InteractiveChannelMediaOptions(callType = CallType.ONE_TO_ONE)
@@ -109,23 +142,9 @@ rtcEngine.leaveChannel()
InteractiveRtcEngine.destroy(rtcEngine) InteractiveRtcEngine.destroy(rtcEngine)
``` ```
### 4. 进阶配置Demo 未覆盖) ### 6. 进阶配置Demo 未覆盖)
#### 4.1 InteractiveRtcEngineConfig 高级字段 #### 6.1 InteractiveChannelMediaOptions 订阅控制
```kotlin
val config = InteractiveRtcEngineConfig(
context = applicationContext,
appId = appId,
defaultCallType = CallType.ONE_TO_ONE,
defaultToken = token,
kiwiRsName = kiwiRsName,
signalingUrlPrefix = "https://",
signalingUrlSuffix = "/signaling"
)
```
#### 4.2 InteractiveChannelMediaOptions 订阅控制
```kotlin ```kotlin
val options = InteractiveChannelMediaOptions( val options = InteractiveChannelMediaOptions(
@@ -135,7 +154,7 @@ val options = InteractiveChannelMediaOptions(
) )
``` ```
#### 4.3 InteractiveVideoEncoderConfig 更多参数 #### 6.2 InteractiveVideoEncoderConfig 更多参数
可选项(按需设置): 可选项(按需设置):
@@ -150,14 +169,16 @@ val options = InteractiveChannelMediaOptions(
## 基础通话流程 ## 基础通话流程
1. 创建 `InteractiveRtcEngine` 1. 初始化 SDK`SellyCloudManager.initialize`
2. 设置 `EventHandler` 2. 设置代理地址(可选,`SellyCloudManager.setProxyAddress`
3. 配置 `InteractiveVideoEncoderConfig` 3. 创建 `InteractiveRtcEngine`
4. 设置本地画布 `setupLocalVideo` 4. 设置 `EventHandler`
5. `joinChannel` 加入频道 5. 配置 `InteractiveVideoEncoderConfig`
6. `onUserJoined` 后设置远端画布 6. 设置本地画布 `setupLocalVideo`
7. 通话中进行音视频控制 7. `joinChannel` 加入频道
8. `leaveChannel` 并释放资源 8. `onUserJoined` 后设置远端画布
9. 通话中进行音视频控制
10. `leaveChannel` 并释放资源
--- ---
@@ -260,7 +281,7 @@ rtcEngine.setRenderVideoFrameInterceptor { frame, userId ->
} }
``` ```
> Demo 中的美颜示例见: > Demo 中的美颜示例见:
> `example/src/main/java/com/demo/SellyCloudSDK/beauty/FuVideoFrameInterceptor.kt` > `example/src/main/java/com/demo/SellyCloudSDK/beauty/FuVideoFrameInterceptor.kt`
--- ---
@@ -321,6 +342,61 @@ rtcEngine.renewToken(newToken, expiresAtSec)
--- ---
## 代理地址配置
SDK 支持通过外部代理(如洋葱盾等安全加速服务)连接信令服务器。代理地址由业务方在 SDK 外部获取,然后注入 SDK。
### 设置方式
```kotlin
// 设置代理地址(在 joinChannel 之前)
SellyCloudManager.setProxyAddress("http://127.0.0.1:12345")
// 清除代理(恢复直连)
SellyCloudManager.setProxyAddress(null)
// 查询当前代理地址
val proxy = SellyCloudManager.getProxyAddress() // null 表示未设置
```
### 格式要求
- 必须以 `http://``https://` 开头
-`null` 或空字符串表示清除代理
- 格式不合法时抛出 `IllegalArgumentException`
### 生效范围
设置后SDK 内部通过代理地址解析真实信令服务器 IP对上层接口透明。
### 时机要求
- 必须在 `joinChannel()` **之前** 设置
- 通话过程中修改代理地址,需 `leaveChannel` 后重新 `joinChannel` 才能生效
### Demo 中的接入示例
Demo 使用 `KiwiHelper` 封装洋葱盾的初始化与代理获取,采用三阶段模式:
```kotlin
// 阶段 1Application.onCreate() 异步初始化
KiwiHelper.initializeAsync()
// 阶段 2Activity 初始化时启动代理获取(非阻塞)
KiwiHelper.startProxySetup(enableKiwi = true, rsName = "your-rs-name")
// 阶段 3joinChannel 前确保代理已就绪
lifecycleScope.launch {
KiwiHelper.awaitProxyReady()
rtcEngine.joinChannel(...)
}
```
> `KiwiHelper` 内部通过 `SellyCloudManager.setProxyAddress()` 将代理地址传给 SDK。
> 详见 `example/src/main/java/com/demo/SellyCloudSDK/KiwiHelper.kt`。
---
## 更多 API 速览(含 Demo 未覆盖) ## 更多 API 速览(含 Demo 未覆盖)
引擎创建与销毁: 引擎创建与销毁:
@@ -328,6 +404,12 @@ rtcEngine.renewToken(newToken, expiresAtSec)
- `InteractiveRtcEngine.create(config)`:创建引擎 - `InteractiveRtcEngine.create(config)`:创建引擎
- `InteractiveRtcEngine.destroy(engine)` / `engine.destroy()`:释放引擎 - `InteractiveRtcEngine.destroy(engine)` / `engine.destroy()`:释放引擎
SDK 初始化与代理:
- `SellyCloudManager.initialize(context, appId, config)`:初始化 SDK
- `SellyCloudManager.setProxyAddress(address)`:设置代理地址
- `SellyCloudManager.getProxyAddress()`:获取当前代理地址
通话控制: 通话控制:
- `setEventHandler(handler)`:设置事件回调 - `setEventHandler(handler)`:设置事件回调
@@ -369,7 +451,11 @@ rtcEngine.renewToken(newToken, expiresAtSec)
1. 检查 `signaling_app_id` 是否正确 1. 检查 `signaling_app_id` 是否正确
2. Token 是否为空或已过期 2. Token 是否为空或已过期
3. 网络是否受限 3. 网络是否受限
4. 若使用代理,检查代理地址是否已正确设置
### Q屏幕分享失败 ### Q屏幕分享失败
1. 是否已获取 `MediaProjection` 授权 1. 是否已获取 `MediaProjection` 授权
2. Android 14+ 是否启动前台服务 2. Android 14+ 是否启动前台服务
### Q如何接入代理/加速服务?
SDK 本身不集成任何第三方代理 SDK。业务方需在外部完成代理初始化获取本地代理地址后通过 `SellyCloudManager.setProxyAddress()` 注入。详见「代理地址配置」章节。

View File

@@ -67,7 +67,8 @@ dependencies {
implementation files( implementation files(
sdkAarPath, sdkAarPath,
"libs/fu_core_all_feature_release.aar", "libs/fu_core_all_feature_release.aar",
"libs/fu_model_all_feature_release.aar" "libs/fu_model_all_feature_release.aar",
"libs/Kiwi.aar"
) )
implementation fileTree(dir: "libs", include: ["*.jar"]) implementation fileTree(dir: "libs", include: ["*.jar"])
implementation 'androidx.appcompat:appcompat:1.7.0-alpha03' implementation 'androidx.appcompat:appcompat:1.7.0-alpha03'

BIN
example/libs/Kiwi.aar Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -18,6 +18,7 @@
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application <application
android:name=".DemoApplication"
android:allowBackup="true" android:allowBackup="true"
android:label="SellyCloudRTC Demo" android:label="SellyCloudRTC Demo"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"

View File

@@ -0,0 +1,11 @@
package com.demo.SellyCloudSDK
import android.app.Application
class DemoApplication : Application() {
override fun onCreate() {
super.onCreate()
// Kiwi SDK 异步初始化(不阻塞启动)
KiwiHelper.initializeAsync()
}
}

View File

@@ -0,0 +1,188 @@
package com.demo.SellyCloudSDK
import android.util.Log
import com.kiwi.sdk.Kiwi
import com.sellycloud.sellycloudsdk.SellyCloudManager
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
/**
* Demo 端的 Kiwi 盾 SDK 封装
*
* 三阶段使用模式:
* 1. Application.onCreate() → initializeAsync() 异步初始化 Kiwi SDK
* 2. Activity 初始化 → startProxySetup(...) 后台获取代理地址
* 3. 开播/入会前 → awaitProxyReady() 确保代理已就绪
*/
object KiwiHelper {
private const val TAG = "KiwiHelper"
private const val DEFAULT_APP_KEY = "5XTXUZ/aqOwfjA4zQkY7VpjcNBucWxmNGY4vFNhwSMKWkn2WK383dbNgI+96Y+ttSPMFzqhu8fxP5SiCK5+/6cGrBQQt8pDQAOi3EN4Z6lzkC2cJ5mfjBVi4ZpFASG9e3divF5UqLG6sTmFI3eCuJxy9/kHXPSSkKWJe1MnBMQETpf4FRDVuR9d/LzXKQgA9PsjRbPRLx4f3h0TU2P4GEfv1c70FvkdwpqirQt9ik2hAhKuj0vJY60g+yYhGY19a07vBTW4MprN53RnSH8bCs79NNbWyzsg2++t+sKdZP1WPGeOho/xpsQRP8yWCXIOOdvdjiE3YXVltBgmPnA6gOjFS97WVlBAQ1mJE7rQi+/5hhfTuJlWoBH6000SRe7dc5EA0WGQX9U1Aj96ahBQhyHTrHJySmJ/hRMYMudqByF6K4PtrwZ8zugTjtx1dyLPOonZDlTu7hPAIcUfuaQ9xS3Phbq8lP67EYDsr3pkWuwL6AjrPjFwNmi0P1g+hV1ZQUmDQVGhNHmF3cE9Pd5ZOS10/fwaXYGRhcq9PlUSmcbU3scLtrBlzpOslyjlQ6W57EudCrvvJU3mimfs1A2y7cjpnLlJN1CWh6dQAaGcwSG2QA8+88qmlMH1t627fItTgHYrP1DkExpAr2dqgYDvsICJnHaRSBMe608GrPbFaECutRz5y3BEtQKcVKdgA1e6W4TFnxs5HqGrzc8iHPOOKGf8zHWEXkITPBKEiA86Nz46pDrqM9FKx4upPijn4Dahj8pd7yWTUIdHBT8X39Vm3/TSV5xT/lTinmv8rhBieb/2SQamTjVQ22VFq3nQ1h4TxUYTEc0nSjqcz54fWf1cyBy7uh82q1weKXUAJ8vG9W05vmt3/aDZ9+C8cWm53AQ90xgDvW7M1mZveuyfof2qrPsXTpj+jhpDkJgm6qJsvV5ClmGth8gvCM0rHjSIwxhYDZaIDK5TkFWjwLltt+YhhYLKketwuTHdlO/hCxrsFzlXHhXGVRC+kgXusfQUrHIm1WjW9o9EqasHg9ufUgg7cMO/9FRZhJ+Xdw9erprYDvu84Da9jL6NUUOSNIGTCJ/s29Lz4SIwCVG2lzm2UhD6E9ipGfG9gc6e/2vt1emOsP3/ipHVJf16r/9S4+dGKIjPX6QcHIIL2AMu2Je07nPmEoz7KaeOShox4bG3puMQdkdQo6kRIFpUzwUty+4EWqHmyPHGkGGGfI8gj0EreiZwgVJmBQ/8S5wlK+iUp+TVeoXo="
private const val INIT_TIMEOUT_SECONDS = 3L
private const val CONVERT_TIMEOUT_SECONDS = 1L
private const val AWAIT_INIT_TIMEOUT_MS = 4000L
/** Kiwi.Init 结果 Deferred */
private val initDeferred = CompletableDeferred<Boolean>()
/** 内部受控 scope不依赖外部 lifecycle */
private val helperScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
/** 当前代理获取 Job */
@Volatile private var currentSetupJob: Job? = null
/**
* 单调递增版本号,用于解决并发取消时旧 Job 覆盖新结果的竞态问题。
* 每次 startProxySetup 递增resolveAndSetProxyAddress 在写入前校验版本一致性。
*/
private val setupVersion = AtomicInteger(0)
// ──────────────── 阶段 1初始化 ────────────────
/**
* 异步初始化 Kiwi SDKApplication.onCreate 调用,只调一次)
*/
fun initializeAsync() {
val executor = Executors.newSingleThreadExecutor()
val future = executor.submit<Int> { Kiwi.Init(DEFAULT_APP_KEY) }
Thread {
try {
val result = future.get(INIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
val success = result == 0
Log.d(TAG, if (success) "Kiwi 初始化成功" else "Kiwi 初始化失败, code=$result")
initDeferred.complete(success)
} catch (e: Exception) {
Log.e(TAG, "Kiwi 初始化异常: ${e.message}")
future.cancel(true)
initDeferred.complete(false)
} finally {
executor.shutdown()
}
}.start()
}
// ──────────────── 阶段 2启动代理获取 ────────────────
/**
* 启动代理获取(非 suspend可在主线程安全调用
* - 递增版本号 + cancel 前一次 Job保证"最后一次调用生效"
* - 内部协程 await 初始化 + IO ServerToLocal不阻塞调用线程
*/
fun startProxySetup(enableKiwi: Boolean, rsName: String) {
val version = setupVersion.incrementAndGet()
currentSetupJob?.cancel()
if (!enableKiwi || rsName.isBlank()) {
SellyCloudManager.setProxyAddress(null)
currentSetupJob = null
return
}
currentSetupJob = helperScope.launch {
resolveAndSetProxyAddress(rsName, version)
}
}
// ──────────────── 阶段 3等待代理就绪 ────────────────
/**
* 在开播/入会前调用suspend 等待代理获取完成
* 如果 startProxySetup 未调用或已完成,立即返回
*/
suspend fun awaitProxyReady() {
currentSetupJob?.join()
}
// ──────────────── 内部实现 ────────────────
private suspend fun awaitInitialization(): Boolean {
return withTimeoutOrNull(AWAIT_INIT_TIMEOUT_MS) {
initDeferred.await()
} ?: run {
Log.w(TAG, "等待 Kiwi 初始化超时 (${AWAIT_INIT_TIMEOUT_MS}ms)")
false
}
}
private suspend fun resolveAndSetProxyAddress(rsName: String, version: Int): Boolean {
// 等待初始化完成
if (!awaitInitialization()) {
Log.w(TAG, "Kiwi 初始化失败/超时,清除代理")
setProxyIfCurrent(version, null)
return false
}
// 在 IO 线程执行阻塞的 ServerToLocal
return withContext(Dispatchers.IO) {
try {
val proxyUrl = convertRsToLocalUrl(rsName)
// 阻塞调用返回后,检查协程是否已取消
ensureActive()
// 版本校验:只有当前版本一致才写入,防止旧 Job 覆盖新结果
if (proxyUrl != null) {
Log.d(TAG, "Kiwi 代理地址: $proxyUrl")
setProxyIfCurrent(version, proxyUrl)
true
} else {
Log.w(TAG, "Kiwi ServerToLocal 失败,清除代理")
setProxyIfCurrent(version, null)
false
}
} catch (e: Exception) {
Log.e(TAG, "代理解析异常: ${e.message}", e)
setProxyIfCurrent(version, null)
false
}
}
}
/**
* 仅当 version 与当前 setupVersion 一致时才写入代理地址,
* 避免已过期的旧 Job 覆盖最新结果。
*/
private fun setProxyIfCurrent(version: Int, address: String?) {
if (setupVersion.get() == version) {
SellyCloudManager.setProxyAddress(address)
} else {
Log.d(TAG, "跳过过期的代理写入 (version=$version, current=${setupVersion.get()})")
}
}
/**
* Kiwi.ServerToLocal + 返回码校验
*/
private fun convertRsToLocalUrl(rsName: String): String? {
val executor = Executors.newSingleThreadExecutor()
return try {
val future = executor.submit<String?> {
val ip = StringBuffer()
val port = StringBuffer()
val ret = Kiwi.ServerToLocal(rsName, ip, port)
if (ret != 0) {
Log.w(TAG, "ServerToLocal 返回错误码: $ret, rsName=$rsName")
return@submit null
}
val ipStr = ip.toString().trim()
val portStr = port.toString().trim()
if (ipStr.isNotEmpty() && portStr.isNotEmpty()) {
"http://$ipStr:$portStr"
} else {
Log.w(TAG, "ServerToLocal 返回空 ip/port")
null
}
}
future.get(CONVERT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
} catch (e: Exception) {
Log.e(TAG, "ServerToLocal 异常: ${e.message}")
null
} finally {
executor.shutdown()
}
}
}

View File

@@ -14,7 +14,10 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.demo.SellyCloudSDK.KiwiHelper
import com.demo.SellyCloudSDK.R import com.demo.SellyCloudSDK.R
import kotlinx.coroutines.launch
import com.demo.SellyCloudSDK.beauty.FURenderer import com.demo.SellyCloudSDK.beauty.FURenderer
import com.demo.SellyCloudSDK.beauty.FuVideoFrameInterceptor import com.demo.SellyCloudSDK.beauty.FuVideoFrameInterceptor
import com.demo.SellyCloudSDK.databinding.ActivityInteractiveLiveBinding import com.demo.SellyCloudSDK.databinding.ActivityInteractiveLiveBinding
@@ -182,7 +185,10 @@ class InteractiveLiveActivity : AppCompatActivity() {
private fun initRtcEngine() { private fun initRtcEngine() {
val appId = getString(R.string.signaling_app_id) val appId = getString(R.string.signaling_app_id)
val token = getString(R.string.signaling_token).takeIf { it.isNotBlank() } val token = getString(R.string.signaling_token).takeIf { it.isNotBlank() }
// Kiwi 代理后台获取rsName 为空时清除残留
val kiwiRsName = getString(R.string.signaling_kiwi_rsname).trim() val kiwiRsName = getString(R.string.signaling_kiwi_rsname).trim()
KiwiHelper.startProxySetup(kiwiRsName.isNotBlank(), kiwiRsName)
beautyRenderer = FURenderer(this).also { it.setup() } beautyRenderer = FURenderer(this).also { it.setup() }
fuFrameInterceptor = beautyRenderer?.let { FuVideoFrameInterceptor(it).apply { fuFrameInterceptor = beautyRenderer?.let { FuVideoFrameInterceptor(it).apply {
setFrontCamera(isFrontCamera) setFrontCamera(isFrontCamera)
@@ -192,8 +198,7 @@ class InteractiveLiveActivity : AppCompatActivity() {
InteractiveRtcEngineConfig( InteractiveRtcEngineConfig(
context = applicationContext, context = applicationContext,
appId = appId, appId = appId,
defaultToken = token, defaultToken = token
kiwiRsName = kiwiRsName
) )
).apply { ).apply {
setEventHandler(rtcEventHandler) setEventHandler(rtcEventHandler)
@@ -596,6 +601,15 @@ class InteractiveLiveActivity : AppCompatActivity() {
private fun executeJoin(request: JoinRequest) { private fun executeJoin(request: JoinRequest) {
pendingJoinRequest = null pendingJoinRequest = null
InteractiveForegroundService.start(this) InteractiveForegroundService.start(this)
// 立即禁用按钮,防止 await 期间重复点击
setJoinButtonEnabled(false)
lifecycleScope.launch {
KiwiHelper.awaitProxyReady()
executeJoinInternal(request)
}
}
private fun executeJoinInternal(request: JoinRequest) {
val renderer = localRenderer ?: createRenderer().also { val renderer = localRenderer ?: createRenderer().also {
localRenderer = it localRenderer = it
} }

View File

@@ -27,9 +27,11 @@ import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.appcompat.widget.AppCompatTextView import androidx.appcompat.widget.AppCompatTextView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import coil.load import coil.load
import com.demo.SellyCloudSDK.KiwiHelper
import com.demo.SellyCloudSDK.R import com.demo.SellyCloudSDK.R
import com.demo.SellyCloudSDK.databinding.ActivityLivePlayBinding import com.demo.SellyCloudSDK.databinding.ActivityLivePlayBinding
import com.demo.SellyCloudSDK.live.auth.LiveAuthHelper import com.demo.SellyCloudSDK.live.auth.LiveAuthHelper
@@ -209,7 +211,10 @@ class LivePlayActivity : AppCompatActivity() {
playerClient.attachRenderView(binding.renderContainer) playerClient.attachRenderView(binding.renderContainer)
if (args.autoStart) { if (args.autoStart) {
startPlayback() lifecycleScope.launch {
KiwiHelper.awaitProxyReady()
startPlayback()
}
} }
} }
@@ -258,7 +263,10 @@ class LivePlayActivity : AppCompatActivity() {
if (currentState == SellyPlayerState.Paused) { if (currentState == SellyPlayerState.Paused) {
playerClient.play() playerClient.play()
} else { } else {
startPlayback() lifecycleScope.launch {
KiwiHelper.awaitProxyReady()
startPlayback()
}
} }
} }

View File

@@ -20,9 +20,11 @@ import android.view.WindowManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.demo.SellyCloudSDK.KiwiHelper
import com.demo.SellyCloudSDK.avdemo.AvDemoSettings import com.demo.SellyCloudSDK.avdemo.AvDemoSettings
import com.demo.SellyCloudSDK.avdemo.AvDemoSettingsStore import com.demo.SellyCloudSDK.avdemo.AvDemoSettingsStore
import com.demo.SellyCloudSDK.beauty.FaceUnityBeautyEngine import com.demo.SellyCloudSDK.beauty.FaceUnityBeautyEngine
@@ -129,31 +131,34 @@ class LivePushActivity : AppCompatActivity() {
if (isPublishing) { if (isPublishing) {
pusher.stopLive() pusher.stopLive()
} else { } else {
val settings = settingsStore.read() lifecycleScope.launch {
applyStreamConfig(settings) KiwiHelper.awaitProxyReady()
pusher.setXorKey(settings.xorKeyHex) val settings = settingsStore.read()
if (settings.useUrlMode) { applyStreamConfig(settings)
val pushUrl = settings.pushUrl pusher.setXorKey(settings.xorKeyHex)
if (pushUrl.isEmpty()) { if (settings.useUrlMode) {
Toast.makeText(this, "请先在设置中输入推流地址", Toast.LENGTH_SHORT).show() val pushUrl = settings.pushUrl
return@setOnClickListener if (pushUrl.isEmpty()) {
Toast.makeText(this@LivePushActivity, "请先在设置中输入推流地址", Toast.LENGTH_SHORT).show()
return@launch
}
pusher.startLiveWithUrl(pushUrl)
} else {
val env = envStore.read()
val streamId = settings.streamId
val authError = LiveAuthHelper.validateAuthConfig(env, streamId)
if (authError != null) {
Toast.makeText(this@LivePushActivity, authError, Toast.LENGTH_SHORT).show()
return@launch
}
val auth = LiveAuthHelper.buildAuthParams(
env = env,
channelId = streamId,
type = LiveTokenSigner.TokenType.PUSH
)
pusher.token = auth?.tokenResult?.token
pusher.startLiveWithStreamId(streamId)
} }
pusher.startLiveWithUrl(pushUrl)
} else {
val env = envStore.read()
val streamId = settings.streamId
val authError = LiveAuthHelper.validateAuthConfig(env, streamId)
if (authError != null) {
Toast.makeText(this, authError, Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
val auth = LiveAuthHelper.buildAuthParams(
env = env,
channelId = streamId,
type = LiveTokenSigner.TokenType.PUSH
)
pusher.token = auth?.tokenResult?.token
pusher.startLiveWithStreamId(streamId)
} }
} }
} }

View File

@@ -22,7 +22,10 @@ import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.AppCompatTextView import androidx.appcompat.widget.AppCompatTextView
import androidx.lifecycle.lifecycleScope
import com.demo.SellyCloudSDK.KiwiHelper
import com.demo.SellyCloudSDK.R import com.demo.SellyCloudSDK.R
import kotlinx.coroutines.launch
import com.demo.SellyCloudSDK.databinding.ActivityPkPlayBinding import com.demo.SellyCloudSDK.databinding.ActivityPkPlayBinding
import com.demo.SellyCloudSDK.live.auth.LiveAuthHelper import com.demo.SellyCloudSDK.live.auth.LiveAuthHelper
import com.demo.SellyCloudSDK.live.auth.LiveTokenSigner import com.demo.SellyCloudSDK.live.auth.LiveTokenSigner
@@ -184,7 +187,10 @@ class PkPlayActivity : AppCompatActivity() {
binding.actionMute.setOnClickListener { toggleMute() } binding.actionMute.setOnClickListener { toggleMute() }
if (args.autoStart) { if (args.autoStart) {
startPlayback() lifecycleScope.launch {
KiwiHelper.awaitProxyReady()
startPlayback()
}
} }
} }
@@ -371,7 +377,10 @@ class PkPlayActivity : AppCompatActivity() {
if (mainPaused) mainPlayer.play() if (mainPaused) mainPlayer.play()
if (pkPaused) pkPlayer.play() if (pkPaused) pkPlayer.play()
} else { } else {
startPlayback() lifecycleScope.launch {
KiwiHelper.awaitProxyReady()
startPlayback()
}
} }
} }
} }

View File

@@ -1,11 +1,13 @@
package com.demo.SellyCloudSDK.live.env package com.demo.SellyCloudSDK.live.env
import android.content.Context import android.content.Context
import com.demo.SellyCloudSDK.KiwiHelper
import com.sellycloud.sellycloudsdk.SellyCloudConfig import com.sellycloud.sellycloudsdk.SellyCloudConfig
import com.sellycloud.sellycloudsdk.SellyCloudManager import com.sellycloud.sellycloudsdk.SellyCloudManager
import com.sellycloud.sellycloudsdk.SellyLiveMode import com.sellycloud.sellycloudsdk.SellyLiveMode
fun LiveEnvSettings.applyToSdkRuntimeConfig(context: Context) { fun LiveEnvSettings.applyToSdkRuntimeConfig(context: Context) {
// 1. SDK 初始化(同步,轻量)
SellyCloudManager.initialize( SellyCloudManager.initialize(
context = context, context = context,
appId = appId, appId = appId,
@@ -15,12 +17,13 @@ fun LiveEnvSettings.applyToSdkRuntimeConfig(context: Context) {
vhost = normalizedVhost(), vhost = normalizedVhost(),
vhostKey = vhostKey, vhostKey = vhostKey,
defaultStreamId = defaultStreamId, defaultStreamId = defaultStreamId,
enableKiwi = enableKiwi,
kiwiRsName = kiwiRsName,
logEnabled = logEnabled, logEnabled = logEnabled,
defaultLiveMode = protocol.toLiveMode() defaultLiveMode = protocol.toLiveMode()
) )
) )
// 2. 启动代理获取:内部受控 scope、cancel 旧 Job
// 不阻塞主线程,关键 start 点通过 awaitProxyReady() 保证就绪
KiwiHelper.startProxySetup(enableKiwi, kiwiRsName)
} }
fun LiveEnvSettings.normalizedAppName(): String = normalizedAppId() fun LiveEnvSettings.normalizedAppName(): String = normalizedAppId()