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" +}