feat(android): implement voice message support and polish chat UX

pull/14/head
alimu 2 weeks ago
parent 3974c061b8
commit 8fd031d9fc

@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:allowBackup="true"

@ -15,6 +15,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestNotificationPermissionIfNeeded()
requestAudioPermissionIfNeeded()
enableEdgeToEdge()
setContent {
OnlineMsgApp()
@ -35,7 +36,21 @@ class MainActivity : ComponentActivity() {
)
}
private fun requestAudioPermissionIfNeeded() {
val granted = ContextCompat.checkSelfPermission(
this,
Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED
if (granted) return
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.RECORD_AUDIO),
REQUEST_AUDIO_PERMISSION
)
}
private companion object {
const val REQUEST_NOTIFICATION_PERMISSION = 1002
const val REQUEST_AUDIO_PERMISSION = 1003
}
}

@ -4,10 +4,12 @@ import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database(
entities = [ChatMessageEntity::class],
version = 1,
version = 2,
exportSchema = false
)
abstract class ChatDatabase : RoomDatabase() {
@ -19,13 +21,23 @@ abstract class ChatDatabase : RoomDatabase() {
@Volatile
private var instance: ChatDatabase? = null
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE chat_messages ADD COLUMN contentType TEXT NOT NULL DEFAULT 'TEXT'")
db.execSQL("ALTER TABLE chat_messages ADD COLUMN audioBase64 TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE chat_messages ADD COLUMN audioDurationMillis INTEGER NOT NULL DEFAULT 0")
}
}
fun getInstance(context: Context): ChatDatabase {
return instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
ChatDatabase::class.java,
DB_NAME
).build().also { db ->
)
.addMigrations(MIGRATION_1_2)
.build().also { db ->
instance = db
}
}

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

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

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

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

@ -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<String?>(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,17 +549,36 @@ private fun ChatTab(
Spacer(modifier = Modifier.height(8.dp))
// 消息输入区域
if (inputMode == ChatInputMode.TEXT) {
Row(
verticalAlignment = Alignment.Bottom,
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),
label = { Text(t("chat.input_placeholder")) },
maxLines = 4,
modifier = Modifier
.weight(1f)
.height(56.dp),
placeholder = { Text(t("chat.input_placeholder")) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(
onSend = { onSend() }
@ -401,11 +588,152 @@ private fun ChatTab(
Button(
onClick = onSend,
enabled = state.canSend,
modifier = Modifier.height(56.dp)
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)
) {
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)
) {
Icon(Icons.AutoMirrored.Rounded.Send, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text(if (state.sending) "..." else t("chat.send"))
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,24 +858,42 @@ private fun MessageItem(
}
}
// 消息正文
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
) {
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)
)
if (message.contentType == MessageContentType.TEXT) {
IconButton(
onClick = onCopy,
modifier = Modifier.size(24.dp)
@ -557,6 +912,98 @@ 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)
)
}
}
/**
@ -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 毫秒时间戳

@ -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<String, IncomingAudioChunkBuffer>()
// 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
runCatching {
if (chunks.size == 1) {
val taggedPayload = AUDIO_MESSAGE_PREFIX + json.encodeToString(
AudioPayloadDto(
durationMillis = safeDuration,
data = normalized
)
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)
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() } ?: "匿名用户"
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 = message.data.asPayloadText(),
content = payloadText,
channel = MessageChannel.BROADCAST
)
}
}
"forward" -> {
val sourceKey = message.key.orEmpty()
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 = "私聊消息",
subtitle = sourceKey.takeIf { it.isNotBlank() }?.let { "来自 ${summarizeKey(it)}" }.orEmpty(),
content = message.data.asPayloadText(),
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<AudioPayloadDto>(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<AudioChunkPayloadDto>(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<String> {
if (base64.isEmpty() || chunkSize <= 0) return emptyList()
if (base64.length <= chunkSize) return listOf(base64)
val chunks = ArrayList<String>((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<String?>
)
// 常量定义
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
}

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

@ -30,6 +30,8 @@ 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)

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

@ -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 "초록",

Loading…
Cancel
Save