diff --git a/android-client/app/src/main/AndroidManifest.xml b/android-client/app/src/main/AndroidManifest.xml
index 6c539c7..0ff0604 100644
--- a/android-client/app/src/main/AndroidManifest.xml
+++ b/android-client/app/src/main/AndroidManifest.xml
@@ -5,6 +5,7 @@
+
+ )
+ .addMigrations(MIGRATION_1_2)
+ .build().also { db ->
instance = db
}
}
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatHistoryRepository.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatHistoryRepository.kt
index e0f0a61..0a28a70 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatHistoryRepository.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatHistoryRepository.kt
@@ -1,6 +1,7 @@
package com.onlinemsg.client.data.local
import com.onlinemsg.client.ui.MessageChannel
+import com.onlinemsg.client.ui.MessageContentType
import com.onlinemsg.client.ui.MessageRole
import com.onlinemsg.client.ui.UiMessage
@@ -31,13 +32,18 @@ private fun UiMessage.toEntity(): ChatMessageEntity {
subtitle = subtitle,
content = content,
channel = channel.name,
- timestampMillis = timestampMillis
+ timestampMillis = timestampMillis,
+ contentType = contentType.name,
+ audioBase64 = audioBase64,
+ audioDurationMillis = audioDurationMillis
)
}
private fun ChatMessageEntity.toUiMessageOrNull(): UiMessage? {
val parsedRole = runCatching { MessageRole.valueOf(role) }.getOrNull() ?: return null
val parsedChannel = runCatching { MessageChannel.valueOf(channel) }.getOrNull() ?: return null
+ val parsedContentType = runCatching { MessageContentType.valueOf(contentType) }.getOrNull()
+ ?: MessageContentType.TEXT
return UiMessage(
id = id,
role = parsedRole,
@@ -45,6 +51,9 @@ private fun ChatMessageEntity.toUiMessageOrNull(): UiMessage? {
subtitle = subtitle,
content = content,
channel = parsedChannel,
- timestampMillis = timestampMillis
+ timestampMillis = timestampMillis,
+ contentType = parsedContentType,
+ audioBase64 = audioBase64,
+ audioDurationMillis = audioDurationMillis
)
}
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageEntity.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageEntity.kt
index e0e6dde..42eaef1 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageEntity.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageEntity.kt
@@ -11,5 +11,8 @@ data class ChatMessageEntity(
val subtitle: String,
val content: String,
val channel: String,
- val timestampMillis: Long
+ val timestampMillis: Long,
+ val contentType: String,
+ val audioBase64: String,
+ val audioDurationMillis: Long
)
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/protocol/ProtocolModels.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/protocol/ProtocolModels.kt
index deb69d1..e872053 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/data/protocol/ProtocolModels.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/data/protocol/ProtocolModels.kt
@@ -39,6 +39,27 @@ data class SignedPayloadDto(
@SerialName("signature") val signature: String
)
+@Serializable
+data class AudioPayloadDto(
+ @SerialName("version") val version: Int = 1,
+ @SerialName("encoding") val encoding: String = "base64",
+ @SerialName("mimeType") val mimeType: String = "audio/mp4",
+ @SerialName("durationMillis") val durationMillis: Long,
+ @SerialName("data") val data: String
+)
+
+@Serializable
+data class AudioChunkPayloadDto(
+ @SerialName("version") val version: Int = 1,
+ @SerialName("encoding") val encoding: String = "base64",
+ @SerialName("mimeType") val mimeType: String = "audio/mp4",
+ @SerialName("messageId") val messageId: String,
+ @SerialName("index") val index: Int,
+ @SerialName("total") val total: Int,
+ @SerialName("durationMillis") val durationMillis: Long,
+ @SerialName("data") val data: String
+)
+
fun JsonElement?.asPayloadText(): String {
if (this == null || this is JsonNull) return ""
return if (this is JsonPrimitive && this.isString) {
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/service/ChatForegroundService.kt b/android-client/app/src/main/java/com/onlinemsg/client/service/ChatForegroundService.kt
index afb0369..9d84725 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/service/ChatForegroundService.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/service/ChatForegroundService.kt
@@ -16,6 +16,7 @@ import androidx.core.app.NotificationManagerCompat
import com.onlinemsg.client.MainActivity
import com.onlinemsg.client.ui.ChatSessionManager
import com.onlinemsg.client.ui.ConnectionStatus
+import com.onlinemsg.client.util.LanguageManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -29,6 +30,10 @@ class ChatForegroundService : Service() {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var statusJob: Job? = null
+ private fun t(key: String): String {
+ return LanguageManager.getString(key, ChatSessionManager.uiState.value.language)
+ }
+
override fun onCreate() {
super.onCreate()
ChatSessionManager.initialize(application)
@@ -113,22 +118,22 @@ class ChatForegroundService : Service() {
)
val title = when (status) {
- ConnectionStatus.READY -> "OnlineMsg 已保持连接"
+ ConnectionStatus.READY -> t("service.foreground.title.ready")
ConnectionStatus.CONNECTING,
ConnectionStatus.HANDSHAKING,
- ConnectionStatus.AUTHENTICATING -> "OnlineMsg 正在连接"
- ConnectionStatus.ERROR -> "OnlineMsg 连接异常"
- ConnectionStatus.IDLE -> "OnlineMsg 后台服务"
+ ConnectionStatus.AUTHENTICATING -> t("service.foreground.title.connecting")
+ ConnectionStatus.ERROR -> t("service.foreground.title.error")
+ ConnectionStatus.IDLE -> t("service.foreground.title.idle")
}
return NotificationCompat.Builder(this, FOREGROUND_CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle(title)
- .setContentText(hint.ifBlank { "后台保持连接中" })
+ .setContentText(hint.ifBlank { t("service.foreground.hint.default") })
.setOngoing(true)
.setOnlyAlertOnce(true)
.setContentIntent(openAppPendingIntent)
- .addAction(0, "断开", stopPendingIntent)
+ .addAction(0, t("service.foreground.action.disconnect"), stopPendingIntent)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
@@ -138,10 +143,10 @@ class ChatForegroundService : Service() {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
FOREGROUND_CHANNEL_ID,
- "OnlineMsg 后台连接",
+ t("service.foreground.channel.name"),
NotificationManager.IMPORTANCE_LOW
).apply {
- description = "保持 WebSocket 后台长连接"
+ description = t("service.foreground.channel.desc")
setShowBadge(false)
}
manager.createNotificationChannel(channel)
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt
index aac72a4..64881b7 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt
@@ -1,7 +1,19 @@
package com.onlinemsg.client.ui
-import android.annotation.SuppressLint
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.media.MediaPlayer
+import android.os.Build
+import android.util.Base64
+import android.view.MotionEvent
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -31,8 +43,12 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Send
import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Forum
+import androidx.compose.material.icons.rounded.Keyboard
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Language
+import androidx.compose.material.icons.rounded.PlayArrow
+import androidx.compose.material.icons.rounded.Stop
+import androidx.compose.material.icons.rounded.KeyboardVoice
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
@@ -55,6 +71,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -62,23 +79,30 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.foundation.isSystemInDarkTheme
-import android.os.Build
import com.onlinemsg.client.ui.theme.OnlineMsgTheme
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
+import java.io.File
import com.onlinemsg.client.ui.theme.themeOptions
+import com.onlinemsg.client.util.AudioRecorder
import com.onlinemsg.client.util.LanguageManager
+import kotlinx.coroutines.delay
/**
@@ -89,6 +113,11 @@ private enum class MainTab(val labelKey: String) {
SETTINGS("tab.settings")
}
+private enum class ChatInputMode {
+ TEXT,
+ AUDIO
+}
+
/**
* 应用程序的根可组合函数。
* 集成 ViewModel、主题、Scaffold 以及选项卡切换逻辑。
@@ -123,7 +152,7 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) {
Scaffold(
topBar = {
AppTopBar(
- statusText = state.statusText,
+ statusText = localizedConnectionStatusText(state.status, state.language),
statusColor = when (state.status) {
ConnectionStatus.READY -> MaterialTheme.colorScheme.primary
ConnectionStatus.ERROR -> MaterialTheme.colorScheme.error
@@ -182,6 +211,7 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) {
onTargetKeyChange = viewModel::updateTargetKey,
onDraftChange = viewModel::updateDraft,
onSend = viewModel::sendMessage,
+ onSendAudio = viewModel::sendAudioMessage,
onCopyMessage = { content ->
clipboard.setText(AnnotatedString(content))
viewModel.onMessageCopied()
@@ -276,6 +306,7 @@ private fun AppTopBar(
* 包含模式切换、消息列表和输入区域。
*/
@Composable
+@OptIn(ExperimentalComposeUiApi::class)
private fun ChatTab(
modifier: Modifier,
state: ChatUiState,
@@ -283,12 +314,101 @@ private fun ChatTab(
onTargetKeyChange: (String) -> Unit,
onDraftChange: (String) -> Unit,
onSend: () -> Unit,
+ onSendAudio: (String, Long) -> Unit,
onCopyMessage: (String) -> Unit
) {
+ val context = LocalContext.current
val listState = rememberLazyListState()
-
+ val audioRecorder = remember(context) { AudioRecorder(context) }
+ val audioPlayer = remember(context) { AudioMessagePlayer(context) }
+ var inputMode by rememberSaveable { mutableStateOf(ChatInputMode.TEXT) }
+ var isRecording by remember { mutableStateOf(false) }
+ var cancelOnRelease by remember { mutableStateOf(false) }
+ var pressDownRawY by remember { mutableStateOf(0f) }
+ var audioHint by remember { mutableStateOf("") }
+ var audioHintVersion by remember { mutableStateOf(0L) }
+ var playingMessageId by remember { mutableStateOf(null) }
+ var recordingStartedAtMillis by remember { mutableStateOf(0L) }
+ var recordingElapsedMillis by remember { mutableStateOf(0L) }
+ val recordingPulse = rememberInfiniteTransition(label = "recordingPulse")
+ val recordingPulseScale by recordingPulse.animateFloat(
+ initialValue = 0.9f,
+ targetValue = 1.2f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 700),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "recordingPulseScale"
+ )
+ val recordingPulseAlpha by recordingPulse.animateFloat(
+ initialValue = 0.25f,
+ targetValue = 0.65f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 700),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "recordingPulseAlpha"
+ )
+
// 定义翻译函数 t
fun t(key: String) = LanguageManager.getString(key, state.language)
+ fun showAudioHint(message: String) {
+ audioHint = message
+ audioHintVersion += 1L
+ }
+ val canHoldToRecord = state.status == ConnectionStatus.READY &&
+ !state.sending &&
+ (!state.directMode || state.targetKey.trim().isNotBlank())
+
+ fun hasRecordPermission(): Boolean {
+ return ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.RECORD_AUDIO
+ ) == PackageManager.PERMISSION_GRANTED
+ }
+
+ fun startRecording() {
+ if (isRecording) return
+ if (!canHoldToRecord) return
+ if (!hasRecordPermission()) {
+ showAudioHint(t("chat.audio_permission_required"))
+ return
+ }
+ val started = audioRecorder.start()
+ if (!started) {
+ showAudioHint(t("chat.audio_record_failed"))
+ return
+ }
+ isRecording = true
+ cancelOnRelease = false
+ recordingStartedAtMillis = System.currentTimeMillis()
+ recordingElapsedMillis = 0L
+ audioHint = ""
+ }
+
+ fun finishRecording(send: Boolean) {
+ if (!isRecording) return
+ isRecording = false
+ cancelOnRelease = false
+ recordingStartedAtMillis = 0L
+ recordingElapsedMillis = 0L
+ val recorded = audioRecorder.stopAndEncode(send = send)
+ when {
+ !send -> {
+ showAudioHint(t("chat.audio_canceled"))
+ }
+ recorded == null -> {
+ showAudioHint(t("chat.audio_record_failed"))
+ }
+ recorded.durationMillis < MIN_AUDIO_DURATION_MS -> {
+ showAudioHint(t("chat.audio_too_short"))
+ }
+ else -> {
+ onSendAudio(recorded.base64, recorded.durationMillis)
+ audioHint = ""
+ }
+ }
+ }
// 当消息列表新增消息时,自动滚动到底部
LaunchedEffect(state.visibleMessages.size) {
@@ -297,6 +417,32 @@ private fun ChatTab(
}
}
+ LaunchedEffect(isRecording, recordingStartedAtMillis) {
+ if (!isRecording || recordingStartedAtMillis <= 0L) return@LaunchedEffect
+ while (isRecording) {
+ recordingElapsedMillis = (System.currentTimeMillis() - recordingStartedAtMillis)
+ .coerceAtLeast(0L)
+ delay(100L)
+ }
+ }
+
+ LaunchedEffect(audioHintVersion) {
+ val latest = audioHint
+ val latestVersion = audioHintVersion
+ if (latest.isBlank()) return@LaunchedEffect
+ delay(2200L)
+ if (audioHintVersion == latestVersion && audioHint == latest) {
+ audioHint = ""
+ }
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ audioRecorder.release()
+ audioPlayer.release()
+ }
+ }
+
Column(
modifier = modifier
.fillMaxSize()
@@ -324,10 +470,20 @@ private fun ChatTab(
}
Spacer(modifier = Modifier.height(8.dp))
+ val statusHintText = if (audioHint.isNotBlank()) {
+ audioHint
+ } else {
+ localizedStatusHintText(state.statusHint, state.language)
+ }
+ val statusHintColor = if (audioHint.isNotBlank()) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ }
Text(
- text = state.statusHint,
+ text = statusHintText,
style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ color = statusHintColor
)
if (state.directMode) {
@@ -373,6 +529,18 @@ private fun ChatTab(
MessageItem(
message = message,
onCopy = { onCopyMessage(message.content) },
+ onPlayAudio = {
+ val nextPlaying = audioPlayer.toggle(
+ messageId = message.id,
+ audioBase64 = message.audioBase64
+ ) { stoppedId ->
+ if (playingMessageId == stoppedId) {
+ playingMessageId = null
+ }
+ }
+ playingMessageId = nextPlaying
+ },
+ isPlaying = playingMessageId == message.id,
currentLanguage = state.language
)
}
@@ -381,31 +549,191 @@ private fun ChatTab(
Spacer(modifier = Modifier.height(8.dp))
- // 消息输入区域
- Row(
- verticalAlignment = Alignment.Bottom,
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- OutlinedTextField(
- value = state.draft,
- onValueChange = onDraftChange,
- modifier = Modifier.weight(1f),
- label = { Text(t("chat.input_placeholder")) },
- maxLines = 4,
- keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
- keyboardActions = KeyboardActions(
- onSend = { onSend() }
+ if (inputMode == ChatInputMode.TEXT) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ IconButton(
+ onClick = { inputMode = ChatInputMode.AUDIO },
+ modifier = Modifier
+ .size(56.dp)
+ .background(
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ shape = RoundedCornerShape(14.dp)
+ )
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.KeyboardVoice,
+ contentDescription = t("chat.mode_audio"),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ OutlinedTextField(
+ value = state.draft,
+ onValueChange = onDraftChange,
+ modifier = Modifier
+ .weight(1f)
+ .height(56.dp),
+ placeholder = { Text(t("chat.input_placeholder")) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
+ keyboardActions = KeyboardActions(
+ onSend = { onSend() }
+ )
)
- )
- Button(
- onClick = onSend,
- enabled = state.canSend,
- modifier = Modifier.height(56.dp)
+ Button(
+ onClick = onSend,
+ enabled = state.canSend,
+ modifier = Modifier.size(56.dp),
+ contentPadding = PaddingValues(0.dp)
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.Send,
+ contentDescription = t("chat.send")
+ )
+ }
+ }
+ } else {
+ val holdToTalkText = when {
+ state.sending -> t("chat.sending")
+ isRecording && cancelOnRelease -> t("chat.audio_release_cancel")
+ isRecording -> t("chat.audio_release_send")
+ else -> t("chat.audio_hold_to_talk")
+ }
+ val holdToTalkColor = when {
+ !canHoldToRecord -> MaterialTheme.colorScheme.surfaceVariant
+ isRecording && cancelOnRelease -> MaterialTheme.colorScheme.errorContainer
+ isRecording -> MaterialTheme.colorScheme.tertiaryContainer
+ else -> MaterialTheme.colorScheme.surfaceVariant
+ }
+ val holdToTalkTextColor = when {
+ isRecording && cancelOnRelease -> MaterialTheme.colorScheme.onErrorContainer
+ isRecording -> MaterialTheme.colorScheme.onTertiaryContainer
+ else -> MaterialTheme.colorScheme.onSurfaceVariant
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
- Icon(Icons.AutoMirrored.Rounded.Send, contentDescription = null)
- Spacer(Modifier.width(6.dp))
- Text(if (state.sending) "..." else t("chat.send"))
+ IconButton(
+ onClick = {
+ if (isRecording) {
+ finishRecording(send = false)
+ }
+ inputMode = ChatInputMode.TEXT
+ },
+ modifier = Modifier
+ .size(56.dp)
+ .background(
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ shape = RoundedCornerShape(14.dp)
+ )
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Keyboard,
+ contentDescription = t("chat.mode_text"),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Card(
+ modifier = Modifier
+ .weight(1f)
+ .height(56.dp)
+ .pointerInteropFilter { event ->
+ if (!canHoldToRecord && event.actionMasked == MotionEvent.ACTION_DOWN) {
+ return@pointerInteropFilter false
+ }
+ when (event.actionMasked) {
+ MotionEvent.ACTION_DOWN -> {
+ pressDownRawY = event.rawY
+ startRecording()
+ true
+ }
+
+ MotionEvent.ACTION_MOVE -> {
+ if (isRecording) {
+ cancelOnRelease = pressDownRawY - event.rawY > AUDIO_CANCEL_TRIGGER_PX
+ }
+ true
+ }
+
+ MotionEvent.ACTION_UP -> {
+ finishRecording(send = !cancelOnRelease)
+ true
+ }
+
+ MotionEvent.ACTION_CANCEL -> {
+ finishRecording(send = false)
+ true
+ }
+
+ else -> false
+ }
+ },
+ colors = CardDefaults.cardColors(containerColor = holdToTalkColor),
+ shape = RoundedCornerShape(12.dp)
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ if (isRecording) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Box(
+ modifier = Modifier
+ .size(18.dp)
+ .graphicsLayer {
+ scaleX = recordingPulseScale
+ scaleY = recordingPulseScale
+ }
+ .background(
+ color = holdToTalkTextColor.copy(alpha = recordingPulseAlpha),
+ shape = RoundedCornerShape(999.dp)
+ )
+ )
+ Icon(
+ imageVector = Icons.Rounded.KeyboardVoice,
+ contentDescription = null,
+ tint = holdToTalkTextColor,
+ modifier = Modifier.size(12.dp)
+ )
+ }
+ Column(
+ horizontalAlignment = Alignment.Start,
+ verticalArrangement = Arrangement.spacedBy(1.dp)
+ ) {
+ Text(
+ text = holdToTalkText,
+ style = MaterialTheme.typography.titleSmall,
+ color = holdToTalkTextColor
+ )
+ Text(
+ text = "${t("chat.audio_recording")} ${formatRecordingElapsed(recordingElapsedMillis)}",
+ style = MaterialTheme.typography.labelSmall,
+ color = holdToTalkTextColor.copy(alpha = 0.9f)
+ )
+ }
+ }
+ } else {
+ Text(
+ text = holdToTalkText,
+ style = MaterialTheme.typography.titleMedium,
+ color = holdToTalkTextColor
+ )
+ }
+ }
+ }
}
}
}
@@ -414,11 +742,12 @@ private fun ChatTab(
/**
* 单个消息气泡组件。
*/
-@SuppressLint("UnusedBoxWithConstraintsScope")
@Composable
private fun MessageItem(
message: UiMessage,
onCopy: () -> Unit,
+ onPlayAudio: () -> Unit,
+ isPlaying: Boolean,
currentLanguage: String
) {
Box(modifier = Modifier.fillMaxWidth()) {
@@ -458,6 +787,10 @@ private fun MessageItem(
}
} else {
val isOutgoing = message.role == MessageRole.OUTGOING
+ val shouldShowSender = !isOutgoing
+ val senderDisplayName = message.sender.ifBlank {
+ LanguageManager.getString("session.sender.anonymous", currentLanguage)
+ }
val bubbleColor = if (isOutgoing) {
MaterialTheme.colorScheme.primaryContainer
} else {
@@ -501,12 +834,16 @@ private fun MessageItem(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
- if (!isOutgoing) {
+ if (shouldShowSender) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
- text = message.sender,
+ text = senderDisplayName,
style = MaterialTheme.typography.labelLarge,
- color = MaterialTheme.colorScheme.primary
+ color = if (isOutgoing) {
+ bubbleTextColor.copy(alpha = 0.9f)
+ } else {
+ MaterialTheme.colorScheme.primary
+ }
)
if (message.subtitle.isNotBlank()) {
Spacer(modifier = Modifier.width(8.dp))
@@ -521,34 +858,53 @@ private fun MessageItem(
}
}
- // 消息正文
- Text(
- text = message.content,
- style = MaterialTheme.typography.bodyMedium,
- color = bubbleTextColor
- )
+ if (message.contentType == MessageContentType.AUDIO &&
+ message.audioBase64.isNotBlank()
+ ) {
+ AudioMessageBody(
+ message = message,
+ bubbleTextColor = bubbleTextColor,
+ onPlayAudio = onPlayAudio,
+ isPlaying = isPlaying,
+ currentLanguage = currentLanguage
+ )
+ } else {
+ Text(
+ text = message.content,
+ style = MaterialTheme.typography.bodyMedium,
+ color = bubbleTextColor
+ )
+ }
// 时间戳和复制按钮
Row(
- modifier = Modifier.fillMaxWidth(),
+ modifier = if (message.contentType == MessageContentType.AUDIO) {
+ Modifier.align(Alignment.End)
+ } else {
+ Modifier.fillMaxWidth()
+ },
verticalAlignment = Alignment.CenterVertically
) {
- Spacer(modifier = Modifier.weight(1f))
+ if (message.contentType == MessageContentType.TEXT) {
+ Spacer(modifier = Modifier.weight(1f))
+ }
Text(
text = formatTime(message.timestampMillis),
style = MaterialTheme.typography.labelSmall,
color = bubbleTextColor.copy(alpha = 0.7f)
)
- IconButton(
- onClick = onCopy,
- modifier = Modifier.size(24.dp)
- ) {
- Icon(
- imageVector = Icons.Rounded.ContentCopy,
- contentDescription = LanguageManager.getString("common.copied", currentLanguage),
- tint = bubbleTextColor.copy(alpha = 0.7f),
- modifier = Modifier.size(14.dp)
- )
+ if (message.contentType == MessageContentType.TEXT) {
+ IconButton(
+ onClick = onCopy,
+ modifier = Modifier.size(24.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.ContentCopy,
+ contentDescription = LanguageManager.getString("common.copied", currentLanguage),
+ tint = bubbleTextColor.copy(alpha = 0.7f),
+ modifier = Modifier.size(14.dp)
+ )
+ }
}
}
}
@@ -559,6 +915,97 @@ private fun MessageItem(
}
}
+@Composable
+private fun AudioMessageBody(
+ message: UiMessage,
+ bubbleTextColor: Color,
+ onPlayAudio: () -> Unit,
+ isPlaying: Boolean,
+ currentLanguage: String
+) {
+ val actionText = if (isPlaying) {
+ LanguageManager.getString("chat.audio_stop", currentLanguage)
+ } else {
+ LanguageManager.getString("chat.audio_play", currentLanguage)
+ }
+ val waveformPulse by rememberInfiniteTransition(label = "audioPlaybackWave").animateFloat(
+ initialValue = 0.55f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 480),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "audioPlaybackWavePulse"
+ )
+ val waveScales = if (isPlaying) {
+ listOf(
+ 0.75f + waveformPulse * 0.22f,
+ 0.92f + waveformPulse * 0.2f,
+ 0.82f + waveformPulse * 0.28f,
+ 0.9f + waveformPulse * 0.18f,
+ 0.7f + waveformPulse * 0.24f
+ )
+ } else {
+ listOf(0.75f, 0.95f, 0.82f, 0.9f, 0.72f)
+ }
+ val baseWaveHeights = listOf(8.dp, 14.dp, 10.dp, 13.dp, 9.dp)
+
+ Row(
+ modifier = Modifier
+ .widthIn(min = 140.dp, max = 210.dp)
+ .background(
+ color = bubbleTextColor.copy(alpha = 0.12f),
+ shape = RoundedCornerShape(12.dp)
+ )
+ .clickable(onClick = onPlayAudio)
+ .padding(horizontal = 10.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .size(28.dp)
+ .background(
+ color = bubbleTextColor.copy(alpha = 0.16f),
+ shape = RoundedCornerShape(999.dp)
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = if (isPlaying) Icons.Rounded.Stop else Icons.Rounded.PlayArrow,
+ contentDescription = actionText,
+ tint = bubbleTextColor,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(3.dp)
+ ) {
+ baseWaveHeights.forEachIndexed { index, baseHeight ->
+ Box(
+ modifier = Modifier
+ .width(3.dp)
+ .height(baseHeight)
+ .graphicsLayer {
+ scaleY = waveScales[index]
+ }
+ .background(
+ color = bubbleTextColor.copy(alpha = if (isPlaying) 0.95f else 0.72f),
+ shape = RoundedCornerShape(999.dp)
+ )
+ )
+ }
+ }
+ Spacer(modifier = Modifier.weight(1f))
+ Text(
+ text = formatAudioDuration(message.audioDurationMillis),
+ style = MaterialTheme.typography.labelMedium,
+ color = bubbleTextColor.copy(alpha = 0.8f)
+ )
+ }
+}
+
/**
* 设置选项卡界面,包含个人设置、服务器管理、身份安全、语言、主题和诊断信息。
* @param modifier 修饰符
@@ -632,7 +1079,7 @@ private fun SettingsTab(
modifier = settingsCardContentModifier,
verticalArrangement = settingsCardContentSpacing
) {
- Text("聊天数据", style = MaterialTheme.typography.titleMedium)
+ Text(t("settings.chat_data"), style = MaterialTheme.typography.titleMedium)
OutlinedButton(onClick = onClearMessages) {
Text(t("settings.clear_msg"))
}
@@ -787,8 +1234,8 @@ private fun SettingsTab(
verticalArrangement = settingsCardContentSpacing
) {
Text(t("settings.diagnostics"), style = MaterialTheme.typography.titleMedium)
- Text("${t("settings.status_hint")}:${state.statusHint}")
- Text("${t("settings.current_status")}:${state.statusText}")
+ Text("${t("settings.status_hint")}:${localizedStatusHintText(state.statusHint, state.language)}")
+ Text("${t("settings.current_status")}:${localizedConnectionStatusText(state.status, state.language)}")
Text("${t("settings.cert_fingerprint")}:${state.certFingerprint.ifBlank { "N/A" }}")
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -805,6 +1252,138 @@ private fun SettingsTab(
}
}
+private class AudioMessagePlayer(private val context: Context) {
+ private var mediaPlayer: MediaPlayer? = null
+ private var currentMessageId: String? = null
+ private var currentAudioFile: File? = null
+
+ fun toggle(
+ messageId: String,
+ audioBase64: String,
+ onStopped: (String) -> Unit
+ ): String? {
+ if (currentMessageId == messageId) {
+ stopPlayback()?.let(onStopped)
+ return null
+ }
+
+ stopPlayback()?.let(onStopped)
+
+ val bytes = runCatching {
+ Base64.decode(audioBase64, Base64.DEFAULT)
+ }.getOrNull() ?: return null
+ if (bytes.isEmpty()) return null
+
+ val audioFile = runCatching {
+ File.createTempFile("oms_play_", ".m4a", context.cacheDir).apply {
+ writeBytes(bytes)
+ }
+ }.getOrNull() ?: return null
+
+ val player = MediaPlayer()
+ val started = runCatching {
+ player.setDataSource(audioFile.absolutePath)
+ player.setOnCompletionListener {
+ stopPlayback()?.let(onStopped)
+ }
+ player.setOnErrorListener { _, _, _ ->
+ stopPlayback()?.let(onStopped)
+ true
+ }
+ player.prepare()
+ player.start()
+ true
+ }.getOrElse {
+ runCatching { player.release() }
+ audioFile.delete()
+ false
+ }
+ if (!started) return null
+
+ mediaPlayer = player
+ currentMessageId = messageId
+ currentAudioFile = audioFile
+ return currentMessageId
+ }
+
+ fun release() {
+ stopPlayback()
+ }
+
+ private fun stopPlayback(): String? {
+ val stoppedId = currentMessageId
+ runCatching { mediaPlayer?.stop() }
+ runCatching { mediaPlayer?.release() }
+ mediaPlayer = null
+ currentMessageId = null
+ currentAudioFile?.delete()
+ currentAudioFile = null
+ return stoppedId
+ }
+}
+
+private fun formatAudioDuration(durationMillis: Long): String {
+ val totalSeconds = (durationMillis / 1000L).coerceAtLeast(0L)
+ val minutes = totalSeconds / 60L
+ val seconds = totalSeconds % 60L
+ return if (minutes > 0L) {
+ String.format("%d:%02d", minutes, seconds)
+ } else {
+ "${seconds}s"
+ }
+}
+
+private fun formatRecordingElapsed(durationMillis: Long): String {
+ val clamped = durationMillis.coerceAtLeast(0L)
+ val seconds = clamped / 1000L
+ val tenths = (clamped % 1000L) / 100L
+ return "${seconds}.${tenths}s"
+}
+
+private fun localizedConnectionStatusText(status: ConnectionStatus, language: String): String {
+ val key = when (status) {
+ ConnectionStatus.IDLE -> "status.idle"
+ ConnectionStatus.CONNECTING,
+ ConnectionStatus.HANDSHAKING,
+ ConnectionStatus.AUTHENTICATING -> "status.connecting"
+ ConnectionStatus.READY -> "status.ready"
+ ConnectionStatus.ERROR -> "status.error"
+ }
+ return LanguageManager.getString(key, language)
+}
+
+private fun localizedStatusHintText(raw: String, language: String): String {
+ val exact = when (raw) {
+ "点击连接开始聊天" -> "hint.tap_to_connect"
+ "正在连接服务器..." -> "hint.connecting_server"
+ "已连接,可以开始聊天" -> "hint.ready_chat"
+ "连接已关闭" -> "hint.closed"
+ "连接已中断,正在重试" -> "hint.reconnecting"
+ "重连失败:服务器地址无效" -> "hint.reconnect_invalid_server"
+ "请先填写目标公钥,再发送私聊消息" -> "hint.fill_target_key"
+ else -> null
+ }
+ if (exact != null) {
+ return LanguageManager.getString(exact, language)
+ }
+ return when {
+ raw.startsWith("服务器拒绝连接:") -> {
+ val suffix = raw.removePrefix("服务器拒绝连接:")
+ LanguageManager.getString("hint.server_rejected_prefix", language) + suffix
+ }
+
+ raw.startsWith("语音发送失败:") -> {
+ val suffix = raw.removePrefix("语音发送失败:")
+ LanguageManager.getString("hint.audio_send_failed_prefix", language) + suffix
+ }
+
+ else -> raw
+ }
+}
+
+private const val AUDIO_CANCEL_TRIGGER_PX = 120f
+private const val MIN_AUDIO_DURATION_MS = 350L
+
/**
* 将时间戳格式化为本地时间的小时:分钟(如 "14:30")。
* @param tsMillis 毫秒时间戳
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt
index a9750ee..5c2c526 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt
@@ -20,12 +20,15 @@ import com.onlinemsg.client.data.local.ChatHistoryRepository
import com.onlinemsg.client.data.network.OnlineMsgSocketClient
import com.onlinemsg.client.data.preferences.ServerUrlFormatter
import com.onlinemsg.client.data.preferences.UserPreferencesRepository
+import com.onlinemsg.client.data.protocol.AudioPayloadDto
+import com.onlinemsg.client.data.protocol.AudioChunkPayloadDto
import com.onlinemsg.client.data.protocol.AuthPayloadDto
import com.onlinemsg.client.data.protocol.EnvelopeDto
import com.onlinemsg.client.data.protocol.HelloDataDto
import com.onlinemsg.client.data.protocol.SignedPayloadDto
import com.onlinemsg.client.data.protocol.asPayloadText
import com.onlinemsg.client.service.ChatForegroundService
+import com.onlinemsg.client.util.LanguageManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -48,6 +51,7 @@ import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.encodeToJsonElement
+import java.util.UUID
/**
* 单例管理类,负责整个聊天会话的生命周期、网络连接、消息收发、状态维护和持久化。
@@ -59,6 +63,15 @@ object ChatSessionManager {
ignoreUnknownKeys = true
}
+ private fun t(key: String): String {
+ return LanguageManager.getString(key, _uiState.value.language)
+ }
+
+ private fun tf(key: String, vararg args: Any): String {
+ val pattern = t(key)
+ return runCatching { String.format(pattern, *args) }.getOrElse { pattern }
+ }
+
private lateinit var app: Application
private lateinit var preferencesRepository: UserPreferencesRepository
private lateinit var cryptoManager: RsaCryptoManager
@@ -93,6 +106,7 @@ object ChatSessionManager {
@Volatile
private var keepAliveRequested = false // 是否应保活(前台服务标志)
private var notificationIdSeed = 2000
+ private val incomingAudioChunkBuffers = mutableMapOf()
// WebSocket 事件监听器
private val socketListener = object : OnlineMsgSocketClient.Listener {
@@ -101,10 +115,10 @@ object ChatSessionManager {
_uiState.update {
it.copy(
status = ConnectionStatus.HANDSHAKING,
- statusHint = "已连接,正在准备聊天..."
+ statusHint = t("session.hint.connected_preparing")
)
}
- addSystemMessage("连接已建立")
+ addSystemMessage(t("session.msg.connection_established"))
startHelloTimeout()
}
}
@@ -114,7 +128,12 @@ object ChatSessionManager {
runCatching {
handleIncomingMessage(text)
}.onFailure { error ->
- addSystemMessage("文本帧处理异常:${error.message ?: "unknown"}")
+ addSystemMessage(
+ tf(
+ "session.msg.text_frame_error",
+ error.message ?: t("common.unknown")
+ )
+ )
}
}
}
@@ -122,7 +141,7 @@ object ChatSessionManager {
override fun onBinaryMessage(payload: ByteArray) {
scope.launch {
if (_uiState.value.status == ConnectionStatus.HANDSHAKING) {
- _uiState.update { it.copy(statusHint = "收到二进制握手帧,正在尝试解析...") }
+ _uiState.update { it.copy(statusHint = t("session.hint.binary_handshake_parsing")) }
}
val utf8 = runCatching { String(payload, StandardCharsets.UTF_8) }.getOrNull().orEmpty()
@@ -130,13 +149,20 @@ object ChatSessionManager {
runCatching {
handleIncomingMessage(utf8)
}.onFailure { error ->
- addSystemMessage("二进制帧处理异常:${error.message ?: "unknown"}")
+ addSystemMessage(
+ tf(
+ "session.msg.binary_frame_error",
+ error.message ?: t("common.unknown")
+ )
+ )
}
} else if (_uiState.value.status == ConnectionStatus.HANDSHAKING) {
val hexPreview = payload.take(24).joinToString(" ") { byte ->
"%02x".format(byte)
}
- addSystemMessage("握手二进制帧无法转为文本,len=${payload.size} hex=$hexPreview")
+ addSystemMessage(
+ tf("session.msg.handshake_binary_unreadable", payload.size, hexPreview)
+ )
}
}
}
@@ -150,15 +176,15 @@ object ChatSessionManager {
override fun onFailure(throwable: Throwable) {
scope.launch {
if (manualClose) return@launch
- val message = throwable.message?.takeIf { it.isNotBlank() } ?: "unknown"
- addSystemMessage("连接异常:$message")
+ val message = throwable.message?.takeIf { it.isNotBlank() } ?: t("common.unknown")
+ addSystemMessage(tf("session.msg.connection_error", message))
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
- statusHint = "连接异常,正在重试"
+ statusHint = t("session.hint.connection_error_retrying")
)
}
- scheduleReconnect("连接异常")
+ scheduleReconnect(t("session.reason.connection_error"))
}
}
}
@@ -316,7 +342,7 @@ object ChatSessionManager {
val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl)
if (normalized.isBlank()) {
scope.launch {
- _events.emit(UiEvent.ShowSnackbar("请输入有效的服务器地址"))
+ _events.emit(UiEvent.ShowSnackbar(t("session.snackbar.invalid_server")))
}
return
}
@@ -326,13 +352,13 @@ object ChatSessionManager {
it.copy(
serverUrl = normalized,
serverUrls = nextUrls,
- statusHint = "服务器地址已保存"
+ statusHint = t("session.hint.server_saved")
)
}
scope.launch {
preferencesRepository.saveCurrentServerUrl(normalized)
- _events.emit(UiEvent.ShowSnackbar("服务器地址已保存"))
+ _events.emit(UiEvent.ShowSnackbar(t("session.snackbar.server_saved")))
}
}
@@ -355,13 +381,17 @@ object ChatSessionManager {
it.copy(
serverUrls = nextUrls,
serverUrl = nextUrls.first(),
- statusHint = if (filtered.isEmpty()) "已恢复默认服务器地址" else "已移除当前服务器地址"
+ statusHint = if (filtered.isEmpty()) {
+ t("session.hint.server_restored_default")
+ } else {
+ t("session.hint.server_removed")
+ }
)
}
scope.launch {
preferencesRepository.removeCurrentServerUrl(normalized)
- _events.emit(UiEvent.ShowSnackbar("已更新服务器地址列表"))
+ _events.emit(UiEvent.ShowSnackbar(t("session.snackbar.server_list_updated")))
}
}
@@ -382,7 +412,14 @@ object ChatSessionManager {
}
}.onFailure { error ->
_uiState.update { it.copy(loadingPublicKey = false) }
- _events.emit(UiEvent.ShowSnackbar("公钥读取失败:${error.message ?: "unknown"}"))
+ _events.emit(
+ UiEvent.ShowSnackbar(
+ tf(
+ "session.snackbar.public_key_read_failed",
+ error.message ?: t("common.unknown")
+ )
+ )
+ )
}
}
}
@@ -408,7 +445,7 @@ object ChatSessionManager {
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
- statusHint = "请填写有效服务器地址"
+ statusHint = t("session.hint.fill_valid_server")
)
}
return
@@ -427,7 +464,7 @@ object ChatSessionManager {
_uiState.update {
it.copy(
status = ConnectionStatus.CONNECTING,
- statusHint = "正在连接服务器...",
+ statusHint = t("session.hint.connecting_server"),
serverUrl = normalized,
certFingerprint = ""
)
@@ -442,7 +479,7 @@ object ChatSessionManager {
socketClient.connect(normalized, socketListener)
if (isAutoRestore) {
- addSystemMessage("已恢复上次会话,正在自动连接")
+ addSystemMessage(t("session.msg.auto_restore_connecting"))
}
}
@@ -459,7 +496,7 @@ object ChatSessionManager {
_uiState.update {
it.copy(
status = ConnectionStatus.IDLE,
- statusHint = "连接已关闭"
+ statusHint = t("session.hint.connection_closed")
)
}
autoReconnectTriggered = false
@@ -470,7 +507,7 @@ object ChatSessionManager {
if (stopService) {
ChatForegroundService.stop(app)
}
- addSystemMessage("已断开连接")
+ addSystemMessage(t("session.msg.disconnected"))
}
/**
@@ -480,56 +517,96 @@ object ChatSessionManager {
fun sendMessage() {
val current = _uiState.value
if (!current.canSend) return
+ val text = current.draft.trim()
+ if (text.isBlank()) return
+ val route = resolveOutgoingRoute(current) ?: return
scope.launch {
- val text = _uiState.value.draft.trim()
- if (text.isBlank()) return@launch
+ _uiState.update { it.copy(sending = true) }
- val key = if (_uiState.value.directMode) _uiState.value.targetKey.trim() else ""
- if (_uiState.value.directMode && key.isBlank()) {
- _uiState.update { it.copy(statusHint = "请先填写目标公钥,再发送私聊消息") }
- return@launch
+ runCatching {
+ sendSignedPayload(route = route, payloadText = text)
+ }.onSuccess {
+ addOutgoingMessage(text, route.subtitle, route.channel)
+ _uiState.update { it.copy(draft = "", sending = false) }
+ }.onFailure { error ->
+ _uiState.update { it.copy(sending = false) }
+ addSystemMessage(
+ tf(
+ "session.msg.send_failed",
+ error.message ?: t("common.unknown")
+ )
+ )
}
+ }
+ }
- val type = if (key.isBlank()) "broadcast" else "forward"
- val channel = if (key.isBlank()) MessageChannel.BROADCAST else MessageChannel.PRIVATE
- val subtitle = if (key.isBlank()) "" else "私聊 ${summarizeKey(key)}"
+ /**
+ * 发送语音消息(Base64 音频负载)。
+ */
+ fun sendAudioMessage(audioBase64: String, durationMillis: Long) {
+ val current = _uiState.value
+ if (current.status != ConnectionStatus.READY || current.sending) return
+ if (audioBase64.isBlank()) return
+ val route = resolveOutgoingRoute(current) ?: return
+ scope.launch {
_uiState.update { it.copy(sending = true) }
-
- runCatching {
- val id = ensureIdentity()
- val timestamp = cryptoManager.unixSecondsNow()
- val nonce = cryptoManager.createNonce()
- val signingInput = listOf(type, key, text, timestamp.toString(), nonce).joinToString("\n")
- val signature = withContext(Dispatchers.Default) {
- cryptoManager.signText(id.privateKey, signingInput)
+ val safeDuration = durationMillis.coerceAtLeast(0L)
+ val normalized = audioBase64.trim()
+ val chunks = splitAudioBase64(normalized, AUDIO_CHUNK_BASE64_SIZE)
+ if (chunks.size > MAX_AUDIO_CHUNK_COUNT) {
+ _uiState.update {
+ it.copy(
+ sending = false,
+ statusHint = t("session.hint.audio_chunk_over_limit")
+ )
}
+ addSystemMessage(t("session.msg.audio_chunk_canceled"))
+ return@launch
+ }
- val payload = SignedPayloadDto(
- payload = text,
- timestamp = timestamp,
- nonce = nonce,
- signature = signature
- )
- val envelope = EnvelopeDto(
- type = type,
- key = key,
- data = json.encodeToJsonElement(payload)
- )
-
- val plain = json.encodeToString(envelope)
- val cipher = withContext(Dispatchers.Default) {
- cryptoManager.encryptChunked(serverPublicKey, plain)
+ runCatching {
+ if (chunks.size == 1) {
+ val taggedPayload = AUDIO_MESSAGE_PREFIX + json.encodeToString(
+ AudioPayloadDto(
+ durationMillis = safeDuration,
+ data = normalized
+ )
+ )
+ sendSignedPayload(route = route, payloadText = taggedPayload)
+ } else {
+ val messageId = UUID.randomUUID().toString()
+ chunks.forEachIndexed { index, chunk ->
+ val taggedPayload = AUDIO_CHUNK_MESSAGE_PREFIX + json.encodeToString(
+ AudioChunkPayloadDto(
+ messageId = messageId,
+ index = index,
+ total = chunks.size,
+ durationMillis = safeDuration,
+ data = chunk
+ )
+ )
+ sendSignedPayload(route = route, payloadText = taggedPayload)
+ }
}
-
- check(socketClient.send(cipher)) { "连接不可用" }
}.onSuccess {
- addOutgoingMessage(text, subtitle, channel)
- _uiState.update { it.copy(draft = "", sending = false) }
- }.onFailure { error ->
+ addOutgoingAudioMessage(
+ subtitle = route.subtitle,
+ channel = route.channel,
+ audioBase64 = normalized,
+ durationMillis = safeDuration
+ )
_uiState.update { it.copy(sending = false) }
- addSystemMessage("发送失败:${error.message ?: "unknown"}")
+ }.onFailure { error ->
+ val message = error.message ?: t("common.unknown")
+ _uiState.update {
+ it.copy(
+ sending = false,
+ statusHint = tf("session.hint.audio_send_failed", message)
+ )
+ }
+ addSystemMessage(tf("session.msg.audio_send_failed", message))
}
}
}
@@ -539,7 +616,7 @@ object ChatSessionManager {
*/
fun onMessageCopied() {
scope.launch {
- _events.emit(UiEvent.ShowSnackbar("已复制"))
+ _events.emit(UiEvent.ShowSnackbar(t("common.copied")))
}
}
@@ -563,7 +640,7 @@ object ChatSessionManager {
*/
private suspend fun handleIncomingMessage(rawText: String) {
if (_uiState.value.status == ConnectionStatus.HANDSHAKING) {
- _uiState.update { it.copy(statusHint = "已收到握手数据,正在解析...") }
+ _uiState.update { it.copy(statusHint = t("session.hint.handshake_data_received")) }
}
val normalizedText = extractJsonCandidate(rawText)
@@ -598,7 +675,7 @@ object ChatSessionManager {
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
- statusHint = "握手失败:服务端响应不完整"
+ statusHint = t("session.hint.handshake_incomplete_response")
)
}
return
@@ -609,15 +686,15 @@ object ChatSessionManager {
// 握手阶段收到非预期消息则报错
if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain != null) {
- _uiState.update { it.copy(statusHint = "握手失败:收到非预期消息") }
- addSystemMessage("握手阶段收到非预期消息类型:${plain.type}")
+ _uiState.update { it.copy(statusHint = t("session.hint.handshake_unexpected_message")) }
+ addSystemMessage(tf("session.msg.handshake_unexpected_type", plain.type))
} else if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain == null) {
val preview = rawText
.replace("\n", " ")
.replace("\r", " ")
.take(80)
- _uiState.update { it.copy(statusHint = "握手失败:首包解析失败") }
- addSystemMessage("握手包解析失败:$preview")
+ _uiState.update { it.copy(statusHint = t("session.hint.handshake_first_packet_parse_failed")) }
+ addSystemMessage(tf("session.msg.handshake_parse_failed", preview))
}
// 尝试解密(若已握手完成,收到的应是加密消息)
@@ -627,7 +704,7 @@ object ChatSessionManager {
cryptoManager.decryptChunked(id.privateKey, normalizedText)
}
}.getOrElse {
- addSystemMessage("收到无法解密的消息")
+ addSystemMessage(t("session.msg.decryption_failed"))
return
}
@@ -648,7 +725,7 @@ object ChatSessionManager {
_uiState.update {
it.copy(
status = ConnectionStatus.AUTHENTICATING,
- statusHint = "正在完成身份验证...",
+ statusHint = t("session.hint.authenticating"),
certFingerprint = hello.certFingerprintSha256.orEmpty()
)
}
@@ -660,10 +737,10 @@ object ChatSessionManager {
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
- statusHint = "连接超时,请重试"
+ statusHint = t("session.hint.connection_timeout_retry")
)
}
- addSystemMessage("认证超时,请检查网络后重试")
+ addSystemMessage(t("session.msg.auth_timeout"))
socketClient.close(1000, "auth_timeout")
}
}
@@ -671,16 +748,21 @@ object ChatSessionManager {
runCatching {
sendAuth(hello.authChallenge)
}.onSuccess {
- addSystemMessage("已发送认证请求")
+ addSystemMessage(t("session.msg.auth_request_sent"))
}.onFailure { error ->
cancelAuthTimeout()
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
- statusHint = "认证失败"
+ statusHint = t("session.hint.auth_failed")
)
}
- addSystemMessage("认证发送失败:${error.message ?: "unknown"}")
+ addSystemMessage(
+ tf(
+ "session.msg.auth_send_failed",
+ error.message ?: t("common.unknown")
+ )
+ )
socketClient.close(1000, "auth_failed")
}
}
@@ -729,7 +811,11 @@ object ChatSessionManager {
val cipher = withContext(Dispatchers.Default) {
cryptoManager.encryptChunked(serverPublicKey, plain)
}
- check(socketClient.send(cipher)) { "连接不可用" }
+ val sizeBytes = cipher.toByteArray(StandardCharsets.UTF_8).size
+ require(sizeBytes <= MAX_OUTBOUND_MESSAGE_BYTES) {
+ tf("session.error.message_too_large", sizeBytes)
+ }
+ check(socketClient.send(cipher)) { t("session.error.connection_unavailable") }
}
/**
@@ -745,33 +831,80 @@ object ChatSessionManager {
_uiState.update {
it.copy(
status = ConnectionStatus.READY,
- statusHint = "已连接,可以开始聊天"
+ statusHint = t("session.hint.ready_to_chat")
)
}
- addSystemMessage("连接准备完成")
+ addSystemMessage(t("session.msg.ready"))
}
"broadcast" -> {
- val sender = message.key?.takeIf { it.isNotBlank() } ?: "匿名用户"
- addIncomingMessage(
- sender = sender,
- subtitle = "",
- content = message.data.asPayloadText(),
- channel = MessageChannel.BROADCAST
- )
+ val sender = message.key?.takeIf { it.isNotBlank() } ?: t("session.sender.anonymous")
+ val payloadText = message.data.asPayloadText()
+ val audioChunk = parseAudioChunkPayload(payloadText)
+ if (audioChunk != null) {
+ ingestIncomingAudioChunk(
+ sender = sender,
+ subtitle = "",
+ channel = MessageChannel.BROADCAST,
+ chunk = audioChunk
+ )
+ return
+ }
+ val audio = parseAudioPayload(payloadText)
+ if (audio != null) {
+ addIncomingAudioMessage(
+ sender = sender,
+ subtitle = "",
+ audioBase64 = audio.data,
+ durationMillis = audio.durationMillis,
+ channel = MessageChannel.BROADCAST
+ )
+ } else {
+ addIncomingMessage(
+ sender = sender,
+ subtitle = "",
+ content = payloadText,
+ channel = MessageChannel.BROADCAST
+ )
+ }
}
"forward" -> {
val sourceKey = message.key.orEmpty()
- addIncomingMessage(
- sender = "私聊消息",
- subtitle = sourceKey.takeIf { it.isNotBlank() }?.let { "来自 ${summarizeKey(it)}" }.orEmpty(),
- content = message.data.asPayloadText(),
- channel = MessageChannel.PRIVATE
- )
+ val payloadText = message.data.asPayloadText()
+ val subtitle = sourceKey.takeIf { it.isNotBlank() }
+ ?.let { tf("session.subtitle.from_key", summarizeKey(it)) }
+ .orEmpty()
+ val audioChunk = parseAudioChunkPayload(payloadText)
+ if (audioChunk != null) {
+ ingestIncomingAudioChunk(
+ sender = t("session.sender.private_message"),
+ subtitle = subtitle,
+ channel = MessageChannel.PRIVATE,
+ chunk = audioChunk
+ )
+ return
+ }
+ val audio = parseAudioPayload(payloadText)
+ if (audio != null) {
+ addIncomingAudioMessage(
+ sender = t("session.sender.private_message"),
+ subtitle = subtitle,
+ audioBase64 = audio.data,
+ durationMillis = audio.durationMillis,
+ channel = MessageChannel.PRIVATE
+ )
+ } else {
+ addIncomingMessage(
+ sender = t("session.sender.private_message"),
+ subtitle = subtitle,
+ content = payloadText,
+ channel = MessageChannel.PRIVATE
+ )
+ }
}
- else -> addSystemMessage("收到未识别消息类型:${message.type}")
+ else -> addSystemMessage(tf("session.msg.unknown_message_type", message.type))
}
}
@@ -794,6 +927,36 @@ object ChatSessionManager {
return
}
+ val reasonLower = reason.lowercase()
+ val isPolicyBlocked = code == 1008 ||
+ reasonLower.contains("ip blocked") ||
+ reasonLower.contains("message too large") ||
+ reasonLower.contains("rate limited")
+ if (isPolicyBlocked) {
+ keepAliveRequested = false
+ _uiState.update {
+ it.copy(
+ status = ConnectionStatus.ERROR,
+ statusHint = tf(
+ "session.hint.server_rejected",
+ reason.ifBlank { t("session.text.policy_restriction") }
+ )
+ )
+ }
+ addSystemMessage(
+ tf(
+ "session.msg.server_rejected",
+ code,
+ reason.ifBlank { t("session.text.policy_restriction") }
+ )
+ )
+ scope.launch {
+ preferencesRepository.setShouldAutoReconnect(false)
+ }
+ ChatForegroundService.stop(app)
+ return
+ }
+
val currentStatus = _uiState.value.status
val allowFallback = !fallbackTried && currentStatus != ConnectionStatus.READY
@@ -807,11 +970,11 @@ object ChatSessionManager {
_uiState.update {
it.copy(
status = ConnectionStatus.CONNECTING,
- statusHint = "正在自动重试连接...",
+ statusHint = t("session.hint.auto_retry_connecting"),
serverUrl = fallbackUrl
)
}
- addSystemMessage("连接方式切换中,正在重试")
+ addSystemMessage(t("session.msg.switching_connection_mode_retry"))
socketClient.connect(fallbackUrl, socketListener)
return
}
@@ -820,11 +983,17 @@ object ChatSessionManager {
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
- statusHint = "连接已中断,正在重试"
+ statusHint = t("session.hint.connection_interrupted_retry")
)
}
- addSystemMessage("连接关闭 ($code):${reason.ifBlank { "连接中断" }}")
- scheduleReconnect("连接已中断")
+ addSystemMessage(
+ tf(
+ "session.msg.connection_closed_with_code",
+ code,
+ reason.ifBlank { t("session.text.connection_interrupted") }
+ )
+ )
+ scheduleReconnect(t("session.reason.connection_interrupted"))
}
/**
@@ -834,7 +1003,7 @@ object ChatSessionManager {
private fun addSystemMessage(content: String) {
val message = UiMessage(
role = MessageRole.SYSTEM,
- sender = "系统",
+ sender = t("session.sender.system"),
subtitle = "",
content = content,
channel = MessageChannel.BROADCAST
@@ -858,7 +1027,7 @@ object ChatSessionManager {
) {
showIncomingNotification(
title = sender,
- body = content.ifBlank { "收到一条新消息" }
+ body = content.ifBlank { t("session.notification.new_message") }
)
appendMessage(
UiMessage(
@@ -871,6 +1040,31 @@ object ChatSessionManager {
)
}
+ private fun addIncomingAudioMessage(
+ sender: String,
+ subtitle: String,
+ audioBase64: String,
+ durationMillis: Long,
+ channel: MessageChannel
+ ) {
+ showIncomingNotification(
+ title = sender,
+ body = t("session.notification.new_voice_message")
+ )
+ appendMessage(
+ UiMessage(
+ role = MessageRole.INCOMING,
+ sender = sender,
+ subtitle = subtitle,
+ content = t("session.message.voice"),
+ channel = channel,
+ contentType = MessageContentType.AUDIO,
+ audioBase64 = audioBase64,
+ audioDurationMillis = durationMillis.coerceAtLeast(0L)
+ )
+ )
+ }
+
/**
* 添加一条发出的消息。
* @param content 消息内容
@@ -885,7 +1079,7 @@ object ChatSessionManager {
appendMessage(
UiMessage(
role = MessageRole.OUTGOING,
- sender = "我",
+ sender = t("session.sender.me"),
subtitle = subtitle,
content = content,
channel = channel
@@ -893,6 +1087,171 @@ object ChatSessionManager {
)
}
+ private fun addOutgoingAudioMessage(
+ subtitle: String,
+ channel: MessageChannel,
+ audioBase64: String,
+ durationMillis: Long
+ ) {
+ appendMessage(
+ UiMessage(
+ role = MessageRole.OUTGOING,
+ sender = t("session.sender.me"),
+ subtitle = subtitle,
+ content = t("session.message.voice"),
+ channel = channel,
+ contentType = MessageContentType.AUDIO,
+ audioBase64 = audioBase64,
+ audioDurationMillis = durationMillis.coerceAtLeast(0L)
+ )
+ )
+ }
+
+ private fun resolveOutgoingRoute(state: ChatUiState): OutgoingRoute? {
+ val key = if (state.directMode) state.targetKey.trim() else ""
+ if (state.directMode && key.isBlank()) {
+ _uiState.update { it.copy(statusHint = t("session.hint.fill_target_key_before_private")) }
+ return null
+ }
+ val type = if (key.isBlank()) "broadcast" else "forward"
+ val channel = if (key.isBlank()) MessageChannel.BROADCAST else MessageChannel.PRIVATE
+ val subtitle = if (key.isBlank()) "" else tf("session.subtitle.private_to_key", summarizeKey(key))
+ return OutgoingRoute(type = type, key = key, channel = channel, subtitle = subtitle)
+ }
+
+ private suspend fun sendSignedPayload(route: OutgoingRoute, payloadText: String) {
+ val id = ensureIdentity()
+ val timestamp = cryptoManager.unixSecondsNow()
+ val nonce = cryptoManager.createNonce()
+ val signingInput = listOf(
+ route.type,
+ route.key,
+ payloadText,
+ timestamp.toString(),
+ nonce
+ ).joinToString("\n")
+ val signature = withContext(Dispatchers.Default) {
+ cryptoManager.signText(id.privateKey, signingInput)
+ }
+
+ val payload = SignedPayloadDto(
+ payload = payloadText,
+ timestamp = timestamp,
+ nonce = nonce,
+ signature = signature
+ )
+ val envelope = EnvelopeDto(
+ type = route.type,
+ key = route.key,
+ data = json.encodeToJsonElement(payload)
+ )
+
+ val plain = json.encodeToString(envelope)
+ val cipher = withContext(Dispatchers.Default) {
+ cryptoManager.encryptChunked(serverPublicKey, plain)
+ }
+ check(socketClient.send(cipher)) { t("session.error.connection_unavailable") }
+ }
+
+ private fun parseAudioPayload(payloadText: String): AudioPayloadDto? {
+ if (!payloadText.startsWith(AUDIO_MESSAGE_PREFIX)) return null
+ val encoded = payloadText.removePrefix(AUDIO_MESSAGE_PREFIX).trim()
+ if (encoded.isBlank()) return null
+ return runCatching {
+ json.decodeFromString(encoded)
+ }.getOrNull()?.takeIf { dto ->
+ dto.encoding.equals("base64", ignoreCase = true) && dto.data.isNotBlank()
+ }
+ }
+
+ private fun parseAudioChunkPayload(payloadText: String): AudioChunkPayloadDto? {
+ if (!payloadText.startsWith(AUDIO_CHUNK_MESSAGE_PREFIX)) return null
+ val encoded = payloadText.removePrefix(AUDIO_CHUNK_MESSAGE_PREFIX).trim()
+ if (encoded.isBlank()) return null
+ return runCatching {
+ json.decodeFromString(encoded)
+ }.getOrNull()?.takeIf { dto ->
+ dto.encoding.equals("base64", ignoreCase = true) &&
+ dto.messageId.isNotBlank() &&
+ dto.total in 1..MAX_AUDIO_CHUNK_COUNT &&
+ dto.index in 0 until dto.total &&
+ dto.data.isNotBlank()
+ }
+ }
+
+ private fun ingestIncomingAudioChunk(
+ sender: String,
+ subtitle: String,
+ channel: MessageChannel,
+ chunk: AudioChunkPayloadDto
+ ) {
+ val now = System.currentTimeMillis()
+ purgeExpiredAudioChunkBuffers(now)
+ val bufferKey = "${channel.name}:${sender}:${chunk.messageId}"
+ val buffer = incomingAudioChunkBuffers[bufferKey]
+ val active = if (buffer == null || buffer.total != chunk.total) {
+ IncomingAudioChunkBuffer(
+ sender = sender,
+ subtitle = subtitle,
+ channel = channel,
+ total = chunk.total,
+ durationMillis = chunk.durationMillis.coerceAtLeast(0L),
+ createdAtMillis = now,
+ chunks = MutableList(chunk.total) { null }
+ ).also { created ->
+ incomingAudioChunkBuffers[bufferKey] = created
+ }
+ } else {
+ if (buffer.sender != sender || buffer.channel != channel) {
+ return
+ }
+ buffer
+ }
+
+ active.chunks[chunk.index] = chunk.data
+ val completed = active.chunks.all { !it.isNullOrBlank() }
+ if (!completed) return
+
+ incomingAudioChunkBuffers.remove(bufferKey)
+ val merged = buildString {
+ active.chunks.forEach { part ->
+ append(part.orEmpty())
+ }
+ }
+ if (merged.isBlank()) return
+
+ addIncomingAudioMessage(
+ sender = active.sender,
+ subtitle = active.subtitle,
+ audioBase64 = merged,
+ durationMillis = active.durationMillis,
+ channel = active.channel
+ )
+ }
+
+ private fun purgeExpiredAudioChunkBuffers(nowMillis: Long) {
+ if (incomingAudioChunkBuffers.isEmpty()) return
+ val expiredKeys = incomingAudioChunkBuffers
+ .filterValues { nowMillis - it.createdAtMillis >= AUDIO_CHUNK_BUFFER_TTL_MS }
+ .keys
+ expiredKeys.forEach { key ->
+ incomingAudioChunkBuffers.remove(key)
+ }
+ }
+
+ private fun splitAudioBase64(base64: String, chunkSize: Int): List {
+ if (base64.isEmpty() || chunkSize <= 0) return emptyList()
+ if (base64.length <= chunkSize) return listOf(base64)
+ val chunks = ArrayList((base64.length + chunkSize - 1) / chunkSize)
+ var start = 0
+ while (start < base64.length) {
+ val end = minOf(start + chunkSize, base64.length)
+ chunks.add(base64.substring(start, end))
+ start = end
+ }
+ return chunks
+ }
+
/**
* 将消息追加到列表尾部,并清理超出数量限制的消息。
* @param message 要追加的消息
@@ -934,11 +1293,11 @@ object ChatSessionManager {
reconnectAttempt += 1
val exponential = 1 shl minOf(reconnectAttempt - 1, 5)
val delaySeconds = minOf(MAX_RECONNECT_DELAY_SECONDS, exponential)
- addSystemMessage("$reason,${delaySeconds}s 后自动重连(第 $reconnectAttempt 次)")
+ addSystemMessage(tf("session.msg.auto_reconnect_in", reason, delaySeconds, reconnectAttempt))
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
- statusHint = "${delaySeconds}s 后自动重连(第 $reconnectAttempt 次)"
+ statusHint = tf("session.hint.auto_reconnect_in", delaySeconds, reconnectAttempt)
)
}
@@ -953,7 +1312,7 @@ object ChatSessionManager {
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
- statusHint = "重连失败:服务器地址无效"
+ statusHint = t("session.hint.reconnect_invalid_server")
)
}
return@launch
@@ -967,7 +1326,7 @@ object ChatSessionManager {
_uiState.update {
it.copy(
status = ConnectionStatus.CONNECTING,
- statusHint = "正在自动重连..."
+ statusHint = t("session.hint.auto_reconnecting")
)
}
socketClient.connect(target, socketListener)
@@ -1018,10 +1377,10 @@ object ChatSessionManager {
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
- statusHint = "握手超时,请检查地址路径与反向代理"
+ statusHint = t("session.hint.handshake_timeout")
)
}
- addSystemMessage("握手超时:未收到服务端 publickey 首包(当前地址:$currentUrl)")
+ addSystemMessage(tf("session.msg.handshake_timeout_with_url", currentUrl))
socketClient.close(1000, "hello_timeout")
}
}
@@ -1113,10 +1472,10 @@ object ChatSessionManager {
val manager = app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
MESSAGE_CHANNEL_ID,
- "OnlineMsg 消息提醒",
+ t("session.notification.channel_name"),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
- description = "收到服务器新消息时提醒"
+ description = t("session.notification.channel_desc")
}
manager.createNotificationChannel(channel)
}
@@ -1167,6 +1526,23 @@ object ChatSessionManager {
return notificationIdSeed
}
+ private data class OutgoingRoute(
+ val type: String,
+ val key: String,
+ val channel: MessageChannel,
+ val subtitle: String
+ )
+
+ private data class IncomingAudioChunkBuffer(
+ val sender: String,
+ val subtitle: String,
+ val channel: MessageChannel,
+ val total: Int,
+ val durationMillis: Long,
+ val createdAtMillis: Long,
+ val chunks: MutableList
+ )
+
// 常量定义
private const val HELLO_TIMEOUT_MS = 12_000L
private const val AUTH_TIMEOUT_MS = 20_000L
@@ -1174,4 +1550,10 @@ object ChatSessionManager {
private const val MAX_RECONNECT_DELAY_SECONDS = 30
private const val SYSTEM_MESSAGE_TTL_MS = 1_000L
private const val MESSAGE_CHANNEL_ID = "onlinemsg_messages"
+ private const val AUDIO_MESSAGE_PREFIX = "[[OMS_AUDIO_V1]]"
+ private const val AUDIO_CHUNK_MESSAGE_PREFIX = "[[OMS_AUDIO_CHUNK_V1]]"
+ private const val AUDIO_CHUNK_BASE64_SIZE = 20_000
+ private const val MAX_AUDIO_CHUNK_COUNT = 30
+ private const val AUDIO_CHUNK_BUFFER_TTL_MS = 180_000L
+ private const val MAX_OUTBOUND_MESSAGE_BYTES = 60 * 1024
}
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt
index c7cc58b..1269f13 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt
@@ -31,6 +31,14 @@ enum class MessageChannel {
PRIVATE
}
+/**
+ * 消息内容类型(文本/音频)。
+ */
+enum class MessageContentType {
+ TEXT,
+ AUDIO
+}
+
/**
* 单条消息的数据类。
* @property id 唯一标识(默认随机 UUID)
@@ -48,7 +56,10 @@ data class UiMessage(
val subtitle: String = "",
val content: String,
val channel: MessageChannel,
- val timestampMillis: Long = System.currentTimeMillis()
+ val timestampMillis: Long = System.currentTimeMillis(),
+ val contentType: MessageContentType = MessageContentType.TEXT,
+ val audioBase64: String = "",
+ val audioDurationMillis: Long = 0L
)
/**
@@ -141,4 +152,4 @@ data class ChatUiState(
*/
sealed interface UiEvent {
data class ShowSnackbar(val message: String) : UiEvent
-}
\ No newline at end of file
+}
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt
index 8b53474..5f8c2f8 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt
@@ -30,9 +30,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
fun connect() = ChatSessionManager.connect()
fun disconnect() = ChatSessionManager.disconnect()
fun sendMessage() = ChatSessionManager.sendMessage()
+ fun sendAudioMessage(audioBase64: String, durationMillis: Long) =
+ ChatSessionManager.sendAudioMessage(audioBase64, durationMillis)
fun onMessageCopied() = ChatSessionManager.onMessageCopied()
fun updateTheme(themeId: String) = ChatSessionManager.updateTheme(themeId)
fun updateUseDynamicColor(enabled: Boolean) = ChatSessionManager.updateUseDynamicColor(enabled)
fun updateLanguage(language: String) = ChatSessionManager.updateLanguage(language)
-}
\ No newline at end of file
+}
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/util/AudioRecorder.kt b/android-client/app/src/main/java/com/onlinemsg/client/util/AudioRecorder.kt
new file mode 100644
index 0000000..11abca4
--- /dev/null
+++ b/android-client/app/src/main/java/com/onlinemsg/client/util/AudioRecorder.kt
@@ -0,0 +1,89 @@
+package com.onlinemsg.client.util
+
+import android.content.Context
+import android.media.MediaRecorder
+import android.os.Build
+import android.util.Base64
+import java.io.File
+
+data class RecordedAudio(
+ val base64: String,
+ val durationMillis: Long
+)
+
+class AudioRecorder(private val context: Context) {
+ private var mediaRecorder: MediaRecorder? = null
+ private var outputFile: File? = null
+ private var startedAtMillis: Long = 0L
+
+ fun start(): Boolean {
+ if (mediaRecorder != null) return false
+ val file = runCatching {
+ File.createTempFile("oms_record_", ".m4a", context.cacheDir)
+ }.getOrNull() ?: return false
+
+ val recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ MediaRecorder(context)
+ } else {
+ MediaRecorder()
+ }
+
+ val started = runCatching {
+ recorder.setAudioSource(MediaRecorder.AudioSource.MIC)
+ recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
+ recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
+ recorder.setAudioChannels(1)
+ recorder.setAudioEncodingBitRate(24_000)
+ recorder.setAudioSamplingRate(16_000)
+ recorder.setMaxDuration(60_000)
+ recorder.setOutputFile(file.absolutePath)
+ recorder.prepare()
+ recorder.start()
+ true
+ }.getOrElse {
+ runCatching { recorder.reset() }
+ runCatching { recorder.release() }
+ file.delete()
+ false
+ }
+
+ if (!started) return false
+
+ mediaRecorder = recorder
+ outputFile = file
+ startedAtMillis = System.currentTimeMillis()
+ return true
+ }
+
+ fun stopAndEncode(send: Boolean): RecordedAudio? {
+ val recorder = mediaRecorder ?: return null
+ mediaRecorder = null
+ val file = outputFile
+ outputFile = null
+
+ runCatching { recorder.stop() }
+ runCatching { recorder.reset() }
+ runCatching { recorder.release() }
+
+ if (!send || file == null) {
+ file?.delete()
+ return null
+ }
+
+ val duration = (System.currentTimeMillis() - startedAtMillis).coerceAtLeast(0L)
+ val bytes = runCatching { file.readBytes() }.getOrNull()
+ file.delete()
+
+ if (bytes == null || bytes.isEmpty()) return null
+ val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
+ return RecordedAudio(base64 = base64, durationMillis = duration)
+ }
+
+ fun cancel() {
+ stopAndEncode(send = false)
+ }
+
+ fun release() {
+ cancel()
+ }
+}
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt b/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt
index 355c591..57ebc9b 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt
@@ -32,7 +32,92 @@ object LanguageManager {
"settings.connect" to "连接",
"settings.disconnect" to "断开",
"settings.clear_msg" to "清空消息",
+ "settings.chat_data" to "聊天数据",
"settings.dynamic_color" to "使用动态颜色",
+ "status.idle" to "未连接",
+ "status.connecting" to "连接中",
+ "status.ready" to "已连接",
+ "status.error" to "异常断开",
+ "hint.tap_to_connect" to "点击连接开始聊天",
+ "hint.connecting_server" to "正在连接服务器...",
+ "hint.ready_chat" to "已连接,可以开始聊天",
+ "hint.closed" to "连接已关闭",
+ "hint.reconnecting" to "连接已中断,正在重试",
+ "hint.reconnect_invalid_server" to "重连失败:服务器地址无效",
+ "hint.fill_target_key" to "请先填写目标公钥,再发送私聊消息",
+ "hint.server_rejected_prefix" to "服务器拒绝连接:",
+ "hint.audio_send_failed_prefix" to "语音发送失败:",
+ "session.sender.system" to "系统",
+ "session.sender.me" to "我",
+ "session.sender.anonymous" to "匿名用户",
+ "session.sender.private_message" to "私聊消息",
+ "session.reason.connection_error" to "连接异常",
+ "session.reason.connection_interrupted" to "连接已中断",
+ "session.text.policy_restriction" to "策略限制",
+ "session.text.connection_interrupted" to "连接中断",
+ "session.snackbar.invalid_server" to "请输入有效的服务器地址",
+ "session.snackbar.server_saved" to "服务器地址已保存",
+ "session.snackbar.server_list_updated" to "已更新服务器地址列表",
+ "session.snackbar.public_key_read_failed" to "公钥读取失败:%s",
+ "session.hint.connected_preparing" to "已连接,正在准备聊天...",
+ "session.hint.binary_handshake_parsing" to "收到二进制握手帧,正在尝试解析...",
+ "session.hint.connection_error_retrying" to "连接异常,正在重试",
+ "session.hint.server_saved" to "服务器地址已保存",
+ "session.hint.server_restored_default" to "已恢复默认服务器地址",
+ "session.hint.server_removed" to "已移除当前服务器地址",
+ "session.hint.fill_valid_server" to "请填写有效服务器地址",
+ "session.hint.connecting_server" to "正在连接服务器...",
+ "session.hint.connection_closed" to "连接已关闭",
+ "session.hint.audio_chunk_over_limit" to "语音过长,超过可发送分片上限",
+ "session.hint.audio_send_failed" to "语音发送失败:%s",
+ "session.hint.handshake_data_received" to "已收到握手数据,正在解析...",
+ "session.hint.handshake_incomplete_response" to "握手失败:服务端响应不完整",
+ "session.hint.handshake_unexpected_message" to "握手失败:收到非预期消息",
+ "session.hint.handshake_first_packet_parse_failed" to "握手失败:首包解析失败",
+ "session.hint.authenticating" to "正在完成身份验证...",
+ "session.hint.connection_timeout_retry" to "连接超时,请重试",
+ "session.hint.auth_failed" to "认证失败",
+ "session.hint.ready_to_chat" to "已连接,可以开始聊天",
+ "session.hint.server_rejected" to "服务器拒绝连接:%s",
+ "session.hint.auto_retry_connecting" to "正在自动重试连接...",
+ "session.hint.connection_interrupted_retry" to "连接已中断,正在重试",
+ "session.hint.fill_target_key_before_private" to "请先填写目标公钥,再发送私聊消息",
+ "session.hint.auto_reconnect_in" to "%ds 后自动重连(第 %d 次)",
+ "session.hint.reconnect_invalid_server" to "重连失败:服务器地址无效",
+ "session.hint.auto_reconnecting" to "正在自动重连...",
+ "session.hint.handshake_timeout" to "握手超时,请检查地址路径与反向代理",
+ "session.msg.connection_established" to "连接已建立",
+ "session.msg.text_frame_error" to "文本帧处理异常:%s",
+ "session.msg.binary_frame_error" to "二进制帧处理异常:%s",
+ "session.msg.handshake_binary_unreadable" to "握手二进制帧无法转为文本,len=%d hex=%s",
+ "session.msg.connection_error" to "连接异常:%s",
+ "session.msg.auto_restore_connecting" to "已恢复上次会话,正在自动连接",
+ "session.msg.disconnected" to "已断开连接",
+ "session.msg.send_failed" to "发送失败:%s",
+ "session.msg.audio_chunk_canceled" to "语音过长,已取消发送",
+ "session.msg.audio_send_failed" to "语音发送失败:%s",
+ "session.msg.handshake_unexpected_type" to "握手阶段收到非预期消息类型:%s",
+ "session.msg.handshake_parse_failed" to "握手包解析失败:%s",
+ "session.msg.decryption_failed" to "收到无法解密的消息",
+ "session.msg.auth_timeout" to "认证超时,请检查网络后重试",
+ "session.msg.auth_request_sent" to "已发送认证请求",
+ "session.msg.auth_send_failed" to "认证发送失败:%s",
+ "session.msg.ready" to "连接准备完成",
+ "session.msg.unknown_message_type" to "收到未识别消息类型:%s",
+ "session.msg.server_rejected" to "连接被服务器拒绝(%d):%s",
+ "session.msg.switching_connection_mode_retry" to "连接方式切换中,正在重试",
+ "session.msg.connection_closed_with_code" to "连接关闭 (%d):%s",
+ "session.msg.auto_reconnect_in" to "%s,%ds 后自动重连(第 %d 次)",
+ "session.msg.handshake_timeout_with_url" to "握手超时:未收到服务端 publickey 首包(当前地址:%s)",
+ "session.error.message_too_large" to "消息体过大(%dB),请缩短消息内容后重试",
+ "session.error.connection_unavailable" to "连接不可用",
+ "session.notification.channel_name" to "OnlineMsg 消息提醒",
+ "session.notification.channel_desc" to "收到服务器新消息时提醒",
+ "session.notification.new_message" to "收到一条新消息",
+ "session.notification.new_voice_message" to "收到一条语音消息",
+ "session.message.voice" to "语音消息",
+ "session.subtitle.from_key" to "来自 %s",
+ "session.subtitle.private_to_key" to "私聊 %s",
"chat.broadcast" to "广播",
"chat.private" to "私聊",
"chat.target_key" to "目标公钥",
@@ -40,8 +125,31 @@ object LanguageManager {
"chat.send" to "发送",
"chat.sending" to "发送中",
"chat.empty_hint" to "连接后即可聊天。默认广播,切换到私聊后可填写目标公钥。",
+ "chat.mode_text" to "文字",
+ "chat.mode_audio" to "语音",
+ "chat.audio_hold_to_talk" to "按住说话",
+ "chat.audio_release_send" to "松开发送",
+ "chat.audio_release_cancel" to "松开取消",
+ "chat.audio_slide_cancel" to "按住说话,上滑取消",
+ "chat.audio_canceled" to "已取消语音发送",
+ "chat.audio_sent" to "语音已发送",
+ "chat.audio_too_short" to "录音时间太短",
+ "chat.audio_too_long" to "录音时间过长",
+ "chat.audio_record_failed" to "录音失败,请重试",
+ "chat.audio_permission_required" to "请先授予麦克风权限",
+ "chat.audio_recording" to "录制中",
+ "chat.audio_play" to "播放语音",
+ "chat.audio_stop" to "停止播放",
"common.copied" to "已复制",
"common.unknown" to "未知",
+ "service.foreground.title.ready" to "OnlineMsg 已保持连接",
+ "service.foreground.title.connecting" to "OnlineMsg 正在连接",
+ "service.foreground.title.error" to "OnlineMsg 连接异常",
+ "service.foreground.title.idle" to "OnlineMsg 后台服务",
+ "service.foreground.hint.default" to "后台保持连接中",
+ "service.foreground.action.disconnect" to "断开",
+ "service.foreground.channel.name" to "OnlineMsg 后台连接",
+ "service.foreground.channel.desc" to "保持 WebSocket 后台长连接",
"theme.blue" to "蔚蓝",
"theme.gray" to "商务灰",
"theme.green" to "翠绿",
@@ -73,7 +181,92 @@ object LanguageManager {
"settings.connect" to "Link",
"settings.disconnect" to "Dislink",
"settings.clear_msg" to "ClearMsg",
+ "settings.chat_data" to "Chat Data",
"settings.dynamic_color" to "Use dynamic color",
+ "status.idle" to "Offline",
+ "status.connecting" to "Connecting",
+ "status.ready" to "Connected",
+ "status.error" to "Disconnected",
+ "hint.tap_to_connect" to "Tap connect to start chatting",
+ "hint.connecting_server" to "Connecting to server...",
+ "hint.ready_chat" to "Connected, ready to chat",
+ "hint.closed" to "Connection closed",
+ "hint.reconnecting" to "Connection interrupted, reconnecting",
+ "hint.reconnect_invalid_server" to "Reconnect failed: invalid server address",
+ "hint.fill_target_key" to "Please fill target public key before private message",
+ "hint.server_rejected_prefix" to "Server rejected connection: ",
+ "hint.audio_send_failed_prefix" to "Voice send failed: ",
+ "session.sender.system" to "System",
+ "session.sender.me" to "Me",
+ "session.sender.anonymous" to "Anonymous",
+ "session.sender.private_message" to "Private Message",
+ "session.reason.connection_error" to "Connection error",
+ "session.reason.connection_interrupted" to "Connection interrupted",
+ "session.text.policy_restriction" to "policy restriction",
+ "session.text.connection_interrupted" to "connection interrupted",
+ "session.snackbar.invalid_server" to "Please enter a valid server address",
+ "session.snackbar.server_saved" to "Server address saved",
+ "session.snackbar.server_list_updated" to "Server address list updated",
+ "session.snackbar.public_key_read_failed" to "Public key read failed: %s",
+ "session.hint.connected_preparing" to "Connected, preparing chat...",
+ "session.hint.binary_handshake_parsing" to "Received binary handshake frame, parsing...",
+ "session.hint.connection_error_retrying" to "Connection error, retrying",
+ "session.hint.server_saved" to "Server address saved",
+ "session.hint.server_restored_default" to "Default server restored",
+ "session.hint.server_removed" to "Current server removed",
+ "session.hint.fill_valid_server" to "Please enter a valid server address",
+ "session.hint.connecting_server" to "Connecting to server...",
+ "session.hint.connection_closed" to "Connection closed",
+ "session.hint.audio_chunk_over_limit" to "Voice message too long, chunk limit exceeded",
+ "session.hint.audio_send_failed" to "Voice send failed: %s",
+ "session.hint.handshake_data_received" to "Handshake data received, parsing...",
+ "session.hint.handshake_incomplete_response" to "Handshake failed: incomplete server response",
+ "session.hint.handshake_unexpected_message" to "Handshake failed: unexpected message",
+ "session.hint.handshake_first_packet_parse_failed" to "Handshake failed: first packet parse failed",
+ "session.hint.authenticating" to "Authenticating...",
+ "session.hint.connection_timeout_retry" to "Connection timeout, please retry",
+ "session.hint.auth_failed" to "Authentication failed",
+ "session.hint.ready_to_chat" to "Connected, ready to chat",
+ "session.hint.server_rejected" to "Server rejected connection: %s",
+ "session.hint.auto_retry_connecting" to "Auto retry connecting...",
+ "session.hint.connection_interrupted_retry" to "Connection interrupted, retrying",
+ "session.hint.fill_target_key_before_private" to "Please enter target public key before private message",
+ "session.hint.auto_reconnect_in" to "Auto reconnect in %ds (attempt %d)",
+ "session.hint.reconnect_invalid_server" to "Reconnect failed: invalid server address",
+ "session.hint.auto_reconnecting" to "Auto reconnecting...",
+ "session.hint.handshake_timeout" to "Handshake timeout, check URL path or reverse proxy",
+ "session.msg.connection_established" to "Connection established",
+ "session.msg.text_frame_error" to "Text frame processing error: %s",
+ "session.msg.binary_frame_error" to "Binary frame processing error: %s",
+ "session.msg.handshake_binary_unreadable" to "Binary handshake frame unreadable, len=%d hex=%s",
+ "session.msg.connection_error" to "Connection error: %s",
+ "session.msg.auto_restore_connecting" to "Restored last session, auto connecting",
+ "session.msg.disconnected" to "Disconnected",
+ "session.msg.send_failed" to "Send failed: %s",
+ "session.msg.audio_chunk_canceled" to "Voice message too long, canceled",
+ "session.msg.audio_send_failed" to "Voice send failed: %s",
+ "session.msg.handshake_unexpected_type" to "Unexpected message type during handshake: %s",
+ "session.msg.handshake_parse_failed" to "Handshake packet parse failed: %s",
+ "session.msg.decryption_failed" to "Received undecryptable message",
+ "session.msg.auth_timeout" to "Authentication timeout, please check network and retry",
+ "session.msg.auth_request_sent" to "Authentication request sent",
+ "session.msg.auth_send_failed" to "Authentication send failed: %s",
+ "session.msg.ready" to "Connection ready",
+ "session.msg.unknown_message_type" to "Unknown message type received: %s",
+ "session.msg.server_rejected" to "Connection rejected by server (%d): %s",
+ "session.msg.switching_connection_mode_retry" to "Switching connection mode, retrying",
+ "session.msg.connection_closed_with_code" to "Connection closed (%d): %s",
+ "session.msg.auto_reconnect_in" to "%s, auto reconnect in %ds (attempt %d)",
+ "session.msg.handshake_timeout_with_url" to "Handshake timeout: no server publickey packet (url: %s)",
+ "session.error.message_too_large" to "Message too large (%dB), please shorten and retry",
+ "session.error.connection_unavailable" to "Connection unavailable",
+ "session.notification.channel_name" to "OnlineMsg Notifications",
+ "session.notification.channel_desc" to "Notify when new server messages arrive",
+ "session.notification.new_message" to "New message received",
+ "session.notification.new_voice_message" to "New voice message received",
+ "session.message.voice" to "Voice message",
+ "session.subtitle.from_key" to "From %s",
+ "session.subtitle.private_to_key" to "Private %s",
"chat.broadcast" to "Broadcast",
"chat.private" to "Private",
"chat.target_key" to "Target Public Key",
@@ -81,8 +274,31 @@ object LanguageManager {
"chat.send" to "Send",
"chat.sending" to "Sending",
"chat.empty_hint" to "Connect to start chatting. Default is broadcast.",
+ "chat.mode_text" to "Text",
+ "chat.mode_audio" to "Voice",
+ "chat.audio_hold_to_talk" to "Hold to Talk",
+ "chat.audio_release_send" to "Release to Send",
+ "chat.audio_release_cancel" to "Release to Cancel",
+ "chat.audio_slide_cancel" to "Hold to talk, slide up to cancel",
+ "chat.audio_canceled" to "Voice message canceled",
+ "chat.audio_sent" to "Voice message sent",
+ "chat.audio_too_short" to "Recording is too short",
+ "chat.audio_too_long" to "Recording is too long",
+ "chat.audio_record_failed" to "Recording failed, try again",
+ "chat.audio_permission_required" to "Microphone permission is required",
+ "chat.audio_recording" to "Recording",
+ "chat.audio_play" to "Play voice",
+ "chat.audio_stop" to "Stop",
"common.copied" to "Copied",
"common.unknown" to "Unknown",
+ "service.foreground.title.ready" to "OnlineMsg Connected",
+ "service.foreground.title.connecting" to "OnlineMsg Connecting",
+ "service.foreground.title.error" to "OnlineMsg Connection Error",
+ "service.foreground.title.idle" to "OnlineMsg Background Service",
+ "service.foreground.hint.default" to "Keeping connection in background",
+ "service.foreground.action.disconnect" to "Disconnect",
+ "service.foreground.channel.name" to "OnlineMsg Background Connection",
+ "service.foreground.channel.desc" to "Keep WebSocket long connection in background",
"theme.blue" to "Blue",
"theme.gray" to "Business Gray",
"theme.green" to "Green",
@@ -114,7 +330,92 @@ object LanguageManager {
"settings.connect" to "接続",
"settings.disconnect" to "切断",
"settings.clear_msg" to "履歴を消去",
+ "settings.chat_data" to "チャットデータ",
"settings.dynamic_color" to "動的カラーを使用",
+ "status.idle" to "未接続",
+ "status.connecting" to "接続中",
+ "status.ready" to "接続済み",
+ "status.error" to "切断",
+ "hint.tap_to_connect" to "接続してチャットを開始",
+ "hint.connecting_server" to "サーバーへ接続中...",
+ "hint.ready_chat" to "接続完了、チャット可能",
+ "hint.closed" to "接続を閉じました",
+ "hint.reconnecting" to "接続が中断され、再接続中",
+ "hint.reconnect_invalid_server" to "再接続失敗:サーバーアドレス無効",
+ "hint.fill_target_key" to "個人チャット前に相手の公開鍵を入力してください",
+ "hint.server_rejected_prefix" to "サーバーが接続を拒否しました:",
+ "hint.audio_send_failed_prefix" to "音声送信失敗:",
+ "session.sender.system" to "システム",
+ "session.sender.me" to "自分",
+ "session.sender.anonymous" to "匿名ユーザー",
+ "session.sender.private_message" to "個人メッセージ",
+ "session.reason.connection_error" to "接続エラー",
+ "session.reason.connection_interrupted" to "接続中断",
+ "session.text.policy_restriction" to "ポリシー制限",
+ "session.text.connection_interrupted" to "接続中断",
+ "session.snackbar.invalid_server" to "有効なサーバーアドレスを入力してください",
+ "session.snackbar.server_saved" to "サーバーアドレスを保存しました",
+ "session.snackbar.server_list_updated" to "サーバーアドレス一覧を更新しました",
+ "session.snackbar.public_key_read_failed" to "公開鍵の読み取りに失敗しました:%s",
+ "session.hint.connected_preparing" to "接続済み、チャット準備中...",
+ "session.hint.binary_handshake_parsing" to "バイナリ握手フレーム受信、解析中...",
+ "session.hint.connection_error_retrying" to "接続エラー、再試行中",
+ "session.hint.server_saved" to "サーバーアドレスを保存しました",
+ "session.hint.server_restored_default" to "デフォルトサーバーを復元しました",
+ "session.hint.server_removed" to "現在のサーバーを削除しました",
+ "session.hint.fill_valid_server" to "有効なサーバーアドレスを入力してください",
+ "session.hint.connecting_server" to "サーバーへ接続中...",
+ "session.hint.connection_closed" to "接続を閉じました",
+ "session.hint.audio_chunk_over_limit" to "音声が長すぎて分割上限を超えました",
+ "session.hint.audio_send_failed" to "音声送信失敗:%s",
+ "session.hint.handshake_data_received" to "握手データ受信、解析中...",
+ "session.hint.handshake_incomplete_response" to "握手失敗:サーバー応答が不完全です",
+ "session.hint.handshake_unexpected_message" to "握手失敗:予期しないメッセージ",
+ "session.hint.handshake_first_packet_parse_failed" to "握手失敗:初回パケット解析失敗",
+ "session.hint.authenticating" to "認証中...",
+ "session.hint.connection_timeout_retry" to "接続タイムアウト、再試行してください",
+ "session.hint.auth_failed" to "認証失敗",
+ "session.hint.ready_to_chat" to "接続済み、チャット可能",
+ "session.hint.server_rejected" to "サーバーが接続を拒否しました:%s",
+ "session.hint.auto_retry_connecting" to "自動再試行で接続中...",
+ "session.hint.connection_interrupted_retry" to "接続が中断され、再試行中",
+ "session.hint.fill_target_key_before_private" to "個人チャット前に相手の公開鍵を入力してください",
+ "session.hint.auto_reconnect_in" to "%d秒後に自動再接続(%d回目)",
+ "session.hint.reconnect_invalid_server" to "再接続失敗:サーバーアドレス無効",
+ "session.hint.auto_reconnecting" to "自動再接続中...",
+ "session.hint.handshake_timeout" to "握手タイムアウト:URL パスまたはリバースプロキシを確認",
+ "session.msg.connection_established" to "接続が確立されました",
+ "session.msg.text_frame_error" to "テキストフレーム処理エラー:%s",
+ "session.msg.binary_frame_error" to "バイナリフレーム処理エラー:%s",
+ "session.msg.handshake_binary_unreadable" to "バイナリ握手フレームをテキスト化できません。len=%d hex=%s",
+ "session.msg.connection_error" to "接続エラー:%s",
+ "session.msg.auto_restore_connecting" to "前回セッションを復元し自動接続中",
+ "session.msg.disconnected" to "切断しました",
+ "session.msg.send_failed" to "送信失敗:%s",
+ "session.msg.audio_chunk_canceled" to "音声が長すぎるため送信を中止しました",
+ "session.msg.audio_send_failed" to "音声送信失敗:%s",
+ "session.msg.handshake_unexpected_type" to "握手中に予期しないメッセージ種別:%s",
+ "session.msg.handshake_parse_failed" to "握手パケット解析失敗:%s",
+ "session.msg.decryption_failed" to "復号できないメッセージを受信しました",
+ "session.msg.auth_timeout" to "認証タイムアウト:ネットワークを確認して再試行してください",
+ "session.msg.auth_request_sent" to "認証リクエストを送信しました",
+ "session.msg.auth_send_failed" to "認証送信失敗:%s",
+ "session.msg.ready" to "接続準備完了",
+ "session.msg.unknown_message_type" to "未識別メッセージ種別を受信:%s",
+ "session.msg.server_rejected" to "サーバーに接続拒否されました(%d):%s",
+ "session.msg.switching_connection_mode_retry" to "接続方式を切り替えて再試行中",
+ "session.msg.connection_closed_with_code" to "接続が閉じられました(%d):%s",
+ "session.msg.auto_reconnect_in" to "%s、%d秒後に自動再接続(%d回目)",
+ "session.msg.handshake_timeout_with_url" to "握手タイムアウト:server publickey 初回パケット未受信(URL: %s)",
+ "session.error.message_too_large" to "メッセージが大きすぎます(%dB)。短くして再試行してください",
+ "session.error.connection_unavailable" to "接続不可",
+ "session.notification.channel_name" to "OnlineMsg 通知",
+ "session.notification.channel_desc" to "サーバー新着メッセージを通知",
+ "session.notification.new_message" to "新着メッセージ",
+ "session.notification.new_voice_message" to "新着音声メッセージ",
+ "session.message.voice" to "音声メッセージ",
+ "session.subtitle.from_key" to "%s から",
+ "session.subtitle.private_to_key" to "個人 %s",
"chat.broadcast" to "全体",
"chat.private" to "個人",
"chat.target_key" to "相手の公開鍵",
@@ -122,8 +423,31 @@ object LanguageManager {
"chat.send" to "送信",
"chat.sending" to "送信中",
"chat.empty_hint" to "接続するとチャットを開始できます。",
+ "chat.mode_text" to "テキスト",
+ "chat.mode_audio" to "音声",
+ "chat.audio_hold_to_talk" to "長押しで録音",
+ "chat.audio_release_send" to "離して送信",
+ "chat.audio_release_cancel" to "離してキャンセル",
+ "chat.audio_slide_cancel" to "長押し中に上へスライドでキャンセル",
+ "chat.audio_canceled" to "音声送信をキャンセルしました",
+ "chat.audio_sent" to "音声を送信しました",
+ "chat.audio_too_short" to "録音時間が短すぎます",
+ "chat.audio_too_long" to "録音が長すぎます",
+ "chat.audio_record_failed" to "録音に失敗しました",
+ "chat.audio_permission_required" to "マイク権限が必要です",
+ "chat.audio_recording" to "録音中",
+ "chat.audio_play" to "再生",
+ "chat.audio_stop" to "停止",
"common.copied" to "コピーしました",
"common.unknown" to "不明",
+ "service.foreground.title.ready" to "OnlineMsg 接続維持中",
+ "service.foreground.title.connecting" to "OnlineMsg 接続中",
+ "service.foreground.title.error" to "OnlineMsg 接続エラー",
+ "service.foreground.title.idle" to "OnlineMsg バックグラウンドサービス",
+ "service.foreground.hint.default" to "バックグラウンドで接続を維持中",
+ "service.foreground.action.disconnect" to "切断",
+ "service.foreground.channel.name" to "OnlineMsg バックグラウンド接続",
+ "service.foreground.channel.desc" to "WebSocket のバックグラウンド長時間接続を維持",
"theme.blue" to "ブルー",
"theme.gray" to "ビジネスグレー",
"theme.green" to "グリーン",
@@ -155,7 +479,92 @@ object LanguageManager {
"settings.connect" to "연결",
"settings.disconnect" to "연결 끊기",
"settings.clear_msg" to "정보 삭제",
+ "settings.chat_data" to "채팅 데이터",
"settings.dynamic_color" to "동적 색상 사용",
+ "status.idle" to "연결 안 됨",
+ "status.connecting" to "연결 중",
+ "status.ready" to "연결됨",
+ "status.error" to "연결 끊김",
+ "hint.tap_to_connect" to "연결을 눌러 채팅을 시작하세요",
+ "hint.connecting_server" to "서버에 연결 중...",
+ "hint.ready_chat" to "연결 완료, 채팅 가능",
+ "hint.closed" to "연결이 종료되었습니다",
+ "hint.reconnecting" to "연결이 끊겨 재연결 중입니다",
+ "hint.reconnect_invalid_server" to "재연결 실패: 서버 주소가 올바르지 않습니다",
+ "hint.fill_target_key" to "비공개 채팅 전 대상 공개키를 입력하세요",
+ "hint.server_rejected_prefix" to "서버가 연결을 거부했습니다: ",
+ "hint.audio_send_failed_prefix" to "음성 전송 실패: ",
+ "session.sender.system" to "시스템",
+ "session.sender.me" to "나",
+ "session.sender.anonymous" to "익명 사용자",
+ "session.sender.private_message" to "비공개 메시지",
+ "session.reason.connection_error" to "연결 오류",
+ "session.reason.connection_interrupted" to "연결 중단",
+ "session.text.policy_restriction" to "정책 제한",
+ "session.text.connection_interrupted" to "연결 중단",
+ "session.snackbar.invalid_server" to "유효한 서버 주소를 입력하세요",
+ "session.snackbar.server_saved" to "서버 주소를 저장했습니다",
+ "session.snackbar.server_list_updated" to "서버 주소 목록을 업데이트했습니다",
+ "session.snackbar.public_key_read_failed" to "공개키 읽기 실패: %s",
+ "session.hint.connected_preparing" to "연결됨, 채팅 준비 중...",
+ "session.hint.binary_handshake_parsing" to "바이너리 핸드셰이크 프레임 수신, 파싱 중...",
+ "session.hint.connection_error_retrying" to "연결 오류, 재시도 중",
+ "session.hint.server_saved" to "서버 주소를 저장했습니다",
+ "session.hint.server_restored_default" to "기본 서버를 복원했습니다",
+ "session.hint.server_removed" to "현재 서버를 삭제했습니다",
+ "session.hint.fill_valid_server" to "유효한 서버 주소를 입력하세요",
+ "session.hint.connecting_server" to "서버에 연결 중...",
+ "session.hint.connection_closed" to "연결이 종료되었습니다",
+ "session.hint.audio_chunk_over_limit" to "음성이 너무 길어 분할 상한을 초과했습니다",
+ "session.hint.audio_send_failed" to "음성 전송 실패: %s",
+ "session.hint.handshake_data_received" to "핸드셰이크 데이터 수신, 파싱 중...",
+ "session.hint.handshake_incomplete_response" to "핸드셰이크 실패: 서버 응답이 불완전합니다",
+ "session.hint.handshake_unexpected_message" to "핸드셰이크 실패: 예상치 못한 메시지",
+ "session.hint.handshake_first_packet_parse_failed" to "핸드셰이크 실패: 첫 패킷 파싱 실패",
+ "session.hint.authenticating" to "인증 중...",
+ "session.hint.connection_timeout_retry" to "연결 시간 초과, 다시 시도하세요",
+ "session.hint.auth_failed" to "인증 실패",
+ "session.hint.ready_to_chat" to "연결 완료, 채팅 가능",
+ "session.hint.server_rejected" to "서버가 연결을 거부했습니다: %s",
+ "session.hint.auto_retry_connecting" to "자동 재시도 연결 중...",
+ "session.hint.connection_interrupted_retry" to "연결이 끊겨 재시도 중",
+ "session.hint.fill_target_key_before_private" to "비공개 채팅 전 대상 공개키를 입력하세요",
+ "session.hint.auto_reconnect_in" to "%d초 후 자동 재연결 (%d회차)",
+ "session.hint.reconnect_invalid_server" to "재연결 실패: 서버 주소가 올바르지 않습니다",
+ "session.hint.auto_reconnecting" to "자동 재연결 중...",
+ "session.hint.handshake_timeout" to "핸드셰이크 시간 초과: URL 경로 또는 리버스 프록시를 확인하세요",
+ "session.msg.connection_established" to "연결이 설정되었습니다",
+ "session.msg.text_frame_error" to "텍스트 프레임 처리 오류: %s",
+ "session.msg.binary_frame_error" to "바이너리 프레임 처리 오류: %s",
+ "session.msg.handshake_binary_unreadable" to "핸드셰이크 바이너리 프레임을 텍스트로 변환할 수 없습니다. len=%d hex=%s",
+ "session.msg.connection_error" to "연결 오류: %s",
+ "session.msg.auto_restore_connecting" to "이전 세션을 복원하여 자동 연결 중",
+ "session.msg.disconnected" to "연결 해제됨",
+ "session.msg.send_failed" to "전송 실패: %s",
+ "session.msg.audio_chunk_canceled" to "음성이 너무 길어 전송이 취소되었습니다",
+ "session.msg.audio_send_failed" to "음성 전송 실패: %s",
+ "session.msg.handshake_unexpected_type" to "핸드셰이크 중 예상치 못한 메시지 유형: %s",
+ "session.msg.handshake_parse_failed" to "핸드셰이크 패킷 파싱 실패: %s",
+ "session.msg.decryption_failed" to "복호화할 수 없는 메시지를 받았습니다",
+ "session.msg.auth_timeout" to "인증 시간 초과: 네트워크를 확인하고 다시 시도하세요",
+ "session.msg.auth_request_sent" to "인증 요청을 전송했습니다",
+ "session.msg.auth_send_failed" to "인증 전송 실패: %s",
+ "session.msg.ready" to "연결 준비 완료",
+ "session.msg.unknown_message_type" to "알 수 없는 메시지 유형 수신: %s",
+ "session.msg.server_rejected" to "서버가 연결을 거부했습니다 (%d): %s",
+ "session.msg.switching_connection_mode_retry" to "연결 방식을 전환하여 재시도 중",
+ "session.msg.connection_closed_with_code" to "연결 종료 (%d): %s",
+ "session.msg.auto_reconnect_in" to "%s, %d초 후 자동 재연결 (%d회차)",
+ "session.msg.handshake_timeout_with_url" to "핸드셰이크 시간 초과: 서버 publickey 첫 패킷 미수신 (URL: %s)",
+ "session.error.message_too_large" to "메시지가 너무 큽니다 (%dB). 줄여서 다시 시도하세요",
+ "session.error.connection_unavailable" to "연결 불가",
+ "session.notification.channel_name" to "OnlineMsg 알림",
+ "session.notification.channel_desc" to "서버 새 메시지 수신 시 알림",
+ "session.notification.new_message" to "새 메시지 수신",
+ "session.notification.new_voice_message" to "새 음성 메시지 수신",
+ "session.message.voice" to "음성 메시지",
+ "session.subtitle.from_key" to "%s 에서",
+ "session.subtitle.private_to_key" to "비공개 %s",
"chat.broadcast" to "브로드캐스트",
"chat.private" to "비공개 채팅",
"chat.target_key" to "대상 공개키",
@@ -163,8 +572,31 @@ object LanguageManager {
"chat.send" to "전송",
"chat.sending" to "전송 중",
"chat.empty_hint" to "연결 후 채팅이 가능합니다. 기본은 브로드캐스트이며, 비공개 채팅으로 전환 후 대상 공개키를 입력할 수 있습니다.",
+ "chat.mode_text" to "텍스트",
+ "chat.mode_audio" to "음성",
+ "chat.audio_hold_to_talk" to "길게 눌러 말하기",
+ "chat.audio_release_send" to "손을 떼면 전송",
+ "chat.audio_release_cancel" to "손을 떼면 취소",
+ "chat.audio_slide_cancel" to "길게 누른 상태에서 위로 밀어 취소",
+ "chat.audio_canceled" to "음성 전송이 취소되었습니다",
+ "chat.audio_sent" to "음성 메시지를 보냈습니다",
+ "chat.audio_too_short" to "녹음 시간이 너무 짧습니다",
+ "chat.audio_too_long" to "녹음 시간이 너무 깁니다",
+ "chat.audio_record_failed" to "녹음에 실패했습니다",
+ "chat.audio_permission_required" to "마이크 권한이 필요합니다",
+ "chat.audio_recording" to "녹음 중",
+ "chat.audio_play" to "재생",
+ "chat.audio_stop" to "정지",
"common.copied" to "복사됨",
"common.unknown" to "알 수 없음",
+ "service.foreground.title.ready" to "OnlineMsg 연결 유지됨",
+ "service.foreground.title.connecting" to "OnlineMsg 연결 중",
+ "service.foreground.title.error" to "OnlineMsg 연결 오류",
+ "service.foreground.title.idle" to "OnlineMsg 백그라운드 서비스",
+ "service.foreground.hint.default" to "백그라운드에서 연결 유지 중",
+ "service.foreground.action.disconnect" to "연결 끊기",
+ "service.foreground.channel.name" to "OnlineMsg 백그라운드 연결",
+ "service.foreground.channel.desc" to "백그라운드에서 WebSocket 장기 연결 유지",
"theme.blue" to "파랑",
"theme.gray" to "비즈니스 그레이",
"theme.green" to "초록",