From 8a77f0735a19450e88dbe99bbdf1608f0c4c7e09 Mon Sep 17 00:00:00 2001 From: alimu Date: Sat, 14 Mar 2026 13:46:53 +0400 Subject: [PATCH] feat(server,android): add online display name sync - add signed rename protocol on the server for authenticated sessions - update Android settings flow to submit display name changes on commit - sync display name without restart and preserve message ordering after rename --- Common/MessageConverter.cs | 3 +- Common/RenameMessage.cs | 104 ++++++++++++++++++ Core/UserService.cs | 28 +++++ .../com/onlinemsg/client/ui/ChatScreen.kt | 38 ++++++- .../onlinemsg/client/ui/ChatSessionManager.kt | 68 +++++++++++- .../onlinemsg/client/util/LanguageManager.kt | 25 +++++ 6 files changed, 258 insertions(+), 8 deletions(-) create mode 100644 Common/RenameMessage.cs diff --git a/Common/MessageConverter.cs b/Common/MessageConverter.cs index e29599a..94e8dc7 100644 --- a/Common/MessageConverter.cs +++ b/Common/MessageConverter.cs @@ -22,6 +22,7 @@ namespace OnlineMsgServer.Common "publickey" => JsonSerializer.Deserialize(root.GetRawText(), options), "forward" => JsonSerializer.Deserialize(root.GetRawText(), options), "broadcast" => JsonSerializer.Deserialize(root.GetRawText(), options), + "rename" => JsonSerializer.Deserialize(root.GetRawText(), options), _ => null }; return message ?? throw new JsonException($"{instruct} 反序列化失败"); @@ -49,4 +50,4 @@ namespace OnlineMsgServer.Common writer.WriteEndObject(); } } -} \ No newline at end of file +} diff --git a/Common/RenameMessage.cs b/Common/RenameMessage.cs new file mode 100644 index 0000000..31f2458 --- /dev/null +++ b/Common/RenameMessage.cs @@ -0,0 +1,104 @@ +using OnlineMsgServer.Core; +using WebSocketSharp.Server; + +namespace OnlineMsgServer.Common +{ + class RenameMessage : Message + { + public RenameMessage() + { + Type = "rename"; + Key = ""; + } + + public override Task Handler(string wsid, WebSocketSessionManager Sessions) + { + return Task.Run(() => + { + try + { + if (!UserService.IsAuthenticated(wsid)) + { + Log.Security("rename_denied_unauthenticated", $"wsid={wsid}"); + return; + } + + string key = Key?.Trim() ?? ""; + if (!SignedMessagePayload.TryParse(Data, out SignedMessagePayload payload, out string parseError)) + { + Log.Security("rename_payload_invalid", $"wsid={wsid} reason={parseError}"); + SendEncryptedResult(Sessions, wsid, "rename_error", "rename payload invalid"); + return; + } + + if (!SecurityValidator.VerifySignedMessage(wsid, Type, key, payload, out string securityReason)) + { + Log.Security("rename_security_failed", $"wsid={wsid} reason={securityReason}"); + SendEncryptedResult(Sessions, wsid, "rename_error", "rename signature invalid"); + return; + } + + string nextName = payload.Payload.Trim(); + if (string.IsNullOrWhiteSpace(nextName)) + { + Log.Security("rename_invalid_name", $"wsid={wsid} reason=blank"); + SendEncryptedResult(Sessions, wsid, "rename_error", "display name cannot be empty"); + return; + } + + if (!UserService.IsPeerNodeSession(wsid) && PeerNetworkService.IsPeerUserName(nextName)) + { + Log.Security("rename_invalid_name", $"wsid={wsid} reason=peer_prefix"); + SendEncryptedResult(Sessions, wsid, "rename_error", "display name uses reserved prefix"); + return; + } + + if (!UserService.TryUpdateUserName(wsid, nextName, out string appliedName)) + { + Log.Security("rename_update_failed", $"wsid={wsid}"); + SendEncryptedResult(Sessions, wsid, "rename_error", "display name update failed"); + return; + } + + Log.Security("rename_success", $"wsid={wsid} user={appliedName}"); + SendEncryptedResult(Sessions, wsid, "rename_ok", appliedName); + } + catch (Exception ex) + { + Log.Security("rename_error", $"wsid={wsid} error={ex.Message}"); + SendEncryptedResult(Sessions, wsid, "rename_error", "display name update failed"); + } + }); + } + + private static void SendEncryptedResult( + WebSocketSessionManager sessions, + string wsid, + string type, + string data + ) + { + string? publicKey = UserService.GetUserPublicKeyByID(wsid); + if (string.IsNullOrWhiteSpace(publicKey)) + { + return; + } + + Message result = new() + { + Type = type, + Data = data + }; + string encrypted = RsaService.EncryptForClient(publicKey, result.ToJsonString()); + + foreach (IWebSocketSession session in sessions.Sessions) + { + if (session.ID == wsid) + { + session.Context.WebSocket.Send(encrypted); + return; + } + } + } + } +} diff --git a/Core/UserService.cs b/Core/UserService.cs index f7cd5d4..2eb66d4 100644 --- a/Core/UserService.cs +++ b/Core/UserService.cs @@ -110,6 +110,34 @@ namespace OnlineMsgServer.Core } } + public static bool TryUpdateUserName(string wsid, string name, out string appliedName) + { + appliedName = ""; + lock (_UserListLock) + { + User? user = _UserList.Find(u => u.ID == wsid); + if (user is not { IsAuthenticated: true }) + { + return false; + } + + string normalized = name.Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + { + return false; + } + + if (normalized.Length > 64) + { + normalized = normalized[..64]; + } + + user.Name = normalized; + appliedName = normalized; + return true; + } + } + /// /// 通过用户PublicKey获取wsid /// diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt index 389b606..84fe8bf 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt @@ -96,7 +96,9 @@ 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.platform.LocalFocusManager import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow @@ -1110,14 +1112,28 @@ private fun SettingsTab( onNotificationSoundChange: (String) -> Unit ) { val context = LocalContext.current + val focusManager = LocalFocusManager.current val previewPlayer = remember(context) { NotificationSoundPreviewPlayer(context) } val notificationStatus = rememberNotificationSoundStatus(state.notificationSound) + var displayNameInput by rememberSaveable { mutableStateOf(state.displayName) } DisposableEffect(Unit) { onDispose { previewPlayer.release() } } + LaunchedEffect(state.displayName) { + if (state.displayName != displayNameInput) { + displayNameInput = state.displayName + } + } + fun t(key: String) = LanguageManager.getString(key, state.language) + fun commitDisplayName() { + val candidate = displayNameInput.take(64) + if (candidate != state.displayName) { + onDisplayNameChange(candidate) + } + } val settingsCardModifier = Modifier.fillMaxWidth() val settingsCardContentModifier = Modifier @@ -1140,11 +1156,25 @@ private fun SettingsTab( ) { Text(t("settings.personal"), style = MaterialTheme.typography.titleMedium) OutlinedTextField( - value = state.displayName, - onValueChange = onDisplayNameChange, - modifier = Modifier.fillMaxWidth(), + value = displayNameInput, + onValueChange = { displayNameInput = it.take(64) }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focusState -> + if (!focusState.isFocused) { + commitDisplayName() + } + }, label = { Text(t("settings.display_name")) }, - maxLines = 1 + maxLines = 1, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { + commitDisplayName() + focusManager.clearFocus() + } + ) ) } } diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt index 73813ef..2723f0a 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt @@ -29,6 +29,7 @@ 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.data.protocol.asStringOrNull import com.onlinemsg.client.service.ChatForegroundService import com.onlinemsg.client.util.LanguageManager import com.onlinemsg.client.util.NotificationSoundCatalog @@ -103,6 +104,7 @@ object ChatSessionManager { private var helloTimeoutJob: Job? = null // 握手超时任务 private var authTimeoutJob: Job? = null // 认证超时任务 private var reconnectJob: Job? = null // 自动重连任务 + private var displayNameSyncJob: Job? = null // 在线改名同步任务 private var reconnectAttempt: Int = 0 // 当前重连尝试次数 private val systemMessageExpiryJobs: MutableMap = mutableMapOf() // 系统消息自动过期任务 private var autoReconnectTriggered = false @@ -286,10 +288,27 @@ object ChatSessionManager { * @param value 新名称(自动截断至 64 字符) */ fun updateDisplayName(value: String) { - val displayName = value.take(64) + val displayName = value.trim().take(64).ifBlank { createGuestName() } + val previous = _uiState.value.displayName + if (displayName == previous) return _uiState.update { it.copy(displayName = displayName) } - scope.launch { - preferencesRepository.setDisplayName(displayName) + displayNameSyncJob?.cancel() + displayNameSyncJob = scope.launch { + if (_uiState.value.status == ConnectionStatus.READY) { + _uiState.update { it.copy(statusHint = t("session.hint.updating_display_name")) } + runCatching { + sendDisplayNameUpdate(displayName) + preferencesRepository.setDisplayName(displayName) + }.onFailure { error -> + val message = error.message ?: t("common.unknown") + _uiState.update { + it.copy(statusHint = tf("session.hint.display_name_update_failed", message)) + } + _events.emit(UiEvent.ShowSnackbar(tf("session.msg.display_name_update_failed", message))) + } + } else { + preferencesRepository.setDisplayName(displayName) + } } } @@ -516,6 +535,7 @@ object ChatSessionManager { */ fun disconnect(stopService: Boolean = true) { manualClose = true + displayNameSyncJob?.cancel() cancelReconnect() cancelHelloTimeout() cancelAuthTimeout() @@ -552,6 +572,7 @@ object ChatSessionManager { scope.launch { _uiState.update { it.copy(sending = true) } + displayNameSyncJob?.join() runCatching { sendSignedPayload(route = route, payloadText = text) @@ -581,6 +602,7 @@ object ChatSessionManager { scope.launch { _uiState.update { it.copy(sending = true) } + displayNameSyncJob?.join() val safeDuration = durationMillis.coerceAtLeast(0L) val normalized = audioBase64.trim() val chunks = splitAudioBase64(normalized, AUDIO_CHUNK_BASE64_SIZE) @@ -874,6 +896,33 @@ object ChatSessionManager { addSystemMessage(t("session.msg.ready")) } + "rename_ok" -> { + val appliedName = message.data.asStringOrNull()?.trim().orEmpty() + .take(64) + .ifBlank { _uiState.value.displayName } + _uiState.update { + it.copy( + displayName = appliedName, + statusHint = tf("session.hint.display_name_updated", appliedName) + ) + } + scope.launch { + preferencesRepository.setDisplayName(appliedName) + _events.emit(UiEvent.ShowSnackbar(tf("session.msg.display_name_updated", appliedName))) + } + } + + "rename_error" -> { + val reason = message.data.asStringOrNull()?.takeIf { it.isNotBlank() } + ?: t("common.unknown") + _uiState.update { + it.copy(statusHint = tf("session.hint.display_name_update_failed", reason)) + } + scope.launch { + _events.emit(UiEvent.ShowSnackbar(tf("session.msg.display_name_update_failed", reason))) + } + } + "broadcast" -> { val sender = message.key?.takeIf { it.isNotBlank() } ?: t("session.sender.anonymous") val payloadText = message.data.asPayloadText() @@ -1194,6 +1243,18 @@ object ChatSessionManager { check(socketClient.send(cipher)) { t("session.error.connection_unavailable") } } + private suspend fun sendDisplayNameUpdate(displayName: String) { + sendSignedPayload( + route = OutgoingRoute( + type = "rename", + key = "", + channel = MessageChannel.BROADCAST, + subtitle = "" + ), + payloadText = displayName + ) + } + private fun parseAudioPayload(payloadText: String): AudioPayloadDto? { if (!payloadText.startsWith(AUDIO_MESSAGE_PREFIX)) return null val encoded = payloadText.removePrefix(AUDIO_MESSAGE_PREFIX).trim() @@ -1547,6 +1608,7 @@ object ChatSessionManager { * 关闭所有资源(用于应用退出时)。 */ fun shutdownAll() { + displayNameSyncJob?.cancel() cancelSystemMessageExpiryJobs() cancelReconnect() cancelHelloTimeout() diff --git a/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt b/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt index 5792aeb..c14bbf5 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt @@ -45,6 +45,9 @@ object LanguageManager { "hint.ready_chat" to "已連線,可以開始聊天", "hint.closed" to "連線已關閉", "hint.reconnecting" to "連線已中斷,正在重試", + "session.hint.updating_display_name" to "正在同步顯示名稱...", + "session.hint.display_name_updated" to "顯示名稱已更新為 %s", + "session.hint.display_name_update_failed" to "顯示名稱更新失敗:%s", "session.hint.switching_server" to "正在切換伺服器", "hint.reconnect_invalid_server" to "重連失敗:伺服器位址無效", "hint.fill_target_key" to "請先填寫目標公鑰,再傳送私訊", @@ -71,6 +74,8 @@ object LanguageManager { "chat.audio_recording" to "錄音中", "chat.audio_play" to "播放語音", "chat.audio_stop" to "停止播放", + "session.msg.display_name_updated" to "顯示名稱已更新為 %s", + "session.msg.display_name_update_failed" to "顯示名稱同步失敗:%s", "sound.default" to "預設", "sound.ding" to "叮", "sound.nameit5" to "音效 5", @@ -165,6 +170,9 @@ object LanguageManager { "session.hint.connection_timeout_retry" to "连接超时,请重试", "session.hint.auth_failed" to "认证失败", "session.hint.ready_to_chat" to "已连接,可以开始聊天", + "session.hint.updating_display_name" to "正在同步显示名称...", + "session.hint.display_name_updated" to "显示名称已更新为 %s", + "session.hint.display_name_update_failed" to "显示名称更新失败:%s", "session.hint.server_rejected" to "服务器拒绝连接:%s", "session.hint.auto_retry_connecting" to "正在自动重试连接...", "session.hint.connection_interrupted_retry" to "连接已中断,正在重试", @@ -190,6 +198,8 @@ object LanguageManager { "session.msg.auth_request_sent" to "已发送认证请求", "session.msg.auth_send_failed" to "认证发送失败:%s", "session.msg.ready" to "连接准备完成", + "session.msg.display_name_updated" to "显示名称已更新为 %s", + "session.msg.display_name_update_failed" to "显示名称同步失败:%s", "session.msg.unknown_message_type" to "收到未识别消息类型:%s", "session.msg.server_rejected" to "连接被服务器拒绝(%d):%s", "session.msg.switching_connection_mode_retry" to "连接方式切换中,正在重试", @@ -325,6 +335,9 @@ object LanguageManager { "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.updating_display_name" to "Syncing display name...", + "session.hint.display_name_updated" to "Display name updated to %s", + "session.hint.display_name_update_failed" to "Display name update failed: %s", "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", @@ -350,6 +363,8 @@ object LanguageManager { "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.display_name_updated" to "Display name updated to %s", + "session.msg.display_name_update_failed" to "Display name sync failed: %s", "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", @@ -485,6 +500,9 @@ object LanguageManager { "session.hint.connection_timeout_retry" to "接続タイムアウト、再試行してください", "session.hint.auth_failed" to "認証失敗", "session.hint.ready_to_chat" to "接続済み、チャット可能", + "session.hint.updating_display_name" to "表示名を同期中...", + "session.hint.display_name_updated" to "表示名を %s に更新しました", + "session.hint.display_name_update_failed" to "表示名の更新に失敗しました:%s", "session.hint.server_rejected" to "サーバーが接続を拒否しました:%s", "session.hint.auto_retry_connecting" to "自動再試行で接続中...", "session.hint.connection_interrupted_retry" to "接続が中断され、再試行中", @@ -510,6 +528,8 @@ object LanguageManager { "session.msg.auth_request_sent" to "認証リクエストを送信しました", "session.msg.auth_send_failed" to "認証送信失敗:%s", "session.msg.ready" to "接続準備完了", + "session.msg.display_name_updated" to "表示名を %s に更新しました", + "session.msg.display_name_update_failed" to "表示名の同期に失敗しました:%s", "session.msg.unknown_message_type" to "未識別メッセージ種別を受信:%s", "session.msg.server_rejected" to "サーバーに接続拒否されました(%d):%s", "session.msg.switching_connection_mode_retry" to "接続方式を切り替えて再試行中", @@ -645,6 +665,9 @@ object LanguageManager { "session.hint.connection_timeout_retry" to "연결 시간 초과, 다시 시도하세요", "session.hint.auth_failed" to "인증 실패", "session.hint.ready_to_chat" to "연결 완료, 채팅 가능", + "session.hint.updating_display_name" to "표시 이름을 동기화하는 중...", + "session.hint.display_name_updated" to "표시 이름이 %s(으)로 업데이트되었습니다", + "session.hint.display_name_update_failed" to "표시 이름 업데이트 실패: %s", "session.hint.server_rejected" to "서버가 연결을 거부했습니다: %s", "session.hint.auto_retry_connecting" to "자동 재시도 연결 중...", "session.hint.connection_interrupted_retry" to "연결이 끊겨 재시도 중", @@ -670,6 +693,8 @@ object LanguageManager { "session.msg.auth_request_sent" to "인증 요청을 전송했습니다", "session.msg.auth_send_failed" to "인증 전송 실패: %s", "session.msg.ready" to "연결 준비 완료", + "session.msg.display_name_updated" to "표시 이름이 %s(으)로 업데이트되었습니다", + "session.msg.display_name_update_failed" to "표시 이름 동기화 실패: %s", "session.msg.unknown_message_type" to "알 수 없는 메시지 유형 수신: %s", "session.msg.server_rejected" to "서버가 연결을 거부했습니다 (%d): %s", "session.msg.switching_connection_mode_retry" to "연결 방식을 전환하여 재시도 중",