UI/动画、交互与并发改进,与 emoji 短码完整映射
1. 交互动画:页面横移切换(登录页↔频道页、频道页↔聊天页、断开回登录页),顶部/底部栏横移过渡 2. UI 美化:重做底部当前频道栏(圆角卡片+在线人数+成员头像堆叠);聊天页输入栏圆角停靠卡、消息气泡去冗余昵称、头部在线人数;配置页字段级校验提示与最近连接删除确认弹框;PTT 按住说话震动反馈 3. 并发修复:重连状态机跨线程访问加锁与 @Volatile;桥接 ViewModel 引用、client/currentIdentity 可见性 4. emoji 短码完整映射(gemoji 1973 条 + TeamSpeak 别名 pleading),附单元测试
This commit is contained in:
@@ -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:"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user