4 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
sansen f27ba9509d 1. 修复真机崩溃:消息 ID 唯一化(LazyColumn 重复 key)+ 消息列表滚动先等布局完成(layout state not idle)
2. 新增 BBCode 解析:服务器欢迎消息/频道描述支持 b/i/u/s/color/size/url,链接可点击,病态输入不崩溃
3. 语音卡成员长按波纹圆角
4. 版本号提升至 1.0.10(versionCode 10)
2026-08-25 13:17:45 +08:00
23 changed files with 2763 additions and 296 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.tsmobile.app"
minSdk = 26
targetSdk = 35
versionCode = 9
versionName = "1.0.9"
versionCode = 12
versionName = "1.0.12"
}
signingConfigs {
+11
View File
@@ -48,6 +48,17 @@
android:name=".ConnectionService"
android:foregroundServiceType="dataSync"
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>
</manifest>
@@ -56,6 +56,52 @@ class ConnectionService : LifecycleService() {
fun stop(context: Context) {
android.util.Log.i(TAG, "Stopping connection service")
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 {
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")
return Service.START_NOT_STICKY
@@ -114,6 +160,9 @@ class ConnectionService : LifecycleService() {
override fun onDestroy() {
releaseLocks()
// 显式移除前台通知,确保断开/停止服务后通知被清除,而不是残留“已连接”
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
cancelNotification(this)
super.onDestroy()
android.util.Log.i(TAG, "Connection service destroyed, locks released")
}
@@ -180,24 +229,6 @@ class ConnectionService : LifecycleService() {
nm.createNotificationChannel(channel)
}
private fun buildNotification(serverName: String): Notification {
val pendingIntent = PendingIntent.getActivity(
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()
}
private fun buildNotification(title: String, contentText: String): Notification =
buildNotification(this, title, contentText)
}
@@ -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,16 +68,28 @@ fun AppTopBar(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
// 标题文字横移切换:旧文字向左滑出,新文字从右滑入。
AnimatedContent(
targetState = title,
transitionSpec = { headerTextSlide() },
label = "appTopBarTitle",
) { t ->
Text(
text = title,
text = t,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
subtitle?.takeIf { it.isNotEmpty() }?.let {
}
subtitle?.takeIf { it.isNotEmpty() }?.let { s ->
AnimatedContent(
targetState = s,
transitionSpec = { headerTextSlide() },
label = "appTopBarSubtitle",
) { subText ->
Text(
text = it,
text = subText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
@@ -76,6 +98,7 @@ fun AppTopBar(
)
}
}
}
if (actionIcon != null && onActionClick != null) {
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))
)
@@ -0,0 +1,172 @@
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
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
/**
* 轻量 BBCode → AnnotatedString 解析器,用于服务器欢迎消息 / 频道描述等富文本。
*
* 支持标签:
* - [b] [i] [u] [s] [color=#RRGGBB|name] [size=N] [url=http://...][url]
*
* 不支持(原样保留内容、忽略标签):[img] [list] [table] 等。
* 未知标签会被安全忽略,不会因为畸形 BBCode 崩溃。
*/
object Bbcode {
private data class RunStyle(
val bold: Boolean = false,
val italic: Boolean = false,
val underline: Boolean = false,
val strike: Boolean = false,
val color: Color? = null,
val size: TextUnit? = null,
val url: String? = null,
)
private val tagRegex = Regex("""\[/?([A-Za-z]+)(?:=([^\]]*))?]""")
/** 链接默认颜色(Material 蓝) */
private val linkColor = Color(0xFF1A73E8)
fun parse(raw: String): AnnotatedString {
return try {
parseInternal(raw)
} catch (e: Exception) {
// BBCode 解析绝不应因畸形输入让 App 崩溃:任何异常都退化为纯文本
Log.d("Bbcode", "parse failed, raw=${raw.take(120)}", e)
AnnotatedString(raw)
}
}
private fun parseInternal(rawInput: String): AnnotatedString {
// 先做 emoji 短码替换,再渲染 BBCode。
val raw = EmojiMapper.replace(rawInput)
val builder = AnnotatedString.Builder()
val stack = ArrayDeque<RunStyle>()
var current = RunStyle()
var i = 0
while (i < raw.length) {
if (raw[i] == '[') {
val m = tagRegex.find(raw, i)
if (m != null && m.range.first == i) {
val isClose = m.value.getOrNull(1) == '/'
// 用 getOrNull 防 Kotlin Regex groupValues 越界(No group N)导致崩溃
val tag = m.groupValues.getOrNull(1)?.lowercase() ?: ""
val value = m.groupValues.getOrNull(2)?.trim()?.ifBlank { null }
if (isClose) {
current = stack.removeLastOrNull() ?: current
} else {
// [br] 作为换行处理(无闭合标签)
if (tag == "br") {
builder.append("\n")
i = m.range.last + 1
continue
}
stack.addLast(current)
current = when (tag) {
"b" -> current.copy(bold = true)
"i" -> current.copy(italic = true)
"u" -> current.copy(underline = true)
"s" -> current.copy(strike = true)
"color" -> current.copy(color = parseColor(value))
"size" -> current.copy(size = parseSize(value))
"url" -> current.copy(url = value ?: current.url)
else -> current
}
}
i = m.range.last + 1
continue
}
}
// 累积普通文本,直到下一个 '['
val next = raw.indexOf('[', i)
val text = if (next < 0) raw.substring(i) else raw.substring(i, next)
if (text.isNotEmpty()) {
appendStyled(builder, current, text)
i += text.length
} else {
// 当前字符是 '[' 但不是合法 BBCode 标签(如 [歌名] 等中文占位符):
// 按普通字符保留,避免被吞掉
appendStyled(builder, current, raw[i].toString())
i++
}
}
return builder.toAnnotatedString()
}
private fun appendStyled(builder: AnnotatedString.Builder, style: RunStyle, text: String) {
val link = style.url
if (link != null) {
// 链接:可点击(Text 通过 LocalUriHandler 打开)+ 下划线 + 链接色
builder.withLink(
LinkAnnotation.Url(
link,
TextLinkStyles(style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)),
),
) {
// 链接内部仍保留 b/i/颜色/字号等基础格式
pushStyle(styleSpan(style.copy(url = null, color = style.color)))
append(text)
pop()
}
} else {
builder.pushStyle(styleSpan(style))
builder.append(text)
builder.pop()
}
}
private fun styleSpan(s: RunStyle): SpanStyle {
val decoration = when {
s.underline -> TextDecoration.Underline
s.strike -> TextDecoration.LineThrough
else -> null
}
return SpanStyle(
fontWeight = if (s.bold) FontWeight.Bold else null,
fontStyle = if (s.italic) FontStyle.Italic else null,
textDecoration = decoration,
color = s.color ?: Color.Unspecified,
fontSize = s.size ?: TextUnit.Unspecified,
)
}
private fun parseColor(value: String?): Color? {
if (value.isNullOrBlank()) return null
val v = value.removePrefix("#").removePrefix("0x")
if (v.length == 6) {
return try { Color(("FF" + v).toLong(16)) } catch (_: Exception) { null }
}
if (v.length == 8) {
return try { Color(v.toLong(16)) } catch (_: Exception) { null }
}
return when (value.lowercase()) {
"red" -> Color(0xFFFF0000); "green" -> Color(0xFF00FF00)
"blue" -> Color(0xFF0000FF); "yellow" -> Color(0xFFFFFF00)
"white" -> Color(0xFFFFFFFF); "black" -> Color(0xFF000000)
"orange" -> Color(0xFFFFA500); "purple" -> Color(0xFF800080)
"grey", "gray" -> Color(0xFF808080)
else -> null
}
}
private fun parseSize(value: String?): TextUnit? {
val n = value?.toFloatOrNull() ?: return null
return n.coerceIn(6f, 40f).sp
}
}
@@ -63,7 +63,7 @@ private fun ChannelInfoSection(channelDetail: ChannelDetailInfo) {
if (channelDetail.description.isNotEmpty()) {
Spacer(Modifier.height(UiTokens.Spacing.Small))
Text("描述", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(channelDetail.description, style = MaterialTheme.typography.bodySmall)
Text(Bbcode.parse(channelDetail.description), style = MaterialTheme.typography.bodySmall)
}
}
}
@@ -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)
)
@@ -27,22 +27,30 @@ fun MessageList(
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
// 统一数据源:去重(兜底防重复 key 崩溃),后续都用 messageItems,避免 count 与 items 不一致
val messageItems = messages.distinctBy { it.id }
// 是否在底部(用于判断是否自动滚动)
val isAtBottom by remember {
derivedStateOf {
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()
lastVisibleItem != null && lastVisibleItem.index >= messages.size - 2
lastVisibleItem != null && lastVisibleItem.index >= messageItems.size - 2
}
}
// 新消息到达时自动滚动到底部
LaunchedEffect(messages.size) {
val count = messages.size
LaunchedEffect(messageItems.size) {
val count = messageItems.size
if (count > 0) {
// 先等当前帧(measure/layout)完成再滚动,避免在 LazyColumn measure 期间操作
// scrollState 触发 "layout state is not idle before measure starts" 崩溃
withFrameNanos { }
try {
listState.animateScrollToItem(count - 1)
} catch (_: IllegalArgumentException) {
// 列表在滚动过程中被清空/缩短(如断线清理会话),忽略即可
} catch (_: IllegalStateException) {
// scrollState 尚未 idle(measure 竞态/键盘布局变化),忽略本次滚动
}
}
}
@@ -55,7 +63,7 @@ fun MessageList(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
items = messages,
items = messageItems,
key = { it.id }
) { message ->
MessageItem(message = message)
@@ -63,7 +71,7 @@ fun MessageList(
}
// 用户查看历史消息时,新消息到达显示 "↓ 新消息" 提示条
if (!isAtBottom && messages.isNotEmpty()) {
if (!isAtBottom && messageItems.isNotEmpty()) {
Surface(
modifier = Modifier
.align(Alignment.BottomCenter)
@@ -76,13 +84,17 @@ fun MessageList(
) {
TextButton(
onClick = {
val count = messages.size
val count = messageItems.size
if (count > 0) {
coroutineScope.launch {
// 等一帧确保布局完成后再滚动
withFrameNanos { }
try {
listState.animateScrollToItem(count - 1)
} catch (_: IllegalArgumentException) {
// 列表在点击瞬间被清空/缩短,忽略即可
} catch (_: IllegalStateException) {
// scrollState 尚未 idle,忽略本次滚动
}
}
}
@@ -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,7 @@ package com.tsmobile.app.ui.components
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.core.content.FileProvider
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@@ -103,7 +104,7 @@ fun ServerDetailCard(
if (serverInfo != null && serverInfo.welcomeMessage.isNotEmpty()) {
SectionCard(title = "欢迎消息") {
Text(
text = serverInfo.welcomeMessage,
text = Bbcode.parse(serverInfo.welcomeMessage),
style = MaterialTheme.typography.bodySmall,
)
}
@@ -233,20 +234,26 @@ private fun readCrashLog(context: Context): String {
return texts.distinct().joinToString("\n\n")
}
/** 通过系统分享面板导出崩溃日志(无日志时提示)。 */
/** 通过系统分享面板导出崩溃日志:写入 .log 文件并作为附件分享(非纯文本)。 */
private fun shareCrashLog(context: Context) {
val log = readCrashLog(context)
if (log.isBlank()) {
Toast.makeText(context, "暂无崩溃日志", Toast.LENGTH_SHORT).show()
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 {
type = "text/plain"
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, "导出崩溃日志")
// 若 context 不是 Activity,需 NEW_TASK
if (context !is android.app.Activity) chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
context.startActivity(chooser)
@@ -254,3 +261,15 @@ private fun shareCrashLog(context: Context) {
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
}
}
@@ -13,6 +13,7 @@ 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.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
@@ -178,6 +179,7 @@ private fun MemberRow(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 64.dp)
.clip(RoundedCornerShape(UiTokens.Spacing.Medium))
.background(containerColor, RoundedCornerShape(UiTokens.Spacing.Medium))
.combinedClickable(onClick = {}, onLongClick = onLongClick)
.padding(horizontal = 14.dp, vertical = UiTokens.Spacing.Medium),
@@ -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)
.clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp))
.clickable(onClick = onClick),
color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = UiTokens.Elevation.Subtle,
) {
// 切换频道时,当前频道栏内容做淡入淡出 + 轻微横向滑入动画。
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,
top = UiTokens.Spacing.ExtraSmall,
bottom = UiTokens.Spacing.ExtraSmall,
bottom = UiTokens.Spacing.Medium,
),
verticalArrangement = Arrangement.spacedBy(UiTokens.Spacing.ExtraSmall),
) {
Row(
modifier = Modifier.fillMaxWidth(),
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.primary,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
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 = 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,
text = "在线 ${clients.size} 人",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
}
}
// 成员头像堆叠
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(
modifier = Modifier
.widthIn(max = 96.dp)
.height(24.dp)
.padding(horizontal = UiTokens.Spacing.Small),
horizontalArrangement = Arrangement.spacedBy((-6).dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (showPresence) {
visible.forEach { client ->
val isSelf = client.id == selfClientId
val isGrouped = client.serverGroups.isNotEmpty()
Box(
modifier = Modifier
.size(6.dp)
.background(presenceColor, CircleShape),
)
Spacer(Modifier.width(UiTokens.Spacing.ExtraSmall))
.size(avatarSize)
.clip(CircleShape)
.background(
when {
isSelf -> MaterialTheme.colorScheme.primaryContainer
isGrouped -> MaterialTheme.colorScheme.tertiaryContainer
else -> MaterialTheme.colorScheme.surfaceContainerHighest
}
Text(
text = name,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
.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,
)
}
}
}
}
@@ -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
@@ -29,8 +29,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private const val TAG = "ChatViewModel"
/** 消息送达确认超时(毫秒):超时未收到回显则标记为 FAILED */
private const val DELIVERY_TIMEOUT_MS = 10_000L
/** 系统消息 ID 计数器,避免同毫秒并发调用产生重复 key */
private val sysMsgSeq = AtomicLong(0)
/** 全局消息 ID 递增序列:保证同一毫秒内生成的消息 ID 唯一,
* 避免 LazyColumn/Row 以 id 为 key 时出现 "Key was already used" 崩溃 */
private val msgIdSeq = AtomicLong(0)
}
// 当前会话的消息列表
@@ -94,7 +95,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
*/
fun addSystemMessage(content: String) {
val message = ChatMessage(
id = "sys_${System.currentTimeMillis()}_${sysMsgSeq.incrementAndGet()}",
id = "sys_${System.currentTimeMillis()}_${msgIdSeq.incrementAndGet()}",
targetMode = 2,
targetId = Repository.currentChannelId.value.toLongOrNull() ?: 0L,
senderId = 0,
@@ -132,7 +133,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val selfName = Repository.getClient(selfId)?.nickname ?: ""
// 生成唯一 ID(用于匹配回显)
val messageId = "local_${System.currentTimeMillis()}_${selfId}"
// 追加全局递增 seq,避免同毫秒多条本地消息 ID 重复
val messageId = "local_${System.currentTimeMillis()}_${selfId}_${msgIdSeq.incrementAndGet()}"
val timestamp = System.currentTimeMillis()
// 1. 立即添加到本地列表(PENDING 状态)
@@ -280,7 +282,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 非自己的消息,或无法匹配的自己的消息 → 正常归档
val message = ChatMessage(
id = "${System.currentTimeMillis()}_${senderId}",
// 追加全局递增 seq,避免同毫秒同发送者多条消息 ID 重复
id = "${System.currentTimeMillis()}_${senderId}_${msgIdSeq.incrementAndGet()}",
targetMode = targetMode,
targetId = targetId,
senderId = senderId,
@@ -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)
@@ -192,7 +195,7 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
lastChannelId = chId
}
_connectionState.value = ConnectionState.WaitingForNetwork()
ConnectionService.stop(getApplication())
ConnectionService.update(getApplication(), "网络已断开,正在重连...", _state.value.address.trim())
if (networkMonitor.isAvailable.value) {
startReconnect("网络变化,正在重连...")
}
@@ -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. 停止重连
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() {
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,6 +873,9 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
* 最多尝试 5 次,间隔递增(2s → 4s → 8s → 16s → 30s)
*/
private fun startReconnect(reason: String) {
synchronized(reconnectLock) {
// 取消仍在运行的上一次循环,确保始终只有一条重连流程。
reconnectJob?.let { it.cancel() }
reconnectAttempt = 0
_connectionState.value = ConnectionState.Reconnecting(
attempt = 0, reason = reason
@@ -898,6 +920,7 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
// 重连失败
android.util.Log.w(TAG, "All reconnect attempts failed")
ConnectionService.stop(getApplication()) // 清除“重连中”通知
Repository.clearSession()
channelViewModel?.clearChannels()
_connectionState.value = ConnectionState.Disconnected(
@@ -905,17 +928,20 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
)
}
}
}
/**
* 手动重连(用户点击"手动重连"按钮)
*/
fun manualReconnect() {
synchronized(reconnectLock) {
reconnectJob?.let { it.cancel() }
reconnectJob = null
reconnectAttempt = 0
val params = lastConnectParams ?: return
}
val params = synchronized(reconnectLock) { lastConnectParams } ?: return
_connectionState.value = ConnectionState.Reconnecting(attempt = 0, reason = "手动重连")
ConnectionService.update(getApplication(), "正在重连...", _state.value.address.trim())
viewModelScope.launch {
val error = withContext(Dispatchers.IO) {
@@ -945,18 +971,20 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
* 放弃重连
*/
fun abandonReconnect() {
synchronized(reconnectLock) {
reconnectJob?.let { it.cancel() }
reconnectJob = null
reconnectAttempt = 0
lastChannelId = ""
lastConnectParams = null
}
networkMonitor.reset()
ConnectionService.stop(getApplication()) // 清除“重连中”通知
Repository.clearSession()
channelViewModel?.clearChannels()
_connectionState.value = ConnectionState.Disconnected(
reason = "重连已放弃", wasKicked = false
)
lastConnectParams = null
_state.update { it.copy(connectState = ConnectState.IDLE) }
}
@@ -1020,8 +1048,11 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
// 伪重连:回到断联前的频道(避开僵尸清理窗口)
// 注意:此时不设置 Connected —— 遮罩保持显示,防止用户干扰自动操作
val targetChannel = lastChannelId
val targetChannel = synchronized(reconnectLock) {
val saved = lastChannelId
lastChannelId = "" // 一次性消费
saved
}
if (targetChannel.isNotEmpty() && targetChannel != "0") {
// 等待 4 秒:Go 侧 cleanupDuplicateIdentitySessions 在 3 秒后 Kick 僵尸
delay(4_000L)
@@ -1077,17 +1108,14 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
if (_connectionState.value is ConnectionState.WaitingForNetwork ||
_connectionState.value is ConnectionState.Reconnecting
) {
android.util.Log.d(TAG, "onDisconnected while already handling reconnect, stopping notification")
ConnectionService.stop(getApplication())
android.util.Log.d(TAG, "onDisconnected while already handling reconnect, keeping reconnecting notification")
ConnectionService.update(getApplication(), "连接已断开,正在重连...", _state.value.address.trim())
return
}
// 语音断开处理
voiceViewModel?.onDisconnected()
// 停止前台服务
ConnectionService.stop(getApplication())
// 判断断线原因:网络丢失 vs 服务器主动断线
// - ConnectivityManager 明确检测到网络切换 → 网络丢失
// - 断线原因为空或 timeout → TCP 静默断开(NAT 超时、运营商丢弃等),
@@ -1098,10 +1126,12 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
message.contains("timeout", ignoreCase = true)
if (isNetworkLoss) {
// 网络丢失 → 重连,通知显示“重连中”(保留前台服务与保活)
ConnectionService.update(getApplication(), "连接已断开,正在重连...", _state.value.address.trim())
// 记录当前频道,供重连后自动回到原频道
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")
}
@@ -1110,9 +1140,10 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
android.util.Log.i(TAG, "Network-loss disconnect detected (fallback path)")
startReconnect("网络变化,正在重连...")
} else {
// 服务器主动断线 — 清理会话,回到首页
// 服务器主动断线 — 清除通知、清理会话,回到首页
android.util.Log.w(TAG, "Server-initiated disconnect: $message")
lastChannelId = ""
ConnectionService.stop(getApplication())
synchronized(reconnectLock) { lastChannelId = "" }
Repository.clearSession()
channelViewModel?.clearChannels()
_connectionState.value = ConnectionState.Disconnected(
@@ -1196,8 +1227,10 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
ConnectionService.stop(getApplication())
// 停止重连
synchronized(reconnectLock) {
reconnectJob?.let { it.cancel() }
reconnectJob = null
}
// 停止语音
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:"))
}
}