Compare commits
8
Commits
v1.0.6
...
0ef1990d52
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ef1990d52 | ||
|
|
d2f0e95441 | ||
|
|
4d06ff2ade | ||
|
|
f27ba9509d | ||
|
|
10c0dea6f1 | ||
|
|
18e2f730e7 | ||
|
|
ebc46233e5 | ||
|
|
31424f8ff6 |
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId = "com.tsmobile.app"
|
applicationId = "com.tsmobile.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 6
|
versionCode = 12
|
||||||
versionName = "1.0.6"
|
versionName = "1.0.12"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -48,6 +48,17 @@
|
|||||||
android:name=".ConnectionService"
|
android:name=".ConnectionService"
|
||||||
android:foregroundServiceType="dataSync"
|
android:foregroundServiceType="dataSync"
|
||||||
android:exported="false" />
|
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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -3,8 +3,13 @@ package com.tsmobile.app
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.SystemClock
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Application 类。
|
* Application 类。
|
||||||
@@ -17,6 +22,9 @@ class App : Application() {
|
|||||||
private const val TAG = "App"
|
private const val TAG = "App"
|
||||||
private const val CONNECTION_CHANNEL_ID = "connection_channel"
|
private const val CONNECTION_CHANNEL_ID = "connection_channel"
|
||||||
const val POKE_CHANNEL_ID = "poke_channel"
|
const val POKE_CHANNEL_ID = "poke_channel"
|
||||||
|
|
||||||
|
/** crash.log 大小上限,超过则轮换到 crash.log.old(避免无限膨胀) */
|
||||||
|
private const val MAX_CRASH_LOG_BYTES = 512L * 1024L
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
@@ -33,8 +41,8 @@ class App : Application() {
|
|||||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
Log.e(TAG, "Uncaught exception in ${thread.name}", throwable)
|
Log.e(TAG, "Uncaught exception in ${thread.name}", throwable)
|
||||||
|
|
||||||
// 写入崩溃日志文件
|
// 写入崩溃日志文件(含线程/版本/设备上下文)
|
||||||
writeCrashLog(throwable)
|
writeCrashLog(thread, throwable)
|
||||||
|
|
||||||
// 交给默认处理器(系统弹窗)
|
// 交给默认处理器(系统弹窗)
|
||||||
defaultHandler?.uncaughtException(thread, throwable)
|
defaultHandler?.uncaughtException(thread, throwable)
|
||||||
@@ -75,18 +83,58 @@ class App : Application() {
|
|||||||
nm.createNotificationChannel(channel)
|
nm.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun writeCrashLog(throwable: Throwable) {
|
private fun writeCrashLog(thread: Thread, throwable: Throwable) {
|
||||||
try {
|
try {
|
||||||
val file = File(getExternalFilesDir(null), "crash.log")
|
val info = buildString {
|
||||||
file.appendText(
|
appendLine("========================================")
|
||||||
buildString {
|
appendLine("时间: ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).format(Date())}")
|
||||||
appendLine("=== ${java.util.Date()} ===")
|
appendLine("线程: ${thread.name} (id=${thread.id})")
|
||||||
|
appendLine("包名: $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())
|
appendLine(throwable.stackTraceToString())
|
||||||
|
// 完整 cause 链
|
||||||
|
var cause = throwable.cause
|
||||||
|
while (cause != null) {
|
||||||
|
appendLine()
|
||||||
|
appendLine("--- Caused by ---")
|
||||||
|
appendLine(cause.stackTraceToString())
|
||||||
|
cause = cause.cause
|
||||||
|
}
|
||||||
appendLine()
|
appendLine()
|
||||||
}
|
}
|
||||||
)
|
// 内部目录(debug 包可用 run-as 读取)+ 外部应用目录,各写一份
|
||||||
|
writeCrashFile(File(filesDir, "crash.log"), info)
|
||||||
|
getExternalFilesDir(null)?.let { writeCrashFile(File(it, "crash.log"), info) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to write crash log", e)
|
Log.e(TAG, "Failed to write crash log", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun writeCrashFile(file: File, content: String) {
|
||||||
|
try {
|
||||||
|
file.parentFile?.mkdirs()
|
||||||
|
// 防日志无限膨胀:超上限时把当前日志旋转为 .old,再新起一档
|
||||||
|
if (file.exists() && file.length() > MAX_CRASH_LOG_BYTES) {
|
||||||
|
val old = File(file.parentFile, "${file.name}.old")
|
||||||
|
old.delete()
|
||||||
|
file.renameTo(old)
|
||||||
|
}
|
||||||
|
file.appendText(content)
|
||||||
|
Log.i(TAG, "Crash log written: ${file.absolutePath}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to write crash log to ${file.absolutePath}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appVersionName(): String = try {
|
||||||
|
packageManager.getPackageInfo(packageName, 0).versionName ?: "?"
|
||||||
|
} catch (e: Exception) { "?" }
|
||||||
|
|
||||||
|
private fun appVersionCode(): Long = try {
|
||||||
|
val info = packageManager.getPackageInfo(packageName, 0)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode else info.versionCode.toLong()
|
||||||
|
} catch (e: Exception) { 0L }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,52 @@ class ConnectionService : LifecycleService() {
|
|||||||
fun stop(context: Context) {
|
fun stop(context: Context) {
|
||||||
android.util.Log.i(TAG, "Stopping connection service")
|
android.util.Log.i(TAG, "Stopping connection service")
|
||||||
context.stopService(Intent(context, ConnectionService::class.java))
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,7 +152,7 @@ class ConnectionService : LifecycleService() {
|
|||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification(serverName), serviceType)
|
ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification("已连接到 TeamSpeak", serverName), serviceType)
|
||||||
|
|
||||||
android.util.Log.i(TAG, "Foreground service started for $serverName")
|
android.util.Log.i(TAG, "Foreground service started for $serverName")
|
||||||
return Service.START_NOT_STICKY
|
return Service.START_NOT_STICKY
|
||||||
@@ -114,6 +160,9 @@ class ConnectionService : LifecycleService() {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
releaseLocks()
|
releaseLocks()
|
||||||
|
// 显式移除前台通知,确保断开/停止服务后通知被清除,而不是残留“已连接”
|
||||||
|
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||||
|
cancelNotification(this)
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
android.util.Log.i(TAG, "Connection service destroyed, locks released")
|
android.util.Log.i(TAG, "Connection service destroyed, locks released")
|
||||||
}
|
}
|
||||||
@@ -180,24 +229,6 @@ class ConnectionService : LifecycleService() {
|
|||||||
nm.createNotificationChannel(channel)
|
nm.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildNotification(serverName: String): Notification {
|
private fun buildNotification(title: String, contentText: String): Notification =
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
buildNotification(this, title, contentText)
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class NetworkMonitor(context: Context) {
|
|||||||
private val _isAvailable = MutableStateFlow(true) // 乐观假设:初始时网络可用
|
private val _isAvailable = MutableStateFlow(true) // 乐观假设:初始时网络可用
|
||||||
val isAvailable: StateFlow<Boolean> = _isAvailable.asStateFlow()
|
val isAvailable: StateFlow<Boolean> = _isAvailable.asStateFlow()
|
||||||
|
|
||||||
/** 网络变化计数器:任何网络回调触发时自增(用于检测 isAvailable 不变但底层网络已切换的场景) */
|
/** 网络切换计数器:仅在 onLost / onAvailable 时自增(onCapabilitiesChanged 太频繁,不计数) */
|
||||||
private val _networkChangeCount = MutableStateFlow(0L)
|
private val _networkChangeCount = MutableStateFlow(0L)
|
||||||
val networkChangeCount: StateFlow<Long> = _networkChangeCount.asStateFlow()
|
val networkChangeCount: StateFlow<Long> = _networkChangeCount.asStateFlow()
|
||||||
|
|
||||||
@@ -76,19 +76,14 @@ class NetworkMonitor(context: Context) {
|
|||||||
val hasInternet = capabilities.hasCapability(
|
val hasInternet = capabilities.hasCapability(
|
||||||
NetworkCapabilities.NET_CAPABILITY_INTERNET
|
NetworkCapabilities.NET_CAPABILITY_INTERNET
|
||||||
)
|
)
|
||||||
val wasAvailable = _isAvailable.value
|
// 不修改 _isAvailable —— onCapabilitiesChanged 在移动网络下太频繁
|
||||||
_isAvailable.value = hasInternet
|
// (LTE↔5G 切换等),修改会导致 isAvailable collector 误触发重连。
|
||||||
|
// 仅记录时间戳供 consumeNetworkLossDisconnect 在 Go idle timeout 兜底时判断。
|
||||||
if (!hasInternet && wasAvailable) {
|
if (!hasInternet) {
|
||||||
// 网络能力降级(例如切换中暂时无 Internet)
|
|
||||||
Log.d(TAG, "Network capabilities degraded: hasInternet=false")
|
Log.d(TAG, "Network capabilities degraded: hasInternet=false")
|
||||||
lastLossTimestamp = System.currentTimeMillis()
|
lastLossTimestamp = System.currentTimeMillis()
|
||||||
pendingDisconnectFromLoss = true
|
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 {
|
object TSBridge {
|
||||||
|
|
||||||
private val connectLock = Any()
|
private val connectLock = Any()
|
||||||
|
// @Volatile:client 被主线程/IO 协程写入(connect/disconnect),
|
||||||
|
// 被多个 getter 线程读取;保证可见性,避免读到过期的 client 引用。
|
||||||
|
@Volatile
|
||||||
private var client: TSClient? = null
|
private var client: TSClient? = null
|
||||||
private var connectionGeneration: Long = 0
|
private var connectionGeneration: Long = 0
|
||||||
|
// @Volatile:connect() 在持有 connectLock 之前读取它,需保证可见性。
|
||||||
|
@Volatile
|
||||||
private var currentIdentity: String = ""
|
private var currentIdentity: String = ""
|
||||||
|
|
||||||
private fun beginConnection(identity: String): Pair<TSClient, Long> = synchronized(connectLock) {
|
private fun beginConnection(identity: String): Pair<TSClient, Long> = synchronized(connectLock) {
|
||||||
@@ -81,7 +86,7 @@ object TSBridge {
|
|||||||
defaultChannelPassword: String = "",
|
defaultChannelPassword: String = "",
|
||||||
callbacks: Callbacks,
|
callbacks: Callbacks,
|
||||||
): String {
|
): String {
|
||||||
val identity = currentIdentity
|
val identity = currentIdentity()
|
||||||
val (tsClient, generation) = beginConnection(identity)
|
val (tsClient, generation) = beginConnection(identity)
|
||||||
val error = doConnect(tsClient, generation, callbacks) { proxy ->
|
val error = doConnect(tsClient, generation, callbacks) { proxy ->
|
||||||
tsClient.connect(host, nickname, password, defaultChannel, defaultChannelPassword, 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()
|
val clients: StateFlow<List<ClientInfo>> = _clients.asStateFlow()
|
||||||
|
|
||||||
// 客户端 Map 索引:ClientID → ClientInfo(O(1) 查找)
|
// 客户端 Map 索引:ClientID → ClientInfo(O(1) 查找)
|
||||||
|
// @Volatile:主线程写入(refreshClientList),JNI 桥接线程读取(onTextMessage),需保证可见性
|
||||||
|
@Volatile
|
||||||
private var clientMap: Map<Int, ClientInfo> = emptyMap()
|
private var clientMap: Map<Int, ClientInfo> = emptyMap()
|
||||||
|
|
||||||
// 客户端 UID 索引:UID → ClientInfo(O(1) UID 反查)
|
// 客户端 UID 索引:UID → ClientInfo(O(1) UID 反查)
|
||||||
|
@Volatile
|
||||||
private var clientUidMap: Map<String, ClientInfo> = emptyMap()
|
private var clientUidMap: Map<String, ClientInfo> = emptyMap()
|
||||||
|
|
||||||
// --- 当前客户端 ID ---
|
// --- 当前客户端 ID ---
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
package com.tsmobile.app.ui.components
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -58,16 +68,28 @@ fun AppTopBar(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
|
// 标题文字横移切换:旧文字向左滑出,新文字从右滑入。
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = title,
|
||||||
|
transitionSpec = { headerTextSlide() },
|
||||||
|
label = "appTopBarTitle",
|
||||||
|
) { t ->
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = t,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
subtitle?.takeIf { it.isNotEmpty() }?.let {
|
}
|
||||||
|
subtitle?.takeIf { it.isNotEmpty() }?.let { s ->
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = s,
|
||||||
|
transitionSpec = { headerTextSlide() },
|
||||||
|
label = "appTopBarSubtitle",
|
||||||
|
) { subText ->
|
||||||
Text(
|
Text(
|
||||||
text = it,
|
text = subText,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
@@ -76,6 +98,7 @@ fun AppTopBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (actionIcon != null && onActionClick != null) {
|
if (actionIcon != null && onActionClick != null) {
|
||||||
IconButton(onClick = onActionClick) {
|
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()) {
|
if (channelDetail.description.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(UiTokens.Spacing.Small))
|
Spacer(Modifier.height(UiTokens.Spacing.Small))
|
||||||
Text("描述", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
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(
|
AppTopBar(
|
||||||
title = channelName,
|
title = channelName,
|
||||||
subtitle = "${memberCount}人",
|
subtitle = "${memberCount} 人在线",
|
||||||
navigationIcon = Icons.AutoMirrored.Filled.ArrowBack,
|
navigationIcon = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
navigationContentDescription = "返回",
|
navigationContentDescription = "返回",
|
||||||
onNavigationClick = onBack,
|
onNavigationClick = onBack,
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
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.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.tsmobile.app.data.MessageSendState
|
import com.tsmobile.app.data.MessageSendState
|
||||||
@@ -29,9 +30,10 @@ fun MessageInputBar(
|
|||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
color = MaterialTheme.colorScheme.surface,
|
// 与下方语音栏同底色,顶部圆角,读成“底部停靠的一组”,而非孤立的浮条。
|
||||||
tonalElevation = 3.dp,
|
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
|
||||||
shadowElevation = 3.dp
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
tonalElevation = 1.dp,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
@@ -52,7 +54,7 @@ fun MessageInputBar(
|
|||||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||||
unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant
|
unfocusedBorderColor = Color.Transparent,
|
||||||
),
|
),
|
||||||
maxLines = 4,
|
maxLines = 4,
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
@@ -90,7 +92,7 @@ fun MessageInputBar(
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Send,
|
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||||
contentDescription = "发送"
|
contentDescription = "发送"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import androidx.compose.ui.layout.ContentScale
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.tsmobile.app.data.ChatMessage
|
import com.tsmobile.app.data.ChatMessage
|
||||||
|
import com.tsmobile.app.data.EmojiMapper
|
||||||
import com.tsmobile.app.data.FileDownloadManager
|
import com.tsmobile.app.data.FileDownloadManager
|
||||||
import com.tsmobile.app.data.FileMessageMeta
|
import com.tsmobile.app.data.FileMessageMeta
|
||||||
import com.tsmobile.app.data.MessageDeliveryState
|
import com.tsmobile.app.data.MessageDeliveryState
|
||||||
@@ -58,7 +59,7 @@ fun MessageItem(message: ChatMessage) {
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = message.content,
|
text = EmojiMapper.replace(message.content),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -112,14 +113,6 @@ fun MessageItem(message: ChatMessage) {
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
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))
|
Spacer(Modifier.height(4.dp))
|
||||||
@@ -158,7 +151,7 @@ fun MessageItem(message: ChatMessage) {
|
|||||||
tonalElevation = 1.dp
|
tonalElevation = 1.dp
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = message.content,
|
text = EmojiMapper.replace(message.content),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,18 +27,31 @@ fun MessageList(
|
|||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// 统一数据源:去重(兜底防重复 key 崩溃),后续都用 messageItems,避免 count 与 items 不一致
|
||||||
|
val messageItems = messages.distinctBy { it.id }
|
||||||
|
|
||||||
// 是否在底部(用于判断是否自动滚动)
|
// 是否在底部(用于判断是否自动滚动)
|
||||||
val isAtBottom by remember {
|
val isAtBottom by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()
|
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()
|
||||||
lastVisibleItem != null && lastVisibleItem.index >= messages.size - 2
|
lastVisibleItem != null && lastVisibleItem.index >= messageItems.size - 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 新消息到达时自动滚动到底部
|
// 新消息到达时自动滚动到底部
|
||||||
LaunchedEffect(messages.size) {
|
LaunchedEffect(messageItems.size) {
|
||||||
if (messages.isNotEmpty()) {
|
val count = messageItems.size
|
||||||
listState.animateScrollToItem(messages.size - 1)
|
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)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
items = messages,
|
items = messageItems,
|
||||||
key = { it.id }
|
key = { it.id }
|
||||||
) { message ->
|
) { message ->
|
||||||
MessageItem(message = message)
|
MessageItem(message = message)
|
||||||
@@ -58,7 +71,7 @@ fun MessageList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 用户查看历史消息时,新消息到达显示 "↓ 新消息" 提示条
|
// 用户查看历史消息时,新消息到达显示 "↓ 新消息" 提示条
|
||||||
if (!isAtBottom && messages.isNotEmpty()) {
|
if (!isAtBottom && messageItems.isNotEmpty()) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
@@ -71,8 +84,19 @@ fun MessageList(
|
|||||||
) {
|
) {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
|
val count = messageItems.size
|
||||||
|
if (count > 0) {
|
||||||
coroutineScope.launch {
|
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)
|
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.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.semantics.stateDescription
|
import androidx.compose.ui.semantics.stateDescription
|
||||||
@@ -37,6 +39,7 @@ fun PTTButton(
|
|||||||
val semanticColors = MaterialTheme.semanticColors
|
val semanticColors = MaterialTheme.semanticColors
|
||||||
val currentOnStartTransmit by rememberUpdatedState(onStartTransmit)
|
val currentOnStartTransmit by rememberUpdatedState(onStartTransmit)
|
||||||
val currentOnStopTransmit by rememberUpdatedState(onStopTransmit)
|
val currentOnStopTransmit by rememberUpdatedState(onStopTransmit)
|
||||||
|
val haptic = LocalHapticFeedback.current
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = {},
|
onClick = {},
|
||||||
@@ -51,6 +54,8 @@ fun PTTButton(
|
|||||||
if (!enabled) return@pointerInput
|
if (!enabled) return@pointerInput
|
||||||
awaitEachGesture {
|
awaitEachGesture {
|
||||||
awaitFirstDown(requireUnconsumed = false)
|
awaitFirstDown(requireUnconsumed = false)
|
||||||
|
// 按下时给予触觉反馈,确认发言动作已开始。
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
currentOnStartTransmit()
|
currentOnStartTransmit()
|
||||||
try {
|
try {
|
||||||
waitForUpOrCancellation()
|
waitForUpOrCancellation()
|
||||||
|
|||||||
@@ -1,30 +1,48 @@
|
|||||||
package com.tsmobile.app.ui.components
|
package com.tsmobile.app.ui.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.LinkOff
|
import androidx.compose.material.icons.filled.LinkOff
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.tsmobile.app.data.ConnectionState
|
import com.tsmobile.app.data.ConnectionState
|
||||||
import com.tsmobile.app.data.ServerInfo
|
import com.tsmobile.app.data.ServerInfo
|
||||||
import com.tsmobile.app.ui.theme.UiTokens
|
import com.tsmobile.app.ui.theme.UiTokens
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ServerDetailCard(
|
fun ServerDetailCard(
|
||||||
@@ -35,28 +53,96 @@ fun ServerDetailCard(
|
|||||||
onDisconnect: () -> Unit,
|
onDisconnect: () -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
var showDisconnectDialog by remember { mutableStateOf(false) }
|
var showDisconnectDialog by remember { mutableStateOf(false) }
|
||||||
val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.8f).dp
|
|
||||||
|
|
||||||
DetailSheetScaffold(
|
ModalDrawerSheet {
|
||||||
title = serverName.ifEmpty { "服务器详情" },
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
subtitle = serverAddress,
|
// ═══ 顶部:服务器名称 + 地址 + 关闭按钮 ═══
|
||||||
onDismiss = onDismiss,
|
Row(
|
||||||
modifier = Modifier.padding(UiTokens.Spacing.Large),
|
modifier = Modifier
|
||||||
maxHeight = maxHeight,
|
.fillMaxWidth()
|
||||||
content = { ServerInfoSection(serverInfo, connectionState) },
|
.padding(
|
||||||
footer = {
|
start = UiTokens.Spacing.Large,
|
||||||
|
end = UiTokens.Spacing.Small,
|
||||||
|
top = UiTokens.Spacing.Large,
|
||||||
|
bottom = UiTokens.Spacing.Medium,
|
||||||
|
),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = serverName.ifEmpty { "服务器详情" },
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
if (serverAddress.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(UiTokens.Spacing.ExtraSmall))
|
||||||
|
Text(
|
||||||
|
text = serverAddress,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "关闭")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
|
||||||
|
// ═══ 中部:欢迎消息 + 服务器信息(可滚动) ═══
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(UiTokens.Spacing.Large),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(UiTokens.Spacing.Medium),
|
||||||
|
) {
|
||||||
|
// 欢迎消息
|
||||||
|
if (serverInfo != null && serverInfo.welcomeMessage.isNotEmpty()) {
|
||||||
|
SectionCard(title = "欢迎消息") {
|
||||||
|
Text(
|
||||||
|
text = 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(
|
OutlinedButton(
|
||||||
onClick = { showDisconnectDialog = true },
|
onClick = { showDisconnectDialog = true },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = UiTokens.Spacing.Large, vertical = UiTokens.Spacing.Small),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.LinkOff, contentDescription = null)
|
Icon(Icons.Default.LinkOff, contentDescription = null)
|
||||||
Spacer(Modifier.width(UiTokens.Spacing.Small))
|
Spacer(Modifier.width(UiTokens.Spacing.Small))
|
||||||
Text("断开连接")
|
Text("断开连接")
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
if (showDisconnectDialog) {
|
if (showDisconnectDialog) {
|
||||||
DisconnectConfirmDialog(
|
DisconnectConfirmDialog(
|
||||||
@@ -67,7 +153,34 @@ fun ServerDetailCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ServerInfoSection(serverInfo: ServerInfo?, connectionState: ConnectionState?) {
|
private fun SectionCard(
|
||||||
|
title: String,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
tonalElevation = UiTokens.Elevation.Raised,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(UiTokens.Spacing.Medium)) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(UiTokens.Spacing.Small))
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ServerInfoContent(
|
||||||
|
serverInfo: ServerInfo?,
|
||||||
|
connectionState: ConnectionState?,
|
||||||
|
) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(UiTokens.Spacing.Small)) {
|
Column(verticalArrangement = Arrangement.spacedBy(UiTokens.Spacing.Small)) {
|
||||||
DetailInfoRow("状态", when (connectionState) {
|
DetailInfoRow("状态", when (connectionState) {
|
||||||
is ConnectionState.Connected -> "已连接"
|
is ConnectionState.Connected -> "已连接"
|
||||||
@@ -78,17 +191,18 @@ private fun ServerInfoSection(serverInfo: ServerInfo?, connectionState: Connecti
|
|||||||
null -> "未连接"
|
null -> "未连接"
|
||||||
})
|
})
|
||||||
if (serverInfo == null) {
|
if (serverInfo == null) {
|
||||||
Text("正在加载服务器信息...", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(
|
||||||
|
"正在加载服务器信息...",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
DetailInfoRow("在线人数", "${serverInfo.clientsOnline} / ${serverInfo.maxClients}")
|
DetailInfoRow("在线人数", "${serverInfo.clientsOnline} / ${serverInfo.maxClients}")
|
||||||
DetailInfoRow("频道数", "${serverInfo.channelsOnline}")
|
DetailInfoRow("频道数", "${serverInfo.channelsOnline}")
|
||||||
if (serverInfo.version.isNotEmpty()) DetailInfoRow("版本", serverInfo.version)
|
if (serverInfo.version.isNotEmpty()) DetailInfoRow("版本", serverInfo.version)
|
||||||
if (serverInfo.platform.isNotEmpty()) DetailInfoRow("平台", serverInfo.platform)
|
if (serverInfo.platform.isNotEmpty()) DetailInfoRow("平台", serverInfo.platform)
|
||||||
(serverInfo.uptime.toLongOrNull() ?: 0).takeIf { it > 0 }?.let { DetailInfoRow("运行时长", formatUptime(it)) }
|
(serverInfo.uptime.toLongOrNull() ?: 0).takeIf { it > 0 }?.let {
|
||||||
if (serverInfo.welcomeMessage.isNotEmpty()) {
|
DetailInfoRow("运行时长", formatUptime(it))
|
||||||
Spacer(Modifier.height(UiTokens.Spacing.Small))
|
|
||||||
Text("欢迎消息", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
Text(serverInfo.welcomeMessage, style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,5 +212,64 @@ private fun formatUptime(seconds: Long): String {
|
|||||||
val days = seconds / 86400
|
val days = seconds / 86400
|
||||||
val hours = (seconds % 86400) / 3600
|
val hours = (seconds % 86400) / 3600
|
||||||
val minutes = (seconds % 3600) / 60
|
val minutes = (seconds % 3600) / 60
|
||||||
return when { days > 0 -> "${days}天${hours}小时"; hours > 0 -> "${hours}小时${minutes}分钟"; else -> "${minutes}分钟" }
|
return when {
|
||||||
|
days > 0 -> "${days}天${hours}小时"
|
||||||
|
hours > 0 -> "${hours}小时${minutes}分钟"
|
||||||
|
else -> "${minutes}分钟"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取崩溃日志(内部 + 外部目录,含轮换的 .old,按内容去重),用于导出/分享。 */
|
||||||
|
private fun readCrashLog(context: Context): String {
|
||||||
|
val candidates = mutableListOf<File>()
|
||||||
|
candidates += File(context.filesDir, "crash.log")
|
||||||
|
candidates += File(context.filesDir, "crash.log.old")
|
||||||
|
context.getExternalFilesDir(null)?.let { ext ->
|
||||||
|
candidates += File(ext, "crash.log")
|
||||||
|
candidates += File(ext, "crash.log.old")
|
||||||
|
}
|
||||||
|
val texts = candidates
|
||||||
|
.filter { it.exists() && it.length() > 0 }
|
||||||
|
.mapNotNull { f -> try { f.readText().ifBlank { null } } catch (_: Exception) { null } }
|
||||||
|
return texts.distinct().joinToString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通过系统分享面板导出崩溃日志:写入 .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.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -178,6 +179,7 @@ private fun MemberRow(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(min = 64.dp)
|
.heightIn(min = 64.dp)
|
||||||
|
.clip(RoundedCornerShape(UiTokens.Spacing.Medium))
|
||||||
.background(containerColor, RoundedCornerShape(UiTokens.Spacing.Medium))
|
.background(containerColor, RoundedCornerShape(UiTokens.Spacing.Medium))
|
||||||
.combinedClickable(onClick = {}, onLongClick = onLongClick)
|
.combinedClickable(onClick = {}, onLongClick = onLongClick)
|
||||||
.padding(horizontal = 14.dp, vertical = UiTokens.Spacing.Medium),
|
.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.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.EnterTransition
|
import androidx.compose.animation.EnterTransition
|
||||||
import androidx.compose.animation.ExitTransition
|
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.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -128,11 +134,46 @@ fun AppNavGraph(
|
|||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = Routes.SERVER_CONFIG,
|
startDestination = Routes.SERVER_CONFIG,
|
||||||
// 禁用默认的淡入淡出动画,避免页面切换显得迟钝
|
// 默认不动画;仅聊天页做“从右滑入/滑出”的过渡,避免整套页面切换显得迟钝。
|
||||||
enterTransition = { EnterTransition.None },
|
enterTransition = {
|
||||||
exitTransition = { ExitTransition.None },
|
when (targetState.destination.route) {
|
||||||
popEnterTransition = { EnterTransition.None },
|
// 前进到主界面(首页→频道页、频道页→聊天页):从右侧滑入。
|
||||||
popExitTransition = { ExitTransition.None },
|
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) {
|
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.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
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.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
@@ -17,8 +26,10 @@ import androidx.compose.material3.*
|
|||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -110,12 +121,52 @@ fun ChannelListScreen(
|
|||||||
// 防抖:服务器详情按钮上次点击时间
|
// 防抖:服务器详情按钮上次点击时间
|
||||||
var lastServerDetailClickTime by remember { mutableLongStateOf(0L) }
|
var lastServerDetailClickTime by remember { mutableLongStateOf(0L) }
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize().systemBarsPadding()) {
|
// 服务器状态(提前声明,供侧栏 drawerContent 读取)
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
|
||||||
// 头部
|
|
||||||
val serverInfo by serverViewModel.serverInfo.collectAsState()
|
val serverInfo by serverViewModel.serverInfo.collectAsState()
|
||||||
val serverState by serverViewModel.state.collectAsState()
|
val serverState by serverViewModel.state.collectAsState()
|
||||||
val themeMode by serverViewModel.themeMode.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(
|
ChannelListHeader(
|
||||||
connectionState = connectionState,
|
connectionState = connectionState,
|
||||||
serverName = serverInfo?.name ?: "",
|
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)
|
// 频道详情卡弹窗(BottomSheet)
|
||||||
if (showChannelDetailCard) {
|
if (showChannelDetailCard) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
@@ -662,49 +692,96 @@ private fun CurrentChannelBar(
|
|||||||
val currentChannelId by channelViewModel.currentChannelId.collectAsState()
|
val currentChannelId by channelViewModel.currentChannelId.collectAsState()
|
||||||
val channels by channelViewModel.channels.collectAsState()
|
val channels by channelViewModel.channels.collectAsState()
|
||||||
val channelClients by channelViewModel.channelClients.collectAsState()
|
val channelClients by channelViewModel.channelClients.collectAsState()
|
||||||
|
val selfClientId by channelViewModel.selfClientId.collectAsState()
|
||||||
|
|
||||||
val currentChannel = channels.find { it.id == currentChannelId }
|
val currentChannel = channels.find { it.id == currentChannelId }
|
||||||
val clients = channelClients[currentChannelId] ?: emptyList()
|
|
||||||
|
|
||||||
if (currentChannel != null) {
|
if (currentChannel != null) {
|
||||||
val selfClientId by channelViewModel.selfClientId.collectAsState()
|
Surface(
|
||||||
val previewCandidates = clients.sortedByDescending { it.id == selfClientId }
|
|
||||||
val semanticColors = MaterialTheme.semanticColors
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(68.dp)
|
.clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick),
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
.padding(
|
tonalElevation = UiTokens.Elevation.Subtle,
|
||||||
start = FOOTER_LEFT_INDENT,
|
) {
|
||||||
end = FOOTER_RIGHT_INDENT,
|
// 切换频道时,当前频道栏内容做淡入淡出 + 轻微横向滑入动画。
|
||||||
top = UiTokens.Spacing.ExtraSmall,
|
AnimatedContent(
|
||||||
bottom = UiTokens.Spacing.ExtraSmall,
|
targetState = currentChannelId,
|
||||||
),
|
transitionSpec = {
|
||||||
verticalArrangement = Arrangement.spacedBy(UiTokens.Spacing.ExtraSmall),
|
(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(
|
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,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
// 频道图标(tonal 圆)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(UiTokens.Size.ControlHeight)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.LocationOn,
|
Icons.Default.LocationOn,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
modifier = Modifier.size(UiTokens.Size.IconMedium),
|
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(
|
||||||
text = currentChannel.name,
|
text = channel.name,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
)
|
||||||
if (currentChannel.isPassword) {
|
if (channel.isPassword) {
|
||||||
Spacer(Modifier.width(UiTokens.Spacing.ExtraSmall))
|
Spacer(Modifier.width(UiTokens.Spacing.ExtraSmall))
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Lock,
|
Icons.Default.Lock,
|
||||||
@@ -713,109 +790,85 @@ private fun CurrentChannelBar(
|
|||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
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(
|
||||||
text = if (currentChannel.maxClients >= 0) {
|
text = "在线 ${clients.size} 人",
|
||||||
"${clients.size}/${currentChannel.maxClients}"
|
style = MaterialTheme.typography.bodySmall,
|
||||||
} 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,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
// 成员头像堆叠
|
||||||
}
|
MemberAvatarStack(
|
||||||
|
clients = clients,
|
||||||
|
selfClientId = selfClientId,
|
||||||
|
maxVisible = 3,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.width(UiTokens.Spacing.Small))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MemberPreviewTag(
|
private fun MemberAvatarStack(
|
||||||
name: String,
|
clients: List<ClientInfo>,
|
||||||
containerColor: Color,
|
selfClientId: Int,
|
||||||
contentColor: Color,
|
maxVisible: Int = 3,
|
||||||
showPresence: Boolean = false,
|
|
||||||
presenceColor: Color = Color.Unspecified,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
modifier = modifier,
|
|
||||||
shape = MaterialTheme.shapes.small,
|
|
||||||
color = containerColor,
|
|
||||||
contentColor = contentColor,
|
|
||||||
) {
|
) {
|
||||||
|
val visible = clients.sortedByDescending { it.id == selfClientId }.take(maxVisible)
|
||||||
|
val hidden = clients.size - visible.size
|
||||||
|
val avatarSize = 28.dp
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
horizontalArrangement = Arrangement.spacedBy((-6).dp),
|
||||||
.widthIn(max = 96.dp)
|
|
||||||
.height(24.dp)
|
|
||||||
.padding(horizontal = UiTokens.Spacing.Small),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
if (showPresence) {
|
visible.forEach { client ->
|
||||||
|
val isSelf = client.id == selfClientId
|
||||||
|
val isGrouped = client.serverGroups.isNotEmpty()
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(6.dp)
|
.size(avatarSize)
|
||||||
.background(presenceColor, CircleShape),
|
.clip(CircleShape)
|
||||||
)
|
.background(
|
||||||
Spacer(Modifier.width(UiTokens.Spacing.ExtraSmall))
|
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(
|
||||||
text = name,
|
text = client.nickname.take(1).ifBlank { "?" },
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
color = when {
|
||||||
overflow = TextOverflow.Ellipsis,
|
isSelf -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
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.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.ui.focus.FocusManager
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.platform.SoftwareKeyboardController
|
||||||
import com.tsmobile.app.data.ChannelInfo
|
import com.tsmobile.app.data.ChannelInfo
|
||||||
import com.tsmobile.app.ui.components.ChannelDetailCard
|
import com.tsmobile.app.ui.components.ChannelDetailCard
|
||||||
import com.tsmobile.app.ui.components.ChatHeader
|
import com.tsmobile.app.ui.components.ChatHeader
|
||||||
@@ -18,6 +21,31 @@ import com.tsmobile.app.viewmodel.ChannelViewModel
|
|||||||
import com.tsmobile.app.viewmodel.ChatViewModel
|
import com.tsmobile.app.viewmodel.ChatViewModel
|
||||||
import com.tsmobile.app.viewmodel.ServerViewModel
|
import com.tsmobile.app.viewmodel.ServerViewModel
|
||||||
import com.tsmobile.app.viewmodel.VoiceViewModel
|
import com.tsmobile.app.viewmodel.VoiceViewModel
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/** 等待输入法收起动画完成的时长,避免 ModalBottomSheet 与键盘 insets 同帧叠加触发测量竞态。 */
|
||||||
|
private const val KEYBOARD_COLLAPSE_MS = 160L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收起键盘并等待 IME 收起动画完成后再执行 open。
|
||||||
|
* 若在同一帧内同时弹出 ModalBottomSheet 且键盘 insets 正在动画,
|
||||||
|
* 会触发 WindowInsets 测量竞态导致概率性闪退。语音卡 / 频道详情卡共用此封装。
|
||||||
|
*/
|
||||||
|
private fun openCardAfterKeyboardCollapse(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
keyboardController: SoftwareKeyboardController?,
|
||||||
|
focusManager: FocusManager,
|
||||||
|
open: () -> Unit,
|
||||||
|
) {
|
||||||
|
keyboardController?.hide()
|
||||||
|
focusManager.clearFocus()
|
||||||
|
scope.launch {
|
||||||
|
delay(KEYBOARD_COLLAPSE_MS)
|
||||||
|
open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 聊天页。
|
* 聊天页。
|
||||||
@@ -38,7 +66,9 @@ fun ChatScreen(
|
|||||||
val currentChannelId by channelViewModel.currentChannelId.collectAsState()
|
val currentChannelId by channelViewModel.currentChannelId.collectAsState()
|
||||||
val channels by channelViewModel.channels.collectAsState()
|
val channels by channelViewModel.channels.collectAsState()
|
||||||
val channelClients by channelViewModel.channelClients.collectAsState()
|
val channelClients by channelViewModel.channelClients.collectAsState()
|
||||||
var showVoiceCard by remember { mutableStateOf(false) }
|
// 语音卡显示状态:统一使用 voiceViewModel 作为唯一来源(与频道列表页一致),
|
||||||
|
// 避免本地 remember 与 ViewModel StateFlow 双份数据源造成状态竞态。
|
||||||
|
val showVoiceCard by voiceViewModel.showVoiceCard.collectAsState()
|
||||||
|
|
||||||
// 语音卡 Poke 状态(长按成员 → PokeDialog)
|
// 语音卡 Poke 状态(长按成员 → PokeDialog)
|
||||||
var voiceCardPokeTarget by remember { mutableStateOf<Pair<Int, String>?>(null) }
|
var voiceCardPokeTarget by remember { mutableStateOf<Pair<Int, String>?>(null) }
|
||||||
@@ -50,6 +80,8 @@ fun ChatScreen(
|
|||||||
val currentChannel = channels.find { it.id == currentChannelId }
|
val currentChannel = channels.find { it.id == currentChannelId }
|
||||||
val clients = channelClients[currentChannelId] ?: emptyList()
|
val clients = channelClients[currentChannelId] ?: emptyList()
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -63,7 +95,11 @@ fun ChatScreen(
|
|||||||
memberCount = clients.size,
|
memberCount = clients.size,
|
||||||
onBack = onNavigateBack,
|
onBack = onNavigateBack,
|
||||||
onChannelDetail = {
|
onChannelDetail = {
|
||||||
currentChannel?.let { onOpenChannelDetail(it.id) }
|
currentChannel?.let { channel ->
|
||||||
|
openCardAfterKeyboardCollapse(scope, keyboardController, focusManager) {
|
||||||
|
onOpenChannelDetail(channel.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,9 +122,10 @@ fun ChatScreen(
|
|||||||
VoiceControlBar(
|
VoiceControlBar(
|
||||||
voiceViewModel = voiceViewModel,
|
voiceViewModel = voiceViewModel,
|
||||||
onExpand = {
|
onExpand = {
|
||||||
focusManager.clearFocus()
|
// 先收起键盘,再等 IME 收起动画完成后再弹语音卡(同频道详情卡共用封装)
|
||||||
|
openCardAfterKeyboardCollapse(scope, keyboardController, focusManager) {
|
||||||
voiceViewModel.openVoiceCard()
|
voiceViewModel.openVoiceCard()
|
||||||
showVoiceCard = true
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -96,18 +133,12 @@ fun ChatScreen(
|
|||||||
// 语音卡弹窗(BottomSheet)
|
// 语音卡弹窗(BottomSheet)
|
||||||
if (showVoiceCard) {
|
if (showVoiceCard) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = {
|
onDismissRequest = { voiceViewModel.closeVoiceCard() },
|
||||||
showVoiceCard = false
|
|
||||||
voiceViewModel.closeVoiceCard()
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
VoiceCard(
|
VoiceCard(
|
||||||
voiceViewModel = voiceViewModel,
|
voiceViewModel = voiceViewModel,
|
||||||
channelViewModel = channelViewModel,
|
channelViewModel = channelViewModel,
|
||||||
onDismiss = {
|
onDismiss = { voiceViewModel.closeVoiceCard() },
|
||||||
showVoiceCard = false
|
|
||||||
voiceViewModel.closeVoiceCard()
|
|
||||||
},
|
|
||||||
onPokeClient = { clientId, message ->
|
onPokeClient = { clientId, message ->
|
||||||
serverViewModel.pokeClient(clientId, message)
|
serverViewModel.pokeClient(clientId, message)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import com.tsmobile.app.ui.components.ConnectButton
|
|||||||
import com.tsmobile.app.ui.components.UpdateCheckDialog
|
import com.tsmobile.app.ui.components.UpdateCheckDialog
|
||||||
import com.tsmobile.app.viewmodel.ConnectState
|
import com.tsmobile.app.viewmodel.ConnectState
|
||||||
import com.tsmobile.app.viewmodel.ServerViewModel
|
import com.tsmobile.app.viewmodel.ServerViewModel
|
||||||
|
import com.tsmobile.app.viewmodel.ValidationErrors
|
||||||
import com.tsmobile.app.viewmodel.formatRelativeTime
|
import com.tsmobile.app.viewmodel.formatRelativeTime
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,6 +43,8 @@ fun ServerConfigScreen(
|
|||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val recents by viewModel.recents.collectAsState()
|
val recents by viewModel.recents.collectAsState()
|
||||||
val themeMode by viewModel.themeMode.collectAsState()
|
val themeMode by viewModel.themeMode.collectAsState()
|
||||||
|
// 长按删除最近连接的确认目标(非 null 时弹出确认框)
|
||||||
|
var pendingRemoveRecent by remember { mutableStateOf<RecentConnection?>(null) }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -66,6 +69,7 @@ fun ServerConfigScreen(
|
|||||||
onPasswordChange = viewModel::updatePassword,
|
onPasswordChange = viewModel::updatePassword,
|
||||||
connectState = state.connectState,
|
connectState = state.connectState,
|
||||||
errorMessage = state.errorMessage,
|
errorMessage = state.errorMessage,
|
||||||
|
validationErrors = state.validationErrors,
|
||||||
onConnect = { viewModel.connect() },
|
onConnect = { viewModel.connect() },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,7 +78,7 @@ fun ServerConfigScreen(
|
|||||||
recents = recents,
|
recents = recents,
|
||||||
enabled = state.connectState != ConnectState.CONNECTING,
|
enabled = state.connectState != ConnectState.CONNECTING,
|
||||||
onConnectRecent = { recent -> viewModel.quickConnect(recent) },
|
onConnectRecent = { recent -> viewModel.quickConnect(recent) },
|
||||||
onRemoveRecent = { recent -> viewModel.removeRecent(recent) },
|
onRemoveRecent = { recent -> pendingRemoveRecent = recent },
|
||||||
onClearAll = { viewModel.clearRecents() },
|
onClearAll = { viewModel.clearRecents() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -97,6 +101,30 @@ fun ServerConfigScreen(
|
|||||||
onClose = { viewModel.closeUpdateDialog() },
|
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,
|
onPasswordChange: (String) -> Unit,
|
||||||
connectState: ConnectState,
|
connectState: ConnectState,
|
||||||
errorMessage: String?,
|
errorMessage: String?,
|
||||||
|
validationErrors: ValidationErrors,
|
||||||
onConnect: () -> Unit,
|
onConnect: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val addressValidation = validationErrors.address
|
||||||
|
val nicknameValidation = validationErrors.nickname
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -206,7 +238,10 @@ private fun InputSection(
|
|||||||
label = { Text("服务器地址") },
|
label = { Text("服务器地址") },
|
||||||
placeholder = { Text("ts.example.com") },
|
placeholder = { Text("ts.example.com") },
|
||||||
singleLine = true,
|
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(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
)
|
)
|
||||||
@@ -220,7 +255,10 @@ private fun InputSection(
|
|||||||
label = { Text("昵称") },
|
label = { Text("昵称") },
|
||||||
placeholder = { Text("我的昵称") },
|
placeholder = { Text("我的昵称") },
|
||||||
singleLine = true,
|
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(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -67,8 +67,9 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
// ChatViewModel 引用(由 ServerViewModel 设置)
|
// ChatViewModel 引用(由 ServerViewModel 设置)。
|
||||||
var chatViewModel: ChatViewModel? = null
|
// 主线程赋值,桥接回调解读,需 @Volatile 保证可见性。
|
||||||
|
@Volatile var chatViewModel: ChatViewModel? = null
|
||||||
|
|
||||||
// 频道列表最后刷新时间
|
// 频道列表最后刷新时间
|
||||||
private var lastChannelRefreshTime: Long = 0
|
private var lastChannelRefreshTime: Long = 0
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
private const val TAG = "ChatViewModel"
|
private const val TAG = "ChatViewModel"
|
||||||
/** 消息送达确认超时(毫秒):超时未收到回显则标记为 FAILED */
|
/** 消息送达确认超时(毫秒):超时未收到回显则标记为 FAILED */
|
||||||
private const val DELIVERY_TIMEOUT_MS = 10_000L
|
private const val DELIVERY_TIMEOUT_MS = 10_000L
|
||||||
/** 系统消息 ID 计数器,避免同毫秒并发调用产生重复 key */
|
/** 全局消息 ID 递增序列:保证同一毫秒内生成的消息 ID 唯一,
|
||||||
private val sysMsgSeq = AtomicLong(0)
|
* 避免 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()
|
val sendState: StateFlow<MessageSendState> = _sendState.asStateFlow()
|
||||||
|
|
||||||
// 当前查看的会话参数
|
// 当前查看的会话参数
|
||||||
|
// @Volatile:主线程写入(enterChat),JNI 桥接线程读取(handleTextMessage),需保证可见性
|
||||||
|
@Volatile
|
||||||
private var currentTargetMode: Int = 2
|
private var currentTargetMode: Int = 2
|
||||||
|
@Volatile
|
||||||
private var currentTargetId: Long = 0
|
private var currentTargetId: Long = 0
|
||||||
|
|
||||||
// 送达确认超时 Job(每条消息独立管理,避免快速发送时相互覆盖)
|
// 送达确认超时 Job(每条消息独立管理,避免快速发送时相互覆盖)
|
||||||
@@ -91,7 +95,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
*/
|
*/
|
||||||
fun addSystemMessage(content: String) {
|
fun addSystemMessage(content: String) {
|
||||||
val message = ChatMessage(
|
val message = ChatMessage(
|
||||||
id = "sys_${System.currentTimeMillis()}_${sysMsgSeq.incrementAndGet()}",
|
id = "sys_${System.currentTimeMillis()}_${msgIdSeq.incrementAndGet()}",
|
||||||
targetMode = 2,
|
targetMode = 2,
|
||||||
targetId = Repository.currentChannelId.value.toLongOrNull() ?: 0L,
|
targetId = Repository.currentChannelId.value.toLongOrNull() ?: 0L,
|
||||||
senderId = 0,
|
senderId = 0,
|
||||||
@@ -129,7 +133,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val selfName = Repository.getClient(selfId)?.nickname ?: ""
|
val selfName = Repository.getClient(selfId)?.nickname ?: ""
|
||||||
|
|
||||||
// 生成唯一 ID(用于匹配回显)
|
// 生成唯一 ID(用于匹配回显)
|
||||||
val messageId = "local_${System.currentTimeMillis()}_${selfId}"
|
// 追加全局递增 seq,避免同毫秒多条本地消息 ID 重复
|
||||||
|
val messageId = "local_${System.currentTimeMillis()}_${selfId}_${msgIdSeq.incrementAndGet()}"
|
||||||
val timestamp = System.currentTimeMillis()
|
val timestamp = System.currentTimeMillis()
|
||||||
|
|
||||||
// 1. 立即添加到本地列表(PENDING 状态)
|
// 1. 立即添加到本地列表(PENDING 状态)
|
||||||
@@ -277,7 +282,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
// 非自己的消息,或无法匹配的自己的消息 → 正常归档
|
// 非自己的消息,或无法匹配的自己的消息 → 正常归档
|
||||||
val message = ChatMessage(
|
val message = ChatMessage(
|
||||||
id = "${System.currentTimeMillis()}_${senderId}",
|
// 追加全局递增 seq,避免同毫秒同发送者多条消息 ID 重复
|
||||||
|
id = "${System.currentTimeMillis()}_${senderId}_${msgIdSeq.incrementAndGet()}",
|
||||||
targetMode = targetMode,
|
targetMode = targetMode,
|
||||||
targetId = targetId,
|
targetId = targetId,
|
||||||
senderId = senderId,
|
senderId = senderId,
|
||||||
|
|||||||
@@ -80,14 +80,13 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
private val recentStore = RecentConnectionsStore(application)
|
private val recentStore = RecentConnectionsStore(application)
|
||||||
private val identityStore = IdentityStore(application)
|
private val identityStore = IdentityStore(application)
|
||||||
|
|
||||||
// ChannelViewModel 引用(由 NavGraph 设置)
|
// ChannelViewModel 引用(由 NavGraph 设置)。
|
||||||
var channelViewModel: ChannelViewModel? = null
|
// NavGraph 在主线程赋值,桥接回调解读,需 @Volatile 保证可见性,避免读到过期 null。
|
||||||
|
@Volatile var channelViewModel: ChannelViewModel? = null
|
||||||
|
|
||||||
// ChatViewModel 引用(由 NavGraph 设置)
|
@Volatile var chatViewModel: ChatViewModel? = null
|
||||||
var chatViewModel: ChatViewModel? = null
|
|
||||||
|
|
||||||
// VoiceViewModel 引用(由 NavGraph 设置)
|
@Volatile var voiceViewModel: VoiceViewModel? = null
|
||||||
var voiceViewModel: VoiceViewModel? = null
|
|
||||||
|
|
||||||
// 页面状态
|
// 页面状态
|
||||||
private val _state = MutableStateFlow(ServerScreenState())
|
private val _state = MutableStateFlow(ServerScreenState())
|
||||||
@@ -142,12 +141,16 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
val showPokeNotification: StateFlow<Boolean> = _showPokeNotification.asStateFlow()
|
val showPokeNotification: StateFlow<Boolean> = _showPokeNotification.asStateFlow()
|
||||||
|
|
||||||
// ── 重连状态 ──
|
// ── 重连状态 ──
|
||||||
private var reconnectAttempt = 0
|
// 这些字段被桥接回调线程(onDisconnected / onKicked)与主线程
|
||||||
private var reconnectJob: Job? = null
|
// (init 网络收集器、手动操作)并发访问。加 @Volatile 保证跨线程可见性,
|
||||||
private var lastConnectParams: ConnectParams? = null
|
// 并用 reconnectLock 串行化重连循环的启动/取消,避免并发启动两条重连。
|
||||||
|
private val reconnectLock = Any()
|
||||||
|
@Volatile private var reconnectAttempt = 0
|
||||||
|
@Volatile private var reconnectJob: Job? = null
|
||||||
|
@Volatile private var lastConnectParams: ConnectParams? = null
|
||||||
|
|
||||||
/** 断联前的频道 ID,用于伪重连后自动回到原频道(空字符串=不自动移动) */
|
/** 断联前的频道 ID,用于伪重连后自动回到原频道(空字符串=不自动移动) */
|
||||||
private var lastChannelId: String = ""
|
@Volatile private var lastChannelId: String = ""
|
||||||
|
|
||||||
// ── 网络监控 ──
|
// ── 网络监控 ──
|
||||||
private val networkMonitor = NetworkMonitor(application)
|
private val networkMonitor = NetworkMonitor(application)
|
||||||
@@ -192,6 +195,7 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
lastChannelId = chId
|
lastChannelId = chId
|
||||||
}
|
}
|
||||||
_connectionState.value = ConnectionState.WaitingForNetwork()
|
_connectionState.value = ConnectionState.WaitingForNetwork()
|
||||||
|
ConnectionService.update(getApplication(), "网络已断开,正在重连...", _state.value.address.trim())
|
||||||
if (networkMonitor.isAvailable.value) {
|
if (networkMonitor.isAvailable.value) {
|
||||||
startReconnect("网络变化,正在重连...")
|
startReconnect("网络变化,正在重连...")
|
||||||
}
|
}
|
||||||
@@ -392,11 +396,23 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
// --- 输入更新 ---
|
// --- 输入更新 ---
|
||||||
|
|
||||||
fun updateAddress(value: String) {
|
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) {
|
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) {
|
fun updatePassword(value: String) {
|
||||||
@@ -782,8 +798,10 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
networkMonitor.reset()
|
networkMonitor.reset()
|
||||||
|
|
||||||
// 1. 停止重连
|
// 1. 停止重连
|
||||||
|
synchronized(reconnectLock) {
|
||||||
reconnectJob?.let { it.cancel() }
|
reconnectJob?.let { it.cancel() }
|
||||||
reconnectJob = null
|
reconnectJob = null
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 设置断开中状态
|
// 2. 设置断开中状态
|
||||||
_connectionState.value = ConnectionState.Disconnecting
|
_connectionState.value = ConnectionState.Disconnecting
|
||||||
@@ -808,14 +826,14 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
// 6. 重置状态
|
// 6. 重置状态
|
||||||
_connectionState.value = null
|
_connectionState.value = null
|
||||||
_state.update { it.copy(connectState = ConnectState.IDLE) }
|
_state.update { it.copy(connectState = ConnectState.IDLE) }
|
||||||
lastConnectParams = null
|
synchronized(reconnectLock) { lastConnectParams = null }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理被踢后的重新连接
|
* 处理被踢后的重新连接
|
||||||
*/
|
*/
|
||||||
fun reconnectAfterKick() {
|
fun reconnectAfterKick() {
|
||||||
val params = lastConnectParams ?: return
|
val params = synchronized(reconnectLock) { lastConnectParams } ?: return
|
||||||
_kickReason.value = ""
|
_kickReason.value = ""
|
||||||
_connectionState.value = null
|
_connectionState.value = null
|
||||||
|
|
||||||
@@ -835,13 +853,15 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
* 返回主页(被踢后或断开后)
|
* 返回主页(被踢后或断开后)
|
||||||
*/
|
*/
|
||||||
fun backToHome() {
|
fun backToHome() {
|
||||||
|
synchronized(reconnectLock) {
|
||||||
reconnectJob?.let { it.cancel() }
|
reconnectJob?.let { it.cancel() }
|
||||||
reconnectJob = null
|
reconnectJob = null
|
||||||
lastChannelId = ""
|
lastChannelId = ""
|
||||||
|
lastConnectParams = null
|
||||||
|
}
|
||||||
networkMonitor.reset()
|
networkMonitor.reset()
|
||||||
_kickReason.value = ""
|
_kickReason.value = ""
|
||||||
_connectionState.value = null
|
_connectionState.value = null
|
||||||
lastConnectParams = null
|
|
||||||
Repository.clearSession()
|
Repository.clearSession()
|
||||||
channelViewModel?.clearChannels()
|
channelViewModel?.clearChannels()
|
||||||
_state.update { it.copy(connectState = ConnectState.IDLE) }
|
_state.update { it.copy(connectState = ConnectState.IDLE) }
|
||||||
@@ -853,6 +873,9 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
* 最多尝试 5 次,间隔递增(2s → 4s → 8s → 16s → 30s)
|
* 最多尝试 5 次,间隔递增(2s → 4s → 8s → 16s → 30s)
|
||||||
*/
|
*/
|
||||||
private fun startReconnect(reason: String) {
|
private fun startReconnect(reason: String) {
|
||||||
|
synchronized(reconnectLock) {
|
||||||
|
// 取消仍在运行的上一次循环,确保始终只有一条重连流程。
|
||||||
|
reconnectJob?.let { it.cancel() }
|
||||||
reconnectAttempt = 0
|
reconnectAttempt = 0
|
||||||
_connectionState.value = ConnectionState.Reconnecting(
|
_connectionState.value = ConnectionState.Reconnecting(
|
||||||
attempt = 0, reason = reason
|
attempt = 0, reason = reason
|
||||||
@@ -897,6 +920,7 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
|
|
||||||
// 重连失败
|
// 重连失败
|
||||||
android.util.Log.w(TAG, "All reconnect attempts failed")
|
android.util.Log.w(TAG, "All reconnect attempts failed")
|
||||||
|
ConnectionService.stop(getApplication()) // 清除“重连中”通知
|
||||||
Repository.clearSession()
|
Repository.clearSession()
|
||||||
channelViewModel?.clearChannels()
|
channelViewModel?.clearChannels()
|
||||||
_connectionState.value = ConnectionState.Disconnected(
|
_connectionState.value = ConnectionState.Disconnected(
|
||||||
@@ -904,17 +928,20 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 手动重连(用户点击"手动重连"按钮)
|
* 手动重连(用户点击"手动重连"按钮)
|
||||||
*/
|
*/
|
||||||
fun manualReconnect() {
|
fun manualReconnect() {
|
||||||
|
synchronized(reconnectLock) {
|
||||||
reconnectJob?.let { it.cancel() }
|
reconnectJob?.let { it.cancel() }
|
||||||
reconnectJob = null
|
reconnectJob = null
|
||||||
reconnectAttempt = 0
|
reconnectAttempt = 0
|
||||||
|
}
|
||||||
val params = lastConnectParams ?: return
|
val params = synchronized(reconnectLock) { lastConnectParams } ?: return
|
||||||
_connectionState.value = ConnectionState.Reconnecting(attempt = 0, reason = "手动重连")
|
_connectionState.value = ConnectionState.Reconnecting(attempt = 0, reason = "手动重连")
|
||||||
|
ConnectionService.update(getApplication(), "正在重连...", _state.value.address.trim())
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val error = withContext(Dispatchers.IO) {
|
val error = withContext(Dispatchers.IO) {
|
||||||
@@ -944,18 +971,20 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
* 放弃重连
|
* 放弃重连
|
||||||
*/
|
*/
|
||||||
fun abandonReconnect() {
|
fun abandonReconnect() {
|
||||||
|
synchronized(reconnectLock) {
|
||||||
reconnectJob?.let { it.cancel() }
|
reconnectJob?.let { it.cancel() }
|
||||||
reconnectJob = null
|
reconnectJob = null
|
||||||
reconnectAttempt = 0
|
reconnectAttempt = 0
|
||||||
|
|
||||||
lastChannelId = ""
|
lastChannelId = ""
|
||||||
|
lastConnectParams = null
|
||||||
|
}
|
||||||
networkMonitor.reset()
|
networkMonitor.reset()
|
||||||
|
ConnectionService.stop(getApplication()) // 清除“重连中”通知
|
||||||
Repository.clearSession()
|
Repository.clearSession()
|
||||||
channelViewModel?.clearChannels()
|
channelViewModel?.clearChannels()
|
||||||
_connectionState.value = ConnectionState.Disconnected(
|
_connectionState.value = ConnectionState.Disconnected(
|
||||||
reason = "重连已放弃", wasKicked = false
|
reason = "重连已放弃", wasKicked = false
|
||||||
)
|
)
|
||||||
lastConnectParams = null
|
|
||||||
_state.update { it.copy(connectState = ConnectState.IDLE) }
|
_state.update { it.copy(connectState = ConnectState.IDLE) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1019,8 +1048,11 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
|
|
||||||
// 伪重连:回到断联前的频道(避开僵尸清理窗口)
|
// 伪重连:回到断联前的频道(避开僵尸清理窗口)
|
||||||
// 注意:此时不设置 Connected —— 遮罩保持显示,防止用户干扰自动操作
|
// 注意:此时不设置 Connected —— 遮罩保持显示,防止用户干扰自动操作
|
||||||
val targetChannel = lastChannelId
|
val targetChannel = synchronized(reconnectLock) {
|
||||||
|
val saved = lastChannelId
|
||||||
lastChannelId = "" // 一次性消费
|
lastChannelId = "" // 一次性消费
|
||||||
|
saved
|
||||||
|
}
|
||||||
if (targetChannel.isNotEmpty() && targetChannel != "0") {
|
if (targetChannel.isNotEmpty() && targetChannel != "0") {
|
||||||
// 等待 4 秒:Go 侧 cleanupDuplicateIdentitySessions 在 3 秒后 Kick 僵尸
|
// 等待 4 秒:Go 侧 cleanupDuplicateIdentitySessions 在 3 秒后 Kick 僵尸
|
||||||
delay(4_000L)
|
delay(4_000L)
|
||||||
@@ -1071,20 +1103,19 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 已在 WaitingForNetwork 或 Reconnecting 状态 → 我们的响应式监控已处理
|
// 已在 WaitingForNetwork 或 Reconnecting 状态 → 响应式监控已处理
|
||||||
|
// 但仍需更新通知栏(否则断连后仍显示"已连接")
|
||||||
if (_connectionState.value is ConnectionState.WaitingForNetwork ||
|
if (_connectionState.value is ConnectionState.WaitingForNetwork ||
|
||||||
_connectionState.value is ConnectionState.Reconnecting
|
_connectionState.value is ConnectionState.Reconnecting
|
||||||
) {
|
) {
|
||||||
android.util.Log.d(TAG, "onDisconnected while already handling reconnect, ignoring")
|
android.util.Log.d(TAG, "onDisconnected while already handling reconnect, keeping reconnecting notification")
|
||||||
|
ConnectionService.update(getApplication(), "连接已断开,正在重连...", _state.value.address.trim())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 语音断开处理
|
// 语音断开处理
|
||||||
voiceViewModel?.onDisconnected()
|
voiceViewModel?.onDisconnected()
|
||||||
|
|
||||||
// 停止前台服务
|
|
||||||
ConnectionService.stop(getApplication())
|
|
||||||
|
|
||||||
// 判断断线原因:网络丢失 vs 服务器主动断线
|
// 判断断线原因:网络丢失 vs 服务器主动断线
|
||||||
// - ConnectivityManager 明确检测到网络切换 → 网络丢失
|
// - ConnectivityManager 明确检测到网络切换 → 网络丢失
|
||||||
// - 断线原因为空或 timeout → TCP 静默断开(NAT 超时、运营商丢弃等),
|
// - 断线原因为空或 timeout → TCP 静默断开(NAT 超时、运营商丢弃等),
|
||||||
@@ -1095,10 +1126,12 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
message.contains("timeout", ignoreCase = true)
|
message.contains("timeout", ignoreCase = true)
|
||||||
|
|
||||||
if (isNetworkLoss) {
|
if (isNetworkLoss) {
|
||||||
|
// 网络丢失 → 重连,通知显示“重连中”(保留前台服务与保活)
|
||||||
|
ConnectionService.update(getApplication(), "连接已断开,正在重连...", _state.value.address.trim())
|
||||||
// 记录当前频道,供重连后自动回到原频道
|
// 记录当前频道,供重连后自动回到原频道
|
||||||
val currentChId = Repository.currentChannelId.value
|
val currentChId = Repository.currentChannelId.value
|
||||||
if (currentChId != "0" && currentChId.isNotEmpty()) {
|
if (currentChId != "0" && currentChId.isNotEmpty()) {
|
||||||
lastChannelId = currentChId
|
synchronized(reconnectLock) { lastChannelId = currentChId }
|
||||||
android.util.Log.i(TAG, "Saved lastChannelId=$lastChannelId for pseudo-reconnect")
|
android.util.Log.i(TAG, "Saved lastChannelId=$lastChannelId for pseudo-reconnect")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1107,9 +1140,10 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
android.util.Log.i(TAG, "Network-loss disconnect detected (fallback path)")
|
android.util.Log.i(TAG, "Network-loss disconnect detected (fallback path)")
|
||||||
startReconnect("网络变化,正在重连...")
|
startReconnect("网络变化,正在重连...")
|
||||||
} else {
|
} else {
|
||||||
// 服务器主动断线 — 清理会话,回到首页
|
// 服务器主动断线 — 清除通知、清理会话,回到首页
|
||||||
android.util.Log.w(TAG, "Server-initiated disconnect: $message")
|
android.util.Log.w(TAG, "Server-initiated disconnect: $message")
|
||||||
lastChannelId = ""
|
ConnectionService.stop(getApplication())
|
||||||
|
synchronized(reconnectLock) { lastChannelId = "" }
|
||||||
Repository.clearSession()
|
Repository.clearSession()
|
||||||
channelViewModel?.clearChannels()
|
channelViewModel?.clearChannels()
|
||||||
_connectionState.value = ConnectionState.Disconnected(
|
_connectionState.value = ConnectionState.Disconnected(
|
||||||
@@ -1193,8 +1227,10 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
ConnectionService.stop(getApplication())
|
ConnectionService.stop(getApplication())
|
||||||
|
|
||||||
// 停止重连
|
// 停止重连
|
||||||
|
synchronized(reconnectLock) {
|
||||||
reconnectJob?.let { it.cancel() }
|
reconnectJob?.let { it.cancel() }
|
||||||
reconnectJob = null
|
reconnectJob = null
|
||||||
|
}
|
||||||
|
|
||||||
// 停止语音
|
// 停止语音
|
||||||
voiceViewModel?.stopVoice()
|
voiceViewModel?.stopVoice()
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
private val _isContinuousMicEnabled = MutableStateFlow(false)
|
private val _isContinuousMicEnabled = MutableStateFlow(false)
|
||||||
val isContinuousMicEnabled: StateFlow<Boolean> = _isContinuousMicEnabled
|
val isContinuousMicEnabled: StateFlow<Boolean> = _isContinuousMicEnabled
|
||||||
|
// @Volatile:主线程写入(startTransmit/toggleMute),JNI 桥接线程读取(onDisconnected→stopTransmit)
|
||||||
|
@Volatile
|
||||||
private var transmitMode: TransmitMode? = null
|
private var transmitMode: TransmitMode? = null
|
||||||
|
|
||||||
private val _speakerEnabled = MutableStateFlow(true)
|
private val _speakerEnabled = MutableStateFlow(true)
|
||||||
|
|||||||
@@ -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:"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user