From 5f0302dd0e3883db831b56024a43db4aa92728cb Mon Sep 17 00:00:00 2001 From: emilia-t Date: Sat, 14 Mar 2026 17:55:40 +0800 Subject: [PATCH] update --- Common/BroadcastMessage.cs | 30 +- Common/ForwardMessage.cs | 34 +- Common/PeerRelayEnvelope.cs | 56 ++ Common/PublicKeyMessage.cs | 3 +- Common/User.cs | 5 + Core/PeerNetworkService.cs | 689 +++++++++++++++ Core/RsaService.cs | 10 + Core/SecurityConfig.cs | 33 + Core/SeenMessageCache.cs | 55 ++ Core/UserService.cs | 54 +- Core/WsService.cs | 7 + Program.cs | 3 + ReadMe.md | 309 +++++-- android-client/README.md | 11 + android-client/app/build.gradle.kts | 68 ++ .../app/src/main/AndroidManifest.xml | 3 + .../java/com/onlinemsg/client/MainActivity.kt | 15 + .../client/data/local/ChatDatabase.kt | 22 +- .../data/local/ChatHistoryRepository.kt | 52 +- .../client/data/local/ChatMessageDao.kt | 23 +- .../client/data/local/ChatMessageEntity.kt | 6 +- .../preferences/UserPreferencesRepository.kt | 13 +- .../client/data/protocol/ProtocolModels.kt | 21 + .../client/service/ChatForegroundService.kt | 21 +- .../com/onlinemsg/client/ui/ChatScreen.kt | 128 +-- .../onlinemsg/client/ui/ChatSessionManager.kt | 372 +++++--- .../com/onlinemsg/client/ui/ChatUiState.kt | 21 +- .../com/onlinemsg/client/ui/ChatViewModel.kt | 1 - .../onlinemsg/client/util/AudioRecorder.kt | 89 ++ .../onlinemsg/client/util/LanguageManager.kt | 238 +----- .../client/util/NotificationSoundCatalog.kt | 19 + .../res/drawable/ic_launcher_background.xml | 8 + .../res/drawable/ic_launcher_foreground.xml | 96 +++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../app/src/main/res/mipmap/ic_launcher.xml | 7 + .../src/main/res/mipmap/ic_launcher_round.xml | 7 + web-client/src/App.jsx | 799 ++++++++++++++++-- web-client/src/styles.css | 159 +++- 39 files changed, 2868 insertions(+), 629 deletions(-) create mode 100644 Common/PeerRelayEnvelope.cs create mode 100644 Core/PeerNetworkService.cs create mode 100644 Core/SeenMessageCache.cs create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/util/AudioRecorder.kt create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/util/NotificationSoundCatalog.kt create mode 100644 android-client/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 android-client/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 android-client/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android-client/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android-client/app/src/main/res/mipmap/ic_launcher.xml create mode 100644 android-client/app/src/main/res/mipmap/ic_launcher_round.xml diff --git a/Common/BroadcastMessage.cs b/Common/BroadcastMessage.cs index e72a97c..e42c614 100644 --- a/Common/BroadcastMessage.cs +++ b/Common/BroadcastMessage.cs @@ -35,26 +35,20 @@ namespace OnlineMsgServer.Common return; } - Message response = new() + string senderPublicKey = UserService.GetUserPublicKeyByID(wsid)!; + if (!PeerNetworkService.TryMarkSeen(senderPublicKey, Type, key, payload.Payload)) { - Type = "broadcast", - Data = payload.Payload, - Key = UserService.GetUserNameByID(wsid), - }; - - foreach (IWebSocketSession session in Sessions.Sessions) - { - if (session.ID != wsid)//不用发给自己 - { - string? publicKey = UserService.GetUserPublicKeyByID(session.ID); - if (publicKey != null) - { - string jsonString = response.ToJsonString(); - string encryptString = RsaService.EncryptForClient(publicKey, jsonString); - session.Context.WebSocket.Send(encryptString); - } - } + return; } + + string senderName = UserService.GetUserNameByID(wsid) ?? "anonymous"; + PeerNetworkService.DeliverBroadcastToLocalClients(senderName, payload.Payload, wsid); + + string? excludePeerPublicKey = UserService.IsPeerNodeSession(wsid) + ? UserService.GetPeerPublicKeyBySessionId(wsid) + : null; + + PeerNetworkService.RelayBroadcast(payload.Payload, excludePeerPublicKey); } catch (Exception ex) { diff --git a/Common/ForwardMessage.cs b/Common/ForwardMessage.cs index 73dca1f..1d246d5 100644 --- a/Common/ForwardMessage.cs +++ b/Common/ForwardMessage.cs @@ -41,33 +41,29 @@ namespace OnlineMsgServer.Common return; } - string fromPublicKey = UserService.GetUserPublicKeyByID(wsid)!; - - Message response = new() + if (PeerNetworkService.TryHandlePeerRelayForward(wsid, forwardPublickKey, payload)) { - Type = "forward", - Data = payload.Payload, - Key = fromPublicKey, - }; - - string jsonString = response.ToJsonString(); - string encryptString = RsaService.EncryptForClient(forwardPublickKey, jsonString); + return; + } - List userList = UserService.GetUserListByPublicKey(forwardPublickKey); - if (userList.Count == 0) + string fromPublicKey = UserService.GetUserPublicKeyByID(wsid)!; + if (!PeerNetworkService.TryMarkSeen(fromPublicKey, Type, forwardPublickKey, payload.Payload)) { - Log.Security("forward_target_offline_or_untrusted", $"wsid={wsid}"); return; } - foreach (IWebSocketSession session in Sessions.Sessions) + bool delivered = PeerNetworkService.DeliverForwardToLocalClient(fromPublicKey, forwardPublickKey, payload.Payload); + if (delivered) { - if (userList.Exists(u => u.ID == session.ID)) - { - session.Context.WebSocket.Send(encryptString); - break; - } + return; } + + string? excludePeerPublicKey = UserService.IsPeerNodeSession(wsid) + ? UserService.GetPeerPublicKeyBySessionId(wsid) + : null; + + PeerNetworkService.RelayForwardMiss(forwardPublickKey, payload.Payload, excludePeerPublicKey); + Log.Security("forward_target_offline_or_untrusted", $"wsid={wsid}"); } catch (Exception ex) { diff --git a/Common/PeerRelayEnvelope.cs b/Common/PeerRelayEnvelope.cs new file mode 100644 index 0000000..60139a6 --- /dev/null +++ b/Common/PeerRelayEnvelope.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OnlineMsgServer.Common +{ + internal sealed class PeerRelayEnvelope + { + public const string OverlayName = "oms-peer/1"; + + public string Overlay { get; init; } = OverlayName; + public string Kind { get; init; } = ""; + public string TargetKey { get; init; } = ""; + public string Payload { get; init; } = ""; + + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public string ToJsonString() + { + return JsonSerializer.Serialize(this, Options); + } + + public static bool TryParse(string? jsonString, out PeerRelayEnvelope envelope) + { + envelope = new PeerRelayEnvelope(); + if (string.IsNullOrWhiteSpace(jsonString)) + { + return false; + } + + try + { + PeerRelayEnvelope? parsed = JsonSerializer.Deserialize(jsonString, Options); + if (parsed == null || !string.Equals(parsed.Overlay, OverlayName, StringComparison.Ordinal)) + { + return false; + } + + if (string.IsNullOrWhiteSpace(parsed.Kind)) + { + return false; + } + + envelope = parsed; + return true; + } + catch + { + return false; + } + } + } +} diff --git a/Common/PublicKeyMessage.cs b/Common/PublicKeyMessage.cs index e4b2cb1..be83e35 100644 --- a/Common/PublicKeyMessage.cs +++ b/Common/PublicKeyMessage.cs @@ -81,7 +81,8 @@ namespace OnlineMsgServer.Common return; } - UserService.UserLogin(wsid, payload.PublicKey, userName); + bool isPeerNode = PeerNetworkService.IsPeerUserName(userName); + UserService.UserLogin(wsid, payload.PublicKey, userName, isPeerNode); Log.Security("auth_success", $"wsid={wsid} user={userName}"); Message ack = new() diff --git a/Common/User.cs b/Common/User.cs index a30325f..d91a5e6 100644 --- a/Common/User.cs +++ b/Common/User.cs @@ -12,6 +12,11 @@ namespace OnlineMsgServer.Common /// public string? Name { get; set; } + /// + /// 是否为服务器节点伪装的 peer 用户 + /// + public bool IsPeerNode { get; set; } + /// /// 用户公钥 用于消息加密发送给用户 diff --git a/Core/PeerNetworkService.cs b/Core/PeerNetworkService.cs new file mode 100644 index 0000000..fd40991 --- /dev/null +++ b/Core/PeerNetworkService.cs @@ -0,0 +1,689 @@ +using System.IO; +using System.Net.WebSockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using OnlineMsgServer.Common; +using WebSocketSharp.Server; + +namespace OnlineMsgServer.Core +{ + internal static class PeerNetworkService + { + private static readonly object _lock = new(); + private static readonly Dictionary _outboundPeers = []; + + private static SecurityConfig _config = SecurityRuntime.Config; + private static SeenMessageCache _seenCache = new(120); + private static WebSocketSessionManager? _sessions; + private static CancellationTokenSource? _cts; + + public static void Initialize(SecurityConfig config, WebSocketSessionManager sessions) + { + lock (_lock) + { + _config = config; + _sessions = sessions; + _seenCache = new SeenMessageCache(config.SeenCacheSeconds); + } + } + + public static void Start() + { + lock (_lock) + { + if (_cts != null) + { + return; + } + + _cts = new CancellationTokenSource(); + foreach (string peerUrl in _config.PeerUrls) + { + if (_outboundPeers.ContainsKey(peerUrl)) + { + continue; + } + + PeerOutboundClient peerClient = new(peerUrl, BuildPeerDisplayName(peerUrl)); + _outboundPeers[peerUrl] = peerClient; + peerClient.Start(_cts.Token); + } + } + } + + public static void Stop() + { + CancellationTokenSource? cts; + List peers; + + lock (_lock) + { + cts = _cts; + _cts = null; + peers = [.. _outboundPeers.Values]; + _outboundPeers.Clear(); + } + + cts?.Cancel(); + foreach (PeerOutboundClient peer in peers) + { + peer.Dispose(); + } + } + + public static bool IsPeerUserName(string? userName) + { + return !string.IsNullOrWhiteSpace(userName) && + userName.StartsWith(_config.PeerUserPrefix, StringComparison.Ordinal); + } + + public static string GetPeerUserName() + { + string userName = $"{_config.PeerUserPrefix}{_config.PeerNodeName}".Trim(); + return userName.Length <= 64 ? userName : userName[..64]; + } + + public static string GetVisibleUserName(string? userName) + { + if (string.IsNullOrWhiteSpace(userName)) + { + return ""; + } + + string trimmed = userName.Trim(); + if (!IsPeerUserName(trimmed)) + { + return trimmed; + } + + string visibleName = trimmed[_config.PeerUserPrefix.Length..].Trim(); + return string.IsNullOrWhiteSpace(visibleName) ? trimmed : visibleName; + } + + public static bool TryMarkSeen(string senderIdentity, string type, string key, string payload) + { + return _seenCache.TryMark(senderIdentity, type, key, payload); + } + + public static bool TryHandlePeerRelayForward(string wsid, string targetKey, SignedMessagePayload payload) + { + if (!UserService.IsPeerNodeSession(wsid)) + { + return false; + } + + if (!string.Equals(targetKey, RsaService.GetRsaPublickKey(), StringComparison.Ordinal)) + { + return false; + } + + if (!PeerRelayEnvelope.TryParse(payload.Payload, out PeerRelayEnvelope envelope)) + { + return false; + } + + string sourcePublicKey = UserService.GetPeerPublicKeyBySessionId(wsid) ?? ""; + string sourceDisplayName = GetVisibleUserName(UserService.GetUserNameByID(wsid)); + ProcessPeerEnvelope(sourcePublicKey, sourceDisplayName, envelope); + return true; + } + + public static void RelayForwardMiss(string targetKey, string payload, string? excludePeerPublicKey = null) + { + PeerRelayEnvelope envelope = new() + { + Kind = "forward", + TargetKey = targetKey, + Payload = payload + }; + + RelayPeerEnvelope(envelope, excludePeerPublicKey); + } + + public static void RelayBroadcast(string payload, string? excludePeerPublicKey = null) + { + PeerRelayEnvelope envelope = new() + { + Kind = "broadcast", + TargetKey = "", + Payload = payload + }; + + RelayPeerEnvelope(envelope, excludePeerPublicKey); + } + + public static void DeliverBroadcastToLocalClients(string senderName, string payload, string? excludeSessionId = null) + { + WebSocketSessionManager sessions = RequireSessions(); + Message response = new() + { + Type = "broadcast", + Data = payload, + Key = senderName + }; + string jsonString = response.ToJsonString(); + + foreach (IWebSocketSession session in sessions.Sessions) + { + if (session.ID == excludeSessionId) + { + continue; + } + + if (!UserService.IsAuthenticated(session.ID) || UserService.IsPeerNodeSession(session.ID)) + { + continue; + } + + string? publicKey = UserService.GetUserPublicKeyByID(session.ID); + if (string.IsNullOrWhiteSpace(publicKey)) + { + continue; + } + + string encryptString = RsaService.EncryptForClient(publicKey, jsonString); + session.Context.WebSocket.Send(encryptString); + } + } + + public static bool DeliverForwardToLocalClient(string senderPublicKey, string targetPublicKey, string payload) + { + WebSocketSessionManager sessions = RequireSessions(); + List userList = UserService.GetUserListByPublicKey(targetPublicKey, includePeerNodes: false); + if (userList.Count == 0) + { + return false; + } + + Message response = new() + { + Type = "forward", + Data = payload, + Key = senderPublicKey + }; + string jsonString = response.ToJsonString(); + string encryptString = RsaService.EncryptForClient(targetPublicKey, jsonString); + + foreach (IWebSocketSession session in sessions.Sessions) + { + if (userList.Exists(u => u.ID == session.ID)) + { + session.Context.WebSocket.Send(encryptString); + return true; + } + } + + return false; + } + + private static void ProcessPeerEnvelope(string sourcePublicKey, string sourceDisplayName, PeerRelayEnvelope envelope) + { + if (!TryMarkSeen(sourcePublicKey, envelope.Kind, envelope.TargetKey, envelope.Payload)) + { + return; + } + + switch (envelope.Kind) + { + case "broadcast": + DeliverBroadcastToLocalClients(sourceDisplayName, envelope.Payload); + RelayPeerEnvelope(envelope, sourcePublicKey); + break; + case "forward": + bool delivered = DeliverForwardToLocalClient(sourcePublicKey, envelope.TargetKey, envelope.Payload); + if (!delivered) + { + RelayPeerEnvelope(envelope, sourcePublicKey); + } + break; + default: + Log.Security("peer_envelope_invalid_kind", $"kind={envelope.Kind}"); + break; + } + } + + private static void RelayPeerEnvelope(PeerRelayEnvelope envelope, string? excludePeerPublicKey) + { + string payloadJson = envelope.ToJsonString(); + HashSet sentPeerKeys = []; + + foreach (PeerOutboundClient peer in SnapshotOutboundPeers()) + { + string? remotePublicKey = peer.RemotePublicKey; + if (!peer.IsAuthenticated || string.IsNullOrWhiteSpace(remotePublicKey)) + { + continue; + } + + if (string.Equals(remotePublicKey, excludePeerPublicKey, StringComparison.Ordinal) || + !sentPeerKeys.Add(remotePublicKey)) + { + continue; + } + + peer.TrySendRelayEnvelope(payloadJson); + } + + SendPeerEnvelopeToInboundPeers(payloadJson, sentPeerKeys, excludePeerPublicKey); + } + + private static void SendPeerEnvelopeToInboundPeers(string payloadJson, HashSet sentPeerKeys, string? excludePeerPublicKey) + { + WebSocketSessionManager sessions = RequireSessions(); + Message response = new() + { + Type = "forward", + Key = RsaService.GetRsaPublickKey(), + Data = payloadJson + }; + string jsonString = response.ToJsonString(); + + foreach (User user in UserService.GetAuthenticatedUsers(includePeerNodes: true)) + { + if (!user.IsPeerNode || string.IsNullOrWhiteSpace(user.PublicKey)) + { + continue; + } + + if (string.Equals(user.PublicKey, excludePeerPublicKey, StringComparison.Ordinal) || + !sentPeerKeys.Add(user.PublicKey)) + { + continue; + } + + string encryptString = RsaService.EncryptForClient(user.PublicKey, jsonString); + foreach (IWebSocketSession session in sessions.Sessions) + { + if (session.ID == user.ID) + { + session.Context.WebSocket.Send(encryptString); + break; + } + } + } + } + + private static List SnapshotOutboundPeers() + { + lock (_lock) + { + return [.. _outboundPeers.Values]; + } + } + + private static WebSocketSessionManager RequireSessions() + { + return _sessions ?? throw new InvalidOperationException("peer network sessions not initialized"); + } + + private static string BuildPeerDisplayName(string peerUrl) + { + try + { + Uri uri = new(peerUrl); + string displayName = $"{_config.PeerUserPrefix}{BuildGuestAlias(uri.Host)}"; + return displayName.Length <= 64 ? displayName : displayName[..64]; + } + catch + { + return GetPeerUserName(); + } + } + + private static void HandlePeerSocketMessage(PeerOutboundClient peer, string text) + { + if (TryHandlePeerHello(peer, text)) + { + return; + } + + string plainText; + try + { + plainText = RsaService.Decrypt(text); + } + catch + { + return; + } + + using JsonDocument doc = JsonDocument.Parse(plainText); + JsonElement root = doc.RootElement; + if (!root.TryGetProperty("type", out JsonElement typeElement) || typeElement.ValueKind != JsonValueKind.String) + { + return; + } + + string type = typeElement.GetString() ?? ""; + switch (type) + { + case "auth_ok": + peer.MarkAuthenticated(); + Log.Debug($"peer auth ok {peer.PeerUrl}"); + return; + case "forward": + case "broadcast": + if (!root.TryGetProperty("data", out JsonElement dataElement)) + { + return; + } + + string payload = ExtractPayloadString(dataElement); + if (PeerRelayEnvelope.TryParse(payload, out PeerRelayEnvelope envelope)) + { + ProcessPeerEnvelope(peer.RemotePublicKey ?? "", GetVisibleUserName(peer.DisplayName), envelope); + } + return; + default: + return; + } + } + + private static string BuildGuestAlias(string seed) + { + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed)); + int value = BitConverter.ToInt32(hash, 0) & int.MaxValue; + return $"guest-{(value % 900000) + 100000:D6}"; + } + + private static bool TryHandlePeerHello(PeerOutboundClient peer, string text) + { + try + { + using JsonDocument doc = JsonDocument.Parse(text); + JsonElement root = doc.RootElement; + if (!root.TryGetProperty("type", out JsonElement typeElement) || + typeElement.ValueKind != JsonValueKind.String || + !string.Equals(typeElement.GetString(), "publickey", StringComparison.Ordinal)) + { + return false; + } + + if (!root.TryGetProperty("data", out JsonElement dataElement) || dataElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + if (!dataElement.TryGetProperty("publicKey", out JsonElement publicKeyElement) || + publicKeyElement.ValueKind != JsonValueKind.String || + !dataElement.TryGetProperty("authChallenge", out JsonElement challengeElement) || + challengeElement.ValueKind != JsonValueKind.String) + { + return false; + } + + string remotePublicKey = publicKeyElement.GetString() ?? ""; + string challenge = challengeElement.GetString() ?? ""; + if (string.IsNullOrWhiteSpace(remotePublicKey) || string.IsNullOrWhiteSpace(challenge)) + { + return false; + } + + peer.SetRemotePublicKey(remotePublicKey); + SendPeerAuth(peer, remotePublicKey, challenge); + return true; + } + catch + { + return false; + } + } + + private static void SendPeerAuth(PeerOutboundClient peer, string remotePublicKey, string challenge) + { + string localPublicKey = RsaService.GetRsaPublickKey(); + string userName = GetPeerUserName(); + long timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + string nonce = SecurityValidator.CreateNonce(); + string signingInput = ClientRegistrationPayload.BuildSigningInput(userName, localPublicKey, challenge, timestamp, nonce); + string signature = RsaService.Sign(signingInput); + + Message request = new() + { + Type = "publickey", + Key = userName, + Data = new + { + publicKey = localPublicKey, + challenge, + timestamp, + nonce, + signature + } + }; + + string cipherText = RsaService.EncryptForClient(remotePublicKey, request.ToJsonString()); + peer.TrySendRaw(cipherText); + } + + private static string ExtractPayloadString(JsonElement dataElement) + { + return dataElement.ValueKind == JsonValueKind.String + ? dataElement.GetString() ?? "" + : dataElement.GetRawText(); + } + + private sealed class PeerOutboundClient(string peerUrl, string displayName) : IDisposable + { + private readonly object _socketLock = new(); + + private ClientWebSocket? _socket; + private Task? _runTask; + private CancellationToken _cancellationToken; + + public string PeerUrl { get; } = peerUrl; + public string DisplayName { get; } = displayName; + public string? RemotePublicKey { get; private set; } + public bool IsAuthenticated { get; private set; } + + public void Start(CancellationToken cancellationToken) + { + _cancellationToken = cancellationToken; + _runTask = Task.Run(RunAsync, cancellationToken); + } + + public void SetRemotePublicKey(string remotePublicKey) + { + RemotePublicKey = remotePublicKey; + } + + public void MarkAuthenticated() + { + IsAuthenticated = true; + } + + public bool TrySendRelayEnvelope(string relayPayload) + { + if (!IsAuthenticated || string.IsNullOrWhiteSpace(RemotePublicKey)) + { + return false; + } + + long timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + string nonce = SecurityValidator.CreateNonce(); + string targetKey = RemotePublicKey; + string signature = RsaService.Sign(SignedMessagePayload.BuildSigningInput("forward", targetKey, relayPayload, timestamp, nonce)); + + Message request = new() + { + Type = "forward", + Key = targetKey, + Data = new + { + payload = relayPayload, + timestamp, + nonce, + signature + } + }; + + string cipherText = RsaService.EncryptForClient(RemotePublicKey, request.ToJsonString()); + return TrySendRaw(cipherText); + } + + public bool TrySendRaw(string text) + { + ClientWebSocket? socket; + lock (_socketLock) + { + socket = _socket; + } + + if (socket == null || socket.State != WebSocketState.Open) + { + return false; + } + + try + { + byte[] payload = Encoding.UTF8.GetBytes(text); + socket.SendAsync(payload, WebSocketMessageType.Text, true, _cancellationToken) + .GetAwaiter() + .GetResult(); + return true; + } + catch (Exception ex) + { + Log.Security("peer_send_failed", $"peer={PeerUrl} error={ex.Message}"); + return false; + } + } + + public void Dispose() + { + ClientWebSocket? socket; + lock (_socketLock) + { + socket = _socket; + _socket = null; + } + + IsAuthenticated = false; + RemotePublicKey = null; + + if (socket == null) + { + return; + } + + try + { + socket.Abort(); + } + catch + { + // ignore + } + + try + { + socket.Dispose(); + } + catch + { + // ignore + } + } + + private async Task RunAsync() + { + while (!_cancellationToken.IsCancellationRequested) + { + ClientWebSocket socket = new(); + if (PeerUrl.StartsWith("wss://", StringComparison.OrdinalIgnoreCase)) + { + socket.Options.RemoteCertificateValidationCallback = static (_, _, _, _) => true; + } + + lock (_socketLock) + { + _socket = socket; + } + + IsAuthenticated = false; + RemotePublicKey = null; + + try + { + await socket.ConnectAsync(new Uri(PeerUrl), _cancellationToken); + Log.Debug($"peer open {PeerUrl}"); + await ReceiveLoopAsync(socket, _cancellationToken); + } + catch (OperationCanceledException) when (_cancellationToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + Log.Security("peer_connect_failed", $"peer={PeerUrl} error={ex}"); + } + finally + { + string closeReason = ""; + try + { + closeReason = socket.CloseStatusDescription + ?? socket.CloseStatus?.ToString() + ?? ""; + } + catch + { + // ignore + } + + Dispose(); + Log.Debug($"peer close {PeerUrl} {closeReason}"); + } + + if (_cancellationToken.IsCancellationRequested) + { + break; + } + + await Task.Delay(TimeSpan.FromSeconds(_config.PeerReconnectSeconds), _cancellationToken) + .ContinueWith(_ => { }, TaskScheduler.Default); + } + } + + private async Task ReceiveLoopAsync(ClientWebSocket socket, CancellationToken cancellationToken) + { + byte[] buffer = new byte[16 * 1024]; + using MemoryStream messageBuffer = new(); + + while (!cancellationToken.IsCancellationRequested && socket.State == WebSocketState.Open) + { + WebSocketReceiveResult result = await socket.ReceiveAsync(buffer, cancellationToken); + if (result.MessageType == WebSocketMessageType.Close) + { + break; + } + + if (result.Count > 0) + { + messageBuffer.Write(buffer, 0, result.Count); + } + + if (!result.EndOfMessage) + { + continue; + } + + if (result.MessageType != WebSocketMessageType.Text) + { + messageBuffer.SetLength(0); + continue; + } + + string text = Encoding.UTF8.GetString(messageBuffer.GetBuffer(), 0, (int)messageBuffer.Length); + messageBuffer.SetLength(0); + + if (!string.IsNullOrWhiteSpace(text)) + { + HandlePeerSocketMessage(this, text); + } + } + } + } + } +} diff --git a/Core/RsaService.cs b/Core/RsaService.cs index 546b60e..2f62eb3 100644 --- a/Core/RsaService.cs +++ b/Core/RsaService.cs @@ -132,6 +132,16 @@ namespace OnlineMsgServer.Core } } + public static string Sign(string src) + { + lock (_RsaLock) + { + byte[] srcBytes = Encoding.UTF8.GetBytes(src); + byte[] signatureBytes = _Rsa.SignData(srcBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return Convert.ToBase64String(signatureBytes); + } + } + public static bool IsPublicKeyValid(string publicKeyBase64) { lock (_PublicRsaLock) diff --git a/Core/SecurityConfig.cs b/Core/SecurityConfig.cs index 518ed8b..249b1fe 100644 --- a/Core/SecurityConfig.cs +++ b/Core/SecurityConfig.cs @@ -20,9 +20,16 @@ namespace OnlineMsgServer.Core public int ChallengeTtlSeconds { get; init; } = 120; public int MaxClockSkewSeconds { get; init; } = 60; public int ReplayWindowSeconds { get; init; } = 120; + public string PeerNodeName { get; init; } = "server"; + public bool PeerNodeNameExplicitlyConfigured { get; init; } + public string PeerUserPrefix { get; init; } = "peer:"; + public string[] PeerUrls { get; init; } = []; + public int PeerReconnectSeconds { get; init; } = 5; + public int SeenCacheSeconds { get; init; } = 120; public static SecurityConfig LoadFromEnvironment() { + string? rawPeerNodeName = GetString("PEER_NODE_NAME"); return new SecurityConfig { ListenPort = GetInt("LISTEN_PORT", 13173, 1), @@ -40,6 +47,12 @@ namespace OnlineMsgServer.Core ChallengeTtlSeconds = GetInt("CHALLENGE_TTL_SECONDS", 120, 10), MaxClockSkewSeconds = GetInt("MAX_CLOCK_SKEW_SECONDS", 60, 1), ReplayWindowSeconds = GetInt("REPLAY_WINDOW_SECONDS", 120, 10), + PeerNodeName = rawPeerNodeName ?? CreateGuestName(), + PeerNodeNameExplicitlyConfigured = !string.IsNullOrWhiteSpace(rawPeerNodeName), + PeerUserPrefix = GetString("PEER_USER_PREFIX") ?? "peer:", + PeerUrls = GetCsv("PEER_URLS"), + PeerReconnectSeconds = GetInt("PEER_RECONNECT_SECONDS", 5, 1), + SeenCacheSeconds = GetInt("SEEN_CACHE_SECONDS", 120, 1), }; } @@ -89,5 +102,25 @@ namespace OnlineMsgServer.Core return Math.Max(parsed, minValue); } + + private static string[] GetCsv(string key) + { + string? value = Environment.GetEnvironmentVariable(key); + if (string.IsNullOrWhiteSpace(value)) + { + return []; + } + + return value + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + + private static string CreateGuestName() + { + return $"guest-{Random.Shared.Next(100000, 1000000)}"; + } } } diff --git a/Core/SeenMessageCache.cs b/Core/SeenMessageCache.cs new file mode 100644 index 0000000..4b7550c --- /dev/null +++ b/Core/SeenMessageCache.cs @@ -0,0 +1,55 @@ +using System.Security.Cryptography; +using System.Text; + +namespace OnlineMsgServer.Core +{ + internal sealed class SeenMessageCache + { + private readonly object _lock = new(); + private readonly Dictionary _seenUntilUtc = []; + private readonly int _ttlSeconds; + + public SeenMessageCache(int ttlSeconds) + { + _ttlSeconds = Math.Max(ttlSeconds, 1); + } + + public bool TryMark(string senderIdentity, string type, string key, string payload) + { + string hash = ComputeHash(senderIdentity, type, key, payload); + DateTime nowUtc = DateTime.UtcNow; + + lock (_lock) + { + if (_seenUntilUtc.TryGetValue(hash, out DateTime untilUtc) && untilUtc > nowUtc) + { + return false; + } + + _seenUntilUtc[hash] = nowUtc.AddSeconds(_ttlSeconds); + + List expiredKeys = []; + foreach (KeyValuePair item in _seenUntilUtc) + { + if (item.Value <= nowUtc) + { + expiredKeys.Add(item.Key); + } + } + + foreach (string expiredKey in expiredKeys) + { + _seenUntilUtc.Remove(expiredKey); + } + + return true; + } + } + + private static string ComputeHash(string senderIdentity, string type, string key, string payload) + { + byte[] bytes = Encoding.UTF8.GetBytes(string.Join("\n", senderIdentity, type, key, payload)); + return Convert.ToHexString(SHA256.HashData(bytes)); + } + } +} diff --git a/Core/UserService.cs b/Core/UserService.cs index ae57b5c..f7cd5d4 100644 --- a/Core/UserService.cs +++ b/Core/UserService.cs @@ -41,11 +41,14 @@ namespace OnlineMsgServer.Core /// /// 通过publickey返回用户列表 /// - public static List GetUserListByPublicKey(string publicKey) + public static List GetUserListByPublicKey(string publicKey, bool includePeerNodes = true) { lock (_UserListLock) { - return _UserList.FindAll(u => u.PublicKey == publicKey && u.IsAuthenticated); + return _UserList.FindAll(u => + u.PublicKey == publicKey && + u.IsAuthenticated && + (includePeerNodes || !u.IsPeerNode)); } } @@ -53,7 +56,7 @@ namespace OnlineMsgServer.Core /// /// 通过wsid设置用户PublicKey /// - public static void UserLogin(string wsid, string publickey, string name) + public static void UserLogin(string wsid, string publickey, string name, bool isPeerNode = false) { lock (_UserListLock) { @@ -62,6 +65,7 @@ namespace OnlineMsgServer.Core { user.PublicKey = publickey.Trim(); user.Name = name.Trim(); + user.IsPeerNode = isPeerNode; user.IsAuthenticated = true; user.PendingChallenge = null; user.AuthenticatedAtUtc = DateTime.UtcNow; @@ -131,6 +135,50 @@ namespace OnlineMsgServer.Core } } + public static bool IsPeerNodeSession(string wsid) + { + lock (_UserListLock) + { + User? user = _UserList.Find(u => u.ID == wsid); + return user is { IsAuthenticated: true, IsPeerNode: true }; + } + } + + public static string? GetPeerPublicKeyBySessionId(string wsid) + { + lock (_UserListLock) + { + User? user = _UserList.Find(u => u.ID == wsid); + if (user is { IsAuthenticated: true, IsPeerNode: true }) + { + return user.PublicKey; + } + + return null; + } + } + + public static List GetAuthenticatedUsers(bool includePeerNodes = true) + { + lock (_UserListLock) + { + return _UserList + .Where(u => u.IsAuthenticated && (includePeerNodes || !u.IsPeerNode)) + .Select(u => new User(u.ID) + { + Name = u.Name, + PublicKey = u.PublicKey, + IsAuthenticated = u.IsAuthenticated, + IsPeerNode = u.IsPeerNode, + IpAddress = u.IpAddress, + PendingChallenge = u.PendingChallenge, + ChallengeIssuedAtUtc = u.ChallengeIssuedAtUtc, + AuthenticatedAtUtc = u.AuthenticatedAtUtc + }) + .ToList(); + } + } + public static int GetConnectionCount() { lock (_UserListLock) diff --git a/Core/WsService.cs b/Core/WsService.cs index fc4dbd0..3c405e0 100644 --- a/Core/WsService.cs +++ b/Core/WsService.cs @@ -12,6 +12,13 @@ namespace OnlineMsgServer.Core private static readonly object _abuseLock = new(); private static readonly Dictionary _ipBlockedUntil = []; + public WsService() + { + // OkHttp/Android on some paths fails to surface a compressed first message. + // Keep the handshake/hello packet uncompressed for maximum client compatibility. + IgnoreExtensions = true; + } + protected override async void OnMessage(MessageEventArgs e) { SecurityConfig config = SecurityRuntime.Config; diff --git a/Program.cs b/Program.cs index 5ed52a2..ffdb02b 100644 --- a/Program.cs +++ b/Program.cs @@ -48,6 +48,8 @@ namespace OnlineMsgServer //开启ws监听 wssv.AddWebSocketService("/"); wssv.Start(); + PeerNetworkService.Initialize(config, wssv.WebSocketServices["/"].Sessions); + PeerNetworkService.Start(); Console.WriteLine("已开启ws监听, 端口: " + config.ListenPort); bool loopFlag = true; @@ -70,6 +72,7 @@ namespace OnlineMsgServer #endif await Task.Delay(5000);// 每5秒检查一次 } + PeerNetworkService.Stop(); wssv.Stop(); } diff --git a/ReadMe.md b/ReadMe.md index 609a541..f454772 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -1,19 +1,48 @@ # OnlineMsgServer -在线消息中转服务(WebSocket + RSA),支持客户端鉴权、单播转发、广播、签名校验、防重放与限流。 +一个基于 WebSocket 的在线消息中转服务,使用 RSA 完成握手、公钥鉴权和业务包加密。 + +当前版本除了单机广播/私聊,还支持“服务器伪装成普通用户”的 peer 互联模式: + +- 客户端外层协议不变 +- 服务器之间通过普通 `publickey / forward / broadcast` 连接 +- 本地私聊未命中时,服务端可继续向 peer 盲转发 +- 广播可在 peer 节点之间扩散 +- 服务端内置短期 `seen-cache`,按 `hash(sender + type + key + payload)` 去重 + +这套 peer 能力更接近“盲转发网络”,不是强一致的用户目录或联邦路由系统。 + +## 功能概览 + +- WebSocket 服务,支持 `ws://` 和 `wss://` +- 明文首包下发服务端公钥与一次性 challenge +- 客户端使用自己的 RSA 公钥 + 签名完成鉴权 +- 业务消息支持广播和按公钥私聊 +- 签名校验、防重放、限流、IP 封禁、消息大小限制 +- 可选 peer 网络:广播扩散、私聊 miss 后继续中继 +- Android / Web 客户端可直接复用现有协议 ## 仓库结构 -- `deploy/`:一键部署与生产产物脚本 +- `Common/`:协议消息与业务处理器 +- `Core/`:安全配置、用户会话、peer 网络、RSA 服务 +- `deploy/`:本地测试 / 局域网证书 / 生产准备脚本 - `web-client/`:React Web 客户端 -- `android-client/`:Android(Kotlin + Compose)客户端 +- `android-client/`:Android 客户端 -## 运行前提 +## 运行依赖 - `.NET 8 SDK` - `Docker` - `openssl` -- 部署脚本 `deploy/deploy_test_ws.sh` 与 `deploy/redeploy_with_lan_cert.sh` 依赖 `ipconfig`、`route`(当前按 macOS 环境编写) + +本仓库附带的 `deploy/*.sh` 脚本按 macOS 环境编写,依赖: + +- `ipconfig` +- `route` +- `awk` +- `base64` +- `tr` ## 快速开始 @@ -23,23 +52,41 @@ cd ``` -### 1) 测试模式(WS) +### 1. 本地测试:WS ```bash bash deploy/deploy_test_ws.sh ``` -脚本会自动生成/复用协议私钥、构建镜像并以 `REQUIRE_WSS=false` 启动容器。 +脚本会: -### 2) 安全模式(WSS + 局域网证书) +- 生成或复用协议私钥 `deploy/keys/server_rsa_pkcs8.b64` +- 构建 Docker 镜像 +- 以 `REQUIRE_WSS=false` 启动单节点服务 + +### 2. 局域网测试:WSS ```bash bash deploy/redeploy_with_lan_cert.sh ``` -脚本会重签包含当前局域网 IP 的证书、构建镜像并以 `REQUIRE_WSS=true` 启动容器。 +脚本会: + +- 自动探测当前局域网 IP +- 生成包含 LAN IP 的自签名证书 +- 生成运行时使用的 `server.pfx` +- 构建镜像并以 `REQUIRE_WSS=true` 启动容器 -### 3) 生产准备(证书 + 镜像 + 部署产物) +适合 Android 真机、同网段设备和浏览器本地联调。 + +Android 客户端 debug 包支持额外信任本地局域网 CA: + +- 把局域网 WSS 使用的 CA 证书复制到 `deploy/certs/android-local/local_ca.crt` +- `deploy/certs/` 已在 `.gitignore` 中,只用于本地调试,不应提交到 Git +- `assembleDebug` 会自动把它接入 debug-only 的 `networkSecurityConfig` +- release 构建不会信任这张本地 CA + +### 3. 生产准备 ```bash DOMAIN=chat.example.com \ @@ -50,9 +97,14 @@ CERT_PASSWORD='change-me' \ bash deploy/prepare_prod_release.sh ``` -输出目录默认在 `deploy/output/prod`,包含 `prod.env`、镜像 tar(可选)和运行示例脚本。 +输出默认在 `deploy/output/prod/`,包括: + +- `prod.env` +- Docker 镜像 tar(可选) +- 运行示例脚本 +- 运行时证书与协议私钥 -无 CA 证书时可临时使用自签名(仅测试): +如果只是临时测试,也可以生成自签名证书: ```bash DOMAIN=chat.example.com \ @@ -62,9 +114,9 @@ CERT_PASSWORD='change-me' \ bash deploy/prepare_prod_release.sh ``` -## 手动 Docker 启动示例 +## 手动 Docker 启动 -### WS(测试) +### 单节点:WS ```bash docker run -d --name onlinemsgserver --restart unless-stopped \ @@ -75,7 +127,7 @@ docker run -d --name onlinemsgserver --restart unless-stopped \ onlinemsgserver:latest ``` -### WSS(生产/预生产) +### 单节点:WSS ```bash docker run -d --name onlinemsgserver --restart unless-stopped \ @@ -89,16 +141,44 @@ docker run -d --name onlinemsgserver --restart unless-stopped \ onlinemsgserver:latest ``` +### 第二节点:通过 peer 连到第一节点 + +下面这个例子会启动第二个节点,对外提供 `13174`,并主动连到第一节点: + +```bash +docker run -d --name onlinemsgserver-peer2 --restart unless-stopped \ + -p 13174:13174 \ + -v "$(pwd)/deploy/certs:/app/certs:ro" \ + -e REQUIRE_WSS=true \ + -e LISTEN_PORT=13174 \ + -e TLS_CERT_PATH=/app/certs/server.pfx \ + -e TLS_CERT_PASSWORD=changeit \ + -e ALLOW_EPHEMERAL_SERVER_KEY=true \ + -e PEER_NODE_NAME=peer-node-b \ + -e PEER_URLS=wss://host.docker.internal:13173/ \ + onlinemsgserver:latest +``` + +这里有一个很重要的约束: + +- 如果客户端访问的是 `wss://host:13174/` +- 那容器内 `LISTEN_PORT` 也应当是 `13174` + +`WebSocketSharp` 会校验握手请求里的 `Host: host:port`,容器内监听端口和客户端看到的端口不一致时,可能直接返回 `400 Bad Request`。 + ## 协议说明 ### 加密方式 -- RSA-2048-OAEP-SHA256 -- 明文按 190 字节分块加密 -- 密文按 256 字节分块解密 -- 业务消息传输为 base64 字符串 +- 服务端握手公钥:RSA-2048(SPKI / PKCS8) +- 传输加密:`RSA/ECB/OAEPWithSHA-256AndMGF1Padding` +- 明文按 `190` 字节分块加密 +- 密文按 `256` 字节分块解密 +- WebSocket 上传输的是 base64 字符串 -### 通用包结构(客户端 -> 服务端) +### 通用包结构 + +客户端发给服务端的明文结构如下,随后再整体用服务端公钥加密: ```json { @@ -108,7 +188,9 @@ docker run -d --name onlinemsgserver --restart unless-stopped \ } ``` -### 连接首包(服务端 -> 客户端,明文) +### 首包:服务端 -> 客户端(明文) + +客户端建立连接后,服务端立即发送: ```json { @@ -122,83 +204,196 @@ docker run -d --name onlinemsgserver --restart unless-stopped \ } ``` -### 鉴权登记 `type=publickey`(客户端 -> 服务端) +### 鉴权:`type=publickey` -- `key`:用户名(为空时服务端会生成匿名名) -- `data`: +- `key`:用户名 +- `data.publicKey`:客户端公钥 +- `data.challenge`:首包中的 `authChallenge` +- `data.timestamp`:Unix 秒级时间戳 +- `data.nonce`:随机串 +- `data.signature`:客户端私钥签名 + +示例: ```json { - "publicKey": "客户端公钥(base64 SPKI)", - "challenge": "上一步 authChallenge", - "timestamp": 1739600000, - "nonce": "随机字符串", - "signature": "签名(base64)" + "type": "publickey", + "key": "guest-123456", + "data": { + "publicKey": "base64-spki", + "challenge": "challenge-from-server", + "timestamp": 1739600000, + "nonce": "random-string", + "signature": "base64-signature" + } } ``` -签名串: +签名原文: ```text -publickey\n{userName}\n{publicKey}\n{challenge}\n{timestamp}\n{nonce} +publickey +{userName} +{publicKey} +{challenge} +{timestamp} +{nonce} ``` -### 单播 `type=forward` +### 私聊:`type=forward` -- `key`:目标客户端公钥 -- `data`: +- `key`:目标用户公钥 +- `data.payload`:消息内容 +- `data.timestamp` / `data.nonce` / `data.signature`:发送者签名信息 ```json { - "payload": "消息内容", - "timestamp": 1739600000, - "nonce": "随机字符串", - "signature": "签名(base64)" + "type": "forward", + "key": "target-user-public-key", + "data": { + "payload": "hello", + "timestamp": 1739600000, + "nonce": "random-string", + "signature": "base64-signature" + } } ``` -签名串: +签名原文: ```text -forward\n{targetPublicKey}\n{payload}\n{timestamp}\n{nonce} +forward +{targetPublicKey} +{payload} +{timestamp} +{nonce} ``` -### 广播 `type=broadcast` +### 广播:`type=broadcast` -- `key`:可为空字符串 -- `data`:同 `forward` +- `key`:通常为空字符串 +- `data`:结构与 `forward` 相同 -签名串: +签名原文: ```text -broadcast\n{key}\n{payload}\n{timestamp}\n{nonce} +broadcast +{key} +{payload} +{timestamp} +{nonce} ``` ### 连接流程 -1. 客户端建立 WebSocket 连接后接收明文 `publickey` 首包。 -2. 客户端发送签名鉴权包(`type=publickey`)。 -3. 鉴权成功后,客户端发送 `forward` / `broadcast` 业务消息(加密 + 签名)。 +1. 客户端建立 WebSocket 连接。 +2. 服务端发送明文 `publickey` 首包。 +3. 客户端用自己的私钥签名后发送 `type=publickey` 鉴权包。 +4. 服务端返回加密的 `auth_ok`。 +5. 客户端开始发送 `forward` / `broadcast`。 + +## Peer 网络说明 + +Peer 网络不引入新的客户端外层协议。节点之间也是普通登录用户,只是服务端会把这类会话当成 peer 处理。 + +当前行为: + +- 本地广播:先发给本地普通客户端,再扩散到 peer +- 从 peer 收到广播:投递给本地普通客户端,再继续扩散 +- 本地私聊命中:直接投递 +- 本地私聊 miss:包装为内部 relay 后继续发给 peer +- peer 收到私聊 relay:本地命中就投递,命不中就继续向其他 peer 转发 + +当前实现特点: + +- 不做用户发现 +- 不维护“谁在哪台服务器”的路由表 +- 只保证尽力转发 +- 依赖短期 `seen-cache` 防止消息在环路里重复扩散 + +### Peer 命名 + +为了让客户端界面更像普通聊天用户: + +- 服务端内部仍用 `peer:` 前缀区分 peer 会话 +- 发给客户端前会去掉这个内部前缀 +- 如果显式设置了 `PEER_NODE_NAME=peer-node-b`,客户端看到的是 `peer-node-b` +- 如果没有显式设置 `PEER_NODE_NAME`,默认自动生成 `guest-xxxxxx` ## 环境变量 +### 基础运行 + - `LISTEN_PORT`:监听端口,默认 `13173` - `REQUIRE_WSS`:是否启用 WSS,默认 `false` -- `TLS_CERT_PATH`:证书路径(启用 WSS 时必填) -- `TLS_CERT_PASSWORD`:证书密码(可空) -- `SERVER_PRIVATE_KEY_B64`:服务端私钥(PKCS8 base64) -- `SERVER_PRIVATE_KEY_PATH`:服务端私钥文件路径(与上面二选一) -- `ALLOW_EPHEMERAL_SERVER_KEY`:允许使用临时内存私钥,默认 `false` +- `TLS_CERT_PATH`:PFX 证书路径,启用 WSS 时必填 +- `TLS_CERT_PASSWORD`:PFX 证书密码,可空 + +### 协议私钥 + +- `SERVER_PRIVATE_KEY_B64`:协议私钥(PKCS8 base64) +- `SERVER_PRIVATE_KEY_PATH`:协议私钥文件路径 +- `ALLOW_EPHEMERAL_SERVER_KEY`:若未提供私钥,是否允许启动临时内存私钥,默认 `false` + +### 安全限制 + - `MAX_CONNECTIONS`:最大连接数,默认 `1000` - `MAX_MESSAGE_BYTES`:单消息最大字节数,默认 `65536` - `RATE_LIMIT_COUNT`:限流窗口允许消息数,默认 `30` - `RATE_LIMIT_WINDOW_SECONDS`:限流窗口秒数,默认 `10` - `IP_BLOCK_SECONDS`:触发滥用后的封禁秒数,默认 `120` -- `CHALLENGE_TTL_SECONDS`:挑战值有效期秒数,默认 `120` +- `CHALLENGE_TTL_SECONDS`:challenge 有效期秒数,默认 `120` - `MAX_CLOCK_SKEW_SECONDS`:允许时钟偏差秒数,默认 `60` - `REPLAY_WINDOW_SECONDS`:防重放窗口秒数,默认 `120` +- `SEEN_CACHE_SECONDS`:短期去重缓存秒数,默认 `120` + +### Peer + +- `PEER_NODE_NAME`:peer 登录名;未显式配置时自动生成 `guest-xxxxxx` +- `PEER_USER_PREFIX`:内部保留前缀,默认 `peer:` +- `PEER_URLS`:要主动连接的 peer 地址,逗号分隔 +- `PEER_RECONNECT_SECONDS`:peer 断线后的重连间隔,默认 `5` + +## 本地调试建议 + +### Android 连 `ws://` + +Android 9 之后默认禁止明文流量。若用 `ws://` 调试,需要客户端显式允许 cleartext。 + +### Android 连 `wss://` + +若服务端使用自签名证书,需要满足其一: + +- 设备/模拟器信任这张 CA +- Android debug 包内置该 CA 的信任配置 + +### 多实例本地测试 + +同一台机器上起多个节点时,建议: + +- 为每个节点分配不同 `LISTEN_PORT` +- 对外映射端口和 `LISTEN_PORT` 保持一致 +- 第一个节点使用固定协议私钥 +- 第二个测试节点可使用 `ALLOW_EPHEMERAL_SERVER_KEY=true` + +## 排错 + +### `expected HTTP 101 but was 400` + +常见原因: + +- 容器内 `LISTEN_PORT` 与客户端访问端口不一致 +- 客户端实际访问了错误的 `Host: port` + +### Android 显示“未收到服务器首包” + +当前服务端已禁用 WebSocket 压缩扩展协商,以避免某些 Android/OkHttp 路径拿不到压缩后的首个 `publickey` Hello。 + +### Peer 连不上 WSS + +当前 peer 出站连接使用 .NET `ClientWebSocket`,可以直连 `wss://` peer。若是自签名测试环境,请确认目标地址可达,并尽量使用稳定的局域网地址或 `host.docker.internal`。 -## 客户端文档 +## 相关文档 -- Web 客户端说明:`web-client/README.md` -- Android 客户端说明:`android-client/README.md` +- Web 客户端:[web-client/README.md](/Users/solux/Codes/OnlineMsgServer/web-client/README.md) +- Android 客户端:[android-client/README.md](/Users/solux/Codes/OnlineMsgServer/android-client/README.md) diff --git a/android-client/README.md b/android-client/README.md index 9312f95..1697d3e 100644 --- a/android-client/README.md +++ b/android-client/README.md @@ -47,6 +47,17 @@ cd android-client - 真机建议地址:`ws://<你的局域网IP>:13173/` - 若服务端启用 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/` + ## 协议注意事项 - 鉴权签名串: diff --git a/android-client/app/build.gradle.kts b/android-client/app/build.gradle.kts index df2b5e5..92f64a8 100644 --- a/android-client/app/build.gradle.kts +++ b/android-client/app/build.gradle.kts @@ -5,6 +5,12 @@ plugins { 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 { namespace = "com.onlinemsg.client" compileSdk = 34 @@ -52,6 +58,13 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + + sourceSets { + getByName("debug") { + res.srcDir(generatedLocalDebugResDir) + manifest.srcFile(generatedLocalDebugManifestFile) + } + } } dependencies { @@ -93,6 +106,56 @@ val debugApkExportDir: String = providers.gradleProperty("debugApkExportDir") .get() 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( + """ + + + + + + + + + + """.trimIndent() + "\n" + ) + + generatedLocalDebugManifestFile.writeText( + """ + + + + + """.trimIndent() + "\n" + ) + } else { + generatedLocalDebugManifestFile.writeText( + """ + + + """.trimIndent() + "\n" + ) + } + } +} + val exportDebugApk by tasks.registering(Copy::class) { from(layout.buildDirectory.file("outputs/apk/debug/app-debug.apk")) 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 { + dependsOn(prepareLocalDebugTrust) finalizedBy(exportDebugApk) } diff --git a/android-client/app/src/main/AndroidManifest.xml b/android-client/app/src/main/AndroidManifest.xml index 6c539c7..3e86fa6 100644 --- a/android-client/app/src/main/AndroidManifest.xml +++ b/android-client/app/src/main/AndroidManifest.xml @@ -5,12 +5,15 @@ + + ) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3) + .build().also { db -> instance = db } } diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatHistoryRepository.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatHistoryRepository.kt index e0f0a61..c176815 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatHistoryRepository.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatHistoryRepository.kt @@ -1,43 +1,60 @@ 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 class ChatHistoryRepository(private val messageDao: ChatMessageDao) { - suspend fun loadMessages(limit: Int): List { - return messageDao.listAll() + suspend fun loadMessages(serverKey: String, limit: Int): List { + migrateLegacyMessagesIfNeeded(serverKey) + return messageDao.listByServer(serverKey) .asSequence() .mapNotNull { entity -> entity.toUiMessageOrNull() } .toList() .takeLast(limit) } - suspend fun appendMessage(message: UiMessage, limit: Int) { - messageDao.upsert(message.toEntity()) - messageDao.trimToLatest(limit) + suspend fun appendMessage(serverKey: String, message: UiMessage, limit: Int) { + messageDao.upsert(message.toEntity(serverKey)) + messageDao.trimToLatest(serverKey, limit) } - suspend fun clearAll() { - messageDao.clearAll() + suspend fun clearAll(serverKey: String) { + 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( - id = id, + id = toStorageId(serverKey, id), + serverKey = serverKey, role = role.name, sender = sender, 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 +62,19 @@ private fun ChatMessageEntity.toUiMessageOrNull(): UiMessage? { subtitle = subtitle, content = content, channel = parsedChannel, - timestampMillis = timestampMillis + timestampMillis = timestampMillis, + contentType = parsedContentType, + audioBase64 = audioBase64, + 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::" diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageDao.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageDao.kt index 1085e09..1021a9e 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageDao.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageDao.kt @@ -7,8 +7,8 @@ import androidx.room.Query @Dao interface ChatMessageDao { - @Query("SELECT * FROM chat_messages ORDER BY timestampMillis ASC") - suspend fun listAll(): List + @Query("SELECT * FROM chat_messages WHERE serverKey = :serverKey ORDER BY timestampMillis ASC") + suspend fun listByServer(serverKey: String): List @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(message: ChatMessageEntity) @@ -16,16 +16,27 @@ interface ChatMessageDao { @Query( """ DELETE FROM chat_messages - WHERE id NOT IN ( + WHERE serverKey = :serverKey + AND id NOT IN ( SELECT id FROM chat_messages + WHERE serverKey = :serverKey ORDER BY timestampMillis DESC LIMIT :limit ) """ ) - suspend fun trimToLatest(limit: Int) + suspend fun trimToLatest(serverKey: String, limit: Int) - @Query("DELETE FROM chat_messages") - suspend fun clearAll() + @Query("DELETE FROM chat_messages WHERE serverKey = :serverKey") + 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) } diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageEntity.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageEntity.kt index e0e6dde..2cc8c11 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageEntity.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageEntity.kt @@ -6,10 +6,14 @@ import androidx.room.PrimaryKey @Entity(tableName = "chat_messages") data class ChatMessageEntity( @PrimaryKey val id: String, + val serverKey: String, val role: String, val sender: String, val subtitle: String, val content: String, val channel: String, - val timestampMillis: Long + val timestampMillis: Long, + val contentType: String, + val audioBase64: String, + val audioDurationMillis: Long ) diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt index 2394210..9e7974b 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt @@ -24,8 +24,7 @@ data class UserPreferences( val shouldAutoReconnect: Boolean, val themeId: String = "blue", val useDynamicColor: Boolean = true, - val language: String = "zh", - val notificationSound: String = "default" + val language: String = "zh" // 默认中文 ) class UserPreferencesRepository( @@ -51,8 +50,7 @@ class UserPreferencesRepository( shouldAutoReconnect = prefs[KEY_SHOULD_AUTO_RECONNECT] ?: false, themeId = prefs[KEY_THEME_ID] ?: "blue", useDynamicColor = prefs[KEY_USE_DYNAMIC_COLOR] ?: true, - language = prefs[KEY_LANGUAGE] ?: "zh", - notificationSound = prefs[KEY_NOTIFICATION_SOUND] ?: "default" + language = prefs[KEY_LANGUAGE] ?: "zh" ) } @@ -68,12 +66,6 @@ class UserPreferencesRepository( } } - suspend fun setNotificationSound(sound: String) { - context.dataStore.edit { prefs -> - prefs[KEY_NOTIFICATION_SOUND] = sound - } - } - suspend fun setUseDynamicColor(enabled: Boolean) { context.dataStore.edit { prefs -> prefs[KEY_USE_DYNAMIC_COLOR] = enabled @@ -163,6 +155,5 @@ class UserPreferencesRepository( val KEY_THEME_ID: Preferences.Key = stringPreferencesKey("theme_id") val KEY_USE_DYNAMIC_COLOR: Preferences.Key = booleanPreferencesKey("use_dynamic_color") val KEY_LANGUAGE: Preferences.Key = stringPreferencesKey("language") - val KEY_NOTIFICATION_SOUND: Preferences.Key = stringPreferencesKey("notification_sound") } } diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/protocol/ProtocolModels.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/protocol/ProtocolModels.kt index deb69d1..e872053 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/data/protocol/ProtocolModels.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/protocol/ProtocolModels.kt @@ -39,6 +39,27 @@ data class SignedPayloadDto( @SerialName("signature") val signature: String ) +@Serializable +data class AudioPayloadDto( + @SerialName("version") val version: Int = 1, + @SerialName("encoding") val encoding: String = "base64", + @SerialName("mimeType") val mimeType: String = "audio/mp4", + @SerialName("durationMillis") val durationMillis: Long, + @SerialName("data") val data: String +) + +@Serializable +data class AudioChunkPayloadDto( + @SerialName("version") val version: Int = 1, + @SerialName("encoding") val encoding: String = "base64", + @SerialName("mimeType") val mimeType: String = "audio/mp4", + @SerialName("messageId") val messageId: String, + @SerialName("index") val index: Int, + @SerialName("total") val total: Int, + @SerialName("durationMillis") val durationMillis: Long, + @SerialName("data") val data: String +) + fun JsonElement?.asPayloadText(): String { if (this == null || this is JsonNull) return "" return if (this is JsonPrimitive && this.isString) { diff --git a/android-client/app/src/main/java/com/onlinemsg/client/service/ChatForegroundService.kt b/android-client/app/src/main/java/com/onlinemsg/client/service/ChatForegroundService.kt index afb0369..9d84725 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/service/ChatForegroundService.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/service/ChatForegroundService.kt @@ -16,6 +16,7 @@ import androidx.core.app.NotificationManagerCompat import com.onlinemsg.client.MainActivity import com.onlinemsg.client.ui.ChatSessionManager import com.onlinemsg.client.ui.ConnectionStatus +import com.onlinemsg.client.util.LanguageManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -29,6 +30,10 @@ class ChatForegroundService : Service() { private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private var statusJob: Job? = null + private fun t(key: String): String { + return LanguageManager.getString(key, ChatSessionManager.uiState.value.language) + } + override fun onCreate() { super.onCreate() ChatSessionManager.initialize(application) @@ -113,22 +118,22 @@ class ChatForegroundService : Service() { ) val title = when (status) { - ConnectionStatus.READY -> "OnlineMsg 已保持连接" + ConnectionStatus.READY -> t("service.foreground.title.ready") ConnectionStatus.CONNECTING, ConnectionStatus.HANDSHAKING, - ConnectionStatus.AUTHENTICATING -> "OnlineMsg 正在连接" - ConnectionStatus.ERROR -> "OnlineMsg 连接异常" - ConnectionStatus.IDLE -> "OnlineMsg 后台服务" + ConnectionStatus.AUTHENTICATING -> t("service.foreground.title.connecting") + ConnectionStatus.ERROR -> t("service.foreground.title.error") + ConnectionStatus.IDLE -> t("service.foreground.title.idle") } return NotificationCompat.Builder(this, FOREGROUND_CHANNEL_ID) .setSmallIcon(android.R.drawable.stat_notify_sync) .setContentTitle(title) - .setContentText(hint.ifBlank { "后台保持连接中" }) + .setContentText(hint.ifBlank { t("service.foreground.hint.default") }) .setOngoing(true) .setOnlyAlertOnce(true) .setContentIntent(openAppPendingIntent) - .addAction(0, "断开", stopPendingIntent) + .addAction(0, t("service.foreground.action.disconnect"), stopPendingIntent) .setPriority(NotificationCompat.PRIORITY_LOW) .build() } @@ -138,10 +143,10 @@ class ChatForegroundService : Service() { val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val channel = NotificationChannel( FOREGROUND_CHANNEL_ID, - "OnlineMsg 后台连接", + t("service.foreground.channel.name"), NotificationManager.IMPORTANCE_LOW ).apply { - description = "保持 WebSocket 后台长连接" + description = t("service.foreground.channel.desc") setShowBadge(false) } manager.createNotificationChannel(channel) diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt index 5e3bd26..aac72a4 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 @@ -33,7 +33,6 @@ import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.Forum import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Language -import androidx.compose.material.icons.rounded.MusicNote import androidx.compose.material3.AssistChip import androidx.compose.material3.Button import androidx.compose.material3.Card @@ -74,8 +73,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.compose.foundation.isSystemInDarkTheme import android.os.Build -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.rememberScrollState import com.onlinemsg.client.ui.theme.OnlineMsgTheme import java.time.Instant import java.time.ZoneId @@ -112,7 +109,7 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) { var tab by rememberSaveable { mutableStateOf(MainTab.CHAT) } // 定义翻译函数 t - fun language(key: String) = LanguageManager.getString(key, state.language) + fun t(key: String) = LanguageManager.getString(key, state.language) // 监听 ViewModel 发送的 UI 事件(如 Snackbar 消息) LaunchedEffect(Unit) { @@ -149,7 +146,7 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) { NavigationBarItem( selected = tab == MainTab.CHAT, onClick = { tab = MainTab.CHAT }, - label = { Text(language(MainTab.CHAT.labelKey), style = MaterialTheme.typography.labelSmall) }, + label = { Text(t(MainTab.CHAT.labelKey), style = MaterialTheme.typography.labelSmall) }, icon = { Icon( imageVector = Icons.Rounded.Forum, @@ -161,7 +158,7 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) { NavigationBarItem( selected = tab == MainTab.SETTINGS, onClick = { tab = MainTab.SETTINGS }, - label = { Text(language(MainTab.SETTINGS.labelKey), style = MaterialTheme.typography.labelSmall) }, + label = { Text(t(MainTab.SETTINGS.labelKey), style = MaterialTheme.typography.labelSmall) }, icon = { Icon( imageVector = Icons.Rounded.Settings, @@ -214,8 +211,7 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) { onClearMessages = viewModel::clearMessages, onThemeChange = viewModel::updateTheme, onUseDynamicColorChange = viewModel::updateUseDynamicColor, - onLanguageChange = viewModel::updateLanguage, - onNotificationSoundChange = viewModel::updateNotificationSound + onLanguageChange = viewModel::updateLanguage ) } } @@ -291,8 +287,8 @@ private fun ChatTab( ) { val listState = rememberLazyListState() - // 定义语言函数 language - fun language(key: String) = LanguageManager.getString(key, state.language) + // 定义翻译函数 t + fun t(key: String) = LanguageManager.getString(key, state.language) // 当消息列表新增消息时,自动滚动到底部 LaunchedEffect(state.visibleMessages.size) { @@ -316,12 +312,12 @@ private fun ChatTab( FilterChip( selected = !state.directMode, onClick = { onToggleDirectMode(false) }, - label = { Text(language("chat.broadcast")) } + label = { Text(t("chat.broadcast")) } ) FilterChip( selected = state.directMode, onClick = { onToggleDirectMode(true) }, - label = { Text(language("chat.private")) } + label = { Text(t("chat.private")) } ) // 在这一行腾出的空间可以放置其他快捷操作,或者保持简洁 @@ -340,8 +336,8 @@ private fun ChatTab( value = state.targetKey, onValueChange = onTargetKeyChange, modifier = Modifier.fillMaxWidth(), - label = { Text(language("chat.target_key")) }, - placeholder = { Text(language("chat.target_key")) }, + label = { Text(t("chat.target_key")) }, + placeholder = { Text(t("chat.target_key")) }, maxLines = 3 ) } @@ -366,7 +362,7 @@ private fun ChatTab( ) ) { Text( - text = language("chat.empty_hint"), + text = t("chat.empty_hint"), modifier = Modifier.padding(12.dp), style = MaterialTheme.typography.bodyMedium ) @@ -394,7 +390,7 @@ private fun ChatTab( value = state.draft, onValueChange = onDraftChange, modifier = Modifier.weight(1f), - label = { Text(language("chat.input_placeholder")) }, + label = { Text(t("chat.input_placeholder")) }, maxLines = 4, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), keyboardActions = KeyboardActions( @@ -409,7 +405,7 @@ private fun ChatTab( ) { Icon(Icons.AutoMirrored.Rounded.Send, contentDescription = null) Spacer(Modifier.width(6.dp)) - Text(if (state.sending) "..." else language("chat.send")) + Text(if (state.sending) "..." else t("chat.send")) } } } @@ -579,7 +575,6 @@ private fun MessageItem( * @param onThemeChange 切换主题 * @param onUseDynamicColorChange 切换动态颜色 * @param onLanguageChange 切换语言 - * @param onNotificationSoundChange 切换通知音效 */ @Composable private fun SettingsTab( @@ -596,10 +591,9 @@ private fun SettingsTab( onClearMessages: () -> Unit, onThemeChange: (String) -> Unit, onUseDynamicColorChange: (Boolean) -> Unit, - onLanguageChange: (String) -> Unit, - onNotificationSoundChange: (String) -> Unit + onLanguageChange: (String) -> Unit ) { - fun language(key: String) = LanguageManager.getString(key, state.language) + fun t(key: String) = LanguageManager.getString(key, state.language) val settingsCardModifier = Modifier.fillMaxWidth() val settingsCardContentModifier = Modifier @@ -620,12 +614,12 @@ private fun SettingsTab( modifier = settingsCardContentModifier, verticalArrangement = settingsCardContentSpacing ) { - Text(language("settings.personal"), style = MaterialTheme.typography.titleMedium) + Text(t("settings.personal"), style = MaterialTheme.typography.titleMedium) OutlinedTextField( value = state.displayName, onValueChange = onDisplayNameChange, modifier = Modifier.fillMaxWidth(), - label = { Text(language("settings.display_name")) }, + label = { Text(t("settings.display_name")) }, maxLines = 1 ) } @@ -638,9 +632,9 @@ private fun SettingsTab( modifier = settingsCardContentModifier, verticalArrangement = settingsCardContentSpacing ) { - Text(language("settings.chat_data"), style = MaterialTheme.typography.titleMedium) + Text("聊天数据", style = MaterialTheme.typography.titleMedium) OutlinedButton(onClick = onClearMessages) { - Text(language("settings.clear_msg")) + Text(t("settings.clear_msg")) } } } @@ -652,26 +646,23 @@ private fun SettingsTab( modifier = settingsCardContentModifier, verticalArrangement = settingsCardContentSpacing ) { - Text(language("settings.server"), style = MaterialTheme.typography.titleMedium) + Text(t("settings.server"), style = MaterialTheme.typography.titleMedium) OutlinedTextField( value = state.serverUrl, onValueChange = onServerUrlChange, modifier = Modifier.fillMaxWidth(), - label = { Text(language("settings.server_url")) }, + label = { Text(t("settings.server_url")) }, maxLines = 1 ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = onSaveServer) { Text(language("settings.save_server")) } - OutlinedButton(onClick = onRemoveServer) { Text(language("settings.remove_current")) } + Button(onClick = onSaveServer) { Text(t("settings.save_server")) } + OutlinedButton(onClick = onRemoveServer) { Text(t("settings.remove_current")) } } if (state.serverUrls.isNotEmpty()) { HorizontalDivider() - Text(language("settings.saved_servers"), style = MaterialTheme.typography.labelLarge) - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - state.serverUrls.forEach { url -> + Text(t("settings.saved_servers"), style = MaterialTheme.typography.labelLarge) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(state.serverUrls) { url -> AssistChip( onClick = { onSelectServer(url) }, label = { Text(url, maxLines = 1, overflow = TextOverflow.Ellipsis) } @@ -689,16 +680,16 @@ private fun SettingsTab( modifier = settingsCardContentModifier, verticalArrangement = settingsCardContentSpacing ) { - Text(language("settings.identity"), style = MaterialTheme.typography.titleMedium) + Text(t("settings.identity"), style = MaterialTheme.typography.titleMedium) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button(onClick = onRevealPublicKey, enabled = !state.loadingPublicKey) { - Text(if (state.loadingPublicKey) "..." else language("settings.reveal_key")) + Text(if (state.loadingPublicKey) "..." else t("settings.reveal_key")) } OutlinedButton( onClick = onCopyPublicKey, enabled = state.myPublicKey.isNotBlank() ) { - Text(language("settings.copy_key")) + Text(t("settings.copy_key")) } } OutlinedTextField( @@ -706,7 +697,7 @@ private fun SettingsTab( onValueChange = {}, modifier = Modifier.fillMaxWidth(), readOnly = true, - label = { Text(language("settings.my_key")) }, + label = { Text(t("settings.my_key")) }, maxLines = 4 ) } @@ -719,7 +710,7 @@ private fun SettingsTab( modifier = settingsCardContentModifier, verticalArrangement = settingsCardContentSpacing ) { - Text(language("settings.language"), style = MaterialTheme.typography.titleMedium) + Text(t("settings.language"), style = MaterialTheme.typography.titleMedium) LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { items(LanguageManager.supportedLanguages) { lang -> FilterChip( @@ -746,40 +737,7 @@ private fun SettingsTab( modifier = settingsCardContentModifier, verticalArrangement = settingsCardContentSpacing ) { - Text(language("settings.notification_sound"), style = MaterialTheme.typography.titleMedium) - val sounds = listOf( - "default" to language("sound.default"), - "ding" to language("sound.ding"), - "nameit5" to language("sound.nameit5"), - "wind_chime" to language("sound.wind_chime") - ) - LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - items(sounds) { (id, label) -> - FilterChip( - selected = state.notificationSound == id, - onClick = { onNotificationSoundChange(id) }, - label = { Text(label) }, - leadingIcon = { - Icon( - Icons.Rounded.MusicNote, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - } - ) - } - } - } - } - } - - item { - Card(modifier = settingsCardModifier) { - Column( - modifier = settingsCardContentModifier, - verticalArrangement = settingsCardContentSpacing - ) { - Text(language("settings.theme"), style = MaterialTheme.typography.titleMedium) + Text(t("settings.theme"), style = MaterialTheme.typography.titleMedium) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { Row( verticalAlignment = Alignment.CenterVertically, @@ -789,18 +747,18 @@ private fun SettingsTab( checked = state.useDynamicColor, onCheckedChange = onUseDynamicColorChange ) - Text(language("settings.dynamic_color")) + Text(t("settings.dynamic_color")) } } if (!state.useDynamicColor || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { - Text(language("settings.preset_themes"), style = MaterialTheme.typography.labelLarge) + Text(t("settings.preset_themes"), style = MaterialTheme.typography.labelLarge) LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { items(themeOptions) { option -> val themeName = when (option.id) { - "blue" -> language("theme.blue") - "gray" -> language("theme.gray") - "green" -> language("theme.green") - "red" -> language("theme.red") + "blue" -> t("theme.blue") + "gray" -> t("theme.gray") + "green" -> t("theme.green") + "red" -> t("theme.red") else -> option.name } FilterChip( @@ -828,16 +786,16 @@ private fun SettingsTab( modifier = settingsCardContentModifier, verticalArrangement = settingsCardContentSpacing ) { - Text(language("settings.diagnostics"), style = MaterialTheme.typography.titleMedium) - Text("${language("settings.status_hint")}:${state.statusHint}") - Text("${language("settings.current_status")}:${state.statusText}") - Text("${language("settings.cert_fingerprint")}:${state.certFingerprint.ifBlank { "N/A" }}") + 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.cert_fingerprint")}:${state.certFingerprint.ifBlank { "N/A" }}") Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Switch(checked = state.showSystemMessages, onCheckedChange = onToggleShowSystem) - Text(language("settings.show_system")) + Text(t("settings.show_system")) } } } 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 e65118c..a9750ee 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 @@ -5,12 +5,9 @@ import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent -import android.content.ContentResolver import android.content.Context import android.content.Intent import android.content.pm.PackageManager -import android.media.AudioAttributes -import android.net.Uri import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -51,10 +48,10 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.encodeToJsonElement -import com.onlinemsg.client.util.LanguageManager /** * 单例管理类,负责整个聊天会话的生命周期、网络连接、消息收发、状态维护和持久化。 + * 所有公开方法均通过 ViewModel 代理调用,内部使用协程处理异步操作。 */ object ChatSessionManager { @@ -83,29 +80,28 @@ object ChatSessionManager { private var identity: RsaCryptoManager.Identity? = null // 连接相关内部状态 - private var manualClose = false - private var fallbackTried = false - private var connectedUrl = "" - private var serverPublicKey = "" - private var helloTimeoutJob: Job? = null - private var authTimeoutJob: Job? = null - private var reconnectJob: Job? = null - private var reconnectAttempt: Int = 0 - private val systemMessageExpiryJobs: MutableMap = mutableMapOf() + private var manualClose = false // 是否为手动断开 + private var fallbackTried = false // 是否已尝试切换 ws/wss + private var connectedUrl = "" // 当前连接的服务器地址 + private var serverPublicKey = "" // 服务端公钥(握手后获得) + private var helloTimeoutJob: Job? = null // 握手超时任务 + private var authTimeoutJob: Job? = null // 认证超时任务 + private var reconnectJob: Job? = null // 自动重连任务 + private var reconnectAttempt: Int = 0 // 当前重连尝试次数 + private val systemMessageExpiryJobs: MutableMap = mutableMapOf() // 系统消息自动过期任务 private var autoReconnectTriggered = false @Volatile - private var keepAliveRequested = false + private var keepAliveRequested = false // 是否应保活(前台服务标志) private var notificationIdSeed = 2000 // WebSocket 事件监听器 private val socketListener = object : OnlineMsgSocketClient.Listener { override fun onOpen() { scope.launch { - val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.HANDSHAKING, - statusHint = LanguageManager.getString("status_hint.handshaking", lang) + statusHint = "已连接,正在准备聊天..." ) } addSystemMessage("连接已建立") @@ -126,8 +122,7 @@ object ChatSessionManager { override fun onBinaryMessage(payload: ByteArray) { scope.launch { if (_uiState.value.status == ConnectionStatus.HANDSHAKING) { - val lang = _uiState.value.language - _uiState.update { it.copy(statusHint = LanguageManager.getString("status_hint.received_binary_handshake", lang)) } + _uiState.update { it.copy(statusHint = "收到二进制握手帧,正在尝试解析...") } } val utf8 = runCatching { String(payload, StandardCharsets.UTF_8) }.getOrNull().orEmpty() @@ -157,11 +152,10 @@ object ChatSessionManager { if (manualClose) return@launch val message = throwable.message?.takeIf { it.isNotBlank() } ?: "unknown" addSystemMessage("连接异常:$message") - val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = LanguageManager.getString("status_hint.connection_error_retrying", lang) + statusHint = "连接异常,正在重试" ) } scheduleReconnect("连接异常") @@ -169,6 +163,10 @@ object ChatSessionManager { } } + /** + * 初始化管理器,必须在应用启动时调用一次。 + * @param application Application 实例 + */ @Synchronized fun initialize(application: Application) { if (initialized) return @@ -177,6 +175,7 @@ object ChatSessionManager { preferencesRepository = UserPreferencesRepository(application, json) cryptoManager = RsaCryptoManager(application) historyRepository = ChatHistoryRepository(ChatDatabase.getInstance(application).chatMessageDao()) + ensureMessageNotificationChannel() scope.launch { val pref = preferencesRepository.preferencesFlow.first() @@ -194,14 +193,10 @@ object ChatSessionManager { themeId = pref.themeId, useDynamicColor = pref.useDynamicColor, language = pref.language, - notificationSound = pref.notificationSound, - messages = historyMessages, - statusHint = LanguageManager.getString("status_hint.click_to_connect", pref.language) + messages = historyMessages ) } - - ensureMessageNotificationChannel(pref.notificationSound) - + // 如果上次会话启用了自动重连,则自动恢复连接 if (pref.shouldAutoReconnect && !autoReconnectTriggered) { autoReconnectTriggered = true ChatForegroundService.start(application) @@ -210,14 +205,10 @@ object ChatSessionManager { } } - fun updateNotificationSound(sound: String) { - _uiState.update { it.copy(notificationSound = sound) } - scope.launch { - preferencesRepository.setNotificationSound(sound) - ensureMessageNotificationChannel(sound) - } - } - + /** + * 更新主题 + * @param themeId 主题名 + */ fun updateTheme(themeId: String) { _uiState.update { it.copy(themeId = themeId) } scope.launch { @@ -225,6 +216,10 @@ object ChatSessionManager { } } + /** + * 更新语言 + * @param language 语言代码 + */ fun updateLanguage(language: String) { _uiState.update { it.copy(language = language) } scope.launch { @@ -232,6 +227,10 @@ object ChatSessionManager { } } + /** + * 更改使用动态颜色 + * @param enabled 主题名 + */ fun updateUseDynamicColor(enabled: Boolean) { _uiState.update { it.copy(useDynamicColor = enabled) } scope.launch { @@ -239,6 +238,10 @@ object ChatSessionManager { } } + /** + * 更新显示名称并持久化。 + * @param value 新名称(自动截断至 64 字符) + */ fun updateDisplayName(value: String) { val displayName = value.take(64) _uiState.update { it.copy(displayName = displayName) } @@ -247,18 +250,34 @@ object ChatSessionManager { } } + /** + * 更新当前输入的服务器地址(不持久化)。 + * @param value 新地址 + */ fun updateServerUrl(value: String) { _uiState.update { it.copy(serverUrl = value) } } + /** + * 更新私聊目标公钥。 + * @param value 公钥字符串 + */ fun updateTargetKey(value: String) { _uiState.update { it.copy(targetKey = value) } } + /** + * 更新消息草稿。 + * @param value 草稿内容 + */ fun updateDraft(value: String) { _uiState.update { it.copy(draft = value) } } + /** + * 切换广播/私聊模式并持久化。 + * @param enabled true 为私聊模式 + */ fun toggleDirectMode(enabled: Boolean) { _uiState.update { it.copy(directMode = enabled) } scope.launch { @@ -266,6 +285,10 @@ object ChatSessionManager { } } + /** + * 切换是否显示系统消息并持久化。 + * @param show true 显示 + */ fun toggleShowSystemMessages(show: Boolean) { _uiState.update { it.copy(showSystemMessages = show) } scope.launch { @@ -273,6 +296,9 @@ object ChatSessionManager { } } + /** + * 清空所有消息,并取消系统消息的过期任务。 + */ fun clearMessages() { cancelSystemMessageExpiryJobs() _uiState.update { it.copy(messages = emptyList()) } @@ -283,6 +309,9 @@ object ChatSessionManager { } } + /** + * 保存当前服务器地址到历史列表并持久化。 + */ fun saveCurrentServerUrl() { val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl) if (normalized.isBlank()) { @@ -293,12 +322,11 @@ object ChatSessionManager { } val nextUrls = ServerUrlFormatter.append(_uiState.value.serverUrls, normalized) - val lang = _uiState.value.language _uiState.update { it.copy( serverUrl = normalized, serverUrls = nextUrls, - statusHint = LanguageManager.getString("status_hint.server_saved", lang) + statusHint = "服务器地址已保存" ) } @@ -308,6 +336,10 @@ object ChatSessionManager { } } + /** + * 从历史列表中移除当前服务器地址。 + * 如果列表清空则恢复默认地址。 + */ fun removeCurrentServerUrl() { val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl) if (normalized.isBlank()) return @@ -319,16 +351,11 @@ object ChatSessionManager { filtered } - val lang = _uiState.value.language _uiState.update { it.copy( serverUrls = nextUrls, serverUrl = nextUrls.first(), - statusHint = if (filtered.isEmpty()) { - LanguageManager.getString("status_hint.server_removed_default", lang) - } else { - LanguageManager.getString("status_hint.server_removed", lang) - } + statusHint = if (filtered.isEmpty()) "已恢复默认服务器地址" else "已移除当前服务器地址" ) } @@ -338,6 +365,9 @@ object ChatSessionManager { } } + /** + * 加载或生成本地身份密钥对,并将公钥显示到 UI。 + */ fun revealMyPublicKey() { scope.launch { _uiState.update { it.copy(loadingPublicKey = true) } @@ -357,10 +387,17 @@ object ChatSessionManager { } } + /** + * 主动连接服务器(由用户点击连接触发)。 + */ fun connect() { connectInternal(isAutoRestore = false) } + /** + * 内部连接逻辑,区分自动恢复和手动连接。 + * @param isAutoRestore 是否为应用启动时的自动恢复连接 + */ private fun connectInternal(isAutoRestore: Boolean) { if (!initialized) return val state = _uiState.value @@ -368,11 +405,10 @@ object ChatSessionManager { val normalized = ServerUrlFormatter.normalize(state.serverUrl) if (normalized.isBlank()) { - val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = LanguageManager.getString("status_hint.invalid_server_url", lang) + statusHint = "请填写有效服务器地址" ) } return @@ -388,11 +424,10 @@ object ChatSessionManager { cancelHelloTimeout() cancelAuthTimeout() - val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.CONNECTING, - statusHint = LanguageManager.getString("status_hint.connecting", lang), + statusHint = "正在连接服务器...", serverUrl = normalized, certFingerprint = "" ) @@ -411,17 +446,20 @@ object ChatSessionManager { } } + /** + * 主动断开连接。 + * @param stopService 是否同时停止前台服务(默认 true) + */ fun disconnect(stopService: Boolean = true) { manualClose = true cancelReconnect() cancelHelloTimeout() cancelAuthTimeout() socketClient.close(1000, "manual_close") - val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.IDLE, - statusHint = LanguageManager.getString("status_hint.disconnected", lang) + statusHint = "连接已关闭" ) } autoReconnectTriggered = false @@ -435,6 +473,10 @@ object ChatSessionManager { addSystemMessage("已断开连接") } + /** + * 发送消息(广播或私聊)。 + * 执行签名、加密并发送。 + */ fun sendMessage() { val current = _uiState.value if (!current.canSend) return @@ -445,8 +487,7 @@ object ChatSessionManager { val key = if (_uiState.value.directMode) _uiState.value.targetKey.trim() else "" if (_uiState.value.directMode && key.isBlank()) { - val lang = _uiState.value.language - _uiState.update { it.copy(statusHint = LanguageManager.getString("status_hint.target_key_required", lang)) } + _uiState.update { it.copy(statusHint = "请先填写目标公钥,再发送私聊消息") } return@launch } @@ -493,12 +534,19 @@ object ChatSessionManager { } } + /** + * 消息复制成功后的回调,显示“已复制”提示。 + */ fun onMessageCopied() { scope.launch { _events.emit(UiEvent.ShowSnackbar("已复制")) } } + /** + * 确保本地身份已加载 or 创建。 + * @return 本地身份对象 + */ private suspend fun ensureIdentity(): RsaCryptoManager.Identity { return identityMutex.withLock { identity ?: withContext(Dispatchers.Default) { @@ -509,10 +557,13 @@ object ChatSessionManager { } } + /** + * 处理收到的原始文本消息(可能是握手包 or 加密消息)。 + * @param rawText 原始文本 + */ private suspend fun handleIncomingMessage(rawText: String) { if (_uiState.value.status == ConnectionStatus.HANDSHAKING) { - val lang = _uiState.value.language - _uiState.update { it.copy(statusHint = LanguageManager.getString("status_hint.hello_received", lang)) } + _uiState.update { it.copy(statusHint = "已收到握手数据,正在解析...") } } val normalizedText = extractJsonCandidate(rawText) @@ -520,6 +571,7 @@ object ChatSessionManager { json.decodeFromString(normalizedText) as? JsonObject }.getOrNull() + // 尝试直接解析为 HelloDataDto(某些服务器可能直接发送,不带外层) val directHello = rootObject?.let { obj -> val hasPublicKey = obj["publicKey"] != null val hasChallenge = obj["authChallenge"] != null @@ -535,6 +587,7 @@ object ChatSessionManager { return } + // 尝试解析为带外层的 EnvelopeDto val plain = runCatching { json.decodeFromString(normalizedText) }.getOrNull() if (plain?.type == "publickey") { cancelHelloTimeout() @@ -542,11 +595,10 @@ object ChatSessionManager { runCatching { json.decodeFromJsonElement(it) }.getOrNull() } if (hello == null || hello.publicKey.isBlank() || hello.authChallenge.isBlank()) { - val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = LanguageManager.getString("status_hint.handshake_failed_incomplete", lang) + statusHint = "握手失败:服务端响应不完整" ) } return @@ -555,17 +607,20 @@ object ChatSessionManager { return } + // 握手阶段收到非预期消息则报错 if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain != null) { - val lang = _uiState.value.language - _uiState.update { it.copy(statusHint = LanguageManager.getString("status_hint.handshake_failed_unexpected", lang)) } + _uiState.update { it.copy(statusHint = "握手失败:收到非预期消息") } addSystemMessage("握手阶段收到非预期消息类型:${plain.type}") } else if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain == null) { - val lang = _uiState.value.language - val preview = rawText.replace("\n", " ").replace("\r", " ").take(80) - _uiState.update { it.copy(statusHint = LanguageManager.getString("status_hint.handshake_failed_parse", lang)) } + val preview = rawText + .replace("\n", " ") + .replace("\r", " ") + .take(80) + _uiState.update { it.copy(statusHint = "握手失败:首包解析失败") } addSystemMessage("握手包解析失败:$preview") } + // 尝试解密(若已握手完成,收到的应是加密消息) val id = ensureIdentity() val decrypted = runCatching { withContext(Dispatchers.Default) { @@ -583,14 +638,17 @@ object ChatSessionManager { handleSecureMessage(secure) } + /** + * 处理服务端发来的握手 Hello 数据。 + * @param hello 服务端公钥和挑战 + */ private suspend fun handleServerHello(hello: HelloDataDto) { cancelHelloTimeout() serverPublicKey = hello.publicKey - val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.AUTHENTICATING, - statusHint = LanguageManager.getString("status_hint.authenticating", lang), + statusHint = "正在完成身份验证...", certFingerprint = hello.certFingerprintSha256.orEmpty() ) } @@ -599,11 +657,10 @@ object ChatSessionManager { authTimeoutJob = scope.launch { delay(AUTH_TIMEOUT_MS) if (_uiState.value.status == ConnectionStatus.AUTHENTICATING) { - val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = LanguageManager.getString("status_hint.auth_timeout", lang) + statusHint = "连接超时,请重试" ) } addSystemMessage("认证超时,请检查网络后重试") @@ -617,11 +674,10 @@ object ChatSessionManager { addSystemMessage("已发送认证请求") }.onFailure { error -> cancelAuthTimeout() - val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = LanguageManager.getString("status_hint.auth_failed", lang) + statusHint = "认证失败" ) } addSystemMessage("认证发送失败:${error.message ?: "unknown"}") @@ -629,6 +685,10 @@ object ChatSessionManager { } } + /** + * 发送认证消息(包含签名后的身份信息)。 + * @param challenge 服务端提供的挑战值 + */ private suspend fun sendAuth(challenge: String) { val id = ensureIdentity() val displayName = _uiState.value.displayName.trim().ifBlank { createGuestName() } @@ -672,17 +732,20 @@ object ChatSessionManager { check(socketClient.send(cipher)) { "连接不可用" } } + /** + * 处理安全通道建立后的业务消息(广播、私聊、认证结果等)。 + * @param message 解密后的 EnvelopeDto + */ private fun handleSecureMessage(message: EnvelopeDto) { when (message.type) { "auth_ok" -> { cancelAuthTimeout() cancelReconnect() reconnectAttempt = 0 - val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.READY, - statusHint = LanguageManager.getString("status_hint.ready", lang) + statusHint = "已连接,可以开始聊天" ) } addSystemMessage("连接准备完成") @@ -712,25 +775,39 @@ object ChatSessionManager { } } + /** + * 处理 WebSocket 连接关闭事件。 + * @param code 关闭状态码 + * @param reason 关闭原因 + */ private fun handleSocketClosed(code: Int, reason: String) { cancelHelloTimeout() cancelAuthTimeout() - if (manualClose || reason == "reconnect" || reconnectJob?.isActive == true) return + if (manualClose) { + return + } + if (reason == "reconnect") { + return + } + if (reconnectJob?.isActive == true) { + return + } val currentStatus = _uiState.value.status + val allowFallback = !fallbackTried && currentStatus != ConnectionStatus.READY + // 尝试切换 ws/wss 协议重试(仅限非就绪状态) if (allowFallback) { val fallbackUrl = ServerUrlFormatter.toggleWsProtocol(connectedUrl) if (fallbackUrl.isNotBlank()) { fallbackTried = true connectedUrl = fallbackUrl - val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.CONNECTING, - statusHint = LanguageManager.getString("status_hint.reconnecting", lang), + statusHint = "正在自动重试连接...", serverUrl = fallbackUrl ) } @@ -740,17 +817,20 @@ object ChatSessionManager { } } - val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = LanguageManager.getString("status_hint.connection_interrupted_retrying", lang) + statusHint = "连接已中断,正在重试" ) } addSystemMessage("连接关闭 ($code):${reason.ifBlank { "连接中断" }}") scheduleReconnect("连接已中断") } + /** + * 添加一条系统消息(自动按 TTL 过期)。 + * @param content 消息内容 + */ private fun addSystemMessage(content: String) { val message = UiMessage( role = MessageRole.SYSTEM, @@ -763,6 +843,13 @@ object ChatSessionManager { scheduleSystemMessageExpiry(message.id) } + /** + * 添加一条接收到的用户消息。 + * @param sender 发送者名称 + * @param subtitle 附加说明(如私聊来源) + * @param content 消息内容 + * @param channel 消息通道(广播/私聊) + */ private fun addIncomingMessage( sender: String, subtitle: String, @@ -784,6 +871,12 @@ object ChatSessionManager { ) } + /** + * 添加一条发出的消息。 + * @param content 消息内容 + * @param subtitle 附加说明(如私聊目标) + * @param channel 消息通道 + */ private fun addOutgoingMessage( content: String, subtitle: String, @@ -800,11 +893,15 @@ object ChatSessionManager { ) } + /** + * 将消息追加到列表尾部,并清理超出数量限制的消息。 + * @param message 要追加的消息 + */ private fun appendMessage(message: UiMessage) { _uiState.update { current -> val next = (current.messages + message).takeLast(MAX_MESSAGES) val aliveIds = next.asSequence().map { it.id }.toSet() - val removedIds = systemMessageExpiryJobs.keys.filterNot { id -> id in aliveIds } + val removedIds = systemMessageExpiryJobs.keys.filterNot { it in aliveIds } removedIds.forEach { id -> systemMessageExpiryJobs.remove(id)?.cancel() } @@ -818,28 +915,30 @@ object ChatSessionManager { } } + /** + * 取消认证超时任务。 + */ private fun cancelAuthTimeout() { authTimeoutJob?.cancel() authTimeoutJob = null } + /** + * 安排自动重连(指数退避)。 + * @param reason 触发重连的原因 + */ private fun scheduleReconnect(reason: String) { - if (manualClose || reconnectJob?.isActive == true) return + if (manualClose) return + if (reconnectJob?.isActive == true) return reconnectAttempt += 1 val exponential = 1 shl minOf(reconnectAttempt - 1, 5) val delaySeconds = minOf(MAX_RECONNECT_DELAY_SECONDS, exponential) addSystemMessage("$reason,${delaySeconds}s 后自动重连(第 $reconnectAttempt 次)") - val lang = _uiState.value.language - val hint = String.format( - LanguageManager.getString("status_hint.reconnect_countdown", lang), - delaySeconds, - reconnectAttempt - ) _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = hint + statusHint = "${delaySeconds}s 后自动重连(第 $reconnectAttempt 次)" ) } @@ -851,11 +950,10 @@ object ChatSessionManager { ServerUrlFormatter.normalize(_uiState.value.serverUrl) } if (target.isBlank()) { - val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = LanguageManager.getString("status_hint.reconnect_failed_invalid_url", lang) + statusHint = "重连失败:服务器地址无效" ) } return@launch @@ -876,11 +974,18 @@ object ChatSessionManager { } } + /** + * 取消自动重连任务。 + */ private fun cancelReconnect() { reconnectJob?.cancel() reconnectJob = null } + /** + * 为系统消息安排过期自动删除。 + * @param messageId 消息唯一 ID + */ private fun scheduleSystemMessageExpiry(messageId: String) { systemMessageExpiryJobs.remove(messageId)?.cancel() systemMessageExpiryJobs[messageId] = scope.launch { @@ -893,22 +998,27 @@ object ChatSessionManager { } } + /** + * 取消所有系统消息的过期任务。 + */ private fun cancelSystemMessageExpiryJobs() { systemMessageExpiryJobs.values.forEach { it.cancel() } systemMessageExpiryJobs.clear() } + /** + * 启动握手超时计时器。 + */ private fun startHelloTimeout() { cancelHelloTimeout() helloTimeoutJob = scope.launch { delay(HELLO_TIMEOUT_MS) if (_uiState.value.status == ConnectionStatus.HANDSHAKING) { val currentUrl = connectedUrl.ifBlank { "unknown" } - val lang = _uiState.value.language _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = LanguageManager.getString("status_hint.hello_timeout", lang) + statusHint = "握手超时,请检查地址路径与反向代理" ) } addSystemMessage("握手超时:未收到服务端 publickey 首包(当前地址:$currentUrl)") @@ -917,29 +1027,56 @@ object ChatSessionManager { } } + /** + * 取消握手超时任务。 + */ private fun cancelHelloTimeout() { helloTimeoutJob?.cancel() helloTimeoutJob = null } + /** + * 缩写显示公钥(取前后各8字符)。 + * @param key 完整公钥 + * @return 缩写字符串 + */ private fun summarizeKey(key: String): String { if (key.length <= 16) return key return "${key.take(8)}...${key.takeLast(8)}" } + /** + * 生成访客名称(如 guest-123456)。 + * @return 随机名称 + */ private fun createGuestName(): String { val rand = (100000..999999).random() return "guest-$rand" } + /** + * 从可能包含前缀的原始文本中提取 JSON 对象部分。 + * @param rawText 原始文本 + * @return 最外层的 JSON 字符串 + */ private fun extractJsonCandidate(rawText: String): String { val trimmed = rawText.trim() - if (trimmed.startsWith("{") && trimmed.endsWith("}")) return trimmed + if (trimmed.startsWith("{") && trimmed.endsWith("}")) { + return trimmed + } + val start = rawText.indexOf('{') val end = rawText.lastIndexOf('}') - return if (start in 0 until end) rawText.substring(start, end + 1) else rawText + return if (start in 0 until end) { + rawText.substring(start, end + 1) + } else { + rawText + } } + /** + * 关闭所有资源(用于应用退出时)。 + */ fun shutdownAll() { cancelSystemMessageExpiryJobs() cancelReconnect() @@ -948,6 +1085,9 @@ object ChatSessionManager { socketClient.shutdown() } + /** + * 前台服务停止时的回调。 + */ fun onForegroundServiceStopped() { keepAliveRequested = false if (_uiState.value.status != ConnectionStatus.IDLE) { @@ -959,57 +1099,52 @@ object ChatSessionManager { } } + /** + * 判断前台服务是否应该运行。 + * @return true 表示应保持服务运行 + */ fun shouldForegroundServiceRun(): Boolean = keepAliveRequested - private fun ensureMessageNotificationChannel(soundCode: String = "default") { + /** + * 创建消息通知渠道(Android O+)。 + */ + private fun ensureMessageNotificationChannel() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val manager = app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val channelId = "${MESSAGE_CHANNEL_ID}_$soundCode" - if (manager.getNotificationChannel(channelId) != null) return - - val soundUri = getSoundUri(soundCode) val channel = NotificationChannel( - channelId, + MESSAGE_CHANNEL_ID, "OnlineMsg 消息提醒", NotificationManager.IMPORTANCE_DEFAULT ).apply { description = "收到服务器新消息时提醒" - if (soundUri != null) { - setSound(soundUri, AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_NOTIFICATION) - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .build()) - } } manager.createNotificationChannel(channel) } - private fun getSoundUri(code: String): Uri? { - val resId = when(code) { - "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) - } - + /** + * 显示新消息到达的通知。 + * @param title 通知标题 + * @param body 通知正文 + */ private fun showIncomingNotification(title: String, body: String) { if (!initialized) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission(app, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED - ) return + ) { + return + } val launchIntent = Intent(app, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP } - val pendingIntent = PendingIntent.getActivity(app, 0, launchIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - - val soundCode = _uiState.value.notificationSound - val channelId = "${MESSAGE_CHANNEL_ID}_$soundCode" + val pendingIntent = PendingIntent.getActivity( + app, + 0, + launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) - val notification = NotificationCompat.Builder(app, channelId) + val notification = NotificationCompat.Builder(app, MESSAGE_CHANNEL_ID) .setSmallIcon(android.R.drawable.stat_notify_chat) .setContentTitle(title.ifBlank { "OnlineMsg" }) .setContentText(body.take(120)) @@ -1022,12 +1157,17 @@ object ChatSessionManager { NotificationManagerCompat.from(app).notify(nextMessageNotificationId(), notification) } + /** + * 生成下一个通知 ID(线程安全递增)。 + * @return 新的通知 ID + */ @Synchronized private fun nextMessageNotificationId(): Int { notificationIdSeed += 1 return notificationIdSeed } + // 常量定义 private const val HELLO_TIMEOUT_MS = 12_000L private const val AUTH_TIMEOUT_MS = 20_000L private const val MAX_MESSAGES = 500 diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt index 86f0938..c7cc58b 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt @@ -1,9 +1,6 @@ package com.onlinemsg.client.ui -import androidx.compose.runtime.getValue -import androidx.lifecycle.compose.collectAsStateWithLifecycle import java.util.UUID -import com.onlinemsg.client.util.LanguageManager /** * 连接状态枚举。 @@ -70,10 +67,7 @@ data class UiMessage( * @property myPublicKey 本地公钥 * @property sending 是否正在发送消息(用于禁用按钮) * @property loadingPublicKey 是否正在加载公钥 - * @property themeId 当前选中的主题 ID - * @property useDynamicColor 是否使用 Android 12+ 动态颜色 - * @property language 当前选择的语言代码 - * @property notificationSound 当前选择的通知音效代号 + * @property language 当前选择的语言代码 (如 "zh", "en", "ja") */ data class ChatUiState( val status: ConnectionStatus = ConnectionStatus.IDLE, @@ -92,8 +86,7 @@ data class ChatUiState( val loadingPublicKey: Boolean = false, val themeId: String = "blue", val useDynamicColor: Boolean = true, - val language: String = "zh", - val notificationSound: String = "default" + val language: String = "zh" ) { /** * 是否允许连接。 @@ -121,12 +114,12 @@ data class ChatUiState( */ val statusText: String get() = when (status) { - ConnectionStatus.IDLE -> LanguageManager.getString("status.idle", language) + ConnectionStatus.IDLE -> "未连接" ConnectionStatus.CONNECTING, ConnectionStatus.HANDSHAKING, - ConnectionStatus.AUTHENTICATING -> LanguageManager.getString("status.connecting", language) - ConnectionStatus.READY -> LanguageManager.getString("status.ready", language) - ConnectionStatus.ERROR -> LanguageManager.getString("status.error", language) + ConnectionStatus.AUTHENTICATING -> "连接中" + ConnectionStatus.READY -> "已连接" + ConnectionStatus.ERROR -> "异常断开" } /** @@ -148,4 +141,4 @@ data class ChatUiState( */ sealed interface UiEvent { data class ShowSnackbar(val message: String) : UiEvent -} +} \ No newline at end of file diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt index eb18399..8b53474 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt @@ -35,5 +35,4 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { fun updateTheme(themeId: String) = ChatSessionManager.updateTheme(themeId) fun updateUseDynamicColor(enabled: Boolean) = ChatSessionManager.updateUseDynamicColor(enabled) fun updateLanguage(language: String) = ChatSessionManager.updateLanguage(language) - fun updateNotificationSound(sound: String) = ChatSessionManager.updateNotificationSound(sound) } \ No newline at end of file diff --git a/android-client/app/src/main/java/com/onlinemsg/client/util/AudioRecorder.kt b/android-client/app/src/main/java/com/onlinemsg/client/util/AudioRecorder.kt new file mode 100644 index 0000000..11abca4 --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/util/AudioRecorder.kt @@ -0,0 +1,89 @@ +package com.onlinemsg.client.util + +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import android.util.Base64 +import java.io.File + +data class RecordedAudio( + val base64: String, + val durationMillis: Long +) + +class AudioRecorder(private val context: Context) { + private var mediaRecorder: MediaRecorder? = null + private var outputFile: File? = null + private var startedAtMillis: Long = 0L + + fun start(): Boolean { + if (mediaRecorder != null) return false + val file = runCatching { + File.createTempFile("oms_record_", ".m4a", context.cacheDir) + }.getOrNull() ?: return false + + val recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + MediaRecorder() + } + + val started = runCatching { + recorder.setAudioSource(MediaRecorder.AudioSource.MIC) + recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + recorder.setAudioChannels(1) + recorder.setAudioEncodingBitRate(24_000) + recorder.setAudioSamplingRate(16_000) + recorder.setMaxDuration(60_000) + recorder.setOutputFile(file.absolutePath) + recorder.prepare() + recorder.start() + true + }.getOrElse { + runCatching { recorder.reset() } + runCatching { recorder.release() } + file.delete() + false + } + + if (!started) return false + + mediaRecorder = recorder + outputFile = file + startedAtMillis = System.currentTimeMillis() + return true + } + + fun stopAndEncode(send: Boolean): RecordedAudio? { + val recorder = mediaRecorder ?: return null + mediaRecorder = null + val file = outputFile + outputFile = null + + runCatching { recorder.stop() } + runCatching { recorder.reset() } + runCatching { recorder.release() } + + if (!send || file == null) { + file?.delete() + return null + } + + val duration = (System.currentTimeMillis() - startedAtMillis).coerceAtLeast(0L) + val bytes = runCatching { file.readBytes() }.getOrNull() + file.delete() + + if (bytes == null || bytes.isEmpty()) return null + val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP) + return RecordedAudio(base64 = base64, durationMillis = duration) + } + + fun cancel() { + stopAndEncode(send = false) + } + + fun release() { + cancel() + } +} diff --git a/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt b/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt index 7328ee7..355c591 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 @@ -2,7 +2,7 @@ package com.onlinemsg.client.util /** * 语言管理类,统一存储应用内的多语言词条。 - * 类似于 Minecraft 的 language 文件映射。 + * 类似于 Minecraft 的语言文件映射。 */ object LanguageManager { @@ -12,7 +12,6 @@ object LanguageManager { "tab.settings" to "设置", "settings.personal" to "个人设置", "settings.display_name" to "显示名称", - "settings.chat_data" to "聊天数据", "settings.server" to "服务器", "settings.server_url" to "服务器地址", "settings.save_server" to "保存地址", @@ -30,13 +29,10 @@ object LanguageManager { "settings.current_status" to "当前状态", "settings.cert_fingerprint" to "证书指纹", "settings.show_system" to "显示系统消息", + "settings.connect" to "连接", + "settings.disconnect" to "断开", "settings.clear_msg" to "清空消息", "settings.dynamic_color" to "使用动态颜色", - "settings.notification_sound" to "通知音效", - "sound.default" to "默认", - "sound.ding" to "叮", - "sound.nameit5" to "音效5", - "sound.wind_chime" to "风铃", "chat.broadcast" to "广播", "chat.private" to "私聊", "chat.target_key" to "目标公钥", @@ -50,43 +46,13 @@ object LanguageManager { "theme.gray" to "商务灰", "theme.green" to "翠绿", "theme.red" to "绯红", - "theme.warm" to "温暖", - "status.idle" to "连接断开", - "status.connecting" to "连接中", - "status.ready" to "已连接", - "status.error" to "异常断开", - - "status_hint.ready" to "已连接,可以开始聊天", - "status_hint.click_to_connect" to "点击连接开始聊天", - "status_hint.handshaking" to "已连接,正在准备聊天...", - "status_hint.received_binary_handshake" to "收到二进制握手帧,正在尝试解析...", - "status_hint.connection_error_retrying" to "连接异常,正在重试", - "status_hint.invalid_server_url" to "请填写有效服务器地址", - "status_hint.connecting" to "正在连接服务器...", - "status_hint.disconnected" to "连接已关闭", - "status_hint.server_saved" to "服务器地址已保存", - "status_hint.server_removed_default" to "已恢复默认服务器地址", - "status_hint.server_removed" to "已移除当前服务器地址", - "status_hint.target_key_required" to "请先填写目标公钥,再发送私聊消息", - "status_hint.hello_received" to "已收到握手数据,正在解析...", - "status_hint.handshake_failed_incomplete" to "握手失败:服务端响应不完整", - "status_hint.handshake_failed_unexpected" to "握手失败:收到非预期消息", - "status_hint.handshake_failed_parse" to "握手失败:首包解析失败", - "status_hint.authenticating" to "正在完成身份验证...", - "status_hint.auth_timeout" to "连接超时,请重试", - "status_hint.auth_failed" to "认证失败", - "status_hint.reconnecting" to "正在自动重试连接...", - "status_hint.connection_interrupted_retrying" to "连接已中断,正在重试", - "status_hint.reconnect_countdown" to "%d秒后自动重连(第 %d 次)", - "status_hint.reconnect_failed_invalid_url" to "重连失败:服务器地址无效", - "status_hint.hello_timeout" to "握手超时,请检查地址路径与反向代理" + "theme.warm" to "温暖" ), "en" to mapOf( "tab.chat" to "Chat", "tab.settings" to "Settings", "settings.personal" to "Personal", "settings.display_name" to "Display Name", - "settings.chat_data" to "Chat Data", "settings.server" to "Server", "settings.server_url" to "Server Address", "settings.save_server" to "Save", @@ -104,13 +70,10 @@ object LanguageManager { "settings.current_status" to "Status", "settings.cert_fingerprint" to "Fingerprint", "settings.show_system" to "Show System Messages", + "settings.connect" to "Link", + "settings.disconnect" to "Dislink", "settings.clear_msg" to "ClearMsg", "settings.dynamic_color" to "Use dynamic color", - "settings.notification_sound" to "Notification Sound", - "sound.default" to "Default", - "sound.ding" to "Ding", - "sound.nameit5" to "Sound 5", - "sound.wind_chime" to "Wind Chime", "chat.broadcast" to "Broadcast", "chat.private" to "Private", "chat.target_key" to "Target Public Key", @@ -124,43 +87,13 @@ object LanguageManager { "theme.gray" to "Business Gray", "theme.green" to "Green", "theme.red" to "Red", - "theme.warm" to "Warm", - "status.idle" to "Idle", - "status.connecting" to "Connecting", - "status.ready" to "Connected", - "status.error" to "Error", - - "status_hint.ready" to "Ready, you can start chatting", - "status_hint.click_to_connect" to "Click connect to start chatting", - "status_hint.handshaking" to "Connected, preparing chat...", - "status_hint.received_binary_handshake" to "Received binary handshake frame, parsing...", - "status_hint.connection_error_retrying" to "Connection error, retrying", - "status_hint.invalid_server_url" to "Please enter a valid server address", - "status_hint.connecting" to "Connecting to server...", - "status_hint.disconnected" to "Connection closed", - "status_hint.server_saved" to "Server address saved", - "status_hint.server_removed_default" to "Restored default server address", - "status_hint.server_removed" to "Removed current server address", - "status_hint.target_key_required" to "Please enter target public key first", - "status_hint.hello_received" to "Handshake data received, parsing...", - "status_hint.handshake_failed_incomplete" to "Handshake failed: incomplete server response", - "status_hint.handshake_failed_unexpected" to "Handshake failed: unexpected message", - "status_hint.handshake_failed_parse" to "Handshake failed: first packet parse error", - "status_hint.authenticating" to "Authenticating...", - "status_hint.auth_timeout" to "Connection timeout, please retry", - "status_hint.auth_failed" to "Authentication failed", - "status_hint.reconnecting" to "Reconnecting...", - "status_hint.connection_interrupted_retrying" to "Connection interrupted, retrying", - "status_hint.reconnect_countdown" to "Reconnecting in %d seconds (attempt %d)", - "status_hint.reconnect_failed_invalid_url" to "Reconnect failed: invalid server address", - "status_hint.hello_timeout" to "Handshake timeout, check server path and reverse proxy" + "theme.warm" to "Warm" ), "ja" to mapOf( "tab.chat" to "チャット", "tab.settings" to "設定", "settings.personal" to "個人設定", "settings.display_name" to "表示名", - "settings.chat_data" to "チャットデータ", "settings.server" to "サーバー", "settings.server_url" to "アドレス", "settings.save_server" to "保存", @@ -178,13 +111,10 @@ object LanguageManager { "settings.current_status" to "ステータス", "settings.cert_fingerprint" to "証明書指紋", "settings.show_system" to "システムメッセージを表示", + "settings.connect" to "接続", + "settings.disconnect" to "切断", "settings.clear_msg" to "履歴を消去", "settings.dynamic_color" to "動的カラーを使用", - "settings.notification_sound" to "通知音", - "sound.default" to "デフォルト", - "sound.ding" to "ディン", - "sound.nameit5" to "音効5", - "sound.wind_chime" to "風鈴", "chat.broadcast" to "全体", "chat.private" to "個人", "chat.target_key" to "相手の公開鍵", @@ -198,43 +128,13 @@ object LanguageManager { "theme.gray" to "ビジネスグレー", "theme.green" to "グリーン", "theme.red" to "レッド", - "theme.warm" to "ウォーム", - "status.idle" to "未接続", - "status.connecting" to "接続中", - "status.ready" to "接続済み", - "status.error" to "エラー", - - "status_hint.ready" to "接続済み、チャットを開始できます", - "status_hint.click_to_connect" to "接続してチャットを開始", - "status_hint.handshaking" to "接続しました、準備中...", - "status_hint.received_binary_handshake" to "バイナリハンドシェイクを受信、解析中...", - "status_hint.connection_error_retrying" to "接続エラー、再試行中", - "status_hint.invalid_server_url" to "有効なサーバーアドレスを入力してください", - "status_hint.connecting" to "サーバーに接続中...", - "status_hint.disconnected" to "接続が切断されました", - "status_hint.server_saved" to "サーバーアドレスを保存しました", - "status_hint.server_removed_default" to "デフォルトサーバーに戻しました", - "status_hint.server_removed" to "現在のサーバーを削除しました", - "status_hint.target_key_required" to "相手の公開鍵を入力してください", - "status_hint.hello_received" to "ハンドシェイクデータを受信、解析中...", - "status_hint.handshake_failed_incomplete" to "ハンドシェイク失敗:サーバー応答が不完全", - "status_hint.handshake_failed_unexpected" to "ハンドシェイク失敗:予期しないメッセージ", - "status_hint.handshake_failed_parse" to "ハンドシェイク失敗:最初のパケット解析エラー", - "status_hint.authenticating" to "認証中...", - "status_hint.auth_timeout" to "接続タイムアウト、再試行してください", - "status_hint.auth_failed" to "認証に失敗しました", - "status_hint.reconnecting" to "自動再接続中...", - "status_hint.connection_interrupted_retrying" to "接続が切断されました、再試行中", - "status_hint.reconnect_countdown" to "%d秒後に再接続(%d回目)", - "status_hint.reconnect_failed_invalid_url" to "再接続失敗:サーバーアドレスが無効", - "status_hint.hello_timeout" to "ハンドシェイクタイムアウト、サーバーパスを確認してください" + "theme.warm" to "ウォーム" ), "ko" to mapOf( "tab.chat" to "채팅", "tab.settings" to "설정", "settings.personal" to "개인 설정", "settings.display_name" to "표시 이름", - "settings.chat_data" to "채팅 데이터", "settings.server" to "서버", "settings.server_url" to "서버 주소", "settings.save_server" to "주소 저장", @@ -252,13 +152,10 @@ object LanguageManager { "settings.current_status" to "현재 상태", "settings.cert_fingerprint" to "인증서 지문", "settings.show_system" to "시스템 메시지 표시", + "settings.connect" to "연결", + "settings.disconnect" to "연결 끊기", "settings.clear_msg" to "정보 삭제", "settings.dynamic_color" to "동적 색상 사용", - "settings.notification_sound" to "알림 소리", - "sound.default" to "기본값", - "sound.ding" to "딩", - "sound.nameit5" to "효과음 5", - "sound.wind_chime" to "풍경", "chat.broadcast" to "브로드캐스트", "chat.private" to "비공개 채팅", "chat.target_key" to "대상 공개키", @@ -272,111 +169,7 @@ object LanguageManager { "theme.gray" to "비즈니스 그레이", "theme.green" to "초록", "theme.red" to "빨강", - "theme.warm" to "따뜻함", - "status.idle" to "연결 안 됨", - "status.connecting" to "연결 중", - "status.ready" to "연결됨", - "status.error" to "오류", - - "status_hint.ready" to "연결됨, 채팅을 시작할 수 있습니다", - "status_hint.click_to_connect" to "연결하여 채팅 시작", - "status_hint.handshaking" to "연결됨, 채팅 준비 중...", - "status_hint.received_binary_handshake" to "바이너리 핸드셰이크 수신, 분석 중...", - "status_hint.connection_error_retrying" to "연결 오류, 재시도 중", - "status_hint.invalid_server_url" to "유효한 서버 주소를 입력하세요", - "status_hint.connecting" to "서버에 연결 중...", - "status_hint.disconnected" to "연결이 종료됨", - "status_hint.server_saved" to "서버 주소가 저장됨", - "status_hint.server_removed_default" to "기본 서버 주소로 복원됨", - "status_hint.server_removed" to "현재 서버 주소가 제거됨", - "status_hint.target_key_required" to "대상 공개키를 먼저 입력하세요", - "status_hint.hello_received" to "핸드셰이크 데이터 수신, 분석 중...", - "status_hint.handshake_failed_incomplete" to "핸드셰이크 실패: 서버 응답 불완전", - "status_hint.handshake_failed_unexpected" to "핸드셰이크 실패: 예상치 못한 메시지", - "status_hint.handshake_failed_parse" to "핸드셰이크 실패: 첫 패킷 구문 분석 오류", - "status_hint.authenticating" to "인증 중...", - "status_hint.auth_timeout" to "연결 시간 초과, 다시 시도하세요", - "status_hint.auth_failed" to "인증 실패", - "status_hint.reconnecting" to "자동 재연결 중...", - "status_hint.connection_interrupted_retrying" to "연결이 끊어짐, 재시도 중", - "status_hint.reconnect_countdown" to "%d초 후 자동 재연결 (시도 %d회)", - "status_hint.reconnect_failed_invalid_url" to "재연결 실패: 서버 주소가 유효하지 않음", - "status_hint.hello_timeout" to "핸드셰이크 시간 초과, 서버 경로와 리버스 프록시를 확인하세요" - ), - // 新增繁体中文 - "zh-Hant" to mapOf( - "tab.chat" to "聊天", - "tab.settings" to "設定", - "settings.personal" to "個人設定", - "settings.display_name" to "顯示名稱", - "settings.chat_data" to "聊天資料", - "settings.server" to "伺服器", - "settings.server_url" to "伺服器位址", - "settings.save_server" to "儲存位址", - "settings.remove_current" to "刪除目前", - "settings.saved_servers" to "已儲存位址", - "settings.identity" to "身份與安全", - "settings.reveal_key" to "檢視/產生公鑰", - "settings.copy_key" to "複製公鑰", - "settings.my_key" to "我的公鑰", - "settings.theme" to "主題", - "settings.preset_themes" to "預設主題", - "settings.language" to "語言", - "settings.diagnostics" to "診斷", - "settings.status_hint" to "連線提示", - "settings.current_status" to "目前狀態", - "settings.cert_fingerprint" to "憑證指紋", - "settings.show_system" to "顯示系統訊息", - "settings.clear_msg" to "清空訊息", - "settings.dynamic_color" to "使用動態顏色", - "settings.notification_sound" to "通知音效", - "sound.default" to "預設", - "sound.ding" to "叮", - "sound.nameit5" to "音效5", - "sound.wind_chime" to "風鈴", - "chat.broadcast" to "廣播", - "chat.private" to "私訊", - "chat.target_key" to "目標公鑰", - "chat.input_placeholder" to "輸入訊息", - "chat.send" to "傳送", - "chat.sending" to "傳送中", - "chat.empty_hint" to "連線後即可聊天。預設為廣播,切換到私訊後可填寫目標公鑰。", - "common.copied" to "已複製", - "common.unknown" to "未知", - "theme.blue" to "蔚藍", - "theme.gray" to "商務灰", - "theme.green" to "翠綠", - "theme.red" to "緋紅", - "theme.warm" to "溫暖", - "status.idle" to "連線中斷", - "status.connecting" to "連線中", - "status.ready" to "已連線", - "status.error" to "異常中斷", - - "status_hint.ready" to "已連線,可以開始聊天", - "status_hint.click_to_connect" to "點擊連線開始聊天", - "status_hint.handshaking" to "已連線,正在準備聊天...", - "status_hint.received_binary_handshake" to "收到二進位握手幀,正在嘗試解析...", - "status_hint.connection_error_retrying" to "連線異常,正在重試", - "status_hint.invalid_server_url" to "請填寫有效伺服器位址", - "status_hint.connecting" to "正在連線伺服器...", - "status_hint.disconnected" to "連線已關閉", - "status_hint.server_saved" to "伺服器位址已儲存", - "status_hint.server_removed_default" to "已恢復預設伺服器位址", - "status_hint.server_removed" to "已移除目前伺服器位址", - "status_hint.target_key_required" to "請先填寫目標公鑰,再傳送私訊", - "status_hint.hello_received" to "已收到握手資料,正在解析...", - "status_hint.handshake_failed_incomplete" to "握手失敗:伺服器回應不完整", - "status_hint.handshake_failed_unexpected" to "握手失敗:收到非預期訊息", - "status_hint.handshake_failed_parse" to "握手失敗:首包解析失敗", - "status_hint.authenticating" to "正在完成身份驗證...", - "status_hint.auth_timeout" to "連線超時,請重試", - "status_hint.auth_failed" to "認證失敗", - "status_hint.reconnecting" to "正在自動重試連線...", - "status_hint.connection_interrupted_retrying" to "連線已中斷,正在重試", - "status_hint.reconnect_countdown" to "%d秒後自動重連(第 %d 次)", - "status_hint.reconnect_failed_invalid_url" to "重連失敗:伺服器位址無效", - "status_hint.hello_timeout" to "握手超時,請檢查位址路徑與反向代理" + "theme.warm" to "따뜻함" ) ) @@ -385,12 +178,11 @@ object LanguageManager { } val supportedLanguages = listOf( - LanguageOption("zh", "中文简体"), - LanguageOption("zh-Hant", "繁體中文"), + LanguageOption("zh", "中文"), LanguageOption("en", "English"), LanguageOption("ja", "日本语"), LanguageOption("ko", "한국어") ) } -data class LanguageOption(val code: String, val name: String) \ No newline at end of file +data class LanguageOption(val code: String, val name: String) diff --git a/android-client/app/src/main/java/com/onlinemsg/client/util/NotificationSoundCatalog.kt b/android-client/app/src/main/java/com/onlinemsg/client/util/NotificationSoundCatalog.kt new file mode 100644 index 0000000..061fb3b --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/util/NotificationSoundCatalog.kt @@ -0,0 +1,19 @@ +package com.onlinemsg.client.util + +import com.onlinemsg.client.R + +object NotificationSoundCatalog { + val soundCodes: List = 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" +} diff --git a/android-client/app/src/main/res/drawable/ic_launcher_background.xml b/android-client/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..53e23fe --- /dev/null +++ b/android-client/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android-client/app/src/main/res/drawable/ic_launcher_foreground.xml b/android-client/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..e2da3bf --- /dev/null +++ b/android-client/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-client/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android-client/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/android-client/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android-client/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android-client/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/android-client/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android-client/app/src/main/res/mipmap/ic_launcher.xml b/android-client/app/src/main/res/mipmap/ic_launcher.xml new file mode 100644 index 0000000..ee578c9 --- /dev/null +++ b/android-client/app/src/main/res/mipmap/ic_launcher.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/android-client/app/src/main/res/mipmap/ic_launcher_round.xml b/android-client/app/src/main/res/mipmap/ic_launcher_round.xml new file mode 100644 index 0000000..ee578c9 --- /dev/null +++ b/android-client/app/src/main/res/mipmap/ic_launcher_round.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/web-client/src/App.jsx b/web-client/src/App.jsx index a6f627b..650b588 100644 --- a/web-client/src/App.jsx +++ b/web-client/src/App.jsx @@ -23,6 +23,15 @@ const STORAGE_CURRENT_SERVER_URL_KEY = "oms_current_server_url"; const MAX_SERVER_URLS = 8; const CHANNEL_BROADCAST = "broadcast"; const CHANNEL_PRIVATE = "private"; +const CONTENT_TEXT = "text"; +const CONTENT_AUDIO = "audio"; +const AUDIO_MESSAGE_PREFIX = "[[OMS_AUDIO_V1]]"; +const AUDIO_CHUNK_MESSAGE_PREFIX = "[[OMS_AUDIO_CHUNK_V1]]"; +const AUDIO_CHUNK_BASE64_SIZE = 20_000; +const MAX_AUDIO_CHUNK_COUNT = 30; +const AUDIO_CHUNK_BUFFER_TTL_MS = 180_000; +const MIN_AUDIO_DURATION_MS = 350; +const AUDIO_CANCEL_TRIGGER_PX = 96; function isLikelyLocalHost(host) { const value = (host || "").toLowerCase(); @@ -156,6 +165,118 @@ function summarizeKey(key = "") { return `${key.slice(0, 8)}...${key.slice(-8)}`; } +function asPayloadText(data) { + if (typeof data === "string") return data; + if (data == null) return ""; + if (typeof data === "object" && typeof data.payload === "string") { + return data.payload; + } + return String(data); +} + +function splitAudioBase64(base64, chunkSize) { + if (!base64 || chunkSize <= 0) return []; + if (base64.length <= chunkSize) return [base64]; + const chunks = []; + for (let i = 0; i < base64.length; i += chunkSize) { + chunks.push(base64.slice(i, i + chunkSize)); + } + return chunks; +} + +function parseAudioPayload(payloadText) { + if (!payloadText.startsWith(AUDIO_MESSAGE_PREFIX)) return null; + const encoded = payloadText.slice(AUDIO_MESSAGE_PREFIX.length).trim(); + if (!encoded) return null; + const parsed = safeJsonParse(encoded); + if (!parsed || !parsed.data) { + return null; + } + const encoding = String(parsed.encoding || "base64").toLowerCase(); + if (encoding !== "base64") { + return null; + } + return { + mimeType: parsed.mimeType || "audio/mp4", + durationMillis: Number(parsed.durationMillis) || 0, + data: String(parsed.data || "") + }; +} + +function parseAudioChunkPayload(payloadText) { + if (!payloadText.startsWith(AUDIO_CHUNK_MESSAGE_PREFIX)) return null; + const encoded = payloadText.slice(AUDIO_CHUNK_MESSAGE_PREFIX.length).trim(); + if (!encoded) return null; + const parsed = safeJsonParse(encoded); + if (!parsed) { + return null; + } + const encoding = String(parsed.encoding || "base64").toLowerCase(); + if (encoding !== "base64") { + return null; + } + const total = Number(parsed.total); + const index = Number(parsed.index); + const messageId = String(parsed.messageId || ""); + const data = String(parsed.data || ""); + if (!messageId || !data || !Number.isInteger(total) || !Number.isInteger(index)) return null; + if (total < 1 || total > MAX_AUDIO_CHUNK_COUNT || index < 0 || index >= total) return null; + return { + mimeType: parsed.mimeType || "audio/mp4", + messageId, + index, + total, + durationMillis: Number(parsed.durationMillis) || 0, + data + }; +} + +function formatAudioDuration(durationMillis) { + const totalSeconds = Math.max(0, Math.floor((Number(durationMillis) || 0) / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return minutes > 0 ? `${minutes}:${String(seconds).padStart(2, "0")}` : `${seconds}s`; +} + +function formatRecordingElapsed(durationMillis) { + const clamped = Math.max(0, Number(durationMillis) || 0); + const seconds = Math.floor(clamped / 1000); + const tenths = Math.floor((clamped % 1000) / 100); + return `${seconds}.${tenths}s`; +} + +function base64ToBytes(base64) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function blobToBase64(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = String(reader.result || ""); + const marker = "base64,"; + const index = result.indexOf(marker); + resolve(index >= 0 ? result.slice(index + marker.length) : result); + }; + reader.onerror = () => reject(reader.error || new Error("读取录音失败")); + reader.readAsDataURL(blob); + }); +} + +function pickRecordingMimeType() { + if (typeof MediaRecorder === "undefined" || typeof MediaRecorder.isTypeSupported !== "function") { + return ""; + } + // Android 客户端当前仅稳定兼容 mp4/aac 语音载荷 + const candidates = ["audio/mp4;codecs=mp4a.40.2", "audio/mp4"]; + return candidates.find((item) => MediaRecorder.isTypeSupported(item)) || ""; +} + function createLocalId() { const c = globalThis.crypto; if (c?.randomUUID) { @@ -206,6 +327,14 @@ export default function App() { const targetComposingRef = useRef(false); const messageListRef = useRef(null); const stickToBottomRef = useRef(true); + const incomingAudioChunkBuffersRef = useRef(new Map()); + const audioPlayerRef = useRef(null); + const audioObjectUrlRef = useRef(""); + const recordingSessionRef = useRef(null); + const recordingTimerRef = useRef(0); + const recordingStartedAtRef = useRef(0); + const recordPressDownYRef = useRef(0); + const transientStatusTimerRef = useRef(0); const [status, setStatus] = useState("idle"); const [statusHint, setStatusHint] = useState("点击连接开始聊天"); @@ -219,6 +348,11 @@ export default function App() { const [messages, setMessages] = useState([]); const [showSystemMessages, setShowSystemMessages] = useState(false); const [sending, setSending] = useState(false); + const [inputMode, setInputMode] = useState(CONTENT_TEXT); + const [isRecording, setIsRecording] = useState(false); + const [cancelOnRelease, setCancelOnRelease] = useState(false); + const [recordingElapsedMillis, setRecordingElapsedMillis] = useState(0); + const [playingMessageId, setPlayingMessageId] = useState(""); const [certFingerprint, setCertFingerprint] = useState(""); const [myPublicKey, setMyPublicKey] = useState(""); const [publicKeyBusy, setPublicKeyBusy] = useState(false); @@ -230,6 +364,7 @@ export default function App() { const canConnect = status === "idle" || status === "error"; const canDisconnect = status !== "idle" && status !== "error"; const canSend = isConnected && draft.trim().length > 0 && !sending; + const canHoldToRecord = isConnected && !sending && (!directMode || targetKey.trim().length > 0); const activeChannel = directMode ? CHANNEL_PRIVATE : CHANNEL_BROADCAST; const mobileConnectText = useMemo(() => { if (status === "ready") return "已连接"; @@ -309,6 +444,44 @@ export default function App() { clearTimeout(messageCopyTimerRef.current); messageCopyTimerRef.current = 0; } + if (recordingTimerRef.current) { + clearInterval(recordingTimerRef.current); + recordingTimerRef.current = 0; + } + if (transientStatusTimerRef.current) { + clearTimeout(transientStatusTimerRef.current); + transientStatusTimerRef.current = 0; + } + if (recordingSessionRef.current) { + try { + if (recordingSessionRef.current.recorder?.state !== "inactive") { + recordingSessionRef.current.recorder.stop(); + } + } catch { + // ignore + } + recordingSessionRef.current.stream?.getTracks?.().forEach((track) => { + try { + track.stop(); + } catch { + // ignore + } + }); + recordingSessionRef.current = null; + } + if (audioPlayerRef.current) { + try { + audioPlayerRef.current.pause(); + } catch { + // ignore + } + audioPlayerRef.current.src = ""; + audioPlayerRef.current = null; + } + if (audioObjectUrlRef.current) { + URL.revokeObjectURL(audioObjectUrlRef.current); + audioObjectUrlRef.current = ""; + } }; }, []); @@ -362,7 +535,7 @@ export default function App() { ]); } - function pushIncoming(sender, text, subtitle = "", channel = CHANNEL_BROADCAST) { + function pushIncoming(sender, text, subtitle = "", channel = CHANNEL_BROADCAST, options = {}) { setMessages((prev) => [ ...prev, { @@ -372,12 +545,16 @@ export default function App() { subtitle, channel, content: text, + contentType: options.contentType || CONTENT_TEXT, + audioBase64: options.audioBase64 || "", + audioDurationMillis: Number(options.audioDurationMillis) || 0, + audioMimeType: options.audioMimeType || "", ts: Date.now() } ]); } - function pushOutgoing(text, subtitle = "", channel = CHANNEL_BROADCAST) { + function pushOutgoing(text, subtitle = "", channel = CHANNEL_BROADCAST, options = {}) { setMessages((prev) => [ ...prev, { @@ -387,11 +564,47 @@ export default function App() { subtitle, channel, content: text, + contentType: options.contentType || CONTENT_TEXT, + audioBase64: options.audioBase64 || "", + audioDurationMillis: Number(options.audioDurationMillis) || 0, + audioMimeType: options.audioMimeType || "", ts: Date.now() } ]); } + function pushIncomingAudio(sender, subtitle, audioBase64, durationMillis, mimeType, channel) { + pushIncoming(sender, "语音消息", subtitle, channel, { + contentType: CONTENT_AUDIO, + audioBase64, + audioDurationMillis: durationMillis, + audioMimeType: mimeType + }); + } + + function pushOutgoingAudio(subtitle, audioBase64, durationMillis, mimeType, channel) { + pushOutgoing("语音消息", subtitle, channel, { + contentType: CONTENT_AUDIO, + audioBase64, + audioDurationMillis: durationMillis, + audioMimeType: mimeType + }); + } + + function showTransientStatusHint(text, durationMs = 2200) { + setStatusHint(text); + if (transientStatusTimerRef.current) { + clearTimeout(transientStatusTimerRef.current); + transientStatusTimerRef.current = 0; + } + transientStatusTimerRef.current = window.setTimeout(() => { + transientStatusTimerRef.current = 0; + if (statusRef.current === "ready") { + setStatusHint("已连接,可以开始聊天"); + } + }, durationMs); + } + async function ensureIdentity() { if (identityRef.current) { return identityRef.current; @@ -484,6 +697,399 @@ export default function App() { }, 1600); } + function purgeExpiredAudioChunkBuffers(nowMillis = Date.now()) { + const map = incomingAudioChunkBuffersRef.current; + if (!map.size) return; + for (const [key, value] of map.entries()) { + if (nowMillis - value.createdAtMillis >= AUDIO_CHUNK_BUFFER_TTL_MS) { + map.delete(key); + } + } + } + + function ingestIncomingAudioChunk(sender, subtitle, channel, chunk) { + const now = Date.now(); + purgeExpiredAudioChunkBuffers(now); + const key = `${channel}:${sender}:${chunk.messageId}`; + const map = incomingAudioChunkBuffersRef.current; + const existing = map.get(key); + const active = + !existing || existing.total !== chunk.total + ? { + sender, + subtitle, + channel, + total: chunk.total, + durationMillis: Math.max(0, Number(chunk.durationMillis) || 0), + mimeType: chunk.mimeType || "audio/mp4", + createdAtMillis: now, + chunks: Array.from({ length: chunk.total }, () => "") + } + : existing; + + if (!existing || existing.total !== chunk.total) { + map.set(key, active); + } else if (existing.sender !== sender || existing.channel !== channel) { + return; + } + + active.chunks[chunk.index] = chunk.data; + if (active.chunks.some((part) => !part)) return; + map.delete(key); + const merged = active.chunks.join(""); + if (!merged) return; + pushIncomingAudio( + active.sender, + active.subtitle, + merged, + active.durationMillis, + active.mimeType, + active.channel + ); + } + + function releaseAudioObjectUrl() { + if (!audioObjectUrlRef.current) return; + URL.revokeObjectURL(audioObjectUrlRef.current); + audioObjectUrlRef.current = ""; + } + + function stopAudioPlayback() { + if (audioPlayerRef.current) { + try { + audioPlayerRef.current.pause(); + } catch { + // ignore + } + audioPlayerRef.current.currentTime = 0; + audioPlayerRef.current.src = ""; + } + releaseAudioObjectUrl(); + setPlayingMessageId(""); + } + + async function togglePlayAudioMessage(item) { + if (!item?.audioBase64) return; + if (playingMessageId === item.id) { + stopAudioPlayback(); + return; + } + stopAudioPlayback(); + try { + const bytes = base64ToBytes(item.audioBase64); + if (!bytes.length) { + pushSystem("语音播放失败:空数据"); + return; + } + const mimeType = item.audioMimeType || "audio/mp4"; + const blob = new Blob([bytes], { type: mimeType }); + const url = URL.createObjectURL(blob); + releaseAudioObjectUrl(); + audioObjectUrlRef.current = url; + + if (!audioPlayerRef.current) { + audioPlayerRef.current = new Audio(); + } + const player = audioPlayerRef.current; + player.onended = () => { + stopAudioPlayback(); + }; + player.onerror = () => { + stopAudioPlayback(); + pushSystem("语音播放失败:浏览器不支持该音频格式"); + }; + player.src = url; + await player.play(); + setPlayingMessageId(item.id); + } catch (error) { + stopAudioPlayback(); + pushSystem(`语音播放失败:${error?.message || "unknown error"}`); + } + } + + async function sendSignedPayload(type, key, payloadText) { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) { + throw new Error("连接不可用"); + } + const identity = identityRef.current; + const serverPublicKey = serverPublicKeyRef.current; + if (!identity || !serverPublicKey) { + throw new Error("身份或服务端公钥未就绪"); + } + + const timestamp = unixSecondsNow(); + const nonce = createNonce(); + const signInput = [type, key, payloadText, timestamp, nonce].join("\n"); + const signature = await signText(identity.signPrivateKey, signInput); + + const envelope = { + type, + key, + data: { + payload: payloadText, + timestamp, + nonce, + signature + } + }; + + const cipher = await rsaEncryptChunked(serverPublicKey, JSON.stringify(envelope)); + ws.send(cipher); + } + + async function sendAudioMessage(audioBase64, durationMillis, mimeType = "audio/mp4") { + if (!isConnected || sending) return; + const normalized = String(audioBase64 || "").trim(); + if (!normalized) return; + + const key = directMode ? targetKey.trim() : ""; + if (directMode && !key) { + showTransientStatusHint("请先填写目标公钥,再发送私聊消息"); + return; + } + const type = key ? "forward" : "broadcast"; + const channel = key ? CHANNEL_PRIVATE : CHANNEL_BROADCAST; + const subtitle = key ? `私聊 ${summarizeKey(key)}` : ""; + const safeDuration = Math.max(0, Number(durationMillis) || 0); + const chunks = splitAudioBase64(normalized, AUDIO_CHUNK_BASE64_SIZE); + if (chunks.length > MAX_AUDIO_CHUNK_COUNT) { + showTransientStatusHint("语音过长,超过可发送分片上限"); + pushSystem("语音过长,已取消发送"); + return; + } + + setSending(true); + try { + if (chunks.length === 1) { + const payloadText = + AUDIO_MESSAGE_PREFIX + + JSON.stringify({ + version: 1, + encoding: "base64", + mimeType: mimeType || "audio/mp4", + durationMillis: safeDuration, + data: normalized + }); + await sendSignedPayload(type, key, payloadText); + } else { + const messageId = createLocalId(); + for (let i = 0; i < chunks.length; i += 1) { + const payloadText = + AUDIO_CHUNK_MESSAGE_PREFIX + + JSON.stringify({ + version: 1, + encoding: "base64", + mimeType: mimeType || "audio/mp4", + messageId, + index: i, + total: chunks.length, + durationMillis: safeDuration, + data: chunks[i] + }); + await sendSignedPayload(type, key, payloadText); + } + } + + pushOutgoingAudio(subtitle, normalized, safeDuration, mimeType || "audio/mp4", channel); + } catch (error) { + const message = error?.message || "unknown error"; + showTransientStatusHint(`语音发送失败:${message}`); + pushSystem(`语音发送失败:${message}`); + } finally { + setSending(false); + } + } + + function clearRecordingTick() { + if (!recordingTimerRef.current) return; + clearInterval(recordingTimerRef.current); + recordingTimerRef.current = 0; + } + + async function startRecording() { + if (recordingSessionRef.current || isRecording) return; + if (!canHoldToRecord) { + if (directMode && !targetKey.trim()) { + showTransientStatusHint("请先填写目标公钥,再发送私聊消息"); + } + return; + } + if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") { + showTransientStatusHint("当前浏览器不支持语音录制"); + pushSystem("语音录制失败:浏览器不支持 MediaRecorder"); + return; + } + + let stream; + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch (error) { + showTransientStatusHint("请先授予麦克风权限"); + pushSystem(`语音录制失败:${error?.message || "未获得权限"}`); + return; + } + + const mimeType = pickRecordingMimeType(); + if (!mimeType) { + stream.getTracks().forEach((track) => { + try { + track.stop(); + } catch { + // ignore + } + }); + showTransientStatusHint("当前浏览器不支持 MP4 语音录制"); + pushSystem("语音录制失败:当前浏览器不支持 MP4/AAC 编码,Android 端可能无法播放"); + return; + } + const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); + const session = { + stream, + recorder, + chunks: [], + mimeType: recorder.mimeType || "audio/mp4", + sendOnStop: true + }; + recordingSessionRef.current = session; + recordingStartedAtRef.current = Date.now(); + setRecordingElapsedMillis(0); + setIsRecording(true); + setCancelOnRelease(false); + clearRecordingTick(); + recordingTimerRef.current = window.setInterval(() => { + setRecordingElapsedMillis(Math.max(0, Date.now() - recordingStartedAtRef.current)); + }, 100); + + recorder.ondataavailable = (event) => { + if (event.data && event.data.size > 0) { + session.chunks.push(event.data); + } + }; + recorder.onerror = () => { + showTransientStatusHint("录音失败,请重试"); + pushSystem("语音录制失败:MediaRecorder 发生错误"); + }; + recorder.onstop = async () => { + clearRecordingTick(); + const recordedDuration = Math.max(0, Date.now() - recordingStartedAtRef.current); + recordingStartedAtRef.current = 0; + setIsRecording(false); + setCancelOnRelease(false); + setRecordingElapsedMillis(0); + recordingSessionRef.current = null; + session.stream.getTracks().forEach((track) => { + try { + track.stop(); + } catch { + // ignore + } + }); + + if (!session.sendOnStop) { + showTransientStatusHint("已取消语音发送", 1600); + return; + } + if (recordedDuration < MIN_AUDIO_DURATION_MS) { + showTransientStatusHint("录音时间太短", 1800); + return; + } + if (session.chunks.length === 0) { + showTransientStatusHint("录音失败,请重试"); + pushSystem("语音录制失败:未采集到音频数据"); + return; + } + + try { + const blob = new Blob(session.chunks, { type: session.mimeType || "audio/webm" }); + const base64 = await blobToBase64(blob); + await sendAudioMessage(base64, recordedDuration, session.mimeType || "audio/webm"); + } catch (error) { + showTransientStatusHint("录音失败,请重试"); + pushSystem(`语音录制失败:${error?.message || "unknown error"}`); + } + }; + + try { + recorder.start(); + } catch (error) { + clearRecordingTick(); + recordingSessionRef.current = null; + recordingStartedAtRef.current = 0; + setIsRecording(false); + setCancelOnRelease(false); + setRecordingElapsedMillis(0); + stream.getTracks().forEach((track) => { + try { + track.stop(); + } catch { + // ignore + } + }); + showTransientStatusHint("录音失败,请重试"); + pushSystem(`语音录制失败:${error?.message || "unknown error"}`); + return; + } + } + + function finishRecording(send) { + const session = recordingSessionRef.current; + if (!session) return; + session.sendOnStop = Boolean(send); + clearRecordingTick(); + setIsRecording(false); + setRecordingElapsedMillis(0); + try { + if (session.recorder.state !== "inactive") { + session.recorder.stop(); + } + } catch { + session.stream.getTracks().forEach((track) => { + try { + track.stop(); + } catch { + // ignore + } + }); + recordingSessionRef.current = null; + showTransientStatusHint("录音失败,请重试"); + } + } + + async function onHoldToTalkPointerDown(event) { + event.preventDefault(); + recordPressDownYRef.current = event.clientY; + setCancelOnRelease(false); + try { + event.currentTarget.setPointerCapture?.(event.pointerId); + } catch { + // ignore + } + await startRecording(); + } + + function onHoldToTalkPointerMove(event) { + if (!isRecording) return; + const deltaY = recordPressDownYRef.current - event.clientY; + setCancelOnRelease(deltaY > AUDIO_CANCEL_TRIGGER_PX); + } + + function onHoldToTalkPointerUp(event) { + if (!isRecording) return; + const shouldSend = !cancelOnRelease; + finishRecording(shouldSend); + try { + event.currentTarget.releasePointerCapture?.(event.pointerId); + } catch { + // ignore + } + } + + function onHoldToTalkPointerCancel() { + if (!isRecording) return; + finishRecording(false); + } + async function connect() { if (!canConnect) return; const cryptoIssue = getCryptoIssueMessage(); @@ -579,6 +1185,10 @@ export default function App() { function disconnect() { manualCloseRef.current = true; + if (isRecording) { + finishRecording(false); + } + stopAudioPlayback(); if (wsRef.current) { wsRef.current.close(); wsRef.current = null; @@ -688,13 +1298,52 @@ export default function App() { } if (message.type === "broadcast") { - pushIncoming(message.key || "匿名用户", String(message.data ?? ""), "", CHANNEL_BROADCAST); + const sender = message.key || "匿名用户"; + const payloadText = asPayloadText(message.data); + const audioChunk = parseAudioChunkPayload(payloadText); + if (audioChunk) { + ingestIncomingAudioChunk(sender, "", CHANNEL_BROADCAST, audioChunk); + return; + } + const audio = parseAudioPayload(payloadText); + if (audio) { + pushIncomingAudio( + sender, + "", + audio.data, + audio.durationMillis, + audio.mimeType || "audio/mp4", + CHANNEL_BROADCAST + ); + } else { + pushIncoming(sender, payloadText, "", CHANNEL_BROADCAST); + } return; } if (message.type === "forward") { + const sourceKey = String(message.key || ""); const sender = "私聊消息"; - pushIncoming(sender, String(message.data ?? ""), "", CHANNEL_PRIVATE); + const subtitle = sourceKey ? `来自 ${summarizeKey(sourceKey)}` : ""; + const payloadText = asPayloadText(message.data); + const audioChunk = parseAudioChunkPayload(payloadText); + if (audioChunk) { + ingestIncomingAudioChunk(sender, subtitle, CHANNEL_PRIVATE, audioChunk); + return; + } + const audio = parseAudioPayload(payloadText); + if (audio) { + pushIncomingAudio( + sender, + subtitle, + audio.data, + audio.durationMillis, + audio.mimeType || "audio/mp4", + CHANNEL_PRIVATE + ); + } else { + pushIncoming(sender, payloadText, subtitle, CHANNEL_PRIVATE); + } return; } @@ -727,24 +1376,7 @@ export default function App() { setSending(true); try { - const timestamp = unixSecondsNow(); - const nonce = createNonce(); - const signInput = [type, key, text, timestamp, nonce].join("\n"); - const signature = await signText(identity.signPrivateKey, signInput); - - const envelope = { - type, - key, - data: { - payload: text, - timestamp, - nonce, - signature - } - }; - - const cipher = await rsaEncryptChunked(serverPublicKey, JSON.stringify(envelope)); - ws.send(cipher); + await sendSignedPayload(type, key, text); pushOutgoing(text, subtitle, channel); setDraft(""); } catch (error) { @@ -921,17 +1553,43 @@ export default function App() { ) : ( <> -
- {item.sender} - {item.subtitle ? {item.subtitle} : null} - -
-

{item.content}

-
- -
+ {(() => { + const isAudioMessage = item.contentType === CONTENT_AUDIO && item.audioBase64; + return ( + <> +
+ {item.sender} + {item.subtitle ? {item.subtitle} : null} + +
+ {isAudioMessage ? ( + + ) : ( +

{item.content}

+ )} + {!isAudioMessage ? ( +
+ +
+ ) : null} + + ); + })()} )} @@ -939,25 +1597,64 @@ export default function App() { )} -
-
-