2 Commits
Author SHA1 Message Date
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
sansen 10c0dea6f1 1. 修复房间页打字与语音卡/频道详情卡同帧叠加导致的键盘(IME)+BottomSheet 竞态闪退
2. 增强崩溃日志转储:线程/版本/设备上下文、内部+外部双路写入、512KB 轮换
3. 服务器详情卡新增“导出崩溃日志”(系统分享面板)
4. 加固消息列表自动滚动越界,跨线程状态字段加 @Volatile
5. 版本号提升至 1.0.9(versionCode 9)
2026-08-25 12:34:01 +08:00
11 changed files with 380 additions and 41 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.tsmobile.app" applicationId = "com.tsmobile.app"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 8 versionCode = 10
versionName = "1.0.8" versionName = "1.0.10"
} }
signingConfigs { signingConfigs {
@@ -3,8 +3,13 @@ package com.tsmobile.app
import android.app.Application import android.app.Application
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.os.Build
import android.os.SystemClock
import android.util.Log import android.util.Log
import java.io.File import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/** /**
* Application 类。 * Application 类。
@@ -17,6 +22,9 @@ class App : Application() {
private const val TAG = "App" private const val TAG = "App"
private const val CONNECTION_CHANNEL_ID = "connection_channel" private const val CONNECTION_CHANNEL_ID = "connection_channel"
const val POKE_CHANNEL_ID = "poke_channel" const val POKE_CHANNEL_ID = "poke_channel"
/** crash.log 大小上限,超过则轮换到 crash.log.old(避免无限膨胀) */
private const val MAX_CRASH_LOG_BYTES = 512L * 1024L
} }
override fun onCreate() { override fun onCreate() {
@@ -33,8 +41,8 @@ class App : Application() {
Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
Log.e(TAG, "Uncaught exception in ${thread.name}", throwable) Log.e(TAG, "Uncaught exception in ${thread.name}", throwable)
// 写入崩溃日志文件 // 写入崩溃日志文件(含线程/版本/设备上下文)
writeCrashLog(throwable) writeCrashLog(thread, throwable)
// 交给默认处理器(系统弹窗) // 交给默认处理器(系统弹窗)
defaultHandler?.uncaughtException(thread, throwable) defaultHandler?.uncaughtException(thread, throwable)
@@ -75,18 +83,58 @@ class App : Application() {
nm.createNotificationChannel(channel) nm.createNotificationChannel(channel)
} }
private fun writeCrashLog(throwable: Throwable) { private fun writeCrashLog(thread: Thread, throwable: Throwable) {
try { try {
val file = File(getExternalFilesDir(null), "crash.log") val info = buildString {
file.appendText( appendLine("========================================")
buildString { appendLine("时间: ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).format(Date())}")
appendLine("=== ${java.util.Date()} ===") appendLine("线程: ${thread.name} (id=${thread.id})")
appendLine("包名: $packageName")
appendLine("版本: ${appVersionName()} (build ${appVersionCode()})")
appendLine("设备: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE}) / Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
appendLine("进程运行时长: ${SystemClock.elapsedRealtime() / 1000} s")
appendLine("----------------------------------------")
appendLine(throwable.stackTraceToString()) appendLine(throwable.stackTraceToString())
// 完整 cause 链
var cause = throwable.cause
while (cause != null) {
appendLine()
appendLine("--- Caused by ---")
appendLine(cause.stackTraceToString())
cause = cause.cause
}
appendLine() appendLine()
} }
) // 内部目录(debug 包可用 run-as 读取)+ 外部应用目录,各写一份
writeCrashFile(File(filesDir, "crash.log"), info)
getExternalFilesDir(null)?.let { writeCrashFile(File(it, "crash.log"), info) }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to write crash log", e) Log.e(TAG, "Failed to write crash log", e)
} }
} }
private fun writeCrashFile(file: File, content: String) {
try {
file.parentFile?.mkdirs()
// 防日志无限膨胀:超上限时把当前日志旋转为 .old,再新起一档
if (file.exists() && file.length() > MAX_CRASH_LOG_BYTES) {
val old = File(file.parentFile, "${file.name}.old")
old.delete()
file.renameTo(old)
}
file.appendText(content)
Log.i(TAG, "Crash log written: ${file.absolutePath}")
} catch (e: Exception) {
Log.e(TAG, "Failed to write crash log to ${file.absolutePath}", e)
}
}
private fun appVersionName(): String = try {
packageManager.getPackageInfo(packageName, 0).versionName ?: "?"
} catch (e: Exception) { "?" }
private fun appVersionCode(): Long = try {
val info = packageManager.getPackageInfo(packageName, 0)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode else info.versionCode.toLong()
} catch (e: Exception) { 0L }
} }
@@ -26,9 +26,12 @@ object Repository {
val clients: StateFlow<List<ClientInfo>> = _clients.asStateFlow() val clients: StateFlow<List<ClientInfo>> = _clients.asStateFlow()
// 客户端 Map 索引:ClientID → ClientInfo(O(1) 查找) // 客户端 Map 索引:ClientID → ClientInfo(O(1) 查找)
// @Volatile:主线程写入(refreshClientList),JNI 桥接线程读取(onTextMessage),需保证可见性
@Volatile
private var clientMap: Map<Int, ClientInfo> = emptyMap() private var clientMap: Map<Int, ClientInfo> = emptyMap()
// 客户端 UID 索引:UID → ClientInfo(O(1) UID 反查) // 客户端 UID 索引:UID → ClientInfo(O(1) UID 反查)
@Volatile
private var clientUidMap: Map<String, ClientInfo> = emptyMap() private var clientUidMap: Map<String, ClientInfo> = emptyMap()
// --- 当前客户端 ID --- // --- 当前客户端 ID ---
@@ -0,0 +1,169 @@
package com.tsmobile.app.ui.components
import android.util.Log
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(raw: String): AnnotatedString {
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()) { if (channelDetail.description.isNotEmpty()) {
Spacer(Modifier.height(UiTokens.Spacing.Small)) Spacer(Modifier.height(UiTokens.Spacing.Small))
Text("描述", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) 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)
} }
} }
} }
@@ -27,18 +27,31 @@ fun MessageList(
val listState = rememberLazyListState() val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
// 统一数据源:去重(兜底防重复 key 崩溃),后续都用 messageItems,避免 count 与 items 不一致
val messageItems = messages.distinctBy { it.id }
// 是否在底部(用于判断是否自动滚动) // 是否在底部(用于判断是否自动滚动)
val isAtBottom by remember { val isAtBottom by remember {
derivedStateOf { derivedStateOf {
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull() val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()
lastVisibleItem != null && lastVisibleItem.index >= messages.size - 2 lastVisibleItem != null && lastVisibleItem.index >= messageItems.size - 2
} }
} }
// 新消息到达时自动滚动到底部 // 新消息到达时自动滚动到底部
LaunchedEffect(messages.size) { LaunchedEffect(messageItems.size) {
if (messages.isNotEmpty()) { val count = messageItems.size
listState.animateScrollToItem(messages.size - 1) 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 竞态/键盘布局变化),忽略本次滚动
}
} }
} }
@@ -50,7 +63,7 @@ fun MessageList(
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
items( items(
items = messages, items = messageItems,
key = { it.id } key = { it.id }
) { message -> ) { message ->
MessageItem(message = message) MessageItem(message = message)
@@ -58,7 +71,7 @@ fun MessageList(
} }
// 用户查看历史消息时,新消息到达显示 "↓ 新消息" 提示条 // 用户查看历史消息时,新消息到达显示 "↓ 新消息" 提示条
if (!isAtBottom && messages.isNotEmpty()) { if (!isAtBottom && messageItems.isNotEmpty()) {
Surface( Surface(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
@@ -71,8 +84,19 @@ fun MessageList(
) { ) {
TextButton( TextButton(
onClick = { onClick = {
val count = messageItems.size
if (count > 0) {
coroutineScope.launch { coroutineScope.launch {
listState.animateScrollToItem(messages.size - 1) // 等一帧确保布局完成后再滚动
withFrameNanos { }
try {
listState.animateScrollToItem(count - 1)
} catch (_: IllegalArgumentException) {
// 列表在点击瞬间被清空/缩短,忽略即可
} catch (_: IllegalStateException) {
// scrollState 尚未 idle,忽略本次滚动
}
}
} }
}, },
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp) contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp)
@@ -1,5 +1,8 @@
package com.tsmobile.app.ui.components package com.tsmobile.app.ui.components
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
@@ -15,6 +18,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.LinkOff import androidx.compose.material.icons.filled.LinkOff
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -31,11 +35,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tsmobile.app.data.ConnectionState import com.tsmobile.app.data.ConnectionState
import com.tsmobile.app.data.ServerInfo import com.tsmobile.app.data.ServerInfo
import com.tsmobile.app.ui.theme.UiTokens import com.tsmobile.app.ui.theme.UiTokens
import java.io.File
@Composable @Composable
fun ServerDetailCard( fun ServerDetailCard(
@@ -46,6 +52,7 @@ fun ServerDetailCard(
onDisconnect: () -> Unit, onDisconnect: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val context = LocalContext.current
var showDisconnectDialog by remember { mutableStateOf(false) } var showDisconnectDialog by remember { mutableStateOf(false) }
ModalDrawerSheet { ModalDrawerSheet {
@@ -96,7 +103,7 @@ fun ServerDetailCard(
if (serverInfo != null && serverInfo.welcomeMessage.isNotEmpty()) { if (serverInfo != null && serverInfo.welcomeMessage.isNotEmpty()) {
SectionCard(title = "欢迎消息") { SectionCard(title = "欢迎消息") {
Text( Text(
text = serverInfo.welcomeMessage, text = Bbcode.parse(serverInfo.welcomeMessage),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
@@ -108,13 +115,23 @@ fun ServerDetailCard(
} }
} }
// ═══ 底部:断开连接按钮 ═══ // ═══ 底部:导出崩溃日志 + 断开连接按钮 ═══
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
OutlinedButton(
onClick = { shareCrashLog(context) },
modifier = Modifier
.fillMaxWidth()
.padding(start = UiTokens.Spacing.Large, end = UiTokens.Spacing.Large, top = UiTokens.Spacing.Large),
) {
Icon(Icons.Default.Share, contentDescription = null)
Spacer(Modifier.width(UiTokens.Spacing.Small))
Text("导出崩溃日志")
}
OutlinedButton( OutlinedButton(
onClick = { showDisconnectDialog = true }, onClick = { showDisconnectDialog = true },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(UiTokens.Spacing.Large), .padding(horizontal = UiTokens.Spacing.Large, vertical = UiTokens.Spacing.Small),
colors = ButtonDefaults.outlinedButtonColors( colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error contentColor = MaterialTheme.colorScheme.error
), ),
@@ -200,3 +217,40 @@ private fun formatUptime(seconds: Long): String {
else -> "${minutes}分钟" else -> "${minutes}分钟"
} }
} }
/** 读取崩溃日志(内部 + 外部目录,含轮换的 .old,按内容去重),用于导出/分享。 */
private fun readCrashLog(context: Context): String {
val candidates = mutableListOf<File>()
candidates += File(context.filesDir, "crash.log")
candidates += File(context.filesDir, "crash.log.old")
context.getExternalFilesDir(null)?.let { ext ->
candidates += File(ext, "crash.log")
candidates += File(ext, "crash.log.old")
}
val texts = candidates
.filter { it.exists() && it.length() > 0 }
.mapNotNull { f -> try { f.readText().ifBlank { null } } catch (_: Exception) { null } }
return texts.distinct().joinToString("\n\n")
}
/** 通过系统分享面板导出崩溃日志(无日志时提示)。 */
private fun shareCrashLog(context: Context) {
val log = readCrashLog(context)
if (log.isBlank()) {
Toast.makeText(context, "暂无崩溃日志", Toast.LENGTH_SHORT).show()
return
}
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, "TeamSpeak Mobile 崩溃日志")
putExtra(Intent.EXTRA_TEXT, log)
}
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)
} catch (e: Exception) {
Toast.makeText(context, "无法打开分享:${e.message}", Toast.LENGTH_SHORT).show()
}
}
@@ -13,6 +13,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -178,6 +179,7 @@ private fun MemberRow(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 64.dp) .heightIn(min = 64.dp)
.clip(RoundedCornerShape(UiTokens.Spacing.Medium))
.background(containerColor, RoundedCornerShape(UiTokens.Spacing.Medium)) .background(containerColor, RoundedCornerShape(UiTokens.Spacing.Medium))
.combinedClickable(onClick = {}, onLongClick = onLongClick) .combinedClickable(onClick = {}, onLongClick = onLongClick)
.padding(horizontal = 14.dp, vertical = UiTokens.Spacing.Medium), .padding(horizontal = 14.dp, vertical = UiTokens.Spacing.Medium),
@@ -5,7 +5,10 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.SoftwareKeyboardController
import com.tsmobile.app.data.ChannelInfo import com.tsmobile.app.data.ChannelInfo
import com.tsmobile.app.ui.components.ChannelDetailCard import com.tsmobile.app.ui.components.ChannelDetailCard
import com.tsmobile.app.ui.components.ChatHeader import com.tsmobile.app.ui.components.ChatHeader
@@ -18,6 +21,31 @@ import com.tsmobile.app.viewmodel.ChannelViewModel
import com.tsmobile.app.viewmodel.ChatViewModel import com.tsmobile.app.viewmodel.ChatViewModel
import com.tsmobile.app.viewmodel.ServerViewModel import com.tsmobile.app.viewmodel.ServerViewModel
import com.tsmobile.app.viewmodel.VoiceViewModel import com.tsmobile.app.viewmodel.VoiceViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/** 等待输入法收起动画完成的时长,避免 ModalBottomSheet 与键盘 insets 同帧叠加触发测量竞态。 */
private const val KEYBOARD_COLLAPSE_MS = 160L
/**
* 收起键盘并等待 IME 收起动画完成后再执行 open。
* 若在同一帧内同时弹出 ModalBottomSheet 且键盘 insets 正在动画,
* 会触发 WindowInsets 测量竞态导致概率性闪退。语音卡 / 频道详情卡共用此封装。
*/
private fun openCardAfterKeyboardCollapse(
scope: CoroutineScope,
keyboardController: SoftwareKeyboardController?,
focusManager: FocusManager,
open: () -> Unit,
) {
keyboardController?.hide()
focusManager.clearFocus()
scope.launch {
delay(KEYBOARD_COLLAPSE_MS)
open()
}
}
/** /**
* 聊天页。 * 聊天页。
@@ -38,7 +66,9 @@ fun ChatScreen(
val currentChannelId by channelViewModel.currentChannelId.collectAsState() val currentChannelId by channelViewModel.currentChannelId.collectAsState()
val channels by channelViewModel.channels.collectAsState() val channels by channelViewModel.channels.collectAsState()
val channelClients by channelViewModel.channelClients.collectAsState() val channelClients by channelViewModel.channelClients.collectAsState()
var showVoiceCard by remember { mutableStateOf(false) } // 语音卡显示状态:统一使用 voiceViewModel 作为唯一来源(与频道列表页一致),
// 避免本地 remember 与 ViewModel StateFlow 双份数据源造成状态竞态。
val showVoiceCard by voiceViewModel.showVoiceCard.collectAsState()
// 语音卡 Poke 状态(长按成员 → PokeDialog) // 语音卡 Poke 状态(长按成员 → PokeDialog)
var voiceCardPokeTarget by remember { mutableStateOf<Pair<Int, String>?>(null) } var voiceCardPokeTarget by remember { mutableStateOf<Pair<Int, String>?>(null) }
@@ -50,6 +80,8 @@ fun ChatScreen(
val currentChannel = channels.find { it.id == currentChannelId } val currentChannel = channels.find { it.id == currentChannelId }
val clients = channelClients[currentChannelId] ?: emptyList() val clients = channelClients[currentChannelId] ?: emptyList()
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val scope = rememberCoroutineScope()
Column( Column(
modifier = Modifier modifier = Modifier
@@ -63,7 +95,11 @@ fun ChatScreen(
memberCount = clients.size, memberCount = clients.size,
onBack = onNavigateBack, onBack = onNavigateBack,
onChannelDetail = { onChannelDetail = {
currentChannel?.let { onOpenChannelDetail(it.id) } currentChannel?.let { channel ->
openCardAfterKeyboardCollapse(scope, keyboardController, focusManager) {
onOpenChannelDetail(channel.id)
}
}
} }
) )
@@ -86,9 +122,10 @@ fun ChatScreen(
VoiceControlBar( VoiceControlBar(
voiceViewModel = voiceViewModel, voiceViewModel = voiceViewModel,
onExpand = { onExpand = {
focusManager.clearFocus() // 先收起键盘,再等 IME 收起动画完成后再弹语音卡(同频道详情卡共用封装)
openCardAfterKeyboardCollapse(scope, keyboardController, focusManager) {
voiceViewModel.openVoiceCard() voiceViewModel.openVoiceCard()
showVoiceCard = true }
}, },
) )
} }
@@ -96,18 +133,12 @@ fun ChatScreen(
// 语音卡弹窗(BottomSheet) // 语音卡弹窗(BottomSheet)
if (showVoiceCard) { if (showVoiceCard) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { onDismissRequest = { voiceViewModel.closeVoiceCard() },
showVoiceCard = false
voiceViewModel.closeVoiceCard()
},
) { ) {
VoiceCard( VoiceCard(
voiceViewModel = voiceViewModel, voiceViewModel = voiceViewModel,
channelViewModel = channelViewModel, channelViewModel = channelViewModel,
onDismiss = { onDismiss = { voiceViewModel.closeVoiceCard() },
showVoiceCard = false
voiceViewModel.closeVoiceCard()
},
onPokeClient = { clientId, message -> onPokeClient = { clientId, message ->
serverViewModel.pokeClient(clientId, message) serverViewModel.pokeClient(clientId, message)
}, },
@@ -29,8 +29,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private const val TAG = "ChatViewModel" private const val TAG = "ChatViewModel"
/** 消息送达确认超时(毫秒):超时未收到回显则标记为 FAILED */ /** 消息送达确认超时(毫秒):超时未收到回显则标记为 FAILED */
private const val DELIVERY_TIMEOUT_MS = 10_000L private const val DELIVERY_TIMEOUT_MS = 10_000L
/** 系统消息 ID 计数器,避免同毫秒并发调用产生重复 key */ /** 全局消息 ID 递增序列:保证同一毫秒内生成的消息 ID 唯一,
private val sysMsgSeq = AtomicLong(0) * 避免 LazyColumn/Row 以 id 为 key 时出现 "Key was already used" 崩溃 */
private val msgIdSeq = AtomicLong(0)
} }
// 当前会话的消息列表 // 当前会话的消息列表
@@ -42,7 +43,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val sendState: StateFlow<MessageSendState> = _sendState.asStateFlow() val sendState: StateFlow<MessageSendState> = _sendState.asStateFlow()
// 当前查看的会话参数 // 当前查看的会话参数
// @Volatile:主线程写入(enterChat),JNI 桥接线程读取(handleTextMessage),需保证可见性
@Volatile
private var currentTargetMode: Int = 2 private var currentTargetMode: Int = 2
@Volatile
private var currentTargetId: Long = 0 private var currentTargetId: Long = 0
// 送达确认超时 Job(每条消息独立管理,避免快速发送时相互覆盖) // 送达确认超时 Job(每条消息独立管理,避免快速发送时相互覆盖)
@@ -91,7 +95,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
*/ */
fun addSystemMessage(content: String) { fun addSystemMessage(content: String) {
val message = ChatMessage( val message = ChatMessage(
id = "sys_${System.currentTimeMillis()}_${sysMsgSeq.incrementAndGet()}", id = "sys_${System.currentTimeMillis()}_${msgIdSeq.incrementAndGet()}",
targetMode = 2, targetMode = 2,
targetId = Repository.currentChannelId.value.toLongOrNull() ?: 0L, targetId = Repository.currentChannelId.value.toLongOrNull() ?: 0L,
senderId = 0, senderId = 0,
@@ -129,7 +133,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val selfName = Repository.getClient(selfId)?.nickname ?: "" val selfName = Repository.getClient(selfId)?.nickname ?: ""
// 生成唯一 ID(用于匹配回显) // 生成唯一 ID(用于匹配回显)
val messageId = "local_${System.currentTimeMillis()}_${selfId}" // 追加全局递增 seq,避免同毫秒多条本地消息 ID 重复
val messageId = "local_${System.currentTimeMillis()}_${selfId}_${msgIdSeq.incrementAndGet()}"
val timestamp = System.currentTimeMillis() val timestamp = System.currentTimeMillis()
// 1. 立即添加到本地列表(PENDING 状态) // 1. 立即添加到本地列表(PENDING 状态)
@@ -277,7 +282,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 非自己的消息,或无法匹配的自己的消息 → 正常归档 // 非自己的消息,或无法匹配的自己的消息 → 正常归档
val message = ChatMessage( val message = ChatMessage(
id = "${System.currentTimeMillis()}_${senderId}", // 追加全局递增 seq,避免同毫秒同发送者多条消息 ID 重复
id = "${System.currentTimeMillis()}_${senderId}_${msgIdSeq.incrementAndGet()}",
targetMode = targetMode, targetMode = targetMode,
targetId = targetId, targetId = targetId,
senderId = senderId, senderId = senderId,
@@ -41,6 +41,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
private val _isContinuousMicEnabled = MutableStateFlow(false) private val _isContinuousMicEnabled = MutableStateFlow(false)
val isContinuousMicEnabled: StateFlow<Boolean> = _isContinuousMicEnabled val isContinuousMicEnabled: StateFlow<Boolean> = _isContinuousMicEnabled
// @Volatile:主线程写入(startTransmit/toggleMute),JNI 桥接线程读取(onDisconnected→stopTransmit)
@Volatile
private var transmitMode: TransmitMode? = null private var transmitMode: TransmitMode? = null
private val _speakerEnabled = MutableStateFlow(true) private val _speakerEnabled = MutableStateFlow(true)