4 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
sansen 18e2f730e7 1. 改服务详情卡片为侧边滑出卡片
2. 修复重连导致的通知栏信息不变更问题
2026-07-24 11:43:05 +08:00
sansen ebc46233e5 1. 改服务详情卡片为侧边滑出卡片
2. 修复重连导致的通知栏信息不变更问题
2026-07-24 11:42:46 +08:00
13 changed files with 549 additions and 88 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.tsmobile.app"
minSdk = 26
targetSdk = 35
versionCode = 7
versionName = "1.0.7"
versionCode = 10
versionName = "1.0.10"
}
signingConfigs {
@@ -3,8 +3,13 @@ package com.tsmobile.app
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
import android.os.SystemClock
import android.util.Log
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Application 类。
@@ -17,6 +22,9 @@ class App : Application() {
private const val TAG = "App"
private const val CONNECTION_CHANNEL_ID = "connection_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() {
@@ -33,8 +41,8 @@ class App : Application() {
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
Log.e(TAG, "Uncaught exception in ${thread.name}", throwable)
// 写入崩溃日志文件
writeCrashLog(throwable)
// 写入崩溃日志文件(含线程/版本/设备上下文)
writeCrashLog(thread, throwable)
// 交给默认处理器(系统弹窗)
defaultHandler?.uncaughtException(thread, throwable)
@@ -75,18 +83,58 @@ class App : Application() {
nm.createNotificationChannel(channel)
}
private fun writeCrashLog(throwable: Throwable) {
private fun writeCrashLog(thread: Thread, throwable: Throwable) {
try {
val file = File(getExternalFilesDir(null), "crash.log")
file.appendText(
buildString {
appendLine("=== ${java.util.Date()} ===")
val info = buildString {
appendLine("========================================")
appendLine("时间: ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).format(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())
// 完整 cause 链
var cause = throwable.cause
while (cause != null) {
appendLine()
appendLine("--- Caused by ---")
appendLine(cause.stackTraceToString())
cause = cause.cause
}
appendLine()
}
)
// 内部目录(debug 包可用 run-as 读取)+ 外部应用目录,各写一份
writeCrashFile(File(filesDir, "crash.log"), info)
getExternalFilesDir(null)?.let { writeCrashFile(File(it, "crash.log"), info) }
} catch (e: Exception) {
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()
// 客户端 Map 索引:ClientID → ClientInfo(O(1) 查找)
// @Volatile:主线程写入(refreshClientList),JNI 桥接线程读取(onTextMessage),需保证可见性
@Volatile
private var clientMap: Map<Int, ClientInfo> = emptyMap()
// 客户端 UID 索引:UID → ClientInfo(O(1) UID 反查)
@Volatile
private var clientUidMap: Map<String, ClientInfo> = emptyMap()
// --- 当前客户端 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()) {
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)
}
}
}
@@ -27,18 +27,31 @@ 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) {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(messages.size - 1)
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 竞态/键盘布局变化),忽略本次滚动
}
}
}
@@ -50,7 +63,7 @@ fun MessageList(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
items = messages,
items = messageItems,
key = { it.id }
) { message ->
MessageItem(message = message)
@@ -58,7 +71,7 @@ fun MessageList(
}
// 用户查看历史消息时,新消息到达显示 "↓ 新消息" 提示条
if (!isAtBottom && messages.isNotEmpty()) {
if (!isAtBottom && messageItems.isNotEmpty()) {
Surface(
modifier = Modifier
.align(Alignment.BottomCenter)
@@ -71,8 +84,19 @@ fun MessageList(
) {
TextButton(
onClick = {
val count = messageItems.size
if (count > 0) {
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)
@@ -1,30 +1,47 @@
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.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.LinkOff
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.tsmobile.app.data.ConnectionState
import com.tsmobile.app.data.ServerInfo
import com.tsmobile.app.ui.theme.UiTokens
import java.io.File
@Composable
fun ServerDetailCard(
@@ -35,28 +52,96 @@ fun ServerDetailCard(
onDisconnect: () -> Unit,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
var showDisconnectDialog by remember { mutableStateOf(false) }
val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.8f).dp
DetailSheetScaffold(
title = serverName.ifEmpty { "服务器详情" },
subtitle = serverAddress,
onDismiss = onDismiss,
modifier = Modifier.padding(UiTokens.Spacing.Large),
maxHeight = maxHeight,
content = { ServerInfoSection(serverInfo, connectionState) },
footer = {
ModalDrawerSheet {
Column(modifier = Modifier.fillMaxSize()) {
// ═══ 顶部:服务器名称 + 地址 + 关闭按钮 ═══
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
start = UiTokens.Spacing.Large,
end = UiTokens.Spacing.Small,
top = UiTokens.Spacing.Large,
bottom = UiTokens.Spacing.Medium,
),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = serverName.ifEmpty { "服务器详情" },
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
if (serverAddress.isNotEmpty()) {
Spacer(Modifier.height(UiTokens.Spacing.ExtraSmall))
Text(
text = serverAddress,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "关闭")
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
// ═══ 中部:欢迎消息 + 服务器信息(可滚动) ═══
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(UiTokens.Spacing.Large),
verticalArrangement = Arrangement.spacedBy(UiTokens.Spacing.Medium),
) {
// 欢迎消息
if (serverInfo != null && serverInfo.welcomeMessage.isNotEmpty()) {
SectionCard(title = "欢迎消息") {
Text(
text = Bbcode.parse(serverInfo.welcomeMessage),
style = MaterialTheme.typography.bodySmall,
)
}
}
// 服务器详细信息
SectionCard(title = "服务器信息") {
ServerInfoContent(serverInfo, connectionState)
}
}
// ═══ 底部:导出崩溃日志 + 断开连接按钮 ═══
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(
onClick = { showDisconnectDialog = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiTokens.Spacing.Large, vertical = UiTokens.Spacing.Small),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
) {
Icon(Icons.Default.LinkOff, contentDescription = null)
Spacer(Modifier.width(UiTokens.Spacing.Small))
Text("断开连接")
}
},
)
}
}
if (showDisconnectDialog) {
DisconnectConfirmDialog(
@@ -67,7 +152,34 @@ fun ServerDetailCard(
}
@Composable
private fun ServerInfoSection(serverInfo: ServerInfo?, connectionState: ConnectionState?) {
private fun SectionCard(
title: String,
content: @Composable ColumnScope.() -> Unit,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant,
tonalElevation = UiTokens.Elevation.Raised,
) {
Column(modifier = Modifier.padding(UiTokens.Spacing.Medium)) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium,
)
Spacer(Modifier.height(UiTokens.Spacing.Small))
content()
}
}
}
@Composable
private fun ServerInfoContent(
serverInfo: ServerInfo?,
connectionState: ConnectionState?,
) {
Column(verticalArrangement = Arrangement.spacedBy(UiTokens.Spacing.Small)) {
DetailInfoRow("状态", when (connectionState) {
is ConnectionState.Connected -> "已连接"
@@ -78,17 +190,18 @@ private fun ServerInfoSection(serverInfo: ServerInfo?, connectionState: Connecti
null -> "未连接"
})
if (serverInfo == null) {
Text("正在加载服务器信息...", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
"正在加载服务器信息...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
DetailInfoRow("在线人数", "${serverInfo.clientsOnline} / ${serverInfo.maxClients}")
DetailInfoRow("频道数", "${serverInfo.channelsOnline}")
if (serverInfo.version.isNotEmpty()) DetailInfoRow("版本", serverInfo.version)
if (serverInfo.platform.isNotEmpty()) DetailInfoRow("平台", serverInfo.platform)
(serverInfo.uptime.toLongOrNull() ?: 0).takeIf { it > 0 }?.let { DetailInfoRow("运行时长", formatUptime(it)) }
if (serverInfo.welcomeMessage.isNotEmpty()) {
Spacer(Modifier.height(UiTokens.Spacing.Small))
Text("欢迎消息", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(serverInfo.welcomeMessage, style = MaterialTheme.typography.bodySmall)
(serverInfo.uptime.toLongOrNull() ?: 0).takeIf { it > 0 }?.let {
DetailInfoRow("运行时长", formatUptime(it))
}
}
}
@@ -98,5 +211,46 @@ private fun formatUptime(seconds: Long): String {
val days = seconds / 86400
val hours = (seconds % 86400) / 3600
val minutes = (seconds % 3600) / 60
return when { days > 0 -> "${days}天${hours}小时"; hours > 0 -> "${hours}小时${minutes}分钟"; else -> "${minutes}分钟" }
return when {
days > 0 -> "${days}天${hours}小时"
hours > 0 -> "${hours}小时${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.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),
@@ -110,12 +110,52 @@ fun ChannelListScreen(
// 防抖:服务器详情按钮上次点击时间
var lastServerDetailClickTime by remember { mutableLongStateOf(0L) }
Box(modifier = Modifier.fillMaxSize().systemBarsPadding()) {
Column(modifier = Modifier.fillMaxSize()) {
// 头部
// 服务器状态(提前声明,供侧栏 drawerContent 读取)
val serverInfo by serverViewModel.serverInfo.collectAsState()
val serverState by serverViewModel.state.collectAsState()
val themeMode by serverViewModel.themeMode.collectAsState()
// 侧栏抽屉状态
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
// 同步 showServerDetail 与抽屉开合
LaunchedEffect(showServerDetail) {
if (showServerDetail) drawerState.open() else drawerState.close()
}
// 手势滑动打开时同步状态并加载数据
LaunchedEffect(drawerState.isOpen) {
if (drawerState.isOpen && !showServerDetail) {
serverViewModel.fetchServerInfo()
showServerDetail = true
}
}
// 手势滑动关闭或编程关闭后同步状态
LaunchedEffect(drawerState.isClosed) {
if (drawerState.isClosed && showServerDetail) {
showServerDetail = false
}
}
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ServerDetailCard(
serverInfo = serverInfo,
serverName = serverInfo?.name ?: "",
serverAddress = serverState.address,
connectionState = connectionState,
onDisconnect = {
showServerDetail = false
serverViewModel.disconnect()
onNavigateToServerConfig()
},
onDismiss = { showServerDetail = false },
)
},
) {
Box(modifier = Modifier.fillMaxSize().systemBarsPadding()) {
Column(modifier = Modifier.fillMaxSize()) {
// 头部
ChannelListHeader(
connectionState = connectionState,
serverName = serverInfo?.name ?: "",
@@ -182,6 +222,7 @@ fun ChannelListScreen(
}
}
} // ModalNavigationDrawer
// ─── 弹窗层 ───
@@ -201,28 +242,6 @@ fun ChannelListScreen(
}
}
// 服务器详情卡弹窗(BottomSheet)
if (showServerDetail) {
val serverInfo by serverViewModel.serverInfo.collectAsState()
val serverState by serverViewModel.state.collectAsState()
ModalBottomSheet(
onDismissRequest = { showServerDetail = false },
) {
ServerDetailCard(
serverInfo = serverInfo,
serverName = serverInfo?.name ?: "",
serverAddress = serverState.address,
connectionState = connectionState,
onDisconnect = {
showServerDetail = false
serverViewModel.disconnect()
onNavigateToServerConfig()
},
onDismiss = { showServerDetail = false },
)
}
}
// 频道详情卡弹窗(BottomSheet)
if (showChannelDetailCard) {
ModalBottomSheet(
@@ -5,7 +5,10 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.imePadding
import androidx.compose.ui.focus.FocusManager
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.ui.components.ChannelDetailCard
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.ServerViewModel
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 channels by channelViewModel.channels.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)
var voiceCardPokeTarget by remember { mutableStateOf<Pair<Int, String>?>(null) }
@@ -50,6 +80,8 @@ fun ChatScreen(
val currentChannel = channels.find { it.id == currentChannelId }
val clients = channelClients[currentChannelId] ?: emptyList()
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val scope = rememberCoroutineScope()
Column(
modifier = Modifier
@@ -63,7 +95,11 @@ fun ChatScreen(
memberCount = clients.size,
onBack = onNavigateBack,
onChannelDetail = {
currentChannel?.let { onOpenChannelDetail(it.id) }
currentChannel?.let { channel ->
openCardAfterKeyboardCollapse(scope, keyboardController, focusManager) {
onOpenChannelDetail(channel.id)
}
}
}
)
@@ -86,9 +122,10 @@ fun ChatScreen(
VoiceControlBar(
voiceViewModel = voiceViewModel,
onExpand = {
focusManager.clearFocus()
// 先收起键盘,再等 IME 收起动画完成后再弹语音卡(同频道详情卡共用封装)
openCardAfterKeyboardCollapse(scope, keyboardController, focusManager) {
voiceViewModel.openVoiceCard()
showVoiceCard = true
}
},
)
}
@@ -96,18 +133,12 @@ fun ChatScreen(
// 语音卡弹窗(BottomSheet)
if (showVoiceCard) {
ModalBottomSheet(
onDismissRequest = {
showVoiceCard = false
voiceViewModel.closeVoiceCard()
},
onDismissRequest = { voiceViewModel.closeVoiceCard() },
) {
VoiceCard(
voiceViewModel = voiceViewModel,
channelViewModel = channelViewModel,
onDismiss = {
showVoiceCard = false
voiceViewModel.closeVoiceCard()
},
onDismiss = { voiceViewModel.closeVoiceCard() },
onPokeClient = { clientId, message ->
serverViewModel.pokeClient(clientId, message)
},
@@ -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)
}
// 当前会话的消息列表
@@ -42,7 +43,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val sendState: StateFlow<MessageSendState> = _sendState.asStateFlow()
// 当前查看的会话参数
// @Volatile:主线程写入(enterChat),JNI 桥接线程读取(handleTextMessage),需保证可见性
@Volatile
private var currentTargetMode: Int = 2
@Volatile
private var currentTargetId: Long = 0
// 送达确认超时 Job(每条消息独立管理,避免快速发送时相互覆盖)
@@ -91,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,
@@ -129,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 状态)
@@ -277,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,
@@ -192,6 +192,7 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
lastChannelId = chId
}
_connectionState.value = ConnectionState.WaitingForNetwork()
ConnectionService.stop(getApplication())
if (networkMonitor.isAvailable.value) {
startReconnect("网络变化,正在重连...")
}
@@ -1071,11 +1072,13 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
return
}
// 已在 WaitingForNetwork 或 Reconnecting 状态 → 我们的响应式监控已处理
// 已在 WaitingForNetwork 或 Reconnecting 状态 → 响应式监控已处理
// 但仍需更新通知栏(否则断连后仍显示"已连接")
if (_connectionState.value is ConnectionState.WaitingForNetwork ||
_connectionState.value is ConnectionState.Reconnecting
) {
android.util.Log.d(TAG, "onDisconnected while already handling reconnect, ignoring")
android.util.Log.d(TAG, "onDisconnected while already handling reconnect, stopping notification")
ConnectionService.stop(getApplication())
return
}
@@ -41,6 +41,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
private val _isContinuousMicEnabled = MutableStateFlow(false)
val isContinuousMicEnabled: StateFlow<Boolean> = _isContinuousMicEnabled
// @Volatile:主线程写入(startTransmit/toggleMute),JNI 桥接线程读取(onDisconnected→stopTransmit)
@Volatile
private var transmitMode: TransmitMode? = null
private val _speakerEnabled = MutableStateFlow(true)