feat(android): improve settings, server switching, and local debug trust

- add notification sound preview and system notification shortcuts
- reconnect automatically when switching servers and scope chat history per server
- add clearer server switching state and related i18n updates
- trust deploy local CA for debug builds and document the setup
pull/14/head
alimu 1 week ago
parent 02046bd3f4
commit ce94f4699d

@ -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

@ -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/`
## 协议注意事项
- 鉴权签名串:

@ -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(
"""
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config>
<trust-anchors>
<certificates src="system" />
<certificates src="@raw/local_ca" />
</trust-anchors>
</base-config>
</network-security-config>
""".trimIndent() + "\n"
)
generatedLocalDebugManifestFile.writeText(
"""
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:networkSecurityConfig="@xml/network_security_config" />
</manifest>
""".trimIndent() + "\n"
)
} else {
generatedLocalDebugManifestFile.writeText(
"""
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
""".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)
}

@ -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
}

@ -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<UiMessage> {
return messageDao.listAll()
suspend fun loadMessages(serverKey: String, limit: Int): List<UiMessage> {
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::"

@ -7,8 +7,8 @@ import androidx.room.Query
@Dao
interface ChatMessageDao {
@Query("SELECT * FROM chat_messages ORDER BY timestampMillis ASC")
suspend fun listAll(): List<ChatMessageEntity>
@Query("SELECT * FROM chat_messages WHERE serverKey = :serverKey ORDER BY timestampMillis ASC")
suspend fun listByServer(serverKey: String): List<ChatMessageEntity>
@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)
}

@ -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,

@ -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

@ -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<String>,
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

@ -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 = ""
) {
/**
* 是否允许连接

@ -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)

@ -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 "서버가 연결을 거부했습니다: ",

@ -0,0 +1,19 @@
package com.onlinemsg.client.util
import com.onlinemsg.client.R
object NotificationSoundCatalog {
val soundCodes: List<String> = 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"
}
Loading…
Cancel
Save