diff --git a/docs/SellySDK_直播推拉流接入文档_Android.md b/docs/SellySDK_直播推拉流接入文档_Android.md index 9bfe549..d99972c 100644 --- a/docs/SellySDK_直播推拉流接入文档_Android.md +++ b/docs/SellySDK_直播推拉流接入文档_Android.md @@ -1,6 +1,6 @@ # Selly Live SDK 推拉流接入文档(Android) -> 统一 SDK 名称:**SellyCloudSDK** +> 统一 SDK 名称:**SellyCloudSDK** > 本文档适用于 Android 客户端,面向对外集成方与内部使用。 --- @@ -19,6 +19,7 @@ Selly Live SDK 提供完整的音视频直播能力,支持 **推流(直播 - 支持视频帧处理(美颜 / 滤镜 / 水印) - 基于 **Token 的安全鉴权机制** - 支持 **RTMP H264 + AAC payload XOR 保护(可选)** +- 支持 **外部代理地址注入**(如洋葱盾等第三方安全代理) --- @@ -34,8 +35,8 @@ Selly Live SDK 提供完整的音视频直播能力,支持 **推流(直播 ### 3.1 项目结构参考 -- `example/`:Android Demo 工程 - - 推流示例:`example/src/main/java/com/demo/SellyCloudSDK/live/LivePushActivity.kt` +- `example/`:Android Demo 工程 + - 推流示例:`example/src/main/java/com/demo/SellyCloudSDK/live/LivePushActivity.kt` - 拉流示例:`example/src/main/java/com/demo/SellyCloudSDK/live/LivePlayActivity.kt` - `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 推拉流、RTC(WHEP/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 - 直接使用 RTMP 地址推/拉流不需要 Token,可不设置 -### 4.2 Token 设置时机(强约束) +### 5.2 Token 设置时机(强约束) #### 推流 @@ -91,9 +164,9 @@ dependencies { - `prepareToPlay()` - `play()` -> ⚠️ 在连接建立后修改 Token,不会影响当前连接。 +> 在连接建立后修改 Token,不会影响当前连接。 -### 4.3 Token 刷新机制说明 +### 5.3 Token 刷新机制说明 - SDK **不提供自动刷新** - 业务层可在任意时刻 **重新设置 token 属性** @@ -104,7 +177,7 @@ dependencies { 2. 调用 `pusher.token = newToken` / `player.token = newToken` 3. 停止并重新开始推流 / 拉流流程 -### 4.4 RTMP Payload XOR 保护(可选) +### 5.4 RTMP Payload XOR 保护(可选) 用途: @@ -131,9 +204,9 @@ Key 格式: --- -## 5. 推流接入详解 +## 6. 推流接入详解 -### 5.1 创建推流实例 +### 6.1 创建推流实例 ```kotlin val pusher = SellyLiveVideoPusher.initWithLiveMode( @@ -147,7 +220,7 @@ pusher.delegate = object : SellyLiveVideoPusherDelegate { } ``` -### 5.2 视频参数配置与预览 +### 6.2 视频参数配置与预览 ```kotlin val config = SellyLiveVideoConfiguration.defaultConfiguration().apply { @@ -166,7 +239,7 @@ pusher.startRunning( ) ``` -### 5.3 设置推流 Token(使用 streamId 时) +### 6.3 设置推流 Token(使用 streamId 时) ```kotlin pusher.token = pushToken @@ -183,7 +256,7 @@ pusher.setXorKey(xorKeyHex) > 若在推流中修改 key,需停止并重新开始推流后才会使用新 key。 -### 5.4 开始/停止推流 +### 6.4 开始/停止推流 ```kotlin pusher.startLiveWithStreamId(streamId) @@ -212,7 +285,7 @@ pusher.stopLive { error -> } ``` -### 5.5 常用控制接口 +### 6.5 常用控制接口 - `setMuted(true/false)`:静音 - `switchCameraPosition(...)`:切换摄像头 @@ -226,7 +299,7 @@ pusher.stopLive { error -> - `setBeautyLevel(level)`:设置美颜强度 - `setBitmapAsVideoSource(...)` / `restoreCameraVideoSource()`:背景图推流 -### 5.6 生命周期建议 +### 6.6 生命周期建议 在宿主 Activity 中对齐生命周期: @@ -234,7 +307,7 @@ pusher.stopLive { error -> - `onPause()` → `pusher.onPause()` - `onDestroy()` → `pusher.release()` -### 5.7 状态与统计回调 +### 6.7 状态与统计回调 **状态枚举:** @@ -252,7 +325,7 @@ pusher.stopLive { error -> - rttMs - 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 val player = SellyLiveVideoPlayer.initWithStreamId( @@ -323,14 +396,14 @@ val player = SellyLiveVideoPlayer.initWithStreamId( > 使用 RTMP 加密流时,请在创建播放器时传入 `xorKeyHex`;后续如需换 key,请重建播放器实例。 -### 6.2 设置拉流 Token(使用 streamId 时) +### 7.2 设置拉流 Token(使用 streamId 时) ```kotlin player.token = playToken ``` > 直接使用 RTMP 地址拉流不需要 Token,可不设置。 -### 6.3 播放流程 +### 7.3 播放流程 ```kotlin player.attachRenderView(renderContainer) @@ -351,7 +424,7 @@ player.play() - `setRenderView(view)`:手动指定渲染 View - `seekBy(deltaMs)`:播放进度跳转(仅在流支持快进/回放时有效) -### 6.4 播放回调 +### 7.4 播放回调 ```kotlin player.delegate = object : SellyLiveVideoPlayerDelegate { @@ -374,7 +447,7 @@ player.delegate = object : SellyLiveVideoPlayerDelegate { - `Reconnecting` - `Failed` -### 6.5 播放 API 速览(含 Demo 未覆盖) +### 7.5 播放 API 速览(含 Demo 未覆盖) 创建与渲染: @@ -397,7 +470,7 @@ player.delegate = object : SellyLiveVideoPlayerDelegate { --- -## 7. 错误处理与重试建议 +## 8. 错误处理与重试建议 ### Token 错误 @@ -414,24 +487,25 @@ player.delegate = object : SellyLiveVideoPlayerDelegate { --- -## 8. 最佳实践 +## 9. 最佳实践 - 推流前先完成采集预览 - Token 即将过期前提前刷新 - 使用统计回调做质量监控 - 拉流失败避免无限重试 +- 使用代理时,确保在推拉流开始前代理地址已设置完毕 --- -## 9. 常见问题(FAQ) +## 10. 常见问题(FAQ) ### Q1:Token 可以拼接到 URL 吗? -**A:** 不可以。 +**A:** 不可以。 SDK 不解析 URL 中的鉴权信息,所有鉴权均通过 `token` 属性完成。 ### Q2:运行中修改 Token 是否生效? **A:** -运行中修改 Token **不会影响当前已建立的连接**。 +运行中修改 Token **不会影响当前已建立的连接**。 **下次重连或重新启动推流 / 拉流时会使用新的 Token**。 ### Q3:播放器出现黑屏怎么办? @@ -449,3 +523,15 @@ SDK 不解析 URL 中的鉴权信息,所有鉴权均通过 `token` 属性完 - key 格式是否为合法 hex(偶数长度,支持 `0x` 前缀) - 当前是否为 RTMP + H264 + AAC - 变更 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` 展示了洋葱盾的完整接入流程,可作为参考。 diff --git a/docs/SellySDK_音视频通话接入文档_Android.md b/docs/SellySDK_音视频通话接入文档_Android.md index df5d9e6..db6f892 100644 --- a/docs/SellySDK_音视频通话接入文档_Android.md +++ b/docs/SellySDK_音视频通话接入文档_Android.md @@ -8,16 +8,17 @@ SDK 核心以 `InteractiveRtcEngine` 为中心,通过 `InteractiveRtcEngineEve ## 目录 -1. 准备工作 -2. 快速开始 -3. 基础通话流程 -4. 常用功能 -5. 屏幕分享 -6. 视频帧前后处理 -7. 事件回调(EventHandler) -8. 通话统计 -9. Token 机制 -10. 常见问题(FAQ) +1. 准备工作 +2. 快速开始 +3. 基础通话流程 +4. 常用功能 +5. 屏幕分享 +6. 视频帧前后处理 +7. 事件回调(EventHandler) +8. 通话统计 +9. Token 机制 +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 val appId = getString(R.string.signaling_app_id) val token = getString(R.string.signaling_token).takeIf { it.isNotBlank() } -val kiwiRsName = getString(R.string.signaling_kiwi_rsname).trim() val rtcEngine = InteractiveRtcEngine.create( InteractiveRtcEngineConfig( context = applicationContext, appId = appId, - defaultToken = token, - kiwiRsName = kiwiRsName + defaultToken = token ) ).apply { 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 val localRenderer = SurfaceViewRenderer(this) @@ -82,7 +115,7 @@ val remoteRenderer = SurfaceViewRenderer(this) rtcEngine.setupRemoteVideo(InteractiveVideoCanvas(remoteRenderer, remoteUserId)) ``` -### 3. 加入通话 +### 5. 加入通话 ```kotlin val options = InteractiveChannelMediaOptions(callType = CallType.ONE_TO_ONE) @@ -109,23 +142,9 @@ rtcEngine.leaveChannel() InteractiveRtcEngine.destroy(rtcEngine) ``` -### 4. 进阶配置(Demo 未覆盖) +### 6. 进阶配置(Demo 未覆盖) -#### 4.1 InteractiveRtcEngineConfig 高级字段 - -```kotlin -val config = InteractiveRtcEngineConfig( - context = applicationContext, - appId = appId, - defaultCallType = CallType.ONE_TO_ONE, - defaultToken = token, - kiwiRsName = kiwiRsName, - signalingUrlPrefix = "https://", - signalingUrlSuffix = "/signaling" -) -``` - -#### 4.2 InteractiveChannelMediaOptions 订阅控制 +#### 6.1 InteractiveChannelMediaOptions 订阅控制 ```kotlin 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` -2. 设置 `EventHandler` -3. 配置 `InteractiveVideoEncoderConfig` -4. 设置本地画布 `setupLocalVideo` -5. `joinChannel` 加入频道 -6. `onUserJoined` 后设置远端画布 -7. 通话中进行音视频控制 -8. `leaveChannel` 并释放资源 +1. 初始化 SDK(`SellyCloudManager.initialize`) +2. 设置代理地址(可选,`SellyCloudManager.setProxyAddress`) +3. 创建 `InteractiveRtcEngine` +4. 设置 `EventHandler` +5. 配置 `InteractiveVideoEncoderConfig` +6. 设置本地画布 `setupLocalVideo` +7. `joinChannel` 加入频道 +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` --- @@ -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 +// 阶段 1:Application.onCreate() 异步初始化 +KiwiHelper.initializeAsync() + +// 阶段 2:Activity 初始化时启动代理获取(非阻塞) +KiwiHelper.startProxySetup(enableKiwi = true, rsName = "your-rs-name") + +// 阶段 3:joinChannel 前确保代理已就绪 +lifecycleScope.launch { + KiwiHelper.awaitProxyReady() + rtcEngine.joinChannel(...) +} +``` + +> `KiwiHelper` 内部通过 `SellyCloudManager.setProxyAddress()` 将代理地址传给 SDK。 +> 详见 `example/src/main/java/com/demo/SellyCloudSDK/KiwiHelper.kt`。 + +--- + ## 更多 API 速览(含 Demo 未覆盖) 引擎创建与销毁: @@ -328,6 +404,12 @@ rtcEngine.renewToken(newToken, expiresAtSec) - `InteractiveRtcEngine.create(config)`:创建引擎 - `InteractiveRtcEngine.destroy(engine)` / `engine.destroy()`:释放引擎 +SDK 初始化与代理: + +- `SellyCloudManager.initialize(context, appId, config)`:初始化 SDK +- `SellyCloudManager.setProxyAddress(address)`:设置代理地址 +- `SellyCloudManager.getProxyAddress()`:获取当前代理地址 + 通话控制: - `setEventHandler(handler)`:设置事件回调 @@ -369,7 +451,11 @@ rtcEngine.renewToken(newToken, expiresAtSec) 1. 检查 `signaling_app_id` 是否正确 2. Token 是否为空或已过期 3. 网络是否受限 +4. 若使用代理,检查代理地址是否已正确设置 ### Q:屏幕分享失败? 1. 是否已获取 `MediaProjection` 授权 2. Android 14+ 是否启动前台服务 + +### Q:如何接入代理/加速服务? +SDK 本身不集成任何第三方代理 SDK。业务方需在外部完成代理初始化,获取本地代理地址后,通过 `SellyCloudManager.setProxyAddress()` 注入。详见「代理地址配置」章节。 diff --git a/example/build.gradle b/example/build.gradle index 39316e7..8bc321a 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -67,7 +67,8 @@ dependencies { implementation files( sdkAarPath, "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 'androidx.appcompat:appcompat:1.7.0-alpha03' diff --git a/example/libs/Kiwi.aar b/example/libs/Kiwi.aar new file mode 100644 index 0000000..f36e8ea Binary files /dev/null and b/example/libs/Kiwi.aar differ diff --git a/example/libs/sellycloudsdk-1.0.0.aar b/example/libs/sellycloudsdk-1.0.0.aar index 5869442..ebbd862 100644 Binary files a/example/libs/sellycloudsdk-1.0.0.aar and b/example/libs/sellycloudsdk-1.0.0.aar differ diff --git a/example/src/main/AndroidManifest.xml b/example/src/main/AndroidManifest.xml index e4b9530..1d88338 100644 --- a/example/src/main/AndroidManifest.xml +++ b/example/src/main/AndroidManifest.xml @@ -18,6 +18,7 @@ () + + /** 内部受控 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 SDK(Application.onCreate 调用,只调一次) + */ + fun initializeAsync() { + val executor = Executors.newSingleThreadExecutor() + val future = executor.submit { 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 { + 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() + } + } +} diff --git a/example/src/main/java/com/demo/SellyCloudSDK/interactive/InteractiveLiveActivity.kt b/example/src/main/java/com/demo/SellyCloudSDK/interactive/InteractiveLiveActivity.kt index 10d8993..2129fee 100644 --- a/example/src/main/java/com/demo/SellyCloudSDK/interactive/InteractiveLiveActivity.kt +++ b/example/src/main/java/com/demo/SellyCloudSDK/interactive/InteractiveLiveActivity.kt @@ -14,7 +14,10 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import com.demo.SellyCloudSDK.KiwiHelper import com.demo.SellyCloudSDK.R +import kotlinx.coroutines.launch import com.demo.SellyCloudSDK.beauty.FURenderer import com.demo.SellyCloudSDK.beauty.FuVideoFrameInterceptor import com.demo.SellyCloudSDK.databinding.ActivityInteractiveLiveBinding @@ -182,7 +185,10 @@ class InteractiveLiveActivity : AppCompatActivity() { private fun initRtcEngine() { val appId = getString(R.string.signaling_app_id) val token = getString(R.string.signaling_token).takeIf { it.isNotBlank() } + // Kiwi 代理后台获取,rsName 为空时清除残留 val kiwiRsName = getString(R.string.signaling_kiwi_rsname).trim() + KiwiHelper.startProxySetup(kiwiRsName.isNotBlank(), kiwiRsName) + beautyRenderer = FURenderer(this).also { it.setup() } fuFrameInterceptor = beautyRenderer?.let { FuVideoFrameInterceptor(it).apply { setFrontCamera(isFrontCamera) @@ -192,8 +198,7 @@ class InteractiveLiveActivity : AppCompatActivity() { InteractiveRtcEngineConfig( context = applicationContext, appId = appId, - defaultToken = token, - kiwiRsName = kiwiRsName + defaultToken = token ) ).apply { setEventHandler(rtcEventHandler) @@ -596,6 +601,15 @@ class InteractiveLiveActivity : AppCompatActivity() { private fun executeJoin(request: JoinRequest) { pendingJoinRequest = null InteractiveForegroundService.start(this) + // 立即禁用按钮,防止 await 期间重复点击 + setJoinButtonEnabled(false) + lifecycleScope.launch { + KiwiHelper.awaitProxyReady() + executeJoinInternal(request) + } + } + + private fun executeJoinInternal(request: JoinRequest) { val renderer = localRenderer ?: createRenderer().also { localRenderer = it } diff --git a/example/src/main/java/com/demo/SellyCloudSDK/live/LivePlayActivity.kt b/example/src/main/java/com/demo/SellyCloudSDK/live/LivePlayActivity.kt index d9beb72..c62a3dd 100644 --- a/example/src/main/java/com/demo/SellyCloudSDK/live/LivePlayActivity.kt +++ b/example/src/main/java/com/demo/SellyCloudSDK/live/LivePlayActivity.kt @@ -27,9 +27,11 @@ import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope import androidx.appcompat.widget.AppCompatTextView import androidx.core.content.ContextCompat import coil.load +import com.demo.SellyCloudSDK.KiwiHelper import com.demo.SellyCloudSDK.R import com.demo.SellyCloudSDK.databinding.ActivityLivePlayBinding import com.demo.SellyCloudSDK.live.auth.LiveAuthHelper @@ -209,7 +211,10 @@ class LivePlayActivity : AppCompatActivity() { playerClient.attachRenderView(binding.renderContainer) if (args.autoStart) { - startPlayback() + lifecycleScope.launch { + KiwiHelper.awaitProxyReady() + startPlayback() + } } } @@ -258,7 +263,10 @@ class LivePlayActivity : AppCompatActivity() { if (currentState == SellyPlayerState.Paused) { playerClient.play() } else { - startPlayback() + lifecycleScope.launch { + KiwiHelper.awaitProxyReady() + startPlayback() + } } } diff --git a/example/src/main/java/com/demo/SellyCloudSDK/live/LivePushActivity.kt b/example/src/main/java/com/demo/SellyCloudSDK/live/LivePushActivity.kt index d833803..e3d441d 100644 --- a/example/src/main/java/com/demo/SellyCloudSDK/live/LivePushActivity.kt +++ b/example/src/main/java/com/demo/SellyCloudSDK/live/LivePushActivity.kt @@ -20,9 +20,11 @@ import android.view.WindowManager import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintSet import androidx.core.content.ContextCompat +import com.demo.SellyCloudSDK.KiwiHelper import com.demo.SellyCloudSDK.avdemo.AvDemoSettings import com.demo.SellyCloudSDK.avdemo.AvDemoSettingsStore import com.demo.SellyCloudSDK.beauty.FaceUnityBeautyEngine @@ -129,31 +131,34 @@ class LivePushActivity : AppCompatActivity() { if (isPublishing) { pusher.stopLive() } else { - val settings = settingsStore.read() - applyStreamConfig(settings) - pusher.setXorKey(settings.xorKeyHex) - if (settings.useUrlMode) { - val pushUrl = settings.pushUrl - if (pushUrl.isEmpty()) { - Toast.makeText(this, "请先在设置中输入推流地址", Toast.LENGTH_SHORT).show() - return@setOnClickListener + lifecycleScope.launch { + KiwiHelper.awaitProxyReady() + val settings = settingsStore.read() + applyStreamConfig(settings) + pusher.setXorKey(settings.xorKeyHex) + if (settings.useUrlMode) { + val pushUrl = settings.pushUrl + 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) } } } diff --git a/example/src/main/java/com/demo/SellyCloudSDK/live/PkPlayActivity.kt b/example/src/main/java/com/demo/SellyCloudSDK/live/PkPlayActivity.kt index 5595821..d9e8058 100644 --- a/example/src/main/java/com/demo/SellyCloudSDK/live/PkPlayActivity.kt +++ b/example/src/main/java/com/demo/SellyCloudSDK/live/PkPlayActivity.kt @@ -22,7 +22,10 @@ import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.AppCompatTextView +import androidx.lifecycle.lifecycleScope +import com.demo.SellyCloudSDK.KiwiHelper import com.demo.SellyCloudSDK.R +import kotlinx.coroutines.launch import com.demo.SellyCloudSDK.databinding.ActivityPkPlayBinding import com.demo.SellyCloudSDK.live.auth.LiveAuthHelper import com.demo.SellyCloudSDK.live.auth.LiveTokenSigner @@ -184,7 +187,10 @@ class PkPlayActivity : AppCompatActivity() { binding.actionMute.setOnClickListener { toggleMute() } if (args.autoStart) { - startPlayback() + lifecycleScope.launch { + KiwiHelper.awaitProxyReady() + startPlayback() + } } } @@ -371,7 +377,10 @@ class PkPlayActivity : AppCompatActivity() { if (mainPaused) mainPlayer.play() if (pkPaused) pkPlayer.play() } else { - startPlayback() + lifecycleScope.launch { + KiwiHelper.awaitProxyReady() + startPlayback() + } } } } diff --git a/example/src/main/java/com/demo/SellyCloudSDK/live/env/LiveEnvExtensions.kt b/example/src/main/java/com/demo/SellyCloudSDK/live/env/LiveEnvExtensions.kt index 4f0a149..fc053c5 100644 --- a/example/src/main/java/com/demo/SellyCloudSDK/live/env/LiveEnvExtensions.kt +++ b/example/src/main/java/com/demo/SellyCloudSDK/live/env/LiveEnvExtensions.kt @@ -1,11 +1,13 @@ package com.demo.SellyCloudSDK.live.env import android.content.Context +import com.demo.SellyCloudSDK.KiwiHelper import com.sellycloud.sellycloudsdk.SellyCloudConfig import com.sellycloud.sellycloudsdk.SellyCloudManager import com.sellycloud.sellycloudsdk.SellyLiveMode fun LiveEnvSettings.applyToSdkRuntimeConfig(context: Context) { + // 1. SDK 初始化(同步,轻量) SellyCloudManager.initialize( context = context, appId = appId, @@ -15,12 +17,13 @@ fun LiveEnvSettings.applyToSdkRuntimeConfig(context: Context) { vhost = normalizedVhost(), vhostKey = vhostKey, defaultStreamId = defaultStreamId, - enableKiwi = enableKiwi, - kiwiRsName = kiwiRsName, logEnabled = logEnabled, defaultLiveMode = protocol.toLiveMode() ) ) + // 2. 启动代理获取:内部受控 scope、cancel 旧 Job + // 不阻塞主线程,关键 start 点通过 awaitProxyReady() 保证就绪 + KiwiHelper.startProxySetup(enableKiwi, kiwiRsName) } fun LiveEnvSettings.normalizedAppName(): String = normalizedAppId()