增加了发布通道

This commit is contained in:
sansen
2026-07-23 19:37:20 +08:00
parent e3d03d74ea
commit e8a45032f8
35 changed files with 1489 additions and 413 deletions
@@ -16,6 +16,7 @@ class App : Application() {
companion object {
private const val TAG = "App"
private const val CONNECTION_CHANNEL_ID = "connection_channel"
const val POKE_CHANNEL_ID = "poke_channel"
}
override fun onCreate() {
@@ -24,6 +25,9 @@ class App : Application() {
// 创建通知频道(前台服务需要)
createConnectionNotificationChannel()
// 创建 Poke 通知频道(高优先级,弹横幅)
createPokeNotificationChannel()
// 全局未捕获异常处理
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
@@ -54,6 +58,23 @@ class App : Application() {
nm.createNotificationChannel(channel)
}
/**
* 创建 Poke 通知频道。
* IMPORTANCE_HIGH:弹出横幅、发出声音、锁屏可见。
*/
private fun createPokeNotificationChannel() {
val channel = NotificationChannel(
POKE_CHANNEL_ID,
"戳一戳 通知",
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "收到其他用户的 戳一戳 通知"
setShowBadge(true)
}
val nm = getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(channel)
}
private fun writeCrashLog(throwable: Throwable) {
try {
val file = File(getExternalFilesDir(null), "crash.log")
@@ -70,8 +70,10 @@ class ConnectionService : LifecycleService() {
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, serviceType)
android.util.Log.i(TAG, "Foreground service started")
// START_STICKY: 被系统杀死后会尝试重建
return Service.START_STICKY
// The service exists only to keep the process alive while a TS connection is active.
// If Android kills it, there is no reliable way to restore the TS session, so don't
// ask the system to recreate it with a misleading "connected" notification.
return Service.START_NOT_STICKY
}
override fun onDestroy() {
@@ -18,7 +18,32 @@ import java.lang.reflect.Proxy
*/
object TSBridge {
private val connectLock = Any()
private var client: TSClient? = null
private var connectionGeneration: Long = 0
private var currentIdentity: String = ""
private fun beginConnection(identity: String): Pair<TSClient, Long> = synchronized(connectLock) {
client?.disconnect()
client = null
connectionGeneration += 1
currentIdentity = identity
val tsClient = Teamspeak.newClient()
client = tsClient
tsClient to connectionGeneration
}
private fun endConnection(tsClient: TSClient) = synchronized(connectLock) {
if (client === tsClient) {
client = null
}
}
private fun captureGeneration(): Long = synchronized(connectLock) { connectionGeneration }
fun currentGeneration(): Long = synchronized(connectLock) { connectionGeneration }
fun currentIdentity(): String = synchronized(connectLock) { currentIdentity }
/** Poke 事件(Kotlin 侧数据类,对应 Go PokeEvent */
data class PokeEventData(
@@ -56,24 +81,61 @@ object TSBridge {
defaultChannelPassword: String = "",
callbacks: Callbacks,
): String {
val tsClient = Teamspeak.newClient()
client = tsClient
val identity = currentIdentity
val (tsClient, generation) = beginConnection(identity)
val error = doConnect(tsClient, generation, callbacks) { proxy ->
tsClient.connect(host, nickname, password, defaultChannel, defaultChannelPassword, proxy)
}
if (error.isNotEmpty()) endConnection(tsClient)
return error
}
// The checked-in AAR may lag the approved Go contract during migration. A dynamic
// proxy lets this source compile against both artifacts while routing only the new
// mixed-PCM callbacks when the regenerated AAR is installed.
fun connectWithIdentity(
identity: String,
host: String,
nickname: String,
password: String = "",
defaultChannel: String = "",
defaultChannelPassword: String = "",
callbacks: Callbacks,
): String {
val (tsClient, generation) = beginConnection(identity)
val error = doConnect(tsClient, generation, callbacks) { proxy ->
tsClient.connectWithIdentity(
identity,
host,
nickname,
password,
defaultChannel,
defaultChannelPassword,
proxy,
)
}
if (error.isNotEmpty()) endConnection(tsClient)
return error
}
private fun doConnect(
tsClient: TSClient,
generation: Long,
callbacks: Callbacks,
connect: (EventCallback) -> String,
): String {
val callbackProxy = Proxy.newProxyInstance(
EventCallback::class.java.classLoader,
arrayOf(EventCallback::class.java),
) { _, method, args ->
if (captureGeneration() != generation) {
return@newProxyInstance null
}
val values = args ?: emptyArray()
when (method.name) {
"onConnected" -> callbacks.onConnected()
"onDisconnected" -> callbacks.onDisconnected(values[0] as String)
"onTextMessage" -> (values[0] as? TextMsg)?.let(callbacks::onTextMessage)
"onClientEnter" -> (values[0] as? Client)?.let(callbacks::onClientEnter)
"onClientLeave" -> callbacks.onClientLeave(values[0] as Long, values[1] as String)
"onClientMoved" -> callbacks.onClientMoved(values[0] as Long, values[1] as String)
"onClientLeave" -> callbacks.onClientLeave((values[0] as Number).toLong(), values[1] as String)
"onClientMoved" -> callbacks.onClientMoved((values[0] as Number).toLong(), values[1] as String)
"onKicked" -> callbacks.onKicked(values[0] as String)
"onMixedVoicePCM" -> (values[0] as? ByteArray)?.let {
android.util.Log.d("TSVoice", "Kotlin mixed PCM bytes=${it.size}")
@@ -81,7 +143,7 @@ object TSBridge {
}
"onClientSpeaking" -> {
android.util.Log.i("TSVoice", "Kotlin speaking client=${values[0]} speaking=${values[1]}")
callbacks.onClientSpeaking(values[0] as Long, values[1] as Boolean)
callbacks.onClientSpeaking((values[0] as Number).toLong(), values[1] as Boolean)
}
"onPoked" -> (values[0] as? teamspeak.PokeEvent)?.let { event ->
callbacks.onPoked(PokeEventData(
@@ -91,7 +153,6 @@ object TSBridge {
message = event.message,
))
}
// Raw Opus callbacks deliberately have no Kotlin receive fallback.
"onVoiceData" -> Unit
"toString" -> "TSBridge.EventCallback"
"hashCode" -> System.identityHashCode(callbacks)
@@ -101,13 +162,21 @@ object TSBridge {
null
} as EventCallback
return tsClient.connect(host, nickname, password, defaultChannel, defaultChannelPassword, callbackProxy)
return try {
connect(callbackProxy)
} catch (t: Throwable) {
android.util.Log.w("TSBridge", "connect threw", t)
"连接失败"
}
}
/** 断开连接 */
fun disconnect() {
client?.disconnect()
client = null
synchronized(connectLock) {
client?.disconnect()
client = null
connectionGeneration += 1
}
}
/** 是否已连接 */
@@ -1,6 +1,10 @@
package com.tsmobile.app.data
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
/** 共享 Json 实例,避免 parseFileMessageMeta 每次调用都创建新实例 */
private val fileJson = Json { ignoreUnknownKeys = true }
/**
* 服务器连接配置。
@@ -99,6 +103,11 @@ data class FileMessageMeta(
}
}
/**
* 消息类型。
*/
enum class MessageType { NORMAL, SYSTEM }
/**
* 聊天消息实体。
* 权威来源:OnTextMessage(对应 notifytextmessage)。
@@ -114,6 +123,7 @@ data class ChatMessage(
val isSelf: Boolean, // 是否是自己发送的
val deliveryState: MessageDeliveryState = MessageDeliveryState.SENT, // 送达状态
val fileMeta: FileMessageMeta? = null, // 文件消息元数据(仅文件消息)
val messageType: MessageType = MessageType.NORMAL, // 消息类型
)
/**
@@ -261,8 +271,7 @@ enum class VoiceOutputDevice {
fun parseFileMessageMeta(content: String): FileMessageMeta? {
if (!content.trimStart().startsWith("{")) return null
return try {
val json = kotlinx.serialization.json.Json { ignoreUnknownKeys = true }
val obj = json.decodeFromString<kotlinx.serialization.json.JsonObject>(content)
val obj = fileJson.decodeFromString<kotlinx.serialization.json.JsonObject>(content)
val msgType = obj["msg_type"]?.toString()?.trim('"') ?: return null
if (!msgType.startsWith("ts.file")) return null
val meta = FileMessageMeta(
@@ -28,6 +28,9 @@ object Repository {
// 客户端 Map 索引:ClientID → ClientInfoO(1) 查找)
private var clientMap: Map<Int, ClientInfo> = emptyMap()
// 客户端 UID 索引:UID → ClientInfoO(1) UID 反查)
private var clientUidMap: Map<String, ClientInfo> = emptyMap()
// --- 当前客户端 ID ---
private val _selfClientId = MutableStateFlow(0)
val selfClientId: StateFlow<Int> = _selfClientId.asStateFlow()
@@ -138,6 +141,7 @@ object Repository {
}
_clients.value = clients
clientMap = clients.associateBy { it.id }
clientUidMap = clients.associateBy { it.uid }
_channelClients.value = clients.groupBy { it.channelId }
android.util.Log.d("Repository", "refreshClientList: ${clients.size} clients updated")
}
@@ -149,6 +153,14 @@ object Repository {
_currentChannelId.value = channelId
}
/**
* 仅更新频道列表(不覆盖 clients 相关状态)。
* 用于 refreshChannelsIfNeeded 等部分刷新场景。
*/
fun updateChannels(channels: List<ChannelInfo>) {
_channels.value = channels
}
/**
* 更新基线数据。
*/
@@ -161,6 +173,7 @@ object Repository {
_channels.value = channels
_clients.value = clients
clientMap = clients.associateBy { it.id }
clientUidMap = clients.associateBy { it.uid }
_selfClientId.value = selfClientId
_currentChannelId.value = currentChannelId
@@ -171,6 +184,12 @@ object Repository {
/** 查询成员是否存在 */
fun hasClient(clientId: Int): Boolean = clientMap.containsKey(clientId)
/** 查询成员信息 */
fun getClient(clientId: Int): ClientInfo? = clientMap[clientId]
/** 通过 UID 查询成员信息(O(1)) */
fun getClientByUid(uid: String): ClientInfo? = clientUidMap[uid]
/** 查询频道是否存在 */
fun hasChannel(channelId: String): Boolean = _channels.value.any { it.id == channelId }
@@ -178,13 +197,10 @@ object Repository {
// 按 (TargetMode, Target) 分组存储,key = "${targetMode}_${targetId}"
private val messageArchives = ConcurrentHashMap<String, MutableList<ChatMessage>>()
// 未读状态:key = "${targetMode}_${targetId}", value = 未读数
private val _unreadCounts = MutableStateFlow<Map<String, Int>>(emptyMap())
val unreadCounts: StateFlow<Map<String, Int>> = _unreadCounts.asStateFlow()
// 当前查看的会话(用于判断是否需要未读提示)
// 当前查看的会话(用于判断是否需要推送系统通知)
@Volatile
private var activeChatKey: String? = null
var activeChatKey: String? = null
private set
/**
* 归档消息(由 OnTextMessage 调用)。
@@ -200,40 +216,32 @@ object Repository {
archive.removeAt(0)
}
}
// 如果不是当前查看的会话,增加未读计数
if (key != activeChatKey) {
val currentCounts = _unreadCounts.value.toMutableMap()
currentCounts[key] = (currentCounts[key] ?: 0) + 1
_unreadCounts.value = currentCounts
}
}
/**
* 标记自己发送的消息为已送达(匹配 OnTextMessage 回显)。
* @return true 表示找到并更新了对应的 PENDING 消息
* 优先用 invokerID + content 双重匹配(AAR 已支持 InvokerID),
* 降级到仅 content 匹配(senderId=0 时)。
* @return 已确认消息的 ID,未匹配返回 null
*/
fun confirmMessageDelivery(targetMode: Int, targetId: Long, senderId: Int, content: String): Boolean {
fun confirmMessageDelivery(targetMode: Int, targetId: Long, senderId: Int, content: String): String? {
val key = "${targetMode}_${targetId}"
val archive = messageArchives[key] ?: return false
val archive = messageArchives[key] ?: return null
synchronized(archive) {
// 从后往前找最近的 PENDING 消息(同内容)
// 不匹配 senderIdgomobile 的 TextMsg 不含 InvokerID
// 通过 UID 反查 clientId 可能因客户端列表未同步而得到 0,
// 导致与 sendMessage 时记录的 selfClientId 不一致。
for (i in archive.indices.reversed()) {
val msg = archive[i]
if (msg.isSelf &&
msg.deliveryState == MessageDeliveryState.PENDING &&
msg.content == content
msg.content == content &&
(senderId == 0 || msg.senderId == senderId)
) {
archive[i] = msg.copy(deliveryState = MessageDeliveryState.SENT)
return true
return msg.id
}
}
}
return false
return null
}
/**
@@ -297,16 +305,6 @@ object Repository {
}
}
/**
* 清除指定会话的未读标记。
*/
fun clearUnread(targetMode: Int, targetId: Long) {
val key = "${targetMode}_${targetId}"
val currentCounts = _unreadCounts.value.toMutableMap()
currentCounts.remove(key)
_unreadCounts.value = currentCounts
}
/**
* 清理会话数据。
*/
@@ -314,12 +312,12 @@ object Repository {
_channels.value = emptyList()
_clients.value = emptyList()
clientMap = emptyMap()
clientUidMap = emptyMap()
_selfClientId.value = 0
_currentChannelId.value = "0"
_serverInfo.value = null
_channelClients.value = emptyMap()
messageArchives.clear()
_unreadCounts.value = emptyMap()
activeChatKey = null
}
@@ -0,0 +1,171 @@
package com.tsmobile.app.data
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
/**
* Gitea 发布信息。
*/
data class ReleaseInfo(
val versionName: String,
val releaseUrl: String,
val tagName: String,
val body: String = "",
)
/**
* GitHub/Gitea API 发布响应(部分字段)。
*/
@Serializable
private data class ReleaseResponse(
val tag_name: String = "",
val name: String = "",
val html_url: String = "",
val body: String = "",
)
/**
* 更新检测器。
*
* 通过 Gitea REST API 获取最新发布的版本信息,
* 用 semver 方式(x.y.z)与本地 versionName 比较判断是否有新版本。
*/
object UpdateChecker {
private const val TAG = "UpdateChecker"
/** Gitea 实例地址 */
private const val GITEA_HOST = "rep.sansenhoshi.top"
/** 仓库路径 */
private const val REPO_PATH = "sansenhoshi/ts-mobile-go"
/** Gitea API: 获取最新发布 */
private val API_URL =
"https://$GITEA_HOST/api/v1/repos/$REPO_PATH/releases/latest"
private val json = Json { ignoreUnknownKeys = true }
private val client: OkHttpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.addInterceptor(UserAgentInterceptor())
.build()
}
/**
* 注入 User-Agent 头(Gitea API 建议携带)。
*/
private class UserAgentInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): okhttp3.Response {
val request: Request = chain.request().newBuilder()
.header("User-Agent", "TSMobile-UpdateChecker")
.build()
return chain.proceed(request)
}
}
/**
* 获取最新发布信息。
*
* @return [ReleaseInfo] 若成功获取并解析;null 表示请求失败或格式异常。
*/
suspend fun fetchLatestRelease(): ReleaseInfo? = withContext(Dispatchers.IO) {
try {
val request = Request.Builder()
.url(API_URL)
.get()
.build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
Log.w(TAG, "API returned ${response.code}: ${response.message}")
response.close()
return@withContext null
}
val body = response.body?.string() ?: run {
response.close()
return@withContext null
}
response.close()
val release = json.decodeFromString<ReleaseResponse>(body)
// 从 tag_name 解析版本号,失败则从 name 解析
val versionName = parseVersionName(release.tag_name)
?: parseVersionName(release.name)
if (versionName == null) {
Log.w(TAG, "Cannot parse version from tag_name='${release.tag_name}' or name='${release.name}'")
return@withContext null
}
Log.d(TAG, "Parsed release: version=$versionName, tag=${release.tag_name}, name=${release.name}")
ReleaseInfo(
versionName = versionName,
releaseUrl = release.html_url.ifBlank {
"https://$GITEA_HOST/$REPO_PATH/releases"
},
tagName = release.tag_name,
body = release.body,
)
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch latest release", e)
null
}
}
/**
* 比较两个 semver 版本号。
*
* @return 正数表示 a > b,负数表示 a < b0 表示相等。
*
* 示例:
* - compareVersion("1.0.2", "1.0.0") → 1
* - compareVersion("2.0.0", "1.9.9") → 1
* - compareVersion("1.0.0", "1.0.0") → 0
* - compareVersion("1.0", "1.0.0") → -1
*/
fun compareVersion(a: String, b: String): Int {
val partsA = a.split(".").map { it.toIntOrNull() ?: 0 }
val partsB = b.split(".").map { it.toIntOrNull() ?: 0 }
val maxLen = maxOf(partsA.size, partsB.size)
for (i in 0 until maxLen) {
val numA = partsA.getOrElse(i) { 0 }
val numB = partsB.getOrElse(i) { 0 }
if (numA != numB) return numA - numB
}
return 0
}
/**
* 从 tag_name 解析版本号(semver)。
*
* 支持格式:
* - "v1.0.2" / "V1.0.2" → "1.0.2"
* - "v2.0" → "2.0"
* - "1.0.2" → "1.0.2"
* - 无法解析则返回 null
*/
private fun parseVersionName(tag: String): String? {
if (tag.isBlank()) return null
// 去掉前缀 v/V
val version = tag.trimStart('v', 'V')
// 提取数字和点号组成的版本段(如 "1.0.2"
val match = Regex("""^\d+(\.\d+)*""").find(version) ?: return null
return match.value
}
}
@@ -0,0 +1,57 @@
package com.tsmobile.app.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.updateDataStore by preferencesDataStore(name = "update_settings")
/**
* 更新检测偏好持久化。
*
* 记录已忽略的版本号和上次检查时间戳,
* 避免重复提示同一版本和频繁请求API。
*/
class UpdatePreferences(private val context: Context) {
companion object {
private val DISMISSED_VERSION_KEY = stringPreferencesKey("dismissed_version")
private val LAST_CHECK_TIMESTAMP_KEY = longPreferencesKey("last_check_timestamp")
}
/**
* 已忽略的版本号(默认空字符串表示未忽略任何版本)。
*/
val dismissedVersion: Flow<String> = context.updateDataStore.data.map { prefs ->
prefs[DISMISSED_VERSION_KEY] ?: ""
}
/**
* 上次检查时间戳(默认0表示从未检查过)。
*/
val lastCheckTimestamp: Flow<Long> = context.updateDataStore.data.map { prefs ->
prefs[LAST_CHECK_TIMESTAMP_KEY] ?: 0L
}
/**
* 保存已忽略的版本号。
*/
suspend fun setDismissedVersion(version: String) {
context.updateDataStore.edit { prefs ->
prefs[DISMISSED_VERSION_KEY] = version
}
}
/**
* 保存上次检查时间戳。
*/
suspend fun setLastCheckTimestamp(ts: Long) {
context.updateDataStore.edit { prefs ->
prefs[LAST_CHECK_TIMESTAMP_KEY] = ts
}
}
}
@@ -29,6 +29,7 @@ import com.tsmobile.app.data.ChatMessage
import com.tsmobile.app.data.FileDownloadManager
import com.tsmobile.app.data.FileMessageMeta
import com.tsmobile.app.data.MessageDeliveryState
import com.tsmobile.app.data.MessageType
import com.tsmobile.app.data.Repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -44,6 +45,33 @@ import java.util.Locale
*/
@Composable
fun MessageItem(message: ChatMessage) {
// 系统消息:居中显示,无气泡
if (message.messageType == MessageType.SYSTEM) {
val timeText = remember(message.timestamp) {
SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(message.timestamp))
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp, horizontal = 16.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = message.content,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(6.dp))
Text(
text = timeText,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
return
}
val timeText = remember(message.timestamp) {
SimpleDateFormat("HH:mm", Locale.getDefault())
.format(Date(message.timestamp))
@@ -36,6 +36,7 @@ fun PokeNotification(
Card(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(
horizontal = UiTokens.Spacing.Large,
vertical = UiTokens.Spacing.Small,
@@ -0,0 +1,80 @@
package com.tsmobile.app.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NewReleases
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.tsmobile.app.ui.theme.UiTokens
import com.tsmobile.app.ui.theme.semanticColors
/**
* 更新横幅 — 显示在服务器配置页顶部。
*
* 当检测到新版本时显示,提供"下载更新"和"忽略此版本"两个操作。
*/
@Composable
fun UpdateBanner(
versionName: String,
releaseUrl: String,
onDownload: (String) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val semanticColors = MaterialTheme.semanticColors
Surface(
modifier = modifier.fillMaxWidth(),
color = semanticColors.infoContainer,
contentColor = semanticColors.onInfoContainer,
tonalElevation = UiTokens.Elevation.Subtle,
shape = MaterialTheme.shapes.medium,
) {
Column(
modifier = Modifier.padding(
horizontal = UiTokens.Spacing.Large,
vertical = UiTokens.Spacing.Medium,
),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.NewReleases,
contentDescription = null,
tint = semanticColors.info,
modifier = Modifier.size(UiTokens.Size.IconMedium),
)
Spacer(Modifier.width(UiTokens.Spacing.Small))
Text(
text = "发现新版本",
style = MaterialTheme.typography.labelLarge,
)
}
Spacer(Modifier.height(UiTokens.Spacing.ExtraSmall))
Text(
text = "新版本 $versionName 已发布,点击下载更新",
style = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.height(UiTokens.Spacing.Small))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(
onClick = onDismiss,
colors = ButtonDefaults.textButtonColors(
contentColor = semanticColors.onInfoContainer.copy(alpha = 0.7f),
),
) {
Text("忽略此版本")
}
Spacer(Modifier.width(UiTokens.Spacing.Small))
TextButton(onClick = { onDownload(releaseUrl) }) {
Text("下载更新")
}
}
}
}
}
@@ -0,0 +1,232 @@
package com.tsmobile.app.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.NewReleases
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tsmobile.app.ui.theme.UiTokens
import com.tsmobile.app.viewmodel.UpdateInfo
/**
* 手动检查更新的结果状态。
*/
sealed class UpdateCheckResult {
/** 正在检查中 */
data object Checking : UpdateCheckResult()
/** 发现新版本 */
data class UpdateAvailable(val info: UpdateInfo) : UpdateCheckResult()
/** 已经是最新版 */
data object NoUpdate : UpdateCheckResult()
/** 检查失败 */
data class Error(val message: String) : UpdateCheckResult()
}
/**
* 更新检查弹窗。
*
* 点击"检查更新"后弹出,显示检查过程和结果。
* - 检查中:旋转进度 + "正在检查更新..."
* - 有新版本:版本号 + 下载/忽略按钮
* - 无更新:绿色对勾 + "已经是最新版"
* - 失败:错误图标 + 错误信息 + 重试/关闭按钮
*/
@Composable
fun UpdateCheckDialog(
result: UpdateCheckResult,
onDownload: (String) -> Unit,
onDismissVersion: () -> Unit,
onRetry: () -> Unit,
onClose: () -> Unit,
) {
AlertDialog(
onDismissRequest = { if (result !is UpdateCheckResult.Checking) onClose() },
shape = MaterialTheme.shapes.extraLarge,
tonalElevation = UiTokens.Elevation.Floating,
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
when (result) {
is UpdateCheckResult.Checking -> {
CircularProgressIndicator(
modifier = Modifier.size(UiTokens.Size.IconMedium),
strokeWidth = UiTokens.Border.Emphasized,
)
Spacer(Modifier.width(UiTokens.Spacing.Medium))
Text("检查更新")
}
is UpdateCheckResult.UpdateAvailable -> {
Icon(
imageVector = Icons.Default.NewReleases,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(UiTokens.Size.IconMedium),
)
Spacer(Modifier.width(UiTokens.Spacing.Medium))
Text("发现新版本")
}
is UpdateCheckResult.NoUpdate -> {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(UiTokens.Size.IconMedium),
)
Spacer(Modifier.width(UiTokens.Spacing.Medium))
Text("检查完成")
}
is UpdateCheckResult.Error -> {
Icon(
imageVector = Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(UiTokens.Size.IconMedium),
)
Spacer(Modifier.width(UiTokens.Spacing.Medium))
Text("检查失败")
}
}
}
},
text = {
Column {
AnimatedVisibility(
visible = result is UpdateCheckResult.Checking,
enter = fadeIn(),
exit = fadeOut(),
) {
Text(
text = "正在连接服务器检查更新...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
AnimatedVisibility(
visible = result is UpdateCheckResult.UpdateAvailable,
enter = fadeIn(),
exit = fadeOut(),
) {
val info = (result as? UpdateCheckResult.UpdateAvailable)?.info
if (info != null) {
Column {
Text(
text = "新版本 ${info.versionName} 已发布",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(UiTokens.Spacing.ExtraSmall))
Text(
text = "建议下载最新版本以获得更好的体验。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// 更新内容(支持滚动)
if (info.body.isNotBlank()) {
Spacer(Modifier.height(UiTokens.Spacing.Medium))
Surface(
color = MaterialTheme.colorScheme.surfaceContainerLow,
shape = MaterialTheme.shapes.small,
) {
Column(
modifier = Modifier
.heightIn(max = 200.dp)
.verticalScroll(rememberScrollState())
.padding(UiTokens.Spacing.Medium),
) {
Text(
text = "更新内容",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(UiTokens.Spacing.ExtraSmall))
Text(
text = info.body,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
AnimatedVisibility(
visible = result is UpdateCheckResult.NoUpdate,
enter = fadeIn(),
exit = fadeOut(),
) {
Text(
text = "已经是最新版,无需更新。",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
AnimatedVisibility(
visible = result is UpdateCheckResult.Error,
enter = fadeIn(),
exit = fadeOut(),
) {
val msg = (result as? UpdateCheckResult.Error)?.message ?: ""
Text(
text = msg.ifBlank { "网络连接失败,请稍后重试" },
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
}
},
confirmButton = {
when (result) {
is UpdateCheckResult.Checking -> {
// 检查中不显示按钮
}
is UpdateCheckResult.UpdateAvailable -> {
TextButton(
onClick = { onDismissVersion() },
) {
Text("忽略此版本")
}
Spacer(Modifier.width(UiTokens.Spacing.Small))
Button(
onClick = { onDownload(result.info.releaseUrl) },
) {
Text("下载更新")
}
}
is UpdateCheckResult.NoUpdate -> {
Button(onClick = onClose) {
Text("确定")
}
}
is UpdateCheckResult.Error -> {
TextButton(onClick = onClose) {
Text("关闭")
}
Spacer(Modifier.width(UiTokens.Spacing.Small))
Button(onClick = onRetry) {
Text("重试")
}
}
}
},
dismissButton = {
if (result is UpdateCheckResult.UpdateAvailable ||
result is UpdateCheckResult.Error
) {
// 关闭按钮在 confirmButton 区域已处理
}
},
)
}
@@ -118,7 +118,7 @@ fun VoiceCard(
.heightIn(min = 280.dp, max = 560.dp),
verticalArrangement = Arrangement.spacedBy(UiTokens.Spacing.ExtraSmall),
) {
items(members, key = { it.id }) { member ->
items(members.distinctBy { it.id }, key = { it.id }) { member ->
MemberRow(
name = member.nickname,
isSelf = member.isSelf,
@@ -3,16 +3,20 @@ package com.tsmobile.app.ui.navigation
import androidx.activity.compose.BackHandler
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.tsmobile.app.data.ConnectionState
import com.tsmobile.app.data.Repository
import com.tsmobile.app.ui.components.PokeNotification
import com.tsmobile.app.ui.screens.ChannelListScreen
import com.tsmobile.app.ui.screens.ChatScreen
import com.tsmobile.app.ui.screens.KickedScreen
@@ -50,6 +54,7 @@ fun AppNavGraph(
serverViewModel.channelViewModel = channelViewModel
serverViewModel.chatViewModel = chatViewModel
serverViewModel.voiceViewModel = voiceViewModel
channelViewModel.chatViewModel = chatViewModel
}
// 观察连接状态变化,处理导航
@@ -80,100 +85,113 @@ fun AppNavGraph(
}
}
NavHost(
navController = navController,
startDestination = Routes.SERVER_CONFIG,
// 禁用默认的淡入淡出动画,避免页面切换显得迟钝
enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None },
popEnterTransition = { EnterTransition.None },
popExitTransition = { ExitTransition.None },
) {
// 服务器配置页
composable(Routes.SERVER_CONFIG) {
ServerConfigScreen(
viewModel = serverViewModel,
onNavigateToChannelList = {
navController.navigate(Routes.CHANNEL_LIST) {
popUpTo(Routes.SERVER_CONFIG) { inclusive = true }
}
},
)
}
// 全局 Poke 气泡通知(覆盖在所有页面上方)
val showPokeNotification by serverViewModel.showPokeNotification.collectAsState()
val pokeNotification by serverViewModel.pokeNotification.collectAsState()
// 频道列表页
composable(Routes.CHANNEL_LIST) {
ChannelListScreen(
channelViewModel = channelViewModel,
voiceViewModel = voiceViewModel,
serverViewModel = serverViewModel,
onNavigateToChat = {
if (navController.currentDestination?.route == Routes.CHANNEL_LIST) {
navController.navigate(Routes.CHAT) {
launchSingleTop = true
Box(modifier = Modifier.fillMaxSize()) {
NavHost(
navController = navController,
startDestination = Routes.SERVER_CONFIG,
// 禁用默认的淡入淡出动画,避免页面切换显得迟钝
enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None },
popEnterTransition = { EnterTransition.None },
popExitTransition = { ExitTransition.None },
) {
// 服务器配置页
composable(Routes.SERVER_CONFIG) {
ServerConfigScreen(
viewModel = serverViewModel,
onNavigateToChannelList = {
navController.navigate(Routes.CHANNEL_LIST) {
popUpTo(Routes.SERVER_CONFIG) { inclusive = true }
}
}
},
onNavigateToServerConfig = {
serverViewModel.disconnect()
navController.navigate(Routes.SERVER_CONFIG) {
popUpTo(Routes.CHANNEL_LIST) { inclusive = true }
}
},
)
}
// 聊天页
composable(Routes.CHAT) {
// 进入聊天页:加载当前频道的消息
val currentChannelId by channelViewModel.currentChannelId.collectAsState()
LaunchedEffect(currentChannelId) {
var channelIdLong = currentChannelId.toLongOrNull() ?: 0L
// 备用方案:如果 Repository.currentChannelId 为 "0"GetChannelID 失败),
// 从客户端列表中获取自身所在的频道 ID
if (channelIdLong <= 0L) {
channelIdLong = Repository.getSelfChannelId().toLongOrNull() ?: 0L
}
if (channelIdLong > 0L) {
chatViewModel.enterChat(2, channelIdLong)
}
},
)
}
val leaveChat = {
if (navController.currentDestination?.route == Routes.CHAT &&
navController.popBackStack(Routes.CHANNEL_LIST, inclusive = false)
) {
chatViewModel.leaveChat()
}
// 频道列表页
composable(Routes.CHANNEL_LIST) {
ChannelListScreen(
channelViewModel = channelViewModel,
voiceViewModel = voiceViewModel,
serverViewModel = serverViewModel,
onNavigateToChat = {
if (navController.currentDestination?.route == Routes.CHANNEL_LIST) {
navController.navigate(Routes.CHAT) {
launchSingleTop = true
}
}
},
onNavigateToServerConfig = {
serverViewModel.disconnect()
navController.navigate(Routes.SERVER_CONFIG) {
popUpTo(Routes.CHANNEL_LIST) { inclusive = true }
}
},
)
}
BackHandler(onBack = leaveChat)
ChatScreen(
chatViewModel = chatViewModel,
channelViewModel = channelViewModel,
voiceViewModel = voiceViewModel,
serverViewModel = serverViewModel,
onNavigateBack = leaveChat,
onOpenChannelDetail = { channelId ->
channelViewModel.openChannelDetailCard(channelId)
},
)
}
// 被踢页面
composable(Routes.KICKED) {
val kickReason by serverViewModel.kickReason.collectAsState()
KickedScreen(
reason = kickReason,
onReconnect = { serverViewModel.reconnectAfterKick() },
onBackToHome = {
serverViewModel.backToHome()
navController.navigate(Routes.SERVER_CONFIG) {
popUpTo(Routes.KICKED) { inclusive = true }
// 聊天页
composable(Routes.CHAT) {
// 进入聊天页:加载当前频道的消息
val currentChannelId by channelViewModel.currentChannelId.collectAsState()
LaunchedEffect(currentChannelId) {
var channelIdLong = currentChannelId.toLongOrNull() ?: 0L
// 备用方案:如果 Repository.currentChannelId 为 "0"GetChannelID 失败),
// 从客户端列表中获取自身所在的频道 ID
if (channelIdLong <= 0L) {
channelIdLong = Repository.getSelfChannelId().toLongOrNull() ?: 0L
}
},
)
if (channelIdLong > 0L) {
chatViewModel.enterChat(2, channelIdLong)
}
}
val leaveChat = {
if (navController.currentDestination?.route == Routes.CHAT &&
navController.popBackStack(Routes.CHANNEL_LIST, inclusive = false)
) {
chatViewModel.leaveChat()
}
}
BackHandler(onBack = leaveChat)
ChatScreen(
chatViewModel = chatViewModel,
channelViewModel = channelViewModel,
voiceViewModel = voiceViewModel,
serverViewModel = serverViewModel,
onNavigateBack = leaveChat,
onOpenChannelDetail = { channelId ->
channelViewModel.openChannelDetailCard(channelId)
},
)
}
// 被踢页面
composable(Routes.KICKED) {
val kickReason by serverViewModel.kickReason.collectAsState()
KickedScreen(
reason = kickReason,
onReconnect = { serverViewModel.reconnectAfterKick() },
onBackToHome = {
serverViewModel.backToHome()
navController.navigate(Routes.SERVER_CONFIG) {
popUpTo(Routes.KICKED) { inclusive = true }
}
},
)
}
}
// Poke 气泡通知(全局覆盖层)
PokeNotification(
pokeEvent = pokeNotification,
isVisible = showPokeNotification,
onDismiss = { serverViewModel.dismissPokeNotification() },
)
}
}
@@ -83,10 +83,6 @@ fun ChannelListScreen(
val channelDetail by channelViewModel.channelDetailInfo.collectAsState()
val currentChannelId by channelViewModel.currentChannelId.collectAsState()
// Poke 通知
val showPokeNotification by serverViewModel.showPokeNotification.collectAsState()
val pokeNotification by serverViewModel.pokeNotification.collectAsState()
// ViewModel 重建保护:如果 syncState 不是 SynchronizedViewModel 被系统回收后重建),
// 重新触发首次同步,否则频道列表页会永远停在加载态。
LaunchedEffect(Unit) {
@@ -189,14 +185,6 @@ fun ChannelListScreen(
)
}
// ─── 全局浮动通知 ───
// Poke 通知(顶部气泡)
PokeNotification(
pokeEvent = pokeNotification,
isVisible = showPokeNotification,
onDismiss = { serverViewModel.dismissPokeNotification() },
)
}
// ─── 弹窗层 ───
@@ -5,6 +5,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.imePadding
import androidx.compose.ui.platform.LocalFocusManager
import com.tsmobile.app.data.ChannelInfo
import com.tsmobile.app.ui.components.ChannelDetailCard
import com.tsmobile.app.ui.components.ChatHeader
@@ -48,6 +49,7 @@ fun ChatScreen(
val currentChannel = channels.find { it.id == currentChannelId }
val clients = channelClients[currentChannelId] ?: emptyList()
val focusManager = LocalFocusManager.current
Column(
modifier = Modifier
@@ -83,19 +85,29 @@ fun ChatScreen(
// 底部:语音控制栏(固定在底部,不受键盘影响)
VoiceControlBar(
voiceViewModel = voiceViewModel,
onExpand = { showVoiceCard = true },
onExpand = {
focusManager.clearFocus()
voiceViewModel.openVoiceCard()
showVoiceCard = true
},
)
}
// 语音卡弹窗(BottomSheet
if (showVoiceCard) {
ModalBottomSheet(
onDismissRequest = { showVoiceCard = false },
onDismissRequest = {
showVoiceCard = false
voiceViewModel.closeVoiceCard()
},
) {
VoiceCard(
voiceViewModel = voiceViewModel,
channelViewModel = channelViewModel,
onDismiss = { showVoiceCard = false },
onDismiss = {
showVoiceCard = false
voiceViewModel.closeVoiceCard()
},
onPokeClient = { clientId, message ->
serverViewModel.pokeClient(clientId, message)
},
@@ -10,7 +10,9 @@ import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.SettingsBrightness
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.SystemUpdate
import com.tsmobile.app.ui.theme.ThemeMode
import com.tsmobile.app.ui.theme.UiTokens
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -23,6 +25,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.tsmobile.app.data.RecentConnection
import com.tsmobile.app.ui.components.ConnectButton
import com.tsmobile.app.ui.components.UpdateCheckDialog
import com.tsmobile.app.viewmodel.ConnectState
import com.tsmobile.app.viewmodel.ServerViewModel
import com.tsmobile.app.viewmodel.formatRelativeTime
@@ -48,7 +51,9 @@ fun ServerConfigScreen(
// 上:品牌区
BrandSection(
themeMode = themeMode,
onToggleTheme = { viewModel.toggleTheme() }
onToggleTheme = { viewModel.toggleTheme() },
isCheckingUpdate = state.isCheckingUpdate,
onCheckUpdate = { viewModel.manualCheckUpdate() },
)
// 中:输入区
@@ -80,13 +85,30 @@ fun ServerConfigScreen(
onNavigateToChannelList()
}
}
// 手动检查更新弹窗
val updateCheckResult by viewModel.updateCheckResult.collectAsState()
updateCheckResult?.let { result ->
UpdateCheckDialog(
result = result,
onDownload = { url -> viewModel.openReleaseUrl(url) },
onDismissVersion = { viewModel.dismissUpdate() },
onRetry = { viewModel.manualCheckUpdate() },
onClose = { viewModel.closeUpdateDialog() },
)
}
}
/**
* 品牌区组件。
*/
@Composable
private fun BrandSection(themeMode: ThemeMode, onToggleTheme: () -> Unit) {
private fun BrandSection(
themeMode: ThemeMode,
onToggleTheme: () -> Unit,
isCheckingUpdate: Boolean = false,
onCheckUpdate: () -> Unit = {},
) {
Box(
modifier = Modifier
.fillMaxWidth()
@@ -115,6 +137,29 @@ private fun BrandSection(themeMode: ThemeMode, onToggleTheme: () -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// 检查更新按钮(左上角)
IconButton(
onClick = onCheckUpdate,
enabled = !isCheckingUpdate,
modifier = Modifier
.align(Alignment.TopStart)
.padding(start = 8.dp),
) {
if (isCheckingUpdate) {
CircularProgressIndicator(
modifier = Modifier.size(UiTokens.Size.IconMedium),
strokeWidth = UiTokens.Border.Emphasized,
)
} else {
Icon(
imageVector = Icons.Default.SystemUpdate,
contentDescription = "检查更新",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// 主题切换按钮(右上角)
IconButton(
onClick = onToggleTheme,
@@ -65,6 +65,9 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
private val json = Json { ignoreUnknownKeys = true }
// ChatViewModel 引用(由 ServerViewModel 设置)
var chatViewModel: ChatViewModel? = null
// 频道列表最后刷新时间
private var lastChannelRefreshTime: Long = 0
@@ -82,10 +85,6 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
private val _expandedChannelIds = MutableStateFlow<Set<String>>(emptySet())
val expandedChannelIds: StateFlow<Set<String>> = _expandedChannelIds.asStateFlow()
// 未读消息标记(当前频道外是否有新消息)
private val _hasUnreadMessage = MutableStateFlow(false)
val hasUnreadMessage: StateFlow<Boolean> = _hasUnreadMessage.asStateFlow()
// --- 当前频道 ---
val currentChannelId: StateFlow<String> = Repository.currentChannelId
@@ -127,7 +126,6 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
fun clearChannels() {
_syncState.value = SyncState.Unsynced
_expandedChannelIds.value = emptySet()
_hasUnreadMessage.value = false
_switchState.value = ChannelSwitchState.Idle
_showSwitchDialog.value = false
_pendingSwitchChannel.value = null
@@ -275,12 +273,7 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
Log.d(TAG, "Channel list stale, refreshing...")
val channelsJson = TSBridge.getChannelsJSON()
val channels = json.decodeFromString<List<ChannelInfo>>(channelsJson)
Repository.updateBaseline(
channels,
Repository.clients.value,
Repository.selfClientId.value,
Repository.currentChannelId.value,
)
Repository.updateChannels(channels)
lastChannelRefreshTime = System.currentTimeMillis()
Log.d(TAG, "Channel list refreshed: ${channels.size} channels")
} catch (e: Exception) {
@@ -291,49 +284,80 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
/**
* 处理客户端进入事件(增量同步)。
* 刷新后对比当前频道成员,检测新加入者并发送系统消息。
*/
fun handleClientEnter() {
Log.d(TAG, "Client enter: refreshing client list")
viewModelScope.launch {
val snapshot = snapshotCurrentChannelClients()
Repository.refreshClientList()
notifyMemberChanges(snapshot, "加入")
}
}
/**
* 处理客户端离开事件。
* 全量刷新客户端列表(人员变动统一用全量更新)
* 刷新后对比当前频道成员,检测离开者并发送系统消息
*/
fun handleClientLeave(clientId: Int, reasonMsg: String) {
Log.d(TAG, "Client leave: $clientId, reason: $reasonMsg")
viewModelScope.launch {
val snapshot = snapshotCurrentChannelClients()
Repository.refreshClientList()
notifyMemberChanges(snapshot, "离开")
}
}
/**
* 处理文字消息(未读指示)
* 只需标记"当前频道外有新消息",不追踪具体频道
* 快照当前频道的成员 ID 和昵称
* 离开事件后 clientMap 会更新,所以需要提前保存昵称
*/
fun onTextMessage(targetMode: Int, targetId: String) {
if (targetMode != 2) return
if (targetId != Repository.currentChannelId.value) {
_hasUnreadMessage.value = true
}
private fun snapshotCurrentChannelClients(): Map<Int, String> {
val channelId = Repository.currentChannelId.value
return Repository.channelClients.value[channelId]
?.associate { it.id to it.nickname }
?: emptyMap()
}
/**
* 清除未读标记
* 对比快照与刷新后的当前频道成员,检测变动并发送系统消息
* @param before 变动前的 {clientId → nickname} 快照
* @param action "加入" 或 "离开"
*/
fun clearUnread() {
_hasUnreadMessage.value = false
private fun notifyMemberChanges(before: Map<Int, String>, action: String) {
val channelId = Repository.currentChannelId.value
val after = Repository.channelClients.value[channelId]
?.map { it.id }
?.toSet()
?: emptySet()
val changedIds = if (action == "加入") {
after - before.keys
} else {
before.keys - after
}
if (changedIds.isEmpty()) return
val cvm = chatViewModel ?: return
for (id in changedIds) {
val nickname = if (action == "加入") {
Repository.getClient(id)?.nickname ?: id.toString()
} else {
before[id] ?: id.toString()
}
cvm.addSystemMessage("$nickname $action 当前频道")
}
}
/**
* 切换频道展开/折叠状态。
*/
fun toggleExpand(channelId: String) {
_expandedChannelIds.value = _expandedChannelIds.value.toMutableSet().apply {
if (contains(channelId)) remove(channelId) else add(channelId)
_expandedChannelIds.update { current ->
current.toMutableSet().apply {
if (contains(channelId)) remove(channelId) else add(channelId)
}
}
}
@@ -356,7 +380,7 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
}
}
_expandedChannelIds.value = expandedWithParents
_expandedChannelIds.update { expandedWithParents }
}
/**
@@ -367,6 +391,9 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
clients: List<ClientInfo>,
expandedIds: Set<String>,
): List<ChannelTreeNode> {
// 预计算:按频道 ID 分组客户端(O(n) 一次,替代递归中 O(n) 每次)
val clientsByChannel = clients.groupBy { it.channelId }
// 找出自引用的根频道(id == parentId),这些频道需要特殊处理
val selfRefRoots = channels.filter { it.id == it.parentId }
// 排除自引用频道后的正常频道列表
@@ -386,7 +413,7 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
return ChannelTreeNode(
channel = channel,
children = emptyList(),
clients = clients.filter { it.channelId == channel.id },
clients = clientsByChannel[channel.id] ?: emptyList(),
isExpanded = expandedIds.contains(channel.id),
)
}
@@ -394,12 +421,11 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
val children = (childrenMap[channel.id] ?: emptyList())
.sortedBy { it.order }
.map { buildNode(it) }
val channelClients = clients.filter { it.channelId == channel.id }
return ChannelTreeNode(
channel = channel,
children = children,
clients = channelClients,
clients = clientsByChannel[channel.id] ?: emptyList(),
isExpanded = expandedIds.contains(channel.id),
)
}
@@ -413,7 +439,7 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
ChannelTreeNode(
channel = root,
children = emptyList(),
clients = clients.filter { it.channelId == root.id },
clients = clientsByChannel[root.id] ?: emptyList(),
isExpanded = expandedIds.contains(root.id),
)
}
@@ -502,6 +528,12 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
if (_switchState.value is ChannelSwitchState.WaitingServerEvent) {
Log.w(TAG, "Channel switch timeout waiting for server event")
_switchState.value = ChannelSwitchState.Failed("等待服务端确认超时")
// 8 秒后自动清除失败状态
delay(8_000)
if (_switchState.value is ChannelSwitchState.Failed) {
_switchState.value = ChannelSwitchState.Idle
_pendingSwitchChannel.value = null
}
}
}
@@ -550,9 +582,8 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
Repository.refreshClientList()
}
// 更新当前频道 ID 并清除未读
// 更新当前频道 ID
Repository.setCurrentChannelId(targetChannelId)
clearUnread()
when (currentState) {
is ChannelSwitchState.WaitingServerEvent -> {
@@ -582,10 +613,21 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
/**
* 处理其他用户的移动事件。
* 全量刷新客户端列表(人员变动统一用全量更新)。
* 根据移动方向检测"加入"或"离开"当前频道。
*/
private fun handleOtherClientMoved(clientId: Int, targetChannelId: String) {
viewModelScope.launch {
val currentChannelId = Repository.currentChannelId.value
val snapshot = snapshotCurrentChannelClients()
Repository.refreshClientList()
if (targetChannelId == currentChannelId) {
// 移入当前频道 → 检测"加入"
notifyMemberChanges(snapshot, "加入")
} else if (snapshot.containsKey(clientId)) {
// 从当前频道移出 → 检测"离开"
notifyMemberChanges(snapshot, "离开")
}
}
}
@@ -8,6 +8,7 @@ import com.tsmobile.app.TSBridge
import com.tsmobile.app.data.ChatMessage
import com.tsmobile.app.data.MessageDeliveryState
import com.tsmobile.app.data.MessageSendState
import com.tsmobile.app.data.MessageType
import com.tsmobile.app.data.Repository
import com.tsmobile.app.data.parseFileMessageMeta
import kotlinx.coroutines.Job
@@ -15,7 +16,9 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
class ChatViewModel(application: Application) : AndroidViewModel(application) {
@@ -37,12 +40,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private var currentTargetMode: Int = 2
private var currentTargetId: Long = 0
// 送达确认超时 Job
private var deliveryTimeoutJob: Job? = null
// 送达确认超时 Job(每条消息独立管理,避免快速发送时相互覆盖)
private val deliveryTimeoutJobs = ConcurrentHashMap<String, Job>()
/**
* 进入聊天页。
* 加载消息并清除未读标记
* 加载消息并设置活跃会话标识
*/
fun enterChat(targetMode: Int, targetId: Long) {
currentTargetMode = targetMode
@@ -50,7 +53,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
Repository.setActiveChat(targetMode, targetId)
_messages.value = Repository.getMessages(targetMode, targetId)
Repository.clearUnread(targetMode, targetId)
Log.d(TAG, "Entered chat: mode=$targetMode, target=$targetId, messages=${_messages.value.size}")
}
@@ -61,7 +63,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
fun leaveChat() {
Repository.setActiveChat(null, null)
_sendState.value = MessageSendState.Idle()
deliveryTimeoutJob?.cancel()
deliveryTimeoutJobs.values.forEach { it.cancel() }
deliveryTimeoutJobs.clear()
Log.d(TAG, "Left chat")
}
@@ -71,11 +74,32 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
fun clearMessages() {
_messages.value = emptyList()
_sendState.value = MessageSendState.Idle()
deliveryTimeoutJob?.cancel()
deliveryTimeoutJobs.values.forEach { it.cancel() }
deliveryTimeoutJobs.clear()
currentTargetMode = 2
currentTargetId = 0
}
/**
* 添加系统消息(频道成员变动等提示)。
* 归档到 Repository 并刷新当前列表。
*/
fun addSystemMessage(content: String) {
val message = ChatMessage(
id = "sys_${System.currentTimeMillis()}",
targetMode = 2,
targetId = Repository.currentChannelId.value.toLongOrNull() ?: 0L,
senderId = 0,
senderName = "",
content = content,
timestamp = System.currentTimeMillis(),
isSelf = false,
messageType = MessageType.SYSTEM,
)
Repository.archiveMessage(message)
refreshMessages()
}
/**
* 发送文本消息。
*
@@ -97,7 +121,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
_sendState.value = MessageSendState.Sending
val selfId = Repository.selfClientId.value
val selfName = Repository.clients.value.find { it.id == selfId }?.nickname ?: ""
val selfName = Repository.getClient(selfId)?.nickname ?: ""
// 生成唯一 ID(用于匹配回显)
val messageId = "local_${System.currentTimeMillis()}_${selfId}"
@@ -155,13 +179,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/**
* 启动送达确认超时。
* 如果在 DELIVERY_TIMEOUT_MS 内没有收到回显,标记为 FAILED。
* 每条消息独立管理超时 Job,快速发送多条消息时互不影响。
*/
private fun startDeliveryTimeout(messageId: String) {
deliveryTimeoutJob?.cancel()
deliveryTimeoutJob = viewModelScope.launch {
val job = viewModelScope.launch {
delay(DELIVERY_TIMEOUT_MS)
// 超时:检查消息是否仍然是 PENDING
deliveryTimeoutJobs.remove(messageId)
val messages = _messages.value
val pending = messages.find {
it.id == messageId && it.deliveryState == MessageDeliveryState.PENDING
@@ -171,21 +196,25 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
markMessageFailed(messageId)
}
}
deliveryTimeoutJobs[messageId] = job
}
/**
* 标记指定消息为 FAILED。
*/
private fun markMessageFailed(messageId: String) {
// 更新本地 _messages 快照
val updated = _messages.value.map { msg ->
if (msg.id == messageId && msg.deliveryState == MessageDeliveryState.PENDING) {
msg.copy(deliveryState = MessageDeliveryState.FAILED)
} else {
msg
// 清理超时 Job
deliveryTimeoutJobs.remove(messageId)?.cancel()
// 原子更新本地 _messages 快照(避免与 refreshMessages 并发覆盖)
_messages.update { current ->
current.map { msg ->
if (msg.id == messageId && msg.deliveryState == MessageDeliveryState.PENDING) {
msg.copy(deliveryState = MessageDeliveryState.FAILED)
} else {
msg
}
}
}
_messages.value = updated
// 同步更新 Repository 归档(确保导航离开再回来时状态不丢失)
Repository.markMessageDeliveryFailed(currentTargetMode, currentTargetId, messageId)
@@ -214,13 +243,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
Log.d(TAG, "handleTextMessage JSON content: $content")
}
// 无论 isSelf,始终尝试匹配 PENDING 消息(送达确认)。
// 原因:gomobile 的 TextMsg 不含 InvokerIDsenderId 通过 UID 反查 clientId
// 如果客户端列表未同步(selfUid=null),senderId 会是 0 ≠ selfId,导致 isSelf=false。
val confirmed = Repository.confirmMessageDelivery(targetMode, targetId, senderId, content)
if (confirmed) {
Log.d(TAG, "Delivery confirmed: content=$content")
deliveryTimeoutJob?.cancel()
// 尝试匹配 PENDING 消息(送达确认)。
// AAR 已支持 InvokerIDsenderId 可靠。仍保留降级逻辑:senderId=0 时仅匹配 content。
val confirmedId = Repository.confirmMessageDelivery(targetMode, targetId, senderId, content)
if (confirmedId != null) {
Log.d(TAG, "Delivery confirmed: id=$confirmedId, content=$content")
deliveryTimeoutJobs.remove(confirmedId)?.cancel()
if (targetMode == currentTargetMode && targetId == currentTargetId) {
refreshMessages()
}
@@ -9,10 +9,20 @@ import com.tsmobile.app.data.*
import com.tsmobile.app.ui.theme.ThemeMode
import teamspeak.TextMsg
import teamspeak.Client
import teamspeak.Teamspeak
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.net.Uri
import androidx.core.app.NotificationCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import com.tsmobile.app.App
import com.tsmobile.app.MainActivity
import kotlinx.coroutines.Job
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
@@ -46,6 +56,16 @@ data class ServerScreenState(
val connectState: ConnectState = ConnectState.IDLE,
val errorMessage: String? = null,
val validationErrors: ValidationErrors = ValidationErrors(),
val isCheckingUpdate: Boolean = false,
)
/**
* 更新检测信息。
*/
data class UpdateInfo(
val versionName: String,
val releaseUrl: String,
val body: String = "",
)
class ServerViewModel(application: Application) : AndroidViewModel(application) {
@@ -57,6 +77,7 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
}
private val recentStore = RecentConnectionsStore(application)
private val identityStore = IdentityStore(application)
// ChannelViewModel 引用(由 NavGraph 设置)
var channelViewModel: ChannelViewModel? = null
@@ -88,6 +109,12 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
private val _serverInfo = MutableStateFlow<ServerInfo?>(null)
val serverInfo: StateFlow<ServerInfo?> = _serverInfo.asStateFlow()
// ── 更新检测 ──
private val updatePreferences = UpdatePreferences(application)
private val _updateCheckResult = MutableStateFlow<com.tsmobile.app.ui.components.UpdateCheckResult?>(null)
val updateCheckResult: StateFlow<com.tsmobile.app.ui.components.UpdateCheckResult?> = _updateCheckResult.asStateFlow()
// ── 主题切换(架构 4.4) ──
private val themePreferences = ThemePreferences(application)
@@ -127,6 +154,177 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
val defaultChannelPassword: String = "",
)
init {
// 启动时自动检查更新
checkForUpdate()
}
// --- 更新检测 ---
/**
* 检查 GitHub/Gitea 是否有新版本。
* 用 semver 方式比较版本号,每次启动最多检查一次(1小时去重)。
*/
fun checkForUpdate() {
if (_state.value.isCheckingUpdate) {
android.util.Log.d(TAG, "Update check: already in progress, skipping")
return
}
android.util.Log.d(TAG, "Update check: starting...")
_state.update { it.copy(isCheckingUpdate = true) }
viewModelScope.launch {
try {
// 去重:1小时内不重复检查
val lastCheckTime = updatePreferences.lastCheckTimestamp.first()
val oneHourMs = 60 * 60 * 1000L
val elapsed = System.currentTimeMillis() - lastCheckTime
if (elapsed < oneHourMs) {
val waitMinutes = (oneHourMs - elapsed) / 60_000
android.util.Log.d(TAG, "Update check: debounced (last check was ${elapsed / 60_000}min ago, can retry in ${waitMinutes}min)")
_state.update { it.copy(isCheckingUpdate = false) }
return@launch
}
// 获取本地版本
val app = getApplication<android.app.Application>()
@Suppress("DEPRECATION")
val pi = app.packageManager.getPackageInfo(app.packageName, 0)
val localVersion = pi.versionName ?: "0"
android.util.Log.d(TAG, "Update check: local version=$localVersion")
// 获取远程版本
val release = UpdateChecker.fetchLatestRelease()
updatePreferences.setLastCheckTimestamp(System.currentTimeMillis())
if (release == null) {
android.util.Log.d(TAG, "Update check: no release info from server (network error or no release yet)")
} else if (UpdateChecker.compareVersion(release.versionName, localVersion) > 0) {
android.util.Log.d(TAG, "Update check: new version available! remote=${release.versionName} > local=$localVersion")
// 检查是否已被用户忽略(仅首次启动自动弹窗时检查)
val dismissed = updatePreferences.dismissedVersion.first()
if (release.versionName != dismissed) {
android.util.Log.d(TAG, "Update check: showing update dialog for version ${release.versionName}")
_updateCheckResult.value =
com.tsmobile.app.ui.components.UpdateCheckResult.UpdateAvailable(
UpdateInfo(
versionName = release.versionName,
releaseUrl = release.releaseUrl,
body = release.body,
)
)
} else {
android.util.Log.d(TAG, "Update check: version ${release.versionName} was previously dismissed, skipping")
}
} else {
android.util.Log.d(TAG, "Update check: no newer version (remote=${release.versionName} <= local=$localVersion)")
}
_state.update { it.copy(isCheckingUpdate = false) }
} catch (e: Exception) {
android.util.Log.w(TAG, "Update check: failed", e)
_state.update { it.copy(isCheckingUpdate = false) }
}
}
}
/**
* 手动检查更新(弹出对话框展示结果)。
* 不走去重逻辑,每次点击都会实际请求。
*/
fun manualCheckUpdate() {
if (_updateCheckResult.value is com.tsmobile.app.ui.components.UpdateCheckResult.Checking) return
_updateCheckResult.value = com.tsmobile.app.ui.components.UpdateCheckResult.Checking
_state.update { it.copy(isCheckingUpdate = true) }
viewModelScope.launch {
try {
// 获取本地版本
val app = getApplication<android.app.Application>()
@Suppress("DEPRECATION")
val pi = app.packageManager.getPackageInfo(app.packageName, 0)
val localVersion = pi.versionName ?: "0"
android.util.Log.d(TAG, "Manual update check: local version=$localVersion")
// 获取远程版本
val release = UpdateChecker.fetchLatestRelease()
updatePreferences.setLastCheckTimestamp(System.currentTimeMillis())
if (release == null) {
android.util.Log.d(TAG, "Manual update check: server returned no release info")
_updateCheckResult.value = com.tsmobile.app.ui.components.UpdateCheckResult.Error("")
} else if (UpdateChecker.compareVersion(release.versionName, localVersion) > 0) {
// 检查是否已被忽略
val dismissed = updatePreferences.dismissedVersion.first()
if (release.versionName != dismissed) {
android.util.Log.d(TAG, "Manual update check: new version ${release.versionName} available!")
_updateCheckResult.value = com.tsmobile.app.ui.components.UpdateCheckResult.UpdateAvailable(
UpdateInfo(
versionName = release.versionName,
releaseUrl = release.releaseUrl,
body = release.body,
)
)
} else {
// 已被忽略但用户主动检查,也显示有新版本(覆盖忽略状态)
android.util.Log.d(TAG, "Manual update check: version ${release.versionName} available (previously dismissed, showing anyway)")
_updateCheckResult.value = com.tsmobile.app.ui.components.UpdateCheckResult.UpdateAvailable(
UpdateInfo(
versionName = release.versionName,
releaseUrl = release.releaseUrl,
body = release.body,
)
)
}
} else {
android.util.Log.d(TAG, "Manual update check: already latest (remote=${release.versionName} <= local=$localVersion)")
_updateCheckResult.value = com.tsmobile.app.ui.components.UpdateCheckResult.NoUpdate
}
} catch (e: Exception) {
android.util.Log.w(TAG, "Manual update check: failed", e)
_updateCheckResult.value = com.tsmobile.app.ui.components.UpdateCheckResult.Error(
e.localizedMessage ?: "网络连接失败"
)
}
}
}
/**
* 关闭更新检查弹窗。
*/
fun closeUpdateDialog() {
_updateCheckResult.value = null
_state.update { it.copy(isCheckingUpdate = false) }
}
/**
* 在浏览器中打开发布页面。
*/
fun openReleaseUrl(url: String) {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
getApplication<android.app.Application>().startActivity(intent)
} catch (e: Exception) {
android.util.Log.w(TAG, "Failed to open release URL", e)
}
}
/**
* 忽略当前版本的更新提示(持久化,同版本不再提示)。
*/
fun dismissUpdate() {
val dialogResult = _updateCheckResult.value
if (dialogResult is com.tsmobile.app.ui.components.UpdateCheckResult.UpdateAvailable) {
viewModelScope.launch {
updatePreferences.setDismissedVersion(dialogResult.info.versionName)
}
_updateCheckResult.value = null
_state.update { it.copy(isCheckingUpdate = false) }
}
}
// --- 输入更新 ---
fun updateAddress(value: String) {
@@ -200,11 +398,27 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
// 异步连接(阻塞调用放到 IO 线程,不阻塞 UI)
viewModelScope.launch {
val identity = withContext(Dispatchers.IO) {
identityStore.getOrCreate { Teamspeak.generateIdentity(8) }
}
if (identity.isBlank()) {
_state.update {
it.copy(
connectState = ConnectState.FAILED,
errorMessage = "身份生成失败",
)
}
return@launch
}
val result = withContext(Dispatchers.IO) {
TSBridge.connect(
TSBridge.connectWithIdentity(
identity = identity,
host = config.address,
nickname = config.nickname,
password = config.password,
defaultChannel = config.defaultChannel,
defaultChannelPassword = config.defaultChannelPassword,
callbacks = createBridgeCallbacks(),
)
}
@@ -337,7 +551,13 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
// 震动反馈
triggerVibration()
// 自动隐藏通知(5秒后)
// app 在后台时发送系统通知
val isInForeground = ProcessLifecycleOwner.get().lifecycle.currentState == Lifecycle.State.RESUMED
if (!isInForeground) {
sendPokeSystemNotification(event)
}
// 自动隐藏 app 内气泡(5秒后)
viewModelScope.launch {
delay(5000)
dismissPokeNotification()
@@ -349,6 +569,91 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
_pokeNotification.value = null
}
private var pokeNotificationId = 1000
/**
* 发送 Poke 系统通知(app 在后台时调用)。
*/
private fun sendPokeSystemNotification(event: com.tsmobile.app.data.PokeEvent) {
try {
val context = getApplication<Application>()
val pendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val text = if (event.message.isNotEmpty()) {
"${event.invokerName} 戳了你一下:${event.message}"
} else {
"${event.invokerName} 戳了你一下"
}
val notification = NotificationCompat.Builder(context, App.POKE_CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("TeamSpeak Poke")
.setContentText(text)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build()
val nm = context.getSystemService(NotificationManager::class.java)
nm.notify(pokeNotificationId++, notification)
} catch (e: Exception) {
android.util.Log.e(TAG, "Failed to send poke notification", e)
}
}
/**
* 发送消息系统通知。
* 仅在 app 前台且正在查看对应聊天页时跳过,其余情况均推送。
*/
private fun sendMessageNotification(targetMode: Int, targetId: Long, senderName: String, content: String) {
val activeKey = Repository.activeChatKey
val msgKey = "${targetMode}_${targetId}"
val isInForeground = ProcessLifecycleOwner.get().lifecycle.currentState == Lifecycle.State.RESUMED
// app 在前台且正在查看该会话 → 跳过(消息已直接显示在 UI 中)
if (isInForeground && activeKey == msgKey) return
try {
val context = getApplication<Application>()
val pendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val title = if (targetMode == 2) "频道消息" else "私聊消息"
val text = "$senderName: $content"
val notification = NotificationCompat.Builder(context, App.POKE_CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(text)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build()
// 使用 targetMode_targetId 作为通知 ID,不同会话各自覆盖
val notificationId = 2000 + (msgKey.hashCode() and 0xFFFF)
val nm = context.getSystemService(NotificationManager::class.java)
nm.notify(notificationId, notification)
} catch (e: Exception) {
android.util.Log.e(TAG, "Failed to send message notification", e)
}
}
private fun triggerVibration() {
try {
val vibrator = getApplication<Application>().getSystemService(android.os.Vibrator::class.java)
@@ -466,7 +771,10 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
val params = lastConnectParams ?: break
val error = withContext(Dispatchers.IO) {
TSBridge.connect(
val identity = identityStore.getOrCreate { Teamspeak.generateIdentity(8) }
if (identity.isBlank()) return@withContext "身份生成失败"
TSBridge.connectWithIdentity(
identity = identity,
host = params.host,
nickname = params.nickname,
password = params.password,
@@ -508,7 +816,10 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
viewModelScope.launch {
val error = withContext(Dispatchers.IO) {
TSBridge.connect(
val identity = identityStore.getOrCreate { Teamspeak.generateIdentity(8) }
if (identity.isBlank()) return@withContext "身份生成失败"
TSBridge.connectWithIdentity(
identity = identity,
host = params.host,
nickname = params.nickname,
password = params.password,
@@ -666,13 +977,17 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
targetIdLong = resolved
}
}
// TextMsg 不含 invokerID,通过 UID 查找客户端 ID
// 直接使用 Go 侧传来的 invokerID(AAR 已包含此字段)
val selfId = Repository.selfClientId.value
val selfUid = Repository.clients.value.find { it.id == selfId }?.uid
val senderClient = Repository.clients.value.find { it.uid == msg.invokerUID }
// 优先匹配客户端列表;如果 UID 是自己的,直接使用 selfId
val senderId = senderClient?.id ?: if (msg.invokerUID == selfUid) selfId else 0
android.util.Log.d(TAG, "onTextMessage: resolved senderId=$senderId (selfId=$selfId, selfUid=$selfUid, senderClient=${senderClient?.id})")
val invokerId = msg.invokerID.toInt()
val senderId: Int = if (invokerId > 0) invokerId else {
// 降级:invokerID 不可用时通过 UID 反查
val selfUid = Repository.getClient(selfId)?.uid
val senderClient = Repository.getClientByUid(msg.invokerUID)
senderClient?.id ?: if (msg.invokerUID == selfUid) selfId else 0
}
val isSelf = senderId == selfId
android.util.Log.d(TAG, "onTextMessage: senderId=$senderId (invokerID=${msg.invokerID}, isSelf=$isSelf)")
val cvm = chatViewModel
if (cvm == null) {
android.util.Log.w(TAG, "onTextMessage: chatViewModel is null, message dropped!")
@@ -685,10 +1000,12 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
content = msg.message
)
}
// 更新未读标记
channelViewModel?.onTextMessage(
// 当用户不在对应聊天页时,推送系统通知
sendMessageNotification(
targetMode = msg.targetMode.toInt(),
targetId = targetIdLong.toString(),
targetId = targetIdLong,
senderName = msg.invokerName,
content = msg.message,
)
}
@@ -72,6 +72,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
private val _speakingClients = MutableStateFlow<Map<Int, Long>>(emptyMap())
val speakingClients: StateFlow<Map<Int, Long>> = _speakingClients
/** 保护 _speakingClients 和 _remoteAudioSettings 的复合读写操作 */
private val audioStateLock = Any()
init {
// 连接 VoiceService 回调到 TSBridge
voiceService.onVoiceData = { opusData, codec ->
@@ -225,35 +228,46 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
/** Go owns speaking detection; Kotlin only reflects explicit bridge transitions. */
fun handleClientSpeaking(clientID: Long, speaking: Boolean) {
val clientId = clientID.toInt()
val current = _speakingClients.value.toMutableMap()
if (speaking) current[clientId] = System.currentTimeMillis() else current.remove(clientId)
_speakingClients.value = current
synchronized(audioStateLock) {
val current = _speakingClients.value.toMutableMap()
if (speaking) current[clientId] = System.currentTimeMillis() else current.remove(clientId)
_speakingClients.value = current
}
}
/** Removes Go's timeline and local transient mute/speaking state for this client. */
fun removeRemoteClient(clientID: Long, moved: Boolean = false) {
val clientId = clientID.toInt()
TSBridge.removeRemoteAudioClient(clientId)
_remoteAudioSettings.value = _remoteAudioSettings.value - clientId
_speakingClients.value = _speakingClients.value - clientId
synchronized(audioStateLock) {
_remoteAudioSettings.value = _remoteAudioSettings.value - clientId
_speakingClients.value = _speakingClients.value - clientId
}
}
fun setRemoteClientMuted(clientId: Int, muted: Boolean) {
_remoteAudioSettings.value = _remoteAudioSettings.value + (clientId to RemoteAudioSettings(muted))
synchronized(audioStateLock) {
_remoteAudioSettings.value = _remoteAudioSettings.value + (clientId to RemoteAudioSettings(muted))
}
TSBridge.setRemoteClientMuted(clientId, muted)
}
fun toggleRemoteClientMuted(clientId: Int) {
val muted = !(_remoteAudioSettings.value[clientId]?.muted ?: false)
setRemoteClientMuted(clientId, muted)
synchronized(audioStateLock) {
val muted = !(_remoteAudioSettings.value[clientId]?.muted ?: false)
_remoteAudioSettings.value = _remoteAudioSettings.value + (clientId to RemoteAudioSettings(muted))
TSBridge.setRemoteClientMuted(clientId, muted)
}
}
fun onClientMoved(clientId: Long, targetChannelId: String) {
val id = clientId.toInt()
if (id == Repository.selfClientId.value) {
TSBridge.clearRemoteAudioClients()
_remoteAudioSettings.value = emptyMap()
_speakingClients.value = emptyMap()
synchronized(audioStateLock) {
_remoteAudioSettings.value = emptyMap()
_speakingClients.value = emptyMap()
}
} else if (targetChannelId != Repository.currentChannelId.value) {
removeRemoteClient(clientId, moved = true)
}