5 Commits
Author SHA1 Message Date
sansen 18e2f730e7 1. 改服务详情卡片为侧边滑出卡片
2. 修复重连导致的通知栏信息不变更问题
2026-07-24 11:43:05 +08:00
sansen ebc46233e5 1. 改服务详情卡片为侧边滑出卡片
2. 修复重连导致的通知栏信息不变更问题
2026-07-24 11:42:46 +08:00
sansen 31424f8ff6 修复移动网络下频繁触发重连的问题 2026-07-24 10:41:40 +08:00
sansen 4cc3047a6f 优化保活策略,防止意外断开导致重连 2026-07-24 00:26:42 +08:00
sansen f968708284 优化保活策略,防止意外断开导致重连 2026-07-24 00:25:52 +08:00
7 changed files with 303 additions and 94 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.tsmobile.app" applicationId = "com.tsmobile.app"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 5 versionCode = 8
versionName = "1.0.5" versionName = "1.0.8"
} }
signingConfigs { signingConfigs {
+6
View File
@@ -17,6 +17,12 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- 保活:防止 CPU 休眠导致 UDP socket 无法收发包 -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- 电池优化豁免:防止息屏后 Doze 模式限制网络 -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application <application
android:name=".App" android:name=".App"
android:allowBackup="true" android:allowBackup="true"
@@ -8,18 +8,24 @@ import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.net.Uri
import android.net.wifi.WifiManager
import android.os.Build import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.lifecycle.LifecycleService import androidx.lifecycle.LifecycleService
/** /**
* 前台服务:保持与 TeamSpeak 服务器的连接。 * 前台服务:保 TeamSpeak 连接。
*
* 三层保活策略:
* 1. 前台服务通知 → 防止 Android 杀死进程
* 2. WakeLock (PARTIAL) → 防止 CPU 休眠导致 UDP socket 无法收发包
* 3. WiFi Lock → 防止 WiFi 进入低功耗模式断连
* *
* 连接成功时启动,断开/被踢时停止。 * 连接成功时启动,断开/被踢时停止。
* 通过 startForeground() 显示持续通知,防止 Android 杀死进程。
*
* 使用 FOREGROUND_SERVICE_TYPE_DATA_SYNC 类型,适合网络数据同步场景。
*/ */
class ConnectionService : LifecycleService() { class ConnectionService : LifecycleService() {
@@ -29,13 +35,15 @@ class ConnectionService : LifecycleService() {
private const val NOTIFICATION_ID = 1 private const val NOTIFICATION_ID = 1
const val EXTRA_SERVER_NAME = "server_name" const val EXTRA_SERVER_NAME = "server_name"
/** WakeLock tag */
private const val WAKELOCK_TAG = "tsmobile:connection"
/** /**
* 启动前台服务。 * 启动前台服务。
* @param context 上下文
* @param serverName 服务器地址(显示在通知中)
*/ */
fun start(context: Context, serverName: String) { fun start(context: Context, serverName: String) {
android.util.Log.i(TAG, "Starting connection service for $serverName") android.util.Log.i(TAG, "Starting connection service for $serverName")
requestBatteryExemptionIfNeeded(context)
val intent = Intent(context, ConnectionService::class.java).apply { val intent = Intent(context, ConnectionService::class.java).apply {
putExtra(EXTRA_SERVER_NAME, serverName) putExtra(EXTRA_SERVER_NAME, serverName)
} }
@@ -49,42 +57,114 @@ class ConnectionService : LifecycleService() {
android.util.Log.i(TAG, "Stopping connection service") android.util.Log.i(TAG, "Stopping connection service")
context.stopService(Intent(context, ConnectionService::class.java)) context.stopService(Intent(context, ConnectionService::class.java))
} }
/**
* 检查电池优化豁免状态,若未豁免则引导用户设置。
* 仅在首次调用时弹窗(系统设置页面),用户设置后不再提示。
*/
fun requestBatteryExemptionIfNeeded(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
val pm = context.getSystemService(POWER_SERVICE) as PowerManager
if (pm.isIgnoringBatteryOptimizations(context.packageName)) {
android.util.Log.d(TAG, "Already exempt from battery optimization")
return
}
android.util.Log.i(TAG, "Requesting battery optimization exemption")
try {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (e: Exception) {
android.util.Log.w(TAG, "Failed to open battery optimization settings", e)
}
}
}
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
override fun onCreate() {
super.onCreate()
acquireWakeLock()
acquireWifiLock()
ensureChannel()
android.util.Log.i(TAG, "Service created, locks acquired")
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId) super.onStartCommand(intent, flags, startId)
val serverName = intent?.getStringExtra(EXTRA_SERVER_NAME) ?: "" val serverName = intent?.getStringExtra(EXTRA_SERVER_NAME) ?: ""
val notification = buildNotification(serverName)
// startForeground 前确保通知频道存在
ensureChannel()
// 启动前台服务 // 启动前台服务
// Android 14+ 需要指定 foregroundServiceType
val serviceType = if (Build.VERSION.SDK_INT >= 34) { val serviceType = if (Build.VERSION.SDK_INT >= 34) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
} else { } else {
0 0
} }
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, serviceType) ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification(serverName), serviceType)
android.util.Log.i(TAG, "Foreground service started") android.util.Log.i(TAG, "Foreground service started for $serverName")
// The service exists only to keep the process alive while a TS connection is active.
// If Android kills it, there is no reliable way to restore the TS session, so don't
// ask the system to recreate it with a misleading "connected" notification.
return Service.START_NOT_STICKY return Service.START_NOT_STICKY
} }
override fun onDestroy() { override fun onDestroy() {
releaseLocks()
super.onDestroy() super.onDestroy()
android.util.Log.i(TAG, "Connection service destroyed") android.util.Log.i(TAG, "Connection service destroyed, locks released")
} }
/** // ── WakeLock ───────────────────────────────────────────────
* 确保通知频道已创建。
* Android 8.0+ 需要通知频道,重复创建是安全的(系统会忽略已存在的频道)。 @Suppress("DEPRECATION")
*/ private fun acquireWakeLock() {
val pm = getSystemService(POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
WAKELOCK_TAG,
).apply {
acquire()
}
android.util.Log.i(TAG, "WakeLock acquired (PARTIAL)")
}
private fun releaseLocks() {
wakeLock?.let {
if (it.isHeld) {
it.release()
android.util.Log.i(TAG, "WakeLock released")
}
}
wakeLock = null
wifiLock?.let {
if (it.isHeld) {
it.release()
android.util.Log.i(TAG, "WiFi lock released")
}
}
wifiLock = null
}
// ── WiFi Lock ──────────────────────────────────────────────
private fun acquireWifiLock() {
val wm = applicationContext.getSystemService(WIFI_SERVICE) as? WifiManager ?: return
wifiLock = wm.createWifiLock(
WifiManager.WIFI_MODE_FULL_HIGH_PERF,
"tsmobile:wifi",
).apply {
acquire()
}
android.util.Log.i(TAG, "WiFi lock acquired")
}
// ── 通知频道 ───────────────────────────────────────────────
private fun ensureChannel() { private fun ensureChannel() {
val nm = getSystemService(NotificationManager::class.java) ?: return val nm = getSystemService(NotificationManager::class.java) ?: return
if (nm.getNotificationChannel(CHANNEL_ID) != null) return if (nm.getNotificationChannel(CHANNEL_ID) != null) return
@@ -100,11 +180,7 @@ class ConnectionService : LifecycleService() {
nm.createNotificationChannel(channel) nm.createNotificationChannel(channel)
} }
/**
* 构建通知。
*/
private fun buildNotification(serverName: String): Notification { private fun buildNotification(serverName: String): Notification {
// 点击通知回到 MainActivity
val pendingIntent = PendingIntent.getActivity( val pendingIntent = PendingIntent.getActivity(
this, this,
0, 0,
@@ -119,8 +195,8 @@ class ConnectionService : LifecycleService() {
.setContentTitle("已连接到 TeamSpeak") .setContentTitle("已连接到 TeamSpeak")
.setContentText(serverName) .setContentText(serverName)
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true) // 不可滑动清除 .setOngoing(true)
.setShowWhen(false) // 不显示时间 .setShowWhen(false)
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.build() .build()
} }
@@ -37,7 +37,7 @@ class NetworkMonitor(context: Context) {
private val _isAvailable = MutableStateFlow(true) // 乐观假设:初始时网络可用 private val _isAvailable = MutableStateFlow(true) // 乐观假设:初始时网络可用
val isAvailable: StateFlow<Boolean> = _isAvailable.asStateFlow() val isAvailable: StateFlow<Boolean> = _isAvailable.asStateFlow()
/** 网络变化计数器:任何网络回调触发时自增(用于检测 isAvailable 不变但底层网络已切换的场景 */ /** 网络切换计数器:仅在 onLost / onAvailable 时自增(onCapabilitiesChanged 太频繁,不计数 */
private val _networkChangeCount = MutableStateFlow(0L) private val _networkChangeCount = MutableStateFlow(0L)
val networkChangeCount: StateFlow<Long> = _networkChangeCount.asStateFlow() val networkChangeCount: StateFlow<Long> = _networkChangeCount.asStateFlow()
@@ -76,19 +76,14 @@ class NetworkMonitor(context: Context) {
val hasInternet = capabilities.hasCapability( val hasInternet = capabilities.hasCapability(
NetworkCapabilities.NET_CAPABILITY_INTERNET NetworkCapabilities.NET_CAPABILITY_INTERNET
) )
val wasAvailable = _isAvailable.value // 不修改 _isAvailable —— onCapabilitiesChanged 在移动网络下太频繁
_isAvailable.value = hasInternet // (LTE↔5G 切换等),修改会导致 isAvailable collector 误触发重连。
// 仅记录时间戳供 consumeNetworkLossDisconnect 在 Go idle timeout 兜底时判断。
if (!hasInternet && wasAvailable) { if (!hasInternet) {
// 网络能力降级(例如切换中暂时无 Internet)
Log.d(TAG, "Network capabilities degraded: hasInternet=false") Log.d(TAG, "Network capabilities degraded: hasInternet=false")
lastLossTimestamp = System.currentTimeMillis() lastLossTimestamp = System.currentTimeMillis()
pendingDisconnectFromLoss = true pendingDisconnectFromLoss = true
} }
if (hasInternet && !wasAvailable) {
Log.d(TAG, "Network capabilities restored: hasInternet=true")
}
_networkChangeCount.value += 1
} }
} }
@@ -2,25 +2,36 @@ package com.tsmobile.app.ui.components
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.LinkOff import androidx.compose.material.icons.filled.LinkOff
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tsmobile.app.data.ConnectionState import com.tsmobile.app.data.ConnectionState
import com.tsmobile.app.data.ServerInfo import com.tsmobile.app.data.ServerInfo
@@ -36,27 +47,84 @@ fun ServerDetailCard(
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
var showDisconnectDialog by remember { mutableStateOf(false) } var showDisconnectDialog by remember { mutableStateOf(false) }
val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.8f).dp
DetailSheetScaffold( ModalDrawerSheet {
title = serverName.ifEmpty { "服务器详情" }, Column(modifier = Modifier.fillMaxSize()) {
subtitle = serverAddress, // ═══ 顶部:服务器名称 + 地址 + 关闭按钮 ═══
onDismiss = onDismiss, Row(
modifier = Modifier.padding(UiTokens.Spacing.Large), modifier = Modifier
maxHeight = maxHeight, .fillMaxWidth()
content = { ServerInfoSection(serverInfo, connectionState) }, .padding(
footer = { start = UiTokens.Spacing.Large,
end = UiTokens.Spacing.Small,
top = UiTokens.Spacing.Large,
bottom = UiTokens.Spacing.Medium,
),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = serverName.ifEmpty { "服务器详情" },
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
if (serverAddress.isNotEmpty()) {
Spacer(Modifier.height(UiTokens.Spacing.ExtraSmall))
Text(
text = serverAddress,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "关闭")
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
// ═══ 中部:欢迎消息 + 服务器信息(可滚动) ═══
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(UiTokens.Spacing.Large),
verticalArrangement = Arrangement.spacedBy(UiTokens.Spacing.Medium),
) {
// 欢迎消息
if (serverInfo != null && serverInfo.welcomeMessage.isNotEmpty()) {
SectionCard(title = "欢迎消息") {
Text(
text = serverInfo.welcomeMessage,
style = MaterialTheme.typography.bodySmall,
)
}
}
// 服务器详细信息
SectionCard(title = "服务器信息") {
ServerInfoContent(serverInfo, connectionState)
}
}
// ═══ 底部:断开连接按钮 ═══
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
OutlinedButton( OutlinedButton(
onClick = { showDisconnectDialog = true }, onClick = { showDisconnectDialog = true },
modifier = Modifier.fillMaxWidth(), modifier = Modifier
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), .fillMaxWidth()
.padding(UiTokens.Spacing.Large),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
) { ) {
Icon(Icons.Default.LinkOff, contentDescription = null) Icon(Icons.Default.LinkOff, contentDescription = null)
Spacer(Modifier.width(UiTokens.Spacing.Small)) Spacer(Modifier.width(UiTokens.Spacing.Small))
Text("断开连接") Text("断开连接")
} }
}, }
) }
if (showDisconnectDialog) { if (showDisconnectDialog) {
DisconnectConfirmDialog( DisconnectConfirmDialog(
@@ -67,7 +135,34 @@ fun ServerDetailCard(
} }
@Composable @Composable
private fun ServerInfoSection(serverInfo: ServerInfo?, connectionState: ConnectionState?) { private fun SectionCard(
title: String,
content: @Composable ColumnScope.() -> Unit,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant,
tonalElevation = UiTokens.Elevation.Raised,
) {
Column(modifier = Modifier.padding(UiTokens.Spacing.Medium)) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium,
)
Spacer(Modifier.height(UiTokens.Spacing.Small))
content()
}
}
}
@Composable
private fun ServerInfoContent(
serverInfo: ServerInfo?,
connectionState: ConnectionState?,
) {
Column(verticalArrangement = Arrangement.spacedBy(UiTokens.Spacing.Small)) { Column(verticalArrangement = Arrangement.spacedBy(UiTokens.Spacing.Small)) {
DetailInfoRow("状态", when (connectionState) { DetailInfoRow("状态", when (connectionState) {
is ConnectionState.Connected -> "已连接" is ConnectionState.Connected -> "已连接"
@@ -78,17 +173,18 @@ private fun ServerInfoSection(serverInfo: ServerInfo?, connectionState: Connecti
null -> "未连接" null -> "未连接"
}) })
if (serverInfo == null) { if (serverInfo == null) {
Text("正在加载服务器信息...", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(
"正在加载服务器信息...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else { } else {
DetailInfoRow("在线人数", "${serverInfo.clientsOnline} / ${serverInfo.maxClients}") DetailInfoRow("在线人数", "${serverInfo.clientsOnline} / ${serverInfo.maxClients}")
DetailInfoRow("频道数", "${serverInfo.channelsOnline}") DetailInfoRow("频道数", "${serverInfo.channelsOnline}")
if (serverInfo.version.isNotEmpty()) DetailInfoRow("版本", serverInfo.version) if (serverInfo.version.isNotEmpty()) DetailInfoRow("版本", serverInfo.version)
if (serverInfo.platform.isNotEmpty()) DetailInfoRow("平台", serverInfo.platform) if (serverInfo.platform.isNotEmpty()) DetailInfoRow("平台", serverInfo.platform)
(serverInfo.uptime.toLongOrNull() ?: 0).takeIf { it > 0 }?.let { DetailInfoRow("运行时长", formatUptime(it)) } (serverInfo.uptime.toLongOrNull() ?: 0).takeIf { it > 0 }?.let {
if (serverInfo.welcomeMessage.isNotEmpty()) { DetailInfoRow("运行时长", formatUptime(it))
Spacer(Modifier.height(UiTokens.Spacing.Small))
Text("欢迎消息", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(serverInfo.welcomeMessage, style = MaterialTheme.typography.bodySmall)
} }
} }
} }
@@ -98,5 +194,9 @@ private fun formatUptime(seconds: Long): String {
val days = seconds / 86400 val days = seconds / 86400
val hours = (seconds % 86400) / 3600 val hours = (seconds % 86400) / 3600
val minutes = (seconds % 3600) / 60 val minutes = (seconds % 3600) / 60
return when { days > 0 -> "${days}${hours}小时"; hours > 0 -> "${hours}小时${minutes}分钟"; else -> "${minutes}分钟" } return when {
days > 0 -> "${days}${hours}小时"
hours > 0 -> "${hours}小时${minutes}分钟"
else -> "${minutes}分钟"
}
} }
@@ -110,12 +110,52 @@ fun ChannelListScreen(
// 防抖:服务器详情按钮上次点击时间 // 防抖:服务器详情按钮上次点击时间
var lastServerDetailClickTime by remember { mutableLongStateOf(0L) } var lastServerDetailClickTime by remember { mutableLongStateOf(0L) }
Box(modifier = Modifier.fillMaxSize().systemBarsPadding()) { // 服务器状态(提前声明,供侧栏 drawerContent 读取)
Column(modifier = Modifier.fillMaxSize()) {
// 头部
val serverInfo by serverViewModel.serverInfo.collectAsState() val serverInfo by serverViewModel.serverInfo.collectAsState()
val serverState by serverViewModel.state.collectAsState() val serverState by serverViewModel.state.collectAsState()
val themeMode by serverViewModel.themeMode.collectAsState() val themeMode by serverViewModel.themeMode.collectAsState()
// 侧栏抽屉状态
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
// 同步 showServerDetail 与抽屉开合
LaunchedEffect(showServerDetail) {
if (showServerDetail) drawerState.open() else drawerState.close()
}
// 手势滑动打开时同步状态并加载数据
LaunchedEffect(drawerState.isOpen) {
if (drawerState.isOpen && !showServerDetail) {
serverViewModel.fetchServerInfo()
showServerDetail = true
}
}
// 手势滑动关闭或编程关闭后同步状态
LaunchedEffect(drawerState.isClosed) {
if (drawerState.isClosed && showServerDetail) {
showServerDetail = false
}
}
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ServerDetailCard(
serverInfo = serverInfo,
serverName = serverInfo?.name ?: "",
serverAddress = serverState.address,
connectionState = connectionState,
onDisconnect = {
showServerDetail = false
serverViewModel.disconnect()
onNavigateToServerConfig()
},
onDismiss = { showServerDetail = false },
)
},
) {
Box(modifier = Modifier.fillMaxSize().systemBarsPadding()) {
Column(modifier = Modifier.fillMaxSize()) {
// 头部
ChannelListHeader( ChannelListHeader(
connectionState = connectionState, connectionState = connectionState,
serverName = serverInfo?.name ?: "", serverName = serverInfo?.name ?: "",
@@ -182,6 +222,7 @@ fun ChannelListScreen(
} }
} }
} // ModalNavigationDrawer
// ─── 弹窗层 ─── // ─── 弹窗层 ───
@@ -201,28 +242,6 @@ fun ChannelListScreen(
} }
} }
// 服务器详情卡弹窗(BottomSheet
if (showServerDetail) {
val serverInfo by serverViewModel.serverInfo.collectAsState()
val serverState by serverViewModel.state.collectAsState()
ModalBottomSheet(
onDismissRequest = { showServerDetail = false },
) {
ServerDetailCard(
serverInfo = serverInfo,
serverName = serverInfo?.name ?: "",
serverAddress = serverState.address,
connectionState = connectionState,
onDisconnect = {
showServerDetail = false
serverViewModel.disconnect()
onNavigateToServerConfig()
},
onDismiss = { showServerDetail = false },
)
}
}
// 频道详情卡弹窗(BottomSheet // 频道详情卡弹窗(BottomSheet
if (showChannelDetailCard) { if (showChannelDetailCard) {
ModalBottomSheet( ModalBottomSheet(
@@ -168,17 +168,23 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
// 启动时自动检查更新 // 启动时自动检查更新
checkForUpdate() checkForUpdate()
// 网络变动检测 — 简单原则 // 网络变动检测:
// 任何 NetworkCallback 触发 + 已连接 → 网络一定变了 → 必须重连 // 任何 NetworkCallback 触发 + 前台 + 已连接 → 重连(UDP socket 绑定在旧 IP 上已死)
// UDP socket 绑定在旧 IP 上,即使 isAvailable 不变也已死) // 后台忽略:Android 挂起应用时可能限制网络导致误触发,
// 真正的断线由 Go idle timeout(60s 无包)兜底,或回到前台时检测
// //
// 两层 collector // 两层 collector
// 1. networkChangeCount → 任何网络事件 → Connected 就重连 // 1. networkChangeCount → 任何网络事件 → Connected 就重连(仅前台)
// 2. isAvailable false→true → WaitingForNetwork 时网络恢复 → 重连 // 2. isAvailable false→true → WaitingForNetwork 时网络恢复 → 重连(不限前后台)
viewModelScope.launch { viewModelScope.launch {
var firstEmission = true var firstEmission = true
networkMonitor.networkChangeCount.collect { _ -> networkMonitor.networkChangeCount.collect { _ ->
if (firstEmission) { firstEmission = false; return@collect } if (firstEmission) { firstEmission = false; return@collect }
// 后台忽略:防止 Android 后台网络限制导致 NetworkCallback 误触发
if (!isAppInForeground()) {
android.util.Log.d(TAG, "Network change ignored (app in background)")
return@collect
}
if (_connectionState.value is ConnectionState.Connected) { if (_connectionState.value is ConnectionState.Connected) {
android.util.Log.w(TAG, "Network changed while connected, reconnecting") android.util.Log.w(TAG, "Network changed while connected, reconnecting")
val chId = Repository.currentChannelId.value val chId = Repository.currentChannelId.value
@@ -186,6 +192,7 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
lastChannelId = chId lastChannelId = chId
} }
_connectionState.value = ConnectionState.WaitingForNetwork() _connectionState.value = ConnectionState.WaitingForNetwork()
ConnectionService.stop(getApplication())
if (networkMonitor.isAvailable.value) { if (networkMonitor.isAvailable.value) {
startReconnect("网络变化,正在重连...") startReconnect("网络变化,正在重连...")
} }
@@ -208,6 +215,10 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
} }
} }
/** 应用是否在前台(RESUMED 状态) */
private fun isAppInForeground(): Boolean =
ProcessLifecycleOwner.get().lifecycle.currentState == Lifecycle.State.RESUMED
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
networkMonitor.stop() networkMonitor.stop()
@@ -1061,11 +1072,13 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
return return
} }
// 已在 WaitingForNetwork 或 Reconnecting 状态 → 我们的响应式监控已处理 // 已在 WaitingForNetwork 或 Reconnecting 状态 → 响应式监控已处理
// 但仍需更新通知栏(否则断连后仍显示"已连接")
if (_connectionState.value is ConnectionState.WaitingForNetwork || if (_connectionState.value is ConnectionState.WaitingForNetwork ||
_connectionState.value is ConnectionState.Reconnecting _connectionState.value is ConnectionState.Reconnecting
) { ) {
android.util.Log.d(TAG, "onDisconnected while already handling reconnect, ignoring") android.util.Log.d(TAG, "onDisconnected while already handling reconnect, stopping notification")
ConnectionService.stop(getApplication())
return return
} }