10 Commits
Author SHA1 Message Date
sansen 0ef1990d52 版本号提升至 1.0.12(versionCode 12) 2026-09-08 12:46:28 +08:00
sansen d2f0e95441 UI/动画、交互与并发改进,与 emoji 短码完整映射
1. 交互动画:页面横移切换(登录页↔频道页、频道页↔聊天页、断开回登录页),顶部/底部栏横移过渡
2. UI 美化:重做底部当前频道栏(圆角卡片+在线人数+成员头像堆叠);聊天页输入栏圆角停靠卡、消息气泡去冗余昵称、头部在线人数;配置页字段级校验提示与最近连接删除确认弹框;PTT 按住说话震动反馈
3. 并发修复:重连状态机跨线程访问加锁与 @Volatile;桥接 ViewModel 引用、client/currentIdentity 可见性
4. emoji 短码完整映射(gemoji 1973 条 + TeamSpeak 别名 pleading),附单元测试
2026-09-08 12:44:33 +08:00
sansen 4d06ff2ade 1. 保活通知状态机:断开/断网时必然清除(stopForeground REMOVE + cancel);断网重连时显示“重连中”,重连成功恢复“已连接”,重连失败/放弃/服务器主动断开则清除
2. 导出崩溃日志改为 .log 文件附件(注册 FileProvider 分享,不再纯文本复制)
3. 版本号提升至 1.0.11(versionCode 11)
2026-08-25 14:11:30 +08:00
sansen f27ba9509d 1. 修复真机崩溃:消息 ID 唯一化(LazyColumn 重复 key)+ 消息列表滚动先等布局完成(layout state not idle)
2. 新增 BBCode 解析:服务器欢迎消息/频道描述支持 b/i/u/s/color/size/url,链接可点击,病态输入不崩溃
3. 语音卡成员长按波纹圆角
4. 版本号提升至 1.0.10(versionCode 10)
2026-08-25 13:17:45 +08:00
sansen 10c0dea6f1 1. 修复房间页打字与语音卡/频道详情卡同帧叠加导致的键盘(IME)+BottomSheet 竞态闪退
2. 增强崩溃日志转储:线程/版本/设备上下文、内部+外部双路写入、512KB 轮换
3. 服务器详情卡新增“导出崩溃日志”(系统分享面板)
4. 加固消息列表自动滚动越界,跨线程状态字段加 @Volatile
5. 版本号提升至 1.0.9(versionCode 9)
2026-08-25 12:34:01 +08:00
sansen 18e2f730e7 1. 改服务详情卡片为侧边滑出卡片
2. 修复重连导致的通知栏信息不变更问题
2026-07-24 11:43:05 +08:00
sansen ebc46233e5 1. 改服务详情卡片为侧边滑出卡片
2. 修复重连导致的通知栏信息不变更问题
2026-07-24 11:42:46 +08:00
sansen 31424f8ff6 修复移动网络下频繁触发重连的问题 2026-07-24 10:41:40 +08:00
sansen 4cc3047a6f 优化保活策略,防止意外断开导致重连 2026-07-24 00:26:42 +08:00
sansen f968708284 优化保活策略,防止意外断开导致重连 2026-07-24 00:25:52 +08:00
28 changed files with 3231 additions and 402 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.tsmobile.app"
minSdk = 26
targetSdk = 35
versionCode = 5
versionName = "1.0.5"
versionCode = 12
versionName = "1.0.12"
}
signingConfigs {
+17
View File
@@ -17,6 +17,12 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- 保活:防止 CPU 休眠导致 UDP socket 无法收发包 -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- 电池优化豁免:防止息屏后 Doze 模式限制网络 -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application
android:name=".App"
android:allowBackup="true"
@@ -42,6 +48,17 @@
android:name=".ConnectionService"
android:foregroundServiceType="dataSync"
android:exported="false" />
<!-- FileProvider:导出/分享崩溃日志文件 -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -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 }
}
@@ -8,18 +8,24 @@ import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.net.Uri
import android.net.wifi.WifiManager
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.lifecycle.LifecycleService
/**
* 前台服务:保持与 TeamSpeak 服务器的连接。
* 前台服务:保活 TeamSpeak 连接。
*
* 三层保活策略:
* 1. 前台服务通知 → 防止 Android 杀死进程
* 2. WakeLock (PARTIAL) → 防止 CPU 休眠导致 UDP socket 无法收发包
* 3. WiFi Lock → 防止 WiFi 进入低功耗模式断连
*
* 连接成功时启动,断开/被踢时停止。
* 通过 startForeground() 显示持续通知,防止 Android 杀死进程。
*
* 使用 FOREGROUND_SERVICE_TYPE_DATA_SYNC 类型,适合网络数据同步场景。
*/
class ConnectionService : LifecycleService() {
@@ -29,13 +35,15 @@ class ConnectionService : LifecycleService() {
private const val NOTIFICATION_ID = 1
const val EXTRA_SERVER_NAME = "server_name"
/** WakeLock tag */
private const val WAKELOCK_TAG = "tsmobile:connection"
/**
* 启动前台服务。
* @param context 上下文
* @param serverName 服务器地址(显示在通知中)
*/
fun start(context: Context, serverName: String) {
android.util.Log.i(TAG, "Starting connection service for $serverName")
requestBatteryExemptionIfNeeded(context)
val intent = Intent(context, ConnectionService::class.java).apply {
putExtra(EXTRA_SERVER_NAME, serverName)
}
@@ -48,43 +56,164 @@ class ConnectionService : LifecycleService() {
fun stop(context: Context) {
android.util.Log.i(TAG, "Stopping connection service")
context.stopService(Intent(context, ConnectionService::class.java))
// 兜底:立即取消保活通知,避免服务已停但通知残留(部分 ROM 不会随 stopService 移除前台通知)
cancelNotification(context)
}
/** 移除保活通知(前台通知的兜底清理) */
private fun cancelNotification(context: Context) {
try {
(context.getSystemService(NotificationManager::class.java) as NotificationManager)
.cancel(NOTIFICATION_ID)
} catch (e: Exception) {
android.util.Log.w(TAG, "Failed to cancel keepalive notification", e)
}
}
/**
* 更新保活通知(用于“重连中”等状态变化)。
* 服务仍在运行时,会更新前台通知;否则投递一条普通通知。
*/
fun update(context: Context, title: String, contentText: String) {
android.util.Log.i(TAG, "Updating keepalive notification: $title")
try {
(context.getSystemService(NotificationManager::class.java) as NotificationManager)
.notify(NOTIFICATION_ID, buildNotification(context, title, contentText))
} catch (e: Exception) {
android.util.Log.w(TAG, "Failed to update keepalive notification", e)
}
}
private fun buildNotification(context: Context, title: String, contentText: String): Notification {
val pendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
return NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(contentText)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setShowWhen(false)
.setContentIntent(pendingIntent)
.build()
}
/**
* 检查电池优化豁免状态,若未豁免则引导用户设置。
* 仅在首次调用时弹窗(系统设置页面),用户设置后不再提示。
*/
fun requestBatteryExemptionIfNeeded(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
val pm = context.getSystemService(POWER_SERVICE) as PowerManager
if (pm.isIgnoringBatteryOptimizations(context.packageName)) {
android.util.Log.d(TAG, "Already exempt from battery optimization")
return
}
android.util.Log.i(TAG, "Requesting battery optimization exemption")
try {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (e: Exception) {
android.util.Log.w(TAG, "Failed to open battery optimization settings", e)
}
}
}
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
override fun onCreate() {
super.onCreate()
acquireWakeLock()
acquireWifiLock()
ensureChannel()
android.util.Log.i(TAG, "Service created, locks acquired")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
val serverName = intent?.getStringExtra(EXTRA_SERVER_NAME) ?: ""
val notification = buildNotification(serverName)
// startForeground 前确保通知频道存在
ensureChannel()
// 启动前台服务
// Android 14+ 需要指定 foregroundServiceType
val serviceType = if (Build.VERSION.SDK_INT >= 34) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
} else {
0
}
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, serviceType)
ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification("已连接到 TeamSpeak", serverName), serviceType)
android.util.Log.i(TAG, "Foreground service started")
// The service exists only to keep the process alive while a TS connection is active.
// If Android kills it, there is no reliable way to restore the TS session, so don't
// ask the system to recreate it with a misleading "connected" notification.
android.util.Log.i(TAG, "Foreground service started for $serverName")
return Service.START_NOT_STICKY
}
override fun onDestroy() {
releaseLocks()
// 显式移除前台通知,确保断开/停止服务后通知被清除,而不是残留“已连接”
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
cancelNotification(this)
super.onDestroy()
android.util.Log.i(TAG, "Connection service destroyed")
android.util.Log.i(TAG, "Connection service destroyed, locks released")
}
/**
* 确保通知频道已创建。
* Android 8.0+ 需要通知频道,重复创建是安全的(系统会忽略已存在的频道)。
*/
// ── WakeLock ───────────────────────────────────────────────
@Suppress("DEPRECATION")
private fun acquireWakeLock() {
val pm = getSystemService(POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
WAKELOCK_TAG,
).apply {
acquire()
}
android.util.Log.i(TAG, "WakeLock acquired (PARTIAL)")
}
private fun releaseLocks() {
wakeLock?.let {
if (it.isHeld) {
it.release()
android.util.Log.i(TAG, "WakeLock released")
}
}
wakeLock = null
wifiLock?.let {
if (it.isHeld) {
it.release()
android.util.Log.i(TAG, "WiFi lock released")
}
}
wifiLock = null
}
// ── WiFi Lock ──────────────────────────────────────────────
private fun acquireWifiLock() {
val wm = applicationContext.getSystemService(WIFI_SERVICE) as? WifiManager ?: return
wifiLock = wm.createWifiLock(
WifiManager.WIFI_MODE_FULL_HIGH_PERF,
"tsmobile:wifi",
).apply {
acquire()
}
android.util.Log.i(TAG, "WiFi lock acquired")
}
// ── 通知频道 ───────────────────────────────────────────────
private fun ensureChannel() {
val nm = getSystemService(NotificationManager::class.java) ?: return
if (nm.getNotificationChannel(CHANNEL_ID) != null) return
@@ -100,28 +229,6 @@ class ConnectionService : LifecycleService() {
nm.createNotificationChannel(channel)
}
/**
* 构建通知。
*/
private fun buildNotification(serverName: String): Notification {
// 点击通知回到 MainActivity
val pendingIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("已连接到 TeamSpeak")
.setContentText(serverName)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true) // 不可滑动清除
.setShowWhen(false) // 不显示时间
.setContentIntent(pendingIntent)
.build()
}
private fun buildNotification(title: String, contentText: String): Notification =
buildNotification(this, title, contentText)
}
@@ -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
}
}
@@ -19,8 +19,13 @@ import java.lang.reflect.Proxy
object TSBridge {
private val connectLock = Any()
// @Volatile:client 被主线程/IO 协程写入(connect/disconnect),
// 被多个 getter 线程读取;保证可见性,避免读到过期的 client 引用。
@Volatile
private var client: TSClient? = null
private var connectionGeneration: Long = 0
// @Volatile:connect() 在持有 connectLock 之前读取它,需保证可见性。
@Volatile
private var currentIdentity: String = ""
private fun beginConnection(identity: String): Pair<TSClient, Long> = synchronized(connectLock) {
@@ -81,7 +86,7 @@ object TSBridge {
defaultChannelPassword: String = "",
callbacks: Callbacks,
): String {
val identity = currentIdentity
val identity = currentIdentity()
val (tsClient, generation) = beginConnection(identity)
val error = doConnect(tsClient, generation, callbacks) { proxy ->
tsClient.connect(host, nickname, password, defaultChannel, defaultChannelPassword, proxy)
File diff suppressed because it is too large Load Diff
@@ -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 ---
@@ -1,5 +1,15 @@
package com.tsmobile.app.ui.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -58,16 +68,28 @@ fun AppTopBar(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
// 标题文字横移切换:旧文字向左滑出,新文字从右滑入。
AnimatedContent(
targetState = title,
transitionSpec = { headerTextSlide() },
label = "appTopBarTitle",
) { t ->
Text(
text = title,
text = t,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
subtitle?.takeIf { it.isNotEmpty() }?.let {
}
subtitle?.takeIf { it.isNotEmpty() }?.let { s ->
AnimatedContent(
targetState = s,
transitionSpec = { headerTextSlide() },
label = "appTopBarSubtitle",
) { subText ->
Text(
text = it,
text = subText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
@@ -76,6 +98,7 @@ fun AppTopBar(
)
}
}
}
if (actionIcon != null && onActionClick != null) {
IconButton(onClick = onActionClick) {
@@ -87,3 +110,12 @@ fun AppTopBar(
}
}
}
/** 标题/副标题的横移过渡:新文字从右侧滑入,旧文字向左侧滑出。 */
private fun <S> AnimatedContentTransitionScope<S>.headerTextSlide(): ContentTransform =
(slideInHorizontally(animationSpec = tween(280, easing = FastOutSlowInEasing)) { it / 3 } +
fadeIn(animationSpec = tween(240, easing = FastOutSlowInEasing)))
.togetherWith(
slideOutHorizontally(animationSpec = tween(260, easing = FastOutSlowInEasing)) { -it / 3 } +
fadeOut(animationSpec = tween(200, easing = FastOutSlowInEasing))
)
@@ -0,0 +1,172 @@
package com.tsmobile.app.ui.components
import android.util.Log
import com.tsmobile.app.data.EmojiMapper
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(rawInput: String): AnnotatedString {
// 先做 emoji 短码替换,再渲染 BBCode。
val raw = EmojiMapper.replace(rawInput)
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)
}
}
}
@@ -15,7 +15,7 @@ fun ChatHeader(
) {
AppTopBar(
title = channelName,
subtitle = "${memberCount}人",
subtitle = "${memberCount} 人在线",
navigationIcon = Icons.AutoMirrored.Filled.ArrowBack,
navigationContentDescription = "返回",
onNavigationClick = onBack,
@@ -5,11 +5,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.tsmobile.app.data.MessageSendState
@@ -29,9 +30,10 @@ fun MessageInputBar(
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 3.dp,
shadowElevation = 3.dp
// 与下方语音栏同底色,顶部圆角,读成“底部停靠的一组”,而非孤立的浮条。
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 1.dp,
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
@@ -52,7 +54,7 @@ fun MessageInputBar(
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant
unfocusedBorderColor = Color.Transparent,
),
maxLines = 4,
keyboardOptions = KeyboardOptions(
@@ -90,7 +92,7 @@ fun MessageInputBar(
)
) {
Icon(
imageVector = Icons.Default.Send,
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "发送"
)
}
@@ -26,6 +26,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.tsmobile.app.data.ChatMessage
import com.tsmobile.app.data.EmojiMapper
import com.tsmobile.app.data.FileDownloadManager
import com.tsmobile.app.data.FileMessageMeta
import com.tsmobile.app.data.MessageDeliveryState
@@ -58,7 +59,7 @@ fun MessageItem(message: ChatMessage) {
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = message.content,
text = EmojiMapper.replace(message.content),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -112,14 +113,6 @@ fun MessageItem(message: ChatMessage) {
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (message.isSelf) {
Spacer(Modifier.width(8.dp))
Text(
text = message.senderName,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
Spacer(Modifier.height(4.dp))
@@ -158,7 +151,7 @@ fun MessageItem(message: ChatMessage) {
tonalElevation = 1.dp
) {
Text(
text = message.content,
text = EmojiMapper.replace(message.content),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
)
@@ -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)
@@ -11,8 +11,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
@@ -37,6 +39,7 @@ fun PTTButton(
val semanticColors = MaterialTheme.semanticColors
val currentOnStartTransmit by rememberUpdatedState(onStartTransmit)
val currentOnStopTransmit by rememberUpdatedState(onStopTransmit)
val haptic = LocalHapticFeedback.current
Button(
onClick = {},
@@ -51,6 +54,8 @@ fun PTTButton(
if (!enabled) return@pointerInput
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
// 按下时给予触觉反馈,确认发言动作已开始。
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
currentOnStartTransmit()
try {
waitForUpOrCancellation()
@@ -1,30 +1,48 @@
package com.tsmobile.app.ui.components
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.core.content.FileProvider
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 +53,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 +153,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 +191,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 +212,64 @@ 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")
}
/** 通过系统分享面板导出崩溃日志:写入 .log 文件并作为附件分享(非纯文本)。 */
private fun shareCrashLog(context: Context) {
val log = readCrashLog(context)
if (log.isBlank()) {
Toast.makeText(context, "暂无崩溃日志", Toast.LENGTH_SHORT).show()
return
}
val file = writeCrashLogToFile(context, log)
if (file == null) {
Toast.makeText(context, "生成日志文件失败", Toast.LENGTH_SHORT).show()
return
}
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, "TeamSpeak Mobile 崩溃日志")
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(send, "导出崩溃日志")
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()
}
}
/** 将日志写入 cacheDir/logs/tsmobile_crash_<时间戳>.log 文件(供 FileProvider 分享)。 */
private fun writeCrashLogToFile(context: Context, log: String): File? {
return try {
val dir = File(context.cacheDir, "logs").apply { mkdirs() }
val file = File(dir, "tsmobile_crash_${System.currentTimeMillis()}.log")
file.writeText(log)
file
} catch (e: Exception) {
null
}
}
@@ -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),
@@ -3,6 +3,12 @@ package com.tsmobile.app.ui.navigation
import androidx.activity.compose.BackHandler
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
@@ -128,11 +134,46 @@ fun AppNavGraph(
NavHost(
navController = navController,
startDestination = Routes.SERVER_CONFIG,
// 禁用默认的淡入淡出动画,避免页面切换显得迟钝
enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None },
popEnterTransition = { EnterTransition.None },
popExitTransition = { ExitTransition.None },
// 默认不动画;仅聊天页做“从右滑入/滑出”的过渡,避免整套页面切换显得迟钝。
enterTransition = {
when (targetState.destination.route) {
// 前进到主界面(首页→频道页、频道页→聊天页):从右侧滑入。
Routes.CHAT, Routes.CHANNEL_LIST ->
slideInHorizontally(tween(340, easing = FastOutSlowInEasing)) { it } +
fadeIn(tween(280, easing = FastOutSlowInEasing))
// 断开/被踢后回登录页:从左侧滑入(像“返回主页”)。
Routes.SERVER_CONFIG ->
slideInHorizontally(tween(340, easing = FastOutSlowInEasing)) { -it } +
fadeIn(tween(280, easing = FastOutSlowInEasing))
else -> EnterTransition.None
}
},
exitTransition = {
// 前进/退回到主页面时,底层页面轻微淡出,形成“叠影推进”而不生硬。
val from = initialState.destination.route
val to = targetState.destination.route
val overlapped =
(from == Routes.SERVER_CONFIG && to == Routes.CHANNEL_LIST) ||
(from == Routes.CHANNEL_LIST && to == Routes.CHAT) ||
(from == Routes.CHANNEL_LIST && to == Routes.SERVER_CONFIG)
if (overlapped) fadeOut(tween(240, easing = FastOutSlowInEasing)) else ExitTransition.None
},
popEnterTransition = {
if (targetState.destination.route == Routes.CHANNEL_LIST) {
slideInHorizontally(tween(340, easing = FastOutSlowInEasing)) { -it } +
fadeIn(tween(280, easing = FastOutSlowInEasing))
} else {
EnterTransition.None
}
},
popExitTransition = {
if (initialState.destination.route == Routes.CHAT) {
slideOutHorizontally(tween(320, easing = FastOutSlowInEasing)) { it } +
fadeOut(tween(280, easing = FastOutSlowInEasing))
} else {
ExitTransition.None
}
},
) {
// 服务器配置页
composable(Routes.SERVER_CONFIG) {
@@ -240,4 +281,3 @@ fun AppNavGraph(
)
}
}
@@ -3,8 +3,17 @@ package com.tsmobile.app.ui.screens
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
@@ -17,8 +26,10 @@ 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.graphics.Color
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -110,12 +121,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 +233,7 @@ fun ChannelListScreen(
}
}
} // ModalNavigationDrawer
// ─── 弹窗层 ───
@@ -201,28 +253,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(
@@ -662,49 +692,96 @@ private fun CurrentChannelBar(
val currentChannelId by channelViewModel.currentChannelId.collectAsState()
val channels by channelViewModel.channels.collectAsState()
val channelClients by channelViewModel.channelClients.collectAsState()
val selfClientId by channelViewModel.selfClientId.collectAsState()
val currentChannel = channels.find { it.id == currentChannelId }
val clients = channelClients[currentChannelId] ?: emptyList()
if (currentChannel != null) {
val selfClientId by channelViewModel.selfClientId.collectAsState()
val previewCandidates = clients.sortedByDescending { it.id == selfClientId }
val semanticColors = MaterialTheme.semanticColors
Column(
Surface(
modifier = Modifier
.fillMaxWidth()
.height(68.dp)
.clickable(onClick = onClick)
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(
start = FOOTER_LEFT_INDENT,
end = FOOTER_RIGHT_INDENT,
top = UiTokens.Spacing.ExtraSmall,
bottom = UiTokens.Spacing.ExtraSmall,
),
verticalArrangement = Arrangement.spacedBy(UiTokens.Spacing.ExtraSmall),
.clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp))
.clickable(onClick = onClick),
color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = UiTokens.Elevation.Subtle,
) {
// 切换频道时,当前频道栏内容做淡入淡出 + 轻微横向滑入动画。
AnimatedContent(
targetState = currentChannelId,
transitionSpec = {
(slideInHorizontally(animationSpec = tween(300, easing = FastOutSlowInEasing), initialOffsetX = { it / 4 }) +
fadeIn(animationSpec = tween(260, easing = FastOutSlowInEasing)))
.togetherWith(
slideOutHorizontally(animationSpec = tween(280, easing = FastOutSlowInEasing), targetOffsetX = { -it / 4 }) +
fadeOut(animationSpec = tween(200, easing = FastOutSlowInEasing))
)
},
label = "currentChannelBar",
) { channelId ->
val channel = channels.find { it.id == channelId }
val members = channelClients[channelId] ?: emptyList()
if (channel != null) {
CurrentChannelBarContent(
channel = channel,
clients = members,
selfClientId = selfClientId,
)
}
}
}
}
}
@Composable
private fun CurrentChannelBarContent(
channel: ChannelInfo,
clients: List<ClientInfo>,
selfClientId: Int,
) {
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.heightIn(min = UiTokens.Size.AppBarHeight)
.padding(
start = FOOTER_LEFT_INDENT,
top = UiTokens.Spacing.Medium,
end = FOOTER_RIGHT_INDENT,
bottom = UiTokens.Spacing.Medium,
),
verticalAlignment = Alignment.CenterVertically,
) {
// 频道图标(tonal 圆)
Box(
modifier = Modifier
.size(UiTokens.Size.ControlHeight)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.LocationOn,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(UiTokens.Size.IconMedium),
)
Spacer(Modifier.width(UiTokens.Spacing.Small))
}
Spacer(Modifier.width(UiTokens.Spacing.Medium))
// 频道名 + 在线人数
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = currentChannel.name,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
text = channel.name,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (currentChannel.isPassword) {
if (channel.isPassword) {
Spacer(Modifier.width(UiTokens.Spacing.ExtraSmall))
Icon(
Icons.Default.Lock,
@@ -713,109 +790,85 @@ private fun CurrentChannelBar(
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(UiTokens.Spacing.Small))
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
) {
}
Text(
text = if (currentChannel.maxClients >= 0) {
"${clients.size}/${currentChannel.maxClients}"
} else {
"${clients.size}"
},
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = UiTokens.Spacing.Small, vertical = UiTokens.Spacing.ExtraSmall),
)
}
}
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val maxPreviewCount = when {
maxWidth >= 380.dp -> 5
maxWidth >= 320.dp -> 4
else -> 3
}
val visibleClients = previewCandidates.take(maxPreviewCount)
val hiddenClientCount = clients.size - visibleClients.size
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(UiTokens.Spacing.ExtraSmall),
verticalAlignment = Alignment.CenterVertically,
) {
visibleClients.forEach { client ->
val isSelf = client.id == selfClientId
val isGrouped = client.serverGroups.isNotEmpty()
MemberPreviewTag(
name = client.nickname,
containerColor = when {
isSelf -> MaterialTheme.colorScheme.primaryContainer
isGrouped -> MaterialTheme.colorScheme.tertiaryContainer
else -> MaterialTheme.colorScheme.surfaceContainerHighest
},
contentColor = when {
isSelf -> MaterialTheme.colorScheme.onPrimaryContainer
isGrouped -> MaterialTheme.colorScheme.onTertiaryContainer
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
showPresence = !isSelf && !isGrouped,
presenceColor = semanticColors.success,
modifier = Modifier.weight(1f, fill = false),
)
}
if (hiddenClientCount > 0) {
Text(
text = "+$hiddenClientCount",
style = MaterialTheme.typography.labelSmall,
text = "在线 ${clients.size} 人",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
}
}
// 成员头像堆叠
MemberAvatarStack(
clients = clients,
selfClientId = selfClientId,
maxVisible = 3,
)
Spacer(Modifier.width(UiTokens.Spacing.Small))
}
}
@Composable
private fun MemberPreviewTag(
name: String,
containerColor: Color,
contentColor: Color,
showPresence: Boolean = false,
presenceColor: Color = Color.Unspecified,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
shape = MaterialTheme.shapes.small,
color = containerColor,
contentColor = contentColor,
private fun MemberAvatarStack(
clients: List<ClientInfo>,
selfClientId: Int,
maxVisible: Int = 3,
) {
val visible = clients.sortedByDescending { it.id == selfClientId }.take(maxVisible)
val hidden = clients.size - visible.size
val avatarSize = 28.dp
Row(
modifier = Modifier
.widthIn(max = 96.dp)
.height(24.dp)
.padding(horizontal = UiTokens.Spacing.Small),
horizontalArrangement = Arrangement.spacedBy((-6).dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (showPresence) {
visible.forEach { client ->
val isSelf = client.id == selfClientId
val isGrouped = client.serverGroups.isNotEmpty()
Box(
modifier = Modifier
.size(6.dp)
.background(presenceColor, CircleShape),
)
Spacer(Modifier.width(UiTokens.Spacing.ExtraSmall))
.size(avatarSize)
.clip(CircleShape)
.background(
when {
isSelf -> MaterialTheme.colorScheme.primaryContainer
isGrouped -> MaterialTheme.colorScheme.tertiaryContainer
else -> MaterialTheme.colorScheme.surfaceContainerHighest
}
)
.border(
width = UiTokens.Border.Emphasized,
color = MaterialTheme.colorScheme.surface,
shape = CircleShape,
),
contentAlignment = Alignment.Center,
) {
Text(
text = name,
text = client.nickname.take(1).ifBlank { "?" },
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
color = when {
isSelf -> MaterialTheme.colorScheme.onPrimaryContainer
isGrouped -> MaterialTheme.colorScheme.onTertiaryContainer
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
if (hidden > 0) {
Box(
modifier = Modifier
.size(avatarSize)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
contentAlignment = Alignment.Center,
) {
Text(
text = "+$hidden",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -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)
},
@@ -28,6 +28,7 @@ import com.tsmobile.app.ui.components.ConnectButton
import com.tsmobile.app.ui.components.UpdateCheckDialog
import com.tsmobile.app.viewmodel.ConnectState
import com.tsmobile.app.viewmodel.ServerViewModel
import com.tsmobile.app.viewmodel.ValidationErrors
import com.tsmobile.app.viewmodel.formatRelativeTime
/**
@@ -42,6 +43,8 @@ fun ServerConfigScreen(
val state by viewModel.state.collectAsState()
val recents by viewModel.recents.collectAsState()
val themeMode by viewModel.themeMode.collectAsState()
// 长按删除最近连接的确认目标(非 null 时弹出确认框)
var pendingRemoveRecent by remember { mutableStateOf<RecentConnection?>(null) }
Column(
modifier = Modifier
@@ -66,6 +69,7 @@ fun ServerConfigScreen(
onPasswordChange = viewModel::updatePassword,
connectState = state.connectState,
errorMessage = state.errorMessage,
validationErrors = state.validationErrors,
onConnect = { viewModel.connect() },
)
@@ -74,7 +78,7 @@ fun ServerConfigScreen(
recents = recents,
enabled = state.connectState != ConnectState.CONNECTING,
onConnectRecent = { recent -> viewModel.quickConnect(recent) },
onRemoveRecent = { recent -> viewModel.removeRecent(recent) },
onRemoveRecent = { recent -> pendingRemoveRecent = recent },
onClearAll = { viewModel.clearRecents() },
)
}
@@ -97,6 +101,30 @@ fun ServerConfigScreen(
onClose = { viewModel.closeUpdateDialog() },
)
}
// 删除最近连接确认弹框
pendingRemoveRecent?.let { target ->
AlertDialog(
onDismissRequest = { pendingRemoveRecent = null },
title = { Text("删除最近连接") },
text = { Text("确定删除 ${target.address} 吗?此操作不会影响服务器。") },
confirmButton = {
TextButton(
onClick = {
viewModel.removeRecent(target)
pendingRemoveRecent = null
},
) {
Text("删除", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { pendingRemoveRecent = null }) {
Text("取消")
}
},
)
}
}
/**
@@ -192,8 +220,12 @@ private fun InputSection(
onPasswordChange: (String) -> Unit,
connectState: ConnectState,
errorMessage: String?,
validationErrors: ValidationErrors,
onConnect: () -> Unit,
) {
val addressValidation = validationErrors.address
val nicknameValidation = validationErrors.nickname
Column(
modifier = Modifier
.fillMaxWidth()
@@ -206,7 +238,10 @@ private fun InputSection(
label = { Text("服务器地址") },
placeholder = { Text("ts.example.com") },
singleLine = true,
isError = connectState == ConnectState.FAILED && address.isBlank(),
isError = addressValidation != null,
supportingText = if (addressValidation != null) {
{ Text(addressValidation, color = MaterialTheme.colorScheme.error) }
} else null,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
)
@@ -220,7 +255,10 @@ private fun InputSection(
label = { Text("昵称") },
placeholder = { Text("我的昵称") },
singleLine = true,
isError = connectState == ConnectState.FAILED && nickname.isBlank(),
isError = nicknameValidation != null,
supportingText = if (nicknameValidation != null) {
{ Text(nicknameValidation, color = MaterialTheme.colorScheme.error) }
} else null,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
)
@@ -67,8 +67,9 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
private val json = Json { ignoreUnknownKeys = true }
// ChatViewModel 引用(由 ServerViewModel 设置)
var chatViewModel: ChatViewModel? = null
// ChatViewModel 引用(由 ServerViewModel 设置)。
// 主线程赋值,桥接回调解读,需 @Volatile 保证可见性。
@Volatile var chatViewModel: ChatViewModel? = null
// 频道列表最后刷新时间
private var lastChannelRefreshTime: Long = 0
@@ -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,
@@ -80,14 +80,13 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
private val recentStore = RecentConnectionsStore(application)
private val identityStore = IdentityStore(application)
// ChannelViewModel 引用(由 NavGraph 设置)
var channelViewModel: ChannelViewModel? = null
// ChannelViewModel 引用(由 NavGraph 设置)。
// NavGraph 在主线程赋值,桥接回调解读,需 @Volatile 保证可见性,避免读到过期 null。
@Volatile var channelViewModel: ChannelViewModel? = null
// ChatViewModel 引用(由 NavGraph 设置)
var chatViewModel: ChatViewModel? = null
@Volatile var chatViewModel: ChatViewModel? = null
// VoiceViewModel 引用(由 NavGraph 设置)
var voiceViewModel: VoiceViewModel? = null
@Volatile var voiceViewModel: VoiceViewModel? = null
// 页面状态
private val _state = MutableStateFlow(ServerScreenState())
@@ -142,12 +141,16 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
val showPokeNotification: StateFlow<Boolean> = _showPokeNotification.asStateFlow()
// ── 重连状态 ──
private var reconnectAttempt = 0
private var reconnectJob: Job? = null
private var lastConnectParams: ConnectParams? = null
// 这些字段被桥接回调线程(onDisconnected / onKicked)与主线程
// (init 网络收集器、手动操作)并发访问。加 @Volatile 保证跨线程可见性,
// 并用 reconnectLock 串行化重连循环的启动/取消,避免并发启动两条重连。
private val reconnectLock = Any()
@Volatile private var reconnectAttempt = 0
@Volatile private var reconnectJob: Job? = null
@Volatile private var lastConnectParams: ConnectParams? = null
/** 断联前的频道 ID,用于伪重连后自动回到原频道(空字符串=不自动移动) */
private var lastChannelId: String = ""
@Volatile private var lastChannelId: String = ""
// ── 网络监控 ──
private val networkMonitor = NetworkMonitor(application)
@@ -168,17 +171,23 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
// 启动时自动检查更新
checkForUpdate()
// 网络变动检测 — 简单原则:
// 任何 NetworkCallback 触发 + 已连接 → 网络一定变了 → 必须重连
// (UDP socket 绑定在旧 IP 上,即使 isAvailable 不变也已死)
// 网络变动检测:
// 任何 NetworkCallback 触发 + 前台 + 已连接 → 重连(UDP socket 绑定在旧 IP 上已死)
// 后台忽略:Android 挂起应用时可能限制网络导致误触发,
// 真正的断线由 Go idle timeout(60s 无包)兜底,或回到前台时检测
//
// 两层 collector:
// 1. networkChangeCount → 任何网络事件 → Connected 就重连
// 2. isAvailable false→true → WaitingForNetwork 时网络恢复 → 重连
// 1. networkChangeCount → 任何网络事件 → Connected 就重连(仅前台)
// 2. isAvailable false→true → WaitingForNetwork 时网络恢复 → 重连(不限前后台)
viewModelScope.launch {
var firstEmission = true
networkMonitor.networkChangeCount.collect { _ ->
if (firstEmission) { firstEmission = false; return@collect }
// 后台忽略:防止 Android 后台网络限制导致 NetworkCallback 误触发
if (!isAppInForeground()) {
android.util.Log.d(TAG, "Network change ignored (app in background)")
return@collect
}
if (_connectionState.value is ConnectionState.Connected) {
android.util.Log.w(TAG, "Network changed while connected, reconnecting")
val chId = Repository.currentChannelId.value
@@ -186,6 +195,7 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
lastChannelId = chId
}
_connectionState.value = ConnectionState.WaitingForNetwork()
ConnectionService.update(getApplication(), "网络已断开,正在重连...", _state.value.address.trim())
if (networkMonitor.isAvailable.value) {
startReconnect("网络变化,正在重连...")
}
@@ -208,6 +218,10 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
}
}
/** 应用是否在前台(RESUMED 状态) */
private fun isAppInForeground(): Boolean =
ProcessLifecycleOwner.get().lifecycle.currentState == Lifecycle.State.RESUMED
override fun onCleared() {
super.onCleared()
networkMonitor.stop()
@@ -382,11 +396,23 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
// --- 输入更新 ---
fun updateAddress(value: String) {
_state.update { it.copy(address = value, errorMessage = null) }
_state.update {
it.copy(
address = value,
errorMessage = null,
validationErrors = it.validationErrors.copy(address = null),
)
}
}
fun updateNickname(value: String) {
_state.update { it.copy(nickname = value, errorMessage = null) }
_state.update {
it.copy(
nickname = value,
errorMessage = null,
validationErrors = it.validationErrors.copy(nickname = null),
)
}
}
fun updatePassword(value: String) {
@@ -772,8 +798,10 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
networkMonitor.reset()
// 1. 停止重连
synchronized(reconnectLock) {
reconnectJob?.let { it.cancel() }
reconnectJob = null
}
// 2. 设置断开中状态
_connectionState.value = ConnectionState.Disconnecting
@@ -798,14 +826,14 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
// 6. 重置状态
_connectionState.value = null
_state.update { it.copy(connectState = ConnectState.IDLE) }
lastConnectParams = null
synchronized(reconnectLock) { lastConnectParams = null }
}
/**
* 处理被踢后的重新连接
*/
fun reconnectAfterKick() {
val params = lastConnectParams ?: return
val params = synchronized(reconnectLock) { lastConnectParams } ?: return
_kickReason.value = ""
_connectionState.value = null
@@ -825,13 +853,15 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
* 返回主页(被踢后或断开后)
*/
fun backToHome() {
synchronized(reconnectLock) {
reconnectJob?.let { it.cancel() }
reconnectJob = null
lastChannelId = ""
lastConnectParams = null
}
networkMonitor.reset()
_kickReason.value = ""
_connectionState.value = null
lastConnectParams = null
Repository.clearSession()
channelViewModel?.clearChannels()
_state.update { it.copy(connectState = ConnectState.IDLE) }
@@ -843,6 +873,9 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
* 最多尝试 5 次,间隔递增(2s → 4s → 8s → 16s → 30s)
*/
private fun startReconnect(reason: String) {
synchronized(reconnectLock) {
// 取消仍在运行的上一次循环,确保始终只有一条重连流程。
reconnectJob?.let { it.cancel() }
reconnectAttempt = 0
_connectionState.value = ConnectionState.Reconnecting(
attempt = 0, reason = reason
@@ -887,6 +920,7 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
// 重连失败
android.util.Log.w(TAG, "All reconnect attempts failed")
ConnectionService.stop(getApplication()) // 清除“重连中”通知
Repository.clearSession()
channelViewModel?.clearChannels()
_connectionState.value = ConnectionState.Disconnected(
@@ -894,17 +928,20 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
)
}
}
}
/**
* 手动重连(用户点击"手动重连"按钮)
*/
fun manualReconnect() {
synchronized(reconnectLock) {
reconnectJob?.let { it.cancel() }
reconnectJob = null
reconnectAttempt = 0
val params = lastConnectParams ?: return
}
val params = synchronized(reconnectLock) { lastConnectParams } ?: return
_connectionState.value = ConnectionState.Reconnecting(attempt = 0, reason = "手动重连")
ConnectionService.update(getApplication(), "正在重连...", _state.value.address.trim())
viewModelScope.launch {
val error = withContext(Dispatchers.IO) {
@@ -934,18 +971,20 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
* 放弃重连
*/
fun abandonReconnect() {
synchronized(reconnectLock) {
reconnectJob?.let { it.cancel() }
reconnectJob = null
reconnectAttempt = 0
lastChannelId = ""
lastConnectParams = null
}
networkMonitor.reset()
ConnectionService.stop(getApplication()) // 清除“重连中”通知
Repository.clearSession()
channelViewModel?.clearChannels()
_connectionState.value = ConnectionState.Disconnected(
reason = "重连已放弃", wasKicked = false
)
lastConnectParams = null
_state.update { it.copy(connectState = ConnectState.IDLE) }
}
@@ -1009,8 +1048,11 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
// 伪重连:回到断联前的频道(避开僵尸清理窗口)
// 注意:此时不设置 Connected —— 遮罩保持显示,防止用户干扰自动操作
val targetChannel = lastChannelId
val targetChannel = synchronized(reconnectLock) {
val saved = lastChannelId
lastChannelId = "" // 一次性消费
saved
}
if (targetChannel.isNotEmpty() && targetChannel != "0") {
// 等待 4 秒:Go 侧 cleanupDuplicateIdentitySessions 在 3 秒后 Kick 僵尸
delay(4_000L)
@@ -1061,20 +1103,19 @@ 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, keeping reconnecting notification")
ConnectionService.update(getApplication(), "连接已断开,正在重连...", _state.value.address.trim())
return
}
// 语音断开处理
voiceViewModel?.onDisconnected()
// 停止前台服务
ConnectionService.stop(getApplication())
// 判断断线原因:网络丢失 vs 服务器主动断线
// - ConnectivityManager 明确检测到网络切换 → 网络丢失
// - 断线原因为空或 timeout → TCP 静默断开(NAT 超时、运营商丢弃等),
@@ -1085,10 +1126,12 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
message.contains("timeout", ignoreCase = true)
if (isNetworkLoss) {
// 网络丢失 → 重连,通知显示“重连中”(保留前台服务与保活)
ConnectionService.update(getApplication(), "连接已断开,正在重连...", _state.value.address.trim())
// 记录当前频道,供重连后自动回到原频道
val currentChId = Repository.currentChannelId.value
if (currentChId != "0" && currentChId.isNotEmpty()) {
lastChannelId = currentChId
synchronized(reconnectLock) { lastChannelId = currentChId }
android.util.Log.i(TAG, "Saved lastChannelId=$lastChannelId for pseudo-reconnect")
}
@@ -1097,9 +1140,10 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
android.util.Log.i(TAG, "Network-loss disconnect detected (fallback path)")
startReconnect("网络变化,正在重连...")
} else {
// 服务器主动断线 — 清理会话,回到首页
// 服务器主动断线 — 清除通知、清理会话,回到首页
android.util.Log.w(TAG, "Server-initiated disconnect: $message")
lastChannelId = ""
ConnectionService.stop(getApplication())
synchronized(reconnectLock) { lastChannelId = "" }
Repository.clearSession()
channelViewModel?.clearChannels()
_connectionState.value = ConnectionState.Disconnected(
@@ -1183,8 +1227,10 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
ConnectionService.stop(getApplication())
// 停止重连
synchronized(reconnectLock) {
reconnectJob?.let { it.cancel() }
reconnectJob = null
}
// 停止语音
voiceViewModel?.stopVoice()
@@ -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)
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- FileProvider 路径映射:暴露 cacheDir/logs 下的日志文件用于分享 -->
<paths>
<cache-path name="logs" path="logs/" />
</paths>
@@ -0,0 +1,37 @@
package com.tsmobile.app.data
import org.junit.Assert.assertEquals
import org.junit.Test
class EmojiMapperTest {
@Test
fun mapsKnownShortcodes() {
assertEquals("😄🔥❤️", EmojiMapper.replace(":smile::fire::heart:"))
}
@Test
fun mapsTeamSpeakShortcodes() {
assertEquals("🥺🐹👴", EmojiMapper.replace(":pleading::hamster::older_man:"))
}
@Test
fun mapsMixedText() {
assertEquals("Hello 😄 world", EmojiMapper.replace("Hello :smile: world"))
}
@Test
fun keepsUnknownShortcodesUnchanged() {
assertEquals(":not_a_real_emoji:", EmojiMapper.replace(":not_a_real_emoji:"))
}
@Test
fun leavesPlainTextUntouched() {
assertEquals("plain text 123", EmojiMapper.replace("plain text 123"))
}
@Test
fun handlesAdjacentShortcodesWithoutSpaces() {
assertEquals("😄❤️", EmojiMapper.replace(":smile::heart:"))
}
}