新增服务器重连机制,由于ts服务端不支持断线重连机制,本机制是新建与服务端连接的伪重连

This commit is contained in:
sansen
2026-07-23 23:54:01 +08:00
parent d961c9ec2d
commit 2898d9d874
17 changed files with 691 additions and 171 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.tsmobile.app"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
versionCode = 4
versionName = "1.0.4"
}
signingConfigs {
@@ -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 不是 SynchronizedViewModel 被系统回收后重建),
// 重新触发首次同步,否则频道列表页会永远停在加载态。
// 仅在连接已建立或正在重连时才同步,避免断联后无用的同步尝试。
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
}