diff --git a/.gitignore b/.gitignore index 25d92c4..c2a2466 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ web-client/dist/ web-client/.vite deploy/certs/ deploy/keys/ +AGENTS.md # macOS metadata .DS_Store diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..90d67ef --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,179 @@ +# Contributing Guide + +本仓库已经同时包含服务端、Android 客户端、Web 客户端和本地部署脚本。后续协作必须按下面的规则执行,避免再次出现分支漂移、协议不一致、WIP PR 长期悬挂、本地调试资产误入版本库等问题。 + +## 基本原则 + +1. `main` 只接收可合并、可验证、可发布的代码。 +2. 一个 `issue` 对应一个主题分支,不要在同一分支里混入多个不相关需求。 +3. 本地调试资产不进 Git,包括证书、密钥、构建产物、个人环境配置。 +4. 任何跨端协议改动都必须做双端联调,不能只改单边实现。 +5. 没做完的工作不要以普通可合并 PR 的形式挂着;未完成状态请使用 Draft 或继续在本地分支开发。 + +## 分支规则 + +1. 新功能或修复必须从最新 `main` 切出: + +```bash +git fetch origin +git checkout main +git pull --ff-only origin main +git checkout -b emilia-t/issue-- +``` + +2. 分支名建议包含作者、issue、主题,例如: + - `emilia-t/issue-7-audio-message` + - `emilia-t/issue-12-notification-sound` + - `emilia-t/issue-18-server-switching` +3. 不要长期复用一个旧分支持续堆新需求。 +4. 合并前必须再次同步 `main`,优先用 `rebase`,避免无意义 merge commit: + +```bash +git fetch origin +git rebase origin/main +``` + +## 提交规则 + +1. 提交应当聚焦单一目的,避免一个提交同时混入协议、UI、美术、文档、重构。 +2. 提交信息必须带范围和目的,推荐格式: + - `feat(android): add voice message playback` + - `fix(web): parse Android audio payload correctly` + - `docs: document local debug CA setup` + - `chore(server): tighten message size validation` +3. 不要使用 `update`、`misc`、`fix bug` 这类无信息量的提交说明。 +4. 提交前先看一遍差异,确认没有把临时调试代码、个人地址、日志打印一起带上。 + +## PR 规则 + +1. PR 标题必须描述结果,不要长期保留 `WIP:` 前缀。 +2. 未完成内容请使用 Draft PR;准备好评审后再转正式 PR。 +3. PR 描述至少写清楚以下内容: + - 改了什么 + - 为什么改 + - 怎么验证 + - 是否影响协议、兼容性或部署方式 +4. 如果 PR 和目标分支冲突,提交人必须先在本地解决冲突,再更新 PR。 +5. 如果发现 PR 只包含部分有效改动,而剩余部分与当前主线冲突或重复,应当把有效增量摘到新提交中,而不是强行点合并。 + +## 验证要求 + +提交 PR 前,至少执行与你改动范围对应的验证命令,并在 PR 描述里写明结果。 + +### 服务端 + +根据改动类型至少做一项: + +```bash +dotnet build +dotnet test +``` + +如果改动涉及消息大小、限流、鉴权、peer 转发或 TLS/WSS,请补充实际联调结果。 + +### Android 客户端 + +```bash +cd android-client +./gradlew :app:assembleDebug +``` + +如果改动涉及以下内容,还需要额外验证: + +- 语音消息:Android 发 -> Web 收;Web 发 -> Android 收 +- 通知:前台/后台通知、系统通知开关、通知音效预览 +- 服务器切换:切换后自动重连、聊天记录按服务器隔离 +- WSS 联调:确认 `deploy/certs/android-local/local_ca.crt` 能被 debug 包信任 + +### Web 客户端 + +```bash +cd web-client +npm install +npm run build +``` + +如果改动涉及录音、播放或协议兼容,还要验证: + +- Web 发语音 -> Android 收 +- Android 发语音 -> Web 收 +- 分片消息能正确重组 + +## 协议改动规则 + +本项目最容易出问题的部分是“服务端、Android、Web 三端协议不同步”。任何协议改动必须遵守以下要求: + +1. 明确改动的是哪一层: + - 握手 + - 鉴权签名 + - 业务包结构 + - 语音标签或分片 + - 消息大小限制 +2. 修改协议后,至少检查: + - Android 客户端 + - Web 客户端 + - 服务端解析或转发逻辑 +3. 如需兼容旧客户端,必须写清楚退化策略。 +4. 不允许只在某一端“默认推断”字段而不在代码或文档中说明。 + +## 本地调试资产规则 + +以下内容只允许本地存在,不允许提交到版本库: + +- `deploy/certs/` +- `deploy/keys/` +- 本地生成的 `server.pfx` +- `android-client/app/build/` +- `web-client/dist/` +- 调试日志、抓包、临时脚本 + +Android debug 本地 CA 信任的约定如下: + +1. 本地 CA 证书路径固定为 `deploy/certs/android-local/local_ca.crt` +2. 该路径已被 `.gitignore` 覆盖 +3. `android-client/app/build.gradle.kts` 会在 `assembleDebug` 时动态生成 debug-only `networkSecurityConfig` +4. release 构建不会信任本地 CA + +## 冲突处理建议 + +如果 PR 已经明显落后于 `main`,不要直接在平台上硬点合并。正确做法: + +1. 本地同步最新 `main` +2. 手动解决冲突 +3. 重新跑验证 +4. 再推回分支 + +如果发现 PR 中一部分内容已经被主线吸收,另一部分仍有价值,优先做“摘取有效增量”,不要把旧分支整条强行合进来。 + +## Issue 与关闭规则 + +1. 处理 issue 前先确认需求边界,避免顺手扩需求。 +2. 做完后在 issue 或 PR 中写清楚: + - 实际完成内容 + - 验证方式 + - 仍未解决的边界 +3. 关闭 issue 前,确认代码已经合入目标分支,而不是只停留在本地或个人分支。 + +## 推荐工作流 + +1. 从最新 `main` 切分支 +2. 只做单一目标改动 +3. 本地自测 +4. 更新必要文档 +5. 同步最新 `main` +6. 再次验证 +7. 提交 PR +8. 评审完成后合并 +9. 删除分支 + +## 最低合并清单 + +合并前至少确认以下事项全部满足: + +- 改动范围单一且清晰 +- 无本地调试资产入库 +- 与改动相关的构建命令已通过 +- 协议变更已做跨端验证 +- 文档已补齐 +- PR 无未处理冲突 +- PR 标题和描述可读,不是 `WIP` 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/MessageConverter.cs b/Common/MessageConverter.cs index e29599a..94e8dc7 100644 --- a/Common/MessageConverter.cs +++ b/Common/MessageConverter.cs @@ -22,6 +22,7 @@ namespace OnlineMsgServer.Common "publickey" => JsonSerializer.Deserialize(root.GetRawText(), options), "forward" => JsonSerializer.Deserialize(root.GetRawText(), options), "broadcast" => JsonSerializer.Deserialize(root.GetRawText(), options), + "rename" => JsonSerializer.Deserialize(root.GetRawText(), options), _ => null }; return message ?? throw new JsonException($"{instruct} 反序列化失败"); @@ -49,4 +50,4 @@ namespace OnlineMsgServer.Common writer.WriteEndObject(); } } -} \ No newline at end of file +} diff --git a/Common/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/RenameMessage.cs b/Common/RenameMessage.cs new file mode 100644 index 0000000..31f2458 --- /dev/null +++ b/Common/RenameMessage.cs @@ -0,0 +1,104 @@ +using OnlineMsgServer.Core; +using WebSocketSharp.Server; + +namespace OnlineMsgServer.Common +{ + class RenameMessage : Message + { + public RenameMessage() + { + Type = "rename"; + Key = ""; + } + + public override Task Handler(string wsid, WebSocketSessionManager Sessions) + { + return Task.Run(() => + { + try + { + if (!UserService.IsAuthenticated(wsid)) + { + Log.Security("rename_denied_unauthenticated", $"wsid={wsid}"); + return; + } + + string key = Key?.Trim() ?? ""; + if (!SignedMessagePayload.TryParse(Data, out SignedMessagePayload payload, out string parseError)) + { + Log.Security("rename_payload_invalid", $"wsid={wsid} reason={parseError}"); + SendEncryptedResult(Sessions, wsid, "rename_error", "rename payload invalid"); + return; + } + + if (!SecurityValidator.VerifySignedMessage(wsid, Type, key, payload, out string securityReason)) + { + Log.Security("rename_security_failed", $"wsid={wsid} reason={securityReason}"); + SendEncryptedResult(Sessions, wsid, "rename_error", "rename signature invalid"); + return; + } + + string nextName = payload.Payload.Trim(); + if (string.IsNullOrWhiteSpace(nextName)) + { + Log.Security("rename_invalid_name", $"wsid={wsid} reason=blank"); + SendEncryptedResult(Sessions, wsid, "rename_error", "display name cannot be empty"); + return; + } + + if (!UserService.IsPeerNodeSession(wsid) && PeerNetworkService.IsPeerUserName(nextName)) + { + Log.Security("rename_invalid_name", $"wsid={wsid} reason=peer_prefix"); + SendEncryptedResult(Sessions, wsid, "rename_error", "display name uses reserved prefix"); + return; + } + + if (!UserService.TryUpdateUserName(wsid, nextName, out string appliedName)) + { + Log.Security("rename_update_failed", $"wsid={wsid}"); + SendEncryptedResult(Sessions, wsid, "rename_error", "display name update failed"); + return; + } + + Log.Security("rename_success", $"wsid={wsid} user={appliedName}"); + SendEncryptedResult(Sessions, wsid, "rename_ok", appliedName); + } + catch (Exception ex) + { + Log.Security("rename_error", $"wsid={wsid} error={ex.Message}"); + SendEncryptedResult(Sessions, wsid, "rename_error", "display name update failed"); + } + }); + } + + private static void SendEncryptedResult( + WebSocketSessionManager sessions, + string wsid, + string type, + string data + ) + { + string? publicKey = UserService.GetUserPublicKeyByID(wsid); + if (string.IsNullOrWhiteSpace(publicKey)) + { + return; + } + + Message result = new() + { + Type = type, + Data = data + }; + string encrypted = RsaService.EncryptForClient(publicKey, result.ToJsonString()); + + foreach (IWebSocketSession session in sessions.Sessions) + { + if (session.ID == wsid) + { + session.Context.WebSocket.Send(encrypted); + return; + } + } + } + } +} diff --git a/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..2eb66d4 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; @@ -106,6 +110,34 @@ namespace OnlineMsgServer.Core } } + public static bool TryUpdateUserName(string wsid, string name, out string appliedName) + { + appliedName = ""; + lock (_UserListLock) + { + User? user = _UserList.Find(u => u.ID == wsid); + if (user is not { IsAuthenticated: true }) + { + return false; + } + + string normalized = name.Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + { + return false; + } + + if (normalized.Length > 64) + { + normalized = normalized[..64]; + } + + user.Name = normalized; + appliedName = normalized; + return true; + } + } + /// /// 通过用户PublicKey获取wsid /// @@ -131,6 +163,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..362f7fa 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -1,19 +1,53 @@ # OnlineMsgServer -在线消息中转服务(WebSocket + RSA),支持客户端鉴权、单播转发、广播、签名校验、防重放与限流。 +一个基于 WebSocket 的在线消息中转服务,使用 RSA 完成握手、公钥鉴权和业务包加密。 + +当前版本除了单机广播/私聊,还支持“服务器伪装成普通用户”的 peer 互联模式: + +- 客户端外层协议不变 +- 服务器之间通过普通 `publickey / forward / broadcast` 连接 +- 本地私聊未命中时,服务端可继续向 peer 盲转发 +- 广播可在 peer 节点之间扩散 +- 服务端内置短期 `seen-cache`,按 `hash(sender + type + key + payload)` 去重 + +这套 peer 能力更接近“盲转发网络”,不是强一致的用户目录或联邦路由系统。 + +## 协作规范 + +- 提交代码前请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md) +- 分支、PR、协议联调、本地证书与调试资产管理都按该文档执行 + +## 功能概览 + +- 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 +57,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` 启动容器 + +适合 Android 真机、同网段设备和浏览器本地联调。 + +Android 客户端 debug 包支持额外信任本地局域网 CA: + +- 把局域网 WSS 使用的 CA 证书复制到 `deploy/certs/android-local/local_ca.crt` +- `deploy/certs/` 已在 `.gitignore` 中,只用于本地调试,不应提交到 Git +- `assembleDebug` 会自动把它接入 debug-only 的 `networkSecurityConfig` +- release 构建不会信任这张本地 CA -### 3) 生产准备(证书 + 镜像 + 部署产物) +### 3. 生产准备 ```bash DOMAIN=chat.example.com \ @@ -50,9 +102,14 @@ CERT_PASSWORD='change-me' \ bash deploy/prepare_prod_release.sh ``` -输出目录默认在 `deploy/output/prod`,包含 `prod.env`、镜像 tar(可选)和运行示例脚本。 +输出默认在 `deploy/output/prod/`,包括: -无 CA 证书时可临时使用自签名(仅测试): +- `prod.env` +- Docker 镜像 tar(可选) +- 运行示例脚本 +- 运行时证书与协议私钥 + +如果只是临时测试,也可以生成自签名证书: ```bash DOMAIN=chat.example.com \ @@ -62,9 +119,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 +132,7 @@ docker run -d --name onlinemsgserver --restart unless-stopped \ onlinemsgserver:latest ``` -### WSS(生产/预生产) +### 单节点:WSS ```bash docker run -d --name onlinemsgserver --restart unless-stopped \ @@ -89,16 +146,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 +193,9 @@ docker run -d --name onlinemsgserver --restart unless-stopped \ } ``` -### 连接首包(服务端 -> 客户端,明文) +### 首包:服务端 -> 客户端(明文) + +客户端建立连接后,服务端立即发送: ```json { @@ -122,83 +209,196 @@ docker run -d --name onlinemsgserver --restart unless-stopped \ } ``` -### 鉴权登记 `type=publickey`(客户端 -> 服务端) +### 鉴权:`type=publickey` + +- `key`:用户名 +- `data.publicKey`:客户端公钥 +- `data.challenge`:首包中的 `authChallenge` +- `data.timestamp`:Unix 秒级时间戳 +- `data.nonce`:随机串 +- `data.signature`:客户端私钥签名 -- `key`:用户名(为空时服务端会生成匿名名) -- `data`: +示例: ```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/Audio_usage_license.txt b/android-client/Audio_usage_license.txt new file mode 100644 index 0000000..10795ca --- /dev/null +++ b/android-client/Audio_usage_license.txt @@ -0,0 +1,20 @@ +作品:Notification sound effects +来源:ear0 +作者:Risteard +许可:CC-BY 保留署名许可协议 +描述:通知铃声。 +------------------------------------------------- +作品:load +来源:ear0 +作者:weidu27 +许可:CC0 公众共享许可协议 +描述:叮咚 +------------------------------------------------- +作品:nameit5 +来源:Freesound +作者:bumpelsnake +许可:CC-BY 保留署名许可协议 +描述:some chime, maybe a notification sound +------------------------------------------------- +Thank you to all the creators mentioned above. +感谢上述所有创作者。 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 7581e5a..92f64a8 100644 --- a/android-client/app/build.gradle.kts +++ b/android-client/app/build.gradle.kts @@ -2,8 +2,15 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.serialization") + 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 @@ -13,7 +20,7 @@ android { minSdk = 26 targetSdk = 34 versionCode = 1 - versionName = "1.0.0.2" + versionName = "1.0.0.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -51,6 +58,13 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + + sourceSets { + getByName("debug") { + res.srcDir(generatedLocalDebugResDir) + manifest.srcFile(generatedLocalDebugManifestFile) + } + } } dependencies { @@ -71,6 +85,9 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") implementation("androidx.datastore:datastore-preferences:1.1.1") + implementation("androidx.room:room-runtime:2.6.1") + implementation("androidx.room:room-ktx:2.6.1") + ksp("androidx.room:room-compiler:2.6.1") implementation("com.squareup.okhttp3:okhttp:4.12.0") debugImplementation("androidx.compose.ui:ui-tooling") @@ -89,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) @@ -98,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 @@ + + 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 new file mode 100644 index 0000000..c176815 --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatHistoryRepository.kt @@ -0,0 +1,80 @@ +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(serverKey: String, limit: Int): List { + migrateLegacyMessagesIfNeeded(serverKey) + return messageDao.listByServer(serverKey) + .asSequence() + .mapNotNull { entity -> entity.toUiMessageOrNull() } + .toList() + .takeLast(limit) + } + + suspend fun appendMessage(serverKey: String, message: UiMessage, limit: Int) { + messageDao.upsert(message.toEntity(serverKey)) + messageDao.trimToLatest(serverKey, limit) + } + + 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(serverKey: String): ChatMessageEntity { + return ChatMessageEntity( + id = toStorageId(serverKey, id), + serverKey = serverKey, + role = role.name, + sender = sender, + subtitle = subtitle, + content = content, + channel = channel.name, + 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, + sender = sender, + subtitle = subtitle, + content = content, + channel = parsedChannel, + 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 new file mode 100644 index 0000000..1021a9e --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageDao.kt @@ -0,0 +1,42 @@ +package com.onlinemsg.client.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface ChatMessageDao { + @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) + + @Query( + """ + DELETE FROM chat_messages + WHERE serverKey = :serverKey + AND id NOT IN ( + SELECT id + FROM chat_messages + WHERE serverKey = :serverKey + ORDER BY timestampMillis DESC + LIMIT :limit + ) + """ + ) + suspend fun trimToLatest(serverKey: String, limit: Int) + + @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 new file mode 100644 index 0000000..2cc8c11 --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageEntity.kt @@ -0,0 +1,19 @@ +package com.onlinemsg.client.data.local + +import androidx.room.Entity +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 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 9e7974b..13d79a7 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,7 +24,8 @@ data class UserPreferences( val shouldAutoReconnect: Boolean, val themeId: String = "blue", val useDynamicColor: Boolean = true, - val language: String = "zh" // 默认中文 + val language: String = "zh", // 默认中文 + val notificationSound: String = "default" ) class UserPreferencesRepository( @@ -50,7 +51,8 @@ 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" + language = prefs[KEY_LANGUAGE] ?: "zh", + notificationSound = prefs[KEY_NOTIFICATION_SOUND] ?: "default" ) } @@ -66,6 +68,12 @@ 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 @@ -155,5 +163,6 @@ 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 aac72a4..84fe8bf 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt @@ -1,7 +1,28 @@ package com.onlinemsg.client.ui -import android.annotation.SuppressLint +import android.Manifest +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.media.AudioAttributes +import android.media.MediaPlayer +import android.os.Build +import android.provider.Settings +import android.util.Base64 +import android.view.MotionEvent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,12 +52,18 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Send import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.Forum +import androidx.compose.material.icons.rounded.Keyboard import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Language +import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Stop +import androidx.compose.material.icons.rounded.KeyboardVoice import androidx.compose.material3.AssistChip import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider @@ -55,6 +82,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -62,23 +90,37 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.compose.foundation.isSystemInDarkTheme -import android.os.Build import com.onlinemsg.client.ui.theme.OnlineMsgTheme import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter +import java.io.File import com.onlinemsg.client.ui.theme.themeOptions +import com.onlinemsg.client.util.AudioRecorder import com.onlinemsg.client.util.LanguageManager +import com.onlinemsg.client.util.NotificationSoundCatalog +import kotlinx.coroutines.delay /** @@ -89,6 +131,11 @@ private enum class MainTab(val labelKey: String) { SETTINGS("tab.settings") } +private enum class ChatInputMode { + TEXT, + AUDIO +} + /** * 应用程序的根可组合函数。 * 集成 ViewModel、主题、Scaffold 以及选项卡切换逻辑。 @@ -123,7 +170,7 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) { Scaffold( topBar = { AppTopBar( - statusText = state.statusText, + statusText = localizedConnectionStatusText(state.status, state.language), statusColor = when (state.status) { ConnectionStatus.READY -> MaterialTheme.colorScheme.primary ConnectionStatus.ERROR -> MaterialTheme.colorScheme.error @@ -182,6 +229,7 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) { onTargetKeyChange = viewModel::updateTargetKey, onDraftChange = viewModel::updateDraft, onSend = viewModel::sendMessage, + onSendAudio = viewModel::sendAudioMessage, onCopyMessage = { content -> clipboard.setText(AnnotatedString(content)) viewModel.onMessageCopied() @@ -199,7 +247,7 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) { onServerUrlChange = viewModel::updateServerUrl, onSaveServer = viewModel::saveCurrentServerUrl, onRemoveServer = viewModel::removeCurrentServerUrl, - onSelectServer = viewModel::updateServerUrl, + onSelectServer = viewModel::selectServerUrl, onToggleShowSystem = viewModel::toggleShowSystemMessages, onRevealPublicKey = viewModel::revealMyPublicKey, onCopyPublicKey = { @@ -211,7 +259,8 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) { onClearMessages = viewModel::clearMessages, onThemeChange = viewModel::updateTheme, onUseDynamicColorChange = viewModel::updateUseDynamicColor, - onLanguageChange = viewModel::updateLanguage + onLanguageChange = viewModel::updateLanguage, + onNotificationSoundChange = viewModel::updateNotificationSound ) } } @@ -276,6 +325,7 @@ private fun AppTopBar( * 包含模式切换、消息列表和输入区域。 */ @Composable +@OptIn(ExperimentalComposeUiApi::class) private fun ChatTab( modifier: Modifier, state: ChatUiState, @@ -283,12 +333,101 @@ private fun ChatTab( onTargetKeyChange: (String) -> Unit, onDraftChange: (String) -> Unit, onSend: () -> Unit, + onSendAudio: (String, Long) -> Unit, onCopyMessage: (String) -> Unit ) { + val context = LocalContext.current val listState = rememberLazyListState() - + val audioRecorder = remember(context) { AudioRecorder(context) } + val audioPlayer = remember(context) { AudioMessagePlayer(context) } + var inputMode by rememberSaveable { mutableStateOf(ChatInputMode.TEXT) } + var isRecording by remember { mutableStateOf(false) } + var cancelOnRelease by remember { mutableStateOf(false) } + var pressDownRawY by remember { mutableStateOf(0f) } + var audioHint by remember { mutableStateOf("") } + var audioHintVersion by remember { mutableStateOf(0L) } + var playingMessageId by remember { mutableStateOf(null) } + var recordingStartedAtMillis by remember { mutableStateOf(0L) } + var recordingElapsedMillis by remember { mutableStateOf(0L) } + val recordingPulse = rememberInfiniteTransition(label = "recordingPulse") + val recordingPulseScale by recordingPulse.animateFloat( + initialValue = 0.9f, + targetValue = 1.2f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 700), + repeatMode = RepeatMode.Reverse + ), + label = "recordingPulseScale" + ) + val recordingPulseAlpha by recordingPulse.animateFloat( + initialValue = 0.25f, + targetValue = 0.65f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 700), + repeatMode = RepeatMode.Reverse + ), + label = "recordingPulseAlpha" + ) + // 定义翻译函数 t fun t(key: String) = LanguageManager.getString(key, state.language) + fun showAudioHint(message: String) { + audioHint = message + audioHintVersion += 1L + } + val canHoldToRecord = state.status == ConnectionStatus.READY && + !state.sending && + (!state.directMode || state.targetKey.trim().isNotBlank()) + + fun hasRecordPermission(): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO + ) == PackageManager.PERMISSION_GRANTED + } + + fun startRecording() { + if (isRecording) return + if (!canHoldToRecord) return + if (!hasRecordPermission()) { + showAudioHint(t("chat.audio_permission_required")) + return + } + val started = audioRecorder.start() + if (!started) { + showAudioHint(t("chat.audio_record_failed")) + return + } + isRecording = true + cancelOnRelease = false + recordingStartedAtMillis = System.currentTimeMillis() + recordingElapsedMillis = 0L + audioHint = "" + } + + fun finishRecording(send: Boolean) { + if (!isRecording) return + isRecording = false + cancelOnRelease = false + recordingStartedAtMillis = 0L + recordingElapsedMillis = 0L + val recorded = audioRecorder.stopAndEncode(send = send) + when { + !send -> { + showAudioHint(t("chat.audio_canceled")) + } + recorded == null -> { + showAudioHint(t("chat.audio_record_failed")) + } + recorded.durationMillis < MIN_AUDIO_DURATION_MS -> { + showAudioHint(t("chat.audio_too_short")) + } + else -> { + onSendAudio(recorded.base64, recorded.durationMillis) + audioHint = "" + } + } + } // 当消息列表新增消息时,自动滚动到底部 LaunchedEffect(state.visibleMessages.size) { @@ -297,6 +436,32 @@ private fun ChatTab( } } + LaunchedEffect(isRecording, recordingStartedAtMillis) { + if (!isRecording || recordingStartedAtMillis <= 0L) return@LaunchedEffect + while (isRecording) { + recordingElapsedMillis = (System.currentTimeMillis() - recordingStartedAtMillis) + .coerceAtLeast(0L) + delay(100L) + } + } + + LaunchedEffect(audioHintVersion) { + val latest = audioHint + val latestVersion = audioHintVersion + if (latest.isBlank()) return@LaunchedEffect + delay(2200L) + if (audioHintVersion == latestVersion && audioHint == latest) { + audioHint = "" + } + } + + DisposableEffect(Unit) { + onDispose { + audioRecorder.release() + audioPlayer.release() + } + } + Column( modifier = modifier .fillMaxSize() @@ -324,10 +489,71 @@ private fun ChatTab( } Spacer(modifier = Modifier.height(8.dp)) + AnimatedVisibility( + visible = state.isSwitchingServer, + enter = fadeIn(animationSpec = tween(180)) + slideInVertically( + animationSpec = tween(220), + initialOffsetY = { -it / 2 } + ), + exit = fadeOut(animationSpec = tween(140)) + slideOutVertically( + animationSpec = tween(180), + targetOffsetY = { -it / 3 } + ) + ) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ), + shape = RoundedCornerShape(14.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = t("session.hint.switching_server"), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + Text( + text = state.switchingServerLabel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + if (state.isSwitchingServer) { + Spacer(modifier = Modifier.height(8.dp)) + } + val statusHintText = if (audioHint.isNotBlank()) { + audioHint + } else { + localizedStatusHintText(state.statusHint, state.language) + } + val statusHintColor = if (audioHint.isNotBlank()) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } Text( - text = state.statusHint, + text = statusHintText, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = statusHintColor ) if (state.directMode) { @@ -373,6 +599,18 @@ private fun ChatTab( MessageItem( message = message, onCopy = { onCopyMessage(message.content) }, + onPlayAudio = { + val nextPlaying = audioPlayer.toggle( + messageId = message.id, + audioBase64 = message.audioBase64 + ) { stoppedId -> + if (playingMessageId == stoppedId) { + playingMessageId = null + } + } + playingMessageId = nextPlaying + }, + isPlaying = playingMessageId == message.id, currentLanguage = state.language ) } @@ -381,31 +619,191 @@ private fun ChatTab( Spacer(modifier = Modifier.height(8.dp)) - // 消息输入区域 - Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - OutlinedTextField( - value = state.draft, - onValueChange = onDraftChange, - modifier = Modifier.weight(1f), - label = { Text(t("chat.input_placeholder")) }, - maxLines = 4, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), - keyboardActions = KeyboardActions( - onSend = { onSend() } + if (inputMode == ChatInputMode.TEXT) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + IconButton( + onClick = { inputMode = ChatInputMode.AUDIO }, + modifier = Modifier + .size(56.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(14.dp) + ) + ) { + Icon( + imageVector = Icons.Rounded.KeyboardVoice, + contentDescription = t("chat.mode_audio"), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + OutlinedTextField( + value = state.draft, + onValueChange = onDraftChange, + modifier = Modifier + .weight(1f) + .height(56.dp), + placeholder = { Text(t("chat.input_placeholder")) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions( + onSend = { onSend() } + ) ) - ) - Button( - onClick = onSend, - enabled = state.canSend, - modifier = Modifier.height(56.dp) + Button( + onClick = onSend, + enabled = state.canSend, + modifier = Modifier.size(56.dp), + contentPadding = PaddingValues(0.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.Send, + contentDescription = t("chat.send") + ) + } + } + } else { + val holdToTalkText = when { + state.sending -> t("chat.sending") + isRecording && cancelOnRelease -> t("chat.audio_release_cancel") + isRecording -> t("chat.audio_release_send") + else -> t("chat.audio_hold_to_talk") + } + val holdToTalkColor = when { + !canHoldToRecord -> MaterialTheme.colorScheme.surfaceVariant + isRecording && cancelOnRelease -> MaterialTheme.colorScheme.errorContainer + isRecording -> MaterialTheme.colorScheme.tertiaryContainer + else -> MaterialTheme.colorScheme.surfaceVariant + } + val holdToTalkTextColor = when { + isRecording && cancelOnRelease -> MaterialTheme.colorScheme.onErrorContainer + isRecording -> MaterialTheme.colorScheme.onTertiaryContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Icon(Icons.AutoMirrored.Rounded.Send, contentDescription = null) - Spacer(Modifier.width(6.dp)) - Text(if (state.sending) "..." else t("chat.send")) + IconButton( + onClick = { + if (isRecording) { + finishRecording(send = false) + } + inputMode = ChatInputMode.TEXT + }, + modifier = Modifier + .size(56.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(14.dp) + ) + ) { + Icon( + imageVector = Icons.Rounded.Keyboard, + contentDescription = t("chat.mode_text"), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Card( + modifier = Modifier + .weight(1f) + .height(56.dp) + .pointerInteropFilter { event -> + if (!canHoldToRecord && event.actionMasked == MotionEvent.ACTION_DOWN) { + return@pointerInteropFilter false + } + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + pressDownRawY = event.rawY + startRecording() + true + } + + MotionEvent.ACTION_MOVE -> { + if (isRecording) { + cancelOnRelease = pressDownRawY - event.rawY > AUDIO_CANCEL_TRIGGER_PX + } + true + } + + MotionEvent.ACTION_UP -> { + finishRecording(send = !cancelOnRelease) + true + } + + MotionEvent.ACTION_CANCEL -> { + finishRecording(send = false) + true + } + + else -> false + } + }, + colors = CardDefaults.cardColors(containerColor = holdToTalkColor), + shape = RoundedCornerShape(12.dp) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (isRecording) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Box( + modifier = Modifier + .size(18.dp) + .graphicsLayer { + scaleX = recordingPulseScale + scaleY = recordingPulseScale + } + .background( + color = holdToTalkTextColor.copy(alpha = recordingPulseAlpha), + shape = RoundedCornerShape(999.dp) + ) + ) + Icon( + imageVector = Icons.Rounded.KeyboardVoice, + contentDescription = null, + tint = holdToTalkTextColor, + modifier = Modifier.size(12.dp) + ) + } + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(1.dp) + ) { + Text( + text = holdToTalkText, + style = MaterialTheme.typography.titleSmall, + color = holdToTalkTextColor + ) + Text( + text = "${t("chat.audio_recording")} ${formatRecordingElapsed(recordingElapsedMillis)}", + style = MaterialTheme.typography.labelSmall, + color = holdToTalkTextColor.copy(alpha = 0.9f) + ) + } + } + } else { + Text( + text = holdToTalkText, + style = MaterialTheme.typography.titleMedium, + color = holdToTalkTextColor + ) + } + } + } } } } @@ -414,11 +812,12 @@ private fun ChatTab( /** * 单个消息气泡组件。 */ -@SuppressLint("UnusedBoxWithConstraintsScope") @Composable private fun MessageItem( message: UiMessage, onCopy: () -> Unit, + onPlayAudio: () -> Unit, + isPlaying: Boolean, currentLanguage: String ) { Box(modifier = Modifier.fillMaxWidth()) { @@ -458,6 +857,10 @@ private fun MessageItem( } } else { val isOutgoing = message.role == MessageRole.OUTGOING + val shouldShowSender = !isOutgoing + val senderDisplayName = message.sender.ifBlank { + LanguageManager.getString("session.sender.anonymous", currentLanguage) + } val bubbleColor = if (isOutgoing) { MaterialTheme.colorScheme.primaryContainer } else { @@ -501,12 +904,16 @@ private fun MessageItem( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp) ) { - if (!isOutgoing) { + if (shouldShowSender) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = message.sender, + text = senderDisplayName, style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary + color = if (isOutgoing) { + bubbleTextColor.copy(alpha = 0.9f) + } else { + MaterialTheme.colorScheme.primary + } ) if (message.subtitle.isNotBlank()) { Spacer(modifier = Modifier.width(8.dp)) @@ -521,34 +928,53 @@ private fun MessageItem( } } - // 消息正文 - Text( - text = message.content, - style = MaterialTheme.typography.bodyMedium, - color = bubbleTextColor - ) + if (message.contentType == MessageContentType.AUDIO && + message.audioBase64.isNotBlank() + ) { + AudioMessageBody( + message = message, + bubbleTextColor = bubbleTextColor, + onPlayAudio = onPlayAudio, + isPlaying = isPlaying, + currentLanguage = currentLanguage + ) + } else { + Text( + text = message.content, + style = MaterialTheme.typography.bodyMedium, + color = bubbleTextColor + ) + } // 时间戳和复制按钮 Row( - modifier = Modifier.fillMaxWidth(), + modifier = if (message.contentType == MessageContentType.AUDIO) { + Modifier.align(Alignment.End) + } else { + Modifier.fillMaxWidth() + }, verticalAlignment = Alignment.CenterVertically ) { - Spacer(modifier = Modifier.weight(1f)) + if (message.contentType == MessageContentType.TEXT) { + Spacer(modifier = Modifier.weight(1f)) + } Text( text = formatTime(message.timestampMillis), style = MaterialTheme.typography.labelSmall, color = bubbleTextColor.copy(alpha = 0.7f) ) - IconButton( - onClick = onCopy, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = Icons.Rounded.ContentCopy, - contentDescription = LanguageManager.getString("common.copied", currentLanguage), - tint = bubbleTextColor.copy(alpha = 0.7f), - modifier = Modifier.size(14.dp) - ) + if (message.contentType == MessageContentType.TEXT) { + IconButton( + onClick = onCopy, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = Icons.Rounded.ContentCopy, + contentDescription = LanguageManager.getString("common.copied", currentLanguage), + tint = bubbleTextColor.copy(alpha = 0.7f), + modifier = Modifier.size(14.dp) + ) + } } } } @@ -559,6 +985,97 @@ private fun MessageItem( } } +@Composable +private fun AudioMessageBody( + message: UiMessage, + bubbleTextColor: Color, + onPlayAudio: () -> Unit, + isPlaying: Boolean, + currentLanguage: String +) { + val actionText = if (isPlaying) { + LanguageManager.getString("chat.audio_stop", currentLanguage) + } else { + LanguageManager.getString("chat.audio_play", currentLanguage) + } + val waveformPulse by rememberInfiniteTransition(label = "audioPlaybackWave").animateFloat( + initialValue = 0.55f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 480), + repeatMode = RepeatMode.Reverse + ), + label = "audioPlaybackWavePulse" + ) + val waveScales = if (isPlaying) { + listOf( + 0.75f + waveformPulse * 0.22f, + 0.92f + waveformPulse * 0.2f, + 0.82f + waveformPulse * 0.28f, + 0.9f + waveformPulse * 0.18f, + 0.7f + waveformPulse * 0.24f + ) + } else { + listOf(0.75f, 0.95f, 0.82f, 0.9f, 0.72f) + } + val baseWaveHeights = listOf(8.dp, 14.dp, 10.dp, 13.dp, 9.dp) + + Row( + modifier = Modifier + .widthIn(min = 140.dp, max = 210.dp) + .background( + color = bubbleTextColor.copy(alpha = 0.12f), + shape = RoundedCornerShape(12.dp) + ) + .clickable(onClick = onPlayAudio) + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier + .size(28.dp) + .background( + color = bubbleTextColor.copy(alpha = 0.16f), + shape = RoundedCornerShape(999.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (isPlaying) Icons.Rounded.Stop else Icons.Rounded.PlayArrow, + contentDescription = actionText, + tint = bubbleTextColor, + modifier = Modifier.size(18.dp) + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp) + ) { + baseWaveHeights.forEachIndexed { index, baseHeight -> + Box( + modifier = Modifier + .width(3.dp) + .height(baseHeight) + .graphicsLayer { + scaleY = waveScales[index] + } + .background( + color = bubbleTextColor.copy(alpha = if (isPlaying) 0.95f else 0.72f), + shape = RoundedCornerShape(999.dp) + ) + ) + } + } + Spacer(modifier = Modifier.weight(1f)) + Text( + text = formatAudioDuration(message.audioDurationMillis), + style = MaterialTheme.typography.labelMedium, + color = bubbleTextColor.copy(alpha = 0.8f) + ) + } +} + /** * 设置选项卡界面,包含个人设置、服务器管理、身份安全、语言、主题和诊断信息。 * @param modifier 修饰符 @@ -591,9 +1108,32 @@ private fun SettingsTab( onClearMessages: () -> Unit, onThemeChange: (String) -> Unit, onUseDynamicColorChange: (Boolean) -> Unit, - onLanguageChange: (String) -> Unit + onLanguageChange: (String) -> Unit, + onNotificationSoundChange: (String) -> Unit ) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + val previewPlayer = remember(context) { NotificationSoundPreviewPlayer(context) } + val notificationStatus = rememberNotificationSoundStatus(state.notificationSound) + var displayNameInput by rememberSaveable { mutableStateOf(state.displayName) } + + DisposableEffect(Unit) { + onDispose { previewPlayer.release() } + } + + LaunchedEffect(state.displayName) { + if (state.displayName != displayNameInput) { + displayNameInput = state.displayName + } + } + fun t(key: String) = LanguageManager.getString(key, state.language) + fun commitDisplayName() { + val candidate = displayNameInput.take(64) + if (candidate != state.displayName) { + onDisplayNameChange(candidate) + } + } val settingsCardModifier = Modifier.fillMaxWidth() val settingsCardContentModifier = Modifier @@ -616,11 +1156,25 @@ private fun SettingsTab( ) { Text(t("settings.personal"), style = MaterialTheme.typography.titleMedium) OutlinedTextField( - value = state.displayName, - onValueChange = onDisplayNameChange, - modifier = Modifier.fillMaxWidth(), + value = displayNameInput, + onValueChange = { displayNameInput = it.take(64) }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focusState -> + if (!focusState.isFocused) { + commitDisplayName() + } + }, label = { Text(t("settings.display_name")) }, - maxLines = 1 + maxLines = 1, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { + commitDisplayName() + focusManager.clearFocus() + } + ) ) } } @@ -632,7 +1186,67 @@ private fun SettingsTab( modifier = settingsCardContentModifier, verticalArrangement = settingsCardContentSpacing ) { - Text("聊天数据", style = MaterialTheme.typography.titleMedium) + Text(t("settings.notification_sound"), style = MaterialTheme.typography.titleMedium) + Text( + text = when { + !notificationStatus.notificationsEnabled -> t("settings.notification_disabled") + !notificationStatus.channelSoundEnabled -> t("settings.notification_sound_disabled") + else -> t("settings.notification_enabled") + }, + style = MaterialTheme.typography.bodySmall, + color = if (notificationStatus.notificationsEnabled && notificationStatus.channelSoundEnabled) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.error + } + ) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(NotificationSoundCatalog.soundCodes) { sound -> + FilterChip( + selected = state.notificationSound == sound, + onClick = { + previewPlayer.play(sound) + onNotificationSoundChange(sound) + }, + label = { Text(t("sound.$sound")) }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.MusicNote, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + ) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { openAppNotificationSettings(context) }) { + Text(t("settings.notification_system_settings")) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + OutlinedButton( + onClick = { + openNotificationChannelSettings( + context = context, + soundCode = state.notificationSound + ) + } + ) { + Text(t("settings.notification_channel_settings")) + } + } + } + } + } + } + + item { + Card(modifier = settingsCardModifier) { + Column( + modifier = settingsCardContentModifier, + verticalArrangement = settingsCardContentSpacing + ) { + Text(t("settings.chat_data"), style = MaterialTheme.typography.titleMedium) OutlinedButton(onClick = onClearMessages) { Text(t("settings.clear_msg")) } @@ -787,8 +1401,8 @@ private fun SettingsTab( verticalArrangement = settingsCardContentSpacing ) { Text(t("settings.diagnostics"), style = MaterialTheme.typography.titleMedium) - Text("${t("settings.status_hint")}:${state.statusHint}") - Text("${t("settings.current_status")}:${state.statusText}") + Text("${t("settings.status_hint")}:${localizedStatusHintText(state.statusHint, state.language)}") + Text("${t("settings.current_status")}:${localizedConnectionStatusText(state.status, state.language)}") Text("${t("settings.cert_fingerprint")}:${state.certFingerprint.ifBlank { "N/A" }}") Row( verticalAlignment = Alignment.CenterVertically, @@ -805,6 +1419,256 @@ private fun SettingsTab( } } +private class AudioMessagePlayer(private val context: Context) { + private var mediaPlayer: MediaPlayer? = null + private var currentMessageId: String? = null + private var currentAudioFile: File? = null + + fun toggle( + messageId: String, + audioBase64: String, + onStopped: (String) -> Unit + ): String? { + if (currentMessageId == messageId) { + stopPlayback()?.let(onStopped) + return null + } + + stopPlayback()?.let(onStopped) + + val bytes = runCatching { + Base64.decode(audioBase64, Base64.DEFAULT) + }.getOrNull() ?: return null + if (bytes.isEmpty()) return null + + val audioFile = runCatching { + File.createTempFile("oms_play_", ".m4a", context.cacheDir).apply { + writeBytes(bytes) + } + }.getOrNull() ?: return null + + val player = MediaPlayer() + val started = runCatching { + player.setDataSource(audioFile.absolutePath) + player.setOnCompletionListener { + stopPlayback()?.let(onStopped) + } + player.setOnErrorListener { _, _, _ -> + stopPlayback()?.let(onStopped) + true + } + player.prepare() + player.start() + true + }.getOrElse { + runCatching { player.release() } + audioFile.delete() + false + } + if (!started) return null + + mediaPlayer = player + currentMessageId = messageId + currentAudioFile = audioFile + return currentMessageId + } + + fun release() { + stopPlayback() + } + + private fun stopPlayback(): String? { + val stoppedId = currentMessageId + runCatching { mediaPlayer?.stop() } + runCatching { mediaPlayer?.release() } + mediaPlayer = null + currentMessageId = null + currentAudioFile?.delete() + currentAudioFile = null + return stoppedId + } +} + +private data class NotificationSoundStatus( + val notificationsEnabled: Boolean, + val channelSoundEnabled: Boolean +) + +@Composable +private fun rememberNotificationSoundStatus(soundCode: String): NotificationSoundStatus { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var status by remember(soundCode, context) { + mutableStateOf(queryNotificationSoundStatus(context, soundCode)) + } + + LaunchedEffect(soundCode, context) { + status = queryNotificationSoundStatus(context, soundCode) + } + + DisposableEffect(soundCode, context, lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + status = queryNotificationSoundStatus(context, soundCode) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + return status +} + +private fun queryNotificationSoundStatus(context: Context, soundCode: String): NotificationSoundStatus { + val notificationsEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled() + if (!notificationsEnabled) { + return NotificationSoundStatus( + notificationsEnabled = false, + channelSoundEnabled = false + ) + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return NotificationSoundStatus( + notificationsEnabled = true, + channelSoundEnabled = true + ) + } + + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channel = manager.getNotificationChannel(NotificationSoundCatalog.channelId(soundCode)) + val channelSoundEnabled = channel?.importance != NotificationManager.IMPORTANCE_NONE && + (channel?.sound != null || channel == null) + return NotificationSoundStatus( + notificationsEnabled = true, + channelSoundEnabled = channelSoundEnabled + ) +} + +private fun openAppNotificationSettings(context: Context) { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) +} + +private fun openNotificationChannelSettings(context: Context, soundCode: String) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + openAppNotificationSettings(context) + return + } + val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + putExtra(Settings.EXTRA_CHANNEL_ID, NotificationSoundCatalog.channelId(soundCode)) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) +} + +private class NotificationSoundPreviewPlayer(private val context: Context) { + private var mediaPlayer: MediaPlayer? = null + + fun play(soundCode: String) { + val resId = NotificationSoundCatalog.resId(soundCode) ?: return + release() + val afd = context.resources.openRawResourceFd(resId) ?: return + val player = MediaPlayer() + val started = runCatching { + player.setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION_EVENT) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + ) + player.setDataSource(afd.fileDescriptor, afd.startOffset, afd.length) + afd.close() + player.setOnCompletionListener { release() } + player.setOnErrorListener { _, _, _ -> + release() + true + } + player.prepare() + player.start() + true + }.getOrElse { + runCatching { afd.close() } + runCatching { player.release() } + false + } + if (!started) return + mediaPlayer = player + } + + fun release() { + runCatching { mediaPlayer?.stop() } + runCatching { mediaPlayer?.release() } + mediaPlayer = null + } +} + +private fun formatAudioDuration(durationMillis: Long): String { + val totalSeconds = (durationMillis / 1000L).coerceAtLeast(0L) + val minutes = totalSeconds / 60L + val seconds = totalSeconds % 60L + return if (minutes > 0L) { + String.format("%d:%02d", minutes, seconds) + } else { + "${seconds}s" + } +} + +private fun formatRecordingElapsed(durationMillis: Long): String { + val clamped = durationMillis.coerceAtLeast(0L) + val seconds = clamped / 1000L + val tenths = (clamped % 1000L) / 100L + return "${seconds}.${tenths}s" +} + +private fun localizedConnectionStatusText(status: ConnectionStatus, language: String): String { + val key = when (status) { + ConnectionStatus.IDLE -> "status.idle" + ConnectionStatus.CONNECTING, + ConnectionStatus.HANDSHAKING, + ConnectionStatus.AUTHENTICATING -> "status.connecting" + ConnectionStatus.READY -> "status.ready" + ConnectionStatus.ERROR -> "status.error" + } + return LanguageManager.getString(key, language) +} + +private fun localizedStatusHintText(raw: String, language: String): String { + val exact = when (raw) { + "点击连接开始聊天" -> "hint.tap_to_connect" + "正在连接服务器..." -> "hint.connecting_server" + "已连接,可以开始聊天" -> "hint.ready_chat" + "连接已关闭" -> "hint.closed" + "连接已中断,正在重试" -> "hint.reconnecting" + "重连失败:服务器地址无效" -> "hint.reconnect_invalid_server" + "请先填写目标公钥,再发送私聊消息" -> "hint.fill_target_key" + else -> null + } + if (exact != null) { + return LanguageManager.getString(exact, language) + } + return when { + raw.startsWith("服务器拒绝连接:") -> { + val suffix = raw.removePrefix("服务器拒绝连接:") + LanguageManager.getString("hint.server_rejected_prefix", language) + suffix + } + + raw.startsWith("语音发送失败:") -> { + val suffix = raw.removePrefix("语音发送失败:") + LanguageManager.getString("hint.audio_send_failed_prefix", language) + suffix + } + + else -> raw + } +} + +private const val AUDIO_CANCEL_TRIGGER_PX = 120f +private const val MIN_AUDIO_DURATION_MS = 350L + /** * 将时间戳格式化为本地时间的小时:分钟(如 "14:30")。 * @param tsMillis 毫秒时间戳 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 b9a1f25..2723f0a 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt @@ -5,25 +5,34 @@ 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 import androidx.core.content.ContextCompat import com.onlinemsg.client.MainActivity -import com.onlinemsg.client.R import com.onlinemsg.client.data.crypto.RsaCryptoManager +import com.onlinemsg.client.data.local.ChatDatabase +import com.onlinemsg.client.data.local.ChatHistoryRepository import com.onlinemsg.client.data.network.OnlineMsgSocketClient import com.onlinemsg.client.data.preferences.ServerUrlFormatter import com.onlinemsg.client.data.preferences.UserPreferencesRepository +import com.onlinemsg.client.data.protocol.AudioPayloadDto +import com.onlinemsg.client.data.protocol.AudioChunkPayloadDto import com.onlinemsg.client.data.protocol.AuthPayloadDto import com.onlinemsg.client.data.protocol.EnvelopeDto import com.onlinemsg.client.data.protocol.HelloDataDto import com.onlinemsg.client.data.protocol.SignedPayloadDto import com.onlinemsg.client.data.protocol.asPayloadText +import com.onlinemsg.client.data.protocol.asStringOrNull import com.onlinemsg.client.service.ChatForegroundService +import com.onlinemsg.client.util.LanguageManager +import com.onlinemsg.client.util.NotificationSoundCatalog import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -46,6 +55,7 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.encodeToJsonElement +import java.util.UUID /** * 单例管理类,负责整个聊天会话的生命周期、网络连接、消息收发、状态维护和持久化。 @@ -57,9 +67,19 @@ object ChatSessionManager { ignoreUnknownKeys = true } + private fun t(key: String): String { + return LanguageManager.getString(key, _uiState.value.language) + } + + private fun tf(key: String, vararg args: Any): String { + val pattern = t(key) + return runCatching { String.format(pattern, *args) }.getOrElse { pattern } + } + private lateinit var app: Application private lateinit var preferencesRepository: UserPreferencesRepository private lateinit var cryptoManager: RsaCryptoManager + private lateinit var historyRepository: ChatHistoryRepository private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val socketClient = OnlineMsgSocketClient() private var initialized = false @@ -84,12 +104,14 @@ object ChatSessionManager { private var helloTimeoutJob: Job? = null // 握手超时任务 private var authTimeoutJob: Job? = null // 认证超时任务 private var reconnectJob: Job? = null // 自动重连任务 + private var displayNameSyncJob: Job? = null // 在线改名同步任务 private var reconnectAttempt: Int = 0 // 当前重连尝试次数 private val systemMessageExpiryJobs: MutableMap = mutableMapOf() // 系统消息自动过期任务 private var autoReconnectTriggered = false @Volatile private var keepAliveRequested = false // 是否应保活(前台服务标志) private var notificationIdSeed = 2000 + private val incomingAudioChunkBuffers = mutableMapOf() // WebSocket 事件监听器 private val socketListener = object : OnlineMsgSocketClient.Listener { @@ -98,10 +120,10 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.HANDSHAKING, - statusHint = "已连接,正在准备聊天..." + statusHint = t("session.hint.connected_preparing") ) } - addSystemMessage("连接已建立") + addSystemMessage(t("session.msg.connection_established")) startHelloTimeout() } } @@ -111,7 +133,12 @@ object ChatSessionManager { runCatching { handleIncomingMessage(text) }.onFailure { error -> - addSystemMessage("文本帧处理异常:${error.message ?: "unknown"}") + addSystemMessage( + tf( + "session.msg.text_frame_error", + error.message ?: t("common.unknown") + ) + ) } } } @@ -119,7 +146,7 @@ object ChatSessionManager { override fun onBinaryMessage(payload: ByteArray) { scope.launch { if (_uiState.value.status == ConnectionStatus.HANDSHAKING) { - _uiState.update { it.copy(statusHint = "收到二进制握手帧,正在尝试解析...") } + _uiState.update { it.copy(statusHint = t("session.hint.binary_handshake_parsing")) } } val utf8 = runCatching { String(payload, StandardCharsets.UTF_8) }.getOrNull().orEmpty() @@ -127,13 +154,20 @@ object ChatSessionManager { runCatching { handleIncomingMessage(utf8) }.onFailure { error -> - addSystemMessage("二进制帧处理异常:${error.message ?: "unknown"}") + addSystemMessage( + tf( + "session.msg.binary_frame_error", + error.message ?: t("common.unknown") + ) + ) } } else if (_uiState.value.status == ConnectionStatus.HANDSHAKING) { val hexPreview = payload.take(24).joinToString(" ") { byte -> "%02x".format(byte) } - addSystemMessage("握手二进制帧无法转为文本,len=${payload.size} hex=$hexPreview") + addSystemMessage( + tf("session.msg.handshake_binary_unreadable", payload.size, hexPreview) + ) } } } @@ -147,15 +181,17 @@ object ChatSessionManager { override fun onFailure(throwable: Throwable) { scope.launch { if (manualClose) return@launch - val message = throwable.message?.takeIf { it.isNotBlank() } ?: "unknown" - addSystemMessage("连接异常:$message") + val message = throwable.message?.takeIf { it.isNotBlank() } ?: t("common.unknown") + addSystemMessage(tf("session.msg.connection_error", message)) _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "连接异常,正在重试" + statusHint = t("session.hint.connection_error_retrying"), + isSwitchingServer = false, + switchingServerLabel = "" ) } - scheduleReconnect("连接异常") + scheduleReconnect(t("session.reason.connection_error")) } } } @@ -171,11 +207,14 @@ object ChatSessionManager { app = application preferencesRepository = UserPreferencesRepository(application, json) cryptoManager = RsaCryptoManager(application) - ensureMessageNotificationChannel() - + historyRepository = ChatHistoryRepository(ChatDatabase.getInstance(application).chatMessageDao()) scope.launch { val pref = preferencesRepository.preferencesFlow.first() + val historyMessages = withContext(Dispatchers.IO) { + historyRepository.loadMessages(serverKeyFor(pref.currentServerUrl), MAX_MESSAGES) + } keepAliveRequested = pref.shouldAutoReconnect + ensureMessageNotificationChannel(pref.notificationSound) _uiState.update { current -> current.copy( displayName = pref.displayName, @@ -185,7 +224,9 @@ object ChatSessionManager { showSystemMessages = pref.showSystemMessages, themeId = pref.themeId, useDynamicColor = pref.useDynamicColor, - language = pref.language + language = pref.language, + notificationSound = pref.notificationSound, + messages = historyMessages ) } // 如果上次会话启用了自动重连,则自动恢复连接 @@ -219,6 +260,18 @@ object ChatSessionManager { } } + /** + * 更新通知音效。 + * @param sound 音效代号 + */ + fun updateNotificationSound(sound: String) { + _uiState.update { it.copy(notificationSound = sound) } + scope.launch { + preferencesRepository.setNotificationSound(sound) + ensureMessageNotificationChannel(sound) + } + } + /** * 更改使用动态颜色 * @param enabled 主题名 @@ -235,10 +288,27 @@ object ChatSessionManager { * @param value 新名称(自动截断至 64 字符) */ fun updateDisplayName(value: String) { - val displayName = value.take(64) + val displayName = value.trim().take(64).ifBlank { createGuestName() } + val previous = _uiState.value.displayName + if (displayName == previous) return _uiState.update { it.copy(displayName = displayName) } - scope.launch { - preferencesRepository.setDisplayName(displayName) + displayNameSyncJob?.cancel() + displayNameSyncJob = scope.launch { + if (_uiState.value.status == ConnectionStatus.READY) { + _uiState.update { it.copy(statusHint = t("session.hint.updating_display_name")) } + runCatching { + sendDisplayNameUpdate(displayName) + preferencesRepository.setDisplayName(displayName) + }.onFailure { error -> + val message = error.message ?: t("common.unknown") + _uiState.update { + it.copy(statusHint = tf("session.hint.display_name_update_failed", message)) + } + _events.emit(UiEvent.ShowSnackbar(tf("session.msg.display_name_update_failed", message))) + } + } else { + preferencesRepository.setDisplayName(displayName) + } } } @@ -250,6 +320,20 @@ object ChatSessionManager { _uiState.update { it.copy(serverUrl = value) } } + /** + * 选择历史服务器并切换会话。 + */ + fun selectServerUrl(value: String) { + val normalized = ServerUrlFormatter.normalize(value) + if (normalized.isBlank()) return + switchServer( + normalized = normalized, + nextUrls = _uiState.value.serverUrls, + statusHint = null, + persist = { preferencesRepository.setCurrentServerUrl(normalized) } + ) + } + /** * 更新私聊目标公钥。 * @param value 公钥字符串 @@ -294,6 +378,11 @@ object ChatSessionManager { fun clearMessages() { cancelSystemMessageExpiryJobs() _uiState.update { it.copy(messages = emptyList()) } + scope.launch(Dispatchers.IO) { + runCatching { + historyRepository.clearAll(currentServerKey()) + } + } } /** @@ -303,24 +392,19 @@ object ChatSessionManager { val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl) if (normalized.isBlank()) { scope.launch { - _events.emit(UiEvent.ShowSnackbar("请输入有效的服务器地址")) + _events.emit(UiEvent.ShowSnackbar(t("session.snackbar.invalid_server"))) } return } val nextUrls = ServerUrlFormatter.append(_uiState.value.serverUrls, normalized) - _uiState.update { - it.copy( - serverUrl = normalized, - serverUrls = nextUrls, - statusHint = "服务器地址已保存" - ) - } - - scope.launch { - preferencesRepository.saveCurrentServerUrl(normalized) - _events.emit(UiEvent.ShowSnackbar("服务器地址已保存")) - } + switchServer( + normalized = normalized, + nextUrls = nextUrls, + statusHint = t("session.hint.server_saved"), + persist = { preferencesRepository.saveCurrentServerUrl(normalized) }, + snackbarMessage = t("session.snackbar.server_saved") + ) } /** @@ -338,18 +422,17 @@ object ChatSessionManager { filtered } - _uiState.update { - it.copy( - serverUrls = nextUrls, - serverUrl = nextUrls.first(), - statusHint = if (filtered.isEmpty()) "已恢复默认服务器地址" else "已移除当前服务器地址" - ) - } - - scope.launch { - preferencesRepository.removeCurrentServerUrl(normalized) - _events.emit(UiEvent.ShowSnackbar("已更新服务器地址列表")) - } + switchServer( + normalized = nextUrls.first(), + nextUrls = nextUrls, + statusHint = if (filtered.isEmpty()) { + t("session.hint.server_restored_default") + } else { + t("session.hint.server_removed") + }, + persist = { preferencesRepository.removeCurrentServerUrl(normalized) }, + snackbarMessage = t("session.snackbar.server_list_updated") + ) } /** @@ -369,7 +452,14 @@ object ChatSessionManager { } }.onFailure { error -> _uiState.update { it.copy(loadingPublicKey = false) } - _events.emit(UiEvent.ShowSnackbar("公钥读取失败:${error.message ?: "unknown"}")) + _events.emit( + UiEvent.ShowSnackbar( + tf( + "session.snackbar.public_key_read_failed", + error.message ?: t("common.unknown") + ) + ) + ) } } } @@ -385,17 +475,23 @@ object ChatSessionManager { * 内部连接逻辑,区分自动恢复和手动连接。 * @param isAutoRestore 是否为应用启动时的自动恢复连接 */ - private fun connectInternal(isAutoRestore: Boolean) { + private fun connectInternal( + isAutoRestore: Boolean, + overrideUrl: String? = null, + forceReconnect: Boolean = false + ) { if (!initialized) return val state = _uiState.value - if (!state.canConnect) return + if (!forceReconnect && !state.canConnect) return - val normalized = ServerUrlFormatter.normalize(state.serverUrl) + val normalized = ServerUrlFormatter.normalize(overrideUrl ?: state.serverUrl) if (normalized.isBlank()) { _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "请填写有效服务器地址" + statusHint = t("session.hint.fill_valid_server"), + isSwitchingServer = false, + switchingServerLabel = "" ) } return @@ -414,7 +510,7 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.CONNECTING, - statusHint = "正在连接服务器...", + statusHint = t("session.hint.connecting_server"), serverUrl = normalized, certFingerprint = "" ) @@ -429,7 +525,7 @@ object ChatSessionManager { socketClient.connect(normalized, socketListener) if (isAutoRestore) { - addSystemMessage("已恢复上次会话,正在自动连接") + addSystemMessage(t("session.msg.auto_restore_connecting")) } } @@ -439,6 +535,7 @@ object ChatSessionManager { */ fun disconnect(stopService: Boolean = true) { manualClose = true + displayNameSyncJob?.cancel() cancelReconnect() cancelHelloTimeout() cancelAuthTimeout() @@ -446,7 +543,9 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.IDLE, - statusHint = "连接已关闭" + statusHint = t("session.hint.connection_closed"), + isSwitchingServer = false, + switchingServerLabel = "" ) } autoReconnectTriggered = false @@ -457,7 +556,7 @@ object ChatSessionManager { if (stopService) { ChatForegroundService.stop(app) } - addSystemMessage("已断开连接") + addSystemMessage(t("session.msg.disconnected")) } /** @@ -467,56 +566,98 @@ object ChatSessionManager { fun sendMessage() { val current = _uiState.value if (!current.canSend) return + val text = current.draft.trim() + if (text.isBlank()) return + val route = resolveOutgoingRoute(current) ?: return scope.launch { - val text = _uiState.value.draft.trim() - if (text.isBlank()) return@launch + _uiState.update { it.copy(sending = true) } + displayNameSyncJob?.join() - val key = if (_uiState.value.directMode) _uiState.value.targetKey.trim() else "" - if (_uiState.value.directMode && key.isBlank()) { - _uiState.update { it.copy(statusHint = "请先填写目标公钥,再发送私聊消息") } - return@launch + runCatching { + sendSignedPayload(route = route, payloadText = text) + }.onSuccess { + addOutgoingMessage(text, route.subtitle, route.channel) + _uiState.update { it.copy(draft = "", sending = false) } + }.onFailure { error -> + _uiState.update { it.copy(sending = false) } + addSystemMessage( + tf( + "session.msg.send_failed", + error.message ?: t("common.unknown") + ) + ) } + } + } - val type = if (key.isBlank()) "broadcast" else "forward" - val channel = if (key.isBlank()) MessageChannel.BROADCAST else MessageChannel.PRIVATE - val subtitle = if (key.isBlank()) "" else "私聊 ${summarizeKey(key)}" + /** + * 发送语音消息(Base64 音频负载)。 + */ + fun sendAudioMessage(audioBase64: String, durationMillis: Long) { + val current = _uiState.value + if (current.status != ConnectionStatus.READY || current.sending) return + if (audioBase64.isBlank()) return + val route = resolveOutgoingRoute(current) ?: return + scope.launch { _uiState.update { it.copy(sending = true) } - - runCatching { - val id = ensureIdentity() - val timestamp = cryptoManager.unixSecondsNow() - val nonce = cryptoManager.createNonce() - val signingInput = listOf(type, key, text, timestamp.toString(), nonce).joinToString("\n") - val signature = withContext(Dispatchers.Default) { - cryptoManager.signText(id.privateKey, signingInput) + displayNameSyncJob?.join() + val safeDuration = durationMillis.coerceAtLeast(0L) + val normalized = audioBase64.trim() + val chunks = splitAudioBase64(normalized, AUDIO_CHUNK_BASE64_SIZE) + if (chunks.size > MAX_AUDIO_CHUNK_COUNT) { + _uiState.update { + it.copy( + sending = false, + statusHint = t("session.hint.audio_chunk_over_limit") + ) } + addSystemMessage(t("session.msg.audio_chunk_canceled")) + return@launch + } - val payload = SignedPayloadDto( - payload = text, - timestamp = timestamp, - nonce = nonce, - signature = signature - ) - val envelope = EnvelopeDto( - type = type, - key = key, - data = json.encodeToJsonElement(payload) - ) - - val plain = json.encodeToString(envelope) - val cipher = withContext(Dispatchers.Default) { - cryptoManager.encryptChunked(serverPublicKey, plain) + runCatching { + if (chunks.size == 1) { + val taggedPayload = AUDIO_MESSAGE_PREFIX + json.encodeToString( + AudioPayloadDto( + durationMillis = safeDuration, + data = normalized + ) + ) + sendSignedPayload(route = route, payloadText = taggedPayload) + } else { + val messageId = UUID.randomUUID().toString() + chunks.forEachIndexed { index, chunk -> + val taggedPayload = AUDIO_CHUNK_MESSAGE_PREFIX + json.encodeToString( + AudioChunkPayloadDto( + messageId = messageId, + index = index, + total = chunks.size, + durationMillis = safeDuration, + data = chunk + ) + ) + sendSignedPayload(route = route, payloadText = taggedPayload) + } } - - check(socketClient.send(cipher)) { "连接不可用" } }.onSuccess { - addOutgoingMessage(text, subtitle, channel) - _uiState.update { it.copy(draft = "", sending = false) } - }.onFailure { error -> + addOutgoingAudioMessage( + subtitle = route.subtitle, + channel = route.channel, + audioBase64 = normalized, + durationMillis = safeDuration + ) _uiState.update { it.copy(sending = false) } - addSystemMessage("发送失败:${error.message ?: "unknown"}") + }.onFailure { error -> + val message = error.message ?: t("common.unknown") + _uiState.update { + it.copy( + sending = false, + statusHint = tf("session.hint.audio_send_failed", message) + ) + } + addSystemMessage(tf("session.msg.audio_send_failed", message)) } } } @@ -526,7 +667,7 @@ object ChatSessionManager { */ fun onMessageCopied() { scope.launch { - _events.emit(UiEvent.ShowSnackbar("已复制")) + _events.emit(UiEvent.ShowSnackbar(t("common.copied"))) } } @@ -550,7 +691,7 @@ object ChatSessionManager { */ private suspend fun handleIncomingMessage(rawText: String) { if (_uiState.value.status == ConnectionStatus.HANDSHAKING) { - _uiState.update { it.copy(statusHint = "已收到握手数据,正在解析...") } + _uiState.update { it.copy(statusHint = t("session.hint.handshake_data_received")) } } val normalizedText = extractJsonCandidate(rawText) @@ -585,7 +726,9 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "握手失败:服务端响应不完整" + statusHint = t("session.hint.handshake_incomplete_response"), + isSwitchingServer = false, + switchingServerLabel = "" ) } return @@ -596,15 +739,15 @@ object ChatSessionManager { // 握手阶段收到非预期消息则报错 if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain != null) { - _uiState.update { it.copy(statusHint = "握手失败:收到非预期消息") } - addSystemMessage("握手阶段收到非预期消息类型:${plain.type}") + _uiState.update { it.copy(statusHint = t("session.hint.handshake_unexpected_message")) } + addSystemMessage(tf("session.msg.handshake_unexpected_type", plain.type)) } else if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain == null) { val preview = rawText .replace("\n", " ") .replace("\r", " ") .take(80) - _uiState.update { it.copy(statusHint = "握手失败:首包解析失败") } - addSystemMessage("握手包解析失败:$preview") + _uiState.update { it.copy(statusHint = t("session.hint.handshake_first_packet_parse_failed")) } + addSystemMessage(tf("session.msg.handshake_parse_failed", preview)) } // 尝试解密(若已握手完成,收到的应是加密消息) @@ -614,7 +757,7 @@ object ChatSessionManager { cryptoManager.decryptChunked(id.privateKey, normalizedText) } }.getOrElse { - addSystemMessage("收到无法解密的消息") + addSystemMessage(t("session.msg.decryption_failed")) return } @@ -635,7 +778,7 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.AUTHENTICATING, - statusHint = "正在完成身份验证...", + statusHint = t("session.hint.authenticating"), certFingerprint = hello.certFingerprintSha256.orEmpty() ) } @@ -647,10 +790,12 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "连接超时,请重试" + statusHint = t("session.hint.connection_timeout_retry"), + isSwitchingServer = false, + switchingServerLabel = "" ) } - addSystemMessage("认证超时,请检查网络后重试") + addSystemMessage(t("session.msg.auth_timeout")) socketClient.close(1000, "auth_timeout") } } @@ -658,16 +803,23 @@ object ChatSessionManager { runCatching { sendAuth(hello.authChallenge) }.onSuccess { - addSystemMessage("已发送认证请求") + addSystemMessage(t("session.msg.auth_request_sent")) }.onFailure { error -> cancelAuthTimeout() _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "认证失败" + statusHint = t("session.hint.auth_failed"), + isSwitchingServer = false, + switchingServerLabel = "" ) } - addSystemMessage("认证发送失败:${error.message ?: "unknown"}") + addSystemMessage( + tf( + "session.msg.auth_send_failed", + error.message ?: t("common.unknown") + ) + ) socketClient.close(1000, "auth_failed") } } @@ -716,7 +868,11 @@ object ChatSessionManager { val cipher = withContext(Dispatchers.Default) { cryptoManager.encryptChunked(serverPublicKey, plain) } - check(socketClient.send(cipher)) { "连接不可用" } + val sizeBytes = cipher.toByteArray(StandardCharsets.UTF_8).size + require(sizeBytes <= MAX_OUTBOUND_MESSAGE_BYTES) { + tf("session.error.message_too_large", sizeBytes) + } + check(socketClient.send(cipher)) { t("session.error.connection_unavailable") } } /** @@ -732,33 +888,109 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.READY, - statusHint = "已连接,可以开始聊天" + statusHint = t("session.hint.ready_to_chat"), + isSwitchingServer = false, + switchingServerLabel = "" + ) + } + addSystemMessage(t("session.msg.ready")) + } + + "rename_ok" -> { + val appliedName = message.data.asStringOrNull()?.trim().orEmpty() + .take(64) + .ifBlank { _uiState.value.displayName } + _uiState.update { + it.copy( + displayName = appliedName, + statusHint = tf("session.hint.display_name_updated", appliedName) ) } - addSystemMessage("连接准备完成") + scope.launch { + preferencesRepository.setDisplayName(appliedName) + _events.emit(UiEvent.ShowSnackbar(tf("session.msg.display_name_updated", appliedName))) + } + } + + "rename_error" -> { + val reason = message.data.asStringOrNull()?.takeIf { it.isNotBlank() } + ?: t("common.unknown") + _uiState.update { + it.copy(statusHint = tf("session.hint.display_name_update_failed", reason)) + } + scope.launch { + _events.emit(UiEvent.ShowSnackbar(tf("session.msg.display_name_update_failed", reason))) + } } "broadcast" -> { - val sender = message.key?.takeIf { it.isNotBlank() } ?: "匿名用户" - addIncomingMessage( - sender = sender, - subtitle = "", - content = message.data.asPayloadText(), - channel = MessageChannel.BROADCAST - ) + val sender = message.key?.takeIf { it.isNotBlank() } ?: t("session.sender.anonymous") + val payloadText = message.data.asPayloadText() + val audioChunk = parseAudioChunkPayload(payloadText) + if (audioChunk != null) { + ingestIncomingAudioChunk( + sender = sender, + subtitle = "", + channel = MessageChannel.BROADCAST, + chunk = audioChunk + ) + return + } + val audio = parseAudioPayload(payloadText) + if (audio != null) { + addIncomingAudioMessage( + sender = sender, + subtitle = "", + audioBase64 = audio.data, + durationMillis = audio.durationMillis, + channel = MessageChannel.BROADCAST + ) + } else { + addIncomingMessage( + sender = sender, + subtitle = "", + content = payloadText, + channel = MessageChannel.BROADCAST + ) + } } "forward" -> { val sourceKey = message.key.orEmpty() - addIncomingMessage( - sender = "私聊消息", - subtitle = sourceKey.takeIf { it.isNotBlank() }?.let { "来自 ${summarizeKey(it)}" }.orEmpty(), - content = message.data.asPayloadText(), - channel = MessageChannel.PRIVATE - ) + val payloadText = message.data.asPayloadText() + val subtitle = sourceKey.takeIf { it.isNotBlank() } + ?.let { tf("session.subtitle.from_key", summarizeKey(it)) } + .orEmpty() + val audioChunk = parseAudioChunkPayload(payloadText) + if (audioChunk != null) { + ingestIncomingAudioChunk( + sender = t("session.sender.private_message"), + subtitle = subtitle, + channel = MessageChannel.PRIVATE, + chunk = audioChunk + ) + return + } + val audio = parseAudioPayload(payloadText) + if (audio != null) { + addIncomingAudioMessage( + sender = t("session.sender.private_message"), + subtitle = subtitle, + audioBase64 = audio.data, + durationMillis = audio.durationMillis, + channel = MessageChannel.PRIVATE + ) + } else { + addIncomingMessage( + sender = t("session.sender.private_message"), + subtitle = subtitle, + content = payloadText, + channel = MessageChannel.PRIVATE + ) + } } - else -> addSystemMessage("收到未识别消息类型:${message.type}") + else -> addSystemMessage(tf("session.msg.unknown_message_type", message.type)) } } @@ -781,6 +1013,38 @@ object ChatSessionManager { return } + val reasonLower = reason.lowercase() + val isPolicyBlocked = code == 1008 || + reasonLower.contains("ip blocked") || + reasonLower.contains("message too large") || + reasonLower.contains("rate limited") + if (isPolicyBlocked) { + keepAliveRequested = false + _uiState.update { + it.copy( + status = ConnectionStatus.ERROR, + statusHint = tf( + "session.hint.server_rejected", + reason.ifBlank { t("session.text.policy_restriction") } + ), + isSwitchingServer = false, + switchingServerLabel = "" + ) + } + addSystemMessage( + tf( + "session.msg.server_rejected", + code, + reason.ifBlank { t("session.text.policy_restriction") } + ) + ) + scope.launch { + preferencesRepository.setShouldAutoReconnect(false) + } + ChatForegroundService.stop(app) + return + } + val currentStatus = _uiState.value.status val allowFallback = !fallbackTried && currentStatus != ConnectionStatus.READY @@ -794,11 +1058,11 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.CONNECTING, - statusHint = "正在自动重试连接...", + statusHint = t("session.hint.auto_retry_connecting"), serverUrl = fallbackUrl ) } - addSystemMessage("连接方式切换中,正在重试") + addSystemMessage(t("session.msg.switching_connection_mode_retry")) socketClient.connect(fallbackUrl, socketListener) return } @@ -807,11 +1071,19 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "连接已中断,正在重试" + statusHint = t("session.hint.connection_interrupted_retry"), + isSwitchingServer = false, + switchingServerLabel = "" ) } - addSystemMessage("连接关闭 ($code):${reason.ifBlank { "连接中断" }}") - scheduleReconnect("连接已中断") + addSystemMessage( + tf( + "session.msg.connection_closed_with_code", + code, + reason.ifBlank { t("session.text.connection_interrupted") } + ) + ) + scheduleReconnect(t("session.reason.connection_interrupted")) } /** @@ -821,7 +1093,7 @@ object ChatSessionManager { private fun addSystemMessage(content: String) { val message = UiMessage( role = MessageRole.SYSTEM, - sender = "系统", + sender = t("session.sender.system"), subtitle = "", content = content, channel = MessageChannel.BROADCAST @@ -845,7 +1117,7 @@ object ChatSessionManager { ) { showIncomingNotification( title = sender, - body = content.ifBlank { "收到一条新消息" } + body = content.ifBlank { t("session.notification.new_message") } ) appendMessage( UiMessage( @@ -858,6 +1130,31 @@ object ChatSessionManager { ) } + private fun addIncomingAudioMessage( + sender: String, + subtitle: String, + audioBase64: String, + durationMillis: Long, + channel: MessageChannel + ) { + showIncomingNotification( + title = sender, + body = t("session.notification.new_voice_message") + ) + appendMessage( + UiMessage( + role = MessageRole.INCOMING, + sender = sender, + subtitle = subtitle, + content = t("session.message.voice"), + channel = channel, + contentType = MessageContentType.AUDIO, + audioBase64 = audioBase64, + audioDurationMillis = durationMillis.coerceAtLeast(0L) + ) + ) + } + /** * 添加一条发出的消息。 * @param content 消息内容 @@ -872,7 +1169,7 @@ object ChatSessionManager { appendMessage( UiMessage( role = MessageRole.OUTGOING, - sender = "我", + sender = t("session.sender.me"), subtitle = subtitle, content = content, channel = channel @@ -880,6 +1177,183 @@ object ChatSessionManager { ) } + private fun addOutgoingAudioMessage( + subtitle: String, + channel: MessageChannel, + audioBase64: String, + durationMillis: Long + ) { + appendMessage( + UiMessage( + role = MessageRole.OUTGOING, + sender = t("session.sender.me"), + subtitle = subtitle, + content = t("session.message.voice"), + channel = channel, + contentType = MessageContentType.AUDIO, + audioBase64 = audioBase64, + audioDurationMillis = durationMillis.coerceAtLeast(0L) + ) + ) + } + + private fun resolveOutgoingRoute(state: ChatUiState): OutgoingRoute? { + val key = if (state.directMode) state.targetKey.trim() else "" + if (state.directMode && key.isBlank()) { + _uiState.update { it.copy(statusHint = t("session.hint.fill_target_key_before_private")) } + return null + } + val type = if (key.isBlank()) "broadcast" else "forward" + val channel = if (key.isBlank()) MessageChannel.BROADCAST else MessageChannel.PRIVATE + val subtitle = if (key.isBlank()) "" else tf("session.subtitle.private_to_key", summarizeKey(key)) + return OutgoingRoute(type = type, key = key, channel = channel, subtitle = subtitle) + } + + private suspend fun sendSignedPayload(route: OutgoingRoute, payloadText: String) { + val id = ensureIdentity() + val timestamp = cryptoManager.unixSecondsNow() + val nonce = cryptoManager.createNonce() + val signingInput = listOf( + route.type, + route.key, + payloadText, + timestamp.toString(), + nonce + ).joinToString("\n") + val signature = withContext(Dispatchers.Default) { + cryptoManager.signText(id.privateKey, signingInput) + } + + val payload = SignedPayloadDto( + payload = payloadText, + timestamp = timestamp, + nonce = nonce, + signature = signature + ) + val envelope = EnvelopeDto( + type = route.type, + key = route.key, + data = json.encodeToJsonElement(payload) + ) + + val plain = json.encodeToString(envelope) + val cipher = withContext(Dispatchers.Default) { + cryptoManager.encryptChunked(serverPublicKey, plain) + } + check(socketClient.send(cipher)) { t("session.error.connection_unavailable") } + } + + private suspend fun sendDisplayNameUpdate(displayName: String) { + sendSignedPayload( + route = OutgoingRoute( + type = "rename", + key = "", + channel = MessageChannel.BROADCAST, + subtitle = "" + ), + payloadText = displayName + ) + } + + private fun parseAudioPayload(payloadText: String): AudioPayloadDto? { + if (!payloadText.startsWith(AUDIO_MESSAGE_PREFIX)) return null + val encoded = payloadText.removePrefix(AUDIO_MESSAGE_PREFIX).trim() + if (encoded.isBlank()) return null + return runCatching { + json.decodeFromString(encoded) + }.getOrNull()?.takeIf { dto -> + dto.encoding.equals("base64", ignoreCase = true) && dto.data.isNotBlank() + } + } + + private fun parseAudioChunkPayload(payloadText: String): AudioChunkPayloadDto? { + if (!payloadText.startsWith(AUDIO_CHUNK_MESSAGE_PREFIX)) return null + val encoded = payloadText.removePrefix(AUDIO_CHUNK_MESSAGE_PREFIX).trim() + if (encoded.isBlank()) return null + return runCatching { + json.decodeFromString(encoded) + }.getOrNull()?.takeIf { dto -> + dto.encoding.equals("base64", ignoreCase = true) && + dto.messageId.isNotBlank() && + dto.total in 1..MAX_AUDIO_CHUNK_COUNT && + dto.index in 0 until dto.total && + dto.data.isNotBlank() + } + } + + private fun ingestIncomingAudioChunk( + sender: String, + subtitle: String, + channel: MessageChannel, + chunk: AudioChunkPayloadDto + ) { + val now = System.currentTimeMillis() + purgeExpiredAudioChunkBuffers(now) + val bufferKey = "${channel.name}:${sender}:${chunk.messageId}" + val buffer = incomingAudioChunkBuffers[bufferKey] + val active = if (buffer == null || buffer.total != chunk.total) { + IncomingAudioChunkBuffer( + sender = sender, + subtitle = subtitle, + channel = channel, + total = chunk.total, + durationMillis = chunk.durationMillis.coerceAtLeast(0L), + createdAtMillis = now, + chunks = MutableList(chunk.total) { null } + ).also { created -> + incomingAudioChunkBuffers[bufferKey] = created + } + } else { + if (buffer.sender != sender || buffer.channel != channel) { + return + } + buffer + } + + active.chunks[chunk.index] = chunk.data + val completed = active.chunks.all { !it.isNullOrBlank() } + if (!completed) return + + incomingAudioChunkBuffers.remove(bufferKey) + val merged = buildString { + active.chunks.forEach { part -> + append(part.orEmpty()) + } + } + if (merged.isBlank()) return + + addIncomingAudioMessage( + sender = active.sender, + subtitle = active.subtitle, + audioBase64 = merged, + durationMillis = active.durationMillis, + channel = active.channel + ) + } + + private fun purgeExpiredAudioChunkBuffers(nowMillis: Long) { + if (incomingAudioChunkBuffers.isEmpty()) return + val expiredKeys = incomingAudioChunkBuffers + .filterValues { nowMillis - it.createdAtMillis >= AUDIO_CHUNK_BUFFER_TTL_MS } + .keys + expiredKeys.forEach { key -> + incomingAudioChunkBuffers.remove(key) + } + } + + private fun splitAudioBase64(base64: String, chunkSize: Int): List { + if (base64.isEmpty() || chunkSize <= 0) return emptyList() + if (base64.length <= chunkSize) return listOf(base64) + val chunks = ArrayList((base64.length + chunkSize - 1) / chunkSize) + var start = 0 + while (start < base64.length) { + val end = minOf(start + chunkSize, base64.length) + chunks.add(base64.substring(start, end)) + start = end + } + return chunks + } + /** * 将消息追加到列表尾部,并清理超出数量限制的消息。 * @param message 要追加的消息 @@ -894,6 +1368,79 @@ object ChatSessionManager { } current.copy(messages = next) } + if (message.role == MessageRole.SYSTEM) return + scope.launch(Dispatchers.IO) { + runCatching { + historyRepository.appendMessage(currentServerKey(), message, MAX_MESSAGES) + } + } + } + + private fun switchServer( + normalized: String, + nextUrls: List, + statusHint: String?, + persist: suspend () -> Unit, + snackbarMessage: String? = null + ) { + val targetServerKey = serverKeyFor(normalized) + val previousServerKey = currentServerKey() + val shouldReconnect = previousServerKey != targetServerKey || _uiState.value.status != ConnectionStatus.READY + val switchingLabel = summarizeServerLabel(normalized) + + scope.launch { + _uiState.update { + it.copy( + serverUrl = normalized, + serverUrls = nextUrls, + isSwitchingServer = shouldReconnect, + switchingServerLabel = if (shouldReconnect) switchingLabel else "", + statusHint = statusHint ?: it.statusHint + ) + } + persist() + val historyMessages = withContext(Dispatchers.IO) { + historyRepository.loadMessages(targetServerKey, MAX_MESSAGES) + } + _uiState.update { + it.copy( + serverUrl = normalized, + serverUrls = nextUrls, + messages = historyMessages, + certFingerprint = if (previousServerKey == targetServerKey) it.certFingerprint else "", + statusHint = statusHint ?: it.statusHint + ) + } + if (shouldReconnect) { + connectInternal( + isAutoRestore = false, + overrideUrl = normalized, + forceReconnect = !_uiState.value.canConnect + ) + } + if (!snackbarMessage.isNullOrBlank()) { + _events.emit(UiEvent.ShowSnackbar(snackbarMessage)) + } + } + } + + private fun currentServerKey(): String = serverKeyFor(_uiState.value.serverUrl) + + private fun serverKeyFor(rawUrl: String): String { + return ServerUrlFormatter.normalize(rawUrl).ifBlank { ServerUrlFormatter.defaultServerUrl } + } + + private fun summarizeServerLabel(rawUrl: String): String { + val normalized = serverKeyFor(rawUrl) + val parsed = runCatching { Uri.parse(normalized) }.getOrNull() + val host = parsed?.host + val port = parsed?.port?.takeIf { it > 0 } + val path = parsed?.encodedPath?.takeIf { !it.isNullOrBlank() && it != "/" } + return buildString { + append(host ?: normalized) + if (port != null) append(":$port") + if (path != null) append(path) + }.ifBlank { normalized } } /** @@ -915,11 +1462,11 @@ object ChatSessionManager { reconnectAttempt += 1 val exponential = 1 shl minOf(reconnectAttempt - 1, 5) val delaySeconds = minOf(MAX_RECONNECT_DELAY_SECONDS, exponential) - addSystemMessage("$reason,${delaySeconds}s 后自动重连(第 $reconnectAttempt 次)") + addSystemMessage(tf("session.msg.auto_reconnect_in", reason, delaySeconds, reconnectAttempt)) _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "${delaySeconds}s 后自动重连(第 $reconnectAttempt 次)" + statusHint = tf("session.hint.auto_reconnect_in", delaySeconds, reconnectAttempt) ) } @@ -934,7 +1481,7 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "重连失败:服务器地址无效" + statusHint = t("session.hint.reconnect_invalid_server") ) } return@launch @@ -948,7 +1495,7 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.CONNECTING, - statusHint = "正在自动重连..." + statusHint = t("session.hint.auto_reconnecting") ) } socketClient.connect(target, socketListener) @@ -999,10 +1546,12 @@ object ChatSessionManager { _uiState.update { it.copy( status = ConnectionStatus.ERROR, - statusHint = "握手超时,请检查地址路径与反向代理" + statusHint = t("session.hint.handshake_timeout"), + isSwitchingServer = false, + switchingServerLabel = "" ) } - addSystemMessage("握手超时:未收到服务端 publickey 首包(当前地址:$currentUrl)") + addSystemMessage(tf("session.msg.handshake_timeout_with_url", currentUrl)) socketClient.close(1000, "hello_timeout") } } @@ -1059,6 +1608,7 @@ object ChatSessionManager { * 关闭所有资源(用于应用退出时)。 */ fun shutdownAll() { + displayNameSyncJob?.cancel() cancelSystemMessageExpiryJobs() cancelReconnect() cancelHelloTimeout() @@ -1089,19 +1639,36 @@ object ChatSessionManager { /** * 创建消息通知渠道(Android O+)。 */ - private fun ensureMessageNotificationChannel() { + private fun ensureMessageNotificationChannel(soundCode: String = "default") { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val manager = app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channelId = NotificationSoundCatalog.channelId(soundCode) + if (manager.getNotificationChannel(channelId) != null) return + val channel = NotificationChannel( - MESSAGE_CHANNEL_ID, - "OnlineMsg 消息提醒", + channelId, + t("session.notification.channel_name"), NotificationManager.IMPORTANCE_DEFAULT ).apply { - description = "收到服务器新消息时提醒" + description = t("session.notification.channel_desc") + getSoundUri(soundCode)?.let { uri -> + setSound( + uri, + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + ) + } } manager.createNotificationChannel(channel) } + private fun getSoundUri(code: String): Uri? { + val resId = NotificationSoundCatalog.resId(code) ?: return null + return Uri.parse("${ContentResolver.SCHEME_ANDROID_RESOURCE}://${app.packageName}/$resId") + } + /** * 显示新消息到达的通知。 * @param title 通知标题 @@ -1124,8 +1691,10 @@ object ChatSessionManager { launchIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) + val channelId = NotificationSoundCatalog.channelId(_uiState.value.notificationSound) + ensureMessageNotificationChannel(_uiState.value.notificationSound) - val notification = NotificationCompat.Builder(app, MESSAGE_CHANNEL_ID) + val notification = NotificationCompat.Builder(app, channelId) .setSmallIcon(android.R.drawable.stat_notify_chat) .setContentTitle(title.ifBlank { "OnlineMsg" }) .setContentText(body.take(120)) @@ -1148,11 +1717,33 @@ object ChatSessionManager { return notificationIdSeed } + private data class OutgoingRoute( + val type: String, + val key: String, + val channel: MessageChannel, + val subtitle: String + ) + + private data class IncomingAudioChunkBuffer( + val sender: String, + val subtitle: String, + val channel: MessageChannel, + val total: Int, + val durationMillis: Long, + val createdAtMillis: Long, + val chunks: MutableList + ) + // 常量定义 private const val HELLO_TIMEOUT_MS = 12_000L private const val AUTH_TIMEOUT_MS = 20_000L private const val MAX_MESSAGES = 500 private const val MAX_RECONNECT_DELAY_SECONDS = 30 private const val SYSTEM_MESSAGE_TTL_MS = 1_000L - private const val MESSAGE_CHANNEL_ID = "onlinemsg_messages" -} \ No newline at end of file + private const val AUDIO_MESSAGE_PREFIX = "[[OMS_AUDIO_V1]]" + private const val AUDIO_CHUNK_MESSAGE_PREFIX = "[[OMS_AUDIO_CHUNK_V1]]" + private const val AUDIO_CHUNK_BASE64_SIZE = 20_000 + private const val MAX_AUDIO_CHUNK_COUNT = 30 + private const val AUDIO_CHUNK_BUFFER_TTL_MS = 180_000L + private const val MAX_OUTBOUND_MESSAGE_BYTES = 60 * 1024 +} 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 c7cc58b..abfe61c 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 @@ -31,6 +31,14 @@ enum class MessageChannel { PRIVATE } +/** + * 消息内容类型(文本/音频)。 + */ +enum class MessageContentType { + TEXT, + AUDIO +} + /** * 单条消息的数据类。 * @property id 唯一标识(默认随机 UUID) @@ -48,7 +56,10 @@ data class UiMessage( val subtitle: String = "", val content: String, val channel: MessageChannel, - val timestampMillis: Long = System.currentTimeMillis() + val timestampMillis: Long = System.currentTimeMillis(), + val contentType: MessageContentType = MessageContentType.TEXT, + val audioBase64: String = "", + val audioDurationMillis: Long = 0L ) /** @@ -86,7 +97,10 @@ data class ChatUiState( val loadingPublicKey: Boolean = false, val themeId: String = "blue", val useDynamicColor: Boolean = true, - val language: String = "zh" + val language: String = "zh", + val notificationSound: String = "default", + val isSwitchingServer: Boolean = false, + val switchingServerLabel: String = "" ) { /** * 是否允许连接。 @@ -141,4 +155,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 8b53474..a0ce6e5 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 @@ -19,6 +19,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { fun updateDisplayName(value: String) = ChatSessionManager.updateDisplayName(value) fun updateServerUrl(value: String) = ChatSessionManager.updateServerUrl(value) + fun selectServerUrl(value: String) = ChatSessionManager.selectServerUrl(value) fun updateTargetKey(value: String) = ChatSessionManager.updateTargetKey(value) fun updateDraft(value: String) = ChatSessionManager.updateDraft(value) fun toggleDirectMode(enabled: Boolean) = ChatSessionManager.toggleDirectMode(enabled) @@ -30,9 +31,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { fun connect() = ChatSessionManager.connect() fun disconnect() = ChatSessionManager.disconnect() fun sendMessage() = ChatSessionManager.sendMessage() + fun sendAudioMessage(audioBase64: String, durationMillis: Long) = + ChatSessionManager.sendAudioMessage(audioBase64, durationMillis) fun onMessageCopied() = ChatSessionManager.onMessageCopied() fun updateTheme(themeId: String) = ChatSessionManager.updateTheme(themeId) fun updateUseDynamicColor(enabled: Boolean) = ChatSessionManager.updateUseDynamicColor(enabled) fun updateLanguage(language: String) = ChatSessionManager.updateLanguage(language) -} \ No newline at end of file + fun updateNotificationSound(sound: String) = ChatSessionManager.updateNotificationSound(sound) +} 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 355c591..c14bbf5 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt @@ -6,6 +6,87 @@ package com.onlinemsg.client.util */ object LanguageManager { + private val zhHantOverrides = mapOf( + "tab.settings" to "設定", + "settings.personal" to "個人設定", + "settings.display_name" 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.chat_data" to "聊天資料", + "settings.dynamic_color" to "使用動態顏色", + "settings.notification_sound" to "通知音效", + "settings.notification_enabled" to "系統通知與音效已啟用", + "settings.notification_disabled" to "系統通知目前已關閉", + "settings.notification_sound_disabled" to "目前音效渠道已被靜音", + "settings.notification_system_settings" to "系統通知設定", + "settings.notification_channel_settings" to "當前音效設定", + "status.idle" to "未連線", + "status.connecting" to "連線中", + "status.ready" to "已連線", + "status.error" to "異常中斷", + "hint.tap_to_connect" to "點擊連線開始聊天", + "hint.connecting_server" to "正在連線伺服器...", + "hint.ready_chat" to "已連線,可以開始聊天", + "hint.closed" to "連線已關閉", + "hint.reconnecting" to "連線已中斷,正在重試", + "session.hint.updating_display_name" to "正在同步顯示名稱...", + "session.hint.display_name_updated" to "顯示名稱已更新為 %s", + "session.hint.display_name_update_failed" to "顯示名稱更新失敗:%s", + "session.hint.switching_server" to "正在切換伺服器", + "hint.reconnect_invalid_server" to "重連失敗:伺服器位址無效", + "hint.fill_target_key" to "請先填寫目標公鑰,再傳送私訊", + "hint.server_rejected_prefix" to "伺服器拒絕連線:", + "hint.audio_send_failed_prefix" to "語音傳送失敗:", + "chat.private" to "私訊", + "chat.target_key" to "目標公鑰", + "chat.input_placeholder" to "輸入訊息", + "chat.send" to "傳送", + "chat.sending" to "傳送中", + "chat.empty_hint" to "連線後即可聊天。預設為廣播,切換到私訊後可填寫目標公鑰。", + "chat.mode_text" to "文字", + "chat.mode_audio" to "語音", + "chat.audio_hold_to_talk" to "按住說話", + "chat.audio_release_send" to "鬆開即可傳送", + "chat.audio_release_cancel" to "鬆開即可取消", + "chat.audio_slide_cancel" to "按住時上滑即可取消", + "chat.audio_canceled" to "已取消語音傳送", + "chat.audio_sent" to "語音訊息已傳送", + "chat.audio_too_short" to "錄音時間太短", + "chat.audio_too_long" to "錄音時間太長", + "chat.audio_record_failed" to "錄音失敗", + "chat.audio_permission_required" to "需要麥克風權限", + "chat.audio_recording" to "錄音中", + "chat.audio_play" to "播放語音", + "chat.audio_stop" to "停止播放", + "session.msg.display_name_updated" to "顯示名稱已更新為 %s", + "session.msg.display_name_update_failed" to "顯示名稱同步失敗:%s", + "sound.default" to "預設", + "sound.ding" to "叮", + "sound.nameit5" to "音效 5", + "sound.wind_chime" to "風鈴", + "theme.blue" to "蔚藍", + "theme.gray" to "商務灰", + "theme.green" to "翠綠", + "theme.red" to "緋紅", + "theme.warm" to "溫暖" + ) + private val translations = mapOf( "zh" to mapOf( "tab.chat" to "聊天", @@ -32,7 +113,108 @@ object LanguageManager { "settings.connect" to "连接", "settings.disconnect" to "断开", "settings.clear_msg" to "清空消息", + "settings.chat_data" to "聊天数据", "settings.dynamic_color" to "使用动态颜色", + "settings.notification_sound" to "通知音效", + "settings.notification_enabled" to "系统通知与音效已启用", + "settings.notification_disabled" to "系统通知当前已关闭", + "settings.notification_sound_disabled" to "当前音效渠道已被静音", + "settings.notification_system_settings" to "系统通知设置", + "settings.notification_channel_settings" to "当前音效设置", + "sound.default" to "默认", + "sound.ding" to "叮", + "sound.nameit5" to "音效 5", + "sound.wind_chime" to "风铃", + "status.idle" to "未连接", + "status.connecting" to "连接中", + "status.ready" to "已连接", + "status.error" to "异常断开", + "hint.tap_to_connect" to "点击连接开始聊天", + "hint.connecting_server" to "正在连接服务器...", + "hint.ready_chat" to "已连接,可以开始聊天", + "hint.closed" to "连接已关闭", + "hint.reconnecting" to "连接已中断,正在重试", + "session.hint.switching_server" to "正在切换服务器", + "hint.reconnect_invalid_server" to "重连失败:服务器地址无效", + "hint.fill_target_key" to "请先填写目标公钥,再发送私聊消息", + "hint.server_rejected_prefix" to "服务器拒绝连接:", + "hint.audio_send_failed_prefix" to "语音发送失败:", + "session.sender.system" to "系统", + "session.sender.me" to "我", + "session.sender.anonymous" to "匿名用户", + "session.sender.private_message" to "私聊消息", + "session.reason.connection_error" to "连接异常", + "session.reason.connection_interrupted" to "连接已中断", + "session.text.policy_restriction" to "策略限制", + "session.text.connection_interrupted" to "连接中断", + "session.snackbar.invalid_server" to "请输入有效的服务器地址", + "session.snackbar.server_saved" to "服务器地址已保存", + "session.snackbar.server_list_updated" to "已更新服务器地址列表", + "session.snackbar.public_key_read_failed" to "公钥读取失败:%s", + "session.hint.connected_preparing" to "已连接,正在准备聊天...", + "session.hint.binary_handshake_parsing" to "收到二进制握手帧,正在尝试解析...", + "session.hint.connection_error_retrying" to "连接异常,正在重试", + "session.hint.server_saved" to "服务器地址已保存", + "session.hint.server_restored_default" to "已恢复默认服务器地址", + "session.hint.server_removed" to "已移除当前服务器地址", + "session.hint.fill_valid_server" to "请填写有效服务器地址", + "session.hint.connecting_server" to "正在连接服务器...", + "session.hint.connection_closed" to "连接已关闭", + "session.hint.audio_chunk_over_limit" to "语音过长,超过可发送分片上限", + "session.hint.audio_send_failed" to "语音发送失败:%s", + "session.hint.handshake_data_received" to "已收到握手数据,正在解析...", + "session.hint.handshake_incomplete_response" to "握手失败:服务端响应不完整", + "session.hint.handshake_unexpected_message" to "握手失败:收到非预期消息", + "session.hint.handshake_first_packet_parse_failed" to "握手失败:首包解析失败", + "session.hint.authenticating" to "正在完成身份验证...", + "session.hint.connection_timeout_retry" to "连接超时,请重试", + "session.hint.auth_failed" to "认证失败", + "session.hint.ready_to_chat" to "已连接,可以开始聊天", + "session.hint.updating_display_name" to "正在同步显示名称...", + "session.hint.display_name_updated" to "显示名称已更新为 %s", + "session.hint.display_name_update_failed" to "显示名称更新失败:%s", + "session.hint.server_rejected" to "服务器拒绝连接:%s", + "session.hint.auto_retry_connecting" to "正在自动重试连接...", + "session.hint.connection_interrupted_retry" to "连接已中断,正在重试", + "session.hint.fill_target_key_before_private" to "请先填写目标公钥,再发送私聊消息", + "session.hint.auto_reconnect_in" to "%ds 后自动重连(第 %d 次)", + "session.hint.reconnect_invalid_server" to "重连失败:服务器地址无效", + "session.hint.auto_reconnecting" to "正在自动重连...", + "session.hint.handshake_timeout" to "握手超时,请检查地址路径与反向代理", + "session.msg.connection_established" to "连接已建立", + "session.msg.text_frame_error" to "文本帧处理异常:%s", + "session.msg.binary_frame_error" to "二进制帧处理异常:%s", + "session.msg.handshake_binary_unreadable" to "握手二进制帧无法转为文本,len=%d hex=%s", + "session.msg.connection_error" to "连接异常:%s", + "session.msg.auto_restore_connecting" to "已恢复上次会话,正在自动连接", + "session.msg.disconnected" to "已断开连接", + "session.msg.send_failed" to "发送失败:%s", + "session.msg.audio_chunk_canceled" to "语音过长,已取消发送", + "session.msg.audio_send_failed" to "语音发送失败:%s", + "session.msg.handshake_unexpected_type" to "握手阶段收到非预期消息类型:%s", + "session.msg.handshake_parse_failed" to "握手包解析失败:%s", + "session.msg.decryption_failed" to "收到无法解密的消息", + "session.msg.auth_timeout" to "认证超时,请检查网络后重试", + "session.msg.auth_request_sent" to "已发送认证请求", + "session.msg.auth_send_failed" to "认证发送失败:%s", + "session.msg.ready" to "连接准备完成", + "session.msg.display_name_updated" to "显示名称已更新为 %s", + "session.msg.display_name_update_failed" to "显示名称同步失败:%s", + "session.msg.unknown_message_type" to "收到未识别消息类型:%s", + "session.msg.server_rejected" to "连接被服务器拒绝(%d):%s", + "session.msg.switching_connection_mode_retry" to "连接方式切换中,正在重试", + "session.msg.connection_closed_with_code" to "连接关闭 (%d):%s", + "session.msg.auto_reconnect_in" to "%s,%ds 后自动重连(第 %d 次)", + "session.msg.handshake_timeout_with_url" to "握手超时:未收到服务端 publickey 首包(当前地址:%s)", + "session.error.message_too_large" to "消息体过大(%dB),请缩短消息内容后重试", + "session.error.connection_unavailable" to "连接不可用", + "session.notification.channel_name" to "OnlineMsg 消息提醒", + "session.notification.channel_desc" to "收到服务器新消息时提醒", + "session.notification.new_message" to "收到一条新消息", + "session.notification.new_voice_message" to "收到一条语音消息", + "session.message.voice" to "语音消息", + "session.subtitle.from_key" to "来自 %s", + "session.subtitle.private_to_key" to "私聊 %s", "chat.broadcast" to "广播", "chat.private" to "私聊", "chat.target_key" to "目标公钥", @@ -40,8 +222,31 @@ object LanguageManager { "chat.send" to "发送", "chat.sending" to "发送中", "chat.empty_hint" to "连接后即可聊天。默认广播,切换到私聊后可填写目标公钥。", + "chat.mode_text" to "文字", + "chat.mode_audio" to "语音", + "chat.audio_hold_to_talk" to "按住说话", + "chat.audio_release_send" to "松开发送", + "chat.audio_release_cancel" to "松开取消", + "chat.audio_slide_cancel" to "按住说话,上滑取消", + "chat.audio_canceled" to "已取消语音发送", + "chat.audio_sent" to "语音已发送", + "chat.audio_too_short" to "录音时间太短", + "chat.audio_too_long" to "录音时间过长", + "chat.audio_record_failed" to "录音失败,请重试", + "chat.audio_permission_required" to "请先授予麦克风权限", + "chat.audio_recording" to "录制中", + "chat.audio_play" to "播放语音", + "chat.audio_stop" to "停止播放", "common.copied" to "已复制", "common.unknown" to "未知", + "service.foreground.title.ready" to "OnlineMsg 已保持连接", + "service.foreground.title.connecting" to "OnlineMsg 正在连接", + "service.foreground.title.error" to "OnlineMsg 连接异常", + "service.foreground.title.idle" to "OnlineMsg 后台服务", + "service.foreground.hint.default" to "后台保持连接中", + "service.foreground.action.disconnect" to "断开", + "service.foreground.channel.name" to "OnlineMsg 后台连接", + "service.foreground.channel.desc" to "保持 WebSocket 后台长连接", "theme.blue" to "蔚蓝", "theme.gray" to "商务灰", "theme.green" to "翠绿", @@ -73,7 +278,108 @@ object LanguageManager { "settings.connect" to "Link", "settings.disconnect" to "Dislink", "settings.clear_msg" to "ClearMsg", + "settings.chat_data" to "Chat Data", "settings.dynamic_color" to "Use dynamic color", + "settings.notification_sound" to "Notification Sound", + "settings.notification_enabled" to "Notifications and sound are enabled", + "settings.notification_disabled" to "Notifications are currently disabled", + "settings.notification_sound_disabled" to "The selected sound channel is muted", + "settings.notification_system_settings" to "System Notification Settings", + "settings.notification_channel_settings" to "Current Sound Channel", + "sound.default" to "Default", + "sound.ding" to "Ding", + "sound.nameit5" to "Sound 5", + "sound.wind_chime" to "Wind Chime", + "status.idle" to "Offline", + "status.connecting" to "Connecting", + "status.ready" to "Connected", + "status.error" to "Disconnected", + "hint.tap_to_connect" to "Tap connect to start chatting", + "hint.connecting_server" to "Connecting to server...", + "hint.ready_chat" to "Connected, ready to chat", + "hint.closed" to "Connection closed", + "hint.reconnecting" to "Connection interrupted, reconnecting", + "session.hint.switching_server" to "Switching server", + "hint.reconnect_invalid_server" to "Reconnect failed: invalid server address", + "hint.fill_target_key" to "Please fill target public key before private message", + "hint.server_rejected_prefix" to "Server rejected connection: ", + "hint.audio_send_failed_prefix" to "Voice send failed: ", + "session.sender.system" to "System", + "session.sender.me" to "Me", + "session.sender.anonymous" to "Anonymous", + "session.sender.private_message" to "Private Message", + "session.reason.connection_error" to "Connection error", + "session.reason.connection_interrupted" to "Connection interrupted", + "session.text.policy_restriction" to "policy restriction", + "session.text.connection_interrupted" to "connection interrupted", + "session.snackbar.invalid_server" to "Please enter a valid server address", + "session.snackbar.server_saved" to "Server address saved", + "session.snackbar.server_list_updated" to "Server address list updated", + "session.snackbar.public_key_read_failed" to "Public key read failed: %s", + "session.hint.connected_preparing" to "Connected, preparing chat...", + "session.hint.binary_handshake_parsing" to "Received binary handshake frame, parsing...", + "session.hint.connection_error_retrying" to "Connection error, retrying", + "session.hint.server_saved" to "Server address saved", + "session.hint.server_restored_default" to "Default server restored", + "session.hint.server_removed" to "Current server removed", + "session.hint.fill_valid_server" to "Please enter a valid server address", + "session.hint.connecting_server" to "Connecting to server...", + "session.hint.connection_closed" to "Connection closed", + "session.hint.audio_chunk_over_limit" to "Voice message too long, chunk limit exceeded", + "session.hint.audio_send_failed" to "Voice send failed: %s", + "session.hint.handshake_data_received" to "Handshake data received, parsing...", + "session.hint.handshake_incomplete_response" to "Handshake failed: incomplete server response", + "session.hint.handshake_unexpected_message" to "Handshake failed: unexpected message", + "session.hint.handshake_first_packet_parse_failed" to "Handshake failed: first packet parse failed", + "session.hint.authenticating" to "Authenticating...", + "session.hint.connection_timeout_retry" to "Connection timeout, please retry", + "session.hint.auth_failed" to "Authentication failed", + "session.hint.ready_to_chat" to "Connected, ready to chat", + "session.hint.updating_display_name" to "Syncing display name...", + "session.hint.display_name_updated" to "Display name updated to %s", + "session.hint.display_name_update_failed" to "Display name update failed: %s", + "session.hint.server_rejected" to "Server rejected connection: %s", + "session.hint.auto_retry_connecting" to "Auto retry connecting...", + "session.hint.connection_interrupted_retry" to "Connection interrupted, retrying", + "session.hint.fill_target_key_before_private" to "Please enter target public key before private message", + "session.hint.auto_reconnect_in" to "Auto reconnect in %ds (attempt %d)", + "session.hint.reconnect_invalid_server" to "Reconnect failed: invalid server address", + "session.hint.auto_reconnecting" to "Auto reconnecting...", + "session.hint.handshake_timeout" to "Handshake timeout, check URL path or reverse proxy", + "session.msg.connection_established" to "Connection established", + "session.msg.text_frame_error" to "Text frame processing error: %s", + "session.msg.binary_frame_error" to "Binary frame processing error: %s", + "session.msg.handshake_binary_unreadable" to "Binary handshake frame unreadable, len=%d hex=%s", + "session.msg.connection_error" to "Connection error: %s", + "session.msg.auto_restore_connecting" to "Restored last session, auto connecting", + "session.msg.disconnected" to "Disconnected", + "session.msg.send_failed" to "Send failed: %s", + "session.msg.audio_chunk_canceled" to "Voice message too long, canceled", + "session.msg.audio_send_failed" to "Voice send failed: %s", + "session.msg.handshake_unexpected_type" to "Unexpected message type during handshake: %s", + "session.msg.handshake_parse_failed" to "Handshake packet parse failed: %s", + "session.msg.decryption_failed" to "Received undecryptable message", + "session.msg.auth_timeout" to "Authentication timeout, please check network and retry", + "session.msg.auth_request_sent" to "Authentication request sent", + "session.msg.auth_send_failed" to "Authentication send failed: %s", + "session.msg.ready" to "Connection ready", + "session.msg.display_name_updated" to "Display name updated to %s", + "session.msg.display_name_update_failed" to "Display name sync failed: %s", + "session.msg.unknown_message_type" to "Unknown message type received: %s", + "session.msg.server_rejected" to "Connection rejected by server (%d): %s", + "session.msg.switching_connection_mode_retry" to "Switching connection mode, retrying", + "session.msg.connection_closed_with_code" to "Connection closed (%d): %s", + "session.msg.auto_reconnect_in" to "%s, auto reconnect in %ds (attempt %d)", + "session.msg.handshake_timeout_with_url" to "Handshake timeout: no server publickey packet (url: %s)", + "session.error.message_too_large" to "Message too large (%dB), please shorten and retry", + "session.error.connection_unavailable" to "Connection unavailable", + "session.notification.channel_name" to "OnlineMsg Notifications", + "session.notification.channel_desc" to "Notify when new server messages arrive", + "session.notification.new_message" to "New message received", + "session.notification.new_voice_message" to "New voice message received", + "session.message.voice" to "Voice message", + "session.subtitle.from_key" to "From %s", + "session.subtitle.private_to_key" to "Private %s", "chat.broadcast" to "Broadcast", "chat.private" to "Private", "chat.target_key" to "Target Public Key", @@ -81,8 +387,31 @@ object LanguageManager { "chat.send" to "Send", "chat.sending" to "Sending", "chat.empty_hint" to "Connect to start chatting. Default is broadcast.", + "chat.mode_text" to "Text", + "chat.mode_audio" to "Voice", + "chat.audio_hold_to_talk" to "Hold to Talk", + "chat.audio_release_send" to "Release to Send", + "chat.audio_release_cancel" to "Release to Cancel", + "chat.audio_slide_cancel" to "Hold to talk, slide up to cancel", + "chat.audio_canceled" to "Voice message canceled", + "chat.audio_sent" to "Voice message sent", + "chat.audio_too_short" to "Recording is too short", + "chat.audio_too_long" to "Recording is too long", + "chat.audio_record_failed" to "Recording failed, try again", + "chat.audio_permission_required" to "Microphone permission is required", + "chat.audio_recording" to "Recording", + "chat.audio_play" to "Play voice", + "chat.audio_stop" to "Stop", "common.copied" to "Copied", "common.unknown" to "Unknown", + "service.foreground.title.ready" to "OnlineMsg Connected", + "service.foreground.title.connecting" to "OnlineMsg Connecting", + "service.foreground.title.error" to "OnlineMsg Connection Error", + "service.foreground.title.idle" to "OnlineMsg Background Service", + "service.foreground.hint.default" to "Keeping connection in background", + "service.foreground.action.disconnect" to "Disconnect", + "service.foreground.channel.name" to "OnlineMsg Background Connection", + "service.foreground.channel.desc" to "Keep WebSocket long connection in background", "theme.blue" to "Blue", "theme.gray" to "Business Gray", "theme.green" to "Green", @@ -114,7 +443,108 @@ object LanguageManager { "settings.connect" to "接続", "settings.disconnect" to "切断", "settings.clear_msg" to "履歴を消去", + "settings.chat_data" to "チャットデータ", "settings.dynamic_color" to "動的カラーを使用", + "settings.notification_sound" to "通知音", + "settings.notification_enabled" to "通知と音が有効です", + "settings.notification_disabled" to "通知が現在オフです", + "settings.notification_sound_disabled" to "現在の音声チャンネルはミュートされています", + "settings.notification_system_settings" to "システム通知設定", + "settings.notification_channel_settings" to "現在の音設定", + "sound.default" to "デフォルト", + "sound.ding" to "ディン", + "sound.nameit5" to "効果音 5", + "sound.wind_chime" to "風鈴", + "status.idle" to "未接続", + "status.connecting" to "接続中", + "status.ready" to "接続済み", + "status.error" to "切断", + "hint.tap_to_connect" to "接続してチャットを開始", + "hint.connecting_server" to "サーバーへ接続中...", + "hint.ready_chat" to "接続完了、チャット可能", + "hint.closed" to "接続を閉じました", + "hint.reconnecting" to "接続が中断され、再接続中", + "session.hint.switching_server" to "サーバーを切り替え中", + "hint.reconnect_invalid_server" to "再接続失敗:サーバーアドレス無効", + "hint.fill_target_key" to "個人チャット前に相手の公開鍵を入力してください", + "hint.server_rejected_prefix" to "サーバーが接続を拒否しました:", + "hint.audio_send_failed_prefix" to "音声送信失敗:", + "session.sender.system" to "システム", + "session.sender.me" to "自分", + "session.sender.anonymous" to "匿名ユーザー", + "session.sender.private_message" to "個人メッセージ", + "session.reason.connection_error" to "接続エラー", + "session.reason.connection_interrupted" to "接続中断", + "session.text.policy_restriction" to "ポリシー制限", + "session.text.connection_interrupted" to "接続中断", + "session.snackbar.invalid_server" to "有効なサーバーアドレスを入力してください", + "session.snackbar.server_saved" to "サーバーアドレスを保存しました", + "session.snackbar.server_list_updated" to "サーバーアドレス一覧を更新しました", + "session.snackbar.public_key_read_failed" to "公開鍵の読み取りに失敗しました:%s", + "session.hint.connected_preparing" to "接続済み、チャット準備中...", + "session.hint.binary_handshake_parsing" to "バイナリ握手フレーム受信、解析中...", + "session.hint.connection_error_retrying" to "接続エラー、再試行中", + "session.hint.server_saved" to "サーバーアドレスを保存しました", + "session.hint.server_restored_default" to "デフォルトサーバーを復元しました", + "session.hint.server_removed" to "現在のサーバーを削除しました", + "session.hint.fill_valid_server" to "有効なサーバーアドレスを入力してください", + "session.hint.connecting_server" to "サーバーへ接続中...", + "session.hint.connection_closed" to "接続を閉じました", + "session.hint.audio_chunk_over_limit" to "音声が長すぎて分割上限を超えました", + "session.hint.audio_send_failed" to "音声送信失敗:%s", + "session.hint.handshake_data_received" to "握手データ受信、解析中...", + "session.hint.handshake_incomplete_response" to "握手失敗:サーバー応答が不完全です", + "session.hint.handshake_unexpected_message" to "握手失敗:予期しないメッセージ", + "session.hint.handshake_first_packet_parse_failed" to "握手失敗:初回パケット解析失敗", + "session.hint.authenticating" to "認証中...", + "session.hint.connection_timeout_retry" to "接続タイムアウト、再試行してください", + "session.hint.auth_failed" to "認証失敗", + "session.hint.ready_to_chat" to "接続済み、チャット可能", + "session.hint.updating_display_name" to "表示名を同期中...", + "session.hint.display_name_updated" to "表示名を %s に更新しました", + "session.hint.display_name_update_failed" to "表示名の更新に失敗しました:%s", + "session.hint.server_rejected" to "サーバーが接続を拒否しました:%s", + "session.hint.auto_retry_connecting" to "自動再試行で接続中...", + "session.hint.connection_interrupted_retry" to "接続が中断され、再試行中", + "session.hint.fill_target_key_before_private" to "個人チャット前に相手の公開鍵を入力してください", + "session.hint.auto_reconnect_in" to "%d秒後に自動再接続(%d回目)", + "session.hint.reconnect_invalid_server" to "再接続失敗:サーバーアドレス無効", + "session.hint.auto_reconnecting" to "自動再接続中...", + "session.hint.handshake_timeout" to "握手タイムアウト:URL パスまたはリバースプロキシを確認", + "session.msg.connection_established" to "接続が確立されました", + "session.msg.text_frame_error" to "テキストフレーム処理エラー:%s", + "session.msg.binary_frame_error" to "バイナリフレーム処理エラー:%s", + "session.msg.handshake_binary_unreadable" to "バイナリ握手フレームをテキスト化できません。len=%d hex=%s", + "session.msg.connection_error" to "接続エラー:%s", + "session.msg.auto_restore_connecting" to "前回セッションを復元し自動接続中", + "session.msg.disconnected" to "切断しました", + "session.msg.send_failed" to "送信失敗:%s", + "session.msg.audio_chunk_canceled" to "音声が長すぎるため送信を中止しました", + "session.msg.audio_send_failed" to "音声送信失敗:%s", + "session.msg.handshake_unexpected_type" to "握手中に予期しないメッセージ種別:%s", + "session.msg.handshake_parse_failed" to "握手パケット解析失敗:%s", + "session.msg.decryption_failed" to "復号できないメッセージを受信しました", + "session.msg.auth_timeout" to "認証タイムアウト:ネットワークを確認して再試行してください", + "session.msg.auth_request_sent" to "認証リクエストを送信しました", + "session.msg.auth_send_failed" to "認証送信失敗:%s", + "session.msg.ready" to "接続準備完了", + "session.msg.display_name_updated" to "表示名を %s に更新しました", + "session.msg.display_name_update_failed" to "表示名の同期に失敗しました:%s", + "session.msg.unknown_message_type" to "未識別メッセージ種別を受信:%s", + "session.msg.server_rejected" to "サーバーに接続拒否されました(%d):%s", + "session.msg.switching_connection_mode_retry" to "接続方式を切り替えて再試行中", + "session.msg.connection_closed_with_code" to "接続が閉じられました(%d):%s", + "session.msg.auto_reconnect_in" to "%s、%d秒後に自動再接続(%d回目)", + "session.msg.handshake_timeout_with_url" to "握手タイムアウト:server publickey 初回パケット未受信(URL: %s)", + "session.error.message_too_large" to "メッセージが大きすぎます(%dB)。短くして再試行してください", + "session.error.connection_unavailable" to "接続不可", + "session.notification.channel_name" to "OnlineMsg 通知", + "session.notification.channel_desc" to "サーバー新着メッセージを通知", + "session.notification.new_message" to "新着メッセージ", + "session.notification.new_voice_message" to "新着音声メッセージ", + "session.message.voice" to "音声メッセージ", + "session.subtitle.from_key" to "%s から", + "session.subtitle.private_to_key" to "個人 %s", "chat.broadcast" to "全体", "chat.private" to "個人", "chat.target_key" to "相手の公開鍵", @@ -122,8 +552,31 @@ object LanguageManager { "chat.send" to "送信", "chat.sending" to "送信中", "chat.empty_hint" to "接続するとチャットを開始できます。", + "chat.mode_text" to "テキスト", + "chat.mode_audio" to "音声", + "chat.audio_hold_to_talk" to "長押しで録音", + "chat.audio_release_send" to "離して送信", + "chat.audio_release_cancel" to "離してキャンセル", + "chat.audio_slide_cancel" to "長押し中に上へスライドでキャンセル", + "chat.audio_canceled" to "音声送信をキャンセルしました", + "chat.audio_sent" to "音声を送信しました", + "chat.audio_too_short" to "録音時間が短すぎます", + "chat.audio_too_long" to "録音が長すぎます", + "chat.audio_record_failed" to "録音に失敗しました", + "chat.audio_permission_required" to "マイク権限が必要です", + "chat.audio_recording" to "録音中", + "chat.audio_play" to "再生", + "chat.audio_stop" to "停止", "common.copied" to "コピーしました", "common.unknown" to "不明", + "service.foreground.title.ready" to "OnlineMsg 接続維持中", + "service.foreground.title.connecting" to "OnlineMsg 接続中", + "service.foreground.title.error" to "OnlineMsg 接続エラー", + "service.foreground.title.idle" to "OnlineMsg バックグラウンドサービス", + "service.foreground.hint.default" to "バックグラウンドで接続を維持中", + "service.foreground.action.disconnect" to "切断", + "service.foreground.channel.name" to "OnlineMsg バックグラウンド接続", + "service.foreground.channel.desc" to "WebSocket のバックグラウンド長時間接続を維持", "theme.blue" to "ブルー", "theme.gray" to "ビジネスグレー", "theme.green" to "グリーン", @@ -155,7 +608,108 @@ object LanguageManager { "settings.connect" to "연결", "settings.disconnect" to "연결 끊기", "settings.clear_msg" to "정보 삭제", + "settings.chat_data" to "채팅 데이터", "settings.dynamic_color" to "동적 색상 사용", + "settings.notification_sound" to "알림 효과음", + "settings.notification_enabled" to "시스템 알림과 효과음이 켜져 있습니다", + "settings.notification_disabled" to "시스템 알림이 현재 꺼져 있습니다", + "settings.notification_sound_disabled" to "현재 효과음 채널이 음소거되어 있습니다", + "settings.notification_system_settings" to "시스템 알림 설정", + "settings.notification_channel_settings" to "현재 효과음 설정", + "sound.default" to "기본값", + "sound.ding" to "딩", + "sound.nameit5" to "효과음 5", + "sound.wind_chime" to "풍경", + "status.idle" to "연결 안 됨", + "status.connecting" to "연결 중", + "status.ready" to "연결됨", + "status.error" to "연결 끊김", + "hint.tap_to_connect" to "연결을 눌러 채팅을 시작하세요", + "hint.connecting_server" to "서버에 연결 중...", + "hint.ready_chat" to "연결 완료, 채팅 가능", + "hint.closed" to "연결이 종료되었습니다", + "hint.reconnecting" to "연결이 끊겨 재연결 중입니다", + "session.hint.switching_server" to "서버 전환 중", + "hint.reconnect_invalid_server" to "재연결 실패: 서버 주소가 올바르지 않습니다", + "hint.fill_target_key" to "비공개 채팅 전 대상 공개키를 입력하세요", + "hint.server_rejected_prefix" to "서버가 연결을 거부했습니다: ", + "hint.audio_send_failed_prefix" to "음성 전송 실패: ", + "session.sender.system" to "시스템", + "session.sender.me" to "나", + "session.sender.anonymous" to "익명 사용자", + "session.sender.private_message" to "비공개 메시지", + "session.reason.connection_error" to "연결 오류", + "session.reason.connection_interrupted" to "연결 중단", + "session.text.policy_restriction" to "정책 제한", + "session.text.connection_interrupted" to "연결 중단", + "session.snackbar.invalid_server" to "유효한 서버 주소를 입력하세요", + "session.snackbar.server_saved" to "서버 주소를 저장했습니다", + "session.snackbar.server_list_updated" to "서버 주소 목록을 업데이트했습니다", + "session.snackbar.public_key_read_failed" to "공개키 읽기 실패: %s", + "session.hint.connected_preparing" to "연결됨, 채팅 준비 중...", + "session.hint.binary_handshake_parsing" to "바이너리 핸드셰이크 프레임 수신, 파싱 중...", + "session.hint.connection_error_retrying" to "연결 오류, 재시도 중", + "session.hint.server_saved" to "서버 주소를 저장했습니다", + "session.hint.server_restored_default" to "기본 서버를 복원했습니다", + "session.hint.server_removed" to "현재 서버를 삭제했습니다", + "session.hint.fill_valid_server" to "유효한 서버 주소를 입력하세요", + "session.hint.connecting_server" to "서버에 연결 중...", + "session.hint.connection_closed" to "연결이 종료되었습니다", + "session.hint.audio_chunk_over_limit" to "음성이 너무 길어 분할 상한을 초과했습니다", + "session.hint.audio_send_failed" to "음성 전송 실패: %s", + "session.hint.handshake_data_received" to "핸드셰이크 데이터 수신, 파싱 중...", + "session.hint.handshake_incomplete_response" to "핸드셰이크 실패: 서버 응답이 불완전합니다", + "session.hint.handshake_unexpected_message" to "핸드셰이크 실패: 예상치 못한 메시지", + "session.hint.handshake_first_packet_parse_failed" to "핸드셰이크 실패: 첫 패킷 파싱 실패", + "session.hint.authenticating" to "인증 중...", + "session.hint.connection_timeout_retry" to "연결 시간 초과, 다시 시도하세요", + "session.hint.auth_failed" to "인증 실패", + "session.hint.ready_to_chat" to "연결 완료, 채팅 가능", + "session.hint.updating_display_name" to "표시 이름을 동기화하는 중...", + "session.hint.display_name_updated" to "표시 이름이 %s(으)로 업데이트되었습니다", + "session.hint.display_name_update_failed" to "표시 이름 업데이트 실패: %s", + "session.hint.server_rejected" to "서버가 연결을 거부했습니다: %s", + "session.hint.auto_retry_connecting" to "자동 재시도 연결 중...", + "session.hint.connection_interrupted_retry" to "연결이 끊겨 재시도 중", + "session.hint.fill_target_key_before_private" to "비공개 채팅 전 대상 공개키를 입력하세요", + "session.hint.auto_reconnect_in" to "%d초 후 자동 재연결 (%d회차)", + "session.hint.reconnect_invalid_server" to "재연결 실패: 서버 주소가 올바르지 않습니다", + "session.hint.auto_reconnecting" to "자동 재연결 중...", + "session.hint.handshake_timeout" to "핸드셰이크 시간 초과: URL 경로 또는 리버스 프록시를 확인하세요", + "session.msg.connection_established" to "연결이 설정되었습니다", + "session.msg.text_frame_error" to "텍스트 프레임 처리 오류: %s", + "session.msg.binary_frame_error" to "바이너리 프레임 처리 오류: %s", + "session.msg.handshake_binary_unreadable" to "핸드셰이크 바이너리 프레임을 텍스트로 변환할 수 없습니다. len=%d hex=%s", + "session.msg.connection_error" to "연결 오류: %s", + "session.msg.auto_restore_connecting" to "이전 세션을 복원하여 자동 연결 중", + "session.msg.disconnected" to "연결 해제됨", + "session.msg.send_failed" to "전송 실패: %s", + "session.msg.audio_chunk_canceled" to "음성이 너무 길어 전송이 취소되었습니다", + "session.msg.audio_send_failed" to "음성 전송 실패: %s", + "session.msg.handshake_unexpected_type" to "핸드셰이크 중 예상치 못한 메시지 유형: %s", + "session.msg.handshake_parse_failed" to "핸드셰이크 패킷 파싱 실패: %s", + "session.msg.decryption_failed" to "복호화할 수 없는 메시지를 받았습니다", + "session.msg.auth_timeout" to "인증 시간 초과: 네트워크를 확인하고 다시 시도하세요", + "session.msg.auth_request_sent" to "인증 요청을 전송했습니다", + "session.msg.auth_send_failed" to "인증 전송 실패: %s", + "session.msg.ready" to "연결 준비 완료", + "session.msg.display_name_updated" to "표시 이름이 %s(으)로 업데이트되었습니다", + "session.msg.display_name_update_failed" to "표시 이름 동기화 실패: %s", + "session.msg.unknown_message_type" to "알 수 없는 메시지 유형 수신: %s", + "session.msg.server_rejected" to "서버가 연결을 거부했습니다 (%d): %s", + "session.msg.switching_connection_mode_retry" to "연결 방식을 전환하여 재시도 중", + "session.msg.connection_closed_with_code" to "연결 종료 (%d): %s", + "session.msg.auto_reconnect_in" to "%s, %d초 후 자동 재연결 (%d회차)", + "session.msg.handshake_timeout_with_url" to "핸드셰이크 시간 초과: 서버 publickey 첫 패킷 미수신 (URL: %s)", + "session.error.message_too_large" to "메시지가 너무 큽니다 (%dB). 줄여서 다시 시도하세요", + "session.error.connection_unavailable" to "연결 불가", + "session.notification.channel_name" to "OnlineMsg 알림", + "session.notification.channel_desc" to "서버 새 메시지 수신 시 알림", + "session.notification.new_message" to "새 메시지 수신", + "session.notification.new_voice_message" to "새 음성 메시지 수신", + "session.message.voice" to "음성 메시지", + "session.subtitle.from_key" to "%s 에서", + "session.subtitle.private_to_key" to "비공개 %s", "chat.broadcast" to "브로드캐스트", "chat.private" to "비공개 채팅", "chat.target_key" to "대상 공개키", @@ -163,22 +717,54 @@ object LanguageManager { "chat.send" to "전송", "chat.sending" to "전송 중", "chat.empty_hint" to "연결 후 채팅이 가능합니다. 기본은 브로드캐스트이며, 비공개 채팅으로 전환 후 대상 공개키를 입력할 수 있습니다.", + "chat.mode_text" to "텍스트", + "chat.mode_audio" to "음성", + "chat.audio_hold_to_talk" to "길게 눌러 말하기", + "chat.audio_release_send" to "손을 떼면 전송", + "chat.audio_release_cancel" to "손을 떼면 취소", + "chat.audio_slide_cancel" to "길게 누른 상태에서 위로 밀어 취소", + "chat.audio_canceled" to "음성 전송이 취소되었습니다", + "chat.audio_sent" to "음성 메시지를 보냈습니다", + "chat.audio_too_short" to "녹음 시간이 너무 짧습니다", + "chat.audio_too_long" to "녹음 시간이 너무 깁니다", + "chat.audio_record_failed" to "녹음에 실패했습니다", + "chat.audio_permission_required" to "마이크 권한이 필요합니다", + "chat.audio_recording" to "녹음 중", + "chat.audio_play" to "재생", + "chat.audio_stop" to "정지", "common.copied" to "복사됨", "common.unknown" to "알 수 없음", + "service.foreground.title.ready" to "OnlineMsg 연결 유지됨", + "service.foreground.title.connecting" to "OnlineMsg 연결 중", + "service.foreground.title.error" to "OnlineMsg 연결 오류", + "service.foreground.title.idle" to "OnlineMsg 백그라운드 서비스", + "service.foreground.hint.default" to "백그라운드에서 연결 유지 중", + "service.foreground.action.disconnect" to "연결 끊기", + "service.foreground.channel.name" to "OnlineMsg 백그라운드 연결", + "service.foreground.channel.desc" to "백그라운드에서 WebSocket 장기 연결 유지", "theme.blue" to "파랑", "theme.gray" to "비즈니스 그레이", "theme.green" to "초록", "theme.red" to "빨강", "theme.warm" to "따뜻함" - ) + ), + "zh-Hant" to zhHantOverrides ) fun getString(key: String, lang: String): String { - return translations[lang]?.get(key) ?: translations["en"]?.get(key) ?: key + return when (lang) { + "zh-Hant" -> translations["zh-Hant"]?.get(key) + ?: translations["zh"]?.get(key) + ?: translations["en"]?.get(key) + ?: key + + else -> translations[lang]?.get(key) ?: translations["en"]?.get(key) ?: key + } } val supportedLanguages = listOf( - LanguageOption("zh", "中文"), + LanguageOption("zh", "中文简体"), + LanguageOption("zh-Hant", "繁體中文"), LanguageOption("en", "English"), LanguageOption("ja", "日本语"), LanguageOption("ko", "한국어") 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/android-client/app/src/main/res/raw/default_sound.mp3 b/android-client/app/src/main/res/raw/default_sound.mp3 new file mode 100644 index 0000000..f3a2068 Binary files /dev/null and b/android-client/app/src/main/res/raw/default_sound.mp3 differ diff --git a/android-client/app/src/main/res/raw/load.mp3 b/android-client/app/src/main/res/raw/load.mp3 new file mode 100644 index 0000000..8afb4c6 Binary files /dev/null and b/android-client/app/src/main/res/raw/load.mp3 differ diff --git a/android-client/app/src/main/res/raw/nameit5.wav b/android-client/app/src/main/res/raw/nameit5.wav new file mode 100644 index 0000000..ef49ba4 Binary files /dev/null and b/android-client/app/src/main/res/raw/nameit5.wav differ diff --git a/android-client/app/src/main/res/raw/notification_sound_effects.mp3 b/android-client/app/src/main/res/raw/notification_sound_effects.mp3 new file mode 100644 index 0000000..54402ab Binary files /dev/null and b/android-client/app/src/main/res/raw/notification_sound_effects.mp3 differ diff --git a/android-client/build.gradle.kts b/android-client/build.gradle.kts index dedf0a4..8c29eb8 100644 --- a/android-client/build.gradle.kts +++ b/android-client/build.gradle.kts @@ -2,4 +2,5 @@ plugins { id("com.android.application") version "8.5.2" apply false id("org.jetbrains.kotlin.android") version "1.9.24" apply false id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false + id("com.google.devtools.ksp") version "1.9.24-1.0.20" apply false } 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() { )} -
-
-