diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt index 9e7974b..13d79a7 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt @@ -24,7 +24,8 @@ data class UserPreferences( val shouldAutoReconnect: Boolean, val themeId: String = "blue", val useDynamicColor: Boolean = true, - val language: String = "zh" // 默认中文 + val language: String = "zh", // 默认中文 + val notificationSound: String = "default" ) class UserPreferencesRepository( @@ -50,7 +51,8 @@ class UserPreferencesRepository( shouldAutoReconnect = prefs[KEY_SHOULD_AUTO_RECONNECT] ?: false, themeId = prefs[KEY_THEME_ID] ?: "blue", useDynamicColor = prefs[KEY_USE_DYNAMIC_COLOR] ?: true, - language = prefs[KEY_LANGUAGE] ?: "zh" + language = prefs[KEY_LANGUAGE] ?: "zh", + notificationSound = prefs[KEY_NOTIFICATION_SOUND] ?: "default" ) } @@ -66,6 +68,12 @@ class UserPreferencesRepository( } } + suspend fun setNotificationSound(sound: String) { + context.dataStore.edit { prefs -> + prefs[KEY_NOTIFICATION_SOUND] = sound + } + } + suspend fun setUseDynamicColor(enabled: Boolean) { context.dataStore.edit { prefs -> prefs[KEY_USE_DYNAMIC_COLOR] = enabled @@ -155,5 +163,6 @@ class UserPreferencesRepository( val KEY_THEME_ID: Preferences.Key = stringPreferencesKey("theme_id") val KEY_USE_DYNAMIC_COLOR: Preferences.Key = booleanPreferencesKey("use_dynamic_color") val KEY_LANGUAGE: Preferences.Key = stringPreferencesKey("language") + val KEY_NOTIFICATION_SOUND: Preferences.Key = stringPreferencesKey("notification_sound") } } 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..389b606 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt @@ -1,7 +1,28 @@ package com.onlinemsg.client.ui -import android.annotation.SuppressLint +import android.Manifest +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.media.AudioAttributes +import android.media.MediaPlayer +import android.os.Build +import android.provider.Settings +import android.util.Base64 +import android.view.MotionEvent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +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,12 +52,18 @@ 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.MusicNote +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 import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider @@ -55,6 +82,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 +90,35 @@ 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.core.app.NotificationManagerCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.compose.foundation.isSystemInDarkTheme -import 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 com.onlinemsg.client.util.NotificationSoundCatalog +import kotlinx.coroutines.delay /** @@ -89,6 +129,11 @@ private enum class MainTab(val labelKey: String) { SETTINGS("tab.settings") } +private enum class ChatInputMode { + TEXT, + AUDIO +} + /** * 应用程序的根可组合函数。 * 集成 ViewModel、主题、Scaffold 以及选项卡切换逻辑。 @@ -123,7 +168,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 +227,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() @@ -199,7 +245,7 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) { onServerUrlChange = viewModel::updateServerUrl, onSaveServer = viewModel::saveCurrentServerUrl, onRemoveServer = viewModel::removeCurrentServerUrl, - onSelectServer = viewModel::updateServerUrl, + onSelectServer = viewModel::selectServerUrl, onToggleShowSystem = viewModel::toggleShowSystemMessages, onRevealPublicKey = viewModel::revealMyPublicKey, onCopyPublicKey = { @@ -211,7 +257,8 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) { onClearMessages = viewModel::clearMessages, onThemeChange = viewModel::updateTheme, onUseDynamicColorChange = viewModel::updateUseDynamicColor, - onLanguageChange = viewModel::updateLanguage + onLanguageChange = viewModel::updateLanguage, + onNotificationSoundChange = viewModel::updateNotificationSound ) } } @@ -276,6 +323,7 @@ private fun AppTopBar( * 包含模式切换、消息列表和输入区域。 */ @Composable +@OptIn(ExperimentalComposeUiApi::class) private fun ChatTab( modifier: Modifier, state: ChatUiState, @@ -283,12 +331,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 +434,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 +487,71 @@ private fun ChatTab( } Spacer(modifier = Modifier.height(8.dp)) + AnimatedVisibility( + visible = state.isSwitchingServer, + enter = fadeIn(animationSpec = tween(180)) + slideInVertically( + animationSpec = tween(220), + initialOffsetY = { -it / 2 } + ), + exit = fadeOut(animationSpec = tween(140)) + slideOutVertically( + animationSpec = tween(180), + targetOffsetY = { -it / 3 } + ) + ) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ), + shape = RoundedCornerShape(14.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = t("session.hint.switching_server"), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + Text( + text = state.switchingServerLabel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + if (state.isSwitchingServer) { + Spacer(modifier = Modifier.height(8.dp)) + } + val statusHintText = if (audioHint.isNotBlank()) { + audioHint + } else { + 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 +597,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 +617,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 +810,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 +855,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 +902,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 +926,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 +983,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 修饰符 @@ -591,8 +1106,17 @@ private fun SettingsTab( onClearMessages: () -> Unit, onThemeChange: (String) -> Unit, onUseDynamicColorChange: (Boolean) -> Unit, - onLanguageChange: (String) -> Unit + onLanguageChange: (String) -> Unit, + onNotificationSoundChange: (String) -> Unit ) { + val context = LocalContext.current + val previewPlayer = remember(context) { NotificationSoundPreviewPlayer(context) } + val notificationStatus = rememberNotificationSoundStatus(state.notificationSound) + + DisposableEffect(Unit) { + onDispose { previewPlayer.release() } + } + fun t(key: String) = LanguageManager.getString(key, state.language) val settingsCardModifier = Modifier.fillMaxWidth() @@ -632,7 +1156,67 @@ private fun SettingsTab( modifier = settingsCardContentModifier, verticalArrangement = settingsCardContentSpacing ) { - Text("聊天数据", style = MaterialTheme.typography.titleMedium) + Text(t("settings.notification_sound"), style = MaterialTheme.typography.titleMedium) + Text( + text = when { + !notificationStatus.notificationsEnabled -> t("settings.notification_disabled") + !notificationStatus.channelSoundEnabled -> t("settings.notification_sound_disabled") + else -> t("settings.notification_enabled") + }, + style = MaterialTheme.typography.bodySmall, + color = if (notificationStatus.notificationsEnabled && notificationStatus.channelSoundEnabled) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.error + } + ) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(NotificationSoundCatalog.soundCodes) { sound -> + FilterChip( + selected = state.notificationSound == sound, + onClick = { + previewPlayer.play(sound) + onNotificationSoundChange(sound) + }, + label = { Text(t("sound.$sound")) }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.MusicNote, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + ) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { openAppNotificationSettings(context) }) { + Text(t("settings.notification_system_settings")) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + OutlinedButton( + onClick = { + openNotificationChannelSettings( + context = context, + soundCode = state.notificationSound + ) + } + ) { + Text(t("settings.notification_channel_settings")) + } + } + } + } + } + } + + item { + Card(modifier = settingsCardModifier) { + Column( + modifier = settingsCardContentModifier, + verticalArrangement = settingsCardContentSpacing + ) { + Text(t("settings.chat_data"), style = MaterialTheme.typography.titleMedium) OutlinedButton(onClick = onClearMessages) { Text(t("settings.clear_msg")) } @@ -787,8 +1371,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 +1389,256 @@ 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 data class NotificationSoundStatus( + val notificationsEnabled: Boolean, + val channelSoundEnabled: Boolean +) + +@Composable +private fun rememberNotificationSoundStatus(soundCode: String): NotificationSoundStatus { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var status by remember(soundCode, context) { + mutableStateOf(queryNotificationSoundStatus(context, soundCode)) + } + + LaunchedEffect(soundCode, context) { + status = queryNotificationSoundStatus(context, soundCode) + } + + DisposableEffect(soundCode, context, lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + status = queryNotificationSoundStatus(context, soundCode) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + return status +} + +private fun queryNotificationSoundStatus(context: Context, soundCode: String): NotificationSoundStatus { + val notificationsEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled() + if (!notificationsEnabled) { + return NotificationSoundStatus( + notificationsEnabled = false, + channelSoundEnabled = false + ) + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return NotificationSoundStatus( + notificationsEnabled = true, + channelSoundEnabled = true + ) + } + + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channel = manager.getNotificationChannel(NotificationSoundCatalog.channelId(soundCode)) + val channelSoundEnabled = channel?.importance != NotificationManager.IMPORTANCE_NONE && + (channel?.sound != null || channel == null) + return NotificationSoundStatus( + notificationsEnabled = true, + channelSoundEnabled = channelSoundEnabled + ) +} + +private fun openAppNotificationSettings(context: Context) { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) +} + +private fun openNotificationChannelSettings(context: Context, soundCode: String) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + openAppNotificationSettings(context) + return + } + val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + putExtra(Settings.EXTRA_CHANNEL_ID, NotificationSoundCatalog.channelId(soundCode)) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) +} + +private class NotificationSoundPreviewPlayer(private val context: Context) { + private var mediaPlayer: MediaPlayer? = null + + fun play(soundCode: String) { + val resId = NotificationSoundCatalog.resId(soundCode) ?: return + release() + val afd = context.resources.openRawResourceFd(resId) ?: return + val player = MediaPlayer() + val started = runCatching { + player.setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION_EVENT) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + ) + player.setDataSource(afd.fileDescriptor, afd.startOffset, afd.length) + afd.close() + player.setOnCompletionListener { release() } + player.setOnErrorListener { _, _, _ -> + release() + true + } + player.prepare() + player.start() + true + }.getOrElse { + runCatching { afd.close() } + runCatching { player.release() } + false + } + if (!started) return + mediaPlayer = player + } + + fun release() { + runCatching { mediaPlayer?.stop() } + runCatching { mediaPlayer?.release() } + mediaPlayer = null + } +} + +private fun formatAudioDuration(durationMillis: Long): String { + val totalSeconds = (durationMillis / 1000L).coerceAtLeast(0L) + val minutes = totalSeconds / 60L + 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..73813ef 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt @@ -5,27 +5,33 @@ import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent +import android.content.ContentResolver import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.media.AudioAttributes +import android.net.Uri import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import com.onlinemsg.client.MainActivity -import com.onlinemsg.client.R import com.onlinemsg.client.data.crypto.RsaCryptoManager import com.onlinemsg.client.data.local.ChatDatabase import com.onlinemsg.client.data.local.ChatHistoryRepository 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 com.onlinemsg.client.util.NotificationSoundCatalog import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -48,6 +54,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 +66,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 +109,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 +118,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 +131,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 +144,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 +152,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 +179,17 @@ 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"), + isSwitchingServer = false, + switchingServerLabel = "" ) } - scheduleReconnect("连接异常") + scheduleReconnect(t("session.reason.connection_error")) } } } @@ -175,14 +206,13 @@ object ChatSessionManager { 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) + historyRepository.loadMessages(serverKeyFor(pref.currentServerUrl), MAX_MESSAGES) } keepAliveRequested = pref.shouldAutoReconnect + ensureMessageNotificationChannel(pref.notificationSound) _uiState.update { current -> current.copy( displayName = pref.displayName, @@ -193,6 +223,7 @@ object ChatSessionManager { themeId = pref.themeId, useDynamicColor = pref.useDynamicColor, language = pref.language, + notificationSound = pref.notificationSound, messages = historyMessages ) } @@ -227,6 +258,18 @@ object ChatSessionManager { } } + /** + * 更新通知音效。 + * @param sound 音效代号 + */ + fun updateNotificationSound(sound: String) { + _uiState.update { it.copy(notificationSound = sound) } + scope.launch { + preferencesRepository.setNotificationSound(sound) + ensureMessageNotificationChannel(sound) + } + } + /** * 更改使用动态颜色 * @param enabled 主题名 @@ -258,6 +301,20 @@ object ChatSessionManager { _uiState.update { it.copy(serverUrl = value) } } + /** + * 选择历史服务器并切换会话。 + */ + fun selectServerUrl(value: String) { + val normalized = ServerUrlFormatter.normalize(value) + if (normalized.isBlank()) return + switchServer( + normalized = normalized, + nextUrls = _uiState.value.serverUrls, + statusHint = null, + persist = { preferencesRepository.setCurrentServerUrl(normalized) } + ) + } + /** * 更新私聊目标公钥。 * @param value 公钥字符串 @@ -304,7 +361,7 @@ object ChatSessionManager { _uiState.update { it.copy(messages = emptyList()) } scope.launch(Dispatchers.IO) { runCatching { - historyRepository.clearAll() + historyRepository.clearAll(currentServerKey()) } } } @@ -316,24 +373,19 @@ 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 } val nextUrls = ServerUrlFormatter.append(_uiState.value.serverUrls, normalized) - _uiState.update { - it.copy( - serverUrl = normalized, - serverUrls = nextUrls, - statusHint = "服务器地址已保存" - ) - } - - scope.launch { - preferencesRepository.saveCurrentServerUrl(normalized) - _events.emit(UiEvent.ShowSnackbar("服务器地址已保存")) - } + switchServer( + normalized = normalized, + nextUrls = nextUrls, + statusHint = t("session.hint.server_saved"), + persist = { preferencesRepository.saveCurrentServerUrl(normalized) }, + snackbarMessage = t("session.snackbar.server_saved") + ) } /** @@ -351,18 +403,17 @@ object ChatSessionManager { filtered } - _uiState.update { - it.copy( - serverUrls = nextUrls, - serverUrl = nextUrls.first(), - statusHint = if (filtered.isEmpty()) "已恢复默认服务器地址" else "已移除当前服务器地址" - ) - } - - scope.launch { - preferencesRepository.removeCurrentServerUrl(normalized) - _events.emit(UiEvent.ShowSnackbar("已更新服务器地址列表")) - } + switchServer( + normalized = nextUrls.first(), + nextUrls = nextUrls, + statusHint = if (filtered.isEmpty()) { + t("session.hint.server_restored_default") + } else { + t("session.hint.server_removed") + }, + persist = { preferencesRepository.removeCurrentServerUrl(normalized) }, + snackbarMessage = t("session.snackbar.server_list_updated") + ) } /** @@ -382,7 +433,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") + ) + ) + ) } } } @@ -398,17 +456,23 @@ object ChatSessionManager { * 内部连接逻辑,区分自动恢复和手动连接。 * @param isAutoRestore 是否为应用启动时的自动恢复连接 */ - private fun connectInternal(isAutoRestore: Boolean) { + private fun connectInternal( + isAutoRestore: Boolean, + overrideUrl: String? = null, + forceReconnect: Boolean = false + ) { if (!initialized) return val state = _uiState.value - if (!state.canConnect) return + if (!forceReconnect && !state.canConnect) return - val normalized = ServerUrlFormatter.normalize(state.serverUrl) + val normalized = ServerUrlFormatter.normalize(overrideUrl ?: state.serverUrl) if (normalized.isBlank()) { _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "请填写有效服务器地址" + statusHint = t("session.hint.fill_valid_server"), + isSwitchingServer = false, + switchingServerLabel = "" ) } return @@ -427,7 +491,7 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.CONNECTING, - statusHint = "正在连接服务器...", + statusHint = t("session.hint.connecting_server"), serverUrl = normalized, certFingerprint = "" ) @@ -442,7 +506,7 @@ object ChatSessionManager { socketClient.connect(normalized, socketListener) if (isAutoRestore) { - addSystemMessage("已恢复上次会话,正在自动连接") + addSystemMessage(t("session.msg.auto_restore_connecting")) } } @@ -459,7 +523,9 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.IDLE, - statusHint = "连接已关闭" + statusHint = t("session.hint.connection_closed"), + isSwitchingServer = false, + switchingServerLabel = "" ) } autoReconnectTriggered = false @@ -470,7 +536,7 @@ object ChatSessionManager { if (stopService) { ChatForegroundService.stop(app) } - addSystemMessage("已断开连接") + addSystemMessage(t("session.msg.disconnected")) } /** @@ -480,56 +546,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 +645,7 @@ object ChatSessionManager { */ fun onMessageCopied() { scope.launch { - _events.emit(UiEvent.ShowSnackbar("已复制")) + _events.emit(UiEvent.ShowSnackbar(t("common.copied"))) } } @@ -563,7 +669,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 +704,9 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "握手失败:服务端响应不完整" + statusHint = t("session.hint.handshake_incomplete_response"), + isSwitchingServer = false, + switchingServerLabel = "" ) } return @@ -609,15 +717,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 +735,7 @@ object ChatSessionManager { cryptoManager.decryptChunked(id.privateKey, normalizedText) } }.getOrElse { - addSystemMessage("收到无法解密的消息") + addSystemMessage(t("session.msg.decryption_failed")) return } @@ -648,7 +756,7 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.AUTHENTICATING, - statusHint = "正在完成身份验证...", + statusHint = t("session.hint.authenticating"), certFingerprint = hello.certFingerprintSha256.orEmpty() ) } @@ -660,10 +768,12 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "连接超时,请重试" + statusHint = t("session.hint.connection_timeout_retry"), + isSwitchingServer = false, + switchingServerLabel = "" ) } - addSystemMessage("认证超时,请检查网络后重试") + addSystemMessage(t("session.msg.auth_timeout")) socketClient.close(1000, "auth_timeout") } } @@ -671,16 +781,23 @@ 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"), + isSwitchingServer = false, + switchingServerLabel = "" ) } - addSystemMessage("认证发送失败:${error.message ?: "unknown"}") + addSystemMessage( + tf( + "session.msg.auth_send_failed", + error.message ?: t("common.unknown") + ) + ) socketClient.close(1000, "auth_failed") } } @@ -729,7 +846,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 +866,82 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.READY, - statusHint = "已连接,可以开始聊天" + statusHint = t("session.hint.ready_to_chat"), + isSwitchingServer = false, + switchingServerLabel = "" ) } - 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 +964,38 @@ 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") } + ), + isSwitchingServer = false, + switchingServerLabel = "" + ) + } + 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 +1009,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 +1022,19 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "连接已中断,正在重试" + statusHint = t("session.hint.connection_interrupted_retry"), + isSwitchingServer = false, + switchingServerLabel = "" ) } - 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 +1044,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 +1068,7 @@ object ChatSessionManager { ) { showIncomingNotification( title = sender, - body = content.ifBlank { "收到一条新消息" } + body = content.ifBlank { t("session.notification.new_message") } ) appendMessage( UiMessage( @@ -871,6 +1081,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 +1120,7 @@ object ChatSessionManager { appendMessage( UiMessage( role = MessageRole.OUTGOING, - sender = "我", + sender = t("session.sender.me"), subtitle = subtitle, content = content, channel = channel @@ -893,6 +1128,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 要追加的消息 @@ -910,11 +1310,78 @@ object ChatSessionManager { if (message.role == MessageRole.SYSTEM) return scope.launch(Dispatchers.IO) { runCatching { - historyRepository.appendMessage(message, MAX_MESSAGES) + historyRepository.appendMessage(currentServerKey(), message, MAX_MESSAGES) } } } + private fun switchServer( + normalized: String, + nextUrls: List, + statusHint: String?, + persist: suspend () -> Unit, + snackbarMessage: String? = null + ) { + val targetServerKey = serverKeyFor(normalized) + val previousServerKey = currentServerKey() + val shouldReconnect = previousServerKey != targetServerKey || _uiState.value.status != ConnectionStatus.READY + val switchingLabel = summarizeServerLabel(normalized) + + scope.launch { + _uiState.update { + it.copy( + serverUrl = normalized, + serverUrls = nextUrls, + isSwitchingServer = shouldReconnect, + switchingServerLabel = if (shouldReconnect) switchingLabel else "", + statusHint = statusHint ?: it.statusHint + ) + } + persist() + val historyMessages = withContext(Dispatchers.IO) { + historyRepository.loadMessages(targetServerKey, MAX_MESSAGES) + } + _uiState.update { + it.copy( + serverUrl = normalized, + serverUrls = nextUrls, + messages = historyMessages, + certFingerprint = if (previousServerKey == targetServerKey) it.certFingerprint else "", + statusHint = statusHint ?: it.statusHint + ) + } + if (shouldReconnect) { + connectInternal( + isAutoRestore = false, + overrideUrl = normalized, + forceReconnect = !_uiState.value.canConnect + ) + } + if (!snackbarMessage.isNullOrBlank()) { + _events.emit(UiEvent.ShowSnackbar(snackbarMessage)) + } + } + } + + private fun currentServerKey(): String = serverKeyFor(_uiState.value.serverUrl) + + private fun serverKeyFor(rawUrl: String): String { + return ServerUrlFormatter.normalize(rawUrl).ifBlank { ServerUrlFormatter.defaultServerUrl } + } + + private fun summarizeServerLabel(rawUrl: String): String { + val normalized = serverKeyFor(rawUrl) + val parsed = runCatching { Uri.parse(normalized) }.getOrNull() + val host = parsed?.host + val port = parsed?.port?.takeIf { it > 0 } + val path = parsed?.encodedPath?.takeIf { !it.isNullOrBlank() && it != "/" } + return buildString { + append(host ?: normalized) + if (port != null) append(":$port") + if (path != null) append(path) + }.ifBlank { normalized } + } + /** * 取消认证超时任务。 */ @@ -934,11 +1401,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 +1420,7 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "重连失败:服务器地址无效" + statusHint = t("session.hint.reconnect_invalid_server") ) } return@launch @@ -967,7 +1434,7 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.CONNECTING, - statusHint = "正在自动重连..." + statusHint = t("session.hint.auto_reconnecting") ) } socketClient.connect(target, socketListener) @@ -1018,10 +1485,12 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "握手超时,请检查地址路径与反向代理" + statusHint = t("session.hint.handshake_timeout"), + isSwitchingServer = false, + switchingServerLabel = "" ) } - addSystemMessage("握手超时:未收到服务端 publickey 首包(当前地址:$currentUrl)") + addSystemMessage(tf("session.msg.handshake_timeout_with_url", currentUrl)) socketClient.close(1000, "hello_timeout") } } @@ -1108,19 +1577,36 @@ object ChatSessionManager { /** * 创建消息通知渠道(Android O+)。 */ - private fun ensureMessageNotificationChannel() { + private fun ensureMessageNotificationChannel(soundCode: String = "default") { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val manager = app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channelId = NotificationSoundCatalog.channelId(soundCode) + if (manager.getNotificationChannel(channelId) != null) return + val channel = NotificationChannel( - MESSAGE_CHANNEL_ID, - "OnlineMsg 消息提醒", + channelId, + t("session.notification.channel_name"), NotificationManager.IMPORTANCE_DEFAULT ).apply { - description = "收到服务器新消息时提醒" + description = t("session.notification.channel_desc") + getSoundUri(soundCode)?.let { uri -> + setSound( + uri, + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + ) + } } manager.createNotificationChannel(channel) } + private fun getSoundUri(code: String): Uri? { + val resId = NotificationSoundCatalog.resId(code) ?: return null + return Uri.parse("${ContentResolver.SCHEME_ANDROID_RESOURCE}://${app.packageName}/$resId") + } + /** * 显示新消息到达的通知。 * @param title 通知标题 @@ -1143,8 +1629,10 @@ object ChatSessionManager { launchIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) + val channelId = NotificationSoundCatalog.channelId(_uiState.value.notificationSound) + ensureMessageNotificationChannel(_uiState.value.notificationSound) - val notification = NotificationCompat.Builder(app, MESSAGE_CHANNEL_ID) + val notification = NotificationCompat.Builder(app, channelId) .setSmallIcon(android.R.drawable.stat_notify_chat) .setContentTitle(title.ifBlank { "OnlineMsg" }) .setContentText(body.take(120)) @@ -1167,11 +1655,33 @@ 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 private const val MAX_MESSAGES = 500 private const val MAX_RECONNECT_DELAY_SECONDS = 30 private const val SYSTEM_MESSAGE_TTL_MS = 1_000L - private const val MESSAGE_CHANNEL_ID = "onlinemsg_messages" + private const val AUDIO_MESSAGE_PREFIX = "[[OMS_AUDIO_V1]]" + private const val AUDIO_CHUNK_MESSAGE_PREFIX = "[[OMS_AUDIO_CHUNK_V1]]" + private const val AUDIO_CHUNK_BASE64_SIZE = 20_000 + 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..abfe61c 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt @@ -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 ) /** @@ -86,7 +97,10 @@ data class ChatUiState( val loadingPublicKey: Boolean = false, val themeId: String = "blue", val useDynamicColor: Boolean = true, - val language: String = "zh" + val language: String = "zh", + val notificationSound: String = "default", + val isSwitchingServer: Boolean = false, + val switchingServerLabel: String = "" ) { /** * 是否允许连接。 @@ -141,4 +155,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..a0ce6e5 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt @@ -19,6 +19,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { fun updateDisplayName(value: String) = ChatSessionManager.updateDisplayName(value) fun updateServerUrl(value: String) = ChatSessionManager.updateServerUrl(value) + fun selectServerUrl(value: String) = ChatSessionManager.selectServerUrl(value) fun updateTargetKey(value: String) = ChatSessionManager.updateTargetKey(value) fun updateDraft(value: String) = ChatSessionManager.updateDraft(value) fun toggleDirectMode(enabled: Boolean) = ChatSessionManager.toggleDirectMode(enabled) @@ -30,9 +31,12 @@ 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 + fun updateNotificationSound(sound: String) = ChatSessionManager.updateNotificationSound(sound) +} 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..5792aeb 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt @@ -6,6 +6,82 @@ package com.onlinemsg.client.util */ object LanguageManager { + private val zhHantOverrides = mapOf( + "tab.settings" to "設定", + "settings.personal" to "個人設定", + "settings.display_name" 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.chat_data" to "聊天資料", + "settings.dynamic_color" to "使用動態顏色", + "settings.notification_sound" to "通知音效", + "settings.notification_enabled" to "系統通知與音效已啟用", + "settings.notification_disabled" to "系統通知目前已關閉", + "settings.notification_sound_disabled" to "目前音效渠道已被靜音", + "settings.notification_system_settings" to "系統通知設定", + "settings.notification_channel_settings" to "當前音效設定", + "status.idle" to "未連線", + "status.connecting" to "連線中", + "status.ready" to "已連線", + "status.error" to "異常中斷", + "hint.tap_to_connect" to "點擊連線開始聊天", + "hint.connecting_server" to "正在連線伺服器...", + "hint.ready_chat" to "已連線,可以開始聊天", + "hint.closed" to "連線已關閉", + "hint.reconnecting" to "連線已中斷,正在重試", + "session.hint.switching_server" to "正在切換伺服器", + "hint.reconnect_invalid_server" to "重連失敗:伺服器位址無效", + "hint.fill_target_key" to "請先填寫目標公鑰,再傳送私訊", + "hint.server_rejected_prefix" to "伺服器拒絕連線:", + "hint.audio_send_failed_prefix" to "語音傳送失敗:", + "chat.private" to "私訊", + "chat.target_key" to "目標公鑰", + "chat.input_placeholder" to "輸入訊息", + "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 "停止播放", + "sound.default" to "預設", + "sound.ding" to "叮", + "sound.nameit5" to "音效 5", + "sound.wind_chime" to "風鈴", + "theme.blue" to "蔚藍", + "theme.gray" to "商務灰", + "theme.green" to "翠綠", + "theme.red" to "緋紅", + "theme.warm" to "溫暖" + ) + private val translations = mapOf( "zh" to mapOf( "tab.chat" to "聊天", @@ -32,7 +108,103 @@ object LanguageManager { "settings.connect" to "连接", "settings.disconnect" to "断开", "settings.clear_msg" to "清空消息", + "settings.chat_data" to "聊天数据", "settings.dynamic_color" to "使用动态颜色", + "settings.notification_sound" to "通知音效", + "settings.notification_enabled" to "系统通知与音效已启用", + "settings.notification_disabled" to "系统通知当前已关闭", + "settings.notification_sound_disabled" to "当前音效渠道已被静音", + "settings.notification_system_settings" to "系统通知设置", + "settings.notification_channel_settings" to "当前音效设置", + "sound.default" to "默认", + "sound.ding" to "叮", + "sound.nameit5" to "音效 5", + "sound.wind_chime" 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 "连接已中断,正在重试", + "session.hint.switching_server" 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 +212,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 +268,103 @@ 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", + "settings.notification_sound" to "Notification Sound", + "settings.notification_enabled" to "Notifications and sound are enabled", + "settings.notification_disabled" to "Notifications are currently disabled", + "settings.notification_sound_disabled" to "The selected sound channel is muted", + "settings.notification_system_settings" to "System Notification Settings", + "settings.notification_channel_settings" to "Current Sound Channel", + "sound.default" to "Default", + "sound.ding" to "Ding", + "sound.nameit5" to "Sound 5", + "sound.wind_chime" to "Wind Chime", + "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", + "session.hint.switching_server" to "Switching server", + "hint.reconnect_invalid_server" to "Reconnect failed: invalid server address", + "hint.fill_target_key" to "Please fill target public key before private message", + "hint.server_rejected_prefix" to "Server rejected connection: ", + "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 +372,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 +428,103 @@ object LanguageManager { "settings.connect" to "接続", "settings.disconnect" to "切断", "settings.clear_msg" to "履歴を消去", + "settings.chat_data" to "チャットデータ", "settings.dynamic_color" to "動的カラーを使用", + "settings.notification_sound" to "通知音", + "settings.notification_enabled" to "通知と音が有効です", + "settings.notification_disabled" to "通知が現在オフです", + "settings.notification_sound_disabled" to "現在の音声チャンネルはミュートされています", + "settings.notification_system_settings" to "システム通知設定", + "settings.notification_channel_settings" to "現在の音設定", + "sound.default" to "デフォルト", + "sound.ding" to "ディン", + "sound.nameit5" to "効果音 5", + "sound.wind_chime" 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 "接続が中断され、再接続中", + "session.hint.switching_server" 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 +532,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 +588,103 @@ object LanguageManager { "settings.connect" to "연결", "settings.disconnect" to "연결 끊기", "settings.clear_msg" to "정보 삭제", + "settings.chat_data" to "채팅 데이터", "settings.dynamic_color" to "동적 색상 사용", + "settings.notification_sound" to "알림 효과음", + "settings.notification_enabled" to "시스템 알림과 효과음이 켜져 있습니다", + "settings.notification_disabled" to "시스템 알림이 현재 꺼져 있습니다", + "settings.notification_sound_disabled" to "현재 효과음 채널이 음소거되어 있습니다", + "settings.notification_system_settings" to "시스템 알림 설정", + "settings.notification_channel_settings" to "현재 효과음 설정", + "sound.default" to "기본값", + "sound.ding" to "딩", + "sound.nameit5" to "효과음 5", + "sound.wind_chime" 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 "연결이 끊겨 재연결 중입니다", + "session.hint.switching_server" 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,22 +692,54 @@ 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 "초록", "theme.red" to "빨강", "theme.warm" to "따뜻함" - ) + ), + "zh-Hant" to zhHantOverrides ) fun getString(key: String, lang: String): String { - return translations[lang]?.get(key) ?: translations["en"]?.get(key) ?: key + return when (lang) { + "zh-Hant" -> translations["zh-Hant"]?.get(key) + ?: translations["zh"]?.get(key) + ?: translations["en"]?.get(key) + ?: key + + else -> translations[lang]?.get(key) ?: translations["en"]?.get(key) ?: key + } } val supportedLanguages = listOf( - LanguageOption("zh", "中文"), + LanguageOption("zh", "中文简体"), + LanguageOption("zh-Hant", "繁體中文"), LanguageOption("en", "English"), LanguageOption("ja", "日本语"), LanguageOption("ko", "한국어")