新增服务器重连机制,由于ts服务端不支持断线重连机制,本机制是新建与服务端连接的伪重连
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
package com.tsmobile.app
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* 网络状态监控器 — 检测网络连接变化。
|
||||
*
|
||||
* 通过 ConnectivityManager.registerDefaultNetworkCallback() 监听网络可用性,
|
||||
* 提供 StateFlow 供 ViewModel 层订阅。核心目的是在网络切换(WiFi ↔ 移动数据)
|
||||
* 时区分"网络丢失导致的断线"与"服务器主动断线",从而触发不同的重连策略。
|
||||
*
|
||||
* 用法:
|
||||
* - start() / stop() 控制生命周期(在 ViewModel init/onCleared 中调用)
|
||||
* - isAvailable 实时反映当前网络状态
|
||||
* - consumeNetworkLossDisconnect() 在 onDisconnected 回调中调用,判断是否应自动重连
|
||||
* - reset() 在手动断开/放弃重连时清除追踪状态
|
||||
*/
|
||||
class NetworkMonitor(context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NetworkMonitor"
|
||||
/** 网络丢失后多长时间内触发的 onDisconnected 视为网络原因 */
|
||||
private const val RECENT_LOSS_WINDOW_MS = 60_000L
|
||||
}
|
||||
|
||||
private val connectivityManager =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
/** 当前网络是否可用 */
|
||||
private val _isAvailable = MutableStateFlow(true) // 乐观假设:初始时网络可用
|
||||
val isAvailable: StateFlow<Boolean> = _isAvailable.asStateFlow()
|
||||
|
||||
/** 网络变化计数器:任何网络回调触发时自增(用于检测 isAvailable 不变但底层网络已切换的场景) */
|
||||
private val _networkChangeCount = MutableStateFlow(0L)
|
||||
val networkChangeCount: StateFlow<Long> = _networkChangeCount.asStateFlow()
|
||||
|
||||
/** 最近是否检测到网络丢失(60 秒窗口内) */
|
||||
private val _wasNetworkLostRecently = MutableStateFlow(false)
|
||||
val wasNetworkLostRecently: StateFlow<Boolean> = _wasNetworkLostRecently.asStateFlow()
|
||||
|
||||
/** 最近一次网络丢失的时间戳(epoch ms),0 表示未丢失过 */
|
||||
@Volatile
|
||||
private var lastLossTimestamp: Long = 0L
|
||||
|
||||
/** 待处理的失连标记:网络丢失后等待 Go 侧 onDisconnected 确认 */
|
||||
@Volatile
|
||||
private var pendingDisconnectFromLoss: Boolean = false
|
||||
|
||||
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
Log.i(TAG, "Network available")
|
||||
_isAvailable.value = true
|
||||
_networkChangeCount.value += 1
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
Log.w(TAG, "Network lost")
|
||||
_isAvailable.value = false
|
||||
_wasNetworkLostRecently.value = true
|
||||
lastLossTimestamp = System.currentTimeMillis()
|
||||
pendingDisconnectFromLoss = true
|
||||
_networkChangeCount.value += 1
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
capabilities: NetworkCapabilities,
|
||||
) {
|
||||
val hasInternet = capabilities.hasCapability(
|
||||
NetworkCapabilities.NET_CAPABILITY_INTERNET
|
||||
)
|
||||
val wasAvailable = _isAvailable.value
|
||||
_isAvailable.value = hasInternet
|
||||
|
||||
if (!hasInternet && wasAvailable) {
|
||||
// 网络能力降级(例如切换中暂时无 Internet)
|
||||
Log.d(TAG, "Network capabilities degraded: hasInternet=false")
|
||||
lastLossTimestamp = System.currentTimeMillis()
|
||||
pendingDisconnectFromLoss = true
|
||||
}
|
||||
if (hasInternet && !wasAvailable) {
|
||||
Log.d(TAG, "Network capabilities restored: hasInternet=true")
|
||||
}
|
||||
_networkChangeCount.value += 1
|
||||
}
|
||||
}
|
||||
|
||||
/** 开始监听网络变化 */
|
||||
fun start() {
|
||||
try {
|
||||
connectivityManager.registerDefaultNetworkCallback(networkCallback)
|
||||
Log.i(TAG, "Network monitoring started")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to register network callback", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止监听网络变化 */
|
||||
fun stop() {
|
||||
try {
|
||||
connectivityManager.unregisterNetworkCallback(networkCallback)
|
||||
Log.i(TAG, "Network monitoring stopped")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to unregister network callback", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 onDisconnected 回调中调用,判断本次断线是否由网络丢失引起。
|
||||
*
|
||||
* 如果是网络丢失导致的断线,返回 true(应触发自动重连);
|
||||
* 如果是服务器主动断线(如被踢、服务器关闭),返回 false。
|
||||
*
|
||||
* 此方法具有消耗性:调用后内部标志位被重置,重复调用返回 false。
|
||||
*/
|
||||
fun consumeNetworkLossDisconnect(): Boolean {
|
||||
val wasRecent = _wasNetworkLostRecently.value ||
|
||||
(lastLossTimestamp > 0 &&
|
||||
System.currentTimeMillis() - lastLossTimestamp < RECENT_LOSS_WINDOW_MS)
|
||||
if (wasRecent && pendingDisconnectFromLoss) {
|
||||
pendingDisconnectFromLoss = false
|
||||
_wasNetworkLostRecently.value = false
|
||||
Log.i(TAG, "Consumed network-loss disconnect flag: wasRecent=$wasRecent")
|
||||
return true
|
||||
}
|
||||
Log.d(TAG, "consumeNetworkLossDisconnect: wasRecent=$wasRecent, pending=$pendingDisconnectFromLoss → false")
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置所有追踪状态。在以下场景调用:
|
||||
* - 用户主动断开连接
|
||||
* - 放弃重连
|
||||
* - 返回主页
|
||||
*/
|
||||
fun reset() {
|
||||
pendingDisconnectFromLoss = false
|
||||
_wasNetworkLostRecently.value = false
|
||||
lastLossTimestamp = 0L
|
||||
Log.d(TAG, "Network loss tracking state reset")
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,11 @@ sealed class ConnectionState {
|
||||
val reason: String = ""
|
||||
) : ConnectionState()
|
||||
|
||||
/** 等待网络恢复 — 网络丢失后等待连接恢复 */
|
||||
data class WaitingForNetwork(
|
||||
val since: Long = System.currentTimeMillis()
|
||||
) : ConnectionState()
|
||||
|
||||
/** 已断开 — 会话结束 */
|
||||
data class Disconnected(
|
||||
val reason: String = "",
|
||||
|
||||
@@ -59,16 +59,19 @@ object Repository {
|
||||
// 并发执行 channellist/clientlist/clientinfo 会导致数据行错配,频道列表为空。
|
||||
val channels = fetchChannels()
|
||||
val clients = fetchClients()
|
||||
val selfId = TSBridge.getClientID().toInt()
|
||||
var channelId = TSBridge.getChannelID()
|
||||
// 备用方案:如果 GetChannelID() 返回 "0"(GetClientInfo 可能失败),
|
||||
// 从客户端列表中获取自身所在的频道 ID
|
||||
if (channelId == "0" && selfId > 0) {
|
||||
val selfChannel = clients.find { it.id == selfId }?.channelId
|
||||
if (selfChannel != null && selfChannel != "0") {
|
||||
android.util.Log.i("Repository", "performInitialSync: getChannelID returned 0, using client list fallback: $selfChannel")
|
||||
channelId = selfChannel
|
||||
val (selfId, channelId) = withContext(Dispatchers.IO) {
|
||||
val id = TSBridge.getClientID().toInt()
|
||||
var cid = TSBridge.getChannelID()
|
||||
// 备用方案:如果 GetChannelID() 返回 "0"(GetClientInfo 可能失败),
|
||||
// 从客户端列表中获取自身所在的频道 ID
|
||||
if (cid == "0" && id > 0) {
|
||||
val selfChannel = clients.find { it.id == id }?.channelId
|
||||
if (selfChannel != null && selfChannel != "0") {
|
||||
android.util.Log.i("Repository", "performInitialSync: getChannelID returned 0, using client list fallback: $selfChannel")
|
||||
cid = selfChannel
|
||||
}
|
||||
}
|
||||
id to cid
|
||||
}
|
||||
|
||||
android.util.Log.i("Repository", "performInitialSync: fetched ${channels.size} channels, ${clients.size} clients, selfId=$selfId, channelId=$channelId")
|
||||
@@ -139,11 +142,7 @@ object Repository {
|
||||
android.util.Log.w("Repository", "refreshClientList: empty result, skipping update")
|
||||
return
|
||||
}
|
||||
_clients.value = clients
|
||||
clientMap = clients.associateBy { it.id }
|
||||
clientUidMap = clients.associateBy { it.uid }
|
||||
_channelClients.value = clients.groupBy { it.channelId }
|
||||
android.util.Log.d("Repository", "refreshClientList: ${clients.size} clients updated")
|
||||
applyClients(clients, _selfClientId.value)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,6 +160,29 @@ object Repository {
|
||||
_channels.value = channels
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用客户端列表并过滤僵尸会话。
|
||||
*
|
||||
* 重连时旧连接可能尚未超时(僵尸会话),服务器会对同 UID 的新连接自动改名(如 "name" → "name_1")。
|
||||
* 此处按 UID 过滤:保留当前 selfClientId,移除同 UID 但不同 clid 的僵尸条目。
|
||||
*/
|
||||
private fun applyClients(clients: List<ClientInfo>, selfId: Int) {
|
||||
val selfUid = clients.find { it.id == selfId }?.uid
|
||||
val filtered = if (selfUid != null) {
|
||||
clients.filter { it.id == selfId || it.uid != selfUid }
|
||||
} else {
|
||||
clients
|
||||
}
|
||||
if (filtered.size < clients.size) {
|
||||
android.util.Log.i("Repository", "Filtered ${clients.size - filtered.size} zombie session(s) from client list")
|
||||
}
|
||||
_clients.value = filtered
|
||||
clientMap = filtered.associateBy { it.id }
|
||||
clientUidMap = filtered.associateBy { it.uid }
|
||||
_channelClients.value = filtered.groupBy { it.channelId }
|
||||
android.util.Log.d("Repository", "applyClients: ${filtered.size} clients (${clients.size} before filtering)")
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新基线数据。
|
||||
*/
|
||||
@@ -171,14 +193,10 @@ object Repository {
|
||||
currentChannelId: String,
|
||||
) {
|
||||
_channels.value = channels
|
||||
_clients.value = clients
|
||||
clientMap = clients.associateBy { it.id }
|
||||
clientUidMap = clients.associateBy { it.uid }
|
||||
_selfClientId.value = selfClientId
|
||||
_currentChannelId.value = currentChannelId
|
||||
|
||||
// 建立频道-成员索引
|
||||
_channelClients.value = clients.groupBy { it.channelId }
|
||||
applyClients(clients, selfClientId)
|
||||
}
|
||||
|
||||
/** 查询成员是否存在 */
|
||||
|
||||
@@ -29,6 +29,7 @@ fun ConnectionStatusIndicator(
|
||||
val (color, text) = when (connectionState) {
|
||||
is ConnectionState.Connected -> semanticColors.success to "已连接"
|
||||
is ConnectionState.Disconnecting -> semanticColors.warning to "断开中"
|
||||
is ConnectionState.WaitingForNetwork -> semanticColors.warning to "等待网络"
|
||||
is ConnectionState.Reconnecting -> semanticColors.warning to "重连中"
|
||||
is ConnectionState.Disconnected -> colors.error to "已断开"
|
||||
null -> colors.outline to "未连接"
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
package com.tsmobile.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tsmobile.app.data.ConnectionState
|
||||
import com.tsmobile.app.ui.theme.UiTokens
|
||||
import com.tsmobile.app.ui.theme.semanticColors
|
||||
@@ -82,3 +89,177 @@ fun ReconnectBanner(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待网络横幅 — 网络丢失后等待恢复。
|
||||
*
|
||||
* 显示"网络已断开"提示,自动等待网络恢复后重连。
|
||||
* 用户可手动触发重连或放弃等待。
|
||||
*/
|
||||
@Composable
|
||||
fun WaitingForNetworkBanner(
|
||||
onManualReconnect: () -> Unit,
|
||||
onAbandon: () -> Unit,
|
||||
) {
|
||||
val semanticColors = MaterialTheme.semanticColors
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = semanticColors.warningContainer,
|
||||
contentColor = semanticColors.onWarningContainer,
|
||||
tonalElevation = UiTokens.Elevation.Subtle,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
horizontal = UiTokens.Spacing.Large,
|
||||
vertical = UiTokens.Spacing.Medium,
|
||||
),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = semanticColors.warning,
|
||||
modifier = Modifier.size(UiTokens.Size.IconMedium),
|
||||
)
|
||||
Spacer(Modifier.width(UiTokens.Spacing.Small))
|
||||
Text(
|
||||
text = "网络已断开",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(UiTokens.Spacing.ExtraSmall))
|
||||
Text(
|
||||
text = "等待网络恢复后自动重连...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(UiTokens.Spacing.Small))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(onClick = onManualReconnect) {
|
||||
Text("手动重连")
|
||||
}
|
||||
TextButton(
|
||||
onClick = onAbandon,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Text("放弃")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重连全屏遮罩 — 覆盖整个界面,阻止用户操作。
|
||||
*
|
||||
* 在重连/等待网络期间显示,包含加载动画、重连进度、放弃按钮。
|
||||
* 遮罩层消费所有点击事件,防止用户在重连期间触发任何操作。
|
||||
*/
|
||||
@Composable
|
||||
fun ReconnectOverlay(
|
||||
connectionState: ConnectionState,
|
||||
onAbandon: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title: String
|
||||
val subtitle: String
|
||||
val detail: String?
|
||||
|
||||
when (connectionState) {
|
||||
is ConnectionState.WaitingForNetwork -> {
|
||||
title = "网络已断开"
|
||||
subtitle = "等待网络恢复后自动重连..."
|
||||
detail = null
|
||||
}
|
||||
is ConnectionState.Reconnecting -> {
|
||||
title = "正在重连..."
|
||||
subtitle = "第 ${connectionState.attempt}/${connectionState.maxAttempts} 次尝试"
|
||||
detail = connectionState.reason.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
else -> return // 仅在等待网络或重连时显示
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
// 消费所有点击事件,阻止穿透到下层 UI
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.width(280.dp)
|
||||
.padding(horizontal = UiTokens.Spacing.Large),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(UiTokens.Spacing.Large)
|
||||
.heightIn(min = 200.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
|
||||
Spacer(Modifier.height(UiTokens.Spacing.Medium))
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(UiTokens.Spacing.Small))
|
||||
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
// 为 detail 预留固定高度空间,避免文本出现/消失导致卡片跳动
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (detail != null) {
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(UiTokens.Spacing.Large))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.Center) {
|
||||
TextButton(
|
||||
onClick = onAbandon,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Text("放弃")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ private fun ServerInfoSection(serverInfo: ServerInfo?, connectionState: Connecti
|
||||
DetailInfoRow("状态", when (connectionState) {
|
||||
is ConnectionState.Connected -> "已连接"
|
||||
is ConnectionState.Disconnecting -> "断开中..."
|
||||
is ConnectionState.WaitingForNetwork -> "等待网络恢复..."
|
||||
is ConnectionState.Reconnecting -> "重连中 (${connectionState.attempt}/${connectionState.maxAttempts})"
|
||||
is ConnectionState.Disconnected -> "已断开"
|
||||
null -> "未连接"
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.navigation.compose.composable
|
||||
import com.tsmobile.app.data.ConnectionState
|
||||
import com.tsmobile.app.data.Repository
|
||||
import com.tsmobile.app.ui.components.PokeNotification
|
||||
import com.tsmobile.app.ui.components.ReconnectOverlay
|
||||
import com.tsmobile.app.ui.screens.ChannelListScreen
|
||||
import com.tsmobile.app.ui.screens.ChatScreen
|
||||
import com.tsmobile.app.ui.screens.KickedScreen
|
||||
@@ -186,6 +187,17 @@ fun AppNavGraph(
|
||||
}
|
||||
}
|
||||
|
||||
// 重连全屏遮罩(阻止用户操作,覆盖所有页面包括聊天页)
|
||||
val connState = connectionState
|
||||
if (connState is ConnectionState.WaitingForNetwork ||
|
||||
connState is ConnectionState.Reconnecting
|
||||
) {
|
||||
ReconnectOverlay(
|
||||
connectionState = connState,
|
||||
onAbandon = { serverViewModel.abandonReconnect() },
|
||||
)
|
||||
}
|
||||
|
||||
// Poke 气泡通知(全局覆盖层)
|
||||
PokeNotification(
|
||||
pokeEvent = pokeNotification,
|
||||
|
||||
@@ -85,12 +85,17 @@ fun ChannelListScreen(
|
||||
|
||||
// ViewModel 重建保护:如果 syncState 不是 Synchronized(ViewModel 被系统回收后重建),
|
||||
// 重新触发首次同步,否则频道列表页会永远停在加载态。
|
||||
// 仅在连接已建立或正在重连时才同步,避免断联后无用的同步尝试。
|
||||
LaunchedEffect(Unit) {
|
||||
android.util.Log.i("ChannelListScreen", "Screen appeared: syncState=$syncState, " +
|
||||
"channels=${channelViewModel.channels.value.size}, clients=${channelViewModel.clients.value.size}")
|
||||
if (syncState !is SyncState.Synchronized) {
|
||||
"channels=${channelViewModel.channels.value.size}, clients=${channelViewModel.clients.value.size}, " +
|
||||
"connectionState=$connectionState")
|
||||
val canSync = connectionState is ConnectionState.Connected
|
||||
if (syncState !is SyncState.Synchronized && canSync) {
|
||||
android.util.Log.w("ChannelListScreen", "ViewModel was recreated (syncState=$syncState), re-triggering sync")
|
||||
channelViewModel.performInitialSyncWithRetry()
|
||||
} else if (syncState !is SyncState.Synchronized) {
|
||||
android.util.Log.w("ChannelListScreen", "Not connected (connectionState=$connectionState), skipping futile sync")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,15 +133,6 @@ fun ChannelListScreen(
|
||||
onToggleTheme = { serverViewModel.toggleTheme() },
|
||||
)
|
||||
|
||||
// 重连横幅
|
||||
val connState = connectionState
|
||||
if (connState is ConnectionState.Reconnecting) {
|
||||
ReconnectBanner(
|
||||
reconnectState = connState,
|
||||
onManualReconnect = { serverViewModel.manualReconnect() },
|
||||
onAbandon = { serverViewModel.abandonReconnect() },
|
||||
)
|
||||
}
|
||||
|
||||
// 切换状态指示器
|
||||
if (switchState != ChannelSwitchState.Idle) {
|
||||
|
||||
@@ -6,10 +6,12 @@ import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tsmobile.app.TSBridge
|
||||
import com.tsmobile.app.data.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
@@ -142,7 +144,9 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
||||
fun fetchChannelDetail(channelId: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val detailJson = TSBridge.getChannelDetailInfoJSON(channelId)
|
||||
val detailJson = withContext(Dispatchers.IO) {
|
||||
TSBridge.getChannelDetailInfoJSON(channelId)
|
||||
}
|
||||
if (detailJson.isNotEmpty() && detailJson != "{}") {
|
||||
_channelDetailInfo.value = json.decodeFromString<ChannelDetailInfo>(detailJson)
|
||||
}
|
||||
@@ -271,7 +275,9 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
||||
|
||||
try {
|
||||
Log.d(TAG, "Channel list stale, refreshing...")
|
||||
val channelsJson = TSBridge.getChannelsJSON()
|
||||
val channelsJson = withContext(Dispatchers.IO) {
|
||||
TSBridge.getChannelsJSON()
|
||||
}
|
||||
val channels = json.decodeFromString<List<ChannelInfo>>(channelsJson)
|
||||
Repository.updateChannels(channels)
|
||||
lastChannelRefreshTime = System.currentTimeMillis()
|
||||
@@ -508,7 +514,9 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
||||
|
||||
try {
|
||||
// 发送 ClientMove 命令
|
||||
val error = TSBridge.moveToChannel(targetChannelId, password)
|
||||
val error = withContext(Dispatchers.IO) {
|
||||
TSBridge.moveToChannel(targetChannelId, password)
|
||||
}
|
||||
|
||||
if (error.isEmpty()) {
|
||||
// 命令成功。但服务端的 OnClientMoved 通知可能先于命令响应到达
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.tsmobile.app.data.MessageSendState
|
||||
import com.tsmobile.app.data.MessageType
|
||||
import com.tsmobile.app.data.Repository
|
||||
import com.tsmobile.app.data.parseFileMessageMeta
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -18,7 +19,9 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
@@ -26,6 +29,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private const val TAG = "ChatViewModel"
|
||||
/** 消息送达确认超时(毫秒):超时未收到回显则标记为 FAILED */
|
||||
private const val DELIVERY_TIMEOUT_MS = 10_000L
|
||||
/** 系统消息 ID 计数器,避免同毫秒并发调用产生重复 key */
|
||||
private val sysMsgSeq = AtomicLong(0)
|
||||
}
|
||||
|
||||
// 当前会话的消息列表
|
||||
@@ -86,7 +91,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
*/
|
||||
fun addSystemMessage(content: String) {
|
||||
val message = ChatMessage(
|
||||
id = "sys_${System.currentTimeMillis()}",
|
||||
id = "sys_${System.currentTimeMillis()}_${sysMsgSeq.incrementAndGet()}",
|
||||
targetMode = 2,
|
||||
targetId = Repository.currentChannelId.value.toLongOrNull() ?: 0L,
|
||||
senderId = 0,
|
||||
@@ -146,11 +151,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// 2. 异步发送命令
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val error = TSBridge.sendTextMessage(
|
||||
currentTargetMode,
|
||||
currentTargetId.toString(),
|
||||
text
|
||||
)
|
||||
val error = withContext(Dispatchers.IO) {
|
||||
TSBridge.sendTextMessage(
|
||||
currentTargetMode,
|
||||
currentTargetId.toString(),
|
||||
text
|
||||
)
|
||||
}
|
||||
|
||||
if (error.isEmpty()) {
|
||||
Log.d(TAG, "SendTextMessage accepted, waiting for echo")
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tsmobile.app.ConnectionService
|
||||
import com.tsmobile.app.NetworkMonitor
|
||||
import com.tsmobile.app.TSBridge
|
||||
import com.tsmobile.app.data.*
|
||||
import com.tsmobile.app.ui.theme.ThemeMode
|
||||
@@ -145,6 +146,12 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
||||
private var reconnectJob: Job? = null
|
||||
private var lastConnectParams: ConnectParams? = null
|
||||
|
||||
/** 断联前的频道 ID,用于伪重连后自动回到原频道(空字符串=不自动移动) */
|
||||
private var lastChannelId: String = ""
|
||||
|
||||
// ── 网络监控 ──
|
||||
private val networkMonitor = NetworkMonitor(application)
|
||||
|
||||
/** 保存连接参数,供重连使用 */
|
||||
private data class ConnectParams(
|
||||
val host: String,
|
||||
@@ -155,8 +162,55 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
||||
)
|
||||
|
||||
init {
|
||||
// 启动网络监控(持续运行,检测网络切换)
|
||||
networkMonitor.start()
|
||||
|
||||
// 启动时自动检查更新
|
||||
checkForUpdate()
|
||||
|
||||
// 网络变动检测 — 简单原则:
|
||||
// 任何 NetworkCallback 触发 + 已连接 → 网络一定变了 → 必须重连
|
||||
// (UDP socket 绑定在旧 IP 上,即使 isAvailable 不变也已死)
|
||||
//
|
||||
// 两层 collector:
|
||||
// 1. networkChangeCount → 任何网络事件 → Connected 就重连
|
||||
// 2. isAvailable false→true → WaitingForNetwork 时网络恢复 → 重连
|
||||
viewModelScope.launch {
|
||||
var firstEmission = true
|
||||
networkMonitor.networkChangeCount.collect { _ ->
|
||||
if (firstEmission) { firstEmission = false; return@collect }
|
||||
if (_connectionState.value is ConnectionState.Connected) {
|
||||
android.util.Log.w(TAG, "Network changed while connected, reconnecting")
|
||||
val chId = Repository.currentChannelId.value
|
||||
if (chId != "0" && chId.isNotEmpty()) {
|
||||
lastChannelId = chId
|
||||
}
|
||||
_connectionState.value = ConnectionState.WaitingForNetwork()
|
||||
if (networkMonitor.isAvailable.value) {
|
||||
startReconnect("网络变化,正在重连...")
|
||||
}
|
||||
// 若当前不可用 → 等 isAvailable 恢复 → 第二个 collector 触发
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
var wasAvailable = networkMonitor.isAvailable.value
|
||||
networkMonitor.isAvailable.collect { available ->
|
||||
if (available && !wasAvailable &&
|
||||
_connectionState.value is ConnectionState.WaitingForNetwork
|
||||
) {
|
||||
android.util.Log.i(TAG, "Network recovered, starting reconnect")
|
||||
startReconnect("网络变化,正在重连...")
|
||||
}
|
||||
wasAvailable = available
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
networkMonitor.stop()
|
||||
}
|
||||
|
||||
// --- 更新检测 ---
|
||||
@@ -492,7 +546,9 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
||||
fun fetchServerInfo() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val json = TSBridge.getServerInfoJSON()
|
||||
val json = withContext(Dispatchers.IO) {
|
||||
TSBridge.getServerInfoJSON()
|
||||
}
|
||||
if (json.isNotEmpty() && json != "{}") {
|
||||
_serverInfo.value = Json.decodeFromString<ServerInfo>(json)
|
||||
}
|
||||
@@ -508,7 +564,9 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
||||
fun pokeClient(clientId: Int, message: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val error = TSBridge.poke(clientId.toString(), message)
|
||||
val error = withContext(Dispatchers.IO) {
|
||||
TSBridge.poke(clientId.toString(), message)
|
||||
}
|
||||
if (error.isNotEmpty()) {
|
||||
android.util.Log.w(TAG, "Poke failed: $error")
|
||||
} else {
|
||||
@@ -529,7 +587,9 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
||||
fun kickClient(clientId: Int, reasonID: Int, reasonMsg: String = "") {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val error = TSBridge.kickClient(clientId.toString(), reasonID, reasonMsg)
|
||||
val error = withContext(Dispatchers.IO) {
|
||||
TSBridge.kickClient(clientId.toString(), reasonID, reasonMsg)
|
||||
}
|
||||
if (error.isNotEmpty()) {
|
||||
android.util.Log.w(TAG, "Kick failed: $error")
|
||||
}
|
||||
@@ -683,6 +743,9 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
||||
// 0. 停止前台服务
|
||||
ConnectionService.stop(getApplication())
|
||||
|
||||
// 0.5 重置网络监控(主动断线不需要自动重连)
|
||||
networkMonitor.reset()
|
||||
|
||||
// 1. 停止重连
|
||||
reconnectJob?.let { it.cancel() }
|
||||
reconnectJob = null
|
||||
@@ -693,11 +756,13 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
||||
// 3. 停止语音
|
||||
voiceViewModel?.stopVoice()
|
||||
|
||||
// 4. 调用 SDK 断开
|
||||
try {
|
||||
TSBridge.disconnect()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Disconnect error", e)
|
||||
// 4. 调用 SDK 断开(IO 线程,避免阻塞 UI)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
TSBridge.disconnect()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Disconnect error", e)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 清理会话
|
||||
@@ -737,6 +802,8 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
||||
fun backToHome() {
|
||||
reconnectJob?.let { it.cancel() }
|
||||
reconnectJob = null
|
||||
lastChannelId = ""
|
||||
networkMonitor.reset()
|
||||
_kickReason.value = ""
|
||||
_connectionState.value = null
|
||||
lastConnectParams = null
|
||||
@@ -846,9 +913,13 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
||||
reconnectJob = null
|
||||
reconnectAttempt = 0
|
||||
|
||||
lastChannelId = ""
|
||||
networkMonitor.reset()
|
||||
Repository.clearSession()
|
||||
channelViewModel?.clearChannels()
|
||||
_connectionState.value = null
|
||||
_connectionState.value = ConnectionState.Disconnected(
|
||||
reason = "重连已放弃", wasKicked = false
|
||||
)
|
||||
lastConnectParams = null
|
||||
_state.update { it.copy(connectState = ConnectState.IDLE) }
|
||||
}
|
||||
@@ -900,17 +971,36 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
||||
}
|
||||
|
||||
if (syncSuccess) {
|
||||
android.util.Log.i(TAG, "Sync succeeded, navigating to channel list")
|
||||
// 启动前台服务保活,防止锁屏/后台时 Android 杀死进程
|
||||
android.util.Log.i(TAG, "Sync succeeded, starting post-reconnect automation")
|
||||
// 启动前台服务保活
|
||||
ConnectionService.start(
|
||||
getApplication(),
|
||||
_state.value.address.trim(),
|
||||
)
|
||||
_state.update { it.copy(connectState = ConnectState.SUCCESS) }
|
||||
_connectionState.value = ConnectionState.Connected
|
||||
|
||||
// 异步预加载服务器详情,打开详情卡时可直接展示
|
||||
// 异步预加载服务器详情
|
||||
fetchServerInfo()
|
||||
|
||||
// 伪重连:回到断联前的频道(避开僵尸清理窗口)
|
||||
// 注意:此时不设置 Connected —— 遮罩保持显示,防止用户干扰自动操作
|
||||
val targetChannel = lastChannelId
|
||||
lastChannelId = "" // 一次性消费
|
||||
if (targetChannel.isNotEmpty() && targetChannel != "0") {
|
||||
// 等待 4 秒:Go 侧 cleanupDuplicateIdentitySessions 在 3 秒后 Kick 僵尸
|
||||
delay(4_000L)
|
||||
android.util.Log.i(TAG, "Pseudo-reconnect: moving back to channel $targetChannel")
|
||||
val error = withContext(Dispatchers.IO) {
|
||||
TSBridge.moveToChannel(targetChannel, "")
|
||||
}
|
||||
if (error.isNotEmpty()) {
|
||||
android.util.Log.w(TAG, "Pseudo-reconnect moveToChannel failed: $error")
|
||||
}
|
||||
}
|
||||
|
||||
// 所有自动操作完成 → 关闭遮罩,允许用户操作
|
||||
android.util.Log.i(TAG, "Post-reconnect automation complete, showing Connected")
|
||||
_connectionState.value = ConnectionState.Connected
|
||||
} else {
|
||||
android.util.Log.e(TAG, "Sync failed after 3 attempts")
|
||||
_state.update {
|
||||
@@ -946,19 +1036,51 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
||||
return
|
||||
}
|
||||
|
||||
// 已在 WaitingForNetwork 或 Reconnecting 状态 → 我们的响应式监控已处理
|
||||
if (_connectionState.value is ConnectionState.WaitingForNetwork ||
|
||||
_connectionState.value is ConnectionState.Reconnecting
|
||||
) {
|
||||
android.util.Log.d(TAG, "onDisconnected while already handling reconnect, ignoring")
|
||||
return
|
||||
}
|
||||
|
||||
// 语音断开处理
|
||||
voiceViewModel?.onDisconnected()
|
||||
|
||||
// 停止前台服务
|
||||
ConnectionService.stop(getApplication())
|
||||
|
||||
// 自动重连已禁用 — 频繁重连会导致服务器判定为高频攻击并封禁。
|
||||
// 用户可通过手动重连按钮自行重试。
|
||||
Repository.clearSession()
|
||||
channelViewModel?.clearChannels()
|
||||
_connectionState.value = ConnectionState.Disconnected(
|
||||
reason = message, wasKicked = false
|
||||
)
|
||||
// 判断断线原因:网络丢失 vs 服务器主动断线
|
||||
// - ConnectivityManager 明确检测到网络切换 → 网络丢失
|
||||
// - 断线原因为空或 timeout → TCP 静默断开(NAT 超时、运营商丢弃等),
|
||||
// 此时网络接口正常但连接已死,也视为网络丢失
|
||||
// - 服务器主动断线(被踢、封禁等)会有明确的错误消息
|
||||
val isNetworkLoss = networkMonitor.consumeNetworkLossDisconnect() ||
|
||||
message.isEmpty() ||
|
||||
message.contains("timeout", ignoreCase = true)
|
||||
|
||||
if (isNetworkLoss) {
|
||||
// 记录当前频道,供重连后自动回到原频道
|
||||
val currentChId = Repository.currentChannelId.value
|
||||
if (currentChId != "0" && currentChId.isNotEmpty()) {
|
||||
lastChannelId = currentChId
|
||||
android.util.Log.i(TAG, "Saved lastChannelId=$lastChannelId for pseudo-reconnect")
|
||||
}
|
||||
|
||||
// 网络丢失 — 启动重连(保留会话数据,不清除频道/成员信息)
|
||||
// 注:正常情况下响应式监控(init block)已处理,此分支为 Go idle timeout 等兜底
|
||||
android.util.Log.i(TAG, "Network-loss disconnect detected (fallback path)")
|
||||
startReconnect("网络变化,正在重连...")
|
||||
} else {
|
||||
// 服务器主动断线 — 清理会话,回到首页
|
||||
android.util.Log.w(TAG, "Server-initiated disconnect: $message")
|
||||
lastChannelId = ""
|
||||
Repository.clearSession()
|
||||
channelViewModel?.clearChannels()
|
||||
_connectionState.value = ConnectionState.Disconnected(
|
||||
reason = message, wasKicked = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTextMessage(msg: TextMsg) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.tsmobile.app.data.VoiceOutputDevice
|
||||
import com.tsmobile.app.data.VoicePreferences
|
||||
import com.tsmobile.app.data.VoiceState
|
||||
import com.tsmobile.app.voice.VoiceService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -208,7 +209,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val newEnabled = !_speakerEnabled.value
|
||||
_speakerEnabled.value = newEnabled
|
||||
voiceService.setSpeakerEnabled(newEnabled)
|
||||
if (newEnabled) TSBridge.startReceiveAudio() else TSBridge.stopReceiveAudio()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
if (newEnabled) TSBridge.startReceiveAudio() else TSBridge.stopReceiveAudio()
|
||||
}
|
||||
Log.d(TAG, "Speaker toggled: $newEnabled")
|
||||
}
|
||||
|
||||
@@ -238,7 +241,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
/** Removes Go's timeline and local transient mute/speaking state for this client. */
|
||||
fun removeRemoteClient(clientID: Long, moved: Boolean = false) {
|
||||
val clientId = clientID.toInt()
|
||||
TSBridge.removeRemoteAudioClient(clientId)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
TSBridge.removeRemoteAudioClient(clientId)
|
||||
}
|
||||
synchronized(audioStateLock) {
|
||||
_remoteAudioSettings.value = _remoteAudioSettings.value - clientId
|
||||
_speakingClients.value = _speakingClients.value - clientId
|
||||
@@ -249,21 +254,27 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
synchronized(audioStateLock) {
|
||||
_remoteAudioSettings.value = _remoteAudioSettings.value + (clientId to RemoteAudioSettings(muted))
|
||||
}
|
||||
TSBridge.setRemoteClientMuted(clientId, muted)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
TSBridge.setRemoteClientMuted(clientId, muted)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleRemoteClientMuted(clientId: Int) {
|
||||
synchronized(audioStateLock) {
|
||||
val muted = !(_remoteAudioSettings.value[clientId]?.muted ?: false)
|
||||
_remoteAudioSettings.value = _remoteAudioSettings.value + (clientId to RemoteAudioSettings(muted))
|
||||
TSBridge.setRemoteClientMuted(clientId, muted)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
TSBridge.setRemoteClientMuted(clientId, muted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onClientMoved(clientId: Long, targetChannelId: String) {
|
||||
val id = clientId.toInt()
|
||||
if (id == Repository.selfClientId.value) {
|
||||
TSBridge.clearRemoteAudioClients()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
TSBridge.clearRemoteAudioClients()
|
||||
}
|
||||
synchronized(audioStateLock) {
|
||||
_remoteAudioSettings.value = emptyMap()
|
||||
_speakingClients.value = emptyMap()
|
||||
@@ -279,8 +290,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
fun stopVoice() {
|
||||
Log.i(TAG, "stopVoice: stopping all voice activity")
|
||||
stopTransmit()
|
||||
TSBridge.clearRemoteAudioClients()
|
||||
TSBridge.stopReceiveAudio()
|
||||
// Go 调用放到 IO 线程,避免主线程阻塞
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
TSBridge.clearRemoteAudioClients()
|
||||
TSBridge.stopReceiveAudio()
|
||||
}
|
||||
voiceService.stopReceivePlayback("voice stopped")
|
||||
_remoteAudioSettings.value = emptyMap()
|
||||
_speakingClients.value = emptyMap()
|
||||
@@ -291,8 +305,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
fun onDisconnected() {
|
||||
Log.w(TAG, "onDisconnected: voice blocked")
|
||||
endTransmit(VoiceState.Blocked("连接已断开"))
|
||||
TSBridge.clearRemoteAudioClients()
|
||||
TSBridge.stopReceiveAudio()
|
||||
// Go 调用放到 IO 线程,避免阻塞回调线程
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
TSBridge.clearRemoteAudioClients()
|
||||
TSBridge.stopReceiveAudio()
|
||||
}
|
||||
voiceService.stopReceivePlayback("disconnected")
|
||||
_remoteAudioSettings.value = emptyMap()
|
||||
_speakingClients.value = emptyMap()
|
||||
@@ -302,7 +319,10 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
fun onReconnected() {
|
||||
Log.i(TAG, "onReconnected: voice idle")
|
||||
voiceService.enableReceivePlayback()
|
||||
TSBridge.startReceiveAudio()
|
||||
// Go 调用放到 IO 线程,避免阻塞回调线程
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
TSBridge.startReceiveAudio()
|
||||
}
|
||||
_voiceState.value = VoiceState.Idle
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user