3 Commits
Author SHA1 Message Date
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
10 changed files with 353 additions and 78 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 = 7 versionCode = 9
versionName = "1.0.7" 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,30 +1,47 @@
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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width 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.Icons
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.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 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(
@@ -35,28 +52,96 @@ 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) }
val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.8f).dp
DetailSheetScaffold( ModalDrawerSheet {
title = serverName.ifEmpty { "服务器详情" }, Column(modifier = Modifier.fillMaxSize()) {
subtitle = serverAddress, // ═══ 顶部:服务器名称 + 地址 + 关闭按钮 ═══
onDismiss = onDismiss, Row(
modifier = Modifier.padding(UiTokens.Spacing.Large), modifier = Modifier
maxHeight = maxHeight, .fillMaxWidth()
content = { ServerInfoSection(serverInfo, connectionState) }, .padding(
footer = { 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 = 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( OutlinedButton(
onClick = { showDisconnectDialog = true }, onClick = { showDisconnectDialog = true },
modifier = Modifier.fillMaxWidth(), modifier = Modifier
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), .fillMaxWidth()
.padding(horizontal = UiTokens.Spacing.Large, vertical = UiTokens.Spacing.Small),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
) { ) {
Icon(Icons.Default.LinkOff, contentDescription = null) Icon(Icons.Default.LinkOff, contentDescription = null)
Spacer(Modifier.width(UiTokens.Spacing.Small)) Spacer(Modifier.width(UiTokens.Spacing.Small))
Text("断开连接") Text("断开连接")
} }
}, }
) }
if (showDisconnectDialog) { if (showDisconnectDialog) {
DisconnectConfirmDialog( DisconnectConfirmDialog(
@@ -67,7 +152,34 @@ fun ServerDetailCard(
} }
@Composable @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)) { Column(verticalArrangement = Arrangement.spacedBy(UiTokens.Spacing.Small)) {
DetailInfoRow("状态", when (connectionState) { DetailInfoRow("状态", when (connectionState) {
is ConnectionState.Connected -> "已连接" is ConnectionState.Connected -> "已连接"
@@ -78,17 +190,18 @@ private fun ServerInfoSection(serverInfo: ServerInfo?, connectionState: Connecti
null -> "未连接" null -> "未连接"
}) })
if (serverInfo == null) { if (serverInfo == null) {
Text("正在加载服务器信息...", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(
"正在加载服务器信息...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else { } else {
DetailInfoRow("在线人数", "${serverInfo.clientsOnline} / ${serverInfo.maxClients}") DetailInfoRow("在线人数", "${serverInfo.clientsOnline} / ${serverInfo.maxClients}")
DetailInfoRow("频道数", "${serverInfo.channelsOnline}") DetailInfoRow("频道数", "${serverInfo.channelsOnline}")
if (serverInfo.version.isNotEmpty()) DetailInfoRow("版本", serverInfo.version) if (serverInfo.version.isNotEmpty()) DetailInfoRow("版本", serverInfo.version)
if (serverInfo.platform.isNotEmpty()) DetailInfoRow("平台", serverInfo.platform) if (serverInfo.platform.isNotEmpty()) DetailInfoRow("平台", serverInfo.platform)
(serverInfo.uptime.toLongOrNull() ?: 0).takeIf { it > 0 }?.let { DetailInfoRow("运行时长", formatUptime(it)) } (serverInfo.uptime.toLongOrNull() ?: 0).takeIf { it > 0 }?.let {
if (serverInfo.welcomeMessage.isNotEmpty()) { DetailInfoRow("运行时长", formatUptime(it))
Spacer(Modifier.height(UiTokens.Spacing.Small))
Text("欢迎消息", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(serverInfo.welcomeMessage, style = MaterialTheme.typography.bodySmall)
} }
} }
} }
@@ -98,5 +211,46 @@ private fun formatUptime(seconds: Long): String {
val days = seconds / 86400 val days = seconds / 86400
val hours = (seconds % 86400) / 3600 val hours = (seconds % 86400) / 3600
val minutes = (seconds % 3600) / 60 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()
}
} }
@@ -110,12 +110,52 @@ fun ChannelListScreen(
// 防抖:服务器详情按钮上次点击时间 // 防抖:服务器详情按钮上次点击时间
var lastServerDetailClickTime by remember { mutableLongStateOf(0L) } var lastServerDetailClickTime by remember { mutableLongStateOf(0L) }
Box(modifier = Modifier.fillMaxSize().systemBarsPadding()) { // 服务器状态(提前声明,供侧栏 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()) { Column(modifier = Modifier.fillMaxSize()) {
// 头部 // 头部
val serverInfo by serverViewModel.serverInfo.collectAsState()
val serverState by serverViewModel.state.collectAsState()
val themeMode by serverViewModel.themeMode.collectAsState()
ChannelListHeader( ChannelListHeader(
connectionState = connectionState, connectionState = connectionState,
serverName = serverInfo?.name ?: "", 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) // 频道详情卡弹窗(BottomSheet)
if (showChannelDetailCard) { if (showChannelDetailCard) {
ModalBottomSheet( ModalBottomSheet(
@@ -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(每条消息独立管理,避免快速发送时相互覆盖)
@@ -192,6 +192,7 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
lastChannelId = chId lastChannelId = chId
} }
_connectionState.value = ConnectionState.WaitingForNetwork() _connectionState.value = ConnectionState.WaitingForNetwork()
ConnectionService.stop(getApplication())
if (networkMonitor.isAvailable.value) { if (networkMonitor.isAvailable.value) {
startReconnect("网络变化,正在重连...") startReconnect("网络变化,正在重连...")
} }
@@ -1071,11 +1072,13 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
return return
} }
// 已在 WaitingForNetwork 或 Reconnecting 状态 → 我们的响应式监控已处理 // 已在 WaitingForNetwork 或 Reconnecting 状态 → 响应式监控已处理
// 但仍需更新通知栏(否则断连后仍显示"已连接")
if (_connectionState.value is ConnectionState.WaitingForNetwork || if (_connectionState.value is ConnectionState.WaitingForNetwork ||
_connectionState.value is ConnectionState.Reconnecting _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 return
} }
@@ -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)