UI/动画、交互与并发改进,与 emoji 短码完整映射

1. 交互动画:页面横移切换(登录页↔频道页、频道页↔聊天页、断开回登录页),顶部/底部栏横移过渡
2. UI 美化:重做底部当前频道栏(圆角卡片+在线人数+成员头像堆叠);聊天页输入栏圆角停靠卡、消息气泡去冗余昵称、头部在线人数;配置页字段级校验提示与最近连接删除确认弹框;PTT 按住说话震动反馈
3. 并发修复:重连状态机跨线程访问加锁与 @Volatile;桥接 ViewModel 引用、client/currentIdentity 可见性
4. emoji 短码完整映射(gemoji 1973 条 + TeamSpeak 别名 pleading),附单元测试
This commit is contained in:
sansen
2026-09-08 12:44:33 +08:00
parent 4d06ff2ade
commit d2f0e95441
14 changed files with 2464 additions and 252 deletions
@@ -19,8 +19,13 @@ import java.lang.reflect.Proxy
object TSBridge {
private val connectLock = Any()
// @Volatile:client 被主线程/IO 协程写入(connect/disconnect),
// 被多个 getter 线程读取;保证可见性,避免读到过期的 client 引用。
@Volatile
private var client: TSClient? = null
private var connectionGeneration: Long = 0
// @Volatile:connect() 在持有 connectLock 之前读取它,需保证可见性。
@Volatile
private var currentIdentity: String = ""
private fun beginConnection(identity: String): Pair<TSClient, Long> = synchronized(connectLock) {
@@ -81,7 +86,7 @@ object TSBridge {
defaultChannelPassword: String = "",
callbacks: Callbacks,
): String {
val identity = currentIdentity
val identity = currentIdentity()
val (tsClient, generation) = beginConnection(identity)
val error = doConnect(tsClient, generation, callbacks) { 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
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.Column
import androidx.compose.foundation.layout.Row
@@ -58,23 +68,36 @@ fun AppTopBar(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
subtitle?.takeIf { it.isNotEmpty() }?.let {
// 标题文字横移切换:旧文字向左滑出,新文字从右滑入。
AnimatedContent(
targetState = title,
transitionSpec = { headerTextSlide() },
label = "appTopBarTitle",
) { t ->
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
text = t,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
subtitle?.takeIf { it.isNotEmpty() }?.let { s ->
AnimatedContent(
targetState = s,
transitionSpec = { headerTextSlide() },
label = "appTopBarSubtitle",
) { subText ->
Text(
text = subText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
if (actionIcon != null && onActionClick != null) {
@@ -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
import android.util.Log
import com.tsmobile.app.data.EmojiMapper
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
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 stack = ArrayDeque<RunStyle>()
var current = RunStyle()
@@ -15,7 +15,7 @@ fun ChatHeader(
) {
AppTopBar(
title = channelName,
subtitle = "${memberCount}人",
subtitle = "${memberCount} 人在线",
navigationIcon = Icons.AutoMirrored.Filled.ArrowBack,
navigationContentDescription = "返回",
onNavigationClick = onBack,
@@ -5,11 +5,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
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.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.tsmobile.app.data.MessageSendState
@@ -29,9 +30,10 @@ fun MessageInputBar(
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 3.dp,
shadowElevation = 3.dp
// 与下方语音栏同底色,顶部圆角,读成“底部停靠的一组”,而非孤立的浮条。
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 1.dp,
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
@@ -52,7 +54,7 @@ fun MessageInputBar(
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant
unfocusedBorderColor = Color.Transparent,
),
maxLines = 4,
keyboardOptions = KeyboardOptions(
@@ -90,7 +92,7 @@ fun MessageInputBar(
)
) {
Icon(
imageVector = Icons.Default.Send,
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "发送"
)
}
@@ -26,6 +26,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.tsmobile.app.data.ChatMessage
import com.tsmobile.app.data.EmojiMapper
import com.tsmobile.app.data.FileDownloadManager
import com.tsmobile.app.data.FileMessageMeta
import com.tsmobile.app.data.MessageDeliveryState
@@ -58,7 +59,7 @@ fun MessageItem(message: ChatMessage) {
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = message.content,
text = EmojiMapper.replace(message.content),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -112,14 +113,6 @@ fun MessageItem(message: ChatMessage) {
style = MaterialTheme.typography.labelSmall,
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))
@@ -158,7 +151,7 @@ fun MessageItem(message: ChatMessage) {
tonalElevation = 1.dp
) {
Text(
text = message.content,
text = EmojiMapper.replace(message.content),
style = MaterialTheme.typography.bodyMedium,
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.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
@@ -37,6 +39,7 @@ fun PTTButton(
val semanticColors = MaterialTheme.semanticColors
val currentOnStartTransmit by rememberUpdatedState(onStartTransmit)
val currentOnStopTransmit by rememberUpdatedState(onStopTransmit)
val haptic = LocalHapticFeedback.current
Button(
onClick = {},
@@ -51,6 +54,8 @@ fun PTTButton(
if (!enabled) return@pointerInput
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
// 按下时给予触觉反馈,确认发言动作已开始。
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
currentOnStartTransmit()
try {
waitForUpOrCancellation()
@@ -3,6 +3,12 @@ package com.tsmobile.app.ui.navigation
import androidx.activity.compose.BackHandler
import androidx.compose.animation.EnterTransition
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.fillMaxSize
import androidx.compose.runtime.Composable
@@ -128,11 +134,46 @@ fun AppNavGraph(
NavHost(
navController = navController,
startDestination = Routes.SERVER_CONFIG,
// 禁用默认的淡入淡出动画,避免页面切换显得迟钝
enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None },
popEnterTransition = { EnterTransition.None },
popExitTransition = { ExitTransition.None },
// 默认不动画;仅聊天页做“从右滑入/滑出”的过渡,避免整套页面切换显得迟钝。
enterTransition = {
when (targetState.destination.route) {
// 前进到主界面(首页→频道页、频道页→聊天页):从右侧滑入。
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) {
@@ -240,4 +281,3 @@ fun AppNavGraph(
)
}
}
@@ -3,8 +3,17 @@ package com.tsmobile.app.ui.screens
import android.content.ClipData
import android.content.ClipboardManager
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.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
@@ -17,8 +26,10 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -681,49 +692,96 @@ private fun CurrentChannelBar(
val currentChannelId by channelViewModel.currentChannelId.collectAsState()
val channels by channelViewModel.channels.collectAsState()
val channelClients by channelViewModel.channelClients.collectAsState()
val selfClientId by channelViewModel.selfClientId.collectAsState()
val currentChannel = channels.find { it.id == currentChannelId }
val clients = channelClients[currentChannelId] ?: emptyList()
if (currentChannel != null) {
val selfClientId by channelViewModel.selfClientId.collectAsState()
val previewCandidates = clients.sortedByDescending { it.id == selfClientId }
val semanticColors = MaterialTheme.semanticColors
Column(
Surface(
modifier = Modifier
.fillMaxWidth()
.height(68.dp)
.clickable(onClick = onClick)
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(
start = FOOTER_LEFT_INDENT,
end = FOOTER_RIGHT_INDENT,
top = UiTokens.Spacing.ExtraSmall,
bottom = UiTokens.Spacing.ExtraSmall,
),
verticalArrangement = Arrangement.spacedBy(UiTokens.Spacing.ExtraSmall),
.clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp))
.clickable(onClick = onClick),
color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = UiTokens.Elevation.Subtle,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.LocationOn,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(UiTokens.Size.IconMedium),
)
Spacer(Modifier.width(UiTokens.Spacing.Small))
// 切换频道时,当前频道栏内容做淡入淡出 + 轻微横向滑入动画。
AnimatedContent(
targetState = currentChannelId,
transitionSpec = {
(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(
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,
) {
// 频道图标(tonal 圆)
Box(
modifier = Modifier
.size(UiTokens.Size.ControlHeight)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.LocationOn,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(UiTokens.Size.IconMedium),
)
}
Spacer(Modifier.width(UiTokens.Spacing.Medium))
// 频道名 + 在线人数
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = currentChannel.name,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
text = channel.name,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (currentChannel.isPassword) {
if (channel.isPassword) {
Spacer(Modifier.width(UiTokens.Spacing.ExtraSmall))
Icon(
Icons.Default.Lock,
@@ -732,109 +790,85 @@ private fun CurrentChannelBar(
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 = if (currentChannel.maxClients >= 0) {
"${clients.size}/${currentChannel.maxClients}"
} 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,
maxLines = 1,
)
}
}
}
Text(
text = "在线 ${clients.size} 人",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// 成员头像堆叠
MemberAvatarStack(
clients = clients,
selfClientId = selfClientId,
maxVisible = 3,
)
Spacer(Modifier.width(UiTokens.Spacing.Small))
}
}
@Composable
private fun MemberPreviewTag(
name: String,
containerColor: Color,
contentColor: Color,
showPresence: Boolean = false,
presenceColor: Color = Color.Unspecified,
modifier: Modifier = Modifier,
private fun MemberAvatarStack(
clients: List<ClientInfo>,
selfClientId: Int,
maxVisible: Int = 3,
) {
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(
horizontalArrangement = Arrangement.spacedBy((-6).dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier
.widthIn(max = 96.dp)
.height(24.dp)
.padding(horizontal = UiTokens.Spacing.Small),
verticalAlignment = Alignment.CenterVertically,
) {
if (showPresence) {
Box(
modifier = Modifier
.size(6.dp)
.background(presenceColor, CircleShape),
visible.forEach { client ->
val isSelf = client.id == selfClientId
val isGrouped = client.serverGroups.isNotEmpty()
Box(
modifier = Modifier
.size(avatarSize)
.clip(CircleShape)
.background(
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 = client.nickname.take(1).ifBlank { "?" },
style = MaterialTheme.typography.labelSmall,
color = when {
isSelf -> MaterialTheme.colorScheme.onPrimaryContainer
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,
)
Spacer(Modifier.width(UiTokens.Spacing.ExtraSmall))
}
Text(
text = name,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
}
}
}
@@ -28,6 +28,7 @@ import com.tsmobile.app.ui.components.ConnectButton
import com.tsmobile.app.ui.components.UpdateCheckDialog
import com.tsmobile.app.viewmodel.ConnectState
import com.tsmobile.app.viewmodel.ServerViewModel
import com.tsmobile.app.viewmodel.ValidationErrors
import com.tsmobile.app.viewmodel.formatRelativeTime
/**
@@ -42,6 +43,8 @@ fun ServerConfigScreen(
val state by viewModel.state.collectAsState()
val recents by viewModel.recents.collectAsState()
val themeMode by viewModel.themeMode.collectAsState()
// 长按删除最近连接的确认目标(非 null 时弹出确认框)
var pendingRemoveRecent by remember { mutableStateOf<RecentConnection?>(null) }
Column(
modifier = Modifier
@@ -66,6 +69,7 @@ fun ServerConfigScreen(
onPasswordChange = viewModel::updatePassword,
connectState = state.connectState,
errorMessage = state.errorMessage,
validationErrors = state.validationErrors,
onConnect = { viewModel.connect() },
)
@@ -74,7 +78,7 @@ fun ServerConfigScreen(
recents = recents,
enabled = state.connectState != ConnectState.CONNECTING,
onConnectRecent = { recent -> viewModel.quickConnect(recent) },
onRemoveRecent = { recent -> viewModel.removeRecent(recent) },
onRemoveRecent = { recent -> pendingRemoveRecent = recent },
onClearAll = { viewModel.clearRecents() },
)
}
@@ -97,6 +101,30 @@ fun ServerConfigScreen(
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,
connectState: ConnectState,
errorMessage: String?,
validationErrors: ValidationErrors,
onConnect: () -> Unit,
) {
val addressValidation = validationErrors.address
val nicknameValidation = validationErrors.nickname
Column(
modifier = Modifier
.fillMaxWidth()
@@ -206,7 +238,10 @@ private fun InputSection(
label = { Text("服务器地址") },
placeholder = { Text("ts.example.com") },
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(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
)
@@ -220,7 +255,10 @@ private fun InputSection(
label = { Text("昵称") },
placeholder = { Text("我的昵称") },
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(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
)
@@ -67,8 +67,9 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
private val json = Json { ignoreUnknownKeys = true }
// ChatViewModel 引用(由 ServerViewModel 设置)
var chatViewModel: ChatViewModel? = null
// ChatViewModel 引用(由 ServerViewModel 设置)。
// 主线程赋值,桥接回调解读,需 @Volatile 保证可见性。
@Volatile var chatViewModel: ChatViewModel? = null
// 频道列表最后刷新时间
private var lastChannelRefreshTime: Long = 0
@@ -80,14 +80,13 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
private val recentStore = RecentConnectionsStore(application)
private val identityStore = IdentityStore(application)
// ChannelViewModel 引用(由 NavGraph 设置)
var channelViewModel: ChannelViewModel? = null
// ChannelViewModel 引用(由 NavGraph 设置)。
// NavGraph 在主线程赋值,桥接回调解读,需 @Volatile 保证可见性,避免读到过期 null。
@Volatile var channelViewModel: ChannelViewModel? = null
// ChatViewModel 引用(由 NavGraph 设置)
var chatViewModel: ChatViewModel? = null
@Volatile var chatViewModel: ChatViewModel? = null
// VoiceViewModel 引用(由 NavGraph 设置)
var voiceViewModel: VoiceViewModel? = null
@Volatile var voiceViewModel: VoiceViewModel? = null
// 页面状态
private val _state = MutableStateFlow(ServerScreenState())
@@ -142,12 +141,16 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
val showPokeNotification: StateFlow<Boolean> = _showPokeNotification.asStateFlow()
// ── 重连状态 ──
private var reconnectAttempt = 0
private var reconnectJob: Job? = null
private var lastConnectParams: ConnectParams? = null
// 这些字段被桥接回调线程(onDisconnected / onKicked)与主线程
// (init 网络收集器、手动操作)并发访问。加 @Volatile 保证跨线程可见性,
// 并用 reconnectLock 串行化重连循环的启动/取消,避免并发启动两条重连。
private val reconnectLock = Any()
@Volatile private var reconnectAttempt = 0
@Volatile private var reconnectJob: Job? = null
@Volatile private var lastConnectParams: ConnectParams? = null
/** 断联前的频道 ID,用于伪重连后自动回到原频道(空字符串=不自动移动) */
private var lastChannelId: String = ""
@Volatile private var lastChannelId: String = ""
// ── 网络监控 ──
private val networkMonitor = NetworkMonitor(application)
@@ -393,11 +396,23 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
// --- 输入更新 ---
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) {
_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) {
@@ -783,8 +798,10 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
networkMonitor.reset()
// 1. 停止重连
reconnectJob?.let { it.cancel() }
reconnectJob = null
synchronized(reconnectLock) {
reconnectJob?.let { it.cancel() }
reconnectJob = null
}
// 2. 设置断开中状态
_connectionState.value = ConnectionState.Disconnecting
@@ -809,14 +826,14 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
// 6. 重置状态
_connectionState.value = null
_state.update { it.copy(connectState = ConnectState.IDLE) }
lastConnectParams = null
synchronized(reconnectLock) { lastConnectParams = null }
}
/**
* 处理被踢后的重新连接
*/
fun reconnectAfterKick() {
val params = lastConnectParams ?: return
val params = synchronized(reconnectLock) { lastConnectParams } ?: return
_kickReason.value = ""
_connectionState.value = null
@@ -836,13 +853,15 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
* 返回主页(被踢后或断开后)
*/
fun backToHome() {
reconnectJob?.let { it.cancel() }
reconnectJob = null
lastChannelId = ""
synchronized(reconnectLock) {
reconnectJob?.let { it.cancel() }
reconnectJob = null
lastChannelId = ""
lastConnectParams = null
}
networkMonitor.reset()
_kickReason.value = ""
_connectionState.value = null
lastConnectParams = null
Repository.clearSession()
channelViewModel?.clearChannels()
_state.update { it.copy(connectState = ConnectState.IDLE) }
@@ -854,56 +873,60 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
* 最多尝试 5 次,间隔递增(2s → 4s → 8s → 16s → 30s)
*/
private fun startReconnect(reason: String) {
reconnectAttempt = 0
_connectionState.value = ConnectionState.Reconnecting(
attempt = 0, reason = reason
)
reconnectJob = viewModelScope.launch {
while (reconnectAttempt < MAX_RECONNECT_ATTEMPTS &&
_connectionState.value is ConnectionState.Reconnecting
) {
val delayMs = RECONNECT_DELAYS[reconnectAttempt]
android.util.Log.i(TAG, "Reconnect attempt ${reconnectAttempt + 1}/$MAX_RECONNECT_ATTEMPTS in ${delayMs}ms")
delay(delayMs)
reconnectAttempt++
_connectionState.value = ConnectionState.Reconnecting(
attempt = reconnectAttempt, reason = reason
)
val params = lastConnectParams ?: break
val error = withContext(Dispatchers.IO) {
val identity = identityStore.getOrCreate { Teamspeak.generateIdentity(8) }
if (identity.isBlank()) return@withContext "身份生成失败"
TSBridge.connectWithIdentity(
identity = identity,
host = params.host,
nickname = params.nickname,
password = params.password,
defaultChannel = params.defaultChannel,
defaultChannelPassword = params.defaultChannelPassword,
callbacks = createBridgeCallbacks(),
)
}
if (error.isEmpty()) {
android.util.Log.i(TAG, "Reconnect succeeded on attempt $reconnectAttempt")
// 重连成功由 onConnected 回调处理
return@launch
}
android.util.Log.w(TAG, "Reconnect attempt $reconnectAttempt failed: $error")
}
// 重连失败
android.util.Log.w(TAG, "All reconnect attempts failed")
ConnectionService.stop(getApplication()) // 清除“重连中”通知
Repository.clearSession()
channelViewModel?.clearChannels()
_connectionState.value = ConnectionState.Disconnected(
reason = reason, wasKicked = false
synchronized(reconnectLock) {
// 取消仍在运行的上一次循环,确保始终只有一条重连流程。
reconnectJob?.let { it.cancel() }
reconnectAttempt = 0
_connectionState.value = ConnectionState.Reconnecting(
attempt = 0, reason = reason
)
reconnectJob = viewModelScope.launch {
while (reconnectAttempt < MAX_RECONNECT_ATTEMPTS &&
_connectionState.value is ConnectionState.Reconnecting
) {
val delayMs = RECONNECT_DELAYS[reconnectAttempt]
android.util.Log.i(TAG, "Reconnect attempt ${reconnectAttempt + 1}/$MAX_RECONNECT_ATTEMPTS in ${delayMs}ms")
delay(delayMs)
reconnectAttempt++
_connectionState.value = ConnectionState.Reconnecting(
attempt = reconnectAttempt, reason = reason
)
val params = lastConnectParams ?: break
val error = withContext(Dispatchers.IO) {
val identity = identityStore.getOrCreate { Teamspeak.generateIdentity(8) }
if (identity.isBlank()) return@withContext "身份生成失败"
TSBridge.connectWithIdentity(
identity = identity,
host = params.host,
nickname = params.nickname,
password = params.password,
defaultChannel = params.defaultChannel,
defaultChannelPassword = params.defaultChannelPassword,
callbacks = createBridgeCallbacks(),
)
}
if (error.isEmpty()) {
android.util.Log.i(TAG, "Reconnect succeeded on attempt $reconnectAttempt")
// 重连成功由 onConnected 回调处理
return@launch
}
android.util.Log.w(TAG, "Reconnect attempt $reconnectAttempt failed: $error")
}
// 重连失败
android.util.Log.w(TAG, "All reconnect attempts failed")
ConnectionService.stop(getApplication()) // 清除“重连中”通知
Repository.clearSession()
channelViewModel?.clearChannels()
_connectionState.value = ConnectionState.Disconnected(
reason = reason, wasKicked = false
)
}
}
}
@@ -911,11 +934,12 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
* 手动重连(用户点击"手动重连"按钮)
*/
fun manualReconnect() {
reconnectJob?.let { it.cancel() }
reconnectJob = null
reconnectAttempt = 0
val params = lastConnectParams ?: return
synchronized(reconnectLock) {
reconnectJob?.let { it.cancel() }
reconnectJob = null
reconnectAttempt = 0
}
val params = synchronized(reconnectLock) { lastConnectParams } ?: return
_connectionState.value = ConnectionState.Reconnecting(attempt = 0, reason = "手动重连")
ConnectionService.update(getApplication(), "正在重连...", _state.value.address.trim())
@@ -947,11 +971,13 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
* 放弃重连
*/
fun abandonReconnect() {
reconnectJob?.let { it.cancel() }
reconnectJob = null
reconnectAttempt = 0
lastChannelId = ""
synchronized(reconnectLock) {
reconnectJob?.let { it.cancel() }
reconnectJob = null
reconnectAttempt = 0
lastChannelId = ""
lastConnectParams = null
}
networkMonitor.reset()
ConnectionService.stop(getApplication()) // 清除“重连中”通知
Repository.clearSession()
@@ -959,7 +985,6 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
_connectionState.value = ConnectionState.Disconnected(
reason = "重连已放弃", wasKicked = false
)
lastConnectParams = null
_state.update { it.copy(connectState = ConnectState.IDLE) }
}
@@ -1023,8 +1048,11 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
// 伪重连:回到断联前的频道(避开僵尸清理窗口)
// 注意:此时不设置 Connected —— 遮罩保持显示,防止用户干扰自动操作
val targetChannel = lastChannelId
lastChannelId = "" // 一次性消费
val targetChannel = synchronized(reconnectLock) {
val saved = lastChannelId
lastChannelId = "" // 一次性消费
saved
}
if (targetChannel.isNotEmpty() && targetChannel != "0") {
// 等待 4 秒:Go 侧 cleanupDuplicateIdentitySessions 在 3 秒后 Kick 僵尸
delay(4_000L)
@@ -1103,7 +1131,7 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
// 记录当前频道,供重连后自动回到原频道
val currentChId = Repository.currentChannelId.value
if (currentChId != "0" && currentChId.isNotEmpty()) {
lastChannelId = currentChId
synchronized(reconnectLock) { lastChannelId = currentChId }
android.util.Log.i(TAG, "Saved lastChannelId=$lastChannelId for pseudo-reconnect")
}
@@ -1115,7 +1143,7 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
// 服务器主动断线 — 清除通知、清理会话,回到首页
android.util.Log.w(TAG, "Server-initiated disconnect: $message")
ConnectionService.stop(getApplication())
lastChannelId = ""
synchronized(reconnectLock) { lastChannelId = "" }
Repository.clearSession()
channelViewModel?.clearChannels()
_connectionState.value = ConnectionState.Disconnected(
@@ -1199,8 +1227,10 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
ConnectionService.stop(getApplication())
// 停止重连
reconnectJob?.let { it.cancel() }
reconnectJob = null
synchronized(reconnectLock) {
reconnectJob?.let { it.cancel() }
reconnectJob = null
}
// 停止语音
voiceViewModel?.stopVoice()
@@ -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:"))
}
}