添加游戏盾代理支持

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

@@ -18,6 +18,7 @@
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application
android:name=".DemoApplication"
android:allowBackup="true"
android:label="SellyCloudRTC Demo"
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.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
}

View File

@@ -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()
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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()