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

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

@ -79,6 +79,13 @@ bash deploy/redeploy_with_lan_cert.sh
适合 Android 真机、同网段设备和浏览器本地联调。 适合 Android 真机、同网段设备和浏览器本地联调。
Android 客户端 debug 包支持额外信任本地局域网 CA
- 把局域网 WSS 使用的 CA 证书复制到 `deploy/certs/android-local/local_ca.crt`
- `deploy/certs/` 已在 `.gitignore` 中,只用于本地调试,不应提交到 Git
- `assembleDebug` 会自动把它接入 debug-only 的 `networkSecurityConfig`
- release 构建不会信任这张本地 CA
### 3. 生产准备 ### 3. 生产准备
```bash ```bash

@ -47,6 +47,17 @@ cd android-client
- 真机建议地址:`ws://<你的局域网IP>:13173/` - 真机建议地址:`ws://<你的局域网IP>:13173/`
- 若服务端启用 WSS需要 Android 设备信任对应证书。 - 若服务端启用 WSS需要 Android 设备信任对应证书。
### Debug 本地 CA 信任
如果你使用 `deploy` 目录生成的局域网自签名证书做 `wss://` 联调debug 构建可以额外信任一张本地 CA
- 把 CA 证书放到仓库根目录的 `deploy/certs/android-local/local_ca.crt`
- `deploy/certs/` 已在 `.gitignore` 中,这张证书只用于本地调试,不应提交到 Git
- 执行 `./gradlew assembleDebug` 时,构建脚本会自动生成 debug-only 的 `networkSecurityConfig`
- 如果该文件不存在debug 仍然可以编译,只是不会额外信任本地 CA
推荐把局域网部署生成的 `local_ca.crt` 放到这个位置,再连接 `wss://<你的局域网IP>:13173/`
## 协议注意事项 ## 协议注意事项
- 鉴权签名串: - 鉴权签名串:

@ -5,6 +5,12 @@ plugins {
id("com.google.devtools.ksp") id("com.google.devtools.ksp")
} }
val localDebugCaCertSource = rootDir.resolve("../deploy/certs/android-local/local_ca.crt")
val generatedLocalDebugTrustDir = layout.buildDirectory.dir("generated/localDebugTrust")
val generatedLocalDebugTrustRoot = generatedLocalDebugTrustDir.get().asFile
val generatedLocalDebugResDir = generatedLocalDebugTrustRoot.resolve("res")
val generatedLocalDebugManifestFile = generatedLocalDebugTrustRoot.resolve("AndroidManifest.xml")
android { android {
namespace = "com.onlinemsg.client" namespace = "com.onlinemsg.client"
compileSdk = 34 compileSdk = 34
@ -14,7 +20,7 @@ android {
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = 1
versionName = "1.0.0.2" versionName = "1.0.0.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@ -52,6 +58,13 @@ android {
excludes += "/META-INF/{AL2.0,LGPL2.1}" excludes += "/META-INF/{AL2.0,LGPL2.1}"
} }
} }
sourceSets {
getByName("debug") {
res.srcDir(generatedLocalDebugResDir)
manifest.srcFile(generatedLocalDebugManifestFile)
}
}
} }
dependencies { dependencies {
@ -93,6 +106,56 @@ val debugApkExportDir: String = providers.gradleProperty("debugApkExportDir")
.get() .get()
val debugApkExportName = "onlinemsgclient-debug.apk" val debugApkExportName = "onlinemsgclient-debug.apk"
val prepareLocalDebugTrust by tasks.registering {
val sourceFile = localDebugCaCertSource
inputs.file(sourceFile).optional()
outputs.dir(generatedLocalDebugTrustDir)
doLast {
generatedLocalDebugTrustRoot.deleteRecursively()
generatedLocalDebugTrustRoot.mkdirs()
if (sourceFile.exists()) {
val rawDir = generatedLocalDebugResDir.resolve("raw")
val xmlDir = generatedLocalDebugResDir.resolve("xml")
rawDir.mkdirs()
xmlDir.mkdirs()
sourceFile.copyTo(rawDir.resolve("local_ca.crt"), overwrite = true)
xmlDir.resolve("network_security_config.xml").writeText(
"""
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config>
<trust-anchors>
<certificates src="system" />
<certificates src="@raw/local_ca" />
</trust-anchors>
</base-config>
</network-security-config>
""".trimIndent() + "\n"
)
generatedLocalDebugManifestFile.writeText(
"""
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:networkSecurityConfig="@xml/network_security_config" />
</manifest>
""".trimIndent() + "\n"
)
} else {
generatedLocalDebugManifestFile.writeText(
"""
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
""".trimIndent() + "\n"
)
}
}
}
val exportDebugApk by tasks.registering(Copy::class) { val exportDebugApk by tasks.registering(Copy::class) {
from(layout.buildDirectory.file("outputs/apk/debug/app-debug.apk")) from(layout.buildDirectory.file("outputs/apk/debug/app-debug.apk"))
into(debugApkExportDir) into(debugApkExportDir)
@ -102,6 +165,11 @@ val exportDebugApk by tasks.registering(Copy::class) {
} }
} }
tasks.matching { it.name == "preDebugBuild" }.configureEach {
dependsOn(prepareLocalDebugTrust)
}
tasks.matching { it.name == "assembleDebug" }.configureEach { tasks.matching { it.name == "assembleDebug" }.configureEach {
dependsOn(prepareLocalDebugTrust)
finalizedBy(exportDebugApk) finalizedBy(exportDebugApk)
} }

@ -9,7 +9,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
@Database( @Database(
entities = [ChatMessageEntity::class], entities = [ChatMessageEntity::class],
version = 2, version = 3,
exportSchema = false exportSchema = false
) )
abstract class ChatDatabase : RoomDatabase() { abstract class ChatDatabase : RoomDatabase() {
@ -29,6 +29,12 @@ abstract class ChatDatabase : RoomDatabase() {
} }
} }
private val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE chat_messages ADD COLUMN serverKey TEXT NOT NULL DEFAULT ''")
}
}
fun getInstance(context: Context): ChatDatabase { fun getInstance(context: Context): ChatDatabase {
return instance ?: synchronized(this) { return instance ?: synchronized(this) {
instance ?: Room.databaseBuilder( instance ?: Room.databaseBuilder(
@ -36,7 +42,7 @@ abstract class ChatDatabase : RoomDatabase() {
ChatDatabase::class.java, ChatDatabase::class.java,
DB_NAME DB_NAME
) )
.addMigrations(MIGRATION_1_2) .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build().also { db -> .build().also { db ->
instance = db instance = db
} }

@ -6,27 +6,38 @@ import com.onlinemsg.client.ui.MessageRole
import com.onlinemsg.client.ui.UiMessage import com.onlinemsg.client.ui.UiMessage
class ChatHistoryRepository(private val messageDao: ChatMessageDao) { class ChatHistoryRepository(private val messageDao: ChatMessageDao) {
suspend fun loadMessages(limit: Int): List<UiMessage> { suspend fun loadMessages(serverKey: String, limit: Int): List<UiMessage> {
return messageDao.listAll() migrateLegacyMessagesIfNeeded(serverKey)
return messageDao.listByServer(serverKey)
.asSequence() .asSequence()
.mapNotNull { entity -> entity.toUiMessageOrNull() } .mapNotNull { entity -> entity.toUiMessageOrNull() }
.toList() .toList()
.takeLast(limit) .takeLast(limit)
} }
suspend fun appendMessage(message: UiMessage, limit: Int) { suspend fun appendMessage(serverKey: String, message: UiMessage, limit: Int) {
messageDao.upsert(message.toEntity()) messageDao.upsert(message.toEntity(serverKey))
messageDao.trimToLatest(limit) messageDao.trimToLatest(serverKey, limit)
} }
suspend fun clearAll() { suspend fun clearAll(serverKey: String) {
messageDao.clearAll() messageDao.clearAll(serverKey)
}
private suspend fun migrateLegacyMessagesIfNeeded(serverKey: String) {
if (messageDao.countByServer(serverKey) > 0) return
if (messageDao.countLegacyMessages() == 0) return
messageDao.migrateLegacyMessagesToServer(
serverKey = serverKey,
idPrefix = storageIdPrefix(serverKey)
)
} }
} }
private fun UiMessage.toEntity(): ChatMessageEntity { private fun UiMessage.toEntity(serverKey: String): ChatMessageEntity {
return ChatMessageEntity( return ChatMessageEntity(
id = id, id = toStorageId(serverKey, id),
serverKey = serverKey,
role = role.name, role = role.name,
sender = sender, sender = sender,
subtitle = subtitle, subtitle = subtitle,
@ -57,3 +68,13 @@ private fun ChatMessageEntity.toUiMessageOrNull(): UiMessage? {
audioDurationMillis = audioDurationMillis audioDurationMillis = audioDurationMillis
) )
} }
private fun toStorageId(serverKey: String, messageId: String): String {
return if (messageId.startsWith(storageIdPrefix(serverKey))) {
messageId
} else {
storageIdPrefix(serverKey) + messageId
}
}
private fun storageIdPrefix(serverKey: String): String = "$serverKey::"

@ -7,8 +7,8 @@ import androidx.room.Query
@Dao @Dao
interface ChatMessageDao { interface ChatMessageDao {
@Query("SELECT * FROM chat_messages ORDER BY timestampMillis ASC") @Query("SELECT * FROM chat_messages WHERE serverKey = :serverKey ORDER BY timestampMillis ASC")
suspend fun listAll(): List<ChatMessageEntity> suspend fun listByServer(serverKey: String): List<ChatMessageEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(message: ChatMessageEntity) suspend fun upsert(message: ChatMessageEntity)
@ -16,16 +16,27 @@ interface ChatMessageDao {
@Query( @Query(
""" """
DELETE FROM chat_messages DELETE FROM chat_messages
WHERE id NOT IN ( WHERE serverKey = :serverKey
AND id NOT IN (
SELECT id SELECT id
FROM chat_messages FROM chat_messages
WHERE serverKey = :serverKey
ORDER BY timestampMillis DESC ORDER BY timestampMillis DESC
LIMIT :limit LIMIT :limit
) )
""" """
) )
suspend fun trimToLatest(limit: Int) suspend fun trimToLatest(serverKey: String, limit: Int)
@Query("DELETE FROM chat_messages") @Query("DELETE FROM chat_messages WHERE serverKey = :serverKey")
suspend fun clearAll() suspend fun clearAll(serverKey: String)
@Query("SELECT COUNT(*) FROM chat_messages WHERE serverKey = :serverKey")
suspend fun countByServer(serverKey: String): Int
@Query("SELECT COUNT(*) FROM chat_messages WHERE serverKey = ''")
suspend fun countLegacyMessages(): Int
@Query("UPDATE chat_messages SET serverKey = :serverKey, id = :idPrefix || id WHERE serverKey = ''")
suspend fun migrateLegacyMessagesToServer(serverKey: String, idPrefix: String)
} }

@ -6,6 +6,7 @@ import androidx.room.PrimaryKey
@Entity(tableName = "chat_messages") @Entity(tableName = "chat_messages")
data class ChatMessageEntity( data class ChatMessageEntity(
@PrimaryKey val id: String, @PrimaryKey val id: String,
val serverKey: String,
val role: String, val role: String,
val sender: String, val sender: String,
val subtitle: String, val subtitle: String,

@ -1,12 +1,21 @@
package com.onlinemsg.client.ui package com.onlinemsg.client.ui
import android.Manifest import android.Manifest
import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.media.AudioAttributes
import android.media.MediaPlayer import android.media.MediaPlayer
import android.os.Build import android.os.Build
import android.provider.Settings
import android.util.Base64 import android.util.Base64
import android.view.MotionEvent import android.view.MotionEvent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
@ -54,6 +63,7 @@ import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@ -92,8 +102,12 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import com.onlinemsg.client.ui.theme.OnlineMsgTheme import com.onlinemsg.client.ui.theme.OnlineMsgTheme
import java.time.Instant import java.time.Instant
@ -103,6 +117,7 @@ import java.io.File
import com.onlinemsg.client.ui.theme.themeOptions import com.onlinemsg.client.ui.theme.themeOptions
import com.onlinemsg.client.util.AudioRecorder import com.onlinemsg.client.util.AudioRecorder
import com.onlinemsg.client.util.LanguageManager import com.onlinemsg.client.util.LanguageManager
import com.onlinemsg.client.util.NotificationSoundCatalog
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -230,7 +245,7 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) {
onServerUrlChange = viewModel::updateServerUrl, onServerUrlChange = viewModel::updateServerUrl,
onSaveServer = viewModel::saveCurrentServerUrl, onSaveServer = viewModel::saveCurrentServerUrl,
onRemoveServer = viewModel::removeCurrentServerUrl, onRemoveServer = viewModel::removeCurrentServerUrl,
onSelectServer = viewModel::updateServerUrl, onSelectServer = viewModel::selectServerUrl,
onToggleShowSystem = viewModel::toggleShowSystemMessages, onToggleShowSystem = viewModel::toggleShowSystemMessages,
onRevealPublicKey = viewModel::revealMyPublicKey, onRevealPublicKey = viewModel::revealMyPublicKey,
onCopyPublicKey = { onCopyPublicKey = {
@ -472,6 +487,57 @@ private fun ChatTab(
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
AnimatedVisibility(
visible = state.isSwitchingServer,
enter = fadeIn(animationSpec = tween(180)) + slideInVertically(
animationSpec = tween(220),
initialOffsetY = { -it / 2 }
),
exit = fadeOut(animationSpec = tween(140)) + slideOutVertically(
animationSpec = tween(180),
targetOffsetY = { -it / 3 }
)
) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
),
shape = RoundedCornerShape(14.dp),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = t("session.hint.switching_server"),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Text(
text = state.switchingServerLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
if (state.isSwitchingServer) {
Spacer(modifier = Modifier.height(8.dp))
}
val statusHintText = if (audioHint.isNotBlank()) { val statusHintText = if (audioHint.isNotBlank()) {
audioHint audioHint
} else { } else {
@ -1043,6 +1109,14 @@ private fun SettingsTab(
onLanguageChange: (String) -> Unit, onLanguageChange: (String) -> Unit,
onNotificationSoundChange: (String) -> Unit onNotificationSoundChange: (String) -> Unit
) { ) {
val context = LocalContext.current
val previewPlayer = remember(context) { NotificationSoundPreviewPlayer(context) }
val notificationStatus = rememberNotificationSoundStatus(state.notificationSound)
DisposableEffect(Unit) {
onDispose { previewPlayer.release() }
}
fun t(key: String) = LanguageManager.getString(key, state.language) fun t(key: String) = LanguageManager.getString(key, state.language)
val settingsCardModifier = Modifier.fillMaxWidth() val settingsCardModifier = Modifier.fillMaxWidth()
@ -1083,11 +1157,27 @@ private fun SettingsTab(
verticalArrangement = settingsCardContentSpacing verticalArrangement = settingsCardContentSpacing
) { ) {
Text(t("settings.notification_sound"), style = MaterialTheme.typography.titleMedium) Text(t("settings.notification_sound"), style = MaterialTheme.typography.titleMedium)
Text(
text = when {
!notificationStatus.notificationsEnabled -> t("settings.notification_disabled")
!notificationStatus.channelSoundEnabled -> t("settings.notification_sound_disabled")
else -> t("settings.notification_enabled")
},
style = MaterialTheme.typography.bodySmall,
color = if (notificationStatus.notificationsEnabled && notificationStatus.channelSoundEnabled) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.error
}
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(listOf("default", "ding", "nameit5", "wind_chime")) { sound -> items(NotificationSoundCatalog.soundCodes) { sound ->
FilterChip( FilterChip(
selected = state.notificationSound == sound, selected = state.notificationSound == sound,
onClick = { onNotificationSoundChange(sound) }, onClick = {
previewPlayer.play(sound)
onNotificationSoundChange(sound)
},
label = { Text(t("sound.$sound")) }, label = { Text(t("sound.$sound")) },
leadingIcon = { leadingIcon = {
Icon( Icon(
@ -1099,6 +1189,23 @@ private fun SettingsTab(
) )
} }
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { openAppNotificationSettings(context) }) {
Text(t("settings.notification_system_settings"))
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
OutlinedButton(
onClick = {
openNotificationChannelSettings(
context = context,
soundCode = state.notificationSound
)
}
) {
Text(t("settings.notification_channel_settings"))
}
}
}
} }
} }
} }
@ -1352,6 +1459,124 @@ private class AudioMessagePlayer(private val context: Context) {
} }
} }
private data class NotificationSoundStatus(
val notificationsEnabled: Boolean,
val channelSoundEnabled: Boolean
)
@Composable
private fun rememberNotificationSoundStatus(soundCode: String): NotificationSoundStatus {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var status by remember(soundCode, context) {
mutableStateOf(queryNotificationSoundStatus(context, soundCode))
}
LaunchedEffect(soundCode, context) {
status = queryNotificationSoundStatus(context, soundCode)
}
DisposableEffect(soundCode, context, lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
status = queryNotificationSoundStatus(context, soundCode)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
return status
}
private fun queryNotificationSoundStatus(context: Context, soundCode: String): NotificationSoundStatus {
val notificationsEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
if (!notificationsEnabled) {
return NotificationSoundStatus(
notificationsEnabled = false,
channelSoundEnabled = false
)
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return NotificationSoundStatus(
notificationsEnabled = true,
channelSoundEnabled = true
)
}
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = manager.getNotificationChannel(NotificationSoundCatalog.channelId(soundCode))
val channelSoundEnabled = channel?.importance != NotificationManager.IMPORTANCE_NONE &&
(channel?.sound != null || channel == null)
return NotificationSoundStatus(
notificationsEnabled = true,
channelSoundEnabled = channelSoundEnabled
)
}
private fun openAppNotificationSettings(context: Context) {
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
private fun openNotificationChannelSettings(context: Context, soundCode: String) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
openAppNotificationSettings(context)
return
}
val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
putExtra(Settings.EXTRA_CHANNEL_ID, NotificationSoundCatalog.channelId(soundCode))
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
private class NotificationSoundPreviewPlayer(private val context: Context) {
private var mediaPlayer: MediaPlayer? = null
fun play(soundCode: String) {
val resId = NotificationSoundCatalog.resId(soundCode) ?: return
release()
val afd = context.resources.openRawResourceFd(resId) ?: return
val player = MediaPlayer()
val started = runCatching {
player.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_EVENT)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
)
player.setDataSource(afd.fileDescriptor, afd.startOffset, afd.length)
afd.close()
player.setOnCompletionListener { release() }
player.setOnErrorListener { _, _, _ ->
release()
true
}
player.prepare()
player.start()
true
}.getOrElse {
runCatching { afd.close() }
runCatching { player.release() }
false
}
if (!started) return
mediaPlayer = player
}
fun release() {
runCatching { mediaPlayer?.stop() }
runCatching { mediaPlayer?.release() }
mediaPlayer = null
}
}
private fun formatAudioDuration(durationMillis: Long): String { private fun formatAudioDuration(durationMillis: Long): String {
val totalSeconds = (durationMillis / 1000L).coerceAtLeast(0L) val totalSeconds = (durationMillis / 1000L).coerceAtLeast(0L)
val minutes = totalSeconds / 60L val minutes = totalSeconds / 60L

@ -16,7 +16,6 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.onlinemsg.client.MainActivity import com.onlinemsg.client.MainActivity
import com.onlinemsg.client.R
import com.onlinemsg.client.data.crypto.RsaCryptoManager import com.onlinemsg.client.data.crypto.RsaCryptoManager
import com.onlinemsg.client.data.local.ChatDatabase import com.onlinemsg.client.data.local.ChatDatabase
import com.onlinemsg.client.data.local.ChatHistoryRepository import com.onlinemsg.client.data.local.ChatHistoryRepository
@ -32,6 +31,7 @@ import com.onlinemsg.client.data.protocol.SignedPayloadDto
import com.onlinemsg.client.data.protocol.asPayloadText import com.onlinemsg.client.data.protocol.asPayloadText
import com.onlinemsg.client.service.ChatForegroundService import com.onlinemsg.client.service.ChatForegroundService
import com.onlinemsg.client.util.LanguageManager import com.onlinemsg.client.util.LanguageManager
import com.onlinemsg.client.util.NotificationSoundCatalog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -184,7 +184,9 @@ object ChatSessionManager {
_uiState.update { _uiState.update {
it.copy( it.copy(
status = ConnectionStatus.ERROR, status = ConnectionStatus.ERROR,
statusHint = t("session.hint.connection_error_retrying") statusHint = t("session.hint.connection_error_retrying"),
isSwitchingServer = false,
switchingServerLabel = ""
) )
} }
scheduleReconnect(t("session.reason.connection_error")) scheduleReconnect(t("session.reason.connection_error"))
@ -207,7 +209,7 @@ object ChatSessionManager {
scope.launch { scope.launch {
val pref = preferencesRepository.preferencesFlow.first() val pref = preferencesRepository.preferencesFlow.first()
val historyMessages = withContext(Dispatchers.IO) { val historyMessages = withContext(Dispatchers.IO) {
historyRepository.loadMessages(MAX_MESSAGES) historyRepository.loadMessages(serverKeyFor(pref.currentServerUrl), MAX_MESSAGES)
} }
keepAliveRequested = pref.shouldAutoReconnect keepAliveRequested = pref.shouldAutoReconnect
ensureMessageNotificationChannel(pref.notificationSound) ensureMessageNotificationChannel(pref.notificationSound)
@ -299,6 +301,20 @@ object ChatSessionManager {
_uiState.update { it.copy(serverUrl = value) } _uiState.update { it.copy(serverUrl = value) }
} }
/**
* 选择历史服务器并切换会话
*/
fun selectServerUrl(value: String) {
val normalized = ServerUrlFormatter.normalize(value)
if (normalized.isBlank()) return
switchServer(
normalized = normalized,
nextUrls = _uiState.value.serverUrls,
statusHint = null,
persist = { preferencesRepository.setCurrentServerUrl(normalized) }
)
}
/** /**
* 更新私聊目标公钥 * 更新私聊目标公钥
* @param value 公钥字符串 * @param value 公钥字符串
@ -345,7 +361,7 @@ object ChatSessionManager {
_uiState.update { it.copy(messages = emptyList()) } _uiState.update { it.copy(messages = emptyList()) }
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
runCatching { runCatching {
historyRepository.clearAll() historyRepository.clearAll(currentServerKey())
} }
} }
} }
@ -363,18 +379,13 @@ object ChatSessionManager {
} }
val nextUrls = ServerUrlFormatter.append(_uiState.value.serverUrls, normalized) val nextUrls = ServerUrlFormatter.append(_uiState.value.serverUrls, normalized)
_uiState.update { switchServer(
it.copy( normalized = normalized,
serverUrl = normalized, nextUrls = nextUrls,
serverUrls = nextUrls, statusHint = t("session.hint.server_saved"),
statusHint = t("session.hint.server_saved") persist = { preferencesRepository.saveCurrentServerUrl(normalized) },
) snackbarMessage = t("session.snackbar.server_saved")
} )
scope.launch {
preferencesRepository.saveCurrentServerUrl(normalized)
_events.emit(UiEvent.ShowSnackbar(t("session.snackbar.server_saved")))
}
} }
/** /**
@ -392,22 +403,17 @@ object ChatSessionManager {
filtered filtered
} }
_uiState.update { switchServer(
it.copy( normalized = nextUrls.first(),
serverUrls = nextUrls, nextUrls = nextUrls,
serverUrl = nextUrls.first(), statusHint = if (filtered.isEmpty()) {
statusHint = if (filtered.isEmpty()) { t("session.hint.server_restored_default")
t("session.hint.server_restored_default") } else {
} else { t("session.hint.server_removed")
t("session.hint.server_removed") },
} persist = { preferencesRepository.removeCurrentServerUrl(normalized) },
) snackbarMessage = t("session.snackbar.server_list_updated")
} )
scope.launch {
preferencesRepository.removeCurrentServerUrl(normalized)
_events.emit(UiEvent.ShowSnackbar(t("session.snackbar.server_list_updated")))
}
} }
/** /**
@ -450,17 +456,23 @@ object ChatSessionManager {
* 内部连接逻辑区分自动恢复和手动连接 * 内部连接逻辑区分自动恢复和手动连接
* @param isAutoRestore 是否为应用启动时的自动恢复连接 * @param isAutoRestore 是否为应用启动时的自动恢复连接
*/ */
private fun connectInternal(isAutoRestore: Boolean) { private fun connectInternal(
isAutoRestore: Boolean,
overrideUrl: String? = null,
forceReconnect: Boolean = false
) {
if (!initialized) return if (!initialized) return
val state = _uiState.value val state = _uiState.value
if (!state.canConnect) return if (!forceReconnect && !state.canConnect) return
val normalized = ServerUrlFormatter.normalize(state.serverUrl) val normalized = ServerUrlFormatter.normalize(overrideUrl ?: state.serverUrl)
if (normalized.isBlank()) { if (normalized.isBlank()) {
_uiState.update { _uiState.update {
it.copy( it.copy(
status = ConnectionStatus.ERROR, status = ConnectionStatus.ERROR,
statusHint = t("session.hint.fill_valid_server") statusHint = t("session.hint.fill_valid_server"),
isSwitchingServer = false,
switchingServerLabel = ""
) )
} }
return return
@ -511,7 +523,9 @@ object ChatSessionManager {
_uiState.update { _uiState.update {
it.copy( it.copy(
status = ConnectionStatus.IDLE, status = ConnectionStatus.IDLE,
statusHint = t("session.hint.connection_closed") statusHint = t("session.hint.connection_closed"),
isSwitchingServer = false,
switchingServerLabel = ""
) )
} }
autoReconnectTriggered = false autoReconnectTriggered = false
@ -690,7 +704,9 @@ object ChatSessionManager {
_uiState.update { _uiState.update {
it.copy( it.copy(
status = ConnectionStatus.ERROR, status = ConnectionStatus.ERROR,
statusHint = t("session.hint.handshake_incomplete_response") statusHint = t("session.hint.handshake_incomplete_response"),
isSwitchingServer = false,
switchingServerLabel = ""
) )
} }
return return
@ -752,7 +768,9 @@ object ChatSessionManager {
_uiState.update { _uiState.update {
it.copy( it.copy(
status = ConnectionStatus.ERROR, status = ConnectionStatus.ERROR,
statusHint = t("session.hint.connection_timeout_retry") statusHint = t("session.hint.connection_timeout_retry"),
isSwitchingServer = false,
switchingServerLabel = ""
) )
} }
addSystemMessage(t("session.msg.auth_timeout")) addSystemMessage(t("session.msg.auth_timeout"))
@ -769,7 +787,9 @@ object ChatSessionManager {
_uiState.update { _uiState.update {
it.copy( it.copy(
status = ConnectionStatus.ERROR, status = ConnectionStatus.ERROR,
statusHint = t("session.hint.auth_failed") statusHint = t("session.hint.auth_failed"),
isSwitchingServer = false,
switchingServerLabel = ""
) )
} }
addSystemMessage( addSystemMessage(
@ -846,7 +866,9 @@ object ChatSessionManager {
_uiState.update { _uiState.update {
it.copy( it.copy(
status = ConnectionStatus.READY, status = ConnectionStatus.READY,
statusHint = t("session.hint.ready_to_chat") statusHint = t("session.hint.ready_to_chat"),
isSwitchingServer = false,
switchingServerLabel = ""
) )
} }
addSystemMessage(t("session.msg.ready")) addSystemMessage(t("session.msg.ready"))
@ -955,7 +977,9 @@ object ChatSessionManager {
statusHint = tf( statusHint = tf(
"session.hint.server_rejected", "session.hint.server_rejected",
reason.ifBlank { t("session.text.policy_restriction") } reason.ifBlank { t("session.text.policy_restriction") }
) ),
isSwitchingServer = false,
switchingServerLabel = ""
) )
} }
addSystemMessage( addSystemMessage(
@ -998,7 +1022,9 @@ object ChatSessionManager {
_uiState.update { _uiState.update {
it.copy( it.copy(
status = ConnectionStatus.ERROR, status = ConnectionStatus.ERROR,
statusHint = t("session.hint.connection_interrupted_retry") statusHint = t("session.hint.connection_interrupted_retry"),
isSwitchingServer = false,
switchingServerLabel = ""
) )
} }
addSystemMessage( addSystemMessage(
@ -1284,11 +1310,78 @@ object ChatSessionManager {
if (message.role == MessageRole.SYSTEM) return if (message.role == MessageRole.SYSTEM) return
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
runCatching { runCatching {
historyRepository.appendMessage(message, MAX_MESSAGES) historyRepository.appendMessage(currentServerKey(), message, MAX_MESSAGES)
}
}
}
private fun switchServer(
normalized: String,
nextUrls: List<String>,
statusHint: String?,
persist: suspend () -> Unit,
snackbarMessage: String? = null
) {
val targetServerKey = serverKeyFor(normalized)
val previousServerKey = currentServerKey()
val shouldReconnect = previousServerKey != targetServerKey || _uiState.value.status != ConnectionStatus.READY
val switchingLabel = summarizeServerLabel(normalized)
scope.launch {
_uiState.update {
it.copy(
serverUrl = normalized,
serverUrls = nextUrls,
isSwitchingServer = shouldReconnect,
switchingServerLabel = if (shouldReconnect) switchingLabel else "",
statusHint = statusHint ?: it.statusHint
)
}
persist()
val historyMessages = withContext(Dispatchers.IO) {
historyRepository.loadMessages(targetServerKey, MAX_MESSAGES)
}
_uiState.update {
it.copy(
serverUrl = normalized,
serverUrls = nextUrls,
messages = historyMessages,
certFingerprint = if (previousServerKey == targetServerKey) it.certFingerprint else "",
statusHint = statusHint ?: it.statusHint
)
}
if (shouldReconnect) {
connectInternal(
isAutoRestore = false,
overrideUrl = normalized,
forceReconnect = !_uiState.value.canConnect
)
}
if (!snackbarMessage.isNullOrBlank()) {
_events.emit(UiEvent.ShowSnackbar(snackbarMessage))
} }
} }
} }
private fun currentServerKey(): String = serverKeyFor(_uiState.value.serverUrl)
private fun serverKeyFor(rawUrl: String): String {
return ServerUrlFormatter.normalize(rawUrl).ifBlank { ServerUrlFormatter.defaultServerUrl }
}
private fun summarizeServerLabel(rawUrl: String): String {
val normalized = serverKeyFor(rawUrl)
val parsed = runCatching { Uri.parse(normalized) }.getOrNull()
val host = parsed?.host
val port = parsed?.port?.takeIf { it > 0 }
val path = parsed?.encodedPath?.takeIf { !it.isNullOrBlank() && it != "/" }
return buildString {
append(host ?: normalized)
if (port != null) append(":$port")
if (path != null) append(path)
}.ifBlank { normalized }
}
/** /**
* 取消认证超时任务 * 取消认证超时任务
*/ */
@ -1392,7 +1485,9 @@ object ChatSessionManager {
_uiState.update { _uiState.update {
it.copy( it.copy(
status = ConnectionStatus.ERROR, status = ConnectionStatus.ERROR,
statusHint = t("session.hint.handshake_timeout") statusHint = t("session.hint.handshake_timeout"),
isSwitchingServer = false,
switchingServerLabel = ""
) )
} }
addSystemMessage(tf("session.msg.handshake_timeout_with_url", currentUrl)) addSystemMessage(tf("session.msg.handshake_timeout_with_url", currentUrl))
@ -1485,7 +1580,7 @@ object ChatSessionManager {
private fun ensureMessageNotificationChannel(soundCode: String = "default") { private fun ensureMessageNotificationChannel(soundCode: String = "default") {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val manager = app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channelId = "${MESSAGE_CHANNEL_ID}_$soundCode" val channelId = NotificationSoundCatalog.channelId(soundCode)
if (manager.getNotificationChannel(channelId) != null) return if (manager.getNotificationChannel(channelId) != null) return
val channel = NotificationChannel( val channel = NotificationChannel(
@ -1508,13 +1603,7 @@ object ChatSessionManager {
} }
private fun getSoundUri(code: String): Uri? { private fun getSoundUri(code: String): Uri? {
val resId = when (code) { val resId = NotificationSoundCatalog.resId(code) ?: return null
"ding" -> R.raw.load
"nameit5" -> R.raw.nameit5
"wind_chime" -> R.raw.notification_sound_effects
"default" -> R.raw.default_sound
else -> return null
}
return Uri.parse("${ContentResolver.SCHEME_ANDROID_RESOURCE}://${app.packageName}/$resId") return Uri.parse("${ContentResolver.SCHEME_ANDROID_RESOURCE}://${app.packageName}/$resId")
} }
@ -1540,7 +1629,7 @@ object ChatSessionManager {
launchIntent, launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
val channelId = "${MESSAGE_CHANNEL_ID}_${_uiState.value.notificationSound}" val channelId = NotificationSoundCatalog.channelId(_uiState.value.notificationSound)
ensureMessageNotificationChannel(_uiState.value.notificationSound) ensureMessageNotificationChannel(_uiState.value.notificationSound)
val notification = NotificationCompat.Builder(app, channelId) val notification = NotificationCompat.Builder(app, channelId)
@ -1589,7 +1678,6 @@ object ChatSessionManager {
private const val MAX_MESSAGES = 500 private const val MAX_MESSAGES = 500
private const val MAX_RECONNECT_DELAY_SECONDS = 30 private const val MAX_RECONNECT_DELAY_SECONDS = 30
private const val SYSTEM_MESSAGE_TTL_MS = 1_000L 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_MESSAGE_PREFIX = "[[OMS_AUDIO_V1]]"
private const val AUDIO_CHUNK_MESSAGE_PREFIX = "[[OMS_AUDIO_CHUNK_V1]]" private const val AUDIO_CHUNK_MESSAGE_PREFIX = "[[OMS_AUDIO_CHUNK_V1]]"
private const val AUDIO_CHUNK_BASE64_SIZE = 20_000 private const val AUDIO_CHUNK_BASE64_SIZE = 20_000

@ -98,7 +98,9 @@ data class ChatUiState(
val themeId: String = "blue", val themeId: String = "blue",
val useDynamicColor: Boolean = true, val useDynamicColor: Boolean = true,
val language: String = "zh", val language: String = "zh",
val notificationSound: String = "default" val notificationSound: String = "default",
val isSwitchingServer: Boolean = false,
val switchingServerLabel: String = ""
) { ) {
/** /**
* 是否允许连接 * 是否允许连接

@ -19,6 +19,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
fun updateDisplayName(value: String) = ChatSessionManager.updateDisplayName(value) fun updateDisplayName(value: String) = ChatSessionManager.updateDisplayName(value)
fun updateServerUrl(value: String) = ChatSessionManager.updateServerUrl(value) fun updateServerUrl(value: String) = ChatSessionManager.updateServerUrl(value)
fun selectServerUrl(value: String) = ChatSessionManager.selectServerUrl(value)
fun updateTargetKey(value: String) = ChatSessionManager.updateTargetKey(value) fun updateTargetKey(value: String) = ChatSessionManager.updateTargetKey(value)
fun updateDraft(value: String) = ChatSessionManager.updateDraft(value) fun updateDraft(value: String) = ChatSessionManager.updateDraft(value)
fun toggleDirectMode(enabled: Boolean) = ChatSessionManager.toggleDirectMode(enabled) fun toggleDirectMode(enabled: Boolean) = ChatSessionManager.toggleDirectMode(enabled)

@ -31,6 +31,11 @@ object LanguageManager {
"settings.chat_data" to "聊天資料", "settings.chat_data" to "聊天資料",
"settings.dynamic_color" to "使用動態顏色", "settings.dynamic_color" to "使用動態顏色",
"settings.notification_sound" to "通知音效", "settings.notification_sound" to "通知音效",
"settings.notification_enabled" to "系統通知與音效已啟用",
"settings.notification_disabled" to "系統通知目前已關閉",
"settings.notification_sound_disabled" to "目前音效渠道已被靜音",
"settings.notification_system_settings" to "系統通知設定",
"settings.notification_channel_settings" to "當前音效設定",
"status.idle" to "未連線", "status.idle" to "未連線",
"status.connecting" to "連線中", "status.connecting" to "連線中",
"status.ready" to "已連線", "status.ready" to "已連線",
@ -40,6 +45,7 @@ object LanguageManager {
"hint.ready_chat" to "已連線,可以開始聊天", "hint.ready_chat" to "已連線,可以開始聊天",
"hint.closed" to "連線已關閉", "hint.closed" to "連線已關閉",
"hint.reconnecting" to "連線已中斷,正在重試", "hint.reconnecting" to "連線已中斷,正在重試",
"session.hint.switching_server" to "正在切換伺服器",
"hint.reconnect_invalid_server" to "重連失敗:伺服器位址無效", "hint.reconnect_invalid_server" to "重連失敗:伺服器位址無效",
"hint.fill_target_key" to "請先填寫目標公鑰,再傳送私訊", "hint.fill_target_key" to "請先填寫目標公鑰,再傳送私訊",
"hint.server_rejected_prefix" to "伺服器拒絕連線:", "hint.server_rejected_prefix" to "伺服器拒絕連線:",
@ -105,6 +111,11 @@ object LanguageManager {
"settings.chat_data" to "聊天数据", "settings.chat_data" to "聊天数据",
"settings.dynamic_color" to "使用动态颜色", "settings.dynamic_color" to "使用动态颜色",
"settings.notification_sound" to "通知音效", "settings.notification_sound" to "通知音效",
"settings.notification_enabled" to "系统通知与音效已启用",
"settings.notification_disabled" to "系统通知当前已关闭",
"settings.notification_sound_disabled" to "当前音效渠道已被静音",
"settings.notification_system_settings" to "系统通知设置",
"settings.notification_channel_settings" to "当前音效设置",
"sound.default" to "默认", "sound.default" to "默认",
"sound.ding" to "", "sound.ding" to "",
"sound.nameit5" to "音效 5", "sound.nameit5" to "音效 5",
@ -118,6 +129,7 @@ object LanguageManager {
"hint.ready_chat" to "已连接,可以开始聊天", "hint.ready_chat" to "已连接,可以开始聊天",
"hint.closed" to "连接已关闭", "hint.closed" to "连接已关闭",
"hint.reconnecting" to "连接已中断,正在重试", "hint.reconnecting" to "连接已中断,正在重试",
"session.hint.switching_server" to "正在切换服务器",
"hint.reconnect_invalid_server" to "重连失败:服务器地址无效", "hint.reconnect_invalid_server" to "重连失败:服务器地址无效",
"hint.fill_target_key" to "请先填写目标公钥,再发送私聊消息", "hint.fill_target_key" to "请先填写目标公钥,再发送私聊消息",
"hint.server_rejected_prefix" to "服务器拒绝连接:", "hint.server_rejected_prefix" to "服务器拒绝连接:",
@ -259,6 +271,11 @@ object LanguageManager {
"settings.chat_data" to "Chat Data", "settings.chat_data" to "Chat Data",
"settings.dynamic_color" to "Use dynamic color", "settings.dynamic_color" to "Use dynamic color",
"settings.notification_sound" to "Notification Sound", "settings.notification_sound" to "Notification Sound",
"settings.notification_enabled" to "Notifications and sound are enabled",
"settings.notification_disabled" to "Notifications are currently disabled",
"settings.notification_sound_disabled" to "The selected sound channel is muted",
"settings.notification_system_settings" to "System Notification Settings",
"settings.notification_channel_settings" to "Current Sound Channel",
"sound.default" to "Default", "sound.default" to "Default",
"sound.ding" to "Ding", "sound.ding" to "Ding",
"sound.nameit5" to "Sound 5", "sound.nameit5" to "Sound 5",
@ -272,6 +289,7 @@ object LanguageManager {
"hint.ready_chat" to "Connected, ready to chat", "hint.ready_chat" to "Connected, ready to chat",
"hint.closed" to "Connection closed", "hint.closed" to "Connection closed",
"hint.reconnecting" to "Connection interrupted, reconnecting", "hint.reconnecting" to "Connection interrupted, reconnecting",
"session.hint.switching_server" to "Switching server",
"hint.reconnect_invalid_server" to "Reconnect failed: invalid server address", "hint.reconnect_invalid_server" to "Reconnect failed: invalid server address",
"hint.fill_target_key" to "Please fill target public key before private message", "hint.fill_target_key" to "Please fill target public key before private message",
"hint.server_rejected_prefix" to "Server rejected connection: ", "hint.server_rejected_prefix" to "Server rejected connection: ",
@ -413,6 +431,11 @@ object LanguageManager {
"settings.chat_data" to "チャットデータ", "settings.chat_data" to "チャットデータ",
"settings.dynamic_color" to "動的カラーを使用", "settings.dynamic_color" to "動的カラーを使用",
"settings.notification_sound" to "通知音", "settings.notification_sound" to "通知音",
"settings.notification_enabled" to "通知と音が有効です",
"settings.notification_disabled" to "通知が現在オフです",
"settings.notification_sound_disabled" to "現在の音声チャンネルはミュートされています",
"settings.notification_system_settings" to "システム通知設定",
"settings.notification_channel_settings" to "現在の音設定",
"sound.default" to "デフォルト", "sound.default" to "デフォルト",
"sound.ding" to "ディン", "sound.ding" to "ディン",
"sound.nameit5" to "効果音 5", "sound.nameit5" to "効果音 5",
@ -426,6 +449,7 @@ object LanguageManager {
"hint.ready_chat" to "接続完了、チャット可能", "hint.ready_chat" to "接続完了、チャット可能",
"hint.closed" to "接続を閉じました", "hint.closed" to "接続を閉じました",
"hint.reconnecting" to "接続が中断され、再接続中", "hint.reconnecting" to "接続が中断され、再接続中",
"session.hint.switching_server" to "サーバーを切り替え中",
"hint.reconnect_invalid_server" to "再接続失敗:サーバーアドレス無効", "hint.reconnect_invalid_server" to "再接続失敗:サーバーアドレス無効",
"hint.fill_target_key" to "個人チャット前に相手の公開鍵を入力してください", "hint.fill_target_key" to "個人チャット前に相手の公開鍵を入力してください",
"hint.server_rejected_prefix" to "サーバーが接続を拒否しました:", "hint.server_rejected_prefix" to "サーバーが接続を拒否しました:",
@ -567,6 +591,11 @@ object LanguageManager {
"settings.chat_data" to "채팅 데이터", "settings.chat_data" to "채팅 데이터",
"settings.dynamic_color" to "동적 색상 사용", "settings.dynamic_color" to "동적 색상 사용",
"settings.notification_sound" to "알림 효과음", "settings.notification_sound" to "알림 효과음",
"settings.notification_enabled" to "시스템 알림과 효과음이 켜져 있습니다",
"settings.notification_disabled" to "시스템 알림이 현재 꺼져 있습니다",
"settings.notification_sound_disabled" to "현재 효과음 채널이 음소거되어 있습니다",
"settings.notification_system_settings" to "시스템 알림 설정",
"settings.notification_channel_settings" to "현재 효과음 설정",
"sound.default" to "기본값", "sound.default" to "기본값",
"sound.ding" to "", "sound.ding" to "",
"sound.nameit5" to "효과음 5", "sound.nameit5" to "효과음 5",
@ -580,6 +609,7 @@ object LanguageManager {
"hint.ready_chat" to "연결 완료, 채팅 가능", "hint.ready_chat" to "연결 완료, 채팅 가능",
"hint.closed" to "연결이 종료되었습니다", "hint.closed" to "연결이 종료되었습니다",
"hint.reconnecting" to "연결이 끊겨 재연결 중입니다", "hint.reconnecting" to "연결이 끊겨 재연결 중입니다",
"session.hint.switching_server" to "서버 전환 중",
"hint.reconnect_invalid_server" to "재연결 실패: 서버 주소가 올바르지 않습니다", "hint.reconnect_invalid_server" to "재연결 실패: 서버 주소가 올바르지 않습니다",
"hint.fill_target_key" to "비공개 채팅 전 대상 공개키를 입력하세요", "hint.fill_target_key" to "비공개 채팅 전 대상 공개키를 입력하세요",
"hint.server_rejected_prefix" to "서버가 연결을 거부했습니다: ", "hint.server_rejected_prefix" to "서버가 연결을 거부했습니다: ",

@ -0,0 +1,19 @@
package com.onlinemsg.client.util
import com.onlinemsg.client.R
object NotificationSoundCatalog {
val soundCodes: List<String> = listOf("default", "ding", "nameit5", "wind_chime")
fun resId(code: String): Int? {
return when (code) {
"default" -> R.raw.default_sound
"ding" -> R.raw.load
"nameit5" -> R.raw.nameit5
"wind_chime" -> R.raw.notification_sound_effects
else -> null
}
}
fun channelId(code: String): String = "onlinemsg_messages_$code"
}
Loading…
Cancel
Save