3 Commits
Author SHA1 Message Date
sansen 0ef1990d52 版本号提升至 1.0.12(versionCode 12) 2026-09-08 12:46:28 +08:00
sansen d2f0e95441 UI/动画、交互与并发改进,与 emoji 短码完整映射
1. 交互动画:页面横移切换(登录页↔频道页、频道页↔聊天页、断开回登录页),顶部/底部栏横移过渡
2. UI 美化:重做底部当前频道栏(圆角卡片+在线人数+成员头像堆叠);聊天页输入栏圆角停靠卡、消息气泡去冗余昵称、头部在线人数;配置页字段级校验提示与最近连接删除确认弹框;PTT 按住说话震动反馈
3. 并发修复:重连状态机跨线程访问加锁与 @Volatile;桥接 ViewModel 引用、client/currentIdentity 可见性
4. emoji 短码完整映射(gemoji 1973 条 + TeamSpeak 别名 pleading),附单元测试
2026-09-08 12:44:33 +08:00
sansen 4d06ff2ade 1. 保活通知状态机:断开/断网时必然清除(stopForeground REMOVE + cancel);断网重连时显示“重连中”,重连成功恢复“已连接”,重连失败/放弃/服务器主动断开则清除
2. 导出崩溃日志改为 .log 文件附件(注册 FileProvider 分享,不再纯文本复制)
3. 版本号提升至 1.0.11(versionCode 11)
2026-08-25 14:11:30 +08:00
19 changed files with 2565 additions and 284 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 = 10 versionCode = 12
versionName = "1.0.10" versionName = "1.0.12"
} }
signingConfigs { signingConfigs {
+11
View File
@@ -48,6 +48,17 @@
android:name=".ConnectionService" android:name=".ConnectionService"
android:foregroundServiceType="dataSync" android:foregroundServiceType="dataSync"
android:exported="false" /> android:exported="false" />
<!-- FileProvider:导出/分享崩溃日志文件 -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>
@@ -56,6 +56,52 @@ class ConnectionService : LifecycleService() {
fun stop(context: Context) { fun stop(context: Context) {
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))
// 兜底:立即取消保活通知,避免服务已停但通知残留(部分 ROM 不会随 stopService 移除前台通知)
cancelNotification(context)
}
/** 移除保活通知(前台通知的兜底清理) */
private fun cancelNotification(context: Context) {
try {
(context.getSystemService(NotificationManager::class.java) as NotificationManager)
.cancel(NOTIFICATION_ID)
} catch (e: Exception) {
android.util.Log.w(TAG, "Failed to cancel keepalive notification", e)
}
}
/**
* 更新保活通知(用于“重连中”等状态变化)。
* 服务仍在运行时,会更新前台通知;否则投递一条普通通知。
*/
fun update(context: Context, title: String, contentText: String) {
android.util.Log.i(TAG, "Updating keepalive notification: $title")
try {
(context.getSystemService(NotificationManager::class.java) as NotificationManager)
.notify(NOTIFICATION_ID, buildNotification(context, title, contentText))
} catch (e: Exception) {
android.util.Log.w(TAG, "Failed to update keepalive notification", e)
}
}
private fun buildNotification(context: Context, title: String, contentText: String): Notification {
val pendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
return NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(contentText)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setShowWhen(false)
.setContentIntent(pendingIntent)
.build()
} }
/** /**
@@ -106,7 +152,7 @@ class ConnectionService : LifecycleService() {
} else { } else {
0 0
} }
ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification(serverName), serviceType) ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification("已连接到 TeamSpeak", serverName), serviceType)
android.util.Log.i(TAG, "Foreground service started for $serverName") android.util.Log.i(TAG, "Foreground service started for $serverName")
return Service.START_NOT_STICKY return Service.START_NOT_STICKY
@@ -114,6 +160,9 @@ class ConnectionService : LifecycleService() {
override fun onDestroy() { override fun onDestroy() {
releaseLocks() releaseLocks()
// 显式移除前台通知,确保断开/停止服务后通知被清除,而不是残留“已连接”
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
cancelNotification(this)
super.onDestroy() super.onDestroy()
android.util.Log.i(TAG, "Connection service destroyed, locks released") android.util.Log.i(TAG, "Connection service destroyed, locks released")
} }
@@ -180,24 +229,6 @@ class ConnectionService : LifecycleService() {
nm.createNotificationChannel(channel) nm.createNotificationChannel(channel)
} }
private fun buildNotification(serverName: String): Notification { private fun buildNotification(title: String, contentText: String): Notification =
val pendingIntent = PendingIntent.getActivity( buildNotification(this, title, contentText)
this,
0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("已连接到 TeamSpeak")
.setContentText(serverName)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setShowWhen(false)
.setContentIntent(pendingIntent)
.build()
}
} }
@@ -19,8 +19,13 @@ import java.lang.reflect.Proxy
object TSBridge { object TSBridge {
private val connectLock = Any() private val connectLock = Any()
// @Volatile:client 被主线程/IO 协程写入(connect/disconnect),
// 被多个 getter 线程读取;保证可见性,避免读到过期的 client 引用。
@Volatile
private var client: TSClient? = null private var client: TSClient? = null
private var connectionGeneration: Long = 0 private var connectionGeneration: Long = 0
// @Volatile:connect() 在持有 connectLock 之前读取它,需保证可见性。
@Volatile
private var currentIdentity: String = "" private var currentIdentity: String = ""
private fun beginConnection(identity: String): Pair<TSClient, Long> = synchronized(connectLock) { private fun beginConnection(identity: String): Pair<TSClient, Long> = synchronized(connectLock) {
@@ -81,7 +86,7 @@ object TSBridge {
defaultChannelPassword: String = "", defaultChannelPassword: String = "",
callbacks: Callbacks, callbacks: Callbacks,
): String { ): String {
val identity = currentIdentity val identity = currentIdentity()
val (tsClient, generation) = beginConnection(identity) val (tsClient, generation) = beginConnection(identity)
val error = doConnect(tsClient, generation, callbacks) { proxy -> val error = doConnect(tsClient, generation, callbacks) { proxy ->
tsClient.connect(host, nickname, password, defaultChannel, defaultChannelPassword, proxy) tsClient.connect(host, nickname, password, defaultChannel, defaultChannelPassword, proxy)
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,15 @@
package com.tsmobile.app.ui.components package com.tsmobile.app.ui.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
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.Row import androidx.compose.foundation.layout.Row
@@ -58,16 +68,28 @@ fun AppTopBar(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
// 标题文字横移切换:旧文字向左滑出,新文字从右滑入。
AnimatedContent(
targetState = title,
transitionSpec = { headerTextSlide() },
label = "appTopBarTitle",
) { t ->
Text( Text(
text = title, text = t,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
subtitle?.takeIf { it.isNotEmpty() }?.let { }
subtitle?.takeIf { it.isNotEmpty() }?.let { s ->
AnimatedContent(
targetState = s,
transitionSpec = { headerTextSlide() },
label = "appTopBarSubtitle",
) { subText ->
Text( Text(
text = it, text = subText,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@@ -76,6 +98,7 @@ fun AppTopBar(
) )
} }
} }
}
if (actionIcon != null && onActionClick != null) { if (actionIcon != null && onActionClick != null) {
IconButton(onClick = onActionClick) { IconButton(onClick = onActionClick) {
@@ -87,3 +110,12 @@ fun AppTopBar(
} }
} }
} }
/** 标题/副标题的横移过渡:新文字从右侧滑入,旧文字向左侧滑出。 */
private fun <S> AnimatedContentTransitionScope<S>.headerTextSlide(): ContentTransform =
(slideInHorizontally(animationSpec = tween(280, easing = FastOutSlowInEasing)) { it / 3 } +
fadeIn(animationSpec = tween(240, easing = FastOutSlowInEasing)))
.togetherWith(
slideOutHorizontally(animationSpec = tween(260, easing = FastOutSlowInEasing)) { -it / 3 } +
fadeOut(animationSpec = tween(200, easing = FastOutSlowInEasing))
)
@@ -1,6 +1,7 @@
package com.tsmobile.app.ui.components package com.tsmobile.app.ui.components
import android.util.Log import android.util.Log
import com.tsmobile.app.data.EmojiMapper
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.LinkAnnotation
@@ -49,7 +50,9 @@ object Bbcode {
} }
} }
private fun parseInternal(raw: String): AnnotatedString { private fun parseInternal(rawInput: String): AnnotatedString {
// 先做 emoji 短码替换,再渲染 BBCode。
val raw = EmojiMapper.replace(rawInput)
val builder = AnnotatedString.Builder() val builder = AnnotatedString.Builder()
val stack = ArrayDeque<RunStyle>() val stack = ArrayDeque<RunStyle>()
var current = RunStyle() var current = RunStyle()
@@ -15,7 +15,7 @@ fun ChatHeader(
) { ) {
AppTopBar( AppTopBar(
title = channelName, title = channelName,
subtitle = "${memberCount}人", subtitle = "${memberCount} 人在线",
navigationIcon = Icons.AutoMirrored.Filled.ArrowBack, navigationIcon = Icons.AutoMirrored.Filled.ArrowBack,
navigationContentDescription = "返回", navigationContentDescription = "返回",
onNavigationClick = onBack, onNavigationClick = onBack,
@@ -5,11 +5,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Send import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tsmobile.app.data.MessageSendState import com.tsmobile.app.data.MessageSendState
@@ -29,9 +30,10 @@ fun MessageInputBar(
Surface( Surface(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface, // 与下方语音栏同底色,顶部圆角,读成“底部停靠的一组”,而非孤立的浮条。
tonalElevation = 3.dp, shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
shadowElevation = 3.dp color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 1.dp,
) { ) {
Row( Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
@@ -52,7 +54,7 @@ fun MessageInputBar(
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
focusedBorderColor = MaterialTheme.colorScheme.primary, focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant unfocusedBorderColor = Color.Transparent,
), ),
maxLines = 4, maxLines = 4,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
@@ -90,7 +92,7 @@ fun MessageInputBar(
) )
) { ) {
Icon( Icon(
imageVector = Icons.Default.Send, imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "发送" contentDescription = "发送"
) )
} }
@@ -26,6 +26,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tsmobile.app.data.ChatMessage import com.tsmobile.app.data.ChatMessage
import com.tsmobile.app.data.EmojiMapper
import com.tsmobile.app.data.FileDownloadManager import com.tsmobile.app.data.FileDownloadManager
import com.tsmobile.app.data.FileMessageMeta import com.tsmobile.app.data.FileMessageMeta
import com.tsmobile.app.data.MessageDeliveryState import com.tsmobile.app.data.MessageDeliveryState
@@ -58,7 +59,7 @@ fun MessageItem(message: ChatMessage) {
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = message.content, text = EmojiMapper.replace(message.content),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -112,14 +113,6 @@ fun MessageItem(message: ChatMessage) {
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
if (message.isSelf) {
Spacer(Modifier.width(8.dp))
Text(
text = message.senderName,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
}
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
@@ -158,7 +151,7 @@ fun MessageItem(message: ChatMessage) {
tonalElevation = 1.dp tonalElevation = 1.dp
) { ) {
Text( Text(
text = message.content, text = EmojiMapper.replace(message.content),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp) modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
) )
@@ -11,8 +11,10 @@ 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.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.semantics.stateDescription
@@ -37,6 +39,7 @@ fun PTTButton(
val semanticColors = MaterialTheme.semanticColors val semanticColors = MaterialTheme.semanticColors
val currentOnStartTransmit by rememberUpdatedState(onStartTransmit) val currentOnStartTransmit by rememberUpdatedState(onStartTransmit)
val currentOnStopTransmit by rememberUpdatedState(onStopTransmit) val currentOnStopTransmit by rememberUpdatedState(onStopTransmit)
val haptic = LocalHapticFeedback.current
Button( Button(
onClick = {}, onClick = {},
@@ -51,6 +54,8 @@ fun PTTButton(
if (!enabled) return@pointerInput if (!enabled) return@pointerInput
awaitEachGesture { awaitEachGesture {
awaitFirstDown(requireUnconsumed = false) awaitFirstDown(requireUnconsumed = false)
// 按下时给予触觉反馈,确认发言动作已开始。
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
currentOnStartTransmit() currentOnStartTransmit()
try { try {
waitForUpOrCancellation() waitForUpOrCancellation()
@@ -3,6 +3,7 @@ package com.tsmobile.app.ui.components
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.widget.Toast import android.widget.Toast
import androidx.core.content.FileProvider
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.ColumnScope
@@ -233,20 +234,26 @@ private fun readCrashLog(context: Context): String {
return texts.distinct().joinToString("\n\n") return texts.distinct().joinToString("\n\n")
} }
/** 通过系统分享面板导出崩溃日志(无日志时提示)。 */ /** 通过系统分享面板导出崩溃日志:写入 .log 文件并作为附件分享(非纯文本)。 */
private fun shareCrashLog(context: Context) { private fun shareCrashLog(context: Context) {
val log = readCrashLog(context) val log = readCrashLog(context)
if (log.isBlank()) { if (log.isBlank()) {
Toast.makeText(context, "暂无崩溃日志", Toast.LENGTH_SHORT).show() Toast.makeText(context, "暂无崩溃日志", Toast.LENGTH_SHORT).show()
return return
} }
val file = writeCrashLogToFile(context, log)
if (file == null) {
Toast.makeText(context, "生成日志文件失败", Toast.LENGTH_SHORT).show()
return
}
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val send = Intent(Intent.ACTION_SEND).apply { val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain" type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, "TeamSpeak Mobile 崩溃日志") putExtra(Intent.EXTRA_SUBJECT, "TeamSpeak Mobile 崩溃日志")
putExtra(Intent.EXTRA_TEXT, log) putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
val chooser = Intent.createChooser(send, "导出崩溃日志") val chooser = Intent.createChooser(send, "导出崩溃日志")
// 若 context 不是 Activity,需 NEW_TASK
if (context !is android.app.Activity) chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) if (context !is android.app.Activity) chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try { try {
context.startActivity(chooser) context.startActivity(chooser)
@@ -254,3 +261,15 @@ private fun shareCrashLog(context: Context) {
Toast.makeText(context, "无法打开分享:${e.message}", Toast.LENGTH_SHORT).show() Toast.makeText(context, "无法打开分享:${e.message}", Toast.LENGTH_SHORT).show()
} }
} }
/** 将日志写入 cacheDir/logs/tsmobile_crash_<时间戳>.log 文件(供 FileProvider 分享)。 */
private fun writeCrashLogToFile(context: Context, log: String): File? {
return try {
val dir = File(context.cacheDir, "logs").apply { mkdirs() }
val file = File(dir, "tsmobile_crash_${System.currentTimeMillis()}.log")
file.writeText(log)
file
} catch (e: Exception) {
null
}
}
@@ -3,6 +3,12 @@ package com.tsmobile.app.ui.navigation
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.EnterTransition import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -128,11 +134,46 @@ fun AppNavGraph(
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = Routes.SERVER_CONFIG, startDestination = Routes.SERVER_CONFIG,
// 禁用默认的淡入淡出动画,避免页面切换显得迟钝 // 默认不动画;仅聊天页做“从右滑入/滑出”的过渡,避免整套页面切换显得迟钝。
enterTransition = { EnterTransition.None }, enterTransition = {
exitTransition = { ExitTransition.None }, when (targetState.destination.route) {
popEnterTransition = { EnterTransition.None }, // 前进到主界面(首页→频道页、频道页→聊天页):从右侧滑入。
popExitTransition = { ExitTransition.None }, Routes.CHAT, Routes.CHANNEL_LIST ->
slideInHorizontally(tween(340, easing = FastOutSlowInEasing)) { it } +
fadeIn(tween(280, easing = FastOutSlowInEasing))
// 断开/被踢后回登录页:从左侧滑入(像“返回主页”)。
Routes.SERVER_CONFIG ->
slideInHorizontally(tween(340, easing = FastOutSlowInEasing)) { -it } +
fadeIn(tween(280, easing = FastOutSlowInEasing))
else -> EnterTransition.None
}
},
exitTransition = {
// 前进/退回到主页面时,底层页面轻微淡出,形成“叠影推进”而不生硬。
val from = initialState.destination.route
val to = targetState.destination.route
val overlapped =
(from == Routes.SERVER_CONFIG && to == Routes.CHANNEL_LIST) ||
(from == Routes.CHANNEL_LIST && to == Routes.CHAT) ||
(from == Routes.CHANNEL_LIST && to == Routes.SERVER_CONFIG)
if (overlapped) fadeOut(tween(240, easing = FastOutSlowInEasing)) else ExitTransition.None
},
popEnterTransition = {
if (targetState.destination.route == Routes.CHANNEL_LIST) {
slideInHorizontally(tween(340, easing = FastOutSlowInEasing)) { -it } +
fadeIn(tween(280, easing = FastOutSlowInEasing))
} else {
EnterTransition.None
}
},
popExitTransition = {
if (initialState.destination.route == Routes.CHAT) {
slideOutHorizontally(tween(320, easing = FastOutSlowInEasing)) { it } +
fadeOut(tween(280, easing = FastOutSlowInEasing))
} else {
ExitTransition.None
}
},
) { ) {
// 服务器配置页 // 服务器配置页
composable(Routes.SERVER_CONFIG) { composable(Routes.SERVER_CONFIG) {
@@ -240,4 +281,3 @@ fun AppNavGraph(
) )
} }
} }
@@ -3,8 +3,17 @@ package com.tsmobile.app.ui.screens
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -17,8 +26,10 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -681,49 +692,96 @@ private fun CurrentChannelBar(
val currentChannelId by channelViewModel.currentChannelId.collectAsState() val currentChannelId by channelViewModel.currentChannelId.collectAsState()
val channels by channelViewModel.channels.collectAsState() val channels by channelViewModel.channels.collectAsState()
val channelClients by channelViewModel.channelClients.collectAsState() val channelClients by channelViewModel.channelClients.collectAsState()
val selfClientId by channelViewModel.selfClientId.collectAsState()
val currentChannel = channels.find { it.id == currentChannelId } val currentChannel = channels.find { it.id == currentChannelId }
val clients = channelClients[currentChannelId] ?: emptyList()
if (currentChannel != null) { if (currentChannel != null) {
val selfClientId by channelViewModel.selfClientId.collectAsState() Surface(
val previewCandidates = clients.sortedByDescending { it.id == selfClientId }
val semanticColors = MaterialTheme.semanticColors
Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(68.dp) .clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick),
.background(MaterialTheme.colorScheme.surfaceVariant) color = MaterialTheme.colorScheme.surfaceContainer,
.padding( tonalElevation = UiTokens.Elevation.Subtle,
start = FOOTER_LEFT_INDENT, ) {
end = FOOTER_RIGHT_INDENT, // 切换频道时,当前频道栏内容做淡入淡出 + 轻微横向滑入动画。
top = UiTokens.Spacing.ExtraSmall, AnimatedContent(
bottom = UiTokens.Spacing.ExtraSmall, targetState = currentChannelId,
), transitionSpec = {
verticalArrangement = Arrangement.spacedBy(UiTokens.Spacing.ExtraSmall), (slideInHorizontally(animationSpec = tween(300, easing = FastOutSlowInEasing), initialOffsetX = { it / 4 }) +
fadeIn(animationSpec = tween(260, easing = FastOutSlowInEasing)))
.togetherWith(
slideOutHorizontally(animationSpec = tween(280, easing = FastOutSlowInEasing), targetOffsetX = { -it / 4 }) +
fadeOut(animationSpec = tween(200, easing = FastOutSlowInEasing))
)
},
label = "currentChannelBar",
) { channelId ->
val channel = channels.find { it.id == channelId }
val members = channelClients[channelId] ?: emptyList()
if (channel != null) {
CurrentChannelBarContent(
channel = channel,
clients = members,
selfClientId = selfClientId,
)
}
}
}
}
}
@Composable
private fun CurrentChannelBarContent(
channel: ChannelInfo,
clients: List<ClientInfo>,
selfClientId: Int,
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.heightIn(min = UiTokens.Size.AppBarHeight)
.padding(
start = FOOTER_LEFT_INDENT,
top = UiTokens.Spacing.Medium,
end = FOOTER_RIGHT_INDENT,
bottom = UiTokens.Spacing.Medium,
),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) {
// 频道图标(tonal 圆)
Box(
modifier = Modifier
.size(UiTokens.Size.ControlHeight)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
Icons.Default.LocationOn, Icons.Default.LocationOn,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(UiTokens.Size.IconMedium), modifier = Modifier.size(UiTokens.Size.IconMedium),
) )
Spacer(Modifier.width(UiTokens.Spacing.Small)) }
Spacer(Modifier.width(UiTokens.Spacing.Medium))
// 频道名 + 在线人数
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = currentChannel.name, text = channel.name,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
) )
if (currentChannel.isPassword) { if (channel.isPassword) {
Spacer(Modifier.width(UiTokens.Spacing.ExtraSmall)) Spacer(Modifier.width(UiTokens.Spacing.ExtraSmall))
Icon( Icon(
Icons.Default.Lock, Icons.Default.Lock,
@@ -732,109 +790,85 @@ private fun CurrentChannelBar(
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
Spacer(Modifier.width(UiTokens.Spacing.Small)) }
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
) {
Text( Text(
text = if (currentChannel.maxClients >= 0) { text = "在线 ${clients.size} 人",
"${clients.size}/${currentChannel.maxClients}" style = MaterialTheme.typography.bodySmall,
} else {
"${clients.size}"
},
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = UiTokens.Spacing.Small, vertical = UiTokens.Spacing.ExtraSmall),
)
}
}
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val maxPreviewCount = when {
maxWidth >= 380.dp -> 5
maxWidth >= 320.dp -> 4
else -> 3
}
val visibleClients = previewCandidates.take(maxPreviewCount)
val hiddenClientCount = clients.size - visibleClients.size
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(UiTokens.Spacing.ExtraSmall),
verticalAlignment = Alignment.CenterVertically,
) {
visibleClients.forEach { client ->
val isSelf = client.id == selfClientId
val isGrouped = client.serverGroups.isNotEmpty()
MemberPreviewTag(
name = client.nickname,
containerColor = when {
isSelf -> MaterialTheme.colorScheme.primaryContainer
isGrouped -> MaterialTheme.colorScheme.tertiaryContainer
else -> MaterialTheme.colorScheme.surfaceContainerHighest
},
contentColor = when {
isSelf -> MaterialTheme.colorScheme.onPrimaryContainer
isGrouped -> MaterialTheme.colorScheme.onTertiaryContainer
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
showPresence = !isSelf && !isGrouped,
presenceColor = semanticColors.success,
modifier = Modifier.weight(1f, fill = false),
)
}
if (hiddenClientCount > 0) {
Text(
text = "+$hiddenClientCount",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
) )
} }
}
} // 成员头像堆叠
} MemberAvatarStack(
clients = clients,
selfClientId = selfClientId,
maxVisible = 3,
)
Spacer(Modifier.width(UiTokens.Spacing.Small))
} }
} }
@Composable @Composable
private fun MemberPreviewTag( private fun MemberAvatarStack(
name: String, clients: List<ClientInfo>,
containerColor: Color, selfClientId: Int,
contentColor: Color, maxVisible: Int = 3,
showPresence: Boolean = false,
presenceColor: Color = Color.Unspecified,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
shape = MaterialTheme.shapes.small,
color = containerColor,
contentColor = contentColor,
) { ) {
val visible = clients.sortedByDescending { it.id == selfClientId }.take(maxVisible)
val hidden = clients.size - visible.size
val avatarSize = 28.dp
Row( Row(
modifier = Modifier horizontalArrangement = Arrangement.spacedBy((-6).dp),
.widthIn(max = 96.dp)
.height(24.dp)
.padding(horizontal = UiTokens.Spacing.Small),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (showPresence) { visible.forEach { client ->
val isSelf = client.id == selfClientId
val isGrouped = client.serverGroups.isNotEmpty()
Box( Box(
modifier = Modifier modifier = Modifier
.size(6.dp) .size(avatarSize)
.background(presenceColor, CircleShape), .clip(CircleShape)
) .background(
Spacer(Modifier.width(UiTokens.Spacing.ExtraSmall)) when {
isSelf -> MaterialTheme.colorScheme.primaryContainer
isGrouped -> MaterialTheme.colorScheme.tertiaryContainer
else -> MaterialTheme.colorScheme.surfaceContainerHighest
} }
)
.border(
width = UiTokens.Border.Emphasized,
color = MaterialTheme.colorScheme.surface,
shape = CircleShape,
),
contentAlignment = Alignment.Center,
) {
Text( Text(
text = name, text = client.nickname.take(1).ifBlank { "?" },
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, color = when {
overflow = TextOverflow.Ellipsis, isSelf -> MaterialTheme.colorScheme.onPrimaryContainer
modifier = Modifier.weight(1f, fill = false), isGrouped -> MaterialTheme.colorScheme.onTertiaryContainer
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
if (hidden > 0) {
Box(
modifier = Modifier
.size(avatarSize)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
contentAlignment = Alignment.Center,
) {
Text(
text = "+$hidden",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
} }
}
@@ -28,6 +28,7 @@ import com.tsmobile.app.ui.components.ConnectButton
import com.tsmobile.app.ui.components.UpdateCheckDialog import com.tsmobile.app.ui.components.UpdateCheckDialog
import com.tsmobile.app.viewmodel.ConnectState import com.tsmobile.app.viewmodel.ConnectState
import com.tsmobile.app.viewmodel.ServerViewModel import com.tsmobile.app.viewmodel.ServerViewModel
import com.tsmobile.app.viewmodel.ValidationErrors
import com.tsmobile.app.viewmodel.formatRelativeTime import com.tsmobile.app.viewmodel.formatRelativeTime
/** /**
@@ -42,6 +43,8 @@ fun ServerConfigScreen(
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val recents by viewModel.recents.collectAsState() val recents by viewModel.recents.collectAsState()
val themeMode by viewModel.themeMode.collectAsState() val themeMode by viewModel.themeMode.collectAsState()
// 长按删除最近连接的确认目标(非 null 时弹出确认框)
var pendingRemoveRecent by remember { mutableStateOf<RecentConnection?>(null) }
Column( Column(
modifier = Modifier modifier = Modifier
@@ -66,6 +69,7 @@ fun ServerConfigScreen(
onPasswordChange = viewModel::updatePassword, onPasswordChange = viewModel::updatePassword,
connectState = state.connectState, connectState = state.connectState,
errorMessage = state.errorMessage, errorMessage = state.errorMessage,
validationErrors = state.validationErrors,
onConnect = { viewModel.connect() }, onConnect = { viewModel.connect() },
) )
@@ -74,7 +78,7 @@ fun ServerConfigScreen(
recents = recents, recents = recents,
enabled = state.connectState != ConnectState.CONNECTING, enabled = state.connectState != ConnectState.CONNECTING,
onConnectRecent = { recent -> viewModel.quickConnect(recent) }, onConnectRecent = { recent -> viewModel.quickConnect(recent) },
onRemoveRecent = { recent -> viewModel.removeRecent(recent) }, onRemoveRecent = { recent -> pendingRemoveRecent = recent },
onClearAll = { viewModel.clearRecents() }, onClearAll = { viewModel.clearRecents() },
) )
} }
@@ -97,6 +101,30 @@ fun ServerConfigScreen(
onClose = { viewModel.closeUpdateDialog() }, onClose = { viewModel.closeUpdateDialog() },
) )
} }
// 删除最近连接确认弹框
pendingRemoveRecent?.let { target ->
AlertDialog(
onDismissRequest = { pendingRemoveRecent = null },
title = { Text("删除最近连接") },
text = { Text("确定删除 ${target.address} 吗?此操作不会影响服务器。") },
confirmButton = {
TextButton(
onClick = {
viewModel.removeRecent(target)
pendingRemoveRecent = null
},
) {
Text("删除", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { pendingRemoveRecent = null }) {
Text("取消")
}
},
)
}
} }
/** /**
@@ -192,8 +220,12 @@ private fun InputSection(
onPasswordChange: (String) -> Unit, onPasswordChange: (String) -> Unit,
connectState: ConnectState, connectState: ConnectState,
errorMessage: String?, errorMessage: String?,
validationErrors: ValidationErrors,
onConnect: () -> Unit, onConnect: () -> Unit,
) { ) {
val addressValidation = validationErrors.address
val nicknameValidation = validationErrors.nickname
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -206,7 +238,10 @@ private fun InputSection(
label = { Text("服务器地址") }, label = { Text("服务器地址") },
placeholder = { Text("ts.example.com") }, placeholder = { Text("ts.example.com") },
singleLine = true, singleLine = true,
isError = connectState == ConnectState.FAILED && address.isBlank(), isError = addressValidation != null,
supportingText = if (addressValidation != null) {
{ Text(addressValidation, color = MaterialTheme.colorScheme.error) }
} else null,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
) )
@@ -220,7 +255,10 @@ private fun InputSection(
label = { Text("昵称") }, label = { Text("昵称") },
placeholder = { Text("我的昵称") }, placeholder = { Text("我的昵称") },
singleLine = true, singleLine = true,
isError = connectState == ConnectState.FAILED && nickname.isBlank(), isError = nicknameValidation != null,
supportingText = if (nicknameValidation != null) {
{ Text(nicknameValidation, color = MaterialTheme.colorScheme.error) }
} else null,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
) )
@@ -67,8 +67,9 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
// ChatViewModel 引用(由 ServerViewModel 设置) // ChatViewModel 引用(由 ServerViewModel 设置)。
var chatViewModel: ChatViewModel? = null // 主线程赋值,桥接回调解读,需 @Volatile 保证可见性。
@Volatile var chatViewModel: ChatViewModel? = null
// 频道列表最后刷新时间 // 频道列表最后刷新时间
private var lastChannelRefreshTime: Long = 0 private var lastChannelRefreshTime: Long = 0
@@ -80,14 +80,13 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
private val recentStore = RecentConnectionsStore(application) private val recentStore = RecentConnectionsStore(application)
private val identityStore = IdentityStore(application) private val identityStore = IdentityStore(application)
// ChannelViewModel 引用(由 NavGraph 设置) // ChannelViewModel 引用(由 NavGraph 设置)。
var channelViewModel: ChannelViewModel? = null // NavGraph 在主线程赋值,桥接回调解读,需 @Volatile 保证可见性,避免读到过期 null。
@Volatile var channelViewModel: ChannelViewModel? = null
// ChatViewModel 引用(由 NavGraph 设置) @Volatile var chatViewModel: ChatViewModel? = null
var chatViewModel: ChatViewModel? = null
// VoiceViewModel 引用(由 NavGraph 设置) @Volatile var voiceViewModel: VoiceViewModel? = null
var voiceViewModel: VoiceViewModel? = null
// 页面状态 // 页面状态
private val _state = MutableStateFlow(ServerScreenState()) private val _state = MutableStateFlow(ServerScreenState())
@@ -142,12 +141,16 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
val showPokeNotification: StateFlow<Boolean> = _showPokeNotification.asStateFlow() val showPokeNotification: StateFlow<Boolean> = _showPokeNotification.asStateFlow()
// ── 重连状态 ── // ── 重连状态 ──
private var reconnectAttempt = 0 // 这些字段被桥接回调线程(onDisconnected / onKicked)与主线程
private var reconnectJob: Job? = null // (init 网络收集器、手动操作)并发访问。加 @Volatile 保证跨线程可见性,
private var lastConnectParams: ConnectParams? = null // 并用 reconnectLock 串行化重连循环的启动/取消,避免并发启动两条重连。
private val reconnectLock = Any()
@Volatile private var reconnectAttempt = 0
@Volatile private var reconnectJob: Job? = null
@Volatile private var lastConnectParams: ConnectParams? = null
/** 断联前的频道 ID,用于伪重连后自动回到原频道(空字符串=不自动移动) */ /** 断联前的频道 ID,用于伪重连后自动回到原频道(空字符串=不自动移动) */
private var lastChannelId: String = "" @Volatile private var lastChannelId: String = ""
// ── 网络监控 ── // ── 网络监控 ──
private val networkMonitor = NetworkMonitor(application) private val networkMonitor = NetworkMonitor(application)
@@ -192,7 +195,7 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
lastChannelId = chId lastChannelId = chId
} }
_connectionState.value = ConnectionState.WaitingForNetwork() _connectionState.value = ConnectionState.WaitingForNetwork()
ConnectionService.stop(getApplication()) ConnectionService.update(getApplication(), "网络已断开,正在重连...", _state.value.address.trim())
if (networkMonitor.isAvailable.value) { if (networkMonitor.isAvailable.value) {
startReconnect("网络变化,正在重连...") startReconnect("网络变化,正在重连...")
} }
@@ -393,11 +396,23 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
// --- 输入更新 --- // --- 输入更新 ---
fun updateAddress(value: String) { fun updateAddress(value: String) {
_state.update { it.copy(address = value, errorMessage = null) } _state.update {
it.copy(
address = value,
errorMessage = null,
validationErrors = it.validationErrors.copy(address = null),
)
}
} }
fun updateNickname(value: String) { fun updateNickname(value: String) {
_state.update { it.copy(nickname = value, errorMessage = null) } _state.update {
it.copy(
nickname = value,
errorMessage = null,
validationErrors = it.validationErrors.copy(nickname = null),
)
}
} }
fun updatePassword(value: String) { fun updatePassword(value: String) {
@@ -783,8 +798,10 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
networkMonitor.reset() networkMonitor.reset()
// 1. 停止重连 // 1. 停止重连
synchronized(reconnectLock) {
reconnectJob?.let { it.cancel() } reconnectJob?.let { it.cancel() }
reconnectJob = null reconnectJob = null
}
// 2. 设置断开中状态 // 2. 设置断开中状态
_connectionState.value = ConnectionState.Disconnecting _connectionState.value = ConnectionState.Disconnecting
@@ -809,14 +826,14 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
// 6. 重置状态 // 6. 重置状态
_connectionState.value = null _connectionState.value = null
_state.update { it.copy(connectState = ConnectState.IDLE) } _state.update { it.copy(connectState = ConnectState.IDLE) }
lastConnectParams = null synchronized(reconnectLock) { lastConnectParams = null }
} }
/** /**
* 处理被踢后的重新连接 * 处理被踢后的重新连接
*/ */
fun reconnectAfterKick() { fun reconnectAfterKick() {
val params = lastConnectParams ?: return val params = synchronized(reconnectLock) { lastConnectParams } ?: return
_kickReason.value = "" _kickReason.value = ""
_connectionState.value = null _connectionState.value = null
@@ -836,13 +853,15 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
* 返回主页(被踢后或断开后) * 返回主页(被踢后或断开后)
*/ */
fun backToHome() { fun backToHome() {
synchronized(reconnectLock) {
reconnectJob?.let { it.cancel() } reconnectJob?.let { it.cancel() }
reconnectJob = null reconnectJob = null
lastChannelId = "" lastChannelId = ""
lastConnectParams = null
}
networkMonitor.reset() networkMonitor.reset()
_kickReason.value = "" _kickReason.value = ""
_connectionState.value = null _connectionState.value = null
lastConnectParams = null
Repository.clearSession() Repository.clearSession()
channelViewModel?.clearChannels() channelViewModel?.clearChannels()
_state.update { it.copy(connectState = ConnectState.IDLE) } _state.update { it.copy(connectState = ConnectState.IDLE) }
@@ -854,6 +873,9 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
* 最多尝试 5 次,间隔递增(2s → 4s → 8s → 16s → 30s) * 最多尝试 5 次,间隔递增(2s → 4s → 8s → 16s → 30s)
*/ */
private fun startReconnect(reason: String) { private fun startReconnect(reason: String) {
synchronized(reconnectLock) {
// 取消仍在运行的上一次循环,确保始终只有一条重连流程。
reconnectJob?.let { it.cancel() }
reconnectAttempt = 0 reconnectAttempt = 0
_connectionState.value = ConnectionState.Reconnecting( _connectionState.value = ConnectionState.Reconnecting(
attempt = 0, reason = reason attempt = 0, reason = reason
@@ -898,6 +920,7 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
// 重连失败 // 重连失败
android.util.Log.w(TAG, "All reconnect attempts failed") android.util.Log.w(TAG, "All reconnect attempts failed")
ConnectionService.stop(getApplication()) // 清除“重连中”通知
Repository.clearSession() Repository.clearSession()
channelViewModel?.clearChannels() channelViewModel?.clearChannels()
_connectionState.value = ConnectionState.Disconnected( _connectionState.value = ConnectionState.Disconnected(
@@ -905,17 +928,20 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
) )
} }
} }
}
/** /**
* 手动重连(用户点击"手动重连"按钮) * 手动重连(用户点击"手动重连"按钮)
*/ */
fun manualReconnect() { fun manualReconnect() {
synchronized(reconnectLock) {
reconnectJob?.let { it.cancel() } reconnectJob?.let { it.cancel() }
reconnectJob = null reconnectJob = null
reconnectAttempt = 0 reconnectAttempt = 0
}
val params = lastConnectParams ?: return val params = synchronized(reconnectLock) { lastConnectParams } ?: return
_connectionState.value = ConnectionState.Reconnecting(attempt = 0, reason = "手动重连") _connectionState.value = ConnectionState.Reconnecting(attempt = 0, reason = "手动重连")
ConnectionService.update(getApplication(), "正在重连...", _state.value.address.trim())
viewModelScope.launch { viewModelScope.launch {
val error = withContext(Dispatchers.IO) { val error = withContext(Dispatchers.IO) {
@@ -945,18 +971,20 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
* 放弃重连 * 放弃重连
*/ */
fun abandonReconnect() { fun abandonReconnect() {
synchronized(reconnectLock) {
reconnectJob?.let { it.cancel() } reconnectJob?.let { it.cancel() }
reconnectJob = null reconnectJob = null
reconnectAttempt = 0 reconnectAttempt = 0
lastChannelId = "" lastChannelId = ""
lastConnectParams = null
}
networkMonitor.reset() networkMonitor.reset()
ConnectionService.stop(getApplication()) // 清除“重连中”通知
Repository.clearSession() Repository.clearSession()
channelViewModel?.clearChannels() channelViewModel?.clearChannels()
_connectionState.value = ConnectionState.Disconnected( _connectionState.value = ConnectionState.Disconnected(
reason = "重连已放弃", wasKicked = false reason = "重连已放弃", wasKicked = false
) )
lastConnectParams = null
_state.update { it.copy(connectState = ConnectState.IDLE) } _state.update { it.copy(connectState = ConnectState.IDLE) }
} }
@@ -1020,8 +1048,11 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
// 伪重连:回到断联前的频道(避开僵尸清理窗口) // 伪重连:回到断联前的频道(避开僵尸清理窗口)
// 注意:此时不设置 Connected —— 遮罩保持显示,防止用户干扰自动操作 // 注意:此时不设置 Connected —— 遮罩保持显示,防止用户干扰自动操作
val targetChannel = lastChannelId val targetChannel = synchronized(reconnectLock) {
val saved = lastChannelId
lastChannelId = "" // 一次性消费 lastChannelId = "" // 一次性消费
saved
}
if (targetChannel.isNotEmpty() && targetChannel != "0") { if (targetChannel.isNotEmpty() && targetChannel != "0") {
// 等待 4 秒:Go 侧 cleanupDuplicateIdentitySessions 在 3 秒后 Kick 僵尸 // 等待 4 秒:Go 侧 cleanupDuplicateIdentitySessions 在 3 秒后 Kick 僵尸
delay(4_000L) delay(4_000L)
@@ -1077,17 +1108,14 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
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, stopping notification") android.util.Log.d(TAG, "onDisconnected while already handling reconnect, keeping reconnecting notification")
ConnectionService.stop(getApplication()) ConnectionService.update(getApplication(), "连接已断开,正在重连...", _state.value.address.trim())
return return
} }
// 语音断开处理 // 语音断开处理
voiceViewModel?.onDisconnected() voiceViewModel?.onDisconnected()
// 停止前台服务
ConnectionService.stop(getApplication())
// 判断断线原因:网络丢失 vs 服务器主动断线 // 判断断线原因:网络丢失 vs 服务器主动断线
// - ConnectivityManager 明确检测到网络切换 → 网络丢失 // - ConnectivityManager 明确检测到网络切换 → 网络丢失
// - 断线原因为空或 timeout → TCP 静默断开(NAT 超时、运营商丢弃等), // - 断线原因为空或 timeout → TCP 静默断开(NAT 超时、运营商丢弃等),
@@ -1098,10 +1126,12 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
message.contains("timeout", ignoreCase = true) message.contains("timeout", ignoreCase = true)
if (isNetworkLoss) { if (isNetworkLoss) {
// 网络丢失 → 重连,通知显示“重连中”(保留前台服务与保活)
ConnectionService.update(getApplication(), "连接已断开,正在重连...", _state.value.address.trim())
// 记录当前频道,供重连后自动回到原频道 // 记录当前频道,供重连后自动回到原频道
val currentChId = Repository.currentChannelId.value val currentChId = Repository.currentChannelId.value
if (currentChId != "0" && currentChId.isNotEmpty()) { if (currentChId != "0" && currentChId.isNotEmpty()) {
lastChannelId = currentChId synchronized(reconnectLock) { lastChannelId = currentChId }
android.util.Log.i(TAG, "Saved lastChannelId=$lastChannelId for pseudo-reconnect") android.util.Log.i(TAG, "Saved lastChannelId=$lastChannelId for pseudo-reconnect")
} }
@@ -1110,9 +1140,10 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
android.util.Log.i(TAG, "Network-loss disconnect detected (fallback path)") android.util.Log.i(TAG, "Network-loss disconnect detected (fallback path)")
startReconnect("网络变化,正在重连...") startReconnect("网络变化,正在重连...")
} else { } else {
// 服务器主动断线 — 清理会话,回到首页 // 服务器主动断线 — 清除通知、清理会话,回到首页
android.util.Log.w(TAG, "Server-initiated disconnect: $message") android.util.Log.w(TAG, "Server-initiated disconnect: $message")
lastChannelId = "" ConnectionService.stop(getApplication())
synchronized(reconnectLock) { lastChannelId = "" }
Repository.clearSession() Repository.clearSession()
channelViewModel?.clearChannels() channelViewModel?.clearChannels()
_connectionState.value = ConnectionState.Disconnected( _connectionState.value = ConnectionState.Disconnected(
@@ -1196,8 +1227,10 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
ConnectionService.stop(getApplication()) ConnectionService.stop(getApplication())
// 停止重连 // 停止重连
synchronized(reconnectLock) {
reconnectJob?.let { it.cancel() } reconnectJob?.let { it.cancel() }
reconnectJob = null reconnectJob = null
}
// 停止语音 // 停止语音
voiceViewModel?.stopVoice() voiceViewModel?.stopVoice()
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- FileProvider 路径映射:暴露 cacheDir/logs 下的日志文件用于分享 -->
<paths>
<cache-path name="logs" path="logs/" />
</paths>
@@ -0,0 +1,37 @@
package com.tsmobile.app.data
import org.junit.Assert.assertEquals
import org.junit.Test
class EmojiMapperTest {
@Test
fun mapsKnownShortcodes() {
assertEquals("😄🔥❤️", EmojiMapper.replace(":smile::fire::heart:"))
}
@Test
fun mapsTeamSpeakShortcodes() {
assertEquals("🥺🐹👴", EmojiMapper.replace(":pleading::hamster::older_man:"))
}
@Test
fun mapsMixedText() {
assertEquals("Hello 😄 world", EmojiMapper.replace("Hello :smile: world"))
}
@Test
fun keepsUnknownShortcodesUnchanged() {
assertEquals(":not_a_real_emoji:", EmojiMapper.replace(":not_a_real_emoji:"))
}
@Test
fun leavesPlainTextUntouched() {
assertEquals("plain text 123", EmojiMapper.replace("plain text 123"))
}
@Test
fun handlesAdjacentShortcodesWithoutSpaces() {
assertEquals("😄❤️", EmojiMapper.replace(":smile::heart:"))
}
}