Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18e2f730e7 | ||
|
|
ebc46233e5 | ||
|
|
31424f8ff6 | ||
|
|
4cc3047a6f | ||
|
|
f968708284 |
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user