diff --git a/ReadMe.md b/ReadMe.md
index 941934f..f454772 100644
--- a/ReadMe.md
+++ b/ReadMe.md
@@ -79,6 +79,13 @@ bash deploy/redeploy_with_lan_cert.sh
适合 Android 真机、同网段设备和浏览器本地联调。
+Android 客户端 debug 包支持额外信任本地局域网 CA:
+
+- 把局域网 WSS 使用的 CA 证书复制到 `deploy/certs/android-local/local_ca.crt`
+- `deploy/certs/` 已在 `.gitignore` 中,只用于本地调试,不应提交到 Git
+- `assembleDebug` 会自动把它接入 debug-only 的 `networkSecurityConfig`
+- release 构建不会信任这张本地 CA
+
### 3. 生产准备
```bash
diff --git a/android-client/README.md b/android-client/README.md
index 9312f95..1697d3e 100644
--- a/android-client/README.md
+++ b/android-client/README.md
@@ -47,6 +47,17 @@ cd android-client
- 真机建议地址:`ws://<你的局域网IP>:13173/`
- 若服务端启用 WSS,需要 Android 设备信任对应证书。
+### Debug 本地 CA 信任
+
+如果你使用 `deploy` 目录生成的局域网自签名证书做 `wss://` 联调,debug 构建可以额外信任一张本地 CA:
+
+- 把 CA 证书放到仓库根目录的 `deploy/certs/android-local/local_ca.crt`
+- `deploy/certs/` 已在 `.gitignore` 中,这张证书只用于本地调试,不应提交到 Git
+- 执行 `./gradlew assembleDebug` 时,构建脚本会自动生成 debug-only 的 `networkSecurityConfig`
+- 如果该文件不存在,debug 仍然可以编译,只是不会额外信任本地 CA
+
+推荐把局域网部署生成的 `local_ca.crt` 放到这个位置,再连接 `wss://<你的局域网IP>:13173/`
+
## 协议注意事项
- 鉴权签名串:
diff --git a/android-client/app/build.gradle.kts b/android-client/app/build.gradle.kts
index d40f9af..92f64a8 100644
--- a/android-client/app/build.gradle.kts
+++ b/android-client/app/build.gradle.kts
@@ -5,6 +5,12 @@ plugins {
id("com.google.devtools.ksp")
}
+val localDebugCaCertSource = rootDir.resolve("../deploy/certs/android-local/local_ca.crt")
+val generatedLocalDebugTrustDir = layout.buildDirectory.dir("generated/localDebugTrust")
+val generatedLocalDebugTrustRoot = generatedLocalDebugTrustDir.get().asFile
+val generatedLocalDebugResDir = generatedLocalDebugTrustRoot.resolve("res")
+val generatedLocalDebugManifestFile = generatedLocalDebugTrustRoot.resolve("AndroidManifest.xml")
+
android {
namespace = "com.onlinemsg.client"
compileSdk = 34
@@ -14,7 +20,7 @@ android {
minSdk = 26
targetSdk = 34
versionCode = 1
- versionName = "1.0.0.2"
+ versionName = "1.0.0.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -52,6 +58,13 @@ android {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
+
+ sourceSets {
+ getByName("debug") {
+ res.srcDir(generatedLocalDebugResDir)
+ manifest.srcFile(generatedLocalDebugManifestFile)
+ }
+ }
}
dependencies {
@@ -93,6 +106,56 @@ val debugApkExportDir: String = providers.gradleProperty("debugApkExportDir")
.get()
val debugApkExportName = "onlinemsgclient-debug.apk"
+val prepareLocalDebugTrust by tasks.registering {
+ val sourceFile = localDebugCaCertSource
+
+ inputs.file(sourceFile).optional()
+ outputs.dir(generatedLocalDebugTrustDir)
+
+ doLast {
+ generatedLocalDebugTrustRoot.deleteRecursively()
+ generatedLocalDebugTrustRoot.mkdirs()
+
+ if (sourceFile.exists()) {
+ val rawDir = generatedLocalDebugResDir.resolve("raw")
+ val xmlDir = generatedLocalDebugResDir.resolve("xml")
+ rawDir.mkdirs()
+ xmlDir.mkdirs()
+
+ sourceFile.copyTo(rawDir.resolve("local_ca.crt"), overwrite = true)
+ xmlDir.resolve("network_security_config.xml").writeText(
+ """
+
+
+
+
+
+
+
+
+
+ """.trimIndent() + "\n"
+ )
+
+ generatedLocalDebugManifestFile.writeText(
+ """
+
+
+
+
+ """.trimIndent() + "\n"
+ )
+ } else {
+ generatedLocalDebugManifestFile.writeText(
+ """
+
+
+ """.trimIndent() + "\n"
+ )
+ }
+ }
+}
+
val exportDebugApk by tasks.registering(Copy::class) {
from(layout.buildDirectory.file("outputs/apk/debug/app-debug.apk"))
into(debugApkExportDir)
@@ -102,6 +165,11 @@ val exportDebugApk by tasks.registering(Copy::class) {
}
}
+tasks.matching { it.name == "preDebugBuild" }.configureEach {
+ dependsOn(prepareLocalDebugTrust)
+}
+
tasks.matching { it.name == "assembleDebug" }.configureEach {
+ dependsOn(prepareLocalDebugTrust)
finalizedBy(exportDebugApk)
}
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatDatabase.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatDatabase.kt
index 6b9260a..7c5e1a5 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatDatabase.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatDatabase.kt
@@ -9,7 +9,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
@Database(
entities = [ChatMessageEntity::class],
- version = 2,
+ version = 3,
exportSchema = false
)
abstract class ChatDatabase : RoomDatabase() {
@@ -29,6 +29,12 @@ abstract class ChatDatabase : RoomDatabase() {
}
}
+ private val MIGRATION_2_3 = object : Migration(2, 3) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE chat_messages ADD COLUMN serverKey TEXT NOT NULL DEFAULT ''")
+ }
+ }
+
fun getInstance(context: Context): ChatDatabase {
return instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
@@ -36,7 +42,7 @@ abstract class ChatDatabase : RoomDatabase() {
ChatDatabase::class.java,
DB_NAME
)
- .addMigrations(MIGRATION_1_2)
+ .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build().also { db ->
instance = db
}
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatHistoryRepository.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatHistoryRepository.kt
index 0a28a70..c176815 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatHistoryRepository.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatHistoryRepository.kt
@@ -6,27 +6,38 @@ import com.onlinemsg.client.ui.MessageRole
import com.onlinemsg.client.ui.UiMessage
class ChatHistoryRepository(private val messageDao: ChatMessageDao) {
- suspend fun loadMessages(limit: Int): List {
- return messageDao.listAll()
+ suspend fun loadMessages(serverKey: String, limit: Int): List {
+ migrateLegacyMessagesIfNeeded(serverKey)
+ return messageDao.listByServer(serverKey)
.asSequence()
.mapNotNull { entity -> entity.toUiMessageOrNull() }
.toList()
.takeLast(limit)
}
- suspend fun appendMessage(message: UiMessage, limit: Int) {
- messageDao.upsert(message.toEntity())
- messageDao.trimToLatest(limit)
+ suspend fun appendMessage(serverKey: String, message: UiMessage, limit: Int) {
+ messageDao.upsert(message.toEntity(serverKey))
+ messageDao.trimToLatest(serverKey, limit)
}
- suspend fun clearAll() {
- messageDao.clearAll()
+ suspend fun clearAll(serverKey: String) {
+ messageDao.clearAll(serverKey)
+ }
+
+ private suspend fun migrateLegacyMessagesIfNeeded(serverKey: String) {
+ if (messageDao.countByServer(serverKey) > 0) return
+ if (messageDao.countLegacyMessages() == 0) return
+ messageDao.migrateLegacyMessagesToServer(
+ serverKey = serverKey,
+ idPrefix = storageIdPrefix(serverKey)
+ )
}
}
-private fun UiMessage.toEntity(): ChatMessageEntity {
+private fun UiMessage.toEntity(serverKey: String): ChatMessageEntity {
return ChatMessageEntity(
- id = id,
+ id = toStorageId(serverKey, id),
+ serverKey = serverKey,
role = role.name,
sender = sender,
subtitle = subtitle,
@@ -57,3 +68,13 @@ private fun ChatMessageEntity.toUiMessageOrNull(): UiMessage? {
audioDurationMillis = audioDurationMillis
)
}
+
+private fun toStorageId(serverKey: String, messageId: String): String {
+ return if (messageId.startsWith(storageIdPrefix(serverKey))) {
+ messageId
+ } else {
+ storageIdPrefix(serverKey) + messageId
+ }
+}
+
+private fun storageIdPrefix(serverKey: String): String = "$serverKey::"
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageDao.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageDao.kt
index 1085e09..1021a9e 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageDao.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageDao.kt
@@ -7,8 +7,8 @@ import androidx.room.Query
@Dao
interface ChatMessageDao {
- @Query("SELECT * FROM chat_messages ORDER BY timestampMillis ASC")
- suspend fun listAll(): List
+ @Query("SELECT * FROM chat_messages WHERE serverKey = :serverKey ORDER BY timestampMillis ASC")
+ suspend fun listByServer(serverKey: String): List
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(message: ChatMessageEntity)
@@ -16,16 +16,27 @@ interface ChatMessageDao {
@Query(
"""
DELETE FROM chat_messages
- WHERE id NOT IN (
+ WHERE serverKey = :serverKey
+ AND id NOT IN (
SELECT id
FROM chat_messages
+ WHERE serverKey = :serverKey
ORDER BY timestampMillis DESC
LIMIT :limit
)
"""
)
- suspend fun trimToLatest(limit: Int)
+ suspend fun trimToLatest(serverKey: String, limit: Int)
- @Query("DELETE FROM chat_messages")
- suspend fun clearAll()
+ @Query("DELETE FROM chat_messages WHERE serverKey = :serverKey")
+ suspend fun clearAll(serverKey: String)
+
+ @Query("SELECT COUNT(*) FROM chat_messages WHERE serverKey = :serverKey")
+ suspend fun countByServer(serverKey: String): Int
+
+ @Query("SELECT COUNT(*) FROM chat_messages WHERE serverKey = ''")
+ suspend fun countLegacyMessages(): Int
+
+ @Query("UPDATE chat_messages SET serverKey = :serverKey, id = :idPrefix || id WHERE serverKey = ''")
+ suspend fun migrateLegacyMessagesToServer(serverKey: String, idPrefix: String)
}
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageEntity.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageEntity.kt
index 42eaef1..2cc8c11 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageEntity.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageEntity.kt
@@ -6,6 +6,7 @@ import androidx.room.PrimaryKey
@Entity(tableName = "chat_messages")
data class ChatMessageEntity(
@PrimaryKey val id: String,
+ val serverKey: String,
val role: String,
val sender: String,
val subtitle: String,
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt
index d8ba727..389b606 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt
@@ -1,12 +1,21 @@
package com.onlinemsg.client.ui
import android.Manifest
+import android.app.NotificationManager
import android.content.Context
+import android.content.Intent
import android.content.pm.PackageManager
+import android.media.AudioAttributes
import android.media.MediaPlayer
import android.os.Build
+import android.provider.Settings
import android.util.Base64
import android.view.MotionEvent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
@@ -54,6 +63,7 @@ import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
@@ -92,8 +102,12 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
+import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
import androidx.compose.foundation.isSystemInDarkTheme
import com.onlinemsg.client.ui.theme.OnlineMsgTheme
import java.time.Instant
@@ -103,6 +117,7 @@ import java.io.File
import com.onlinemsg.client.ui.theme.themeOptions
import com.onlinemsg.client.util.AudioRecorder
import com.onlinemsg.client.util.LanguageManager
+import com.onlinemsg.client.util.NotificationSoundCatalog
import kotlinx.coroutines.delay
@@ -230,7 +245,7 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) {
onServerUrlChange = viewModel::updateServerUrl,
onSaveServer = viewModel::saveCurrentServerUrl,
onRemoveServer = viewModel::removeCurrentServerUrl,
- onSelectServer = viewModel::updateServerUrl,
+ onSelectServer = viewModel::selectServerUrl,
onToggleShowSystem = viewModel::toggleShowSystemMessages,
onRevealPublicKey = viewModel::revealMyPublicKey,
onCopyPublicKey = {
@@ -472,6 +487,57 @@ private fun ChatTab(
}
Spacer(modifier = Modifier.height(8.dp))
+ AnimatedVisibility(
+ visible = state.isSwitchingServer,
+ enter = fadeIn(animationSpec = tween(180)) + slideInVertically(
+ animationSpec = tween(220),
+ initialOffsetY = { -it / 2 }
+ ),
+ exit = fadeOut(animationSpec = tween(140)) + slideOutVertically(
+ animationSpec = tween(180),
+ targetOffsetY = { -it / 3 }
+ )
+ ) {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ ),
+ shape = RoundedCornerShape(14.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(18.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
+ Text(
+ text = t("session.hint.switching_server"),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ Text(
+ text = state.switchingServerLabel,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ }
+ }
+ if (state.isSwitchingServer) {
+ Spacer(modifier = Modifier.height(8.dp))
+ }
val statusHintText = if (audioHint.isNotBlank()) {
audioHint
} else {
@@ -1043,6 +1109,14 @@ private fun SettingsTab(
onLanguageChange: (String) -> Unit,
onNotificationSoundChange: (String) -> Unit
) {
+ val context = LocalContext.current
+ val previewPlayer = remember(context) { NotificationSoundPreviewPlayer(context) }
+ val notificationStatus = rememberNotificationSoundStatus(state.notificationSound)
+
+ DisposableEffect(Unit) {
+ onDispose { previewPlayer.release() }
+ }
+
fun t(key: String) = LanguageManager.getString(key, state.language)
val settingsCardModifier = Modifier.fillMaxWidth()
@@ -1083,11 +1157,27 @@ private fun SettingsTab(
verticalArrangement = settingsCardContentSpacing
) {
Text(t("settings.notification_sound"), style = MaterialTheme.typography.titleMedium)
+ Text(
+ text = when {
+ !notificationStatus.notificationsEnabled -> t("settings.notification_disabled")
+ !notificationStatus.channelSoundEnabled -> t("settings.notification_sound_disabled")
+ else -> t("settings.notification_enabled")
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = if (notificationStatus.notificationsEnabled && notificationStatus.channelSoundEnabled) {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ } else {
+ MaterialTheme.colorScheme.error
+ }
+ )
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- items(listOf("default", "ding", "nameit5", "wind_chime")) { sound ->
+ items(NotificationSoundCatalog.soundCodes) { sound ->
FilterChip(
selected = state.notificationSound == sound,
- onClick = { onNotificationSoundChange(sound) },
+ onClick = {
+ previewPlayer.play(sound)
+ onNotificationSoundChange(sound)
+ },
label = { Text(t("sound.$sound")) },
leadingIcon = {
Icon(
@@ -1099,6 +1189,23 @@ private fun SettingsTab(
)
}
}
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedButton(onClick = { openAppNotificationSettings(context) }) {
+ Text(t("settings.notification_system_settings"))
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ OutlinedButton(
+ onClick = {
+ openNotificationChannelSettings(
+ context = context,
+ soundCode = state.notificationSound
+ )
+ }
+ ) {
+ Text(t("settings.notification_channel_settings"))
+ }
+ }
+ }
}
}
}
@@ -1352,6 +1459,124 @@ private class AudioMessagePlayer(private val context: Context) {
}
}
+private data class NotificationSoundStatus(
+ val notificationsEnabled: Boolean,
+ val channelSoundEnabled: Boolean
+)
+
+@Composable
+private fun rememberNotificationSoundStatus(soundCode: String): NotificationSoundStatus {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+ var status by remember(soundCode, context) {
+ mutableStateOf(queryNotificationSoundStatus(context, soundCode))
+ }
+
+ LaunchedEffect(soundCode, context) {
+ status = queryNotificationSoundStatus(context, soundCode)
+ }
+
+ DisposableEffect(soundCode, context, lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ status = queryNotificationSoundStatus(context, soundCode)
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose {
+ lifecycleOwner.lifecycle.removeObserver(observer)
+ }
+ }
+ return status
+}
+
+private fun queryNotificationSoundStatus(context: Context, soundCode: String): NotificationSoundStatus {
+ val notificationsEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
+ if (!notificationsEnabled) {
+ return NotificationSoundStatus(
+ notificationsEnabled = false,
+ channelSoundEnabled = false
+ )
+ }
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ return NotificationSoundStatus(
+ notificationsEnabled = true,
+ channelSoundEnabled = true
+ )
+ }
+
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel = manager.getNotificationChannel(NotificationSoundCatalog.channelId(soundCode))
+ val channelSoundEnabled = channel?.importance != NotificationManager.IMPORTANCE_NONE &&
+ (channel?.sound != null || channel == null)
+ return NotificationSoundStatus(
+ notificationsEnabled = true,
+ channelSoundEnabled = channelSoundEnabled
+ )
+}
+
+private fun openAppNotificationSettings(context: Context) {
+ val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
+ putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+}
+
+private fun openNotificationChannelSettings(context: Context, soundCode: String) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ openAppNotificationSettings(context)
+ return
+ }
+ val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
+ putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
+ putExtra(Settings.EXTRA_CHANNEL_ID, NotificationSoundCatalog.channelId(soundCode))
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+}
+
+private class NotificationSoundPreviewPlayer(private val context: Context) {
+ private var mediaPlayer: MediaPlayer? = null
+
+ fun play(soundCode: String) {
+ val resId = NotificationSoundCatalog.resId(soundCode) ?: return
+ release()
+ val afd = context.resources.openRawResourceFd(resId) ?: return
+ val player = MediaPlayer()
+ val started = runCatching {
+ player.setAudioAttributes(
+ AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_NOTIFICATION_EVENT)
+ .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
+ .build()
+ )
+ player.setDataSource(afd.fileDescriptor, afd.startOffset, afd.length)
+ afd.close()
+ player.setOnCompletionListener { release() }
+ player.setOnErrorListener { _, _, _ ->
+ release()
+ true
+ }
+ player.prepare()
+ player.start()
+ true
+ }.getOrElse {
+ runCatching { afd.close() }
+ runCatching { player.release() }
+ false
+ }
+ if (!started) return
+ mediaPlayer = player
+ }
+
+ fun release() {
+ runCatching { mediaPlayer?.stop() }
+ runCatching { mediaPlayer?.release() }
+ mediaPlayer = null
+ }
+}
+
private fun formatAudioDuration(durationMillis: Long): String {
val totalSeconds = (durationMillis / 1000L).coerceAtLeast(0L)
val minutes = totalSeconds / 60L
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt
index db285cf..73813ef 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt
@@ -16,7 +16,6 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.onlinemsg.client.MainActivity
-import com.onlinemsg.client.R
import com.onlinemsg.client.data.crypto.RsaCryptoManager
import com.onlinemsg.client.data.local.ChatDatabase
import com.onlinemsg.client.data.local.ChatHistoryRepository
@@ -32,6 +31,7 @@ import com.onlinemsg.client.data.protocol.SignedPayloadDto
import com.onlinemsg.client.data.protocol.asPayloadText
import com.onlinemsg.client.service.ChatForegroundService
import com.onlinemsg.client.util.LanguageManager
+import com.onlinemsg.client.util.NotificationSoundCatalog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -184,7 +184,9 @@ object ChatSessionManager {
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
- statusHint = t("session.hint.connection_error_retrying")
+ statusHint = t("session.hint.connection_error_retrying"),
+ isSwitchingServer = false,
+ switchingServerLabel = ""
)
}
scheduleReconnect(t("session.reason.connection_error"))
@@ -207,7 +209,7 @@ object ChatSessionManager {
scope.launch {
val pref = preferencesRepository.preferencesFlow.first()
val historyMessages = withContext(Dispatchers.IO) {
- historyRepository.loadMessages(MAX_MESSAGES)
+ historyRepository.loadMessages(serverKeyFor(pref.currentServerUrl), MAX_MESSAGES)
}
keepAliveRequested = pref.shouldAutoReconnect
ensureMessageNotificationChannel(pref.notificationSound)
@@ -299,6 +301,20 @@ object ChatSessionManager {
_uiState.update { it.copy(serverUrl = value) }
}
+ /**
+ * 选择历史服务器并切换会话。
+ */
+ fun selectServerUrl(value: String) {
+ val normalized = ServerUrlFormatter.normalize(value)
+ if (normalized.isBlank()) return
+ switchServer(
+ normalized = normalized,
+ nextUrls = _uiState.value.serverUrls,
+ statusHint = null,
+ persist = { preferencesRepository.setCurrentServerUrl(normalized) }
+ )
+ }
+
/**
* 更新私聊目标公钥。
* @param value 公钥字符串
@@ -345,7 +361,7 @@ object ChatSessionManager {
_uiState.update { it.copy(messages = emptyList()) }
scope.launch(Dispatchers.IO) {
runCatching {
- historyRepository.clearAll()
+ historyRepository.clearAll(currentServerKey())
}
}
}
@@ -363,18 +379,13 @@ object ChatSessionManager {
}
val nextUrls = ServerUrlFormatter.append(_uiState.value.serverUrls, normalized)
- _uiState.update {
- it.copy(
- serverUrl = normalized,
- serverUrls = nextUrls,
- statusHint = t("session.hint.server_saved")
- )
- }
-
- scope.launch {
- preferencesRepository.saveCurrentServerUrl(normalized)
- _events.emit(UiEvent.ShowSnackbar(t("session.snackbar.server_saved")))
- }
+ switchServer(
+ normalized = normalized,
+ nextUrls = nextUrls,
+ statusHint = t("session.hint.server_saved"),
+ persist = { preferencesRepository.saveCurrentServerUrl(normalized) },
+ snackbarMessage = t("session.snackbar.server_saved")
+ )
}
/**
@@ -392,22 +403,17 @@ object ChatSessionManager {
filtered
}
- _uiState.update {
- it.copy(
- serverUrls = nextUrls,
- serverUrl = nextUrls.first(),
- statusHint = if (filtered.isEmpty()) {
- t("session.hint.server_restored_default")
- } else {
- t("session.hint.server_removed")
- }
- )
- }
-
- scope.launch {
- preferencesRepository.removeCurrentServerUrl(normalized)
- _events.emit(UiEvent.ShowSnackbar(t("session.snackbar.server_list_updated")))
- }
+ switchServer(
+ normalized = nextUrls.first(),
+ nextUrls = nextUrls,
+ statusHint = if (filtered.isEmpty()) {
+ t("session.hint.server_restored_default")
+ } else {
+ t("session.hint.server_removed")
+ },
+ persist = { preferencesRepository.removeCurrentServerUrl(normalized) },
+ snackbarMessage = t("session.snackbar.server_list_updated")
+ )
}
/**
@@ -450,17 +456,23 @@ object ChatSessionManager {
* 内部连接逻辑,区分自动恢复和手动连接。
* @param isAutoRestore 是否为应用启动时的自动恢复连接
*/
- private fun connectInternal(isAutoRestore: Boolean) {
+ private fun connectInternal(
+ isAutoRestore: Boolean,
+ overrideUrl: String? = null,
+ forceReconnect: Boolean = false
+ ) {
if (!initialized) return
val state = _uiState.value
- if (!state.canConnect) return
+ if (!forceReconnect && !state.canConnect) return
- val normalized = ServerUrlFormatter.normalize(state.serverUrl)
+ val normalized = ServerUrlFormatter.normalize(overrideUrl ?: state.serverUrl)
if (normalized.isBlank()) {
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
- statusHint = t("session.hint.fill_valid_server")
+ statusHint = t("session.hint.fill_valid_server"),
+ isSwitchingServer = false,
+ switchingServerLabel = ""
)
}
return
@@ -511,7 +523,9 @@ object ChatSessionManager {
_uiState.update {
it.copy(
status = ConnectionStatus.IDLE,
- statusHint = t("session.hint.connection_closed")
+ statusHint = t("session.hint.connection_closed"),
+ isSwitchingServer = false,
+ switchingServerLabel = ""
)
}
autoReconnectTriggered = false
@@ -690,7 +704,9 @@ object ChatSessionManager {
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
- statusHint = t("session.hint.handshake_incomplete_response")
+ statusHint = t("session.hint.handshake_incomplete_response"),
+ isSwitchingServer = false,
+ switchingServerLabel = ""
)
}
return
@@ -752,7 +768,9 @@ object ChatSessionManager {
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
- statusHint = t("session.hint.connection_timeout_retry")
+ statusHint = t("session.hint.connection_timeout_retry"),
+ isSwitchingServer = false,
+ switchingServerLabel = ""
)
}
addSystemMessage(t("session.msg.auth_timeout"))
@@ -769,7 +787,9 @@ object ChatSessionManager {
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
- statusHint = t("session.hint.auth_failed")
+ statusHint = t("session.hint.auth_failed"),
+ isSwitchingServer = false,
+ switchingServerLabel = ""
)
}
addSystemMessage(
@@ -846,7 +866,9 @@ object ChatSessionManager {
_uiState.update {
it.copy(
status = ConnectionStatus.READY,
- statusHint = t("session.hint.ready_to_chat")
+ statusHint = t("session.hint.ready_to_chat"),
+ isSwitchingServer = false,
+ switchingServerLabel = ""
)
}
addSystemMessage(t("session.msg.ready"))
@@ -955,7 +977,9 @@ object ChatSessionManager {
statusHint = tf(
"session.hint.server_rejected",
reason.ifBlank { t("session.text.policy_restriction") }
- )
+ ),
+ isSwitchingServer = false,
+ switchingServerLabel = ""
)
}
addSystemMessage(
@@ -998,7 +1022,9 @@ object ChatSessionManager {
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
- statusHint = t("session.hint.connection_interrupted_retry")
+ statusHint = t("session.hint.connection_interrupted_retry"),
+ isSwitchingServer = false,
+ switchingServerLabel = ""
)
}
addSystemMessage(
@@ -1284,11 +1310,78 @@ object ChatSessionManager {
if (message.role == MessageRole.SYSTEM) return
scope.launch(Dispatchers.IO) {
runCatching {
- historyRepository.appendMessage(message, MAX_MESSAGES)
+ historyRepository.appendMessage(currentServerKey(), message, MAX_MESSAGES)
+ }
+ }
+ }
+
+ private fun switchServer(
+ normalized: String,
+ nextUrls: List,
+ statusHint: String?,
+ persist: suspend () -> Unit,
+ snackbarMessage: String? = null
+ ) {
+ val targetServerKey = serverKeyFor(normalized)
+ val previousServerKey = currentServerKey()
+ val shouldReconnect = previousServerKey != targetServerKey || _uiState.value.status != ConnectionStatus.READY
+ val switchingLabel = summarizeServerLabel(normalized)
+
+ scope.launch {
+ _uiState.update {
+ it.copy(
+ serverUrl = normalized,
+ serverUrls = nextUrls,
+ isSwitchingServer = shouldReconnect,
+ switchingServerLabel = if (shouldReconnect) switchingLabel else "",
+ statusHint = statusHint ?: it.statusHint
+ )
+ }
+ persist()
+ val historyMessages = withContext(Dispatchers.IO) {
+ historyRepository.loadMessages(targetServerKey, MAX_MESSAGES)
+ }
+ _uiState.update {
+ it.copy(
+ serverUrl = normalized,
+ serverUrls = nextUrls,
+ messages = historyMessages,
+ certFingerprint = if (previousServerKey == targetServerKey) it.certFingerprint else "",
+ statusHint = statusHint ?: it.statusHint
+ )
+ }
+ if (shouldReconnect) {
+ connectInternal(
+ isAutoRestore = false,
+ overrideUrl = normalized,
+ forceReconnect = !_uiState.value.canConnect
+ )
+ }
+ if (!snackbarMessage.isNullOrBlank()) {
+ _events.emit(UiEvent.ShowSnackbar(snackbarMessage))
}
}
}
+ private fun currentServerKey(): String = serverKeyFor(_uiState.value.serverUrl)
+
+ private fun serverKeyFor(rawUrl: String): String {
+ return ServerUrlFormatter.normalize(rawUrl).ifBlank { ServerUrlFormatter.defaultServerUrl }
+ }
+
+ private fun summarizeServerLabel(rawUrl: String): String {
+ val normalized = serverKeyFor(rawUrl)
+ val parsed = runCatching { Uri.parse(normalized) }.getOrNull()
+ val host = parsed?.host
+ val port = parsed?.port?.takeIf { it > 0 }
+ val path = parsed?.encodedPath?.takeIf { !it.isNullOrBlank() && it != "/" }
+ return buildString {
+ append(host ?: normalized)
+ if (port != null) append(":$port")
+ if (path != null) append(path)
+ }.ifBlank { normalized }
+ }
+
/**
* 取消认证超时任务。
*/
@@ -1392,7 +1485,9 @@ object ChatSessionManager {
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
- statusHint = t("session.hint.handshake_timeout")
+ statusHint = t("session.hint.handshake_timeout"),
+ isSwitchingServer = false,
+ switchingServerLabel = ""
)
}
addSystemMessage(tf("session.msg.handshake_timeout_with_url", currentUrl))
@@ -1485,7 +1580,7 @@ object ChatSessionManager {
private fun ensureMessageNotificationChannel(soundCode: String = "default") {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
- val channelId = "${MESSAGE_CHANNEL_ID}_$soundCode"
+ val channelId = NotificationSoundCatalog.channelId(soundCode)
if (manager.getNotificationChannel(channelId) != null) return
val channel = NotificationChannel(
@@ -1508,13 +1603,7 @@ object ChatSessionManager {
}
private fun getSoundUri(code: String): Uri? {
- val resId = when (code) {
- "ding" -> R.raw.load
- "nameit5" -> R.raw.nameit5
- "wind_chime" -> R.raw.notification_sound_effects
- "default" -> R.raw.default_sound
- else -> return null
- }
+ val resId = NotificationSoundCatalog.resId(code) ?: return null
return Uri.parse("${ContentResolver.SCHEME_ANDROID_RESOURCE}://${app.packageName}/$resId")
}
@@ -1540,7 +1629,7 @@ object ChatSessionManager {
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
- val channelId = "${MESSAGE_CHANNEL_ID}_${_uiState.value.notificationSound}"
+ val channelId = NotificationSoundCatalog.channelId(_uiState.value.notificationSound)
ensureMessageNotificationChannel(_uiState.value.notificationSound)
val notification = NotificationCompat.Builder(app, channelId)
@@ -1589,7 +1678,6 @@ object ChatSessionManager {
private const val MAX_MESSAGES = 500
private const val MAX_RECONNECT_DELAY_SECONDS = 30
private const val SYSTEM_MESSAGE_TTL_MS = 1_000L
- private const val MESSAGE_CHANNEL_ID = "onlinemsg_messages"
private const val AUDIO_MESSAGE_PREFIX = "[[OMS_AUDIO_V1]]"
private const val AUDIO_CHUNK_MESSAGE_PREFIX = "[[OMS_AUDIO_CHUNK_V1]]"
private const val AUDIO_CHUNK_BASE64_SIZE = 20_000
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt
index 913b9bc..abfe61c 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt
@@ -98,7 +98,9 @@ data class ChatUiState(
val themeId: String = "blue",
val useDynamicColor: Boolean = true,
val language: String = "zh",
- val notificationSound: String = "default"
+ val notificationSound: String = "default",
+ val isSwitchingServer: Boolean = false,
+ val switchingServerLabel: String = ""
) {
/**
* 是否允许连接。
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt
index f8135f3..a0ce6e5 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt
@@ -19,6 +19,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
fun updateDisplayName(value: String) = ChatSessionManager.updateDisplayName(value)
fun updateServerUrl(value: String) = ChatSessionManager.updateServerUrl(value)
+ fun selectServerUrl(value: String) = ChatSessionManager.selectServerUrl(value)
fun updateTargetKey(value: String) = ChatSessionManager.updateTargetKey(value)
fun updateDraft(value: String) = ChatSessionManager.updateDraft(value)
fun toggleDirectMode(enabled: Boolean) = ChatSessionManager.toggleDirectMode(enabled)
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt b/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt
index d9ba62e..5792aeb 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt
@@ -31,6 +31,11 @@ object LanguageManager {
"settings.chat_data" to "聊天資料",
"settings.dynamic_color" to "使用動態顏色",
"settings.notification_sound" to "通知音效",
+ "settings.notification_enabled" to "系統通知與音效已啟用",
+ "settings.notification_disabled" to "系統通知目前已關閉",
+ "settings.notification_sound_disabled" to "目前音效渠道已被靜音",
+ "settings.notification_system_settings" to "系統通知設定",
+ "settings.notification_channel_settings" to "當前音效設定",
"status.idle" to "未連線",
"status.connecting" to "連線中",
"status.ready" to "已連線",
@@ -40,6 +45,7 @@ object LanguageManager {
"hint.ready_chat" to "已連線,可以開始聊天",
"hint.closed" to "連線已關閉",
"hint.reconnecting" to "連線已中斷,正在重試",
+ "session.hint.switching_server" to "正在切換伺服器",
"hint.reconnect_invalid_server" to "重連失敗:伺服器位址無效",
"hint.fill_target_key" to "請先填寫目標公鑰,再傳送私訊",
"hint.server_rejected_prefix" to "伺服器拒絕連線:",
@@ -105,6 +111,11 @@ object LanguageManager {
"settings.chat_data" to "聊天数据",
"settings.dynamic_color" to "使用动态颜色",
"settings.notification_sound" to "通知音效",
+ "settings.notification_enabled" to "系统通知与音效已启用",
+ "settings.notification_disabled" to "系统通知当前已关闭",
+ "settings.notification_sound_disabled" to "当前音效渠道已被静音",
+ "settings.notification_system_settings" to "系统通知设置",
+ "settings.notification_channel_settings" to "当前音效设置",
"sound.default" to "默认",
"sound.ding" to "叮",
"sound.nameit5" to "音效 5",
@@ -118,6 +129,7 @@ object LanguageManager {
"hint.ready_chat" to "已连接,可以开始聊天",
"hint.closed" to "连接已关闭",
"hint.reconnecting" to "连接已中断,正在重试",
+ "session.hint.switching_server" to "正在切换服务器",
"hint.reconnect_invalid_server" to "重连失败:服务器地址无效",
"hint.fill_target_key" to "请先填写目标公钥,再发送私聊消息",
"hint.server_rejected_prefix" to "服务器拒绝连接:",
@@ -259,6 +271,11 @@ object LanguageManager {
"settings.chat_data" to "Chat Data",
"settings.dynamic_color" to "Use dynamic color",
"settings.notification_sound" to "Notification Sound",
+ "settings.notification_enabled" to "Notifications and sound are enabled",
+ "settings.notification_disabled" to "Notifications are currently disabled",
+ "settings.notification_sound_disabled" to "The selected sound channel is muted",
+ "settings.notification_system_settings" to "System Notification Settings",
+ "settings.notification_channel_settings" to "Current Sound Channel",
"sound.default" to "Default",
"sound.ding" to "Ding",
"sound.nameit5" to "Sound 5",
@@ -272,6 +289,7 @@ object LanguageManager {
"hint.ready_chat" to "Connected, ready to chat",
"hint.closed" to "Connection closed",
"hint.reconnecting" to "Connection interrupted, reconnecting",
+ "session.hint.switching_server" to "Switching server",
"hint.reconnect_invalid_server" to "Reconnect failed: invalid server address",
"hint.fill_target_key" to "Please fill target public key before private message",
"hint.server_rejected_prefix" to "Server rejected connection: ",
@@ -413,6 +431,11 @@ object LanguageManager {
"settings.chat_data" to "チャットデータ",
"settings.dynamic_color" to "動的カラーを使用",
"settings.notification_sound" to "通知音",
+ "settings.notification_enabled" to "通知と音が有効です",
+ "settings.notification_disabled" to "通知が現在オフです",
+ "settings.notification_sound_disabled" to "現在の音声チャンネルはミュートされています",
+ "settings.notification_system_settings" to "システム通知設定",
+ "settings.notification_channel_settings" to "現在の音設定",
"sound.default" to "デフォルト",
"sound.ding" to "ディン",
"sound.nameit5" to "効果音 5",
@@ -426,6 +449,7 @@ object LanguageManager {
"hint.ready_chat" to "接続完了、チャット可能",
"hint.closed" to "接続を閉じました",
"hint.reconnecting" to "接続が中断され、再接続中",
+ "session.hint.switching_server" to "サーバーを切り替え中",
"hint.reconnect_invalid_server" to "再接続失敗:サーバーアドレス無効",
"hint.fill_target_key" to "個人チャット前に相手の公開鍵を入力してください",
"hint.server_rejected_prefix" to "サーバーが接続を拒否しました:",
@@ -567,6 +591,11 @@ object LanguageManager {
"settings.chat_data" to "채팅 데이터",
"settings.dynamic_color" to "동적 색상 사용",
"settings.notification_sound" to "알림 효과음",
+ "settings.notification_enabled" to "시스템 알림과 효과음이 켜져 있습니다",
+ "settings.notification_disabled" to "시스템 알림이 현재 꺼져 있습니다",
+ "settings.notification_sound_disabled" to "현재 효과음 채널이 음소거되어 있습니다",
+ "settings.notification_system_settings" to "시스템 알림 설정",
+ "settings.notification_channel_settings" to "현재 효과음 설정",
"sound.default" to "기본값",
"sound.ding" to "딩",
"sound.nameit5" to "효과음 5",
@@ -580,6 +609,7 @@ object LanguageManager {
"hint.ready_chat" to "연결 완료, 채팅 가능",
"hint.closed" to "연결이 종료되었습니다",
"hint.reconnecting" to "연결이 끊겨 재연결 중입니다",
+ "session.hint.switching_server" to "서버 전환 중",
"hint.reconnect_invalid_server" to "재연결 실패: 서버 주소가 올바르지 않습니다",
"hint.fill_target_key" to "비공개 채팅 전 대상 공개키를 입력하세요",
"hint.server_rejected_prefix" to "서버가 연결을 거부했습니다: ",
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/util/NotificationSoundCatalog.kt b/android-client/app/src/main/java/com/onlinemsg/client/util/NotificationSoundCatalog.kt
new file mode 100644
index 0000000..061fb3b
--- /dev/null
+++ b/android-client/app/src/main/java/com/onlinemsg/client/util/NotificationSoundCatalog.kt
@@ -0,0 +1,19 @@
+package com.onlinemsg.client.util
+
+import com.onlinemsg.client.R
+
+object NotificationSoundCatalog {
+ val soundCodes: List = listOf("default", "ding", "nameit5", "wind_chime")
+
+ fun resId(code: String): Int? {
+ return when (code) {
+ "default" -> R.raw.default_sound
+ "ding" -> R.raw.load
+ "nameit5" -> R.raw.nameit5
+ "wind_chime" -> R.raw.notification_sound_effects
+ else -> null
+ }
+ }
+
+ fun channelId(code: String): String = "onlinemsg_messages_$code"
+}