diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 581cac9..94a25f7 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -2,6 +2,5 @@
-
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
deleted file mode 100644
index dd31fcf..0000000
--- a/AGENTS.md
+++ /dev/null
@@ -1,126 +0,0 @@
-# AGENTS.md
-
-This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
-
-## Project Overview
-
-TeamSpeak Android native client with a two-layer architecture:
-
-- **Protocol layer**: Go + [teamspeak-go](https://github.com/honeybbq/teamspeak-go) → compiled to `.aar` via gomobile
-- **UI layer**: Kotlin + Jetpack Compose + Material Design 3
-
-## Build Commands
-
-### Full Build (Recommended)
-```bash
-# Windows
-build.bat
-
-# Linux/macOS
-./build.sh
-```
-
-### Manual Build (Two Steps Required)
-
-**Step 1: Compile Go → AAR**
-```bash
-cd go
-set JAVA_TOOL_OPTIONS=-Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8 # Windows only
-gomobile bind -target=android -androidapi=26 -ldflags="-linkmode=external -extldflags=-Wl,--hash-style=both" -o ../android/app/libs/teamspeak.aar ./teamspeak
-```
-
-**Step 2: Build Android APK**
-```bash
-cd android
-gradlew.bat assembleDebug # Windows
-./gradlew assembleDebug # Linux/macOS
-```
-
-### Development Workflow
-
-- **Modified Go code** (`go/`): Re-run Step 1, then run from IDE
-- **Modified Kotlin code** (`android/`): Just run from IDE (IntelliJ/Android Studio)
-
-Open the `android/` directory (not root) in IntelliJ IDEA or Android Studio.
-
-## Architecture
-
-### Go ↔ Kotlin Bridge
-
-Communication flows through two bridge layers:
-
-1. **Go side** (`go/teamspeak/bridge.go`): Exports `TSClient` class via gomobile
-2. **Kotlin side** (`android/app/src/main/java/com/tsmobile/app/TSBridge.kt`): Wraps gomobile API
-
-**Key constraints** (gomobile limitations):
-- Cannot export `[]string`, `[]*T`, or Go `error` types
-- Complex data passed as JSON strings (channels, clients)
-- Events delivered via callback interfaces
-- All JNI callbacks serialized through an event queue to avoid threading issues
-
-**Event flow**: Go library → event queue → single consumer goroutine → JNI callback → Kotlin callback → ViewModel
-
-### Android Layer Structure
-
-```
-android/app/src/main/java/com/tsmobile/app/
-├── MainActivity.kt # Entry point
-├── TSBridge.kt # Go bridge wrapper
-├── ui/
-│ ├── components/ # Reusable UI components
-│ ├── screens/ # Top-level screens
-│ ├── navigation/NavGraph.kt # App navigation
-│ └── theme/ # Material Design 3 theme
-└── viewmodel/ # State management
- ├── ChannelViewModel.kt # Channel list & navigation
- ├── ChatViewModel.kt # Text messaging
- ├── ServerViewModel.kt # Connection & server info
- └── VoiceViewModel.kt # Voice communication
-```
-
-State flows from ViewModels → Screens via `StateFlow`. User actions flow back via ViewModel methods.
-
-## Critical Build Details
-
-### Required Flags for gomobile
-
-The `-ldflags` are **mandatory** to avoid runtime crashes:
-
-1. **`-linkmode=external`**: Use NDK's external linker instead of Go's built-in linker
- - **Why**: Prevents `SIGSEGV` crashes due to signal handling conflicts between Go runtime and Android
- - Go uses SIGSEGV for GC and goroutine scheduling; Android's memory protection blocks this
-
-2. **`-extldflags=-Wl,--hash-style=both`**: Generate compatible ELF hash tables
- - **Why**: Go 1.24+ uses `DT_SUNW_HASH` by default; Android requires `DT_HASH` or `DT_GNU_HASH`
- - Without this: `dlopen failed: empty/missing DT_HASH/DT_GNU_HASH` error
-
-### Local Patches
-
-The `go/_patches/github.com/honeybbq/teamspeak-go/` directory contains modified upstream code:
-- Fixes 32-bit integer overflow issues (`math.MaxUint32` → platform-specific limits)
-- Applied via `go.mod` replace directive: `replace github.com/honeybbq/teamspeak-go => ./_patches/github.com/honeybbq/teamspeak-go`
-
-Do not remove this directory or the replace directive.
-
-## Common Issues
-
-### "javac: 非法字符" or "GBK unmappable character"
-Set encoding before running gomobile on Windows:
-```bash
-set JAVA_TOOL_OPTIONS=-Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8
-```
-
-### App crashes with "SIGSEGV" on startup
-Missing `-linkmode=external` flag. Rebuild AAR with correct flags.
-
-### App crashes with "empty/missing DT_HASH"
-Missing `-Wl,--hash-style=both` flag. Rebuild AAR with correct flags.
-
-### "Unresolved reference: teamspeak"
-AAR not compiled or not in `android/app/libs/teamspeak.aar`. Run Step 1 of manual build.
-
-## Testing
-
-Run from IDE with connected device or emulator. Check Logcat in IDE for runtime logs.
-
-No automated test suite currently exists.
diff --git a/CLAUDE.md b/CLAUDE.md
index d8362e3..fb98685 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -60,25 +60,44 @@ Communication flows through two bridge layers:
**Event flow**: Go library → event queue → single consumer goroutine → JNI callback → Kotlin callback → ViewModel
-### Android Layer Structure
+### Android MVVM + Repository Pattern
```
-android/app/src/main/java/com/tsmobile/app/
-├── MainActivity.kt # Entry point
-├── TSBridge.kt # Go bridge wrapper
-├── ui/
-│ ├── components/ # Reusable UI components
-│ ├── screens/ # Top-level screens
-│ ├── navigation/NavGraph.kt # App navigation
-│ └── theme/ # Material Design 3 theme
-└── viewmodel/ # State management
- ├── ChannelViewModel.kt # Channel list & navigation
- ├── ChatViewModel.kt # Text messaging
- ├── ServerViewModel.kt # Connection & server info
- └── VoiceViewModel.kt # Voice communication
+Go Library → TSBridge (JNI) → ServerViewModel (callbacks) → Repository (state) → ViewModels → Compose Screens
+User Actions → Compose UI → ViewModel methods → TSBridge → Go Library
```
-State flows from ViewModels → Screens via `StateFlow`. User actions flow back via ViewModel methods.
+**Repository** (`data/Repository.kt`): Singleton object, single source of truth for shared state — channels, clients, channel→clients mapping, current channel, unread counts. Uses `ConcurrentHashMap` for thread-safe message archives (max 500 per session, keyed by `targetMode_targetId`).
+
+**ViewModels**: Cross-references set by `NavGraph` (e.g. `serverViewModel.channelViewModel = channelViewModel`). `ServerViewModel` dispatches all bridge callbacks to other ViewModels.
+
+**State machines** (sealed classes in `data/Models.kt`): `ConnectionState`, `MessageSendState`, `MessageDeliveryState`, `VoiceState`, `SyncState`, `ChannelSwitchState` — use `when` for pattern matching.
+
+### Voice System Ownership Split
+
+- **Go side** owns the full decode/mix pipeline: per-client jitter buffers, Opus decoders, stereo mixing. Delivers mixed PCM16 as 20ms frames (48kHz, interleaved stereo) via `onPCM` callback. Speaking detection: 400ms silence timeout.
+- **Kotlin side** owns capture: `AudioRecord` (mono, 48kHz) → `OpusEncoder` → `TSBridge.sendVoice()`. Noise suppressor support. Speaking detection via RMS threshold (0.015).
+
+### Message Delivery Confirmation
+
+Messages appear immediately as `PENDING` in UI. Server echo (`OnTextMessage` with matching content) confirms → `SENT`. 10-second timeout → `FAILED`. Duplicate detection prevents self-message re-archival.
+
+### Navigation Routes
+
+Four routes in `NavGraph.kt`: `server_config` (connection form) → `channel_list` (channel tree + members) → `chat` (text chat) → `kicked` (kick notification). Navigation driven by `ConnectionState` changes via `LaunchedEffect`.
+
+### Known SDK Limitations
+
+- **No channel CRUD events**: The SDK does not fire events for channel create/update/delete. Channel list is refreshed after 5 minutes of staleness or before channel switch operations.
+- **Unreliable ChannelID in enter events**: `notifycliententerview` ChannelID is not effective per SDK docs. Client enter/leave events trigger a full `clientlist` refresh instead of incremental updates.
+- **Auto-reconnect disabled by default**: Frequent reconnect attempts cause server-side rate limiting/bans.
+
+### Thread Safety
+
+- `TSClient.mu` mutex protects Go-side client access
+- `TSBridge.connectLock` synchronizes Kotlin-side connection lifecycle
+- `connectionGeneration` counter prevents stale callbacks from reaching current session
+- Event queue PCM events capped at 12 with oldest eviction to prevent buildup
## Critical Build Details
@@ -102,6 +121,10 @@ The `go/_patches/github.com/honeybbq/teamspeak-go/` directory contains modified
Do not remove this directory or the replace directive.
+### Go Module and CGo
+
+Go module name is `tsmobile`. Unlike upstream teamspeak-go (zero CGO), this project enables CGO for the Android libopus decoder. Pre-built static libraries live in `go/teamspeak/.opus/lib/{abi}/libopus.a` for all four Android ABIs.
+
## Common Issues
### "javac: 非法字符" or "GBK unmappable character"
@@ -119,8 +142,13 @@ Missing `-Wl,--hash-style=both` flag. Rebuild AAR with correct flags.
### "Unresolved reference: teamspeak"
AAR not compiled or not in `android/app/libs/teamspeak.aar`. Run Step 1 of manual build.
+## Error Conventions
+
+- **Go bridge**: Empty string return = success, non-empty = error message. All IDs are strings for JSON transport.
+- **Kotlin**: Chinese-language user-facing errors. `ServerViewModel.classifyError()` maps raw errors to friendly messages. `ChannelViewModel.mapMoveError()` handles channel switch errors.
+
## Testing
Run from IDE with connected device or emulator. Check Logcat in IDE for runtime logs.
-No automated test suite currently exists.
+Go unit tests exist for the audio receive pipeline (`go/teamspeak/receive_audio_test.go`): `cd go && go test ./teamspeak/`
diff --git a/SOUL.md b/SOUL.md
deleted file mode 100644
index 7e9f0a6..0000000
--- a/SOUL.md
+++ /dev/null
@@ -1,15 +0,0 @@
-# Soul
-
-> This file defines who you are. Update it as your personality evolves.
-
-## Personality
-
-
-## Tone & Communication Style
-
-
-## Core Principles
-
-
-## Boundaries
-
diff --git a/USER.md b/USER.md
deleted file mode 100644
index e56b8da..0000000
--- a/USER.md
+++ /dev/null
@@ -1,15 +0,0 @@
-# User Profile
-
-> This file describes the user you serve. Update it as you learn more.
-
-## Name
-
-
-## Preferences
-
-
-## Timezone
-
-
-## Context
-
diff --git a/android/.gradle/8.11.1/checksums/checksums.lock b/android/.gradle/8.11.1/checksums/checksums.lock
index 3e4b50d..e2fd43d 100644
Binary files a/android/.gradle/8.11.1/checksums/checksums.lock and b/android/.gradle/8.11.1/checksums/checksums.lock differ
diff --git a/android/.gradle/8.11.1/checksums/md5-checksums.bin b/android/.gradle/8.11.1/checksums/md5-checksums.bin
index 752f8c7..b87a78d 100644
Binary files a/android/.gradle/8.11.1/checksums/md5-checksums.bin and b/android/.gradle/8.11.1/checksums/md5-checksums.bin differ
diff --git a/android/.gradle/8.11.1/checksums/sha1-checksums.bin b/android/.gradle/8.11.1/checksums/sha1-checksums.bin
index 23231dd..8c87790 100644
Binary files a/android/.gradle/8.11.1/checksums/sha1-checksums.bin and b/android/.gradle/8.11.1/checksums/sha1-checksums.bin differ
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index edbef75..199c756 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -83,6 +83,7 @@ dependencies {
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-service:2.8.7")
+ implementation("androidx.lifecycle:lifecycle-process:2.8.7")
// DataStore (替代 localStorage)
implementation("androidx.datastore:datastore-preferences:1.1.1")
@@ -93,6 +94,9 @@ dependencies {
// JSON 序列化
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
+ // HTTP client (更新检测)
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+
// Coil 图片加载(Compose)
implementation("io.coil-kt.coil3:coil-compose:3.0.4")
diff --git a/android/app/src/main/java/com/tsmobile/app/App.kt b/android/app/src/main/java/com/tsmobile/app/App.kt
index c008152..f51eef2 100644
--- a/android/app/src/main/java/com/tsmobile/app/App.kt
+++ b/android/app/src/main/java/com/tsmobile/app/App.kt
@@ -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")
diff --git a/android/app/src/main/java/com/tsmobile/app/ConnectionService.kt b/android/app/src/main/java/com/tsmobile/app/ConnectionService.kt
index 0711d77..f84b08c 100644
--- a/android/app/src/main/java/com/tsmobile/app/ConnectionService.kt
+++ b/android/app/src/main/java/com/tsmobile/app/ConnectionService.kt
@@ -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() {
diff --git a/android/app/src/main/java/com/tsmobile/app/TSBridge.kt b/android/app/src/main/java/com/tsmobile/app/TSBridge.kt
index 85a8bcb..73154c7 100644
--- a/android/app/src/main/java/com/tsmobile/app/TSBridge.kt
+++ b/android/app/src/main/java/com/tsmobile/app/TSBridge.kt
@@ -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 = 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
+ }
}
/** 是否已连接 */
diff --git a/android/app/src/main/java/com/tsmobile/app/data/Models.kt b/android/app/src/main/java/com/tsmobile/app/data/Models.kt
index 18dfbc3..cb19a50 100644
--- a/android/app/src/main/java/com/tsmobile/app/data/Models.kt
+++ b/android/app/src/main/java/com/tsmobile/app/data/Models.kt
@@ -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(content)
+ val obj = fileJson.decodeFromString(content)
val msgType = obj["msg_type"]?.toString()?.trim('"') ?: return null
if (!msgType.startsWith("ts.file")) return null
val meta = FileMessageMeta(
diff --git a/android/app/src/main/java/com/tsmobile/app/data/Repository.kt b/android/app/src/main/java/com/tsmobile/app/data/Repository.kt
index 7cae528..931c5f6 100644
--- a/android/app/src/main/java/com/tsmobile/app/data/Repository.kt
+++ b/android/app/src/main/java/com/tsmobile/app/data/Repository.kt
@@ -28,6 +28,9 @@ object Repository {
// 客户端 Map 索引:ClientID → ClientInfo(O(1) 查找)
private var clientMap: Map = emptyMap()
+ // 客户端 UID 索引:UID → ClientInfo(O(1) UID 反查)
+ private var clientUidMap: Map = emptyMap()
+
// --- 当前客户端 ID ---
private val _selfClientId = MutableStateFlow(0)
val selfClientId: StateFlow = _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) {
+ _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>()
- // 未读状态:key = "${targetMode}_${targetId}", value = 未读数
- private val _unreadCounts = MutableStateFlow