From 10c0dea6f1a8cc6fef88ed5599d31d1c897bbf63 Mon Sep 17 00:00:00 2001 From: sansen Date: Tue, 25 Aug 2026 12:34:01 +0800 Subject: [PATCH] =?UTF-8?q?=EF=BB=BF1.=20=E4=BF=AE=E5=A4=8D=E6=88=BF?= =?UTF-8?q?=E9=97=B4=E9=A1=B5=E6=89=93=E5=AD=97=E4=B8=8E=E8=AF=AD=E9=9F=B3?= =?UTF-8?q?=E5=8D=A1/=E9=A2=91=E9=81=93=E8=AF=A6=E6=83=85=E5=8D=A1?= =?UTF-8?q?=E5=90=8C=E5=B8=A7=E5=8F=A0=E5=8A=A0=E5=AF=BC=E8=87=B4=E7=9A=84?= =?UTF-8?q?=E9=94=AE=E7=9B=98(IME)+BottomSheet=20=E7=AB=9E=E6=80=81?= =?UTF-8?q?=E9=97=AA=E9=80=80=202.=20=E5=A2=9E=E5=BC=BA=E5=B4=A9=E6=BA=83?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E8=BD=AC=E5=82=A8=EF=BC=9A=E7=BA=BF=E7=A8=8B?= =?UTF-8?q?/=E7=89=88=E6=9C=AC/=E8=AE=BE=E5=A4=87=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87=E3=80=81=E5=86=85=E9=83=A8+=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E5=8F=8C=E8=B7=AF=E5=86=99=E5=85=A5=E3=80=81512KB=20=E8=BD=AE?= =?UTF-8?q?=E6=8D=A2=203.=20=E6=9C=8D=E5=8A=A1=E5=99=A8=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E5=8D=A1=E6=96=B0=E5=A2=9E=E2=80=9C=E5=AF=BC=E5=87=BA=E5=B4=A9?= =?UTF-8?q?=E6=BA=83=E6=97=A5=E5=BF=97=E2=80=9D=EF=BC=88=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=88=86=E4=BA=AB=E9=9D=A2=E6=9D=BF=EF=BC=89=204.=20=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA=E6=B6=88=E6=81=AF=E5=88=97=E8=A1=A8=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E8=B6=8A=E7=95=8C=EF=BC=8C=E8=B7=A8=E7=BA=BF?= =?UTF-8?q?=E7=A8=8B=E7=8A=B6=E6=80=81=E5=AD=97=E6=AE=B5=E5=8A=A0=20@Volat?= =?UTF-8?q?ile=205.=20=E7=89=88=E6=9C=AC=E5=8F=B7=E6=8F=90=E5=8D=87?= =?UTF-8?q?=E8=87=B3=201.0.9=EF=BC=88versionCode=209=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle.kts | 4 +- .../app/src/main/java/com/tsmobile/app/App.kt | 66 ++++++++++++++++--- .../java/com/tsmobile/app/data/Repository.kt | 3 + .../tsmobile/app/ui/components/MessageList.kt | 20 ++++-- .../app/ui/components/ServerDetailCard.kt | 58 +++++++++++++++- .../com/tsmobile/app/ui/screens/ChatScreen.kt | 57 ++++++++++++---- .../tsmobile/app/viewmodel/ChatViewModel.kt | 3 + .../tsmobile/app/viewmodel/VoiceViewModel.kt | 2 + 8 files changed, 183 insertions(+), 30 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e0f629b..2c57b00 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.tsmobile.app" minSdk = 26 targetSdk = 35 - versionCode = 8 - versionName = "1.0.8" + versionCode = 9 + versionName = "1.0.9" } signingConfigs { diff --git a/android/app/src/main/java/com/tsmobile/app/App.kt b/android/app/src/main/java/com/tsmobile/app/App.kt index f51eef2..9c420dc 100644 --- a/android/app/src/main/java/com/tsmobile/app/App.kt +++ b/android/app/src/main/java/com/tsmobile/app/App.kt @@ -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()} ===") - appendLine(throwable.stackTraceToString()) + 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 } } diff --git a/android/app/src/main/java/com/tsmobile/app/data/Repository.kt b/android/app/src/main/java/com/tsmobile/app/data/Repository.kt index 4966b16..1311861 100644 --- a/android/app/src/main/java/com/tsmobile/app/data/Repository.kt +++ b/android/app/src/main/java/com/tsmobile/app/data/Repository.kt @@ -26,9 +26,12 @@ object Repository { val clients: StateFlow> = _clients.asStateFlow() // 客户端 Map 索引:ClientID → ClientInfo(O(1) 查找) + // @Volatile:主线程写入(refreshClientList),JNI 桥接线程读取(onTextMessage),需保证可见性 + @Volatile private var clientMap: Map = emptyMap() // 客户端 UID 索引:UID → ClientInfo(O(1) UID 反查) + @Volatile private var clientUidMap: Map = emptyMap() // --- 当前客户端 ID --- diff --git a/android/app/src/main/java/com/tsmobile/app/ui/components/MessageList.kt b/android/app/src/main/java/com/tsmobile/app/ui/components/MessageList.kt index 9e016e2..c670ca6 100644 --- a/android/app/src/main/java/com/tsmobile/app/ui/components/MessageList.kt +++ b/android/app/src/main/java/com/tsmobile/app/ui/components/MessageList.kt @@ -37,8 +37,13 @@ fun MessageList( // 新消息到达时自动滚动到底部 LaunchedEffect(messages.size) { - if (messages.isNotEmpty()) { - listState.animateScrollToItem(messages.size - 1) + val count = messages.size + if (count > 0) { + try { + listState.animateScrollToItem(count - 1) + } catch (_: IllegalArgumentException) { + // 列表在滚动过程中被清空/缩短(如断线清理会话),忽略即可 + } } } @@ -71,8 +76,15 @@ fun MessageList( ) { TextButton( onClick = { - coroutineScope.launch { - listState.animateScrollToItem(messages.size - 1) + val count = messages.size + if (count > 0) { + coroutineScope.launch { + try { + listState.animateScrollToItem(count - 1) + } catch (_: IllegalArgumentException) { + // 列表在点击瞬间被清空/缩短,忽略即可 + } + } } }, contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp) diff --git a/android/app/src/main/java/com/tsmobile/app/ui/components/ServerDetailCard.kt b/android/app/src/main/java/com/tsmobile/app/ui/components/ServerDetailCard.kt index ace72bb..5051ff5 100644 --- a/android/app/src/main/java/com/tsmobile/app/ui/components/ServerDetailCard.kt +++ b/android/app/src/main/java/com/tsmobile/app/ui/components/ServerDetailCard.kt @@ -1,5 +1,8 @@ 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 @@ -15,6 +18,7 @@ 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 @@ -31,11 +35,13 @@ 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.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( @@ -46,6 +52,7 @@ fun ServerDetailCard( onDisconnect: () -> Unit, onDismiss: () -> Unit, ) { + val context = LocalContext.current var showDisconnectDialog by remember { mutableStateOf(false) } ModalDrawerSheet { @@ -108,13 +115,23 @@ fun ServerDetailCard( } } - // ═══ 底部:断开连接按钮 ═══ + // ═══ 底部:导出崩溃日志 + 断开连接按钮 ═══ 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() - .padding(UiTokens.Spacing.Large), + .padding(horizontal = UiTokens.Spacing.Large, vertical = UiTokens.Spacing.Small), colors = ButtonDefaults.outlinedButtonColors( contentColor = MaterialTheme.colorScheme.error ), @@ -200,3 +217,40 @@ private fun formatUptime(seconds: Long): String { else -> "${minutes}分钟" } } + +/** 读取崩溃日志(内部 + 外部目录,含轮换的 .old,按内容去重),用于导出/分享。 */ +private fun readCrashLog(context: Context): String { + val candidates = mutableListOf() + 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() + } +} diff --git a/android/app/src/main/java/com/tsmobile/app/ui/screens/ChatScreen.kt b/android/app/src/main/java/com/tsmobile/app/ui/screens/ChatScreen.kt index 1d36d9d..dd33408 100644 --- a/android/app/src/main/java/com/tsmobile/app/ui/screens/ChatScreen.kt +++ b/android/app/src/main/java/com/tsmobile/app/ui/screens/ChatScreen.kt @@ -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?>(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() - voiceViewModel.openVoiceCard() - showVoiceCard = true + // 先收起键盘,再等 IME 收起动画完成后再弹语音卡(同频道详情卡共用封装) + openCardAfterKeyboardCollapse(scope, keyboardController, focusManager) { + voiceViewModel.openVoiceCard() + } }, ) } @@ -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) }, diff --git a/android/app/src/main/java/com/tsmobile/app/viewmodel/ChatViewModel.kt b/android/app/src/main/java/com/tsmobile/app/viewmodel/ChatViewModel.kt index 934c88f..969d8c9 100644 --- a/android/app/src/main/java/com/tsmobile/app/viewmodel/ChatViewModel.kt +++ b/android/app/src/main/java/com/tsmobile/app/viewmodel/ChatViewModel.kt @@ -42,7 +42,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val sendState: StateFlow = _sendState.asStateFlow() // 当前查看的会话参数 + // @Volatile:主线程写入(enterChat),JNI 桥接线程读取(handleTextMessage),需保证可见性 + @Volatile private var currentTargetMode: Int = 2 + @Volatile private var currentTargetId: Long = 0 // 送达确认超时 Job(每条消息独立管理,避免快速发送时相互覆盖) diff --git a/android/app/src/main/java/com/tsmobile/app/viewmodel/VoiceViewModel.kt b/android/app/src/main/java/com/tsmobile/app/viewmodel/VoiceViewModel.kt index 4383049..aa47811 100644 --- a/android/app/src/main/java/com/tsmobile/app/viewmodel/VoiceViewModel.kt +++ b/android/app/src/main/java/com/tsmobile/app/viewmodel/VoiceViewModel.kt @@ -41,6 +41,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) { private val _isContinuousMicEnabled = MutableStateFlow(false) val isContinuousMicEnabled: StateFlow = _isContinuousMicEnabled + // @Volatile:主线程写入(startTransmit/toggleMute),JNI 桥接线程读取(onDisconnected→stopTransmit) + @Volatile private var transmitMode: TransmitMode? = null private val _speakerEnabled = MutableStateFlow(true)