Compare commits

..

20 Commits

Author SHA1 Message Date
minxiwan 4ed194af0e Merge branch 'main' into linran 1 week ago
emilia-t b7b7d423a1 Merge pull request '自动合并' (#14) from emilia-t into main
Reviewed-on: #14
1 week ago
emilia-t 7c05911921 Merge branch 'main' into emilia-t 1 week ago
minxiwan 58710e6a30 update 1 week ago
minxiwan 2f92f4f78b Merge branch 'main' into emilia-t 1 week ago
minxiwan 5f0302dd0e update 1 week ago
alimu 8a77f0735a feat(server,android): add online display name sync
- add signed rename protocol on the server for authenticated sessions
- update Android settings flow to submit display name changes on commit
- sync display name without restart and preserve message ordering after rename
1 week ago
alimu ffc9a77b5a Document rules in AGENTS 1 week ago
alimu fe96894389 docs: add contribution guide and README link 1 week ago
alimu ce94f4699d feat(android): improve settings, server switching, and local debug trust
- add notification sound preview and system notification shortcuts
- reconnect automatically when switching servers and scope chat history per server
- add clearer server switching state and related i18n updates
- trust deploy local CA for debug builds and document the setup
1 week ago
alimu 02046bd3f4 feat(android): add notification sounds and zh-Hant support 1 week ago
minxiwan 82c4d9b6e3 1.0.0.3更新1.语音发送支持2.繁体中文支持3.通知音效支持4.修复部分多语言翻译错误 1 week ago
alimu 16bcf34e4a Sync main with upstream ai-dev 2 weeks ago
alimu 67f14790f4 chore(android): add adaptive launcher icon set 2 weeks ago
alimu b1b86487b7 feat(web): add voice message recording, chunking, and playback 2 weeks ago
minxiwan 0404e665c1 增加繁体中文 2 weeks ago
minxiwan af50d78f78 修复部分卡顿问题 2 weeks ago
alimu 8fd031d9fc feat(android): implement voice message support and polish chat UX 2 weeks ago
minxiwan c5b9d779ad 多语言支持完整更新 2 weeks ago
alimu 3974c061b8 feat(android): persist chat history in local Room database 2 weeks ago

1
.gitignore vendored

@ -6,6 +6,7 @@ web-client/dist/
web-client/.vite web-client/.vite
deploy/certs/ deploy/certs/
deploy/keys/ deploy/keys/
AGENTS.md
# macOS metadata # macOS metadata
.DS_Store .DS_Store

@ -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-<id>-<topic>
```
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`

@ -35,26 +35,20 @@ namespace OnlineMsgServer.Common
return; return;
} }
Message response = new() string senderPublicKey = UserService.GetUserPublicKeyByID(wsid)!;
if (!PeerNetworkService.TryMarkSeen(senderPublicKey, Type, key, payload.Payload))
{ {
Type = "broadcast", return;
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);
}
}
} }
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) catch (Exception ex)
{ {

@ -41,33 +41,29 @@ namespace OnlineMsgServer.Common
return; return;
} }
string fromPublicKey = UserService.GetUserPublicKeyByID(wsid)!; if (PeerNetworkService.TryHandlePeerRelayForward(wsid, forwardPublickKey, payload))
Message response = new()
{ {
Type = "forward", return;
Data = payload.Payload, }
Key = fromPublicKey,
};
string jsonString = response.ToJsonString();
string encryptString = RsaService.EncryptForClient(forwardPublickKey, jsonString);
List<User> userList = UserService.GetUserListByPublicKey(forwardPublickKey); string fromPublicKey = UserService.GetUserPublicKeyByID(wsid)!;
if (userList.Count == 0) if (!PeerNetworkService.TryMarkSeen(fromPublicKey, Type, forwardPublickKey, payload.Payload))
{ {
Log.Security("forward_target_offline_or_untrusted", $"wsid={wsid}");
return; 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)) return;
{
session.Context.WebSocket.Send(encryptString);
break;
}
} }
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) catch (Exception ex)
{ {

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

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

@ -81,7 +81,8 @@ namespace OnlineMsgServer.Common
return; 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}"); Log.Security("auth_success", $"wsid={wsid} user={userName}");
Message ack = new() Message ack = new()

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

@ -12,6 +12,11 @@ namespace OnlineMsgServer.Common
/// </summary> /// </summary>
public string? Name { get; set; } public string? Name { get; set; }
/// <summary>
/// 是否为服务器节点伪装的 peer 用户
/// </summary>
public bool IsPeerNode { get; set; }
/// <summary> /// <summary>
/// 用户公钥 用于消息加密发送给用户 /// 用户公钥 用于消息加密发送给用户

@ -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<string, PeerOutboundClient> _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<PeerOutboundClient> 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<User> 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<string> 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<string> 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<PeerOutboundClient> 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);
}
}
}
}
}
}

@ -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) public static bool IsPublicKeyValid(string publicKeyBase64)
{ {
lock (_PublicRsaLock) lock (_PublicRsaLock)

@ -20,9 +20,16 @@ namespace OnlineMsgServer.Core
public int ChallengeTtlSeconds { get; init; } = 120; public int ChallengeTtlSeconds { get; init; } = 120;
public int MaxClockSkewSeconds { get; init; } = 60; public int MaxClockSkewSeconds { get; init; } = 60;
public int ReplayWindowSeconds { get; init; } = 120; 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() public static SecurityConfig LoadFromEnvironment()
{ {
string? rawPeerNodeName = GetString("PEER_NODE_NAME");
return new SecurityConfig return new SecurityConfig
{ {
ListenPort = GetInt("LISTEN_PORT", 13173, 1), ListenPort = GetInt("LISTEN_PORT", 13173, 1),
@ -40,6 +47,12 @@ namespace OnlineMsgServer.Core
ChallengeTtlSeconds = GetInt("CHALLENGE_TTL_SECONDS", 120, 10), ChallengeTtlSeconds = GetInt("CHALLENGE_TTL_SECONDS", 120, 10),
MaxClockSkewSeconds = GetInt("MAX_CLOCK_SKEW_SECONDS", 60, 1), MaxClockSkewSeconds = GetInt("MAX_CLOCK_SKEW_SECONDS", 60, 1),
ReplayWindowSeconds = GetInt("REPLAY_WINDOW_SECONDS", 120, 10), 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); 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)}";
}
} }
} }

@ -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<string, DateTime> _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<string> expiredKeys = [];
foreach (KeyValuePair<string, DateTime> 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));
}
}
}

@ -41,11 +41,14 @@ namespace OnlineMsgServer.Core
/// <summary> /// <summary>
/// 通过publickey返回用户列表 /// 通过publickey返回用户列表
/// </summary> /// </summary>
public static List<User> GetUserListByPublicKey(string publicKey) public static List<User> GetUserListByPublicKey(string publicKey, bool includePeerNodes = true)
{ {
lock (_UserListLock) 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
/// <summary> /// <summary>
/// 通过wsid设置用户PublicKey /// 通过wsid设置用户PublicKey
/// </summary> /// </summary>
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) lock (_UserListLock)
{ {
@ -62,6 +65,7 @@ namespace OnlineMsgServer.Core
{ {
user.PublicKey = publickey.Trim(); user.PublicKey = publickey.Trim();
user.Name = name.Trim(); user.Name = name.Trim();
user.IsPeerNode = isPeerNode;
user.IsAuthenticated = true; user.IsAuthenticated = true;
user.PendingChallenge = null; user.PendingChallenge = null;
user.AuthenticatedAtUtc = DateTime.UtcNow; 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;
}
}
/// <summary> /// <summary>
/// 通过用户PublicKey获取wsid /// 通过用户PublicKey获取wsid
/// </summary> /// </summary>
@ -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<User> 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() public static int GetConnectionCount()
{ {
lock (_UserListLock) lock (_UserListLock)

@ -12,6 +12,13 @@ namespace OnlineMsgServer.Core
private static readonly object _abuseLock = new(); private static readonly object _abuseLock = new();
private static readonly Dictionary<string, DateTime> _ipBlockedUntil = []; private static readonly Dictionary<string, DateTime> _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) protected override async void OnMessage(MessageEventArgs e)
{ {
SecurityConfig config = SecurityRuntime.Config; SecurityConfig config = SecurityRuntime.Config;

@ -48,6 +48,8 @@ namespace OnlineMsgServer
//开启ws监听 //开启ws监听
wssv.AddWebSocketService<WsService>("/"); wssv.AddWebSocketService<WsService>("/");
wssv.Start(); wssv.Start();
PeerNetworkService.Initialize(config, wssv.WebSocketServices["/"].Sessions);
PeerNetworkService.Start();
Console.WriteLine("已开启ws监听, 端口: " + config.ListenPort); Console.WriteLine("已开启ws监听, 端口: " + config.ListenPort);
bool loopFlag = true; bool loopFlag = true;
@ -70,6 +72,7 @@ namespace OnlineMsgServer
#endif #endif
await Task.Delay(5000);// 每5秒检查一次 await Task.Delay(5000);// 每5秒检查一次
} }
PeerNetworkService.Stop();
wssv.Stop(); wssv.Stop();
} }

@ -1,19 +1,53 @@
# OnlineMsgServer # 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 客户端 - `web-client/`React Web 客户端
- `android-client/`AndroidKotlin + Compose客户端 - `android-client/`Android 客户端
## 运行前提 ## 运行依赖
- `.NET 8 SDK` - `.NET 8 SDK`
- `Docker` - `Docker`
- `openssl` - `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 <repo-root> cd <repo-root>
``` ```
### 1) 测试模式WS ### 1. 本地测试WS
```bash ```bash
bash deploy/deploy_test_ws.sh 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
bash deploy/redeploy_with_lan_cert.sh 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 ```bash
DOMAIN=chat.example.com \ DOMAIN=chat.example.com \
@ -50,9 +102,14 @@ CERT_PASSWORD='change-me' \
bash deploy/prepare_prod_release.sh bash deploy/prepare_prod_release.sh
``` ```
输出目录默认在 `deploy/output/prod`,包含 `prod.env`、镜像 tar可选和运行示例脚本。 输出默认在 `deploy/output/prod/`,包括:
无 CA 证书时可临时使用自签名(仅测试): - `prod.env`
- Docker 镜像 tar可选
- 运行示例脚本
- 运行时证书与协议私钥
如果只是临时测试,也可以生成自签名证书:
```bash ```bash
DOMAIN=chat.example.com \ DOMAIN=chat.example.com \
@ -62,9 +119,9 @@ CERT_PASSWORD='change-me' \
bash deploy/prepare_prod_release.sh bash deploy/prepare_prod_release.sh
``` ```
## 手动 Docker 启动示例 ## 手动 Docker 启动
### WS(测试) ### 单节点:WS
```bash ```bash
docker run -d --name onlinemsgserver --restart unless-stopped \ docker run -d --name onlinemsgserver --restart unless-stopped \
@ -75,7 +132,7 @@ docker run -d --name onlinemsgserver --restart unless-stopped \
onlinemsgserver:latest onlinemsgserver:latest
``` ```
### WSS(生产/预生产) ### 单节点:WSS
```bash ```bash
docker run -d --name onlinemsgserver --restart unless-stopped \ docker run -d --name onlinemsgserver --restart unless-stopped \
@ -89,16 +146,44 @@ docker run -d --name onlinemsgserver --restart unless-stopped \
onlinemsgserver:latest 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 - 服务端握手公钥RSA-2048SPKI / PKCS8
- 明文按 190 字节分块加密 - 传输加密:`RSA/ECB/OAEPWithSHA-256AndMGF1Padding`
- 密文按 256 字节分块解密 - 明文按 `190` 字节分块加密
- 业务消息传输为 base64 字符串 - 密文按 `256` 字节分块解密
- WebSocket 上传输的是 base64 字符串
### 通用包结构
### 通用包结构(客户端 -> 服务端) 客户端发给服务端的明文结构如下,随后再整体用服务端公钥加密:
```json ```json
{ {
@ -108,7 +193,9 @@ docker run -d --name onlinemsgserver --restart unless-stopped \
} }
``` ```
### 连接首包(服务端 -> 客户端,明文) ### 首包:服务端 -> 客户端(明文)
客户端建立连接后,服务端立即发送:
```json ```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 ```json
{ {
"publicKey": "客户端公钥(base64 SPKI)", "type": "publickey",
"challenge": "上一步 authChallenge", "key": "guest-123456",
"timestamp": 1739600000, "data": {
"nonce": "随机字符串", "publicKey": "base64-spki",
"signature": "签名(base64)" "challenge": "challenge-from-server",
"timestamp": 1739600000,
"nonce": "random-string",
"signature": "base64-signature"
}
} }
``` ```
签名串: 签名原文
```text ```text
publickey\n{userName}\n{publicKey}\n{challenge}\n{timestamp}\n{nonce} publickey
{userName}
{publicKey}
{challenge}
{timestamp}
{nonce}
``` ```
### 单播 `type=forward` ### 私聊:`type=forward`
- `key`:目标客户端公钥 - `key`:目标用户公钥
- `data` - `data.payload`:消息内容
- `data.timestamp` / `data.nonce` / `data.signature`:发送者签名信息
```json ```json
{ {
"payload": "消息内容", "type": "forward",
"timestamp": 1739600000, "key": "target-user-public-key",
"nonce": "随机字符串", "data": {
"signature": "签名(base64)" "payload": "hello",
"timestamp": 1739600000,
"nonce": "random-string",
"signature": "base64-signature"
}
} }
``` ```
签名串: 签名原文
```text ```text
forward\n{targetPublicKey}\n{payload}\n{timestamp}\n{nonce} forward
{targetPublicKey}
{payload}
{timestamp}
{nonce}
``` ```
### 广播 `type=broadcast` ### 广播`type=broadcast`
- `key`为空字符串 - `key`通常为空字符串
- `data``forward` - `data`结构与 `forward` 相同
签名 签名原文
```text ```text
broadcast\n{key}\n{payload}\n{timestamp}\n{nonce} broadcast
{key}
{payload}
{timestamp}
{nonce}
``` ```
### 连接流程 ### 连接流程
1. 客户端建立 WebSocket 连接后接收明文 `publickey` 首包。 1. 客户端建立 WebSocket 连接。
2. 客户端发送签名鉴权包(`type=publickey`)。 2. 服务端发送明文 `publickey` 首包。
3. 鉴权成功后,客户端发送 `forward` / `broadcast` 业务消息(加密 + 签名)。 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` - `LISTEN_PORT`:监听端口,默认 `13173`
- `REQUIRE_WSS`:是否启用 WSS默认 `false` - `REQUIRE_WSS`:是否启用 WSS默认 `false`
- `TLS_CERT_PATH`:证书路径(启用 WSS 时必填) - `TLS_CERT_PATH`PFX 证书路径,启用 WSS 时必填
- `TLS_CERT_PASSWORD`:证书密码(可空) - `TLS_CERT_PASSWORD`PFX 证书密码,可空
- `SERVER_PRIVATE_KEY_B64`服务端私钥PKCS8 base64
- `SERVER_PRIVATE_KEY_PATH`:服务端私钥文件路径(与上面二选一) ### 协议私钥
- `ALLOW_EPHEMERAL_SERVER_KEY`:允许使用临时内存私钥,默认 `false`
- `SERVER_PRIVATE_KEY_B64`协议私钥PKCS8 base64
- `SERVER_PRIVATE_KEY_PATH`:协议私钥文件路径
- `ALLOW_EPHEMERAL_SERVER_KEY`:若未提供私钥,是否允许启动临时内存私钥,默认 `false`
### 安全限制
- `MAX_CONNECTIONS`:最大连接数,默认 `1000` - `MAX_CONNECTIONS`:最大连接数,默认 `1000`
- `MAX_MESSAGE_BYTES`:单消息最大字节数,默认 `65536` - `MAX_MESSAGE_BYTES`:单消息最大字节数,默认 `65536`
- `RATE_LIMIT_COUNT`:限流窗口允许消息数,默认 `30` - `RATE_LIMIT_COUNT`:限流窗口允许消息数,默认 `30`
- `RATE_LIMIT_WINDOW_SECONDS`:限流窗口秒数,默认 `10` - `RATE_LIMIT_WINDOW_SECONDS`:限流窗口秒数,默认 `10`
- `IP_BLOCK_SECONDS`:触发滥用后的封禁秒数,默认 `120` - `IP_BLOCK_SECONDS`:触发滥用后的封禁秒数,默认 `120`
- `CHALLENGE_TTL_SECONDS`:挑战值有效期秒数,默认 `120` - `CHALLENGE_TTL_SECONDS`challenge 有效期秒数,默认 `120`
- `MAX_CLOCK_SKEW_SECONDS`:允许时钟偏差秒数,默认 `60` - `MAX_CLOCK_SKEW_SECONDS`:允许时钟偏差秒数,默认 `60`
- `REPLAY_WINDOW_SECONDS`:防重放窗口秒数,默认 `120` - `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` - Web 客户端[web-client/README.md](/Users/solux/Codes/OnlineMsgServer/web-client/README.md)
- Android 客户端说明:`android-client/README.md` - Android 客户端[android-client/README.md](/Users/solux/Codes/OnlineMsgServer/android-client/README.md)

@ -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.
感谢上述所有创作者。

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

@ -2,8 +2,15 @@ plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization") 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 { android {
namespace = "com.onlinemsg.client" namespace = "com.onlinemsg.client"
compileSdk = 34 compileSdk = 34
@ -13,7 +20,7 @@ android {
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = 1
versionName = "1.0.0.2" versionName = "1.0.0.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@ -51,6 +58,13 @@ android {
excludes += "/META-INF/{AL2.0,LGPL2.1}" excludes += "/META-INF/{AL2.0,LGPL2.1}"
} }
} }
sourceSets {
getByName("debug") {
res.srcDir(generatedLocalDebugResDir)
manifest.srcFile(generatedLocalDebugManifestFile)
}
}
} }
dependencies { dependencies {
@ -71,6 +85,9 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("androidx.datastore:datastore-preferences:1.1.1") 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") implementation("com.squareup.okhttp3:okhttp:4.12.0")
debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-tooling")
@ -89,6 +106,56 @@ val debugApkExportDir: String = providers.gradleProperty("debugApkExportDir")
.get() .get()
val debugApkExportName = "onlinemsgclient-debug.apk" val debugApkExportName = "onlinemsgclient-debug.apk"
val prepareLocalDebugTrust by tasks.registering {
val sourceFile = localDebugCaCertSource
inputs.file(sourceFile).optional()
outputs.dir(generatedLocalDebugTrustDir)
doLast {
generatedLocalDebugTrustRoot.deleteRecursively()
generatedLocalDebugTrustRoot.mkdirs()
if (sourceFile.exists()) {
val rawDir = generatedLocalDebugResDir.resolve("raw")
val xmlDir = generatedLocalDebugResDir.resolve("xml")
rawDir.mkdirs()
xmlDir.mkdirs()
sourceFile.copyTo(rawDir.resolve("local_ca.crt"), overwrite = true)
xmlDir.resolve("network_security_config.xml").writeText(
"""
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config>
<trust-anchors>
<certificates src="system" />
<certificates src="@raw/local_ca" />
</trust-anchors>
</base-config>
</network-security-config>
""".trimIndent() + "\n"
)
generatedLocalDebugManifestFile.writeText(
"""
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:networkSecurityConfig="@xml/network_security_config" />
</manifest>
""".trimIndent() + "\n"
)
} else {
generatedLocalDebugManifestFile.writeText(
"""
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
""".trimIndent() + "\n"
)
}
}
}
val exportDebugApk by tasks.registering(Copy::class) { val exportDebugApk by tasks.registering(Copy::class) {
from(layout.buildDirectory.file("outputs/apk/debug/app-debug.apk")) from(layout.buildDirectory.file("outputs/apk/debug/app-debug.apk"))
into(debugApkExportDir) into(debugApkExportDir)
@ -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 { tasks.matching { it.name == "assembleDebug" }.configureEach {
dependsOn(prepareLocalDebugTrust)
finalizedBy(exportDebugApk) finalizedBy(exportDebugApk)
} }

@ -5,12 +5,15 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.OnlineMsg"> android:theme="@style/Theme.OnlineMsg">
<activity <activity

@ -15,6 +15,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
requestNotificationPermissionIfNeeded() requestNotificationPermissionIfNeeded()
requestAudioPermissionIfNeeded()
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
OnlineMsgApp() OnlineMsgApp()
@ -35,7 +36,21 @@ class MainActivity : ComponentActivity() {
) )
} }
private fun requestAudioPermissionIfNeeded() {
val granted = ContextCompat.checkSelfPermission(
this,
Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED
if (granted) return
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.RECORD_AUDIO),
REQUEST_AUDIO_PERMISSION
)
}
private companion object { private companion object {
const val REQUEST_NOTIFICATION_PERMISSION = 1002 const val REQUEST_NOTIFICATION_PERMISSION = 1002
const val REQUEST_AUDIO_PERMISSION = 1003
} }
} }

@ -0,0 +1,52 @@
package com.onlinemsg.client.data.local
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database(
entities = [ChatMessageEntity::class],
version = 3,
exportSchema = false
)
abstract class ChatDatabase : RoomDatabase() {
abstract fun chatMessageDao(): ChatMessageDao
companion object {
private const val DB_NAME = "onlinemsg_chat.db"
@Volatile
private var instance: ChatDatabase? = null
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE chat_messages ADD COLUMN contentType TEXT NOT NULL DEFAULT 'TEXT'")
db.execSQL("ALTER TABLE chat_messages ADD COLUMN audioBase64 TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE chat_messages ADD COLUMN audioDurationMillis INTEGER NOT NULL DEFAULT 0")
}
}
private val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE chat_messages ADD COLUMN serverKey TEXT NOT NULL DEFAULT ''")
}
}
fun getInstance(context: Context): ChatDatabase {
return instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
ChatDatabase::class.java,
DB_NAME
)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build().also { db ->
instance = db
}
}
}
}
}

@ -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<UiMessage> {
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::"

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

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

@ -24,7 +24,8 @@ data class UserPreferences(
val shouldAutoReconnect: Boolean, val shouldAutoReconnect: Boolean,
val themeId: String = "blue", val themeId: String = "blue",
val useDynamicColor: Boolean = true, val useDynamicColor: Boolean = true,
val language: String = "zh" // 默认中文 val language: String = "zh", // 默认中文
val notificationSound: String = "default"
) )
class UserPreferencesRepository( class UserPreferencesRepository(
@ -50,7 +51,8 @@ class UserPreferencesRepository(
shouldAutoReconnect = prefs[KEY_SHOULD_AUTO_RECONNECT] ?: false, shouldAutoReconnect = prefs[KEY_SHOULD_AUTO_RECONNECT] ?: false,
themeId = prefs[KEY_THEME_ID] ?: "blue", themeId = prefs[KEY_THEME_ID] ?: "blue",
useDynamicColor = prefs[KEY_USE_DYNAMIC_COLOR] ?: true, 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) { suspend fun setUseDynamicColor(enabled: Boolean) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
prefs[KEY_USE_DYNAMIC_COLOR] = enabled prefs[KEY_USE_DYNAMIC_COLOR] = enabled
@ -155,5 +163,6 @@ class UserPreferencesRepository(
val KEY_THEME_ID: Preferences.Key<String> = stringPreferencesKey("theme_id") val KEY_THEME_ID: Preferences.Key<String> = stringPreferencesKey("theme_id")
val KEY_USE_DYNAMIC_COLOR: Preferences.Key<Boolean> = booleanPreferencesKey("use_dynamic_color") val KEY_USE_DYNAMIC_COLOR: Preferences.Key<Boolean> = booleanPreferencesKey("use_dynamic_color")
val KEY_LANGUAGE: Preferences.Key<String> = stringPreferencesKey("language") val KEY_LANGUAGE: Preferences.Key<String> = stringPreferencesKey("language")
val KEY_NOTIFICATION_SOUND: Preferences.Key<String> = stringPreferencesKey("notification_sound")
} }
} }

@ -39,6 +39,27 @@ data class SignedPayloadDto(
@SerialName("signature") val signature: String @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 { fun JsonElement?.asPayloadText(): String {
if (this == null || this is JsonNull) return "" if (this == null || this is JsonNull) return ""
return if (this is JsonPrimitive && this.isString) { return if (this is JsonPrimitive && this.isString) {

@ -16,6 +16,7 @@ import androidx.core.app.NotificationManagerCompat
import com.onlinemsg.client.MainActivity import com.onlinemsg.client.MainActivity
import com.onlinemsg.client.ui.ChatSessionManager import com.onlinemsg.client.ui.ChatSessionManager
import com.onlinemsg.client.ui.ConnectionStatus import com.onlinemsg.client.ui.ConnectionStatus
import com.onlinemsg.client.util.LanguageManager
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -29,6 +30,10 @@ class ChatForegroundService : Service() {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var statusJob: Job? = null private var statusJob: Job? = null
private fun t(key: String): String {
return LanguageManager.getString(key, ChatSessionManager.uiState.value.language)
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
ChatSessionManager.initialize(application) ChatSessionManager.initialize(application)
@ -113,22 +118,22 @@ class ChatForegroundService : Service() {
) )
val title = when (status) { val title = when (status) {
ConnectionStatus.READY -> "OnlineMsg 已保持连接" ConnectionStatus.READY -> t("service.foreground.title.ready")
ConnectionStatus.CONNECTING, ConnectionStatus.CONNECTING,
ConnectionStatus.HANDSHAKING, ConnectionStatus.HANDSHAKING,
ConnectionStatus.AUTHENTICATING -> "OnlineMsg 正在连接" ConnectionStatus.AUTHENTICATING -> t("service.foreground.title.connecting")
ConnectionStatus.ERROR -> "OnlineMsg 连接异常" ConnectionStatus.ERROR -> t("service.foreground.title.error")
ConnectionStatus.IDLE -> "OnlineMsg 后台服务" ConnectionStatus.IDLE -> t("service.foreground.title.idle")
} }
return NotificationCompat.Builder(this, FOREGROUND_CHANNEL_ID) return NotificationCompat.Builder(this, FOREGROUND_CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_sync) .setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle(title) .setContentTitle(title)
.setContentText(hint.ifBlank { "后台保持连接中" }) .setContentText(hint.ifBlank { t("service.foreground.hint.default") })
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setContentIntent(openAppPendingIntent) .setContentIntent(openAppPendingIntent)
.addAction(0, "断开", stopPendingIntent) .addAction(0, t("service.foreground.action.disconnect"), stopPendingIntent)
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_LOW)
.build() .build()
} }
@ -138,10 +143,10 @@ class ChatForegroundService : Service() {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel( val channel = NotificationChannel(
FOREGROUND_CHANNEL_ID, FOREGROUND_CHANNEL_ID,
"OnlineMsg 后台连接", t("service.foreground.channel.name"),
NotificationManager.IMPORTANCE_LOW NotificationManager.IMPORTANCE_LOW
).apply { ).apply {
description = "保持 WebSocket 后台长连接" description = t("service.foreground.channel.desc")
setShowBadge(false) setShowBadge(false)
} }
manager.createNotificationChannel(channel) manager.createNotificationChannel(channel)

@ -31,6 +31,14 @@ enum class MessageChannel {
PRIVATE PRIVATE
} }
/**
* 消息内容类型文本/音频
*/
enum class MessageContentType {
TEXT,
AUDIO
}
/** /**
* 单条消息的数据类 * 单条消息的数据类
* @property id 唯一标识默认随机 UUID * @property id 唯一标识默认随机 UUID
@ -48,7 +56,10 @@ data class UiMessage(
val subtitle: String = "", val subtitle: String = "",
val content: String, val content: String,
val channel: MessageChannel, 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 loadingPublicKey: Boolean = false,
val themeId: String = "blue", val themeId: String = "blue",
val useDynamicColor: Boolean = true, val useDynamicColor: Boolean = true,
val language: String = "zh" val language: String = "zh",
val notificationSound: String = "default",
val isSwitchingServer: Boolean = false,
val switchingServerLabel: String = ""
) { ) {
/** /**
* 是否允许连接 * 是否允许连接
@ -141,4 +155,4 @@ data class ChatUiState(
*/ */
sealed interface UiEvent { sealed interface UiEvent {
data class ShowSnackbar(val message: String) : UiEvent data class ShowSnackbar(val message: String) : UiEvent
} }

@ -19,6 +19,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
fun updateDisplayName(value: String) = ChatSessionManager.updateDisplayName(value) fun updateDisplayName(value: String) = ChatSessionManager.updateDisplayName(value)
fun updateServerUrl(value: String) = ChatSessionManager.updateServerUrl(value) fun updateServerUrl(value: String) = ChatSessionManager.updateServerUrl(value)
fun selectServerUrl(value: String) = ChatSessionManager.selectServerUrl(value)
fun updateTargetKey(value: String) = ChatSessionManager.updateTargetKey(value) fun updateTargetKey(value: String) = ChatSessionManager.updateTargetKey(value)
fun updateDraft(value: String) = ChatSessionManager.updateDraft(value) fun updateDraft(value: String) = ChatSessionManager.updateDraft(value)
fun toggleDirectMode(enabled: Boolean) = ChatSessionManager.toggleDirectMode(enabled) fun toggleDirectMode(enabled: Boolean) = ChatSessionManager.toggleDirectMode(enabled)
@ -30,9 +31,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
fun connect() = ChatSessionManager.connect() fun connect() = ChatSessionManager.connect()
fun disconnect() = ChatSessionManager.disconnect() fun disconnect() = ChatSessionManager.disconnect()
fun sendMessage() = ChatSessionManager.sendMessage() fun sendMessage() = ChatSessionManager.sendMessage()
fun sendAudioMessage(audioBase64: String, durationMillis: Long) =
ChatSessionManager.sendAudioMessage(audioBase64, durationMillis)
fun onMessageCopied() = ChatSessionManager.onMessageCopied() fun onMessageCopied() = ChatSessionManager.onMessageCopied()
fun updateTheme(themeId: String) = ChatSessionManager.updateTheme(themeId) fun updateTheme(themeId: String) = ChatSessionManager.updateTheme(themeId)
fun updateUseDynamicColor(enabled: Boolean) = ChatSessionManager.updateUseDynamicColor(enabled) fun updateUseDynamicColor(enabled: Boolean) = ChatSessionManager.updateUseDynamicColor(enabled)
fun updateLanguage(language: String) = ChatSessionManager.updateLanguage(language) fun updateLanguage(language: String) = ChatSessionManager.updateLanguage(language)
} fun updateNotificationSound(sound: String) = ChatSessionManager.updateNotificationSound(sound)
}

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

@ -6,6 +6,87 @@ package com.onlinemsg.client.util
*/ */
object LanguageManager { 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( private val translations = mapOf(
"zh" to mapOf( "zh" to mapOf(
"tab.chat" to "聊天", "tab.chat" to "聊天",
@ -32,7 +113,108 @@ object LanguageManager {
"settings.connect" to "连接", "settings.connect" to "连接",
"settings.disconnect" to "断开", "settings.disconnect" to "断开",
"settings.clear_msg" to "清空消息", "settings.clear_msg" to "清空消息",
"settings.chat_data" to "聊天数据",
"settings.dynamic_color" 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.broadcast" to "广播",
"chat.private" to "私聊", "chat.private" to "私聊",
"chat.target_key" to "目标公钥", "chat.target_key" to "目标公钥",
@ -40,8 +222,31 @@ object LanguageManager {
"chat.send" to "发送", "chat.send" to "发送",
"chat.sending" to "发送中", "chat.sending" to "发送中",
"chat.empty_hint" 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.copied" to "已复制",
"common.unknown" 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.blue" to "蔚蓝",
"theme.gray" to "商务灰", "theme.gray" to "商务灰",
"theme.green" to "翠绿", "theme.green" to "翠绿",
@ -73,7 +278,108 @@ object LanguageManager {
"settings.connect" to "Link", "settings.connect" to "Link",
"settings.disconnect" to "Dislink", "settings.disconnect" to "Dislink",
"settings.clear_msg" to "ClearMsg", "settings.clear_msg" to "ClearMsg",
"settings.chat_data" to "Chat Data",
"settings.dynamic_color" to "Use dynamic color", "settings.dynamic_color" to "Use dynamic color",
"settings.notification_sound" to "Notification Sound",
"settings.notification_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.broadcast" to "Broadcast",
"chat.private" to "Private", "chat.private" to "Private",
"chat.target_key" to "Target Public Key", "chat.target_key" to "Target Public Key",
@ -81,8 +387,31 @@ object LanguageManager {
"chat.send" to "Send", "chat.send" to "Send",
"chat.sending" to "Sending", "chat.sending" to "Sending",
"chat.empty_hint" to "Connect to start chatting. Default is broadcast.", "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.copied" to "Copied",
"common.unknown" to "Unknown", "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.blue" to "Blue",
"theme.gray" to "Business Gray", "theme.gray" to "Business Gray",
"theme.green" to "Green", "theme.green" to "Green",
@ -114,7 +443,108 @@ object LanguageManager {
"settings.connect" to "接続", "settings.connect" to "接続",
"settings.disconnect" to "切断", "settings.disconnect" to "切断",
"settings.clear_msg" to "履歴を消去", "settings.clear_msg" to "履歴を消去",
"settings.chat_data" to "チャットデータ",
"settings.dynamic_color" 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.broadcast" to "全体",
"chat.private" to "個人", "chat.private" to "個人",
"chat.target_key" to "相手の公開鍵", "chat.target_key" to "相手の公開鍵",
@ -122,8 +552,31 @@ object LanguageManager {
"chat.send" to "送信", "chat.send" to "送信",
"chat.sending" to "送信中", "chat.sending" to "送信中",
"chat.empty_hint" 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.copied" to "コピーしました",
"common.unknown" 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.blue" to "ブルー",
"theme.gray" to "ビジネスグレー", "theme.gray" to "ビジネスグレー",
"theme.green" to "グリーン", "theme.green" to "グリーン",
@ -155,7 +608,108 @@ object LanguageManager {
"settings.connect" to "연결", "settings.connect" to "연결",
"settings.disconnect" to "연결 끊기", "settings.disconnect" to "연결 끊기",
"settings.clear_msg" to "정보 삭제", "settings.clear_msg" to "정보 삭제",
"settings.chat_data" to "채팅 데이터",
"settings.dynamic_color" 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.broadcast" to "브로드캐스트",
"chat.private" to "비공개 채팅", "chat.private" to "비공개 채팅",
"chat.target_key" to "대상 공개키", "chat.target_key" to "대상 공개키",
@ -163,22 +717,54 @@ object LanguageManager {
"chat.send" to "전송", "chat.send" to "전송",
"chat.sending" to "전송 중", "chat.sending" to "전송 중",
"chat.empty_hint" 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.copied" to "복사됨",
"common.unknown" 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.blue" to "파랑",
"theme.gray" to "비즈니스 그레이", "theme.gray" to "비즈니스 그레이",
"theme.green" to "초록", "theme.green" to "초록",
"theme.red" to "빨강", "theme.red" to "빨강",
"theme.warm" to "따뜻함" "theme.warm" to "따뜻함"
) ),
"zh-Hant" to zhHantOverrides
) )
fun getString(key: String, lang: String): String { 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( val supportedLanguages = listOf(
LanguageOption("zh", "中文"), LanguageOption("zh", "中文简体"),
LanguageOption("zh-Hant", "繁體中文"),
LanguageOption("en", "English"), LanguageOption("en", "English"),
LanguageOption("ja", "日本语"), LanguageOption("ja", "日本语"),
LanguageOption("ko", "한국어") LanguageOption("ko", "한국어")

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

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<gradient
android:angle="315"
android:startColor="#0A1128"
android:centerColor="#121C3B"
android:endColor="#18264A" />
</shape>

@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#00000000"
android:pathData="M54,54m-30,0a30,30 0,1 1,60 0a30,30 0,1 1,-60 0"
android:strokeColor="#FF28E7FF"
android:strokeWidth="6" />
<path
android:fillColor="#00000000"
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0"
android:strokeColor="#FF223A65"
android:strokeWidth="2" />
<path
android:fillColor="#00000000"
android:pathData="M32,28H40"
android:strokeColor="#FF2A6AA0"
android:strokeLineCap="round"
android:strokeWidth="2" />
<path
android:fillColor="#00000000"
android:pathData="M68,28H76"
android:strokeColor="#FF2A6AA0"
android:strokeLineCap="round"
android:strokeWidth="2" />
<path
android:fillColor="#00000000"
android:pathData="M16,54H24"
android:strokeColor="#FF2A6AA0"
android:strokeLineCap="round"
android:strokeWidth="2" />
<path
android:fillColor="#00000000"
android:pathData="M84,54H92"
android:strokeColor="#FF2A6AA0"
android:strokeLineCap="round"
android:strokeWidth="2" />
<path
android:fillColor="#00000000"
android:pathData="M32,80H40"
android:strokeColor="#FF2A6AA0"
android:strokeLineCap="round"
android:strokeWidth="2" />
<path
android:fillColor="#00000000"
android:pathData="M68,80H76"
android:strokeColor="#FF2A6AA0"
android:strokeLineCap="round"
android:strokeWidth="2" />
<path
android:fillColor="#00000000"
android:pathData="M34,64V44"
android:strokeColor="#FF23E5FF"
android:strokeLineCap="round"
android:strokeWidth="5" />
<path
android:fillColor="#00000000"
android:pathData="M44,68V40"
android:strokeColor="#FF23E5FF"
android:strokeLineCap="round"
android:strokeWidth="5" />
<path
android:fillColor="#00000000"
android:pathData="M54,72V36"
android:strokeColor="#FF00FFA3"
android:strokeLineCap="round"
android:strokeWidth="5" />
<path
android:fillColor="#00000000"
android:pathData="M64,66V42"
android:strokeColor="#FF00FFA3"
android:strokeLineCap="round"
android:strokeWidth="5" />
<path
android:fillColor="#00000000"
android:pathData="M74,60V48"
android:strokeColor="#FF00FFA3"
android:strokeLineCap="round"
android:strokeWidth="5" />
</vector>

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_launcher_background" />
<item
android:drawable="@drawable/ic_launcher_foreground"
android:gravity="center" />
</layer-list>

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_launcher_background" />
<item
android:drawable="@drawable/ic_launcher_foreground"
android:gravity="center" />
</layer-list>

@ -2,4 +2,5 @@ plugins {
id("com.android.application") version "8.5.2" apply false 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.android") version "1.9.24" apply false
id("org.jetbrains.kotlin.plugin.serialization") 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
} }

@ -23,6 +23,15 @@ const STORAGE_CURRENT_SERVER_URL_KEY = "oms_current_server_url";
const MAX_SERVER_URLS = 8; const MAX_SERVER_URLS = 8;
const CHANNEL_BROADCAST = "broadcast"; const CHANNEL_BROADCAST = "broadcast";
const CHANNEL_PRIVATE = "private"; 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) { function isLikelyLocalHost(host) {
const value = (host || "").toLowerCase(); const value = (host || "").toLowerCase();
@ -156,6 +165,118 @@ function summarizeKey(key = "") {
return `${key.slice(0, 8)}...${key.slice(-8)}`; 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() { function createLocalId() {
const c = globalThis.crypto; const c = globalThis.crypto;
if (c?.randomUUID) { if (c?.randomUUID) {
@ -206,6 +327,14 @@ export default function App() {
const targetComposingRef = useRef(false); const targetComposingRef = useRef(false);
const messageListRef = useRef(null); const messageListRef = useRef(null);
const stickToBottomRef = useRef(true); 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 [status, setStatus] = useState("idle");
const [statusHint, setStatusHint] = useState("点击连接开始聊天"); const [statusHint, setStatusHint] = useState("点击连接开始聊天");
@ -219,6 +348,11 @@ export default function App() {
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
const [showSystemMessages, setShowSystemMessages] = useState(false); const [showSystemMessages, setShowSystemMessages] = useState(false);
const [sending, setSending] = 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 [certFingerprint, setCertFingerprint] = useState("");
const [myPublicKey, setMyPublicKey] = useState(""); const [myPublicKey, setMyPublicKey] = useState("");
const [publicKeyBusy, setPublicKeyBusy] = useState(false); const [publicKeyBusy, setPublicKeyBusy] = useState(false);
@ -230,6 +364,7 @@ export default function App() {
const canConnect = status === "idle" || status === "error"; const canConnect = status === "idle" || status === "error";
const canDisconnect = status !== "idle" && status !== "error"; const canDisconnect = status !== "idle" && status !== "error";
const canSend = isConnected && draft.trim().length > 0 && !sending; 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 activeChannel = directMode ? CHANNEL_PRIVATE : CHANNEL_BROADCAST;
const mobileConnectText = useMemo(() => { const mobileConnectText = useMemo(() => {
if (status === "ready") return "已连接"; if (status === "ready") return "已连接";
@ -309,6 +444,44 @@ export default function App() {
clearTimeout(messageCopyTimerRef.current); clearTimeout(messageCopyTimerRef.current);
messageCopyTimerRef.current = 0; 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) => [ setMessages((prev) => [
...prev, ...prev,
{ {
@ -372,12 +545,16 @@ export default function App() {
subtitle, subtitle,
channel, channel,
content: text, content: text,
contentType: options.contentType || CONTENT_TEXT,
audioBase64: options.audioBase64 || "",
audioDurationMillis: Number(options.audioDurationMillis) || 0,
audioMimeType: options.audioMimeType || "",
ts: Date.now() ts: Date.now()
} }
]); ]);
} }
function pushOutgoing(text, subtitle = "", channel = CHANNEL_BROADCAST) { function pushOutgoing(text, subtitle = "", channel = CHANNEL_BROADCAST, options = {}) {
setMessages((prev) => [ setMessages((prev) => [
...prev, ...prev,
{ {
@ -387,11 +564,47 @@ export default function App() {
subtitle, subtitle,
channel, channel,
content: text, content: text,
contentType: options.contentType || CONTENT_TEXT,
audioBase64: options.audioBase64 || "",
audioDurationMillis: Number(options.audioDurationMillis) || 0,
audioMimeType: options.audioMimeType || "",
ts: Date.now() 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() { async function ensureIdentity() {
if (identityRef.current) { if (identityRef.current) {
return identityRef.current; return identityRef.current;
@ -484,6 +697,399 @@ export default function App() {
}, 1600); }, 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() { async function connect() {
if (!canConnect) return; if (!canConnect) return;
const cryptoIssue = getCryptoIssueMessage(); const cryptoIssue = getCryptoIssueMessage();
@ -579,6 +1185,10 @@ export default function App() {
function disconnect() { function disconnect() {
manualCloseRef.current = true; manualCloseRef.current = true;
if (isRecording) {
finishRecording(false);
}
stopAudioPlayback();
if (wsRef.current) { if (wsRef.current) {
wsRef.current.close(); wsRef.current.close();
wsRef.current = null; wsRef.current = null;
@ -688,13 +1298,52 @@ export default function App() {
} }
if (message.type === "broadcast") { 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; return;
} }
if (message.type === "forward") { if (message.type === "forward") {
const sourceKey = String(message.key || "");
const sender = "私聊消息"; 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; return;
} }
@ -727,24 +1376,7 @@ export default function App() {
setSending(true); setSending(true);
try { try {
const timestamp = unixSecondsNow(); await sendSignedPayload(type, key, text);
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);
pushOutgoing(text, subtitle, channel); pushOutgoing(text, subtitle, channel);
setDraft(""); setDraft("");
} catch (error) { } catch (error) {
@ -921,17 +1553,43 @@ export default function App() {
</> </>
) : ( ) : (
<> <>
<div className="msg-head"> {(() => {
<strong>{item.sender}</strong> const isAudioMessage = item.contentType === CONTENT_AUDIO && item.audioBase64;
{item.subtitle ? <span>{item.subtitle}</span> : null} return (
<time>{formatTime(item.ts)}</time> <>
</div> <div className="msg-head">
<p>{item.content}</p> <strong>{item.sender}</strong>
<div className="msg-actions"> {item.subtitle ? <span>{item.subtitle}</span> : null}
<button className="btn btn-copy" onClick={() => copyMessageText(item.id, item.content)}> <time>{formatTime(item.ts)}</time>
{copiedMessageId === item.id ? "已复制" : "复制"} </div>
</button> {isAudioMessage ? (
</div> <button
className={`audio-message ${playingMessageId === item.id ? "playing" : ""}`}
type="button"
onClick={() => togglePlayAudioMessage(item)}
>
<span className="audio-icon">{playingMessageId === item.id ? "■" : "▶"}</span>
<span className="audio-waves" aria-hidden="true">
<i />
<i />
<i />
<i />
</span>
<span className="audio-duration">{formatAudioDuration(item.audioDurationMillis)}</span>
</button>
) : (
<p>{item.content}</p>
)}
{!isAudioMessage ? (
<div className="msg-actions">
<button className="btn btn-copy" onClick={() => copyMessageText(item.id, item.content)}>
{copiedMessageId === item.id ? "已复制" : "复制"}
</button>
</div>
) : null}
</>
);
})()}
</> </>
)} )}
</article> </article>
@ -939,25 +1597,64 @@ export default function App() {
)} )}
</div> </div>
<div className="composer"> <div className={`composer ${inputMode === CONTENT_AUDIO ? "audio-mode" : ""}`}>
<div className="composer-input-wrap"> <button
<textarea className={`btn btn-ghost btn-input-switch ${inputMode === CONTENT_AUDIO ? "active" : ""}`}
value={draft} type="button"
onChange={(event) => setDraft(event.target.value)} onClick={() => {
onKeyDown={onDraftKeyDown} if (inputMode === CONTENT_AUDIO && isRecording) {
onCompositionStart={() => { finishRecording(false);
draftComposingRef.current = true; }
}} setInputMode((prev) => (prev === CONTENT_TEXT ? CONTENT_AUDIO : CONTENT_TEXT));
onCompositionEnd={() => { }}
draftComposingRef.current = false; title={inputMode === CONTENT_TEXT ? "切换到语音输入" : "切换到文字输入"}
}} >
placeholder="输入消息" {inputMode === CONTENT_TEXT ? "语音" : "键盘"}
rows={1}
/>
</div>
<button className="btn btn-main btn-send" onClick={sendMessage} disabled={!canSend}>
{sending ? "发送中..." : "发送"}
</button> </button>
{inputMode === CONTENT_TEXT ? (
<>
<div className="composer-input-wrap">
<textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={onDraftKeyDown}
onCompositionStart={() => {
draftComposingRef.current = true;
}}
onCompositionEnd={() => {
draftComposingRef.current = false;
}}
placeholder="输入消息"
rows={1}
/>
</div>
<button className="btn btn-main btn-send" onClick={sendMessage} disabled={!canSend}>
{sending ? "发送中..." : "发送"}
</button>
</>
) : (
<button
className={`hold-to-talk ${isRecording ? (cancelOnRelease ? "cancel" : "recording") : ""}`}
type="button"
disabled={!canHoldToRecord}
onPointerDown={onHoldToTalkPointerDown}
onPointerMove={onHoldToTalkPointerMove}
onPointerUp={onHoldToTalkPointerUp}
onPointerCancel={onHoldToTalkPointerCancel}
onPointerLeave={(event) => {
if (isRecording && (event.buttons & 1) === 0) {
onHoldToTalkPointerUp(event);
}
}}
>
{isRecording
? cancelOnRelease
? "松开取消"
: `录制中 ${formatRecordingElapsed(recordingElapsedMillis)}`
: "按住说话"}
</button>
)}
</div> </div>
</section> </section>

@ -326,6 +326,94 @@ body {
font-size: 14px; font-size: 14px;
} }
.audio-message {
width: min(260px, 100%);
border: 0;
border-radius: 12px;
padding: 8px 10px;
margin-top: 2px;
background: rgba(8, 27, 52, 0.08);
color: #143556;
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.msg.outgoing .audio-message {
background: rgba(57, 139, 18, 0.14);
}
.audio-message:hover {
filter: brightness(0.98);
}
.audio-icon {
width: 24px;
height: 24px;
border-radius: 50%;
display: inline-grid;
place-items: center;
background: rgba(20, 53, 86, 0.14);
font-size: 12px;
font-weight: 700;
}
.audio-waves {
flex: 1;
min-width: 44px;
display: inline-flex;
align-items: center;
gap: 3px;
}
.audio-waves i {
width: 3px;
border-radius: 999px;
background: #1887ff;
display: block;
transform-origin: center bottom;
}
.audio-waves i:nth-child(1) {
height: 9px;
}
.audio-waves i:nth-child(2) {
height: 15px;
}
.audio-waves i:nth-child(3) {
height: 11px;
}
.audio-waves i:nth-child(4) {
height: 13px;
background: #13bca8;
}
.audio-message.playing .audio-waves i {
animation: audio-wave 0.75s ease-in-out infinite alternate;
}
.audio-message.playing .audio-waves i:nth-child(2) {
animation-delay: 0.08s;
}
.audio-message.playing .audio-waves i:nth-child(3) {
animation-delay: 0.16s;
}
.audio-message.playing .audio-waves i:nth-child(4) {
animation-delay: 0.24s;
}
.audio-duration {
color: #4d6684;
font-size: 12px;
font-weight: 700;
}
.msg-actions { .msg-actions {
margin-top: 0; margin-top: 0;
display: flex; display: flex;
@ -361,12 +449,17 @@ body {
border-top: 1px solid var(--card-border); border-top: 1px solid var(--card-border);
padding: 10px 12px; padding: 10px 12px;
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: auto 1fr auto;
gap: 10px; gap: 10px;
align-items: center;
background: rgba(255, 255, 255, 0.96); background: rgba(255, 255, 255, 0.96);
box-shadow: inset 0 1px 0 rgba(19, 35, 58, 0.06); box-shadow: inset 0 1px 0 rgba(19, 35, 58, 0.06);
} }
.composer.audio-mode {
grid-template-columns: auto 1fr;
}
.composer-input-wrap { .composer-input-wrap {
border: 1px solid rgba(19, 35, 58, 0.18); border: 1px solid rgba(19, 35, 58, 0.18);
border-radius: 15px; border-radius: 15px;
@ -374,6 +467,41 @@ body {
padding: 6px 10px; padding: 6px 10px;
} }
.btn-input-switch {
min-width: 56px;
min-height: 38px;
border-radius: 12px;
padding: 0 12px;
align-self: stretch;
}
.btn-input-switch.active {
background: #dff4ff;
color: #145184;
}
.hold-to-talk {
border: 1px solid rgba(19, 35, 58, 0.16);
border-radius: 15px;
min-height: 42px;
background: #f6fbff;
color: #1a426b;
font-size: 14px;
font-weight: 700;
}
.hold-to-talk.recording {
background: rgba(21, 132, 255, 0.14);
border-color: rgba(21, 132, 255, 0.36);
color: #0e4d84;
}
.hold-to-talk.cancel {
background: rgba(216, 56, 99, 0.14);
border-color: rgba(216, 56, 99, 0.34);
color: #8f1534;
}
.composer textarea { .composer textarea {
width: 100%; width: 100%;
resize: none; resize: none;
@ -600,6 +728,15 @@ select:focus {
} }
} }
@keyframes audio-wave {
from {
transform: scaleY(0.62);
}
to {
transform: scaleY(1.12);
}
}
@media (max-width: 980px) { @media (max-width: 980px) {
html, html,
body, body,
@ -805,7 +942,7 @@ select:focus {
.composer { .composer {
position: sticky; position: sticky;
bottom: 56px; bottom: 56px;
grid-template-columns: 1fr auto; grid-template-columns: auto 1fr auto;
align-items: center; align-items: center;
padding: 6px 8px; padding: 6px 8px;
gap: 6px; gap: 6px;
@ -813,6 +950,10 @@ select:focus {
border-top: 1px solid rgba(19, 35, 58, 0.08); border-top: 1px solid rgba(19, 35, 58, 0.08);
} }
.composer.audio-mode {
grid-template-columns: auto 1fr;
}
.btn-send { .btn-send {
width: auto; width: auto;
min-width: 56px; min-width: 56px;
@ -831,6 +972,20 @@ select:focus {
padding: 6px 12px; padding: 6px 12px;
} }
.btn-input-switch {
min-width: 52px;
min-height: 38px;
border-radius: 19px;
font-size: 12px;
padding: 0 10px;
}
.hold-to-talk {
min-height: 40px;
border-radius: 20px;
font-size: 13px;
}
.composer textarea { .composer textarea {
min-height: 20px; min-height: 20px;
max-height: 88px; max-height: 88px;

Loading…
Cancel
Save