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
pull/14/head^2
alimu 1 week ago
parent ffc9a77b5a
commit 8a77f0735a

@ -22,6 +22,7 @@ namespace OnlineMsgServer.Common
"publickey" => JsonSerializer.Deserialize<PublicKeyMessage>(root.GetRawText(), options),
"forward" => JsonSerializer.Deserialize<ForwardMessage>(root.GetRawText(), options),
"broadcast" => JsonSerializer.Deserialize<BroadcastMessage>(root.GetRawText(), options),
"rename" => JsonSerializer.Deserialize<RenameMessage>(root.GetRawText(), options),
_ => null
};
return message ?? throw new JsonException($"{instruct} 反序列化失败");
@ -49,4 +50,4 @@ namespace OnlineMsgServer.Common
writer.WriteEndObject();
}
}
}
}

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

@ -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;
}
}
/// <summary>
/// 通过用户PublicKey获取wsid
/// </summary>

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

@ -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<String, Job> = 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()

@ -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 "연결 방식을 전환하여 재시도 중",

Loading…
Cancel
Save