Compare commits
5
Commits
v1.0.6
...
f27ba9509d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f27ba9509d | ||
|
|
10c0dea6f1 | ||
|
|
18e2f730e7 | ||
|
|
ebc46233e5 | ||
|
|
31424f8ff6 |
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "com.tsmobile.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 6
|
||||
versionName = "1.0.6"
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ class NetworkMonitor(context: Context) {
|
||||
private val _isAvailable = MutableStateFlow(true) // 乐观假设:初始时网络可用
|
||||
val isAvailable: StateFlow<Boolean> = _isAvailable.asStateFlow()
|
||||
|
||||
/** 网络变化计数器:任何网络回调触发时自增(用于检测 isAvailable 不变但底层网络已切换的场景) */
|
||||
/** 网络切换计数器:仅在 onLost / onAvailable 时自增(onCapabilitiesChanged 太频繁,不计数) */
|
||||
private val _networkChangeCount = MutableStateFlow(0L)
|
||||
val networkChangeCount: StateFlow<Long> = _networkChangeCount.asStateFlow()
|
||||
|
||||
@@ -76,19 +76,14 @@ class NetworkMonitor(context: Context) {
|
||||
val hasInternet = capabilities.hasCapability(
|
||||
NetworkCapabilities.NET_CAPABILITY_INTERNET
|
||||
)
|
||||
val wasAvailable = _isAvailable.value
|
||||
_isAvailable.value = hasInternet
|
||||
|
||||
if (!hasInternet && wasAvailable) {
|
||||
// 网络能力降级(例如切换中暂时无 Internet)
|
||||
// 不修改 _isAvailable —— onCapabilitiesChanged 在移动网络下太频繁
|
||||
// (LTE↔5G 切换等),修改会导致 isAvailable collector 误触发重连。
|
||||
// 仅记录时间戳供 consumeNetworkLossDisconnect 在 Go idle timeout 兜底时判断。
|
||||
if (!hasInternet) {
|
||||
Log.d(TAG, "Network capabilities degraded: hasInternet=false")
|
||||
lastLossTimestamp = System.currentTimeMillis()
|
||||
pendingDisconnectFromLoss = true
|
||||
}
|
||||
if (hasInternet && !wasAvailable) {
|
||||
Log.d(TAG, "Network capabilities restored: hasInternet=true")
|
||||
}
|
||||
_networkChangeCount.value += 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user