From e8a45032f866cb59986f1587b3d72cd04a3b14ca Mon Sep 17 00:00:00 2001 From: sansen Date: Thu, 23 Jul 2026 19:37:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BA=86=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E9=80=9A=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/vcs.xml | 1 - AGENTS.md | 126 ------- CLAUDE.md | 60 ++- SOUL.md | 15 - USER.md | 15 - .../.gradle/8.11.1/checksums/checksums.lock | Bin 17 -> 17 bytes .../8.11.1/checksums/md5-checksums.bin | Bin 41297 -> 41897 bytes .../8.11.1/checksums/sha1-checksums.bin | Bin 135071 -> 135989 bytes android/app/build.gradle.kts | 4 + .../app/src/main/java/com/tsmobile/app/App.kt | 21 ++ .../com/tsmobile/app/ConnectionService.kt | 6 +- .../main/java/com/tsmobile/app/TSBridge.kt | 93 ++++- .../main/java/com/tsmobile/app/data/Models.kt | 13 +- .../java/com/tsmobile/app/data/Repository.kt | 66 ++-- .../com/tsmobile/app/data/UpdateChecker.kt | 171 +++++++++ .../tsmobile/app/data/UpdatePreferences.kt | 57 +++ .../tsmobile/app/ui/components/MessageItem.kt | 28 ++ .../app/ui/components/PokeNotification.kt | 1 + .../app/ui/components/UpdateBanner.kt | 80 ++++ .../app/ui/components/UpdateCheckDialog.kt | 232 ++++++++++++ .../tsmobile/app/ui/components/VoiceCard.kt | 2 +- .../tsmobile/app/ui/navigation/NavGraph.kt | 190 +++++----- .../app/ui/screens/ChannelListScreen.kt | 12 - .../com/tsmobile/app/ui/screens/ChatScreen.kt | 18 +- .../app/ui/screens/ServerConfigScreen.kt | 49 ++- .../app/viewmodel/ChannelViewModel.kt | 104 ++++-- .../tsmobile/app/viewmodel/ChatViewModel.kt | 74 ++-- .../tsmobile/app/viewmodel/ServerViewModel.kt | 343 +++++++++++++++++- .../tsmobile/app/viewmodel/VoiceViewModel.kt | 34 +- .../honeybbq/teamspeak-go/notifications.go | 10 +- .../github.com/honeybbq/teamspeak-go/types.go | 1 + go/teamspeak/bridge.go | 57 ++- go/teamspeak/kotlin_api.go | 17 +- links.md | 2 - ...动签名文件到项目目录.md => release构建文档.md | 0 35 files changed, 1489 insertions(+), 413 deletions(-) delete mode 100644 AGENTS.md delete mode 100644 SOUL.md delete mode 100644 USER.md create mode 100644 android/app/src/main/java/com/tsmobile/app/data/UpdateChecker.kt create mode 100644 android/app/src/main/java/com/tsmobile/app/data/UpdatePreferences.kt create mode 100644 android/app/src/main/java/com/tsmobile/app/ui/components/UpdateBanner.kt create mode 100644 android/app/src/main/java/com/tsmobile/app/ui/components/UpdateCheckDialog.kt delete mode 100644 links.md rename 第一步:移动签名文件到项目目录.md => release构建文档.md (100%) 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 3e4b50d2c7f63587ceea01b6ae5a55ab09e03e24..e2fd43d7ffe1cc7bf3643bc611d35a41b28b6ae1 100644 GIT binary patch literal 17 VcmZRMI`#5^MiJ{n1~6dz3II471xx?{ literal 17 VcmZRMI`#5^MiJ{n1~6c|001}j1t$Oi diff --git a/android/.gradle/8.11.1/checksums/md5-checksums.bin b/android/.gradle/8.11.1/checksums/md5-checksums.bin index 752f8c7a6b013269054317e21911617aba720c88..b87a78d46309a2f9a8a98e45b001ca88db5923d1 100644 GIT binary patch delta 769 zcmcb3h-u|0~_T_>TU<&*DfTk;ppJMpL_f(>fs`pvvLnVkF{OOA3L3oL?~*}OT| z)RC7znRAzr&JrW2ZsEyGouZhuKWgF7T=x9s< zN&@NcIZ_ zh-9+`SU*Fv0gy(sl22p<`-NkyvyxOG{X1KCe~N6gVG)69Sq*Ztihb$@rTN_R1Ap*D UpYB@zZVyDV-4|#K5I~Fr0B(Wr1poj5 delta 85 zcmV-b0IL6~#{$v90{-vN`oEHSeNEno?=el|D_lg>j?0xHq5AwUJQK}Zl;d4eBV diff --git a/android/.gradle/8.11.1/checksums/sha1-checksums.bin b/android/.gradle/8.11.1/checksums/sha1-checksums.bin index 23231dd9b5c459d3dd59da22dcd528e6fdd08370..8c877903d3faa5a71748a3185a9d8a5548a9e12a 100644 GIT binary patch delta 1201 zcmbO~pJVGXjtwRfj3%2+B{J9rCfqDxm6LHh#sC6LLMJy*l|RYMKlNwQGiOsJh@hb4 z=1}ECZvJHtxACZ*QHBT#aBp6#r_ILrakHXn@p?w{%~PKoU=tAf*xq}*=iYRv#@5Zg zuRpT#Pk$gL^iU)ND(Ju2^y5Zm{x$O>v^cxAK}{>)eD})&76Ai~B}X}r1r|X?9XFT$ z)MV$EEU4h>%Fu%fuAMrSaTk-U<@B0%M%C@_7#Tm-G1_f^(7TAN z7ea*j=Wj2V&iI;*@!>{A1EJ|}HZa;Tx@_0p$heS$U$Vn7q;J_ih(f`SlT*zfPG7Q% z(StwusOrkujj~XQ-IH@IGPnEeW?aC^-+XYdNP^~ksM%L1zqJXRZgP+@f#3er-p4HA zQVEc?F|uUGtsN*cccX=QqFhXaXC+z_x0#Vxq7z5$dKZYB35ce@IPz z{otwU!-dUTXU)3w=ym06pi968{)HIGH5II|o(P3N4MxU5LKlctL0BX?q|B#*<4XA@ zanmIevKN^C;tdT08Q<64@Dr#22-qcn1ez}H&P~;>X*Xh>MQ&UbT@xP9%gNrr0nyf& zGCkjfQA);GJ!hqz=lYkjA<{hIGIPFdyL(s%qN0g;vTm?cQ1P6#H}=|x+^W#ZTJ$>R zOnAWm)Rhng%@$yb8JZ0+ZMKV@TEkn@?lAf6Cza+af0$g#+-E^`U7V~KCKY8ib*Zhn zw|3Qzg_}z`2{YnIyd*~scwHEdKRLscQIHS505lR8vrl^he-ec delta 212 zcmV;_04x8srU;*(2(UC50Wq^R7-$ExwI9I)vyLNJ3$y+%A_f7em#4M?Y_ml*Pp<(o zvz6q)2D5GJ=>@ZH@u&l{F7=lLv)%b22bUHB0kHxc!IuPw0iCz*0Ri!T0XnzffC1SI zx6zFO4+6JM2m!DGm#C2eNtZ4P0g$(uk^v_Lx7e2f;|2kNu_2HWm+Y_sJ^??sE3pBg z2$OC%*q6Mz0Z5Z}IAFJYy8)mDllDAFmwdqiV3Te=c(-iA0q6y{Y{&sA2Lc`Wu^|`| Om+Y_s4!1?&0a`9Hv{r!t 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>(emptyMap()) - val unreadCounts: StateFlow> = _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 消息(同内容) - // 不匹配 senderId:gomobile 的 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 } diff --git a/android/app/src/main/java/com/tsmobile/app/data/UpdateChecker.kt b/android/app/src/main/java/com/tsmobile/app/data/UpdateChecker.kt new file mode 100644 index 0000000..302a42c --- /dev/null +++ b/android/app/src/main/java/com/tsmobile/app/data/UpdateChecker.kt @@ -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(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 < b,0 表示相等。 + * + * 示例: + * - 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 + } +} diff --git a/android/app/src/main/java/com/tsmobile/app/data/UpdatePreferences.kt b/android/app/src/main/java/com/tsmobile/app/data/UpdatePreferences.kt new file mode 100644 index 0000000..1e1bd67 --- /dev/null +++ b/android/app/src/main/java/com/tsmobile/app/data/UpdatePreferences.kt @@ -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 = context.updateDataStore.data.map { prefs -> + prefs[DISMISSED_VERSION_KEY] ?: "" + } + + /** + * 上次检查时间戳(默认0表示从未检查过)。 + */ + val lastCheckTimestamp: Flow = 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 + } + } +} diff --git a/android/app/src/main/java/com/tsmobile/app/ui/components/MessageItem.kt b/android/app/src/main/java/com/tsmobile/app/ui/components/MessageItem.kt index f401fc6..eec89d4 100644 --- a/android/app/src/main/java/com/tsmobile/app/ui/components/MessageItem.kt +++ b/android/app/src/main/java/com/tsmobile/app/ui/components/MessageItem.kt @@ -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)) diff --git a/android/app/src/main/java/com/tsmobile/app/ui/components/PokeNotification.kt b/android/app/src/main/java/com/tsmobile/app/ui/components/PokeNotification.kt index 3df636d..a8b7b4a 100644 --- a/android/app/src/main/java/com/tsmobile/app/ui/components/PokeNotification.kt +++ b/android/app/src/main/java/com/tsmobile/app/ui/components/PokeNotification.kt @@ -36,6 +36,7 @@ fun PokeNotification( Card( modifier = Modifier .fillMaxWidth() + .statusBarsPadding() .padding( horizontal = UiTokens.Spacing.Large, vertical = UiTokens.Spacing.Small, diff --git a/android/app/src/main/java/com/tsmobile/app/ui/components/UpdateBanner.kt b/android/app/src/main/java/com/tsmobile/app/ui/components/UpdateBanner.kt new file mode 100644 index 0000000..409d12e --- /dev/null +++ b/android/app/src/main/java/com/tsmobile/app/ui/components/UpdateBanner.kt @@ -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("下载更新") + } + } + } + } +} diff --git a/android/app/src/main/java/com/tsmobile/app/ui/components/UpdateCheckDialog.kt b/android/app/src/main/java/com/tsmobile/app/ui/components/UpdateCheckDialog.kt new file mode 100644 index 0000000..bd744be --- /dev/null +++ b/android/app/src/main/java/com/tsmobile/app/ui/components/UpdateCheckDialog.kt @@ -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 区域已处理 + } + }, + ) +} diff --git a/android/app/src/main/java/com/tsmobile/app/ui/components/VoiceCard.kt b/android/app/src/main/java/com/tsmobile/app/ui/components/VoiceCard.kt index f7efa23..39d30ac 100644 --- a/android/app/src/main/java/com/tsmobile/app/ui/components/VoiceCard.kt +++ b/android/app/src/main/java/com/tsmobile/app/ui/components/VoiceCard.kt @@ -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, diff --git a/android/app/src/main/java/com/tsmobile/app/ui/navigation/NavGraph.kt b/android/app/src/main/java/com/tsmobile/app/ui/navigation/NavGraph.kt index bd9dde6..9ca33f5 100644 --- a/android/app/src/main/java/com/tsmobile/app/ui/navigation/NavGraph.kt +++ b/android/app/src/main/java/com/tsmobile/app/ui/navigation/NavGraph.kt @@ -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() }, + ) } } diff --git a/android/app/src/main/java/com/tsmobile/app/ui/screens/ChannelListScreen.kt b/android/app/src/main/java/com/tsmobile/app/ui/screens/ChannelListScreen.kt index af46b7c..6524926 100644 --- a/android/app/src/main/java/com/tsmobile/app/ui/screens/ChannelListScreen.kt +++ b/android/app/src/main/java/com/tsmobile/app/ui/screens/ChannelListScreen.kt @@ -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 不是 Synchronized(ViewModel 被系统回收后重建), // 重新触发首次同步,否则频道列表页会永远停在加载态。 LaunchedEffect(Unit) { @@ -189,14 +185,6 @@ fun ChannelListScreen( ) } - // ─── 全局浮动通知 ─── - - // Poke 通知(顶部气泡) - PokeNotification( - pokeEvent = pokeNotification, - isVisible = showPokeNotification, - onDismiss = { serverViewModel.dismissPokeNotification() }, - ) } // ─── 弹窗层 ─── diff --git a/android/app/src/main/java/com/tsmobile/app/ui/screens/ChatScreen.kt b/android/app/src/main/java/com/tsmobile/app/ui/screens/ChatScreen.kt index 9ac2660..1d36d9d 100644 --- a/android/app/src/main/java/com/tsmobile/app/ui/screens/ChatScreen.kt +++ b/android/app/src/main/java/com/tsmobile/app/ui/screens/ChatScreen.kt @@ -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) }, diff --git a/android/app/src/main/java/com/tsmobile/app/ui/screens/ServerConfigScreen.kt b/android/app/src/main/java/com/tsmobile/app/ui/screens/ServerConfigScreen.kt index 6d329c1..78be992 100644 --- a/android/app/src/main/java/com/tsmobile/app/ui/screens/ServerConfigScreen.kt +++ b/android/app/src/main/java/com/tsmobile/app/ui/screens/ServerConfigScreen.kt @@ -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, diff --git a/android/app/src/main/java/com/tsmobile/app/viewmodel/ChannelViewModel.kt b/android/app/src/main/java/com/tsmobile/app/viewmodel/ChannelViewModel.kt index 53305df..4f228d3 100644 --- a/android/app/src/main/java/com/tsmobile/app/viewmodel/ChannelViewModel.kt +++ b/android/app/src/main/java/com/tsmobile/app/viewmodel/ChannelViewModel.kt @@ -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>(emptySet()) val expandedChannelIds: StateFlow> = _expandedChannelIds.asStateFlow() - // 未读消息标记(当前频道外是否有新消息) - private val _hasUnreadMessage = MutableStateFlow(false) - val hasUnreadMessage: StateFlow = _hasUnreadMessage.asStateFlow() - // --- 当前频道 --- val currentChannelId: StateFlow = 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>(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 { + 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, 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, expandedIds: Set, ): List { + // 预计算:按频道 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, "离开") + } } } diff --git a/android/app/src/main/java/com/tsmobile/app/viewmodel/ChatViewModel.kt b/android/app/src/main/java/com/tsmobile/app/viewmodel/ChatViewModel.kt index e039dea..f0096f4 100644 --- a/android/app/src/main/java/com/tsmobile/app/viewmodel/ChatViewModel.kt +++ b/android/app/src/main/java/com/tsmobile/app/viewmodel/ChatViewModel.kt @@ -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() /** * 进入聊天页。 - * 加载消息并清除未读标记。 + * 加载消息并设置活跃会话标识。 */ 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 不含 InvokerID,senderId 通过 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 已支持 InvokerID,senderId 可靠。仍保留降级逻辑: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() } diff --git a/android/app/src/main/java/com/tsmobile/app/viewmodel/ServerViewModel.kt b/android/app/src/main/java/com/tsmobile/app/viewmodel/ServerViewModel.kt index b611900..65f28c3 100644 --- a/android/app/src/main/java/com/tsmobile/app/viewmodel/ServerViewModel.kt +++ b/android/app/src/main/java/com/tsmobile/app/viewmodel/ServerViewModel.kt @@ -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(null) val serverInfo: StateFlow = _serverInfo.asStateFlow() + // ── 更新检测 ── + private val updatePreferences = UpdatePreferences(application) + + private val _updateCheckResult = MutableStateFlow(null) + val updateCheckResult: StateFlow = _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() + @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() + @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().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() + + 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() + + 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().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, ) } diff --git a/android/app/src/main/java/com/tsmobile/app/viewmodel/VoiceViewModel.kt b/android/app/src/main/java/com/tsmobile/app/viewmodel/VoiceViewModel.kt index 35f0940..e27da72 100644 --- a/android/app/src/main/java/com/tsmobile/app/viewmodel/VoiceViewModel.kt +++ b/android/app/src/main/java/com/tsmobile/app/viewmodel/VoiceViewModel.kt @@ -72,6 +72,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) { private val _speakingClients = MutableStateFlow>(emptyMap()) val speakingClients: StateFlow> = _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) } diff --git a/go/_patches/github.com/honeybbq/teamspeak-go/notifications.go b/go/_patches/github.com/honeybbq/teamspeak-go/notifications.go index 459226b..ebf713b 100644 --- a/go/_patches/github.com/honeybbq/teamspeak-go/notifications.go +++ b/go/_patches/github.com/honeybbq/teamspeak-go/notifications.go @@ -61,7 +61,10 @@ func (c *Client) handleClientEnterView(cmd *commands.Command) { c.mu.Lock() c.clients[clid] = info unescapedNick := commands.Unescape(nick) - if isAutoNicknameMatch(c.nickname, unescapedNick) { + // Only allow clid override from nickname match if we haven't received initserver yet. + // After initserver, the clid is authoritative and should not be overwritten by + // stale same-UID sessions that happen to have matching nicknames. + if c.clid == 0 && isAutoNicknameMatch(c.nickname, unescapedNick) { c.clid = clid c.handler.SetClientID(clid) } @@ -80,11 +83,16 @@ func (c *Client) handleClientLeftView(cmd *commands.Command) { if clid != 0 { c.mu.Lock() isSelf := (clid == c.clid) + uid := "" + if info, ok := c.clients[clid]; ok { + uid = info.UID + } delete(c.clients, clid) c.mu.Unlock() evt := ClientLeftViewEvent{ ID: clid, + UID: uid, ReasonID: reasonID, ReasonMsg: reasonMsg, } diff --git a/go/_patches/github.com/honeybbq/teamspeak-go/types.go b/go/_patches/github.com/honeybbq/teamspeak-go/types.go index efe3097..f465f3d 100644 --- a/go/_patches/github.com/honeybbq/teamspeak-go/types.go +++ b/go/_patches/github.com/honeybbq/teamspeak-go/types.go @@ -34,6 +34,7 @@ type ClientLeftViewEvent struct { ReasonMsg string ReasonID int ID uint16 + UID string TargetID uint16 } diff --git a/go/teamspeak/bridge.go b/go/teamspeak/bridge.go index d864c3a..2082d95 100644 --- a/go/teamspeak/bridge.go +++ b/go/teamspeak/bridge.go @@ -222,6 +222,7 @@ type TextMsg struct { InvokerName string InvokerUID string Message string + InvokerID int TargetMode int TargetID string } @@ -261,6 +262,7 @@ type TSClient struct { callback EventCallback connected bool host string // 服务器地址(用于文件传输 TCP 连接) + selfUID string // 当前连接的 TeamSpeak UID,用于识别同 identity 的残留 session // 事件队列:保证所有 JNI 回调在同一个协程中顺序执行 evtQueueMu sync.Mutex // protects eventQueue replacement @@ -303,6 +305,11 @@ func (c *TSClient) Connect(host, nickname, password, defaultChannel, defaultChan return fmt.Sprintf("生成身份失败: %v", err) } + // 计算 TeamSpeak UID 并存储,用于僵尸会话过滤(isStaleSelfSession)。 + c.mu.Lock() + c.selfUID = crypto.GetUidFromPublicKey(identity.PublicKeyBase64()) + c.mu.Unlock() + opts := []ts.ClientOption{} if password != "" { opts = append(opts, ts.WithServerPassword(password)) @@ -1423,6 +1430,25 @@ func (c *TSClient) isCurrentClient(client *ts.Client) bool { return c.client == client } +// isStaleSelfSession checks if a remote client event belongs to a stale session +// using the same identity as the current connection. This happens when the previous +// process crashed without sending clientdisconnect, leaving a "zombie" session on +// the server that the new connection must isolate from. +func (c *TSClient) isStaleSelfSession(uid string, clid int) bool { + c.mu.Lock() + selfUID := c.selfUID + selfClid := 0 + if c.client != nil { + selfClid = int(c.client.ClientID()) + } + c.mu.Unlock() + + if selfUID == "" || uid == "" || uid != selfUID { + return false + } + return clid != selfClid +} + func (c *TSClient) queueClientEvent(client *ts.Client, evt queuedEvent) { if c.isCurrentClient(client) { c.queueEvent(evt) @@ -1444,6 +1470,10 @@ func (c *TSClient) registerEvents(client *ts.Client) { client.OnDisconnected(func(err error) { c.StopReceiveAudio() c.mu.Lock() + if c.client != client { + c.mu.Unlock() + return + } c.connected = false c.client = nil c.mu.Unlock() @@ -1455,12 +1485,13 @@ func (c *TSClient) registerEvents(client *ts.Client) { }) client.OnTextMessage(func(msg ts.TextMessage) { - c.queueEvent(queuedEvent{ + c.queueClientEvent(client, queuedEvent{ evtType: "textmessage", data: &TextMsg{ InvokerName: msg.InvokerName, InvokerUID: msg.InvokerUID, Message: msg.Message, + InvokerID: int(msg.InvokerID), TargetMode: msg.TargetMode, TargetID: fmt.Sprintf("%d", msg.TargetID), }, @@ -1468,7 +1499,11 @@ func (c *TSClient) registerEvents(client *ts.Client) { }) client.OnClientEnter(func(info ts.ClientInfo) { - c.queueEvent(queuedEvent{ + if c.isStaleSelfSession(info.UID, int(info.ID)) { + log.Printf("[TSBridge] ignoring stale self session enter clid=%d uid=%s", info.ID, info.UID) + return + } + c.queueClientEvent(client, queuedEvent{ evtType: "cliententer", data: &Client{ ID: int(info.ID), @@ -1481,8 +1516,13 @@ func (c *TSClient) registerEvents(client *ts.Client) { }) client.OnClientLeave(func(data ts.ClientLeftViewEvent) { + // 不过滤僵尸的 leave 事件:GetClientsJSON 初始同步可能已包含僵尸, + // 需要 leave 事件来更新 UI 将其移除。同时确保音频解码器被清理。 + if c.isStaleSelfSession(data.UID, int(data.ID)) { + log.Printf("[TSBridge] stale self session leave clid=%d uid=%s (allowing for UI cleanup)", data.ID, data.UID) + } c.RemoveRemoteAudioClient(int(data.ID)) - c.queueEvent(queuedEvent{ + c.queueClientEvent(client, queuedEvent{ evtType: "clientleave", data: struct { ID int @@ -1495,7 +1535,7 @@ func (c *TSClient) registerEvents(client *ts.Client) { }) client.OnClientMoved(func(data ts.ClientMovedEvent) { - c.queueEvent(queuedEvent{ + c.queueClientEvent(client, queuedEvent{ evtType: "clientmoved", data: struct { ID int @@ -1510,6 +1550,10 @@ func (c *TSClient) registerEvents(client *ts.Client) { client.OnKicked(func(reason string) { c.StopReceiveAudio() c.mu.Lock() + if c.client != client { + c.mu.Unlock() + return + } c.connected = false c.client = nil c.mu.Unlock() @@ -1526,6 +1570,9 @@ func (c *TSClient) registerEvents(client *ts.Client) { } // Keep the existing raw bridge active until an Android libopus decoder // factory has been supplied and the Go receiver explicitly started. + if !c.isCurrentClient(client) { + return + } c.queueEvent(queuedEvent{ evtType: "voicedata", data: struct { @@ -1545,7 +1592,7 @@ func (c *TSClient) registerEvents(client *ts.Client) { }) client.OnPoked(func(evt ts.PokeEvent) { - c.queueEvent(queuedEvent{ + c.queueClientEvent(client, queuedEvent{ evtType: "poked", data: &PokeEvent{ InvokerID: int(evt.InvokerID), diff --git a/go/teamspeak/kotlin_api.go b/go/teamspeak/kotlin_api.go index 7b401bf..59fb0e2 100644 --- a/go/teamspeak/kotlin_api.go +++ b/go/teamspeak/kotlin_api.go @@ -70,6 +70,13 @@ func (c *TSClient) ConnectWithIdentity(identityStr, host, nickname, password, de return fmt.Sprintf("identity 解析失败: %v", err) } + // 计算 TeamSpeak UID(base64(SHA1(publicKey))),用于僵尸会话过滤。 + // 不能直接存储 identity 字符串,因为 UID 是公钥的哈希,格式完全不同。 + uid := crypto.GetUidFromPublicKey(identity.PublicKeyBase64()) + c.mu.Lock() + c.selfUID = uid + c.mu.Unlock() + opts := []ts.ClientOption{} if password != "" { opts = append(opts, ts.WithServerPassword(password)) @@ -102,7 +109,15 @@ func (c *TSClient) ConnectWithIdentity(identityStr, host, nickname, password, de return fmt.Sprintf("连接失败: %v", err) } - c.cleanupDuplicateIdentitySessions(client, identity) + // 延迟执行僵尸清理:连接刚建立时客户端在默认频道,ListClients 可能尚未 + // 收到服务器发来的完整客户端列表。延迟 3 秒确保所有 notifycliententerview + // 事件已到达,此时 ListClients 能发现不同频道中的僵尸会话。 + go func() { + time.Sleep(3 * time.Second) + if c.isCurrentClient(client) { + c.cleanupDuplicateIdentitySessions(client, identity) + } + }() return "" } diff --git a/links.md b/links.md deleted file mode 100644 index e98c56d..0000000 --- a/links.md +++ /dev/null @@ -1,2 +0,0 @@ - -ts官方sdk路径 `E:\MyProject\ts-mobile-go\docs\teamspeak-sdk-3.5.2` \ No newline at end of file diff --git a/第一步:移动签名文件到项目目录.md b/release构建文档.md similarity index 100% rename from 第一步:移动签名文件到项目目录.md rename to release构建文档.md