From 3974c061b8336097d0c5f4b39a2b3d5d76634ee3 Mon Sep 17 00:00:00 2001 From: alimu Date: Sun, 8 Mar 2026 19:47:28 +0400 Subject: [PATCH 01/16] feat(android): persist chat history in local Room database --- android-client/app/build.gradle.kts | 4 ++ .../client/data/local/ChatDatabase.kt | 34 +++++++++++++ .../data/local/ChatHistoryRepository.kt | 50 +++++++++++++++++++ .../client/data/local/ChatMessageDao.kt | 31 ++++++++++++ .../client/data/local/ChatMessageEntity.kt | 15 ++++++ .../onlinemsg/client/ui/ChatSessionManager.kt | 23 ++++++++- android-client/build.gradle.kts | 1 + 7 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatDatabase.kt create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatHistoryRepository.kt create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageDao.kt create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageEntity.kt diff --git a/android-client/app/build.gradle.kts b/android-client/app/build.gradle.kts index 7581e5a..d40f9af 100644 --- a/android-client/app/build.gradle.kts +++ b/android-client/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.serialization") + id("com.google.devtools.ksp") } android { @@ -71,6 +72,9 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") implementation("androidx.datastore:datastore-preferences:1.1.1") + implementation("androidx.room:room-runtime:2.6.1") + implementation("androidx.room:room-ktx:2.6.1") + ksp("androidx.room:room-compiler:2.6.1") implementation("com.squareup.okhttp3:okhttp:4.12.0") debugImplementation("androidx.compose.ui:ui-tooling") 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 new file mode 100644 index 0000000..25ea563 --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatDatabase.kt @@ -0,0 +1,34 @@ +package com.onlinemsg.client.data.local + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +@Database( + entities = [ChatMessageEntity::class], + version = 1, + exportSchema = false +) +abstract class ChatDatabase : RoomDatabase() { + abstract fun chatMessageDao(): ChatMessageDao + + companion object { + private const val DB_NAME = "onlinemsg_chat.db" + + @Volatile + private var instance: ChatDatabase? = null + + fun getInstance(context: Context): ChatDatabase { + return instance ?: synchronized(this) { + instance ?: Room.databaseBuilder( + context.applicationContext, + ChatDatabase::class.java, + DB_NAME + ).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 new file mode 100644 index 0000000..e0f0a61 --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatHistoryRepository.kt @@ -0,0 +1,50 @@ +package com.onlinemsg.client.data.local + +import com.onlinemsg.client.ui.MessageChannel +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() + .asSequence() + .mapNotNull { entity -> entity.toUiMessageOrNull() } + .toList() + .takeLast(limit) + } + + suspend fun appendMessage(message: UiMessage, limit: Int) { + messageDao.upsert(message.toEntity()) + messageDao.trimToLatest(limit) + } + + suspend fun clearAll() { + messageDao.clearAll() + } +} + +private fun UiMessage.toEntity(): ChatMessageEntity { + return ChatMessageEntity( + id = id, + role = role.name, + sender = sender, + subtitle = subtitle, + content = content, + channel = channel.name, + timestampMillis = timestampMillis + ) +} + +private fun ChatMessageEntity.toUiMessageOrNull(): UiMessage? { + val parsedRole = runCatching { MessageRole.valueOf(role) }.getOrNull() ?: return null + val parsedChannel = runCatching { MessageChannel.valueOf(channel) }.getOrNull() ?: return null + return UiMessage( + id = id, + role = parsedRole, + sender = sender, + subtitle = subtitle, + content = content, + channel = parsedChannel, + timestampMillis = timestampMillis + ) +} 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 new file mode 100644 index 0000000..1085e09 --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageDao.kt @@ -0,0 +1,31 @@ +package com.onlinemsg.client.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface ChatMessageDao { + @Query("SELECT * FROM chat_messages ORDER BY timestampMillis ASC") + suspend fun listAll(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(message: ChatMessageEntity) + + @Query( + """ + DELETE FROM chat_messages + WHERE id NOT IN ( + SELECT id + FROM chat_messages + ORDER BY timestampMillis DESC + LIMIT :limit + ) + """ + ) + suspend fun trimToLatest(limit: Int) + + @Query("DELETE FROM chat_messages") + suspend fun clearAll() +} 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 new file mode 100644 index 0000000..e0e6dde --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageEntity.kt @@ -0,0 +1,15 @@ +package com.onlinemsg.client.data.local + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "chat_messages") +data class ChatMessageEntity( + @PrimaryKey val id: String, + val role: String, + val sender: String, + val subtitle: String, + val content: String, + val channel: String, + val timestampMillis: Long +) 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 b9a1f25..a9750ee 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 @@ -15,6 +15,8 @@ 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 import com.onlinemsg.client.data.network.OnlineMsgSocketClient import com.onlinemsg.client.data.preferences.ServerUrlFormatter import com.onlinemsg.client.data.preferences.UserPreferencesRepository @@ -60,6 +62,7 @@ object ChatSessionManager { private lateinit var app: Application private lateinit var preferencesRepository: UserPreferencesRepository private lateinit var cryptoManager: RsaCryptoManager + private lateinit var historyRepository: ChatHistoryRepository private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val socketClient = OnlineMsgSocketClient() private var initialized = false @@ -171,10 +174,14 @@ object ChatSessionManager { app = application preferencesRepository = UserPreferencesRepository(application, json) cryptoManager = RsaCryptoManager(application) + historyRepository = ChatHistoryRepository(ChatDatabase.getInstance(application).chatMessageDao()) ensureMessageNotificationChannel() scope.launch { val pref = preferencesRepository.preferencesFlow.first() + val historyMessages = withContext(Dispatchers.IO) { + historyRepository.loadMessages(MAX_MESSAGES) + } keepAliveRequested = pref.shouldAutoReconnect _uiState.update { current -> current.copy( @@ -185,7 +192,8 @@ object ChatSessionManager { showSystemMessages = pref.showSystemMessages, themeId = pref.themeId, useDynamicColor = pref.useDynamicColor, - language = pref.language + language = pref.language, + messages = historyMessages ) } // 如果上次会话启用了自动重连,则自动恢复连接 @@ -294,6 +302,11 @@ object ChatSessionManager { fun clearMessages() { cancelSystemMessageExpiryJobs() _uiState.update { it.copy(messages = emptyList()) } + scope.launch(Dispatchers.IO) { + runCatching { + historyRepository.clearAll() + } + } } /** @@ -894,6 +907,12 @@ object ChatSessionManager { } current.copy(messages = next) } + if (message.role == MessageRole.SYSTEM) return + scope.launch(Dispatchers.IO) { + runCatching { + historyRepository.appendMessage(message, MAX_MESSAGES) + } + } } /** @@ -1155,4 +1174,4 @@ object ChatSessionManager { 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" -} \ No newline at end of file +} diff --git a/android-client/build.gradle.kts b/android-client/build.gradle.kts index dedf0a4..8c29eb8 100644 --- a/android-client/build.gradle.kts +++ b/android-client/build.gradle.kts @@ -2,4 +2,5 @@ plugins { id("com.android.application") version "8.5.2" apply false id("org.jetbrains.kotlin.android") version "1.9.24" apply false id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false + id("com.google.devtools.ksp") version "1.9.24-1.0.20" apply false } From c5b9d779add8f501573de0ea6b95aa138204cd2c Mon Sep 17 00:00:00 2001 From: emilia-t Date: Mon, 9 Mar 2026 08:13:19 +0800 Subject: [PATCH 02/16] =?UTF-8?q?=E5=A4=9A=E8=AF=AD=E8=A8=80=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=AE=8C=E6=95=B4=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/onlinemsg/client/ui/ChatScreen.kt | 78 +++++----- .../onlinemsg/client/ui/ChatSessionManager.kt | 85 +++++++---- .../com/onlinemsg/client/ui/ChatUiState.kt | 12 +- .../onlinemsg/client/util/LanguageManager.kt | 140 ++++++++++++++++-- android-client/build.gradle.kts | 2 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- 6 files changed, 235 insertions(+), 84 deletions(-) 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 aac72a4..c191e39 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 @@ -109,7 +109,7 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) { var tab by rememberSaveable { mutableStateOf(MainTab.CHAT) } // 定义翻译函数 t - fun t(key: String) = LanguageManager.getString(key, state.language) + fun language(key: String) = LanguageManager.getString(key, state.language) // 监听 ViewModel 发送的 UI 事件(如 Snackbar 消息) LaunchedEffect(Unit) { @@ -146,7 +146,7 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) { NavigationBarItem( selected = tab == MainTab.CHAT, onClick = { tab = MainTab.CHAT }, - label = { Text(t(MainTab.CHAT.labelKey), style = MaterialTheme.typography.labelSmall) }, + label = { Text(language(MainTab.CHAT.labelKey), style = MaterialTheme.typography.labelSmall) }, icon = { Icon( imageVector = Icons.Rounded.Forum, @@ -158,7 +158,7 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) { NavigationBarItem( selected = tab == MainTab.SETTINGS, onClick = { tab = MainTab.SETTINGS }, - label = { Text(t(MainTab.SETTINGS.labelKey), style = MaterialTheme.typography.labelSmall) }, + label = { Text(language(MainTab.SETTINGS.labelKey), style = MaterialTheme.typography.labelSmall) }, icon = { Icon( imageVector = Icons.Rounded.Settings, @@ -287,8 +287,8 @@ private fun ChatTab( ) { val listState = rememberLazyListState() - // 定义翻译函数 t - fun t(key: String) = LanguageManager.getString(key, state.language) + // 定义语言函数 language + fun language(key: String) = LanguageManager.getString(key, state.language) // 当消息列表新增消息时,自动滚动到底部 LaunchedEffect(state.visibleMessages.size) { @@ -312,12 +312,12 @@ private fun ChatTab( FilterChip( selected = !state.directMode, onClick = { onToggleDirectMode(false) }, - label = { Text(t("chat.broadcast")) } + label = { Text(language("chat.broadcast")) } ) FilterChip( selected = state.directMode, onClick = { onToggleDirectMode(true) }, - label = { Text(t("chat.private")) } + label = { Text(language("chat.private")) } ) // 在这一行腾出的空间可以放置其他快捷操作,或者保持简洁 @@ -336,8 +336,8 @@ private fun ChatTab( value = state.targetKey, onValueChange = onTargetKeyChange, modifier = Modifier.fillMaxWidth(), - label = { Text(t("chat.target_key")) }, - placeholder = { Text(t("chat.target_key")) }, + label = { Text(language("chat.target_key")) }, + placeholder = { Text(language("chat.target_key")) }, maxLines = 3 ) } @@ -362,7 +362,7 @@ private fun ChatTab( ) ) { Text( - text = t("chat.empty_hint"), + text = language("chat.empty_hint"), modifier = Modifier.padding(12.dp), style = MaterialTheme.typography.bodyMedium ) @@ -390,7 +390,7 @@ private fun ChatTab( value = state.draft, onValueChange = onDraftChange, modifier = Modifier.weight(1f), - label = { Text(t("chat.input_placeholder")) }, + label = { Text(language("chat.input_placeholder")) }, maxLines = 4, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), keyboardActions = KeyboardActions( @@ -405,7 +405,7 @@ private fun ChatTab( ) { Icon(Icons.AutoMirrored.Rounded.Send, contentDescription = null) Spacer(Modifier.width(6.dp)) - Text(if (state.sending) "..." else t("chat.send")) + Text(if (state.sending) "..." else language("chat.send")) } } } @@ -593,7 +593,7 @@ private fun SettingsTab( onUseDynamicColorChange: (Boolean) -> Unit, onLanguageChange: (String) -> Unit ) { - fun t(key: String) = LanguageManager.getString(key, state.language) + fun language(key: String) = LanguageManager.getString(key, state.language) val settingsCardModifier = Modifier.fillMaxWidth() val settingsCardContentModifier = Modifier @@ -614,12 +614,12 @@ private fun SettingsTab( modifier = settingsCardContentModifier, verticalArrangement = settingsCardContentSpacing ) { - Text(t("settings.personal"), style = MaterialTheme.typography.titleMedium) + Text(language("settings.personal"), style = MaterialTheme.typography.titleMedium) OutlinedTextField( value = state.displayName, onValueChange = onDisplayNameChange, modifier = Modifier.fillMaxWidth(), - label = { Text(t("settings.display_name")) }, + label = { Text(language("settings.display_name")) }, maxLines = 1 ) } @@ -632,9 +632,9 @@ private fun SettingsTab( modifier = settingsCardContentModifier, verticalArrangement = settingsCardContentSpacing ) { - Text("聊天数据", style = MaterialTheme.typography.titleMedium) + Text(language("settings.chat_data"), style = MaterialTheme.typography.titleMedium) OutlinedButton(onClick = onClearMessages) { - Text(t("settings.clear_msg")) + Text(language("settings.clear_msg")) } } } @@ -646,21 +646,21 @@ private fun SettingsTab( modifier = settingsCardContentModifier, verticalArrangement = settingsCardContentSpacing ) { - Text(t("settings.server"), style = MaterialTheme.typography.titleMedium) + Text(language("settings.server"), style = MaterialTheme.typography.titleMedium) OutlinedTextField( value = state.serverUrl, onValueChange = onServerUrlChange, modifier = Modifier.fillMaxWidth(), - label = { Text(t("settings.server_url")) }, + label = { Text(language("settings.server_url")) }, maxLines = 1 ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = onSaveServer) { Text(t("settings.save_server")) } - OutlinedButton(onClick = onRemoveServer) { Text(t("settings.remove_current")) } + Button(onClick = onSaveServer) { Text(language("settings.save_server")) } + OutlinedButton(onClick = onRemoveServer) { Text(language("settings.remove_current")) } } if (state.serverUrls.isNotEmpty()) { HorizontalDivider() - Text(t("settings.saved_servers"), style = MaterialTheme.typography.labelLarge) + Text(language("settings.saved_servers"), style = MaterialTheme.typography.labelLarge) LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { items(state.serverUrls) { url -> AssistChip( @@ -680,16 +680,16 @@ private fun SettingsTab( modifier = settingsCardContentModifier, verticalArrangement = settingsCardContentSpacing ) { - Text(t("settings.identity"), style = MaterialTheme.typography.titleMedium) + Text(language("settings.identity"), style = MaterialTheme.typography.titleMedium) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button(onClick = onRevealPublicKey, enabled = !state.loadingPublicKey) { - Text(if (state.loadingPublicKey) "..." else t("settings.reveal_key")) + Text(if (state.loadingPublicKey) "..." else language("settings.reveal_key")) } OutlinedButton( onClick = onCopyPublicKey, enabled = state.myPublicKey.isNotBlank() ) { - Text(t("settings.copy_key")) + Text(language("settings.copy_key")) } } OutlinedTextField( @@ -697,7 +697,7 @@ private fun SettingsTab( onValueChange = {}, modifier = Modifier.fillMaxWidth(), readOnly = true, - label = { Text(t("settings.my_key")) }, + label = { Text(language("settings.my_key")) }, maxLines = 4 ) } @@ -710,7 +710,7 @@ private fun SettingsTab( modifier = settingsCardContentModifier, verticalArrangement = settingsCardContentSpacing ) { - Text(t("settings.language"), style = MaterialTheme.typography.titleMedium) + Text(language("settings.language"), style = MaterialTheme.typography.titleMedium) LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { items(LanguageManager.supportedLanguages) { lang -> FilterChip( @@ -737,7 +737,7 @@ private fun SettingsTab( modifier = settingsCardContentModifier, verticalArrangement = settingsCardContentSpacing ) { - Text(t("settings.theme"), style = MaterialTheme.typography.titleMedium) + Text(language("settings.theme"), style = MaterialTheme.typography.titleMedium) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { Row( verticalAlignment = Alignment.CenterVertically, @@ -747,18 +747,18 @@ private fun SettingsTab( checked = state.useDynamicColor, onCheckedChange = onUseDynamicColorChange ) - Text(t("settings.dynamic_color")) + Text(language("settings.dynamic_color")) } } if (!state.useDynamicColor || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { - Text(t("settings.preset_themes"), style = MaterialTheme.typography.labelLarge) + Text(language("settings.preset_themes"), style = MaterialTheme.typography.labelLarge) LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { items(themeOptions) { option -> val themeName = when (option.id) { - "blue" -> t("theme.blue") - "gray" -> t("theme.gray") - "green" -> t("theme.green") - "red" -> t("theme.red") + "blue" -> language("theme.blue") + "gray" -> language("theme.gray") + "green" -> language("theme.green") + "red" -> language("theme.red") else -> option.name } FilterChip( @@ -786,16 +786,16 @@ private fun SettingsTab( modifier = settingsCardContentModifier, verticalArrangement = settingsCardContentSpacing ) { - Text(t("settings.diagnostics"), style = MaterialTheme.typography.titleMedium) - Text("${t("settings.status_hint")}:${state.statusHint}") - Text("${t("settings.current_status")}:${state.statusText}") - Text("${t("settings.cert_fingerprint")}:${state.certFingerprint.ifBlank { "N/A" }}") + Text(language("settings.diagnostics"), style = MaterialTheme.typography.titleMedium) + Text("${language("settings.status_hint")}:${state.statusHint}") + Text("${language("settings.current_status")}:${state.statusText}") + Text("${language("settings.cert_fingerprint")}:${state.certFingerprint.ifBlank { "N/A" }}") Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Switch(checked = state.showSystemMessages, onCheckedChange = onToggleShowSystem) - Text(t("settings.show_system")) + Text(language("settings.show_system")) } } } 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 a9750ee..9bdec04 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 @@ -48,6 +48,7 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.encodeToJsonElement +import com.onlinemsg.client.util.LanguageManager /** * 单例管理类,负责整个聊天会话的生命周期、网络连接、消息收发、状态维护和持久化。 @@ -98,10 +99,11 @@ object ChatSessionManager { private val socketListener = object : OnlineMsgSocketClient.Listener { override fun onOpen() { scope.launch { + val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.HANDSHAKING, - statusHint = "已连接,正在准备聊天..." + statusHint = LanguageManager.getString("status_hint.handshaking", lang) ) } addSystemMessage("连接已建立") @@ -122,7 +124,8 @@ object ChatSessionManager { override fun onBinaryMessage(payload: ByteArray) { scope.launch { if (_uiState.value.status == ConnectionStatus.HANDSHAKING) { - _uiState.update { it.copy(statusHint = "收到二进制握手帧,正在尝试解析...") } + val lang = _uiState.value.language + _uiState.update { it.copy(statusHint = LanguageManager.getString("status_hint.received_binary_handshake", lang)) } } val utf8 = runCatching { String(payload, StandardCharsets.UTF_8) }.getOrNull().orEmpty() @@ -152,10 +155,11 @@ object ChatSessionManager { if (manualClose) return@launch val message = throwable.message?.takeIf { it.isNotBlank() } ?: "unknown" addSystemMessage("连接异常:$message") + val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "连接异常,正在重试" + statusHint = LanguageManager.getString("status_hint.connection_error_retrying", lang) ) } scheduleReconnect("连接异常") @@ -193,7 +197,8 @@ object ChatSessionManager { themeId = pref.themeId, useDynamicColor = pref.useDynamicColor, language = pref.language, - messages = historyMessages + messages = historyMessages, + statusHint = LanguageManager.getString("status_hint.click_to_connect", pref.language) ) } // 如果上次会话启用了自动重连,则自动恢复连接 @@ -322,11 +327,12 @@ object ChatSessionManager { } val nextUrls = ServerUrlFormatter.append(_uiState.value.serverUrls, normalized) + val lang = _uiState.value.language _uiState.update { it.copy( serverUrl = normalized, serverUrls = nextUrls, - statusHint = "服务器地址已保存" + statusHint = LanguageManager.getString("status_hint.server_saved", lang) ) } @@ -351,11 +357,16 @@ object ChatSessionManager { filtered } + val lang = _uiState.value.language _uiState.update { it.copy( serverUrls = nextUrls, serverUrl = nextUrls.first(), - statusHint = if (filtered.isEmpty()) "已恢复默认服务器地址" else "已移除当前服务器地址" + statusHint = if (filtered.isEmpty()) { + LanguageManager.getString("status_hint.server_removed_default", lang) + } else { + LanguageManager.getString("status_hint.server_removed", lang) + } ) } @@ -405,10 +416,11 @@ object ChatSessionManager { val normalized = ServerUrlFormatter.normalize(state.serverUrl) if (normalized.isBlank()) { + val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "请填写有效服务器地址" + statusHint = LanguageManager.getString("status_hint.invalid_server_url", lang) ) } return @@ -424,10 +436,11 @@ object ChatSessionManager { cancelHelloTimeout() cancelAuthTimeout() + val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.CONNECTING, - statusHint = "正在连接服务器...", + statusHint = LanguageManager.getString("status_hint.connecting", lang), serverUrl = normalized, certFingerprint = "" ) @@ -456,10 +469,11 @@ object ChatSessionManager { cancelHelloTimeout() cancelAuthTimeout() socketClient.close(1000, "manual_close") + val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.IDLE, - statusHint = "连接已关闭" + statusHint = LanguageManager.getString("status_hint.disconnected", lang) ) } autoReconnectTriggered = false @@ -487,7 +501,8 @@ object ChatSessionManager { val key = if (_uiState.value.directMode) _uiState.value.targetKey.trim() else "" if (_uiState.value.directMode && key.isBlank()) { - _uiState.update { it.copy(statusHint = "请先填写目标公钥,再发送私聊消息") } + val lang = _uiState.value.language + _uiState.update { it.copy(statusHint = LanguageManager.getString("status_hint.target_key_required", lang)) } return@launch } @@ -563,7 +578,8 @@ object ChatSessionManager { */ private suspend fun handleIncomingMessage(rawText: String) { if (_uiState.value.status == ConnectionStatus.HANDSHAKING) { - _uiState.update { it.copy(statusHint = "已收到握手数据,正在解析...") } + val lang = _uiState.value.language + _uiState.update { it.copy(statusHint = LanguageManager.getString("status_hint.hello_received", lang)) } } val normalizedText = extractJsonCandidate(rawText) @@ -595,10 +611,11 @@ object ChatSessionManager { runCatching { json.decodeFromJsonElement(it) }.getOrNull() } if (hello == null || hello.publicKey.isBlank() || hello.authChallenge.isBlank()) { + val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "握手失败:服务端响应不完整" + statusHint = LanguageManager.getString("status_hint.handshake_failed_incomplete", lang) ) } return @@ -609,14 +626,13 @@ object ChatSessionManager { // 握手阶段收到非预期消息则报错 if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain != null) { - _uiState.update { it.copy(statusHint = "握手失败:收到非预期消息") } + val lang = _uiState.value.language + _uiState.update { it.copy(statusHint = LanguageManager.getString("status_hint.handshake_failed_unexpected", lang)) } addSystemMessage("握手阶段收到非预期消息类型:${plain.type}") } else if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain == null) { - val preview = rawText - .replace("\n", " ") - .replace("\r", " ") - .take(80) - _uiState.update { it.copy(statusHint = "握手失败:首包解析失败") } + val lang = _uiState.value.language + val preview = rawText.replace("\n", " ").replace("\r", " ").take(80) + _uiState.update { it.copy(statusHint = LanguageManager.getString("status_hint.handshake_failed_parse", lang)) } addSystemMessage("握手包解析失败:$preview") } @@ -645,10 +661,11 @@ object ChatSessionManager { private suspend fun handleServerHello(hello: HelloDataDto) { cancelHelloTimeout() serverPublicKey = hello.publicKey + val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.AUTHENTICATING, - statusHint = "正在完成身份验证...", + statusHint = LanguageManager.getString("status_hint.authenticating", lang), certFingerprint = hello.certFingerprintSha256.orEmpty() ) } @@ -657,10 +674,11 @@ object ChatSessionManager { authTimeoutJob = scope.launch { delay(AUTH_TIMEOUT_MS) if (_uiState.value.status == ConnectionStatus.AUTHENTICATING) { + val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "连接超时,请重试" + statusHint = LanguageManager.getString("status_hint.auth_timeout", lang) ) } addSystemMessage("认证超时,请检查网络后重试") @@ -674,10 +692,11 @@ object ChatSessionManager { addSystemMessage("已发送认证请求") }.onFailure { error -> cancelAuthTimeout() + val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "认证失败" + statusHint = LanguageManager.getString("status_hint.auth_failed", lang) ) } addSystemMessage("认证发送失败:${error.message ?: "unknown"}") @@ -742,10 +761,11 @@ object ChatSessionManager { cancelAuthTimeout() cancelReconnect() reconnectAttempt = 0 + val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.READY, - statusHint = "已连接,可以开始聊天" + statusHint = LanguageManager.getString("status_hint.ready", lang) ) } addSystemMessage("连接准备完成") @@ -804,10 +824,11 @@ object ChatSessionManager { if (fallbackUrl.isNotBlank()) { fallbackTried = true connectedUrl = fallbackUrl + val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.CONNECTING, - statusHint = "正在自动重试连接...", + statusHint = LanguageManager.getString("status_hint.reconnecting", lang), serverUrl = fallbackUrl ) } @@ -817,14 +838,16 @@ object ChatSessionManager { } } + val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "连接已中断,正在重试" + statusHint = LanguageManager.getString("status_hint.connection_interrupted_retrying", lang) ) } addSystemMessage("连接关闭 ($code):${reason.ifBlank { "连接中断" }}") scheduleReconnect("连接已中断") + } /** @@ -935,10 +958,16 @@ object ChatSessionManager { val exponential = 1 shl minOf(reconnectAttempt - 1, 5) val delaySeconds = minOf(MAX_RECONNECT_DELAY_SECONDS, exponential) addSystemMessage("$reason,${delaySeconds}s 后自动重连(第 $reconnectAttempt 次)") + val lang = _uiState.value.language + val hint = String.format( + LanguageManager.getString("status_hint.reconnect_countdown", lang), + delaySeconds, + reconnectAttempt + ) _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "${delaySeconds}s 后自动重连(第 $reconnectAttempt 次)" + statusHint = hint ) } @@ -950,10 +979,11 @@ object ChatSessionManager { ServerUrlFormatter.normalize(_uiState.value.serverUrl) } if (target.isBlank()) { + val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "重连失败:服务器地址无效" + statusHint = LanguageManager.getString("status_hint.reconnect_failed_invalid_url", lang) ) } return@launch @@ -1015,10 +1045,11 @@ object ChatSessionManager { delay(HELLO_TIMEOUT_MS) if (_uiState.value.status == ConnectionStatus.HANDSHAKING) { val currentUrl = connectedUrl.ifBlank { "unknown" } + val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "握手超时,请检查地址路径与反向代理" + statusHint = LanguageManager.getString("status_hint.hello_timeout", lang) ) } addSystemMessage("握手超时:未收到服务端 publickey 首包(当前地址:$currentUrl)") 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 c7cc58b..e0438ac 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 @@ -1,6 +1,9 @@ package com.onlinemsg.client.ui +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import java.util.UUID +import com.onlinemsg.client.util.LanguageManager /** * 连接状态枚举。 @@ -109,17 +112,18 @@ data class ChatUiState( val canSend: Boolean get() = status == ConnectionStatus.READY && draft.trim().isNotEmpty() && !sending + /** * 连接状态的简短文本描述。 */ val statusText: String get() = when (status) { - ConnectionStatus.IDLE -> "未连接" + ConnectionStatus.IDLE -> LanguageManager.getString("status.idle", language) ConnectionStatus.CONNECTING, ConnectionStatus.HANDSHAKING, - ConnectionStatus.AUTHENTICATING -> "连接中" - ConnectionStatus.READY -> "已连接" - ConnectionStatus.ERROR -> "异常断开" + ConnectionStatus.AUTHENTICATING -> LanguageManager.getString("status.connecting", language) + ConnectionStatus.READY -> LanguageManager.getString("status.ready", language) + ConnectionStatus.ERROR -> LanguageManager.getString("status.error", language) } /** 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 355c591..3ebc08b 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 @@ -12,6 +12,7 @@ object LanguageManager { "tab.settings" to "设置", "settings.personal" to "个人设置", "settings.display_name" to "显示名称", + "settings.chat_data" to "聊天数据", "settings.server" to "服务器", "settings.server_url" to "服务器地址", "settings.save_server" to "保存地址", @@ -29,8 +30,6 @@ object LanguageManager { "settings.current_status" to "当前状态", "settings.cert_fingerprint" to "证书指纹", "settings.show_system" to "显示系统消息", - "settings.connect" to "连接", - "settings.disconnect" to "断开", "settings.clear_msg" to "清空消息", "settings.dynamic_color" to "使用动态颜色", "chat.broadcast" to "广播", @@ -46,13 +45,44 @@ object LanguageManager { "theme.gray" to "商务灰", "theme.green" to "翠绿", "theme.red" to "绯红", - "theme.warm" to "温暖" + "theme.warm" to "温暖", + "top_bar.link" to "已连接", + "top_bar.dislink" to "未连接", + "top_bar.link_start" to "连接中", + "top_bar.error_dislink" to "异常断开", + + "status_hint.ready" to "已连接,可以开始聊天", + "status_hint.click_to_connect" to "点击连接开始聊天", + "status_hint.handshaking" to "已连接,正在准备聊天...", + "status_hint.received_binary_handshake" to "收到二进制握手帧,正在尝试解析...", + "status_hint.connection_error_retrying" to "连接异常,正在重试", + "status_hint.invalid_server_url" to "请填写有效服务器地址", + "status_hint.connecting" to "正在连接服务器...", + "status_hint.disconnected" to "连接已关闭", + "status_hint.server_saved" to "服务器地址已保存", + "status_hint.server_removed_default" to "已恢复默认服务器地址", + "status_hint.server_removed" to "已移除当前服务器地址", + "status_hint.target_key_required" to "请先填写目标公钥,再发送私聊消息", + "status_hint.hello_received" to "已收到握手数据,正在解析...", + "status_hint.handshake_failed_incomplete" to "握手失败:服务端响应不完整", + "status_hint.handshake_failed_unexpected" to "握手失败:收到非预期消息", + "status_hint.handshake_failed_parse" to "握手失败:首包解析失败", + "status_hint.authenticating" to "正在完成身份验证...", + "status_hint.auth_timeout" to "连接超时,请重试", + "status_hint.auth_failed" to "认证失败", + "status_hint.ready" to "已连接,可以开始聊天", + "status_hint.reconnecting" to "正在自动重试连接...", + "status_hint.connection_interrupted_retrying" to "连接已中断,正在重试", + "status_hint.reconnect_countdown" to "%d秒后自动重连(第 %d 次)", + "status_hint.reconnect_failed_invalid_url" to "重连失败:服务器地址无效", + "status_hint.hello_timeout" to "握手超时,请检查地址路径与反向代理" ), "en" to mapOf( "tab.chat" to "Chat", "tab.settings" to "Settings", "settings.personal" to "Personal", "settings.display_name" to "Display Name", + "settings.chat_data" to "Chat Data", "settings.server" to "Server", "settings.server_url" to "Server Address", "settings.save_server" to "Save", @@ -70,8 +100,6 @@ object LanguageManager { "settings.current_status" to "Status", "settings.cert_fingerprint" to "Fingerprint", "settings.show_system" to "Show System Messages", - "settings.connect" to "Link", - "settings.disconnect" to "Dislink", "settings.clear_msg" to "ClearMsg", "settings.dynamic_color" to "Use dynamic color", "chat.broadcast" to "Broadcast", @@ -87,13 +115,44 @@ object LanguageManager { "theme.gray" to "Business Gray", "theme.green" to "Green", "theme.red" to "Red", - "theme.warm" to "Warm" + "theme.warm" to "Warm", + "status.idle" to "Idle", + "status.connecting" to "Connecting", + "status.ready" to "Connected", + "status.error" to "Error", + + "status_hint.ready" to "Ready, you can start chatting", + "status_hint.click_to_connect" to "Click connect to start chatting", + "status_hint.handshaking" to "Connected, preparing chat...", + "status_hint.received_binary_handshake" to "Received binary handshake frame, parsing...", + "status_hint.connection_error_retrying" to "Connection error, retrying", + "status_hint.invalid_server_url" to "Please enter a valid server address", + "status_hint.connecting" to "Connecting to server...", + "status_hint.disconnected" to "Connection closed", + "status_hint.server_saved" to "Server address saved", + "status_hint.server_removed_default" to "Restored default server address", + "status_hint.server_removed" to "Removed current server address", + "status_hint.target_key_required" to "Please enter target public key first", + "status_hint.hello_received" to "Handshake data received, parsing...", + "status_hint.handshake_failed_incomplete" to "Handshake failed: incomplete server response", + "status_hint.handshake_failed_unexpected" to "Handshake failed: unexpected message", + "status_hint.handshake_failed_parse" to "Handshake failed: first packet parse error", + "status_hint.authenticating" to "Authenticating...", + "status_hint.auth_timeout" to "Connection timeout, please retry", + "status_hint.auth_failed" to "Authentication failed", + "status_hint.ready" to "Connected, ready to chat", + "status_hint.reconnecting" to "Reconnecting...", + "status_hint.connection_interrupted_retrying" to "Connection interrupted, retrying", + "status_hint.reconnect_countdown" to "Reconnecting in %d seconds (attempt %d)", + "status_hint.reconnect_failed_invalid_url" to "Reconnect failed: invalid server address", + "status_hint.hello_timeout" to "Handshake timeout, check server path and reverse proxy" ), "ja" to mapOf( "tab.chat" to "チャット", "tab.settings" to "設定", "settings.personal" to "個人設定", "settings.display_name" to "表示名", + "settings.chat_data" to "チャットデータ", "settings.server" to "サーバー", "settings.server_url" to "アドレス", "settings.save_server" to "保存", @@ -111,8 +170,6 @@ object LanguageManager { "settings.current_status" to "ステータス", "settings.cert_fingerprint" to "証明書指紋", "settings.show_system" to "システムメッセージを表示", - "settings.connect" to "接続", - "settings.disconnect" to "切断", "settings.clear_msg" to "履歴を消去", "settings.dynamic_color" to "動的カラーを使用", "chat.broadcast" to "全体", @@ -128,13 +185,44 @@ object LanguageManager { "theme.gray" to "ビジネスグレー", "theme.green" to "グリーン", "theme.red" to "レッド", - "theme.warm" to "ウォーム" + "theme.warm" to "ウォーム", + "status.idle" to "未接続", + "status.connecting" to "接続中", + "status.ready" to "接続済み", + "status.error" to "エラー", + + "status_hint.ready" to "接続済み、チャットを開始できます", + "status_hint.click_to_connect" to "接続してチャットを開始", + "status_hint.handshaking" to "接続しました、準備中...", + "status_hint.received_binary_handshake" to "バイナリハンドシェイクを受信、解析中...", + "status_hint.connection_error_retrying" to "接続エラー、再試行中", + "status_hint.invalid_server_url" to "有効なサーバーアドレスを入力してください", + "status_hint.connecting" to "サーバーに接続中...", + "status_hint.disconnected" to "接続が切断されました", + "status_hint.server_saved" to "サーバーアドレスを保存しました", + "status_hint.server_removed_default" to "デフォルトサーバーに戻しました", + "status_hint.server_removed" to "現在のサーバーを削除しました", + "status_hint.target_key_required" to "相手の公開鍵を入力してください", + "status_hint.hello_received" to "ハンドシェイクデータを受信、解析中...", + "status_hint.handshake_failed_incomplete" to "ハンドシェイク失敗:サーバー応答が不完全", + "status_hint.handshake_failed_unexpected" to "ハンドシェイク失敗:予期しないメッセージ", + "status_hint.handshake_failed_parse" to "ハンドシェイク失敗:最初のパケット解析エラー", + "status_hint.authenticating" to "認証中...", + "status_hint.auth_timeout" to "接続タイムアウト、再試行してください", + "status_hint.auth_failed" to "認証に失敗しました", + "status_hint.ready" to "接続完了、チャットを開始できます", + "status_hint.reconnecting" to "自動再接続中...", + "status_hint.connection_interrupted_retrying" to "接続が切断されました、再試行中", + "status_hint.reconnect_countdown" to "%d秒後に再接続(%d回目)", + "status_hint.reconnect_failed_invalid_url" to "再接続失敗:サーバーアドレスが無効", + "status_hint.hello_timeout" to "ハンドシェイクタイムアウト、サーバーパスを確認してください" ), "ko" to mapOf( "tab.chat" to "채팅", "tab.settings" to "설정", "settings.personal" to "개인 설정", "settings.display_name" to "표시 이름", + "settings.chat_data" to "채팅 데이터", "settings.server" to "서버", "settings.server_url" to "서버 주소", "settings.save_server" to "주소 저장", @@ -152,8 +240,6 @@ object LanguageManager { "settings.current_status" to "현재 상태", "settings.cert_fingerprint" to "인증서 지문", "settings.show_system" to "시스템 메시지 표시", - "settings.connect" to "연결", - "settings.disconnect" to "연결 끊기", "settings.clear_msg" to "정보 삭제", "settings.dynamic_color" to "동적 색상 사용", "chat.broadcast" to "브로드캐스트", @@ -169,7 +255,37 @@ object LanguageManager { "theme.gray" to "비즈니스 그레이", "theme.green" to "초록", "theme.red" to "빨강", - "theme.warm" to "따뜻함" + "theme.warm" to "따뜻함", + "status.idle" to "연결 안 됨", + "status.connecting" to "연결 중", + "status.ready" to "연결됨", + "status.error" to "오류", + + "status_hint.ready" to "연결됨, 채팅을 시작할 수 있습니다", + "status_hint.click_to_connect" to "연결하여 채팅 시작", + "status_hint.handshaking" to "연결됨, 채팅 준비 중...", + "status_hint.received_binary_handshake" to "바이너리 핸드셰이크 수신, 분석 중...", + "status_hint.connection_error_retrying" to "연결 오류, 재시도 중", + "status_hint.invalid_server_url" to "유효한 서버 주소를 입력하세요", + "status_hint.connecting" to "서버에 연결 중...", + "status_hint.disconnected" to "연결이 종료됨", + "status_hint.server_saved" to "서버 주소가 저장됨", + "status_hint.server_removed_default" to "기본 서버 주소로 복원됨", + "status_hint.server_removed" to "현재 서버 주소가 제거됨", + "status_hint.target_key_required" to "대상 공개키를 먼저 입력하세요", + "status_hint.hello_received" to "핸드셰이크 데이터 수신, 분석 중...", + "status_hint.handshake_failed_incomplete" to "핸드셰이크 실패: 서버 응답 불완전", + "status_hint.handshake_failed_unexpected" to "핸드셰이크 실패: 예상치 못한 메시지", + "status_hint.handshake_failed_parse" to "핸드셰이크 실패: 첫 패킷 구문 분석 오류", + "status_hint.authenticating" to "인증 중...", + "status_hint.auth_timeout" to "연결 시간 초과, 다시 시도하세요", + "status_hint.auth_failed" to "인증 실패", + "status_hint.ready" to "연결됨, 채팅 가능", + "status_hint.reconnecting" to "자동 재연결 중...", + "status_hint.connection_interrupted_retrying" to "연결이 끊어짐, 재시도 중", + "status_hint.reconnect_countdown" to "%d초 후 자동 재연결 (시도 %d회)", + "status_hint.reconnect_failed_invalid_url" to "재연결 실패: 서버 주소가 유효하지 않음", + "status_hint.hello_timeout" to "핸드셰이크 시간 초과, 서버 경로와 리버스 프록시를 확인하세요" ) ) diff --git a/android-client/build.gradle.kts b/android-client/build.gradle.kts index 8c29eb8..916c95d 100644 --- a/android-client/build.gradle.kts +++ b/android-client/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("com.android.application") version "8.5.2" apply false + id("com.android.application") version "8.13.2" apply false id("org.jetbrains.kotlin.android") version "1.9.24" apply false id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false id("com.google.devtools.ksp") version "1.9.24-1.0.20" apply false diff --git a/android-client/gradle/wrapper/gradle-wrapper.properties b/android-client/gradle/wrapper/gradle-wrapper.properties index b82aa23..37f853b 100644 --- a/android-client/gradle/wrapper/gradle-wrapper.properties +++ b/android-client/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 8fd031d9fcb8ca2ba72b10d68771f4b4f3243876 Mon Sep 17 00:00:00 2001 From: alimu Date: Mon, 9 Mar 2026 22:41:31 +0400 Subject: [PATCH 03/16] feat(android): implement voice message support and polish chat UX --- .../app/src/main/AndroidManifest.xml | 1 + .../java/com/onlinemsg/client/MainActivity.kt | 15 + .../client/data/local/ChatDatabase.kt | 16 +- .../data/local/ChatHistoryRepository.kt | 13 +- .../client/data/local/ChatMessageEntity.kt | 5 +- .../client/data/protocol/ProtocolModels.kt | 21 + .../client/service/ChatForegroundService.kt | 21 +- .../com/onlinemsg/client/ui/ChatScreen.kt | 685 ++++++++++++++++-- .../onlinemsg/client/ui/ChatSessionManager.kt | 594 ++++++++++++--- .../com/onlinemsg/client/ui/ChatUiState.kt | 15 +- .../com/onlinemsg/client/ui/ChatViewModel.kt | 4 +- .../onlinemsg/client/util/AudioRecorder.kt | 89 +++ .../onlinemsg/client/util/LanguageManager.kt | 432 +++++++++++ 13 files changed, 1736 insertions(+), 175 deletions(-) create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/util/AudioRecorder.kt diff --git a/android-client/app/src/main/AndroidManifest.xml b/android-client/app/src/main/AndroidManifest.xml index 6c539c7..0ff0604 100644 --- a/android-client/app/src/main/AndroidManifest.xml +++ b/android-client/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + + ) + .addMigrations(MIGRATION_1_2) + .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 e0f0a61..0a28a70 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 @@ -1,6 +1,7 @@ package com.onlinemsg.client.data.local import com.onlinemsg.client.ui.MessageChannel +import com.onlinemsg.client.ui.MessageContentType import com.onlinemsg.client.ui.MessageRole import com.onlinemsg.client.ui.UiMessage @@ -31,13 +32,18 @@ private fun UiMessage.toEntity(): ChatMessageEntity { subtitle = subtitle, content = content, channel = channel.name, - timestampMillis = timestampMillis + timestampMillis = timestampMillis, + contentType = contentType.name, + audioBase64 = audioBase64, + audioDurationMillis = audioDurationMillis ) } private fun ChatMessageEntity.toUiMessageOrNull(): UiMessage? { val parsedRole = runCatching { MessageRole.valueOf(role) }.getOrNull() ?: return null val parsedChannel = runCatching { MessageChannel.valueOf(channel) }.getOrNull() ?: return null + val parsedContentType = runCatching { MessageContentType.valueOf(contentType) }.getOrNull() + ?: MessageContentType.TEXT return UiMessage( id = id, role = parsedRole, @@ -45,6 +51,9 @@ private fun ChatMessageEntity.toUiMessageOrNull(): UiMessage? { subtitle = subtitle, content = content, channel = parsedChannel, - timestampMillis = timestampMillis + timestampMillis = timestampMillis, + contentType = parsedContentType, + audioBase64 = audioBase64, + audioDurationMillis = audioDurationMillis ) } 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 e0e6dde..42eaef1 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 @@ -11,5 +11,8 @@ data class ChatMessageEntity( val subtitle: String, val content: String, val channel: String, - val timestampMillis: Long + val timestampMillis: Long, + val contentType: String, + val audioBase64: String, + val audioDurationMillis: Long ) diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/protocol/ProtocolModels.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/protocol/ProtocolModels.kt index deb69d1..e872053 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/data/protocol/ProtocolModels.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/protocol/ProtocolModels.kt @@ -39,6 +39,27 @@ data class SignedPayloadDto( @SerialName("signature") val signature: String ) +@Serializable +data class AudioPayloadDto( + @SerialName("version") val version: Int = 1, + @SerialName("encoding") val encoding: String = "base64", + @SerialName("mimeType") val mimeType: String = "audio/mp4", + @SerialName("durationMillis") val durationMillis: Long, + @SerialName("data") val data: String +) + +@Serializable +data class AudioChunkPayloadDto( + @SerialName("version") val version: Int = 1, + @SerialName("encoding") val encoding: String = "base64", + @SerialName("mimeType") val mimeType: String = "audio/mp4", + @SerialName("messageId") val messageId: String, + @SerialName("index") val index: Int, + @SerialName("total") val total: Int, + @SerialName("durationMillis") val durationMillis: Long, + @SerialName("data") val data: String +) + fun JsonElement?.asPayloadText(): String { if (this == null || this is JsonNull) return "" return if (this is JsonPrimitive && this.isString) { diff --git a/android-client/app/src/main/java/com/onlinemsg/client/service/ChatForegroundService.kt b/android-client/app/src/main/java/com/onlinemsg/client/service/ChatForegroundService.kt index afb0369..9d84725 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/service/ChatForegroundService.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/service/ChatForegroundService.kt @@ -16,6 +16,7 @@ import androidx.core.app.NotificationManagerCompat import com.onlinemsg.client.MainActivity import com.onlinemsg.client.ui.ChatSessionManager import com.onlinemsg.client.ui.ConnectionStatus +import com.onlinemsg.client.util.LanguageManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -29,6 +30,10 @@ class ChatForegroundService : Service() { private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private var statusJob: Job? = null + private fun t(key: String): String { + return LanguageManager.getString(key, ChatSessionManager.uiState.value.language) + } + override fun onCreate() { super.onCreate() ChatSessionManager.initialize(application) @@ -113,22 +118,22 @@ class ChatForegroundService : Service() { ) val title = when (status) { - ConnectionStatus.READY -> "OnlineMsg 已保持连接" + ConnectionStatus.READY -> t("service.foreground.title.ready") ConnectionStatus.CONNECTING, ConnectionStatus.HANDSHAKING, - ConnectionStatus.AUTHENTICATING -> "OnlineMsg 正在连接" - ConnectionStatus.ERROR -> "OnlineMsg 连接异常" - ConnectionStatus.IDLE -> "OnlineMsg 后台服务" + ConnectionStatus.AUTHENTICATING -> t("service.foreground.title.connecting") + ConnectionStatus.ERROR -> t("service.foreground.title.error") + ConnectionStatus.IDLE -> t("service.foreground.title.idle") } return NotificationCompat.Builder(this, FOREGROUND_CHANNEL_ID) .setSmallIcon(android.R.drawable.stat_notify_sync) .setContentTitle(title) - .setContentText(hint.ifBlank { "后台保持连接中" }) + .setContentText(hint.ifBlank { t("service.foreground.hint.default") }) .setOngoing(true) .setOnlyAlertOnce(true) .setContentIntent(openAppPendingIntent) - .addAction(0, "断开", stopPendingIntent) + .addAction(0, t("service.foreground.action.disconnect"), stopPendingIntent) .setPriority(NotificationCompat.PRIORITY_LOW) .build() } @@ -138,10 +143,10 @@ class ChatForegroundService : Service() { val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val channel = NotificationChannel( FOREGROUND_CHANNEL_ID, - "OnlineMsg 后台连接", + t("service.foreground.channel.name"), NotificationManager.IMPORTANCE_LOW ).apply { - description = "保持 WebSocket 后台长连接" + description = t("service.foreground.channel.desc") setShowBadge(false) } manager.createNotificationChannel(channel) 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 aac72a4..64881b7 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,7 +1,19 @@ package com.onlinemsg.client.ui -import android.annotation.SuppressLint +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.media.MediaPlayer +import android.os.Build +import android.util.Base64 +import android.view.MotionEvent +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,8 +43,12 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Send import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.Forum +import androidx.compose.material.icons.rounded.Keyboard import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Language +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Stop +import androidx.compose.material.icons.rounded.KeyboardVoice import androidx.compose.material3.AssistChip import androidx.compose.material3.Button import androidx.compose.material3.Card @@ -55,6 +71,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -62,23 +79,30 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.input.pointer.pointerInteropFilter import androidx.compose.ui.text.AnnotatedString 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.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.compose.foundation.isSystemInDarkTheme -import android.os.Build import com.onlinemsg.client.ui.theme.OnlineMsgTheme import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter +import java.io.File import com.onlinemsg.client.ui.theme.themeOptions +import com.onlinemsg.client.util.AudioRecorder import com.onlinemsg.client.util.LanguageManager +import kotlinx.coroutines.delay /** @@ -89,6 +113,11 @@ private enum class MainTab(val labelKey: String) { SETTINGS("tab.settings") } +private enum class ChatInputMode { + TEXT, + AUDIO +} + /** * 应用程序的根可组合函数。 * 集成 ViewModel、主题、Scaffold 以及选项卡切换逻辑。 @@ -123,7 +152,7 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) { Scaffold( topBar = { AppTopBar( - statusText = state.statusText, + statusText = localizedConnectionStatusText(state.status, state.language), statusColor = when (state.status) { ConnectionStatus.READY -> MaterialTheme.colorScheme.primary ConnectionStatus.ERROR -> MaterialTheme.colorScheme.error @@ -182,6 +211,7 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) { onTargetKeyChange = viewModel::updateTargetKey, onDraftChange = viewModel::updateDraft, onSend = viewModel::sendMessage, + onSendAudio = viewModel::sendAudioMessage, onCopyMessage = { content -> clipboard.setText(AnnotatedString(content)) viewModel.onMessageCopied() @@ -276,6 +306,7 @@ private fun AppTopBar( * 包含模式切换、消息列表和输入区域。 */ @Composable +@OptIn(ExperimentalComposeUiApi::class) private fun ChatTab( modifier: Modifier, state: ChatUiState, @@ -283,12 +314,101 @@ private fun ChatTab( onTargetKeyChange: (String) -> Unit, onDraftChange: (String) -> Unit, onSend: () -> Unit, + onSendAudio: (String, Long) -> Unit, onCopyMessage: (String) -> Unit ) { + val context = LocalContext.current val listState = rememberLazyListState() - + val audioRecorder = remember(context) { AudioRecorder(context) } + val audioPlayer = remember(context) { AudioMessagePlayer(context) } + var inputMode by rememberSaveable { mutableStateOf(ChatInputMode.TEXT) } + var isRecording by remember { mutableStateOf(false) } + var cancelOnRelease by remember { mutableStateOf(false) } + var pressDownRawY by remember { mutableStateOf(0f) } + var audioHint by remember { mutableStateOf("") } + var audioHintVersion by remember { mutableStateOf(0L) } + var playingMessageId by remember { mutableStateOf(null) } + var recordingStartedAtMillis by remember { mutableStateOf(0L) } + var recordingElapsedMillis by remember { mutableStateOf(0L) } + val recordingPulse = rememberInfiniteTransition(label = "recordingPulse") + val recordingPulseScale by recordingPulse.animateFloat( + initialValue = 0.9f, + targetValue = 1.2f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 700), + repeatMode = RepeatMode.Reverse + ), + label = "recordingPulseScale" + ) + val recordingPulseAlpha by recordingPulse.animateFloat( + initialValue = 0.25f, + targetValue = 0.65f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 700), + repeatMode = RepeatMode.Reverse + ), + label = "recordingPulseAlpha" + ) + // 定义翻译函数 t fun t(key: String) = LanguageManager.getString(key, state.language) + fun showAudioHint(message: String) { + audioHint = message + audioHintVersion += 1L + } + val canHoldToRecord = state.status == ConnectionStatus.READY && + !state.sending && + (!state.directMode || state.targetKey.trim().isNotBlank()) + + fun hasRecordPermission(): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO + ) == PackageManager.PERMISSION_GRANTED + } + + fun startRecording() { + if (isRecording) return + if (!canHoldToRecord) return + if (!hasRecordPermission()) { + showAudioHint(t("chat.audio_permission_required")) + return + } + val started = audioRecorder.start() + if (!started) { + showAudioHint(t("chat.audio_record_failed")) + return + } + isRecording = true + cancelOnRelease = false + recordingStartedAtMillis = System.currentTimeMillis() + recordingElapsedMillis = 0L + audioHint = "" + } + + fun finishRecording(send: Boolean) { + if (!isRecording) return + isRecording = false + cancelOnRelease = false + recordingStartedAtMillis = 0L + recordingElapsedMillis = 0L + val recorded = audioRecorder.stopAndEncode(send = send) + when { + !send -> { + showAudioHint(t("chat.audio_canceled")) + } + recorded == null -> { + showAudioHint(t("chat.audio_record_failed")) + } + recorded.durationMillis < MIN_AUDIO_DURATION_MS -> { + showAudioHint(t("chat.audio_too_short")) + } + else -> { + onSendAudio(recorded.base64, recorded.durationMillis) + audioHint = "" + } + } + } // 当消息列表新增消息时,自动滚动到底部 LaunchedEffect(state.visibleMessages.size) { @@ -297,6 +417,32 @@ private fun ChatTab( } } + LaunchedEffect(isRecording, recordingStartedAtMillis) { + if (!isRecording || recordingStartedAtMillis <= 0L) return@LaunchedEffect + while (isRecording) { + recordingElapsedMillis = (System.currentTimeMillis() - recordingStartedAtMillis) + .coerceAtLeast(0L) + delay(100L) + } + } + + LaunchedEffect(audioHintVersion) { + val latest = audioHint + val latestVersion = audioHintVersion + if (latest.isBlank()) return@LaunchedEffect + delay(2200L) + if (audioHintVersion == latestVersion && audioHint == latest) { + audioHint = "" + } + } + + DisposableEffect(Unit) { + onDispose { + audioRecorder.release() + audioPlayer.release() + } + } + Column( modifier = modifier .fillMaxSize() @@ -324,10 +470,20 @@ private fun ChatTab( } Spacer(modifier = Modifier.height(8.dp)) + val statusHintText = if (audioHint.isNotBlank()) { + audioHint + } else { + localizedStatusHintText(state.statusHint, state.language) + } + val statusHintColor = if (audioHint.isNotBlank()) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } Text( - text = state.statusHint, + text = statusHintText, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = statusHintColor ) if (state.directMode) { @@ -373,6 +529,18 @@ private fun ChatTab( MessageItem( message = message, onCopy = { onCopyMessage(message.content) }, + onPlayAudio = { + val nextPlaying = audioPlayer.toggle( + messageId = message.id, + audioBase64 = message.audioBase64 + ) { stoppedId -> + if (playingMessageId == stoppedId) { + playingMessageId = null + } + } + playingMessageId = nextPlaying + }, + isPlaying = playingMessageId == message.id, currentLanguage = state.language ) } @@ -381,31 +549,191 @@ private fun ChatTab( Spacer(modifier = Modifier.height(8.dp)) - // 消息输入区域 - Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - OutlinedTextField( - value = state.draft, - onValueChange = onDraftChange, - modifier = Modifier.weight(1f), - label = { Text(t("chat.input_placeholder")) }, - maxLines = 4, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), - keyboardActions = KeyboardActions( - onSend = { onSend() } + if (inputMode == ChatInputMode.TEXT) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + IconButton( + onClick = { inputMode = ChatInputMode.AUDIO }, + modifier = Modifier + .size(56.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(14.dp) + ) + ) { + Icon( + imageVector = Icons.Rounded.KeyboardVoice, + contentDescription = t("chat.mode_audio"), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + OutlinedTextField( + value = state.draft, + onValueChange = onDraftChange, + modifier = Modifier + .weight(1f) + .height(56.dp), + placeholder = { Text(t("chat.input_placeholder")) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions( + onSend = { onSend() } + ) ) - ) - Button( - onClick = onSend, - enabled = state.canSend, - modifier = Modifier.height(56.dp) + Button( + onClick = onSend, + enabled = state.canSend, + modifier = Modifier.size(56.dp), + contentPadding = PaddingValues(0.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.Send, + contentDescription = t("chat.send") + ) + } + } + } else { + val holdToTalkText = when { + state.sending -> t("chat.sending") + isRecording && cancelOnRelease -> t("chat.audio_release_cancel") + isRecording -> t("chat.audio_release_send") + else -> t("chat.audio_hold_to_talk") + } + val holdToTalkColor = when { + !canHoldToRecord -> MaterialTheme.colorScheme.surfaceVariant + isRecording && cancelOnRelease -> MaterialTheme.colorScheme.errorContainer + isRecording -> MaterialTheme.colorScheme.tertiaryContainer + else -> MaterialTheme.colorScheme.surfaceVariant + } + val holdToTalkTextColor = when { + isRecording && cancelOnRelease -> MaterialTheme.colorScheme.onErrorContainer + isRecording -> MaterialTheme.colorScheme.onTertiaryContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Icon(Icons.AutoMirrored.Rounded.Send, contentDescription = null) - Spacer(Modifier.width(6.dp)) - Text(if (state.sending) "..." else t("chat.send")) + IconButton( + onClick = { + if (isRecording) { + finishRecording(send = false) + } + inputMode = ChatInputMode.TEXT + }, + modifier = Modifier + .size(56.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(14.dp) + ) + ) { + Icon( + imageVector = Icons.Rounded.Keyboard, + contentDescription = t("chat.mode_text"), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Card( + modifier = Modifier + .weight(1f) + .height(56.dp) + .pointerInteropFilter { event -> + if (!canHoldToRecord && event.actionMasked == MotionEvent.ACTION_DOWN) { + return@pointerInteropFilter false + } + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + pressDownRawY = event.rawY + startRecording() + true + } + + MotionEvent.ACTION_MOVE -> { + if (isRecording) { + cancelOnRelease = pressDownRawY - event.rawY > AUDIO_CANCEL_TRIGGER_PX + } + true + } + + MotionEvent.ACTION_UP -> { + finishRecording(send = !cancelOnRelease) + true + } + + MotionEvent.ACTION_CANCEL -> { + finishRecording(send = false) + true + } + + else -> false + } + }, + colors = CardDefaults.cardColors(containerColor = holdToTalkColor), + shape = RoundedCornerShape(12.dp) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (isRecording) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Box( + modifier = Modifier + .size(18.dp) + .graphicsLayer { + scaleX = recordingPulseScale + scaleY = recordingPulseScale + } + .background( + color = holdToTalkTextColor.copy(alpha = recordingPulseAlpha), + shape = RoundedCornerShape(999.dp) + ) + ) + Icon( + imageVector = Icons.Rounded.KeyboardVoice, + contentDescription = null, + tint = holdToTalkTextColor, + modifier = Modifier.size(12.dp) + ) + } + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(1.dp) + ) { + Text( + text = holdToTalkText, + style = MaterialTheme.typography.titleSmall, + color = holdToTalkTextColor + ) + Text( + text = "${t("chat.audio_recording")} ${formatRecordingElapsed(recordingElapsedMillis)}", + style = MaterialTheme.typography.labelSmall, + color = holdToTalkTextColor.copy(alpha = 0.9f) + ) + } + } + } else { + Text( + text = holdToTalkText, + style = MaterialTheme.typography.titleMedium, + color = holdToTalkTextColor + ) + } + } + } } } } @@ -414,11 +742,12 @@ private fun ChatTab( /** * 单个消息气泡组件。 */ -@SuppressLint("UnusedBoxWithConstraintsScope") @Composable private fun MessageItem( message: UiMessage, onCopy: () -> Unit, + onPlayAudio: () -> Unit, + isPlaying: Boolean, currentLanguage: String ) { Box(modifier = Modifier.fillMaxWidth()) { @@ -458,6 +787,10 @@ private fun MessageItem( } } else { val isOutgoing = message.role == MessageRole.OUTGOING + val shouldShowSender = !isOutgoing + val senderDisplayName = message.sender.ifBlank { + LanguageManager.getString("session.sender.anonymous", currentLanguage) + } val bubbleColor = if (isOutgoing) { MaterialTheme.colorScheme.primaryContainer } else { @@ -501,12 +834,16 @@ private fun MessageItem( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp) ) { - if (!isOutgoing) { + if (shouldShowSender) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = message.sender, + text = senderDisplayName, style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary + color = if (isOutgoing) { + bubbleTextColor.copy(alpha = 0.9f) + } else { + MaterialTheme.colorScheme.primary + } ) if (message.subtitle.isNotBlank()) { Spacer(modifier = Modifier.width(8.dp)) @@ -521,34 +858,53 @@ private fun MessageItem( } } - // 消息正文 - Text( - text = message.content, - style = MaterialTheme.typography.bodyMedium, - color = bubbleTextColor - ) + if (message.contentType == MessageContentType.AUDIO && + message.audioBase64.isNotBlank() + ) { + AudioMessageBody( + message = message, + bubbleTextColor = bubbleTextColor, + onPlayAudio = onPlayAudio, + isPlaying = isPlaying, + currentLanguage = currentLanguage + ) + } else { + Text( + text = message.content, + style = MaterialTheme.typography.bodyMedium, + color = bubbleTextColor + ) + } // 时间戳和复制按钮 Row( - modifier = Modifier.fillMaxWidth(), + modifier = if (message.contentType == MessageContentType.AUDIO) { + Modifier.align(Alignment.End) + } else { + Modifier.fillMaxWidth() + }, verticalAlignment = Alignment.CenterVertically ) { - Spacer(modifier = Modifier.weight(1f)) + if (message.contentType == MessageContentType.TEXT) { + Spacer(modifier = Modifier.weight(1f)) + } Text( text = formatTime(message.timestampMillis), style = MaterialTheme.typography.labelSmall, color = bubbleTextColor.copy(alpha = 0.7f) ) - IconButton( - onClick = onCopy, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = Icons.Rounded.ContentCopy, - contentDescription = LanguageManager.getString("common.copied", currentLanguage), - tint = bubbleTextColor.copy(alpha = 0.7f), - modifier = Modifier.size(14.dp) - ) + if (message.contentType == MessageContentType.TEXT) { + IconButton( + onClick = onCopy, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = Icons.Rounded.ContentCopy, + contentDescription = LanguageManager.getString("common.copied", currentLanguage), + tint = bubbleTextColor.copy(alpha = 0.7f), + modifier = Modifier.size(14.dp) + ) + } } } } @@ -559,6 +915,97 @@ private fun MessageItem( } } +@Composable +private fun AudioMessageBody( + message: UiMessage, + bubbleTextColor: Color, + onPlayAudio: () -> Unit, + isPlaying: Boolean, + currentLanguage: String +) { + val actionText = if (isPlaying) { + LanguageManager.getString("chat.audio_stop", currentLanguage) + } else { + LanguageManager.getString("chat.audio_play", currentLanguage) + } + val waveformPulse by rememberInfiniteTransition(label = "audioPlaybackWave").animateFloat( + initialValue = 0.55f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 480), + repeatMode = RepeatMode.Reverse + ), + label = "audioPlaybackWavePulse" + ) + val waveScales = if (isPlaying) { + listOf( + 0.75f + waveformPulse * 0.22f, + 0.92f + waveformPulse * 0.2f, + 0.82f + waveformPulse * 0.28f, + 0.9f + waveformPulse * 0.18f, + 0.7f + waveformPulse * 0.24f + ) + } else { + listOf(0.75f, 0.95f, 0.82f, 0.9f, 0.72f) + } + val baseWaveHeights = listOf(8.dp, 14.dp, 10.dp, 13.dp, 9.dp) + + Row( + modifier = Modifier + .widthIn(min = 140.dp, max = 210.dp) + .background( + color = bubbleTextColor.copy(alpha = 0.12f), + shape = RoundedCornerShape(12.dp) + ) + .clickable(onClick = onPlayAudio) + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier + .size(28.dp) + .background( + color = bubbleTextColor.copy(alpha = 0.16f), + shape = RoundedCornerShape(999.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (isPlaying) Icons.Rounded.Stop else Icons.Rounded.PlayArrow, + contentDescription = actionText, + tint = bubbleTextColor, + modifier = Modifier.size(18.dp) + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp) + ) { + baseWaveHeights.forEachIndexed { index, baseHeight -> + Box( + modifier = Modifier + .width(3.dp) + .height(baseHeight) + .graphicsLayer { + scaleY = waveScales[index] + } + .background( + color = bubbleTextColor.copy(alpha = if (isPlaying) 0.95f else 0.72f), + shape = RoundedCornerShape(999.dp) + ) + ) + } + } + Spacer(modifier = Modifier.weight(1f)) + Text( + text = formatAudioDuration(message.audioDurationMillis), + style = MaterialTheme.typography.labelMedium, + color = bubbleTextColor.copy(alpha = 0.8f) + ) + } +} + /** * 设置选项卡界面,包含个人设置、服务器管理、身份安全、语言、主题和诊断信息。 * @param modifier 修饰符 @@ -632,7 +1079,7 @@ private fun SettingsTab( modifier = settingsCardContentModifier, verticalArrangement = settingsCardContentSpacing ) { - Text("聊天数据", style = MaterialTheme.typography.titleMedium) + Text(t("settings.chat_data"), style = MaterialTheme.typography.titleMedium) OutlinedButton(onClick = onClearMessages) { Text(t("settings.clear_msg")) } @@ -787,8 +1234,8 @@ private fun SettingsTab( verticalArrangement = settingsCardContentSpacing ) { Text(t("settings.diagnostics"), style = MaterialTheme.typography.titleMedium) - Text("${t("settings.status_hint")}:${state.statusHint}") - Text("${t("settings.current_status")}:${state.statusText}") + Text("${t("settings.status_hint")}:${localizedStatusHintText(state.statusHint, state.language)}") + Text("${t("settings.current_status")}:${localizedConnectionStatusText(state.status, state.language)}") Text("${t("settings.cert_fingerprint")}:${state.certFingerprint.ifBlank { "N/A" }}") Row( verticalAlignment = Alignment.CenterVertically, @@ -805,6 +1252,138 @@ private fun SettingsTab( } } +private class AudioMessagePlayer(private val context: Context) { + private var mediaPlayer: MediaPlayer? = null + private var currentMessageId: String? = null + private var currentAudioFile: File? = null + + fun toggle( + messageId: String, + audioBase64: String, + onStopped: (String) -> Unit + ): String? { + if (currentMessageId == messageId) { + stopPlayback()?.let(onStopped) + return null + } + + stopPlayback()?.let(onStopped) + + val bytes = runCatching { + Base64.decode(audioBase64, Base64.DEFAULT) + }.getOrNull() ?: return null + if (bytes.isEmpty()) return null + + val audioFile = runCatching { + File.createTempFile("oms_play_", ".m4a", context.cacheDir).apply { + writeBytes(bytes) + } + }.getOrNull() ?: return null + + val player = MediaPlayer() + val started = runCatching { + player.setDataSource(audioFile.absolutePath) + player.setOnCompletionListener { + stopPlayback()?.let(onStopped) + } + player.setOnErrorListener { _, _, _ -> + stopPlayback()?.let(onStopped) + true + } + player.prepare() + player.start() + true + }.getOrElse { + runCatching { player.release() } + audioFile.delete() + false + } + if (!started) return null + + mediaPlayer = player + currentMessageId = messageId + currentAudioFile = audioFile + return currentMessageId + } + + fun release() { + stopPlayback() + } + + private fun stopPlayback(): String? { + val stoppedId = currentMessageId + runCatching { mediaPlayer?.stop() } + runCatching { mediaPlayer?.release() } + mediaPlayer = null + currentMessageId = null + currentAudioFile?.delete() + currentAudioFile = null + return stoppedId + } +} + +private fun formatAudioDuration(durationMillis: Long): String { + val totalSeconds = (durationMillis / 1000L).coerceAtLeast(0L) + val minutes = totalSeconds / 60L + val seconds = totalSeconds % 60L + return if (minutes > 0L) { + String.format("%d:%02d", minutes, seconds) + } else { + "${seconds}s" + } +} + +private fun formatRecordingElapsed(durationMillis: Long): String { + val clamped = durationMillis.coerceAtLeast(0L) + val seconds = clamped / 1000L + val tenths = (clamped % 1000L) / 100L + return "${seconds}.${tenths}s" +} + +private fun localizedConnectionStatusText(status: ConnectionStatus, language: String): String { + val key = when (status) { + ConnectionStatus.IDLE -> "status.idle" + ConnectionStatus.CONNECTING, + ConnectionStatus.HANDSHAKING, + ConnectionStatus.AUTHENTICATING -> "status.connecting" + ConnectionStatus.READY -> "status.ready" + ConnectionStatus.ERROR -> "status.error" + } + return LanguageManager.getString(key, language) +} + +private fun localizedStatusHintText(raw: String, language: String): String { + val exact = when (raw) { + "点击连接开始聊天" -> "hint.tap_to_connect" + "正在连接服务器..." -> "hint.connecting_server" + "已连接,可以开始聊天" -> "hint.ready_chat" + "连接已关闭" -> "hint.closed" + "连接已中断,正在重试" -> "hint.reconnecting" + "重连失败:服务器地址无效" -> "hint.reconnect_invalid_server" + "请先填写目标公钥,再发送私聊消息" -> "hint.fill_target_key" + else -> null + } + if (exact != null) { + return LanguageManager.getString(exact, language) + } + return when { + raw.startsWith("服务器拒绝连接:") -> { + val suffix = raw.removePrefix("服务器拒绝连接:") + LanguageManager.getString("hint.server_rejected_prefix", language) + suffix + } + + raw.startsWith("语音发送失败:") -> { + val suffix = raw.removePrefix("语音发送失败:") + LanguageManager.getString("hint.audio_send_failed_prefix", language) + suffix + } + + else -> raw + } +} + +private const val AUDIO_CANCEL_TRIGGER_PX = 120f +private const val MIN_AUDIO_DURATION_MS = 350L + /** * 将时间戳格式化为本地时间的小时:分钟(如 "14:30")。 * @param tsMillis 毫秒时间戳 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 a9750ee..5c2c526 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 @@ -20,12 +20,15 @@ import com.onlinemsg.client.data.local.ChatHistoryRepository import com.onlinemsg.client.data.network.OnlineMsgSocketClient import com.onlinemsg.client.data.preferences.ServerUrlFormatter import com.onlinemsg.client.data.preferences.UserPreferencesRepository +import com.onlinemsg.client.data.protocol.AudioPayloadDto +import com.onlinemsg.client.data.protocol.AudioChunkPayloadDto import com.onlinemsg.client.data.protocol.AuthPayloadDto import com.onlinemsg.client.data.protocol.EnvelopeDto import com.onlinemsg.client.data.protocol.HelloDataDto 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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -48,6 +51,7 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.encodeToJsonElement +import java.util.UUID /** * 单例管理类,负责整个聊天会话的生命周期、网络连接、消息收发、状态维护和持久化。 @@ -59,6 +63,15 @@ object ChatSessionManager { ignoreUnknownKeys = true } + private fun t(key: String): String { + return LanguageManager.getString(key, _uiState.value.language) + } + + private fun tf(key: String, vararg args: Any): String { + val pattern = t(key) + return runCatching { String.format(pattern, *args) }.getOrElse { pattern } + } + private lateinit var app: Application private lateinit var preferencesRepository: UserPreferencesRepository private lateinit var cryptoManager: RsaCryptoManager @@ -93,6 +106,7 @@ object ChatSessionManager { @Volatile private var keepAliveRequested = false // 是否应保活(前台服务标志) private var notificationIdSeed = 2000 + private val incomingAudioChunkBuffers = mutableMapOf() // WebSocket 事件监听器 private val socketListener = object : OnlineMsgSocketClient.Listener { @@ -101,10 +115,10 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.HANDSHAKING, - statusHint = "已连接,正在准备聊天..." + statusHint = t("session.hint.connected_preparing") ) } - addSystemMessage("连接已建立") + addSystemMessage(t("session.msg.connection_established")) startHelloTimeout() } } @@ -114,7 +128,12 @@ object ChatSessionManager { runCatching { handleIncomingMessage(text) }.onFailure { error -> - addSystemMessage("文本帧处理异常:${error.message ?: "unknown"}") + addSystemMessage( + tf( + "session.msg.text_frame_error", + error.message ?: t("common.unknown") + ) + ) } } } @@ -122,7 +141,7 @@ object ChatSessionManager { override fun onBinaryMessage(payload: ByteArray) { scope.launch { if (_uiState.value.status == ConnectionStatus.HANDSHAKING) { - _uiState.update { it.copy(statusHint = "收到二进制握手帧,正在尝试解析...") } + _uiState.update { it.copy(statusHint = t("session.hint.binary_handshake_parsing")) } } val utf8 = runCatching { String(payload, StandardCharsets.UTF_8) }.getOrNull().orEmpty() @@ -130,13 +149,20 @@ object ChatSessionManager { runCatching { handleIncomingMessage(utf8) }.onFailure { error -> - addSystemMessage("二进制帧处理异常:${error.message ?: "unknown"}") + addSystemMessage( + tf( + "session.msg.binary_frame_error", + error.message ?: t("common.unknown") + ) + ) } } else if (_uiState.value.status == ConnectionStatus.HANDSHAKING) { val hexPreview = payload.take(24).joinToString(" ") { byte -> "%02x".format(byte) } - addSystemMessage("握手二进制帧无法转为文本,len=${payload.size} hex=$hexPreview") + addSystemMessage( + tf("session.msg.handshake_binary_unreadable", payload.size, hexPreview) + ) } } } @@ -150,15 +176,15 @@ object ChatSessionManager { override fun onFailure(throwable: Throwable) { scope.launch { if (manualClose) return@launch - val message = throwable.message?.takeIf { it.isNotBlank() } ?: "unknown" - addSystemMessage("连接异常:$message") + val message = throwable.message?.takeIf { it.isNotBlank() } ?: t("common.unknown") + addSystemMessage(tf("session.msg.connection_error", message)) _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "连接异常,正在重试" + statusHint = t("session.hint.connection_error_retrying") ) } - scheduleReconnect("连接异常") + scheduleReconnect(t("session.reason.connection_error")) } } } @@ -316,7 +342,7 @@ object ChatSessionManager { val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl) if (normalized.isBlank()) { scope.launch { - _events.emit(UiEvent.ShowSnackbar("请输入有效的服务器地址")) + _events.emit(UiEvent.ShowSnackbar(t("session.snackbar.invalid_server"))) } return } @@ -326,13 +352,13 @@ object ChatSessionManager { it.copy( serverUrl = normalized, serverUrls = nextUrls, - statusHint = "服务器地址已保存" + statusHint = t("session.hint.server_saved") ) } scope.launch { preferencesRepository.saveCurrentServerUrl(normalized) - _events.emit(UiEvent.ShowSnackbar("服务器地址已保存")) + _events.emit(UiEvent.ShowSnackbar(t("session.snackbar.server_saved"))) } } @@ -355,13 +381,17 @@ object ChatSessionManager { it.copy( serverUrls = nextUrls, serverUrl = nextUrls.first(), - statusHint = if (filtered.isEmpty()) "已恢复默认服务器地址" else "已移除当前服务器地址" + 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("已更新服务器地址列表")) + _events.emit(UiEvent.ShowSnackbar(t("session.snackbar.server_list_updated"))) } } @@ -382,7 +412,14 @@ object ChatSessionManager { } }.onFailure { error -> _uiState.update { it.copy(loadingPublicKey = false) } - _events.emit(UiEvent.ShowSnackbar("公钥读取失败:${error.message ?: "unknown"}")) + _events.emit( + UiEvent.ShowSnackbar( + tf( + "session.snackbar.public_key_read_failed", + error.message ?: t("common.unknown") + ) + ) + ) } } } @@ -408,7 +445,7 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "请填写有效服务器地址" + statusHint = t("session.hint.fill_valid_server") ) } return @@ -427,7 +464,7 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.CONNECTING, - statusHint = "正在连接服务器...", + statusHint = t("session.hint.connecting_server"), serverUrl = normalized, certFingerprint = "" ) @@ -442,7 +479,7 @@ object ChatSessionManager { socketClient.connect(normalized, socketListener) if (isAutoRestore) { - addSystemMessage("已恢复上次会话,正在自动连接") + addSystemMessage(t("session.msg.auto_restore_connecting")) } } @@ -459,7 +496,7 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.IDLE, - statusHint = "连接已关闭" + statusHint = t("session.hint.connection_closed") ) } autoReconnectTriggered = false @@ -470,7 +507,7 @@ object ChatSessionManager { if (stopService) { ChatForegroundService.stop(app) } - addSystemMessage("已断开连接") + addSystemMessage(t("session.msg.disconnected")) } /** @@ -480,56 +517,96 @@ object ChatSessionManager { fun sendMessage() { val current = _uiState.value if (!current.canSend) return + val text = current.draft.trim() + if (text.isBlank()) return + val route = resolveOutgoingRoute(current) ?: return scope.launch { - val text = _uiState.value.draft.trim() - if (text.isBlank()) return@launch + _uiState.update { it.copy(sending = true) } - val key = if (_uiState.value.directMode) _uiState.value.targetKey.trim() else "" - if (_uiState.value.directMode && key.isBlank()) { - _uiState.update { it.copy(statusHint = "请先填写目标公钥,再发送私聊消息") } - return@launch + runCatching { + sendSignedPayload(route = route, payloadText = text) + }.onSuccess { + addOutgoingMessage(text, route.subtitle, route.channel) + _uiState.update { it.copy(draft = "", sending = false) } + }.onFailure { error -> + _uiState.update { it.copy(sending = false) } + addSystemMessage( + tf( + "session.msg.send_failed", + error.message ?: t("common.unknown") + ) + ) } + } + } - val type = if (key.isBlank()) "broadcast" else "forward" - val channel = if (key.isBlank()) MessageChannel.BROADCAST else MessageChannel.PRIVATE - val subtitle = if (key.isBlank()) "" else "私聊 ${summarizeKey(key)}" + /** + * 发送语音消息(Base64 音频负载)。 + */ + fun sendAudioMessage(audioBase64: String, durationMillis: Long) { + val current = _uiState.value + if (current.status != ConnectionStatus.READY || current.sending) return + if (audioBase64.isBlank()) return + val route = resolveOutgoingRoute(current) ?: return + scope.launch { _uiState.update { it.copy(sending = true) } - - runCatching { - val id = ensureIdentity() - val timestamp = cryptoManager.unixSecondsNow() - val nonce = cryptoManager.createNonce() - val signingInput = listOf(type, key, text, timestamp.toString(), nonce).joinToString("\n") - val signature = withContext(Dispatchers.Default) { - cryptoManager.signText(id.privateKey, signingInput) + val safeDuration = durationMillis.coerceAtLeast(0L) + val normalized = audioBase64.trim() + val chunks = splitAudioBase64(normalized, AUDIO_CHUNK_BASE64_SIZE) + if (chunks.size > MAX_AUDIO_CHUNK_COUNT) { + _uiState.update { + it.copy( + sending = false, + statusHint = t("session.hint.audio_chunk_over_limit") + ) } + addSystemMessage(t("session.msg.audio_chunk_canceled")) + return@launch + } - val payload = SignedPayloadDto( - payload = text, - timestamp = timestamp, - nonce = nonce, - signature = signature - ) - val envelope = EnvelopeDto( - type = type, - key = key, - data = json.encodeToJsonElement(payload) - ) - - val plain = json.encodeToString(envelope) - val cipher = withContext(Dispatchers.Default) { - cryptoManager.encryptChunked(serverPublicKey, plain) + runCatching { + if (chunks.size == 1) { + val taggedPayload = AUDIO_MESSAGE_PREFIX + json.encodeToString( + AudioPayloadDto( + durationMillis = safeDuration, + data = normalized + ) + ) + sendSignedPayload(route = route, payloadText = taggedPayload) + } else { + val messageId = UUID.randomUUID().toString() + chunks.forEachIndexed { index, chunk -> + val taggedPayload = AUDIO_CHUNK_MESSAGE_PREFIX + json.encodeToString( + AudioChunkPayloadDto( + messageId = messageId, + index = index, + total = chunks.size, + durationMillis = safeDuration, + data = chunk + ) + ) + sendSignedPayload(route = route, payloadText = taggedPayload) + } } - - check(socketClient.send(cipher)) { "连接不可用" } }.onSuccess { - addOutgoingMessage(text, subtitle, channel) - _uiState.update { it.copy(draft = "", sending = false) } - }.onFailure { error -> + addOutgoingAudioMessage( + subtitle = route.subtitle, + channel = route.channel, + audioBase64 = normalized, + durationMillis = safeDuration + ) _uiState.update { it.copy(sending = false) } - addSystemMessage("发送失败:${error.message ?: "unknown"}") + }.onFailure { error -> + val message = error.message ?: t("common.unknown") + _uiState.update { + it.copy( + sending = false, + statusHint = tf("session.hint.audio_send_failed", message) + ) + } + addSystemMessage(tf("session.msg.audio_send_failed", message)) } } } @@ -539,7 +616,7 @@ object ChatSessionManager { */ fun onMessageCopied() { scope.launch { - _events.emit(UiEvent.ShowSnackbar("已复制")) + _events.emit(UiEvent.ShowSnackbar(t("common.copied"))) } } @@ -563,7 +640,7 @@ object ChatSessionManager { */ private suspend fun handleIncomingMessage(rawText: String) { if (_uiState.value.status == ConnectionStatus.HANDSHAKING) { - _uiState.update { it.copy(statusHint = "已收到握手数据,正在解析...") } + _uiState.update { it.copy(statusHint = t("session.hint.handshake_data_received")) } } val normalizedText = extractJsonCandidate(rawText) @@ -598,7 +675,7 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "握手失败:服务端响应不完整" + statusHint = t("session.hint.handshake_incomplete_response") ) } return @@ -609,15 +686,15 @@ object ChatSessionManager { // 握手阶段收到非预期消息则报错 if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain != null) { - _uiState.update { it.copy(statusHint = "握手失败:收到非预期消息") } - addSystemMessage("握手阶段收到非预期消息类型:${plain.type}") + _uiState.update { it.copy(statusHint = t("session.hint.handshake_unexpected_message")) } + addSystemMessage(tf("session.msg.handshake_unexpected_type", plain.type)) } else if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain == null) { val preview = rawText .replace("\n", " ") .replace("\r", " ") .take(80) - _uiState.update { it.copy(statusHint = "握手失败:首包解析失败") } - addSystemMessage("握手包解析失败:$preview") + _uiState.update { it.copy(statusHint = t("session.hint.handshake_first_packet_parse_failed")) } + addSystemMessage(tf("session.msg.handshake_parse_failed", preview)) } // 尝试解密(若已握手完成,收到的应是加密消息) @@ -627,7 +704,7 @@ object ChatSessionManager { cryptoManager.decryptChunked(id.privateKey, normalizedText) } }.getOrElse { - addSystemMessage("收到无法解密的消息") + addSystemMessage(t("session.msg.decryption_failed")) return } @@ -648,7 +725,7 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.AUTHENTICATING, - statusHint = "正在完成身份验证...", + statusHint = t("session.hint.authenticating"), certFingerprint = hello.certFingerprintSha256.orEmpty() ) } @@ -660,10 +737,10 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "连接超时,请重试" + statusHint = t("session.hint.connection_timeout_retry") ) } - addSystemMessage("认证超时,请检查网络后重试") + addSystemMessage(t("session.msg.auth_timeout")) socketClient.close(1000, "auth_timeout") } } @@ -671,16 +748,21 @@ object ChatSessionManager { runCatching { sendAuth(hello.authChallenge) }.onSuccess { - addSystemMessage("已发送认证请求") + addSystemMessage(t("session.msg.auth_request_sent")) }.onFailure { error -> cancelAuthTimeout() _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "认证失败" + statusHint = t("session.hint.auth_failed") ) } - addSystemMessage("认证发送失败:${error.message ?: "unknown"}") + addSystemMessage( + tf( + "session.msg.auth_send_failed", + error.message ?: t("common.unknown") + ) + ) socketClient.close(1000, "auth_failed") } } @@ -729,7 +811,11 @@ object ChatSessionManager { val cipher = withContext(Dispatchers.Default) { cryptoManager.encryptChunked(serverPublicKey, plain) } - check(socketClient.send(cipher)) { "连接不可用" } + val sizeBytes = cipher.toByteArray(StandardCharsets.UTF_8).size + require(sizeBytes <= MAX_OUTBOUND_MESSAGE_BYTES) { + tf("session.error.message_too_large", sizeBytes) + } + check(socketClient.send(cipher)) { t("session.error.connection_unavailable") } } /** @@ -745,33 +831,80 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.READY, - statusHint = "已连接,可以开始聊天" + statusHint = t("session.hint.ready_to_chat") ) } - addSystemMessage("连接准备完成") + addSystemMessage(t("session.msg.ready")) } "broadcast" -> { - val sender = message.key?.takeIf { it.isNotBlank() } ?: "匿名用户" - addIncomingMessage( - sender = sender, - subtitle = "", - content = message.data.asPayloadText(), - channel = MessageChannel.BROADCAST - ) + val sender = message.key?.takeIf { it.isNotBlank() } ?: t("session.sender.anonymous") + val payloadText = message.data.asPayloadText() + val audioChunk = parseAudioChunkPayload(payloadText) + if (audioChunk != null) { + ingestIncomingAudioChunk( + sender = sender, + subtitle = "", + channel = MessageChannel.BROADCAST, + chunk = audioChunk + ) + return + } + val audio = parseAudioPayload(payloadText) + if (audio != null) { + addIncomingAudioMessage( + sender = sender, + subtitle = "", + audioBase64 = audio.data, + durationMillis = audio.durationMillis, + channel = MessageChannel.BROADCAST + ) + } else { + addIncomingMessage( + sender = sender, + subtitle = "", + content = payloadText, + channel = MessageChannel.BROADCAST + ) + } } "forward" -> { val sourceKey = message.key.orEmpty() - addIncomingMessage( - sender = "私聊消息", - subtitle = sourceKey.takeIf { it.isNotBlank() }?.let { "来自 ${summarizeKey(it)}" }.orEmpty(), - content = message.data.asPayloadText(), - channel = MessageChannel.PRIVATE - ) + val payloadText = message.data.asPayloadText() + val subtitle = sourceKey.takeIf { it.isNotBlank() } + ?.let { tf("session.subtitle.from_key", summarizeKey(it)) } + .orEmpty() + val audioChunk = parseAudioChunkPayload(payloadText) + if (audioChunk != null) { + ingestIncomingAudioChunk( + sender = t("session.sender.private_message"), + subtitle = subtitle, + channel = MessageChannel.PRIVATE, + chunk = audioChunk + ) + return + } + val audio = parseAudioPayload(payloadText) + if (audio != null) { + addIncomingAudioMessage( + sender = t("session.sender.private_message"), + subtitle = subtitle, + audioBase64 = audio.data, + durationMillis = audio.durationMillis, + channel = MessageChannel.PRIVATE + ) + } else { + addIncomingMessage( + sender = t("session.sender.private_message"), + subtitle = subtitle, + content = payloadText, + channel = MessageChannel.PRIVATE + ) + } } - else -> addSystemMessage("收到未识别消息类型:${message.type}") + else -> addSystemMessage(tf("session.msg.unknown_message_type", message.type)) } } @@ -794,6 +927,36 @@ object ChatSessionManager { return } + val reasonLower = reason.lowercase() + val isPolicyBlocked = code == 1008 || + reasonLower.contains("ip blocked") || + reasonLower.contains("message too large") || + reasonLower.contains("rate limited") + if (isPolicyBlocked) { + keepAliveRequested = false + _uiState.update { + it.copy( + status = ConnectionStatus.ERROR, + statusHint = tf( + "session.hint.server_rejected", + reason.ifBlank { t("session.text.policy_restriction") } + ) + ) + } + addSystemMessage( + tf( + "session.msg.server_rejected", + code, + reason.ifBlank { t("session.text.policy_restriction") } + ) + ) + scope.launch { + preferencesRepository.setShouldAutoReconnect(false) + } + ChatForegroundService.stop(app) + return + } + val currentStatus = _uiState.value.status val allowFallback = !fallbackTried && currentStatus != ConnectionStatus.READY @@ -807,11 +970,11 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.CONNECTING, - statusHint = "正在自动重试连接...", + statusHint = t("session.hint.auto_retry_connecting"), serverUrl = fallbackUrl ) } - addSystemMessage("连接方式切换中,正在重试") + addSystemMessage(t("session.msg.switching_connection_mode_retry")) socketClient.connect(fallbackUrl, socketListener) return } @@ -820,11 +983,17 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "连接已中断,正在重试" + statusHint = t("session.hint.connection_interrupted_retry") ) } - addSystemMessage("连接关闭 ($code):${reason.ifBlank { "连接中断" }}") - scheduleReconnect("连接已中断") + addSystemMessage( + tf( + "session.msg.connection_closed_with_code", + code, + reason.ifBlank { t("session.text.connection_interrupted") } + ) + ) + scheduleReconnect(t("session.reason.connection_interrupted")) } /** @@ -834,7 +1003,7 @@ object ChatSessionManager { private fun addSystemMessage(content: String) { val message = UiMessage( role = MessageRole.SYSTEM, - sender = "系统", + sender = t("session.sender.system"), subtitle = "", content = content, channel = MessageChannel.BROADCAST @@ -858,7 +1027,7 @@ object ChatSessionManager { ) { showIncomingNotification( title = sender, - body = content.ifBlank { "收到一条新消息" } + body = content.ifBlank { t("session.notification.new_message") } ) appendMessage( UiMessage( @@ -871,6 +1040,31 @@ object ChatSessionManager { ) } + private fun addIncomingAudioMessage( + sender: String, + subtitle: String, + audioBase64: String, + durationMillis: Long, + channel: MessageChannel + ) { + showIncomingNotification( + title = sender, + body = t("session.notification.new_voice_message") + ) + appendMessage( + UiMessage( + role = MessageRole.INCOMING, + sender = sender, + subtitle = subtitle, + content = t("session.message.voice"), + channel = channel, + contentType = MessageContentType.AUDIO, + audioBase64 = audioBase64, + audioDurationMillis = durationMillis.coerceAtLeast(0L) + ) + ) + } + /** * 添加一条发出的消息。 * @param content 消息内容 @@ -885,7 +1079,7 @@ object ChatSessionManager { appendMessage( UiMessage( role = MessageRole.OUTGOING, - sender = "我", + sender = t("session.sender.me"), subtitle = subtitle, content = content, channel = channel @@ -893,6 +1087,171 @@ object ChatSessionManager { ) } + private fun addOutgoingAudioMessage( + subtitle: String, + channel: MessageChannel, + audioBase64: String, + durationMillis: Long + ) { + appendMessage( + UiMessage( + role = MessageRole.OUTGOING, + sender = t("session.sender.me"), + subtitle = subtitle, + content = t("session.message.voice"), + channel = channel, + contentType = MessageContentType.AUDIO, + audioBase64 = audioBase64, + audioDurationMillis = durationMillis.coerceAtLeast(0L) + ) + ) + } + + private fun resolveOutgoingRoute(state: ChatUiState): OutgoingRoute? { + val key = if (state.directMode) state.targetKey.trim() else "" + if (state.directMode && key.isBlank()) { + _uiState.update { it.copy(statusHint = t("session.hint.fill_target_key_before_private")) } + return null + } + val type = if (key.isBlank()) "broadcast" else "forward" + val channel = if (key.isBlank()) MessageChannel.BROADCAST else MessageChannel.PRIVATE + val subtitle = if (key.isBlank()) "" else tf("session.subtitle.private_to_key", summarizeKey(key)) + return OutgoingRoute(type = type, key = key, channel = channel, subtitle = subtitle) + } + + private suspend fun sendSignedPayload(route: OutgoingRoute, payloadText: String) { + val id = ensureIdentity() + val timestamp = cryptoManager.unixSecondsNow() + val nonce = cryptoManager.createNonce() + val signingInput = listOf( + route.type, + route.key, + payloadText, + timestamp.toString(), + nonce + ).joinToString("\n") + val signature = withContext(Dispatchers.Default) { + cryptoManager.signText(id.privateKey, signingInput) + } + + val payload = SignedPayloadDto( + payload = payloadText, + timestamp = timestamp, + nonce = nonce, + signature = signature + ) + val envelope = EnvelopeDto( + type = route.type, + key = route.key, + data = json.encodeToJsonElement(payload) + ) + + val plain = json.encodeToString(envelope) + val cipher = withContext(Dispatchers.Default) { + cryptoManager.encryptChunked(serverPublicKey, plain) + } + check(socketClient.send(cipher)) { t("session.error.connection_unavailable") } + } + + private fun parseAudioPayload(payloadText: String): AudioPayloadDto? { + if (!payloadText.startsWith(AUDIO_MESSAGE_PREFIX)) return null + val encoded = payloadText.removePrefix(AUDIO_MESSAGE_PREFIX).trim() + if (encoded.isBlank()) return null + return runCatching { + json.decodeFromString(encoded) + }.getOrNull()?.takeIf { dto -> + dto.encoding.equals("base64", ignoreCase = true) && dto.data.isNotBlank() + } + } + + private fun parseAudioChunkPayload(payloadText: String): AudioChunkPayloadDto? { + if (!payloadText.startsWith(AUDIO_CHUNK_MESSAGE_PREFIX)) return null + val encoded = payloadText.removePrefix(AUDIO_CHUNK_MESSAGE_PREFIX).trim() + if (encoded.isBlank()) return null + return runCatching { + json.decodeFromString(encoded) + }.getOrNull()?.takeIf { dto -> + dto.encoding.equals("base64", ignoreCase = true) && + dto.messageId.isNotBlank() && + dto.total in 1..MAX_AUDIO_CHUNK_COUNT && + dto.index in 0 until dto.total && + dto.data.isNotBlank() + } + } + + private fun ingestIncomingAudioChunk( + sender: String, + subtitle: String, + channel: MessageChannel, + chunk: AudioChunkPayloadDto + ) { + val now = System.currentTimeMillis() + purgeExpiredAudioChunkBuffers(now) + val bufferKey = "${channel.name}:${sender}:${chunk.messageId}" + val buffer = incomingAudioChunkBuffers[bufferKey] + val active = if (buffer == null || buffer.total != chunk.total) { + IncomingAudioChunkBuffer( + sender = sender, + subtitle = subtitle, + channel = channel, + total = chunk.total, + durationMillis = chunk.durationMillis.coerceAtLeast(0L), + createdAtMillis = now, + chunks = MutableList(chunk.total) { null } + ).also { created -> + incomingAudioChunkBuffers[bufferKey] = created + } + } else { + if (buffer.sender != sender || buffer.channel != channel) { + return + } + buffer + } + + active.chunks[chunk.index] = chunk.data + val completed = active.chunks.all { !it.isNullOrBlank() } + if (!completed) return + + incomingAudioChunkBuffers.remove(bufferKey) + val merged = buildString { + active.chunks.forEach { part -> + append(part.orEmpty()) + } + } + if (merged.isBlank()) return + + addIncomingAudioMessage( + sender = active.sender, + subtitle = active.subtitle, + audioBase64 = merged, + durationMillis = active.durationMillis, + channel = active.channel + ) + } + + private fun purgeExpiredAudioChunkBuffers(nowMillis: Long) { + if (incomingAudioChunkBuffers.isEmpty()) return + val expiredKeys = incomingAudioChunkBuffers + .filterValues { nowMillis - it.createdAtMillis >= AUDIO_CHUNK_BUFFER_TTL_MS } + .keys + expiredKeys.forEach { key -> + incomingAudioChunkBuffers.remove(key) + } + } + + private fun splitAudioBase64(base64: String, chunkSize: Int): List { + if (base64.isEmpty() || chunkSize <= 0) return emptyList() + if (base64.length <= chunkSize) return listOf(base64) + val chunks = ArrayList((base64.length + chunkSize - 1) / chunkSize) + var start = 0 + while (start < base64.length) { + val end = minOf(start + chunkSize, base64.length) + chunks.add(base64.substring(start, end)) + start = end + } + return chunks + } + /** * 将消息追加到列表尾部,并清理超出数量限制的消息。 * @param message 要追加的消息 @@ -934,11 +1293,11 @@ object ChatSessionManager { reconnectAttempt += 1 val exponential = 1 shl minOf(reconnectAttempt - 1, 5) val delaySeconds = minOf(MAX_RECONNECT_DELAY_SECONDS, exponential) - addSystemMessage("$reason,${delaySeconds}s 后自动重连(第 $reconnectAttempt 次)") + addSystemMessage(tf("session.msg.auto_reconnect_in", reason, delaySeconds, reconnectAttempt)) _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "${delaySeconds}s 后自动重连(第 $reconnectAttempt 次)" + statusHint = tf("session.hint.auto_reconnect_in", delaySeconds, reconnectAttempt) ) } @@ -953,7 +1312,7 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "重连失败:服务器地址无效" + statusHint = t("session.hint.reconnect_invalid_server") ) } return@launch @@ -967,7 +1326,7 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.CONNECTING, - statusHint = "正在自动重连..." + statusHint = t("session.hint.auto_reconnecting") ) } socketClient.connect(target, socketListener) @@ -1018,10 +1377,10 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "握手超时,请检查地址路径与反向代理" + statusHint = t("session.hint.handshake_timeout") ) } - addSystemMessage("握手超时:未收到服务端 publickey 首包(当前地址:$currentUrl)") + addSystemMessage(tf("session.msg.handshake_timeout_with_url", currentUrl)) socketClient.close(1000, "hello_timeout") } } @@ -1113,10 +1472,10 @@ object ChatSessionManager { val manager = app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val channel = NotificationChannel( MESSAGE_CHANNEL_ID, - "OnlineMsg 消息提醒", + t("session.notification.channel_name"), NotificationManager.IMPORTANCE_DEFAULT ).apply { - description = "收到服务器新消息时提醒" + description = t("session.notification.channel_desc") } manager.createNotificationChannel(channel) } @@ -1167,6 +1526,23 @@ object ChatSessionManager { return notificationIdSeed } + private data class OutgoingRoute( + val type: String, + val key: String, + val channel: MessageChannel, + val subtitle: String + ) + + private data class IncomingAudioChunkBuffer( + val sender: String, + val subtitle: String, + val channel: MessageChannel, + val total: Int, + val durationMillis: Long, + val createdAtMillis: Long, + val chunks: MutableList + ) + // 常量定义 private const val HELLO_TIMEOUT_MS = 12_000L private const val AUTH_TIMEOUT_MS = 20_000L @@ -1174,4 +1550,10 @@ object ChatSessionManager { 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 + private const val MAX_AUDIO_CHUNK_COUNT = 30 + private const val AUDIO_CHUNK_BUFFER_TTL_MS = 180_000L + private const val MAX_OUTBOUND_MESSAGE_BYTES = 60 * 1024 } 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 c7cc58b..1269f13 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 @@ -31,6 +31,14 @@ enum class MessageChannel { PRIVATE } +/** + * 消息内容类型(文本/音频)。 + */ +enum class MessageContentType { + TEXT, + AUDIO +} + /** * 单条消息的数据类。 * @property id 唯一标识(默认随机 UUID) @@ -48,7 +56,10 @@ data class UiMessage( val subtitle: String = "", val content: String, val channel: MessageChannel, - val timestampMillis: Long = System.currentTimeMillis() + val timestampMillis: Long = System.currentTimeMillis(), + val contentType: MessageContentType = MessageContentType.TEXT, + val audioBase64: String = "", + val audioDurationMillis: Long = 0L ) /** @@ -141,4 +152,4 @@ data class ChatUiState( */ sealed interface UiEvent { data class ShowSnackbar(val message: String) : UiEvent -} \ No newline at end of file +} 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 8b53474..5f8c2f8 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 @@ -30,9 +30,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { fun connect() = ChatSessionManager.connect() fun disconnect() = ChatSessionManager.disconnect() fun sendMessage() = ChatSessionManager.sendMessage() + fun sendAudioMessage(audioBase64: String, durationMillis: Long) = + ChatSessionManager.sendAudioMessage(audioBase64, durationMillis) fun onMessageCopied() = ChatSessionManager.onMessageCopied() fun updateTheme(themeId: String) = ChatSessionManager.updateTheme(themeId) fun updateUseDynamicColor(enabled: Boolean) = ChatSessionManager.updateUseDynamicColor(enabled) fun updateLanguage(language: String) = ChatSessionManager.updateLanguage(language) -} \ No newline at end of file +} diff --git a/android-client/app/src/main/java/com/onlinemsg/client/util/AudioRecorder.kt b/android-client/app/src/main/java/com/onlinemsg/client/util/AudioRecorder.kt new file mode 100644 index 0000000..11abca4 --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/util/AudioRecorder.kt @@ -0,0 +1,89 @@ +package com.onlinemsg.client.util + +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import android.util.Base64 +import java.io.File + +data class RecordedAudio( + val base64: String, + val durationMillis: Long +) + +class AudioRecorder(private val context: Context) { + private var mediaRecorder: MediaRecorder? = null + private var outputFile: File? = null + private var startedAtMillis: Long = 0L + + fun start(): Boolean { + if (mediaRecorder != null) return false + val file = runCatching { + File.createTempFile("oms_record_", ".m4a", context.cacheDir) + }.getOrNull() ?: return false + + val recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + MediaRecorder() + } + + val started = runCatching { + recorder.setAudioSource(MediaRecorder.AudioSource.MIC) + recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + recorder.setAudioChannels(1) + recorder.setAudioEncodingBitRate(24_000) + recorder.setAudioSamplingRate(16_000) + recorder.setMaxDuration(60_000) + recorder.setOutputFile(file.absolutePath) + recorder.prepare() + recorder.start() + true + }.getOrElse { + runCatching { recorder.reset() } + runCatching { recorder.release() } + file.delete() + false + } + + if (!started) return false + + mediaRecorder = recorder + outputFile = file + startedAtMillis = System.currentTimeMillis() + return true + } + + fun stopAndEncode(send: Boolean): RecordedAudio? { + val recorder = mediaRecorder ?: return null + mediaRecorder = null + val file = outputFile + outputFile = null + + runCatching { recorder.stop() } + runCatching { recorder.reset() } + runCatching { recorder.release() } + + if (!send || file == null) { + file?.delete() + return null + } + + val duration = (System.currentTimeMillis() - startedAtMillis).coerceAtLeast(0L) + val bytes = runCatching { file.readBytes() }.getOrNull() + file.delete() + + if (bytes == null || bytes.isEmpty()) return null + val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP) + return RecordedAudio(base64 = base64, durationMillis = duration) + } + + fun cancel() { + stopAndEncode(send = false) + } + + fun release() { + cancel() + } +} 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 355c591..57ebc9b 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 @@ -32,7 +32,92 @@ object LanguageManager { "settings.connect" to "连接", "settings.disconnect" to "断开", "settings.clear_msg" to "清空消息", + "settings.chat_data" to "聊天数据", "settings.dynamic_color" to "使用动态颜色", + "status.idle" to "未连接", + "status.connecting" to "连接中", + "status.ready" to "已连接", + "status.error" to "异常断开", + "hint.tap_to_connect" to "点击连接开始聊天", + "hint.connecting_server" to "正在连接服务器...", + "hint.ready_chat" to "已连接,可以开始聊天", + "hint.closed" to "连接已关闭", + "hint.reconnecting" to "连接已中断,正在重试", + "hint.reconnect_invalid_server" to "重连失败:服务器地址无效", + "hint.fill_target_key" to "请先填写目标公钥,再发送私聊消息", + "hint.server_rejected_prefix" to "服务器拒绝连接:", + "hint.audio_send_failed_prefix" to "语音发送失败:", + "session.sender.system" to "系统", + "session.sender.me" to "我", + "session.sender.anonymous" to "匿名用户", + "session.sender.private_message" to "私聊消息", + "session.reason.connection_error" to "连接异常", + "session.reason.connection_interrupted" to "连接已中断", + "session.text.policy_restriction" to "策略限制", + "session.text.connection_interrupted" to "连接中断", + "session.snackbar.invalid_server" to "请输入有效的服务器地址", + "session.snackbar.server_saved" to "服务器地址已保存", + "session.snackbar.server_list_updated" to "已更新服务器地址列表", + "session.snackbar.public_key_read_failed" to "公钥读取失败:%s", + "session.hint.connected_preparing" to "已连接,正在准备聊天...", + "session.hint.binary_handshake_parsing" to "收到二进制握手帧,正在尝试解析...", + "session.hint.connection_error_retrying" to "连接异常,正在重试", + "session.hint.server_saved" to "服务器地址已保存", + "session.hint.server_restored_default" to "已恢复默认服务器地址", + "session.hint.server_removed" to "已移除当前服务器地址", + "session.hint.fill_valid_server" to "请填写有效服务器地址", + "session.hint.connecting_server" to "正在连接服务器...", + "session.hint.connection_closed" to "连接已关闭", + "session.hint.audio_chunk_over_limit" to "语音过长,超过可发送分片上限", + "session.hint.audio_send_failed" to "语音发送失败:%s", + "session.hint.handshake_data_received" to "已收到握手数据,正在解析...", + "session.hint.handshake_incomplete_response" to "握手失败:服务端响应不完整", + "session.hint.handshake_unexpected_message" to "握手失败:收到非预期消息", + "session.hint.handshake_first_packet_parse_failed" to "握手失败:首包解析失败", + "session.hint.authenticating" to "正在完成身份验证...", + "session.hint.connection_timeout_retry" to "连接超时,请重试", + "session.hint.auth_failed" to "认证失败", + "session.hint.ready_to_chat" to "已连接,可以开始聊天", + "session.hint.server_rejected" to "服务器拒绝连接:%s", + "session.hint.auto_retry_connecting" to "正在自动重试连接...", + "session.hint.connection_interrupted_retry" to "连接已中断,正在重试", + "session.hint.fill_target_key_before_private" to "请先填写目标公钥,再发送私聊消息", + "session.hint.auto_reconnect_in" to "%ds 后自动重连(第 %d 次)", + "session.hint.reconnect_invalid_server" to "重连失败:服务器地址无效", + "session.hint.auto_reconnecting" to "正在自动重连...", + "session.hint.handshake_timeout" to "握手超时,请检查地址路径与反向代理", + "session.msg.connection_established" to "连接已建立", + "session.msg.text_frame_error" to "文本帧处理异常:%s", + "session.msg.binary_frame_error" to "二进制帧处理异常:%s", + "session.msg.handshake_binary_unreadable" to "握手二进制帧无法转为文本,len=%d hex=%s", + "session.msg.connection_error" to "连接异常:%s", + "session.msg.auto_restore_connecting" to "已恢复上次会话,正在自动连接", + "session.msg.disconnected" to "已断开连接", + "session.msg.send_failed" to "发送失败:%s", + "session.msg.audio_chunk_canceled" to "语音过长,已取消发送", + "session.msg.audio_send_failed" to "语音发送失败:%s", + "session.msg.handshake_unexpected_type" to "握手阶段收到非预期消息类型:%s", + "session.msg.handshake_parse_failed" to "握手包解析失败:%s", + "session.msg.decryption_failed" to "收到无法解密的消息", + "session.msg.auth_timeout" to "认证超时,请检查网络后重试", + "session.msg.auth_request_sent" to "已发送认证请求", + "session.msg.auth_send_failed" to "认证发送失败:%s", + "session.msg.ready" to "连接准备完成", + "session.msg.unknown_message_type" to "收到未识别消息类型:%s", + "session.msg.server_rejected" to "连接被服务器拒绝(%d):%s", + "session.msg.switching_connection_mode_retry" to "连接方式切换中,正在重试", + "session.msg.connection_closed_with_code" to "连接关闭 (%d):%s", + "session.msg.auto_reconnect_in" to "%s,%ds 后自动重连(第 %d 次)", + "session.msg.handshake_timeout_with_url" to "握手超时:未收到服务端 publickey 首包(当前地址:%s)", + "session.error.message_too_large" to "消息体过大(%dB),请缩短消息内容后重试", + "session.error.connection_unavailable" to "连接不可用", + "session.notification.channel_name" to "OnlineMsg 消息提醒", + "session.notification.channel_desc" to "收到服务器新消息时提醒", + "session.notification.new_message" to "收到一条新消息", + "session.notification.new_voice_message" to "收到一条语音消息", + "session.message.voice" to "语音消息", + "session.subtitle.from_key" to "来自 %s", + "session.subtitle.private_to_key" to "私聊 %s", "chat.broadcast" to "广播", "chat.private" to "私聊", "chat.target_key" to "目标公钥", @@ -40,8 +125,31 @@ object LanguageManager { "chat.send" to "发送", "chat.sending" to "发送中", "chat.empty_hint" to "连接后即可聊天。默认广播,切换到私聊后可填写目标公钥。", + "chat.mode_text" to "文字", + "chat.mode_audio" to "语音", + "chat.audio_hold_to_talk" to "按住说话", + "chat.audio_release_send" to "松开发送", + "chat.audio_release_cancel" to "松开取消", + "chat.audio_slide_cancel" to "按住说话,上滑取消", + "chat.audio_canceled" to "已取消语音发送", + "chat.audio_sent" to "语音已发送", + "chat.audio_too_short" to "录音时间太短", + "chat.audio_too_long" to "录音时间过长", + "chat.audio_record_failed" to "录音失败,请重试", + "chat.audio_permission_required" to "请先授予麦克风权限", + "chat.audio_recording" to "录制中", + "chat.audio_play" to "播放语音", + "chat.audio_stop" to "停止播放", "common.copied" to "已复制", "common.unknown" to "未知", + "service.foreground.title.ready" to "OnlineMsg 已保持连接", + "service.foreground.title.connecting" to "OnlineMsg 正在连接", + "service.foreground.title.error" to "OnlineMsg 连接异常", + "service.foreground.title.idle" to "OnlineMsg 后台服务", + "service.foreground.hint.default" to "后台保持连接中", + "service.foreground.action.disconnect" to "断开", + "service.foreground.channel.name" to "OnlineMsg 后台连接", + "service.foreground.channel.desc" to "保持 WebSocket 后台长连接", "theme.blue" to "蔚蓝", "theme.gray" to "商务灰", "theme.green" to "翠绿", @@ -73,7 +181,92 @@ object LanguageManager { "settings.connect" to "Link", "settings.disconnect" to "Dislink", "settings.clear_msg" to "ClearMsg", + "settings.chat_data" to "Chat Data", "settings.dynamic_color" to "Use dynamic color", + "status.idle" to "Offline", + "status.connecting" to "Connecting", + "status.ready" to "Connected", + "status.error" to "Disconnected", + "hint.tap_to_connect" to "Tap connect to start chatting", + "hint.connecting_server" to "Connecting to server...", + "hint.ready_chat" to "Connected, ready to chat", + "hint.closed" to "Connection closed", + "hint.reconnecting" to "Connection interrupted, reconnecting", + "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: ", + "hint.audio_send_failed_prefix" to "Voice send failed: ", + "session.sender.system" to "System", + "session.sender.me" to "Me", + "session.sender.anonymous" to "Anonymous", + "session.sender.private_message" to "Private Message", + "session.reason.connection_error" to "Connection error", + "session.reason.connection_interrupted" to "Connection interrupted", + "session.text.policy_restriction" to "policy restriction", + "session.text.connection_interrupted" to "connection interrupted", + "session.snackbar.invalid_server" to "Please enter a valid server address", + "session.snackbar.server_saved" to "Server address saved", + "session.snackbar.server_list_updated" to "Server address list updated", + "session.snackbar.public_key_read_failed" to "Public key read failed: %s", + "session.hint.connected_preparing" to "Connected, preparing chat...", + "session.hint.binary_handshake_parsing" to "Received binary handshake frame, parsing...", + "session.hint.connection_error_retrying" to "Connection error, retrying", + "session.hint.server_saved" to "Server address saved", + "session.hint.server_restored_default" to "Default server restored", + "session.hint.server_removed" to "Current server removed", + "session.hint.fill_valid_server" to "Please enter a valid server address", + "session.hint.connecting_server" to "Connecting to server...", + "session.hint.connection_closed" to "Connection closed", + "session.hint.audio_chunk_over_limit" to "Voice message too long, chunk limit exceeded", + "session.hint.audio_send_failed" to "Voice send failed: %s", + "session.hint.handshake_data_received" to "Handshake data received, parsing...", + "session.hint.handshake_incomplete_response" to "Handshake failed: incomplete server response", + "session.hint.handshake_unexpected_message" to "Handshake failed: unexpected message", + "session.hint.handshake_first_packet_parse_failed" to "Handshake failed: first packet parse failed", + "session.hint.authenticating" to "Authenticating...", + "session.hint.connection_timeout_retry" to "Connection timeout, please retry", + "session.hint.auth_failed" to "Authentication failed", + "session.hint.ready_to_chat" to "Connected, ready to chat", + "session.hint.server_rejected" to "Server rejected connection: %s", + "session.hint.auto_retry_connecting" to "Auto retry connecting...", + "session.hint.connection_interrupted_retry" to "Connection interrupted, retrying", + "session.hint.fill_target_key_before_private" to "Please enter target public key before private message", + "session.hint.auto_reconnect_in" to "Auto reconnect in %ds (attempt %d)", + "session.hint.reconnect_invalid_server" to "Reconnect failed: invalid server address", + "session.hint.auto_reconnecting" to "Auto reconnecting...", + "session.hint.handshake_timeout" to "Handshake timeout, check URL path or reverse proxy", + "session.msg.connection_established" to "Connection established", + "session.msg.text_frame_error" to "Text frame processing error: %s", + "session.msg.binary_frame_error" to "Binary frame processing error: %s", + "session.msg.handshake_binary_unreadable" to "Binary handshake frame unreadable, len=%d hex=%s", + "session.msg.connection_error" to "Connection error: %s", + "session.msg.auto_restore_connecting" to "Restored last session, auto connecting", + "session.msg.disconnected" to "Disconnected", + "session.msg.send_failed" to "Send failed: %s", + "session.msg.audio_chunk_canceled" to "Voice message too long, canceled", + "session.msg.audio_send_failed" to "Voice send failed: %s", + "session.msg.handshake_unexpected_type" to "Unexpected message type during handshake: %s", + "session.msg.handshake_parse_failed" to "Handshake packet parse failed: %s", + "session.msg.decryption_failed" to "Received undecryptable message", + "session.msg.auth_timeout" to "Authentication timeout, please check network and retry", + "session.msg.auth_request_sent" to "Authentication request sent", + "session.msg.auth_send_failed" to "Authentication send failed: %s", + "session.msg.ready" to "Connection ready", + "session.msg.unknown_message_type" to "Unknown message type received: %s", + "session.msg.server_rejected" to "Connection rejected by server (%d): %s", + "session.msg.switching_connection_mode_retry" to "Switching connection mode, retrying", + "session.msg.connection_closed_with_code" to "Connection closed (%d): %s", + "session.msg.auto_reconnect_in" to "%s, auto reconnect in %ds (attempt %d)", + "session.msg.handshake_timeout_with_url" to "Handshake timeout: no server publickey packet (url: %s)", + "session.error.message_too_large" to "Message too large (%dB), please shorten and retry", + "session.error.connection_unavailable" to "Connection unavailable", + "session.notification.channel_name" to "OnlineMsg Notifications", + "session.notification.channel_desc" to "Notify when new server messages arrive", + "session.notification.new_message" to "New message received", + "session.notification.new_voice_message" to "New voice message received", + "session.message.voice" to "Voice message", + "session.subtitle.from_key" to "From %s", + "session.subtitle.private_to_key" to "Private %s", "chat.broadcast" to "Broadcast", "chat.private" to "Private", "chat.target_key" to "Target Public Key", @@ -81,8 +274,31 @@ object LanguageManager { "chat.send" to "Send", "chat.sending" to "Sending", "chat.empty_hint" to "Connect to start chatting. Default is broadcast.", + "chat.mode_text" to "Text", + "chat.mode_audio" to "Voice", + "chat.audio_hold_to_talk" to "Hold to Talk", + "chat.audio_release_send" to "Release to Send", + "chat.audio_release_cancel" to "Release to Cancel", + "chat.audio_slide_cancel" to "Hold to talk, slide up to cancel", + "chat.audio_canceled" to "Voice message canceled", + "chat.audio_sent" to "Voice message sent", + "chat.audio_too_short" to "Recording is too short", + "chat.audio_too_long" to "Recording is too long", + "chat.audio_record_failed" to "Recording failed, try again", + "chat.audio_permission_required" to "Microphone permission is required", + "chat.audio_recording" to "Recording", + "chat.audio_play" to "Play voice", + "chat.audio_stop" to "Stop", "common.copied" to "Copied", "common.unknown" to "Unknown", + "service.foreground.title.ready" to "OnlineMsg Connected", + "service.foreground.title.connecting" to "OnlineMsg Connecting", + "service.foreground.title.error" to "OnlineMsg Connection Error", + "service.foreground.title.idle" to "OnlineMsg Background Service", + "service.foreground.hint.default" to "Keeping connection in background", + "service.foreground.action.disconnect" to "Disconnect", + "service.foreground.channel.name" to "OnlineMsg Background Connection", + "service.foreground.channel.desc" to "Keep WebSocket long connection in background", "theme.blue" to "Blue", "theme.gray" to "Business Gray", "theme.green" to "Green", @@ -114,7 +330,92 @@ object LanguageManager { "settings.connect" to "接続", "settings.disconnect" to "切断", "settings.clear_msg" to "履歴を消去", + "settings.chat_data" to "チャットデータ", "settings.dynamic_color" to "動的カラーを使用", + "status.idle" to "未接続", + "status.connecting" to "接続中", + "status.ready" to "接続済み", + "status.error" to "切断", + "hint.tap_to_connect" to "接続してチャットを開始", + "hint.connecting_server" to "サーバーへ接続中...", + "hint.ready_chat" to "接続完了、チャット可能", + "hint.closed" to "接続を閉じました", + "hint.reconnecting" to "接続が中断され、再接続中", + "hint.reconnect_invalid_server" to "再接続失敗:サーバーアドレス無効", + "hint.fill_target_key" to "個人チャット前に相手の公開鍵を入力してください", + "hint.server_rejected_prefix" to "サーバーが接続を拒否しました:", + "hint.audio_send_failed_prefix" to "音声送信失敗:", + "session.sender.system" to "システム", + "session.sender.me" to "自分", + "session.sender.anonymous" to "匿名ユーザー", + "session.sender.private_message" to "個人メッセージ", + "session.reason.connection_error" to "接続エラー", + "session.reason.connection_interrupted" to "接続中断", + "session.text.policy_restriction" to "ポリシー制限", + "session.text.connection_interrupted" to "接続中断", + "session.snackbar.invalid_server" to "有効なサーバーアドレスを入力してください", + "session.snackbar.server_saved" to "サーバーアドレスを保存しました", + "session.snackbar.server_list_updated" to "サーバーアドレス一覧を更新しました", + "session.snackbar.public_key_read_failed" to "公開鍵の読み取りに失敗しました:%s", + "session.hint.connected_preparing" to "接続済み、チャット準備中...", + "session.hint.binary_handshake_parsing" to "バイナリ握手フレーム受信、解析中...", + "session.hint.connection_error_retrying" to "接続エラー、再試行中", + "session.hint.server_saved" to "サーバーアドレスを保存しました", + "session.hint.server_restored_default" to "デフォルトサーバーを復元しました", + "session.hint.server_removed" to "現在のサーバーを削除しました", + "session.hint.fill_valid_server" to "有効なサーバーアドレスを入力してください", + "session.hint.connecting_server" to "サーバーへ接続中...", + "session.hint.connection_closed" to "接続を閉じました", + "session.hint.audio_chunk_over_limit" to "音声が長すぎて分割上限を超えました", + "session.hint.audio_send_failed" to "音声送信失敗:%s", + "session.hint.handshake_data_received" to "握手データ受信、解析中...", + "session.hint.handshake_incomplete_response" to "握手失敗:サーバー応答が不完全です", + "session.hint.handshake_unexpected_message" to "握手失敗:予期しないメッセージ", + "session.hint.handshake_first_packet_parse_failed" to "握手失敗:初回パケット解析失敗", + "session.hint.authenticating" to "認証中...", + "session.hint.connection_timeout_retry" to "接続タイムアウト、再試行してください", + "session.hint.auth_failed" to "認証失敗", + "session.hint.ready_to_chat" to "接続済み、チャット可能", + "session.hint.server_rejected" to "サーバーが接続を拒否しました:%s", + "session.hint.auto_retry_connecting" to "自動再試行で接続中...", + "session.hint.connection_interrupted_retry" to "接続が中断され、再試行中", + "session.hint.fill_target_key_before_private" to "個人チャット前に相手の公開鍵を入力してください", + "session.hint.auto_reconnect_in" to "%d秒後に自動再接続(%d回目)", + "session.hint.reconnect_invalid_server" to "再接続失敗:サーバーアドレス無効", + "session.hint.auto_reconnecting" to "自動再接続中...", + "session.hint.handshake_timeout" to "握手タイムアウト:URL パスまたはリバースプロキシを確認", + "session.msg.connection_established" to "接続が確立されました", + "session.msg.text_frame_error" to "テキストフレーム処理エラー:%s", + "session.msg.binary_frame_error" to "バイナリフレーム処理エラー:%s", + "session.msg.handshake_binary_unreadable" to "バイナリ握手フレームをテキスト化できません。len=%d hex=%s", + "session.msg.connection_error" to "接続エラー:%s", + "session.msg.auto_restore_connecting" to "前回セッションを復元し自動接続中", + "session.msg.disconnected" to "切断しました", + "session.msg.send_failed" to "送信失敗:%s", + "session.msg.audio_chunk_canceled" to "音声が長すぎるため送信を中止しました", + "session.msg.audio_send_failed" to "音声送信失敗:%s", + "session.msg.handshake_unexpected_type" to "握手中に予期しないメッセージ種別:%s", + "session.msg.handshake_parse_failed" to "握手パケット解析失敗:%s", + "session.msg.decryption_failed" to "復号できないメッセージを受信しました", + "session.msg.auth_timeout" to "認証タイムアウト:ネットワークを確認して再試行してください", + "session.msg.auth_request_sent" to "認証リクエストを送信しました", + "session.msg.auth_send_failed" to "認証送信失敗:%s", + "session.msg.ready" to "接続準備完了", + "session.msg.unknown_message_type" to "未識別メッセージ種別を受信:%s", + "session.msg.server_rejected" to "サーバーに接続拒否されました(%d):%s", + "session.msg.switching_connection_mode_retry" to "接続方式を切り替えて再試行中", + "session.msg.connection_closed_with_code" to "接続が閉じられました(%d):%s", + "session.msg.auto_reconnect_in" to "%s、%d秒後に自動再接続(%d回目)", + "session.msg.handshake_timeout_with_url" to "握手タイムアウト:server publickey 初回パケット未受信(URL: %s)", + "session.error.message_too_large" to "メッセージが大きすぎます(%dB)。短くして再試行してください", + "session.error.connection_unavailable" to "接続不可", + "session.notification.channel_name" to "OnlineMsg 通知", + "session.notification.channel_desc" to "サーバー新着メッセージを通知", + "session.notification.new_message" to "新着メッセージ", + "session.notification.new_voice_message" to "新着音声メッセージ", + "session.message.voice" to "音声メッセージ", + "session.subtitle.from_key" to "%s から", + "session.subtitle.private_to_key" to "個人 %s", "chat.broadcast" to "全体", "chat.private" to "個人", "chat.target_key" to "相手の公開鍵", @@ -122,8 +423,31 @@ object LanguageManager { "chat.send" to "送信", "chat.sending" to "送信中", "chat.empty_hint" to "接続するとチャットを開始できます。", + "chat.mode_text" to "テキスト", + "chat.mode_audio" to "音声", + "chat.audio_hold_to_talk" to "長押しで録音", + "chat.audio_release_send" to "離して送信", + "chat.audio_release_cancel" to "離してキャンセル", + "chat.audio_slide_cancel" to "長押し中に上へスライドでキャンセル", + "chat.audio_canceled" to "音声送信をキャンセルしました", + "chat.audio_sent" to "音声を送信しました", + "chat.audio_too_short" to "録音時間が短すぎます", + "chat.audio_too_long" to "録音が長すぎます", + "chat.audio_record_failed" to "録音に失敗しました", + "chat.audio_permission_required" to "マイク権限が必要です", + "chat.audio_recording" to "録音中", + "chat.audio_play" to "再生", + "chat.audio_stop" to "停止", "common.copied" to "コピーしました", "common.unknown" to "不明", + "service.foreground.title.ready" to "OnlineMsg 接続維持中", + "service.foreground.title.connecting" to "OnlineMsg 接続中", + "service.foreground.title.error" to "OnlineMsg 接続エラー", + "service.foreground.title.idle" to "OnlineMsg バックグラウンドサービス", + "service.foreground.hint.default" to "バックグラウンドで接続を維持中", + "service.foreground.action.disconnect" to "切断", + "service.foreground.channel.name" to "OnlineMsg バックグラウンド接続", + "service.foreground.channel.desc" to "WebSocket のバックグラウンド長時間接続を維持", "theme.blue" to "ブルー", "theme.gray" to "ビジネスグレー", "theme.green" to "グリーン", @@ -155,7 +479,92 @@ object LanguageManager { "settings.connect" to "연결", "settings.disconnect" to "연결 끊기", "settings.clear_msg" to "정보 삭제", + "settings.chat_data" to "채팅 데이터", "settings.dynamic_color" to "동적 색상 사용", + "status.idle" to "연결 안 됨", + "status.connecting" to "연결 중", + "status.ready" to "연결됨", + "status.error" to "연결 끊김", + "hint.tap_to_connect" to "연결을 눌러 채팅을 시작하세요", + "hint.connecting_server" to "서버에 연결 중...", + "hint.ready_chat" to "연결 완료, 채팅 가능", + "hint.closed" to "연결이 종료되었습니다", + "hint.reconnecting" to "연결이 끊겨 재연결 중입니다", + "hint.reconnect_invalid_server" to "재연결 실패: 서버 주소가 올바르지 않습니다", + "hint.fill_target_key" to "비공개 채팅 전 대상 공개키를 입력하세요", + "hint.server_rejected_prefix" to "서버가 연결을 거부했습니다: ", + "hint.audio_send_failed_prefix" to "음성 전송 실패: ", + "session.sender.system" to "시스템", + "session.sender.me" to "나", + "session.sender.anonymous" to "익명 사용자", + "session.sender.private_message" to "비공개 메시지", + "session.reason.connection_error" to "연결 오류", + "session.reason.connection_interrupted" to "연결 중단", + "session.text.policy_restriction" to "정책 제한", + "session.text.connection_interrupted" to "연결 중단", + "session.snackbar.invalid_server" to "유효한 서버 주소를 입력하세요", + "session.snackbar.server_saved" to "서버 주소를 저장했습니다", + "session.snackbar.server_list_updated" to "서버 주소 목록을 업데이트했습니다", + "session.snackbar.public_key_read_failed" to "공개키 읽기 실패: %s", + "session.hint.connected_preparing" to "연결됨, 채팅 준비 중...", + "session.hint.binary_handshake_parsing" to "바이너리 핸드셰이크 프레임 수신, 파싱 중...", + "session.hint.connection_error_retrying" to "연결 오류, 재시도 중", + "session.hint.server_saved" to "서버 주소를 저장했습니다", + "session.hint.server_restored_default" to "기본 서버를 복원했습니다", + "session.hint.server_removed" to "현재 서버를 삭제했습니다", + "session.hint.fill_valid_server" to "유효한 서버 주소를 입력하세요", + "session.hint.connecting_server" to "서버에 연결 중...", + "session.hint.connection_closed" to "연결이 종료되었습니다", + "session.hint.audio_chunk_over_limit" to "음성이 너무 길어 분할 상한을 초과했습니다", + "session.hint.audio_send_failed" to "음성 전송 실패: %s", + "session.hint.handshake_data_received" to "핸드셰이크 데이터 수신, 파싱 중...", + "session.hint.handshake_incomplete_response" to "핸드셰이크 실패: 서버 응답이 불완전합니다", + "session.hint.handshake_unexpected_message" to "핸드셰이크 실패: 예상치 못한 메시지", + "session.hint.handshake_first_packet_parse_failed" to "핸드셰이크 실패: 첫 패킷 파싱 실패", + "session.hint.authenticating" to "인증 중...", + "session.hint.connection_timeout_retry" to "연결 시간 초과, 다시 시도하세요", + "session.hint.auth_failed" to "인증 실패", + "session.hint.ready_to_chat" to "연결 완료, 채팅 가능", + "session.hint.server_rejected" to "서버가 연결을 거부했습니다: %s", + "session.hint.auto_retry_connecting" to "자동 재시도 연결 중...", + "session.hint.connection_interrupted_retry" to "연결이 끊겨 재시도 중", + "session.hint.fill_target_key_before_private" to "비공개 채팅 전 대상 공개키를 입력하세요", + "session.hint.auto_reconnect_in" to "%d초 후 자동 재연결 (%d회차)", + "session.hint.reconnect_invalid_server" to "재연결 실패: 서버 주소가 올바르지 않습니다", + "session.hint.auto_reconnecting" to "자동 재연결 중...", + "session.hint.handshake_timeout" to "핸드셰이크 시간 초과: URL 경로 또는 리버스 프록시를 확인하세요", + "session.msg.connection_established" to "연결이 설정되었습니다", + "session.msg.text_frame_error" to "텍스트 프레임 처리 오류: %s", + "session.msg.binary_frame_error" to "바이너리 프레임 처리 오류: %s", + "session.msg.handshake_binary_unreadable" to "핸드셰이크 바이너리 프레임을 텍스트로 변환할 수 없습니다. len=%d hex=%s", + "session.msg.connection_error" to "연결 오류: %s", + "session.msg.auto_restore_connecting" to "이전 세션을 복원하여 자동 연결 중", + "session.msg.disconnected" to "연결 해제됨", + "session.msg.send_failed" to "전송 실패: %s", + "session.msg.audio_chunk_canceled" to "음성이 너무 길어 전송이 취소되었습니다", + "session.msg.audio_send_failed" to "음성 전송 실패: %s", + "session.msg.handshake_unexpected_type" to "핸드셰이크 중 예상치 못한 메시지 유형: %s", + "session.msg.handshake_parse_failed" to "핸드셰이크 패킷 파싱 실패: %s", + "session.msg.decryption_failed" to "복호화할 수 없는 메시지를 받았습니다", + "session.msg.auth_timeout" to "인증 시간 초과: 네트워크를 확인하고 다시 시도하세요", + "session.msg.auth_request_sent" to "인증 요청을 전송했습니다", + "session.msg.auth_send_failed" to "인증 전송 실패: %s", + "session.msg.ready" to "연결 준비 완료", + "session.msg.unknown_message_type" to "알 수 없는 메시지 유형 수신: %s", + "session.msg.server_rejected" to "서버가 연결을 거부했습니다 (%d): %s", + "session.msg.switching_connection_mode_retry" to "연결 방식을 전환하여 재시도 중", + "session.msg.connection_closed_with_code" to "연결 종료 (%d): %s", + "session.msg.auto_reconnect_in" to "%s, %d초 후 자동 재연결 (%d회차)", + "session.msg.handshake_timeout_with_url" to "핸드셰이크 시간 초과: 서버 publickey 첫 패킷 미수신 (URL: %s)", + "session.error.message_too_large" to "메시지가 너무 큽니다 (%dB). 줄여서 다시 시도하세요", + "session.error.connection_unavailable" to "연결 불가", + "session.notification.channel_name" to "OnlineMsg 알림", + "session.notification.channel_desc" to "서버 새 메시지 수신 시 알림", + "session.notification.new_message" to "새 메시지 수신", + "session.notification.new_voice_message" to "새 음성 메시지 수신", + "session.message.voice" to "음성 메시지", + "session.subtitle.from_key" to "%s 에서", + "session.subtitle.private_to_key" to "비공개 %s", "chat.broadcast" to "브로드캐스트", "chat.private" to "비공개 채팅", "chat.target_key" to "대상 공개키", @@ -163,8 +572,31 @@ object LanguageManager { "chat.send" to "전송", "chat.sending" to "전송 중", "chat.empty_hint" to "연결 후 채팅이 가능합니다. 기본은 브로드캐스트이며, 비공개 채팅으로 전환 후 대상 공개키를 입력할 수 있습니다.", + "chat.mode_text" to "텍스트", + "chat.mode_audio" to "음성", + "chat.audio_hold_to_talk" to "길게 눌러 말하기", + "chat.audio_release_send" to "손을 떼면 전송", + "chat.audio_release_cancel" to "손을 떼면 취소", + "chat.audio_slide_cancel" to "길게 누른 상태에서 위로 밀어 취소", + "chat.audio_canceled" to "음성 전송이 취소되었습니다", + "chat.audio_sent" to "음성 메시지를 보냈습니다", + "chat.audio_too_short" to "녹음 시간이 너무 짧습니다", + "chat.audio_too_long" to "녹음 시간이 너무 깁니다", + "chat.audio_record_failed" to "녹음에 실패했습니다", + "chat.audio_permission_required" to "마이크 권한이 필요합니다", + "chat.audio_recording" to "녹음 중", + "chat.audio_play" to "재생", + "chat.audio_stop" to "정지", "common.copied" to "복사됨", "common.unknown" to "알 수 없음", + "service.foreground.title.ready" to "OnlineMsg 연결 유지됨", + "service.foreground.title.connecting" to "OnlineMsg 연결 중", + "service.foreground.title.error" to "OnlineMsg 연결 오류", + "service.foreground.title.idle" to "OnlineMsg 백그라운드 서비스", + "service.foreground.hint.default" to "백그라운드에서 연결 유지 중", + "service.foreground.action.disconnect" to "연결 끊기", + "service.foreground.channel.name" to "OnlineMsg 백그라운드 연결", + "service.foreground.channel.desc" to "백그라운드에서 WebSocket 장기 연결 유지", "theme.blue" to "파랑", "theme.gray" to "비즈니스 그레이", "theme.green" to "초록", From af50d78f7865ef479b371f0e0f4fd1b75cec143e Mon Sep 17 00:00:00 2001 From: emilia-t Date: Tue, 10 Mar 2026 07:04:28 +0800 Subject: [PATCH 04/16] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=83=A8=E5=88=86?= =?UTF-8?q?=E5=8D=A1=E9=A1=BF=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/onlinemsg/client/ui/ChatScreen.kt | 9 +++++++-- .../com/onlinemsg/client/util/LanguageManager.kt | 12 ++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) 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 c191e39..f286efd 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 @@ -73,6 +73,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.compose.foundation.isSystemInDarkTheme import android.os.Build +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.rememberScrollState import com.onlinemsg.client.ui.theme.OnlineMsgTheme import java.time.Instant import java.time.ZoneId @@ -661,8 +663,11 @@ private fun SettingsTab( if (state.serverUrls.isNotEmpty()) { HorizontalDivider() Text(language("settings.saved_servers"), style = MaterialTheme.typography.labelLarge) - LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - items(state.serverUrls) { url -> + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + state.serverUrls.forEach { url -> AssistChip( onClick = { onSelectServer(url) }, label = { Text(url, maxLines = 1, overflow = TextOverflow.Ellipsis) } 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 3ebc08b..ac245a9 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 @@ -46,10 +46,10 @@ object LanguageManager { "theme.green" to "翠绿", "theme.red" to "绯红", "theme.warm" to "温暖", - "top_bar.link" to "已连接", - "top_bar.dislink" to "未连接", - "top_bar.link_start" to "连接中", - "top_bar.error_dislink" to "异常断开", + "status.idle" to "连接断开", + "status.connecting" to "连接中", + "status.ready" to "已连接", + "status.error" to "异常断开", "status_hint.ready" to "已连接,可以开始聊天", "status_hint.click_to_connect" to "点击连接开始聊天", @@ -70,7 +70,6 @@ object LanguageManager { "status_hint.authenticating" to "正在完成身份验证...", "status_hint.auth_timeout" to "连接超时,请重试", "status_hint.auth_failed" to "认证失败", - "status_hint.ready" to "已连接,可以开始聊天", "status_hint.reconnecting" to "正在自动重试连接...", "status_hint.connection_interrupted_retrying" to "连接已中断,正在重试", "status_hint.reconnect_countdown" to "%d秒后自动重连(第 %d 次)", @@ -140,7 +139,6 @@ object LanguageManager { "status_hint.authenticating" to "Authenticating...", "status_hint.auth_timeout" to "Connection timeout, please retry", "status_hint.auth_failed" to "Authentication failed", - "status_hint.ready" to "Connected, ready to chat", "status_hint.reconnecting" to "Reconnecting...", "status_hint.connection_interrupted_retrying" to "Connection interrupted, retrying", "status_hint.reconnect_countdown" to "Reconnecting in %d seconds (attempt %d)", @@ -210,7 +208,6 @@ object LanguageManager { "status_hint.authenticating" to "認証中...", "status_hint.auth_timeout" to "接続タイムアウト、再試行してください", "status_hint.auth_failed" to "認証に失敗しました", - "status_hint.ready" to "接続完了、チャットを開始できます", "status_hint.reconnecting" to "自動再接続中...", "status_hint.connection_interrupted_retrying" to "接続が切断されました、再試行中", "status_hint.reconnect_countdown" to "%d秒後に再接続(%d回目)", @@ -280,7 +277,6 @@ object LanguageManager { "status_hint.authenticating" to "인증 중...", "status_hint.auth_timeout" to "연결 시간 초과, 다시 시도하세요", "status_hint.auth_failed" to "인증 실패", - "status_hint.ready" to "연결됨, 채팅 가능", "status_hint.reconnecting" to "자동 재연결 중...", "status_hint.connection_interrupted_retrying" to "연결이 끊어짐, 재시도 중", "status_hint.reconnect_countdown" to "%d초 후 자동 재연결 (시도 %d회)", From 0404e665c11dd1b34f684430a28a5020cf6a1f1d Mon Sep 17 00:00:00 2001 From: emilia-t Date: Tue, 10 Mar 2026 07:29:41 +0800 Subject: [PATCH 05/16] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=B9=81=E4=BD=93?= =?UTF-8?q?=E4=B8=AD=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onlinemsg/client/util/LanguageManager.kt | 75 ++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) 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 ac245a9..40cc2bc 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 @@ -282,6 +282,76 @@ object LanguageManager { "status_hint.reconnect_countdown" to "%d초 후 자동 재연결 (시도 %d회)", "status_hint.reconnect_failed_invalid_url" to "재연결 실패: 서버 주소가 유효하지 않음", "status_hint.hello_timeout" to "핸드셰이크 시간 초과, 서버 경로와 리버스 프록시를 확인하세요" + ), + // 新增繁体中文 + "zh-Hant" to mapOf( + "tab.chat" to "聊天", + "tab.settings" to "設定", + "settings.personal" to "個人設定", + "settings.display_name" to "顯示名稱", + "settings.chat_data" to "聊天資料", + "settings.server" to "伺服器", + "settings.server_url" to "伺服器位址", + "settings.save_server" to "儲存位址", + "settings.remove_current" to "刪除目前", + "settings.saved_servers" to "已儲存位址", + "settings.identity" to "身份與安全", + "settings.reveal_key" to "檢視/產生公鑰", + "settings.copy_key" to "複製公鑰", + "settings.my_key" to "我的公鑰", + "settings.theme" to "主題", + "settings.preset_themes" to "預設主題", + "settings.language" to "語言", + "settings.diagnostics" to "診斷", + "settings.status_hint" to "連線提示", + "settings.current_status" to "目前狀態", + "settings.cert_fingerprint" to "憑證指紋", + "settings.show_system" to "顯示系統訊息", + "settings.clear_msg" to "清空訊息", + "settings.dynamic_color" to "使用動態顏色", + "chat.broadcast" to "廣播", + "chat.private" to "私訊", + "chat.target_key" to "目標公鑰", + "chat.input_placeholder" to "輸入訊息", + "chat.send" to "傳送", + "chat.sending" to "傳送中", + "chat.empty_hint" to "連線後即可聊天。預設為廣播,切換到私訊後可填寫目標公鑰。", + "common.copied" to "已複製", + "common.unknown" to "未知", + "theme.blue" to "蔚藍", + "theme.gray" to "商務灰", + "theme.green" to "翠綠", + "theme.red" to "緋紅", + "theme.warm" to "溫暖", + "status.idle" to "連線中斷", + "status.connecting" to "連線中", + "status.ready" to "已連線", + "status.error" to "異常中斷", + + "status_hint.ready" to "已連線,可以開始聊天", + "status_hint.click_to_connect" to "點擊連線開始聊天", + "status_hint.handshaking" to "已連線,正在準備聊天...", + "status_hint.received_binary_handshake" to "收到二進位握手幀,正在嘗試解析...", + "status_hint.connection_error_retrying" to "連線異常,正在重試", + "status_hint.invalid_server_url" to "請填寫有效伺服器位址", + "status_hint.connecting" to "正在連線伺服器...", + "status_hint.disconnected" to "連線已關閉", + "status_hint.server_saved" to "伺服器位址已儲存", + "status_hint.server_removed_default" to "已恢復預設伺服器位址", + "status_hint.server_removed" to "已移除目前伺服器位址", + "status_hint.target_key_required" to "請先填寫目標公鑰,再傳送私訊", + "status_hint.hello_received" to "已收到握手資料,正在解析...", + "status_hint.handshake_failed_incomplete" to "握手失敗:伺服器回應不完整", + "status_hint.handshake_failed_unexpected" to "握手失敗:收到非預期訊息", + "status_hint.handshake_failed_parse" to "握手失敗:首包解析失敗", + "status_hint.authenticating" to "正在完成身份驗證...", + "status_hint.auth_timeout" to "連線超時,請重試", + "status_hint.auth_failed" to "認證失敗", + "status_hint.reconnecting" to "正在自動重試連線...", + "status_hint.connection_interrupted_retrying" to "連線已中斷,正在重試", + "status_hint.reconnect_countdown" to "%d秒後自動重連(第 %d 次)", + "status_hint.reconnect_failed_invalid_url" to "重連失敗:伺服器位址無效", + "status_hint.hello_timeout" to "握手超時,請檢查位址路徑與反向代理" ) ) @@ -290,11 +360,12 @@ object LanguageManager { } val supportedLanguages = listOf( - LanguageOption("zh", "中文"), + LanguageOption("zh", "中文简体"), + LanguageOption("zh-Hant", "繁體中文"), LanguageOption("en", "English"), LanguageOption("ja", "日本语"), LanguageOption("ko", "한국어") ) } -data class LanguageOption(val code: String, val name: String) +data class LanguageOption(val code: String, val name: String) \ No newline at end of file From b1b86487b75c6ead21bc41c725fa98a0a47242b8 Mon Sep 17 00:00:00 2001 From: alimu Date: Tue, 10 Mar 2026 21:03:32 +0400 Subject: [PATCH 06/16] feat(web): add voice message recording, chunking, and playback --- web-client/src/App.jsx | 799 +++++++++++++++++++++++++++++++++++--- web-client/src/styles.css | 159 +++++++- 2 files changed, 905 insertions(+), 53 deletions(-) diff --git a/web-client/src/App.jsx b/web-client/src/App.jsx index a6f627b..650b588 100644 --- a/web-client/src/App.jsx +++ b/web-client/src/App.jsx @@ -23,6 +23,15 @@ const STORAGE_CURRENT_SERVER_URL_KEY = "oms_current_server_url"; const MAX_SERVER_URLS = 8; const CHANNEL_BROADCAST = "broadcast"; const CHANNEL_PRIVATE = "private"; +const CONTENT_TEXT = "text"; +const CONTENT_AUDIO = "audio"; +const AUDIO_MESSAGE_PREFIX = "[[OMS_AUDIO_V1]]"; +const AUDIO_CHUNK_MESSAGE_PREFIX = "[[OMS_AUDIO_CHUNK_V1]]"; +const AUDIO_CHUNK_BASE64_SIZE = 20_000; +const MAX_AUDIO_CHUNK_COUNT = 30; +const AUDIO_CHUNK_BUFFER_TTL_MS = 180_000; +const MIN_AUDIO_DURATION_MS = 350; +const AUDIO_CANCEL_TRIGGER_PX = 96; function isLikelyLocalHost(host) { const value = (host || "").toLowerCase(); @@ -156,6 +165,118 @@ function summarizeKey(key = "") { return `${key.slice(0, 8)}...${key.slice(-8)}`; } +function asPayloadText(data) { + if (typeof data === "string") return data; + if (data == null) return ""; + if (typeof data === "object" && typeof data.payload === "string") { + return data.payload; + } + return String(data); +} + +function splitAudioBase64(base64, chunkSize) { + if (!base64 || chunkSize <= 0) return []; + if (base64.length <= chunkSize) return [base64]; + const chunks = []; + for (let i = 0; i < base64.length; i += chunkSize) { + chunks.push(base64.slice(i, i + chunkSize)); + } + return chunks; +} + +function parseAudioPayload(payloadText) { + if (!payloadText.startsWith(AUDIO_MESSAGE_PREFIX)) return null; + const encoded = payloadText.slice(AUDIO_MESSAGE_PREFIX.length).trim(); + if (!encoded) return null; + const parsed = safeJsonParse(encoded); + if (!parsed || !parsed.data) { + return null; + } + const encoding = String(parsed.encoding || "base64").toLowerCase(); + if (encoding !== "base64") { + return null; + } + return { + mimeType: parsed.mimeType || "audio/mp4", + durationMillis: Number(parsed.durationMillis) || 0, + data: String(parsed.data || "") + }; +} + +function parseAudioChunkPayload(payloadText) { + if (!payloadText.startsWith(AUDIO_CHUNK_MESSAGE_PREFIX)) return null; + const encoded = payloadText.slice(AUDIO_CHUNK_MESSAGE_PREFIX.length).trim(); + if (!encoded) return null; + const parsed = safeJsonParse(encoded); + if (!parsed) { + return null; + } + const encoding = String(parsed.encoding || "base64").toLowerCase(); + if (encoding !== "base64") { + return null; + } + const total = Number(parsed.total); + const index = Number(parsed.index); + const messageId = String(parsed.messageId || ""); + const data = String(parsed.data || ""); + if (!messageId || !data || !Number.isInteger(total) || !Number.isInteger(index)) return null; + if (total < 1 || total > MAX_AUDIO_CHUNK_COUNT || index < 0 || index >= total) return null; + return { + mimeType: parsed.mimeType || "audio/mp4", + messageId, + index, + total, + durationMillis: Number(parsed.durationMillis) || 0, + data + }; +} + +function formatAudioDuration(durationMillis) { + const totalSeconds = Math.max(0, Math.floor((Number(durationMillis) || 0) / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return minutes > 0 ? `${minutes}:${String(seconds).padStart(2, "0")}` : `${seconds}s`; +} + +function formatRecordingElapsed(durationMillis) { + const clamped = Math.max(0, Number(durationMillis) || 0); + const seconds = Math.floor(clamped / 1000); + const tenths = Math.floor((clamped % 1000) / 100); + return `${seconds}.${tenths}s`; +} + +function base64ToBytes(base64) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function blobToBase64(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = String(reader.result || ""); + const marker = "base64,"; + const index = result.indexOf(marker); + resolve(index >= 0 ? result.slice(index + marker.length) : result); + }; + reader.onerror = () => reject(reader.error || new Error("读取录音失败")); + reader.readAsDataURL(blob); + }); +} + +function pickRecordingMimeType() { + if (typeof MediaRecorder === "undefined" || typeof MediaRecorder.isTypeSupported !== "function") { + return ""; + } + // Android 客户端当前仅稳定兼容 mp4/aac 语音载荷 + const candidates = ["audio/mp4;codecs=mp4a.40.2", "audio/mp4"]; + return candidates.find((item) => MediaRecorder.isTypeSupported(item)) || ""; +} + function createLocalId() { const c = globalThis.crypto; if (c?.randomUUID) { @@ -206,6 +327,14 @@ export default function App() { const targetComposingRef = useRef(false); const messageListRef = useRef(null); const stickToBottomRef = useRef(true); + const incomingAudioChunkBuffersRef = useRef(new Map()); + const audioPlayerRef = useRef(null); + const audioObjectUrlRef = useRef(""); + const recordingSessionRef = useRef(null); + const recordingTimerRef = useRef(0); + const recordingStartedAtRef = useRef(0); + const recordPressDownYRef = useRef(0); + const transientStatusTimerRef = useRef(0); const [status, setStatus] = useState("idle"); const [statusHint, setStatusHint] = useState("点击连接开始聊天"); @@ -219,6 +348,11 @@ export default function App() { const [messages, setMessages] = useState([]); const [showSystemMessages, setShowSystemMessages] = useState(false); const [sending, setSending] = useState(false); + const [inputMode, setInputMode] = useState(CONTENT_TEXT); + const [isRecording, setIsRecording] = useState(false); + const [cancelOnRelease, setCancelOnRelease] = useState(false); + const [recordingElapsedMillis, setRecordingElapsedMillis] = useState(0); + const [playingMessageId, setPlayingMessageId] = useState(""); const [certFingerprint, setCertFingerprint] = useState(""); const [myPublicKey, setMyPublicKey] = useState(""); const [publicKeyBusy, setPublicKeyBusy] = useState(false); @@ -230,6 +364,7 @@ export default function App() { const canConnect = status === "idle" || status === "error"; const canDisconnect = status !== "idle" && status !== "error"; const canSend = isConnected && draft.trim().length > 0 && !sending; + const canHoldToRecord = isConnected && !sending && (!directMode || targetKey.trim().length > 0); const activeChannel = directMode ? CHANNEL_PRIVATE : CHANNEL_BROADCAST; const mobileConnectText = useMemo(() => { if (status === "ready") return "已连接"; @@ -309,6 +444,44 @@ export default function App() { clearTimeout(messageCopyTimerRef.current); messageCopyTimerRef.current = 0; } + if (recordingTimerRef.current) { + clearInterval(recordingTimerRef.current); + recordingTimerRef.current = 0; + } + if (transientStatusTimerRef.current) { + clearTimeout(transientStatusTimerRef.current); + transientStatusTimerRef.current = 0; + } + if (recordingSessionRef.current) { + try { + if (recordingSessionRef.current.recorder?.state !== "inactive") { + recordingSessionRef.current.recorder.stop(); + } + } catch { + // ignore + } + recordingSessionRef.current.stream?.getTracks?.().forEach((track) => { + try { + track.stop(); + } catch { + // ignore + } + }); + recordingSessionRef.current = null; + } + if (audioPlayerRef.current) { + try { + audioPlayerRef.current.pause(); + } catch { + // ignore + } + audioPlayerRef.current.src = ""; + audioPlayerRef.current = null; + } + if (audioObjectUrlRef.current) { + URL.revokeObjectURL(audioObjectUrlRef.current); + audioObjectUrlRef.current = ""; + } }; }, []); @@ -362,7 +535,7 @@ export default function App() { ]); } - function pushIncoming(sender, text, subtitle = "", channel = CHANNEL_BROADCAST) { + function pushIncoming(sender, text, subtitle = "", channel = CHANNEL_BROADCAST, options = {}) { setMessages((prev) => [ ...prev, { @@ -372,12 +545,16 @@ export default function App() { subtitle, channel, content: text, + contentType: options.contentType || CONTENT_TEXT, + audioBase64: options.audioBase64 || "", + audioDurationMillis: Number(options.audioDurationMillis) || 0, + audioMimeType: options.audioMimeType || "", ts: Date.now() } ]); } - function pushOutgoing(text, subtitle = "", channel = CHANNEL_BROADCAST) { + function pushOutgoing(text, subtitle = "", channel = CHANNEL_BROADCAST, options = {}) { setMessages((prev) => [ ...prev, { @@ -387,11 +564,47 @@ export default function App() { subtitle, channel, content: text, + contentType: options.contentType || CONTENT_TEXT, + audioBase64: options.audioBase64 || "", + audioDurationMillis: Number(options.audioDurationMillis) || 0, + audioMimeType: options.audioMimeType || "", ts: Date.now() } ]); } + function pushIncomingAudio(sender, subtitle, audioBase64, durationMillis, mimeType, channel) { + pushIncoming(sender, "语音消息", subtitle, channel, { + contentType: CONTENT_AUDIO, + audioBase64, + audioDurationMillis: durationMillis, + audioMimeType: mimeType + }); + } + + function pushOutgoingAudio(subtitle, audioBase64, durationMillis, mimeType, channel) { + pushOutgoing("语音消息", subtitle, channel, { + contentType: CONTENT_AUDIO, + audioBase64, + audioDurationMillis: durationMillis, + audioMimeType: mimeType + }); + } + + function showTransientStatusHint(text, durationMs = 2200) { + setStatusHint(text); + if (transientStatusTimerRef.current) { + clearTimeout(transientStatusTimerRef.current); + transientStatusTimerRef.current = 0; + } + transientStatusTimerRef.current = window.setTimeout(() => { + transientStatusTimerRef.current = 0; + if (statusRef.current === "ready") { + setStatusHint("已连接,可以开始聊天"); + } + }, durationMs); + } + async function ensureIdentity() { if (identityRef.current) { return identityRef.current; @@ -484,6 +697,399 @@ export default function App() { }, 1600); } + function purgeExpiredAudioChunkBuffers(nowMillis = Date.now()) { + const map = incomingAudioChunkBuffersRef.current; + if (!map.size) return; + for (const [key, value] of map.entries()) { + if (nowMillis - value.createdAtMillis >= AUDIO_CHUNK_BUFFER_TTL_MS) { + map.delete(key); + } + } + } + + function ingestIncomingAudioChunk(sender, subtitle, channel, chunk) { + const now = Date.now(); + purgeExpiredAudioChunkBuffers(now); + const key = `${channel}:${sender}:${chunk.messageId}`; + const map = incomingAudioChunkBuffersRef.current; + const existing = map.get(key); + const active = + !existing || existing.total !== chunk.total + ? { + sender, + subtitle, + channel, + total: chunk.total, + durationMillis: Math.max(0, Number(chunk.durationMillis) || 0), + mimeType: chunk.mimeType || "audio/mp4", + createdAtMillis: now, + chunks: Array.from({ length: chunk.total }, () => "") + } + : existing; + + if (!existing || existing.total !== chunk.total) { + map.set(key, active); + } else if (existing.sender !== sender || existing.channel !== channel) { + return; + } + + active.chunks[chunk.index] = chunk.data; + if (active.chunks.some((part) => !part)) return; + map.delete(key); + const merged = active.chunks.join(""); + if (!merged) return; + pushIncomingAudio( + active.sender, + active.subtitle, + merged, + active.durationMillis, + active.mimeType, + active.channel + ); + } + + function releaseAudioObjectUrl() { + if (!audioObjectUrlRef.current) return; + URL.revokeObjectURL(audioObjectUrlRef.current); + audioObjectUrlRef.current = ""; + } + + function stopAudioPlayback() { + if (audioPlayerRef.current) { + try { + audioPlayerRef.current.pause(); + } catch { + // ignore + } + audioPlayerRef.current.currentTime = 0; + audioPlayerRef.current.src = ""; + } + releaseAudioObjectUrl(); + setPlayingMessageId(""); + } + + async function togglePlayAudioMessage(item) { + if (!item?.audioBase64) return; + if (playingMessageId === item.id) { + stopAudioPlayback(); + return; + } + stopAudioPlayback(); + try { + const bytes = base64ToBytes(item.audioBase64); + if (!bytes.length) { + pushSystem("语音播放失败:空数据"); + return; + } + const mimeType = item.audioMimeType || "audio/mp4"; + const blob = new Blob([bytes], { type: mimeType }); + const url = URL.createObjectURL(blob); + releaseAudioObjectUrl(); + audioObjectUrlRef.current = url; + + if (!audioPlayerRef.current) { + audioPlayerRef.current = new Audio(); + } + const player = audioPlayerRef.current; + player.onended = () => { + stopAudioPlayback(); + }; + player.onerror = () => { + stopAudioPlayback(); + pushSystem("语音播放失败:浏览器不支持该音频格式"); + }; + player.src = url; + await player.play(); + setPlayingMessageId(item.id); + } catch (error) { + stopAudioPlayback(); + pushSystem(`语音播放失败:${error?.message || "unknown error"}`); + } + } + + async function sendSignedPayload(type, key, payloadText) { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) { + throw new Error("连接不可用"); + } + const identity = identityRef.current; + const serverPublicKey = serverPublicKeyRef.current; + if (!identity || !serverPublicKey) { + throw new Error("身份或服务端公钥未就绪"); + } + + const timestamp = unixSecondsNow(); + const nonce = createNonce(); + const signInput = [type, key, payloadText, timestamp, nonce].join("\n"); + const signature = await signText(identity.signPrivateKey, signInput); + + const envelope = { + type, + key, + data: { + payload: payloadText, + timestamp, + nonce, + signature + } + }; + + const cipher = await rsaEncryptChunked(serverPublicKey, JSON.stringify(envelope)); + ws.send(cipher); + } + + async function sendAudioMessage(audioBase64, durationMillis, mimeType = "audio/mp4") { + if (!isConnected || sending) return; + const normalized = String(audioBase64 || "").trim(); + if (!normalized) return; + + const key = directMode ? targetKey.trim() : ""; + if (directMode && !key) { + showTransientStatusHint("请先填写目标公钥,再发送私聊消息"); + return; + } + const type = key ? "forward" : "broadcast"; + const channel = key ? CHANNEL_PRIVATE : CHANNEL_BROADCAST; + const subtitle = key ? `私聊 ${summarizeKey(key)}` : ""; + const safeDuration = Math.max(0, Number(durationMillis) || 0); + const chunks = splitAudioBase64(normalized, AUDIO_CHUNK_BASE64_SIZE); + if (chunks.length > MAX_AUDIO_CHUNK_COUNT) { + showTransientStatusHint("语音过长,超过可发送分片上限"); + pushSystem("语音过长,已取消发送"); + return; + } + + setSending(true); + try { + if (chunks.length === 1) { + const payloadText = + AUDIO_MESSAGE_PREFIX + + JSON.stringify({ + version: 1, + encoding: "base64", + mimeType: mimeType || "audio/mp4", + durationMillis: safeDuration, + data: normalized + }); + await sendSignedPayload(type, key, payloadText); + } else { + const messageId = createLocalId(); + for (let i = 0; i < chunks.length; i += 1) { + const payloadText = + AUDIO_CHUNK_MESSAGE_PREFIX + + JSON.stringify({ + version: 1, + encoding: "base64", + mimeType: mimeType || "audio/mp4", + messageId, + index: i, + total: chunks.length, + durationMillis: safeDuration, + data: chunks[i] + }); + await sendSignedPayload(type, key, payloadText); + } + } + + pushOutgoingAudio(subtitle, normalized, safeDuration, mimeType || "audio/mp4", channel); + } catch (error) { + const message = error?.message || "unknown error"; + showTransientStatusHint(`语音发送失败:${message}`); + pushSystem(`语音发送失败:${message}`); + } finally { + setSending(false); + } + } + + function clearRecordingTick() { + if (!recordingTimerRef.current) return; + clearInterval(recordingTimerRef.current); + recordingTimerRef.current = 0; + } + + async function startRecording() { + if (recordingSessionRef.current || isRecording) return; + if (!canHoldToRecord) { + if (directMode && !targetKey.trim()) { + showTransientStatusHint("请先填写目标公钥,再发送私聊消息"); + } + return; + } + if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") { + showTransientStatusHint("当前浏览器不支持语音录制"); + pushSystem("语音录制失败:浏览器不支持 MediaRecorder"); + return; + } + + let stream; + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch (error) { + showTransientStatusHint("请先授予麦克风权限"); + pushSystem(`语音录制失败:${error?.message || "未获得权限"}`); + return; + } + + const mimeType = pickRecordingMimeType(); + if (!mimeType) { + stream.getTracks().forEach((track) => { + try { + track.stop(); + } catch { + // ignore + } + }); + showTransientStatusHint("当前浏览器不支持 MP4 语音录制"); + pushSystem("语音录制失败:当前浏览器不支持 MP4/AAC 编码,Android 端可能无法播放"); + return; + } + const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); + const session = { + stream, + recorder, + chunks: [], + mimeType: recorder.mimeType || "audio/mp4", + sendOnStop: true + }; + recordingSessionRef.current = session; + recordingStartedAtRef.current = Date.now(); + setRecordingElapsedMillis(0); + setIsRecording(true); + setCancelOnRelease(false); + clearRecordingTick(); + recordingTimerRef.current = window.setInterval(() => { + setRecordingElapsedMillis(Math.max(0, Date.now() - recordingStartedAtRef.current)); + }, 100); + + recorder.ondataavailable = (event) => { + if (event.data && event.data.size > 0) { + session.chunks.push(event.data); + } + }; + recorder.onerror = () => { + showTransientStatusHint("录音失败,请重试"); + pushSystem("语音录制失败:MediaRecorder 发生错误"); + }; + recorder.onstop = async () => { + clearRecordingTick(); + const recordedDuration = Math.max(0, Date.now() - recordingStartedAtRef.current); + recordingStartedAtRef.current = 0; + setIsRecording(false); + setCancelOnRelease(false); + setRecordingElapsedMillis(0); + recordingSessionRef.current = null; + session.stream.getTracks().forEach((track) => { + try { + track.stop(); + } catch { + // ignore + } + }); + + if (!session.sendOnStop) { + showTransientStatusHint("已取消语音发送", 1600); + return; + } + if (recordedDuration < MIN_AUDIO_DURATION_MS) { + showTransientStatusHint("录音时间太短", 1800); + return; + } + if (session.chunks.length === 0) { + showTransientStatusHint("录音失败,请重试"); + pushSystem("语音录制失败:未采集到音频数据"); + return; + } + + try { + const blob = new Blob(session.chunks, { type: session.mimeType || "audio/webm" }); + const base64 = await blobToBase64(blob); + await sendAudioMessage(base64, recordedDuration, session.mimeType || "audio/webm"); + } catch (error) { + showTransientStatusHint("录音失败,请重试"); + pushSystem(`语音录制失败:${error?.message || "unknown error"}`); + } + }; + + try { + recorder.start(); + } catch (error) { + clearRecordingTick(); + recordingSessionRef.current = null; + recordingStartedAtRef.current = 0; + setIsRecording(false); + setCancelOnRelease(false); + setRecordingElapsedMillis(0); + stream.getTracks().forEach((track) => { + try { + track.stop(); + } catch { + // ignore + } + }); + showTransientStatusHint("录音失败,请重试"); + pushSystem(`语音录制失败:${error?.message || "unknown error"}`); + return; + } + } + + function finishRecording(send) { + const session = recordingSessionRef.current; + if (!session) return; + session.sendOnStop = Boolean(send); + clearRecordingTick(); + setIsRecording(false); + setRecordingElapsedMillis(0); + try { + if (session.recorder.state !== "inactive") { + session.recorder.stop(); + } + } catch { + session.stream.getTracks().forEach((track) => { + try { + track.stop(); + } catch { + // ignore + } + }); + recordingSessionRef.current = null; + showTransientStatusHint("录音失败,请重试"); + } + } + + async function onHoldToTalkPointerDown(event) { + event.preventDefault(); + recordPressDownYRef.current = event.clientY; + setCancelOnRelease(false); + try { + event.currentTarget.setPointerCapture?.(event.pointerId); + } catch { + // ignore + } + await startRecording(); + } + + function onHoldToTalkPointerMove(event) { + if (!isRecording) return; + const deltaY = recordPressDownYRef.current - event.clientY; + setCancelOnRelease(deltaY > AUDIO_CANCEL_TRIGGER_PX); + } + + function onHoldToTalkPointerUp(event) { + if (!isRecording) return; + const shouldSend = !cancelOnRelease; + finishRecording(shouldSend); + try { + event.currentTarget.releasePointerCapture?.(event.pointerId); + } catch { + // ignore + } + } + + function onHoldToTalkPointerCancel() { + if (!isRecording) return; + finishRecording(false); + } + async function connect() { if (!canConnect) return; const cryptoIssue = getCryptoIssueMessage(); @@ -579,6 +1185,10 @@ export default function App() { function disconnect() { manualCloseRef.current = true; + if (isRecording) { + finishRecording(false); + } + stopAudioPlayback(); if (wsRef.current) { wsRef.current.close(); wsRef.current = null; @@ -688,13 +1298,52 @@ export default function App() { } if (message.type === "broadcast") { - pushIncoming(message.key || "匿名用户", String(message.data ?? ""), "", CHANNEL_BROADCAST); + const sender = message.key || "匿名用户"; + const payloadText = asPayloadText(message.data); + const audioChunk = parseAudioChunkPayload(payloadText); + if (audioChunk) { + ingestIncomingAudioChunk(sender, "", CHANNEL_BROADCAST, audioChunk); + return; + } + const audio = parseAudioPayload(payloadText); + if (audio) { + pushIncomingAudio( + sender, + "", + audio.data, + audio.durationMillis, + audio.mimeType || "audio/mp4", + CHANNEL_BROADCAST + ); + } else { + pushIncoming(sender, payloadText, "", CHANNEL_BROADCAST); + } return; } if (message.type === "forward") { + const sourceKey = String(message.key || ""); const sender = "私聊消息"; - pushIncoming(sender, String(message.data ?? ""), "", CHANNEL_PRIVATE); + const subtitle = sourceKey ? `来自 ${summarizeKey(sourceKey)}` : ""; + const payloadText = asPayloadText(message.data); + const audioChunk = parseAudioChunkPayload(payloadText); + if (audioChunk) { + ingestIncomingAudioChunk(sender, subtitle, CHANNEL_PRIVATE, audioChunk); + return; + } + const audio = parseAudioPayload(payloadText); + if (audio) { + pushIncomingAudio( + sender, + subtitle, + audio.data, + audio.durationMillis, + audio.mimeType || "audio/mp4", + CHANNEL_PRIVATE + ); + } else { + pushIncoming(sender, payloadText, subtitle, CHANNEL_PRIVATE); + } return; } @@ -727,24 +1376,7 @@ export default function App() { setSending(true); try { - const timestamp = unixSecondsNow(); - const nonce = createNonce(); - const signInput = [type, key, text, timestamp, nonce].join("\n"); - const signature = await signText(identity.signPrivateKey, signInput); - - const envelope = { - type, - key, - data: { - payload: text, - timestamp, - nonce, - signature - } - }; - - const cipher = await rsaEncryptChunked(serverPublicKey, JSON.stringify(envelope)); - ws.send(cipher); + await sendSignedPayload(type, key, text); pushOutgoing(text, subtitle, channel); setDraft(""); } catch (error) { @@ -921,17 +1553,43 @@ export default function App() { ) : ( <> -
- {item.sender} - {item.subtitle ? {item.subtitle} : null} - -
-

{item.content}

-
- -
+ {(() => { + const isAudioMessage = item.contentType === CONTENT_AUDIO && item.audioBase64; + return ( + <> +
+ {item.sender} + {item.subtitle ? {item.subtitle} : null} + +
+ {isAudioMessage ? ( + + ) : ( +

{item.content}

+ )} + {!isAudioMessage ? ( +
+ +
+ ) : null} + + ); + })()} )} @@ -939,25 +1597,64 @@ export default function App() { )} -
-
-