1. 修复房间页打字与语音卡/频道详情卡同帧叠加导致的键盘(IME)+BottomSheet 竞态闪退

2. 增强崩溃日志转储:线程/版本/设备上下文、内部+外部双路写入、512KB 轮换
3. 服务器详情卡新增“导出崩溃日志”(系统分享面板)
4. 加固消息列表自动滚动越界,跨线程状态字段加 @Volatile
5. 版本号提升至 1.0.9(versionCode 9)
This commit is contained in:
sansen
2026-08-25 12:34:01 +08:00
parent 18e2f730e7
commit 10c0dea6f1
8 changed files with 183 additions and 30 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 = 9
versionName = "1.0.8" versionName = "1.0.9"
} }
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(throwable.stackTraceToString()) 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()
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) { } 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 ---
@@ -37,8 +37,13 @@ fun MessageList(
// 新消息到达时自动滚动到底部 // 新消息到达时自动滚动到底部
LaunchedEffect(messages.size) { LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) { val count = messages.size
listState.animateScrollToItem(messages.size - 1) if (count > 0) {
try {
listState.animateScrollToItem(count - 1)
} catch (_: IllegalArgumentException) {
// 列表在滚动过程中被清空/缩短(如断线清理会话),忽略即可
}
} }
} }
@@ -71,8 +76,15 @@ fun MessageList(
) { ) {
TextButton( TextButton(
onClick = { onClick = {
coroutineScope.launch { val count = messages.size
listState.animateScrollToItem(messages.size - 1) if (count > 0) {
coroutineScope.launch {
try {
listState.animateScrollToItem(count - 1)
} catch (_: IllegalArgumentException) {
// 列表在点击瞬间被清空/缩短,忽略即可
}
}
} }
}, },
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 {
@@ -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()
}
}
@@ -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 收起动画完成后再弹语音卡(同频道详情卡共用封装)
voiceViewModel.openVoiceCard() openCardAfterKeyboardCollapse(scope, keyboardController, focusManager) {
showVoiceCard = true voiceViewModel.openVoiceCard()
}
}, },
) )
} }
@@ -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)
}, },
@@ -42,7 +42,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(每条消息独立管理,避免快速发送时相互覆盖)
@@ -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)