pull/14/head
minxiwan 1 week ago
parent 82c4d9b6e3
commit 5f0302dd0e

@ -35,26 +35,20 @@ namespace OnlineMsgServer.Common
return;
}
Message response = new()
string senderPublicKey = UserService.GetUserPublicKeyByID(wsid)!;
if (!PeerNetworkService.TryMarkSeen(senderPublicKey, Type, key, payload.Payload))
{
Type = "broadcast",
Data = payload.Payload,
Key = UserService.GetUserNameByID(wsid),
};
foreach (IWebSocketSession session in Sessions.Sessions)
{
if (session.ID != wsid)//不用发给自己
{
string? publicKey = UserService.GetUserPublicKeyByID(session.ID);
if (publicKey != null)
{
string jsonString = response.ToJsonString();
string encryptString = RsaService.EncryptForClient(publicKey, jsonString);
session.Context.WebSocket.Send(encryptString);
}
}
return;
}
string senderName = UserService.GetUserNameByID(wsid) ?? "anonymous";
PeerNetworkService.DeliverBroadcastToLocalClients(senderName, payload.Payload, wsid);
string? excludePeerPublicKey = UserService.IsPeerNodeSession(wsid)
? UserService.GetPeerPublicKeyBySessionId(wsid)
: null;
PeerNetworkService.RelayBroadcast(payload.Payload, excludePeerPublicKey);
}
catch (Exception ex)
{

@ -41,33 +41,29 @@ namespace OnlineMsgServer.Common
return;
}
string fromPublicKey = UserService.GetUserPublicKeyByID(wsid)!;
Message response = new()
if (PeerNetworkService.TryHandlePeerRelayForward(wsid, forwardPublickKey, payload))
{
Type = "forward",
Data = payload.Payload,
Key = fromPublicKey,
};
string jsonString = response.ToJsonString();
string encryptString = RsaService.EncryptForClient(forwardPublickKey, jsonString);
return;
}
List<User> userList = UserService.GetUserListByPublicKey(forwardPublickKey);
if (userList.Count == 0)
string fromPublicKey = UserService.GetUserPublicKeyByID(wsid)!;
if (!PeerNetworkService.TryMarkSeen(fromPublicKey, Type, forwardPublickKey, payload.Payload))
{
Log.Security("forward_target_offline_or_untrusted", $"wsid={wsid}");
return;
}
foreach (IWebSocketSession session in Sessions.Sessions)
bool delivered = PeerNetworkService.DeliverForwardToLocalClient(fromPublicKey, forwardPublickKey, payload.Payload);
if (delivered)
{
if (userList.Exists(u => u.ID == session.ID))
{
session.Context.WebSocket.Send(encryptString);
break;
}
return;
}
string? excludePeerPublicKey = UserService.IsPeerNodeSession(wsid)
? UserService.GetPeerPublicKeyBySessionId(wsid)
: null;
PeerNetworkService.RelayForwardMiss(forwardPublickKey, payload.Payload, excludePeerPublicKey);
Log.Security("forward_target_offline_or_untrusted", $"wsid={wsid}");
}
catch (Exception ex)
{

@ -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;
}
UserService.UserLogin(wsid, payload.PublicKey, userName);
bool isPeerNode = PeerNetworkService.IsPeerUserName(userName);
UserService.UserLogin(wsid, payload.PublicKey, userName, isPeerNode);
Log.Security("auth_success", $"wsid={wsid} user={userName}");
Message ack = new()

@ -12,6 +12,11 @@ namespace OnlineMsgServer.Common
/// </summary>
public string? Name { get; set; }
/// <summary>
/// 是否为服务器节点伪装的 peer 用户
/// </summary>
public bool IsPeerNode { get; set; }
/// <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)
{
lock (_PublicRsaLock)

@ -20,9 +20,16 @@ namespace OnlineMsgServer.Core
public int ChallengeTtlSeconds { get; init; } = 120;
public int MaxClockSkewSeconds { get; init; } = 60;
public int ReplayWindowSeconds { get; init; } = 120;
public string PeerNodeName { get; init; } = "server";
public bool PeerNodeNameExplicitlyConfigured { get; init; }
public string PeerUserPrefix { get; init; } = "peer:";
public string[] PeerUrls { get; init; } = [];
public int PeerReconnectSeconds { get; init; } = 5;
public int SeenCacheSeconds { get; init; } = 120;
public static SecurityConfig LoadFromEnvironment()
{
string? rawPeerNodeName = GetString("PEER_NODE_NAME");
return new SecurityConfig
{
ListenPort = GetInt("LISTEN_PORT", 13173, 1),
@ -40,6 +47,12 @@ namespace OnlineMsgServer.Core
ChallengeTtlSeconds = GetInt("CHALLENGE_TTL_SECONDS", 120, 10),
MaxClockSkewSeconds = GetInt("MAX_CLOCK_SKEW_SECONDS", 60, 1),
ReplayWindowSeconds = GetInt("REPLAY_WINDOW_SECONDS", 120, 10),
PeerNodeName = rawPeerNodeName ?? CreateGuestName(),
PeerNodeNameExplicitlyConfigured = !string.IsNullOrWhiteSpace(rawPeerNodeName),
PeerUserPrefix = GetString("PEER_USER_PREFIX") ?? "peer:",
PeerUrls = GetCsv("PEER_URLS"),
PeerReconnectSeconds = GetInt("PEER_RECONNECT_SECONDS", 5, 1),
SeenCacheSeconds = GetInt("SEEN_CACHE_SECONDS", 120, 1),
};
}
@ -89,5 +102,25 @@ namespace OnlineMsgServer.Core
return Math.Max(parsed, minValue);
}
private static string[] GetCsv(string key)
{
string? value = Environment.GetEnvironmentVariable(key);
if (string.IsNullOrWhiteSpace(value))
{
return [];
}
return value
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct(StringComparer.Ordinal)
.ToArray();
}
private static string CreateGuestName()
{
return $"guest-{Random.Shared.Next(100000, 1000000)}";
}
}
}

@ -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>
/// 通过publickey返回用户列表
/// </summary>
public static List<User> GetUserListByPublicKey(string publicKey)
public static List<User> GetUserListByPublicKey(string publicKey, bool includePeerNodes = true)
{
lock (_UserListLock)
{
return _UserList.FindAll(u => u.PublicKey == publicKey && u.IsAuthenticated);
return _UserList.FindAll(u =>
u.PublicKey == publicKey &&
u.IsAuthenticated &&
(includePeerNodes || !u.IsPeerNode));
}
}
@ -53,7 +56,7 @@ namespace OnlineMsgServer.Core
/// <summary>
/// 通过wsid设置用户PublicKey
/// </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)
{
@ -62,6 +65,7 @@ namespace OnlineMsgServer.Core
{
user.PublicKey = publickey.Trim();
user.Name = name.Trim();
user.IsPeerNode = isPeerNode;
user.IsAuthenticated = true;
user.PendingChallenge = null;
user.AuthenticatedAtUtc = DateTime.UtcNow;
@ -131,6 +135,50 @@ namespace OnlineMsgServer.Core
}
}
public static bool IsPeerNodeSession(string wsid)
{
lock (_UserListLock)
{
User? user = _UserList.Find(u => u.ID == wsid);
return user is { IsAuthenticated: true, IsPeerNode: true };
}
}
public static string? GetPeerPublicKeyBySessionId(string wsid)
{
lock (_UserListLock)
{
User? user = _UserList.Find(u => u.ID == wsid);
if (user is { IsAuthenticated: true, IsPeerNode: true })
{
return user.PublicKey;
}
return null;
}
}
public static List<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()
{
lock (_UserListLock)

@ -12,6 +12,13 @@ namespace OnlineMsgServer.Core
private static readonly object _abuseLock = new();
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)
{
SecurityConfig config = SecurityRuntime.Config;

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

@ -1,19 +1,48 @@
# OnlineMsgServer
在线消息中转服务WebSocket + RSA支持客户端鉴权、单播转发、广播、签名校验、防重放与限流。
一个基于 WebSocket 的在线消息中转服务,使用 RSA 完成握手、公钥鉴权和业务包加密。
当前版本除了单机广播/私聊,还支持“服务器伪装成普通用户”的 peer 互联模式:
- 客户端外层协议不变
- 服务器之间通过普通 `publickey / forward / broadcast` 连接
- 本地私聊未命中时,服务端可继续向 peer 盲转发
- 广播可在 peer 节点之间扩散
- 服务端内置短期 `seen-cache`,按 `hash(sender + type + key + payload)` 去重
这套 peer 能力更接近“盲转发网络”,不是强一致的用户目录或联邦路由系统。
## 功能概览
- WebSocket 服务,支持 `ws://``wss://`
- 明文首包下发服务端公钥与一次性 challenge
- 客户端使用自己的 RSA 公钥 + 签名完成鉴权
- 业务消息支持广播和按公钥私聊
- 签名校验、防重放、限流、IP 封禁、消息大小限制
- 可选 peer 网络:广播扩散、私聊 miss 后继续中继
- Android / Web 客户端可直接复用现有协议
## 仓库结构
- `deploy/`:一键部署与生产产物脚本
- `Common/`:协议消息与业务处理器
- `Core/`安全配置、用户会话、peer 网络、RSA 服务
- `deploy/`:本地测试 / 局域网证书 / 生产准备脚本
- `web-client/`React Web 客户端
- `android-client/`AndroidKotlin + Compose客户端
- `android-client/`Android 客户端
## 运行前提
## 运行依赖
- `.NET 8 SDK`
- `Docker`
- `openssl`
- 部署脚本 `deploy/deploy_test_ws.sh``deploy/redeploy_with_lan_cert.sh` 依赖 `ipconfig`、`route`(当前按 macOS 环境编写)
本仓库附带的 `deploy/*.sh` 脚本按 macOS 环境编写,依赖:
- `ipconfig`
- `route`
- `awk`
- `base64`
- `tr`
## 快速开始
@ -23,23 +52,41 @@
cd <repo-root>
```
### 1) 测试模式WS
### 1. 本地测试WS
```bash
bash deploy/deploy_test_ws.sh
```
脚本会自动生成/复用协议私钥、构建镜像并以 `REQUIRE_WSS=false` 启动容器。
脚本会
### 2) 安全模式WSS + 局域网证书)
- 生成或复用协议私钥 `deploy/keys/server_rsa_pkcs8.b64`
- 构建 Docker 镜像
- 以 `REQUIRE_WSS=false` 启动单节点服务
### 2. 局域网测试WSS
```bash
bash deploy/redeploy_with_lan_cert.sh
```
脚本会重签包含当前局域网 IP 的证书、构建镜像并以 `REQUIRE_WSS=true` 启动容器。
脚本会:
- 自动探测当前局域网 IP
- 生成包含 LAN IP 的自签名证书
- 生成运行时使用的 `server.pfx`
- 构建镜像并以 `REQUIRE_WSS=true` 启动容器
### 3) 生产准备(证书 + 镜像 + 部署产物)
适合 Android 真机、同网段设备和浏览器本地联调。
Android 客户端 debug 包支持额外信任本地局域网 CA
- 把局域网 WSS 使用的 CA 证书复制到 `deploy/certs/android-local/local_ca.crt`
- `deploy/certs/` 已在 `.gitignore` 中,只用于本地调试,不应提交到 Git
- `assembleDebug` 会自动把它接入 debug-only 的 `networkSecurityConfig`
- release 构建不会信任这张本地 CA
### 3. 生产准备
```bash
DOMAIN=chat.example.com \
@ -50,9 +97,14 @@ CERT_PASSWORD='change-me' \
bash deploy/prepare_prod_release.sh
```
输出目录默认在 `deploy/output/prod`,包含 `prod.env`、镜像 tar可选和运行示例脚本。
输出默认在 `deploy/output/prod/`,包括:
- `prod.env`
- Docker 镜像 tar可选
- 运行示例脚本
- 运行时证书与协议私钥
无 CA 证书时可临时使用自签名(仅测试):
如果只是临时测试,也可以生成自签名证书
```bash
DOMAIN=chat.example.com \
@ -62,9 +114,9 @@ CERT_PASSWORD='change-me' \
bash deploy/prepare_prod_release.sh
```
## 手动 Docker 启动示例
## 手动 Docker 启动
### WS(测试)
### 单节点:WS
```bash
docker run -d --name onlinemsgserver --restart unless-stopped \
@ -75,7 +127,7 @@ docker run -d --name onlinemsgserver --restart unless-stopped \
onlinemsgserver:latest
```
### WSS(生产/预生产)
### 单节点:WSS
```bash
docker run -d --name onlinemsgserver --restart unless-stopped \
@ -89,16 +141,44 @@ docker run -d --name onlinemsgserver --restart unless-stopped \
onlinemsgserver:latest
```
### 第二节点:通过 peer 连到第一节点
下面这个例子会启动第二个节点,对外提供 `13174`,并主动连到第一节点:
```bash
docker run -d --name onlinemsgserver-peer2 --restart unless-stopped \
-p 13174:13174 \
-v "$(pwd)/deploy/certs:/app/certs:ro" \
-e REQUIRE_WSS=true \
-e LISTEN_PORT=13174 \
-e TLS_CERT_PATH=/app/certs/server.pfx \
-e TLS_CERT_PASSWORD=changeit \
-e ALLOW_EPHEMERAL_SERVER_KEY=true \
-e PEER_NODE_NAME=peer-node-b \
-e PEER_URLS=wss://host.docker.internal:13173/ \
onlinemsgserver:latest
```
这里有一个很重要的约束:
- 如果客户端访问的是 `wss://host:13174/`
- 那容器内 `LISTEN_PORT` 也应当是 `13174`
`WebSocketSharp` 会校验握手请求里的 `Host: host:port`,容器内监听端口和客户端看到的端口不一致时,可能直接返回 `400 Bad Request`
## 协议说明
### 加密方式
- RSA-2048-OAEP-SHA256
- 明文按 190 字节分块加密
- 密文按 256 字节分块解密
- 业务消息传输为 base64 字符串
- 服务端握手公钥RSA-2048SPKI / PKCS8
- 传输加密:`RSA/ECB/OAEPWithSHA-256AndMGF1Padding`
- 明文按 `190` 字节分块加密
- 密文按 `256` 字节分块解密
- WebSocket 上传输的是 base64 字符串
### 通用包结构(客户端 -> 服务端)
### 通用包结构
客户端发给服务端的明文结构如下,随后再整体用服务端公钥加密:
```json
{
@ -108,7 +188,9 @@ docker run -d --name onlinemsgserver --restart unless-stopped \
}
```
### 连接首包(服务端 -> 客户端,明文)
### 首包:服务端 -> 客户端(明文)
客户端建立连接后,服务端立即发送:
```json
{
@ -122,83 +204,196 @@ docker run -d --name onlinemsgserver --restart unless-stopped \
}
```
### 鉴权登记 `type=publickey`(客户端 -> 服务端)
### 鉴权`type=publickey`
- `key`:用户名(为空时服务端会生成匿名名)
- `data`
- `key`:用户名
- `data.publicKey`:客户端公钥
- `data.challenge`:首包中的 `authChallenge`
- `data.timestamp`Unix 秒级时间戳
- `data.nonce`:随机串
- `data.signature`:客户端私钥签名
示例:
```json
{
"publicKey": "客户端公钥(base64 SPKI)",
"challenge": "上一步 authChallenge",
"timestamp": 1739600000,
"nonce": "随机字符串",
"signature": "签名(base64)"
"type": "publickey",
"key": "guest-123456",
"data": {
"publicKey": "base64-spki",
"challenge": "challenge-from-server",
"timestamp": 1739600000,
"nonce": "random-string",
"signature": "base64-signature"
}
}
```
签名串:
签名原文
```text
publickey\n{userName}\n{publicKey}\n{challenge}\n{timestamp}\n{nonce}
publickey
{userName}
{publicKey}
{challenge}
{timestamp}
{nonce}
```
### 单播 `type=forward`
### 私聊:`type=forward`
- `key`:目标客户端公钥
- `data`
- `key`:目标用户公钥
- `data.payload`:消息内容
- `data.timestamp` / `data.nonce` / `data.signature`:发送者签名信息
```json
{
"payload": "消息内容",
"timestamp": 1739600000,
"nonce": "随机字符串",
"signature": "签名(base64)"
"type": "forward",
"key": "target-user-public-key",
"data": {
"payload": "hello",
"timestamp": 1739600000,
"nonce": "random-string",
"signature": "base64-signature"
}
}
```
签名串:
签名原文
```text
forward\n{targetPublicKey}\n{payload}\n{timestamp}\n{nonce}
forward
{targetPublicKey}
{payload}
{timestamp}
{nonce}
```
### 广播 `type=broadcast`
### 广播`type=broadcast`
- `key`为空字符串
- `data``forward`
- `key`通常为空字符串
- `data`结构与 `forward` 相同
签名
签名原文
```text
broadcast\n{key}\n{payload}\n{timestamp}\n{nonce}
broadcast
{key}
{payload}
{timestamp}
{nonce}
```
### 连接流程
1. 客户端建立 WebSocket 连接后接收明文 `publickey` 首包。
2. 客户端发送签名鉴权包(`type=publickey`)。
3. 鉴权成功后,客户端发送 `forward` / `broadcast` 业务消息(加密 + 签名)。
1. 客户端建立 WebSocket 连接。
2. 服务端发送明文 `publickey` 首包。
3. 客户端用自己的私钥签名后发送 `type=publickey` 鉴权包。
4. 服务端返回加密的 `auth_ok`
5. 客户端开始发送 `forward` / `broadcast`
## Peer 网络说明
Peer 网络不引入新的客户端外层协议。节点之间也是普通登录用户,只是服务端会把这类会话当成 peer 处理。
当前行为:
- 本地广播:先发给本地普通客户端,再扩散到 peer
- 从 peer 收到广播:投递给本地普通客户端,再继续扩散
- 本地私聊命中:直接投递
- 本地私聊 miss包装为内部 relay 后继续发给 peer
- peer 收到私聊 relay本地命中就投递命不中就继续向其他 peer 转发
当前实现特点:
- 不做用户发现
- 不维护“谁在哪台服务器”的路由表
- 只保证尽力转发
- 依赖短期 `seen-cache` 防止消息在环路里重复扩散
### Peer 命名
为了让客户端界面更像普通聊天用户:
- 服务端内部仍用 `peer:` 前缀区分 peer 会话
- 发给客户端前会去掉这个内部前缀
- 如果显式设置了 `PEER_NODE_NAME=peer-node-b`,客户端看到的是 `peer-node-b`
- 如果没有显式设置 `PEER_NODE_NAME`,默认自动生成 `guest-xxxxxx`
## 环境变量
### 基础运行
- `LISTEN_PORT`:监听端口,默认 `13173`
- `REQUIRE_WSS`:是否启用 WSS默认 `false`
- `TLS_CERT_PATH`:证书路径(启用 WSS 时必填)
- `TLS_CERT_PASSWORD`:证书密码(可空)
- `SERVER_PRIVATE_KEY_B64`服务端私钥PKCS8 base64
- `SERVER_PRIVATE_KEY_PATH`:服务端私钥文件路径(与上面二选一)
- `ALLOW_EPHEMERAL_SERVER_KEY`:允许使用临时内存私钥,默认 `false`
- `TLS_CERT_PATH`PFX 证书路径,启用 WSS 时必填
- `TLS_CERT_PASSWORD`PFX 证书密码,可空
### 协议私钥
- `SERVER_PRIVATE_KEY_B64`协议私钥PKCS8 base64
- `SERVER_PRIVATE_KEY_PATH`:协议私钥文件路径
- `ALLOW_EPHEMERAL_SERVER_KEY`:若未提供私钥,是否允许启动临时内存私钥,默认 `false`
### 安全限制
- `MAX_CONNECTIONS`:最大连接数,默认 `1000`
- `MAX_MESSAGE_BYTES`:单消息最大字节数,默认 `65536`
- `RATE_LIMIT_COUNT`:限流窗口允许消息数,默认 `30`
- `RATE_LIMIT_WINDOW_SECONDS`:限流窗口秒数,默认 `10`
- `IP_BLOCK_SECONDS`:触发滥用后的封禁秒数,默认 `120`
- `CHALLENGE_TTL_SECONDS`:挑战值有效期秒数,默认 `120`
- `CHALLENGE_TTL_SECONDS`challenge 有效期秒数,默认 `120`
- `MAX_CLOCK_SKEW_SECONDS`:允许时钟偏差秒数,默认 `60`
- `REPLAY_WINDOW_SECONDS`:防重放窗口秒数,默认 `120`
- `SEEN_CACHE_SECONDS`:短期去重缓存秒数,默认 `120`
### Peer
- `PEER_NODE_NAME`peer 登录名;未显式配置时自动生成 `guest-xxxxxx`
- `PEER_USER_PREFIX`:内部保留前缀,默认 `peer:`
- `PEER_URLS`:要主动连接的 peer 地址,逗号分隔
- `PEER_RECONNECT_SECONDS`peer 断线后的重连间隔,默认 `5`
## 本地调试建议
### Android 连 `ws://`
Android 9 之后默认禁止明文流量。若用 `ws://` 调试,需要客户端显式允许 cleartext。
### Android 连 `wss://`
若服务端使用自签名证书,需要满足其一:
- 设备/模拟器信任这张 CA
- Android debug 包内置该 CA 的信任配置
### 多实例本地测试
同一台机器上起多个节点时,建议:
- 为每个节点分配不同 `LISTEN_PORT`
- 对外映射端口和 `LISTEN_PORT` 保持一致
- 第一个节点使用固定协议私钥
- 第二个测试节点可使用 `ALLOW_EPHEMERAL_SERVER_KEY=true`
## 排错
### `expected HTTP 101 but was 400`
常见原因:
- 容器内 `LISTEN_PORT` 与客户端访问端口不一致
- 客户端实际访问了错误的 `Host: port`
### Android 显示“未收到服务器首包”
当前服务端已禁用 WebSocket 压缩扩展协商,以避免某些 Android/OkHttp 路径拿不到压缩后的首个 `publickey` Hello。
### Peer 连不上 WSS
当前 peer 出站连接使用 .NET `ClientWebSocket`,可以直连 `wss://` peer。若是自签名测试环境请确认目标地址可达并尽量使用稳定的局域网地址或 `host.docker.internal`
## 客户端文档
## 相关文档
- Web 客户端说明:`web-client/README.md`
- Android 客户端说明:`android-client/README.md`
- Web 客户端[web-client/README.md](/Users/solux/Codes/OnlineMsgServer/web-client/README.md)
- Android 客户端[android-client/README.md](/Users/solux/Codes/OnlineMsgServer/android-client/README.md)

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

@ -5,6 +5,12 @@ plugins {
id("com.google.devtools.ksp")
}
val localDebugCaCertSource = rootDir.resolve("../deploy/certs/android-local/local_ca.crt")
val generatedLocalDebugTrustDir = layout.buildDirectory.dir("generated/localDebugTrust")
val generatedLocalDebugTrustRoot = generatedLocalDebugTrustDir.get().asFile
val generatedLocalDebugResDir = generatedLocalDebugTrustRoot.resolve("res")
val generatedLocalDebugManifestFile = generatedLocalDebugTrustRoot.resolve("AndroidManifest.xml")
android {
namespace = "com.onlinemsg.client"
compileSdk = 34
@ -52,6 +58,13 @@ android {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
sourceSets {
getByName("debug") {
res.srcDir(generatedLocalDebugResDir)
manifest.srcFile(generatedLocalDebugManifestFile)
}
}
}
dependencies {
@ -93,6 +106,56 @@ val debugApkExportDir: String = providers.gradleProperty("debugApkExportDir")
.get()
val debugApkExportName = "onlinemsgclient-debug.apk"
val prepareLocalDebugTrust by tasks.registering {
val sourceFile = localDebugCaCertSource
inputs.file(sourceFile).optional()
outputs.dir(generatedLocalDebugTrustDir)
doLast {
generatedLocalDebugTrustRoot.deleteRecursively()
generatedLocalDebugTrustRoot.mkdirs()
if (sourceFile.exists()) {
val rawDir = generatedLocalDebugResDir.resolve("raw")
val xmlDir = generatedLocalDebugResDir.resolve("xml")
rawDir.mkdirs()
xmlDir.mkdirs()
sourceFile.copyTo(rawDir.resolve("local_ca.crt"), overwrite = true)
xmlDir.resolve("network_security_config.xml").writeText(
"""
<?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) {
from(layout.buildDirectory.file("outputs/apk/debug/app-debug.apk"))
into(debugApkExportDir)
@ -102,6 +165,11 @@ val exportDebugApk by tasks.registering(Copy::class) {
}
}
tasks.matching { it.name == "preDebugBuild" }.configureEach {
dependsOn(prepareLocalDebugTrust)
}
tasks.matching { it.name == "assembleDebug" }.configureEach {
dependsOn(prepareLocalDebugTrust)
finalizedBy(exportDebugApk)
}

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

@ -15,6 +15,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestNotificationPermissionIfNeeded()
requestAudioPermissionIfNeeded()
enableEdgeToEdge()
setContent {
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 {
const val REQUEST_NOTIFICATION_PERMISSION = 1002
const val REQUEST_AUDIO_PERMISSION = 1003
}
}

@ -4,10 +4,12 @@ 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 = 1,
version = 3,
exportSchema = false
)
abstract class ChatDatabase : RoomDatabase() {
@ -19,13 +21,29 @@ abstract class ChatDatabase : RoomDatabase() {
@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
).build().also { db ->
)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build().also { db ->
instance = db
}
}

@ -1,43 +1,60 @@
package com.onlinemsg.client.data.local
import com.onlinemsg.client.ui.MessageChannel
import com.onlinemsg.client.ui.MessageContentType
import com.onlinemsg.client.ui.MessageRole
import com.onlinemsg.client.ui.UiMessage
class ChatHistoryRepository(private val messageDao: ChatMessageDao) {
suspend fun loadMessages(limit: Int): List<UiMessage> {
return messageDao.listAll()
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(message: UiMessage, limit: Int) {
messageDao.upsert(message.toEntity())
messageDao.trimToLatest(limit)
suspend fun appendMessage(serverKey: String, message: UiMessage, limit: Int) {
messageDao.upsert(message.toEntity(serverKey))
messageDao.trimToLatest(serverKey, limit)
}
suspend fun clearAll() {
messageDao.clearAll()
suspend fun clearAll(serverKey: String) {
messageDao.clearAll(serverKey)
}
private suspend fun migrateLegacyMessagesIfNeeded(serverKey: String) {
if (messageDao.countByServer(serverKey) > 0) return
if (messageDao.countLegacyMessages() == 0) return
messageDao.migrateLegacyMessagesToServer(
serverKey = serverKey,
idPrefix = storageIdPrefix(serverKey)
)
}
}
private fun UiMessage.toEntity(): ChatMessageEntity {
private fun UiMessage.toEntity(serverKey: String): ChatMessageEntity {
return ChatMessageEntity(
id = id,
id = toStorageId(serverKey, id),
serverKey = serverKey,
role = role.name,
sender = sender,
subtitle = subtitle,
content = content,
channel = channel.name,
timestampMillis = timestampMillis
timestampMillis = timestampMillis,
contentType = contentType.name,
audioBase64 = audioBase64,
audioDurationMillis = audioDurationMillis
)
}
private fun ChatMessageEntity.toUiMessageOrNull(): UiMessage? {
val parsedRole = runCatching { MessageRole.valueOf(role) }.getOrNull() ?: return null
val parsedChannel = runCatching { MessageChannel.valueOf(channel) }.getOrNull() ?: return null
val parsedContentType = runCatching { MessageContentType.valueOf(contentType) }.getOrNull()
?: MessageContentType.TEXT
return UiMessage(
id = id,
role = parsedRole,
@ -45,6 +62,19 @@ private fun ChatMessageEntity.toUiMessageOrNull(): UiMessage? {
subtitle = subtitle,
content = content,
channel = parsedChannel,
timestampMillis = timestampMillis
timestampMillis = timestampMillis,
contentType = parsedContentType,
audioBase64 = audioBase64,
audioDurationMillis = audioDurationMillis
)
}
private fun toStorageId(serverKey: String, messageId: String): String {
return if (messageId.startsWith(storageIdPrefix(serverKey))) {
messageId
} else {
storageIdPrefix(serverKey) + messageId
}
}
private fun storageIdPrefix(serverKey: String): String = "$serverKey::"

@ -7,8 +7,8 @@ import androidx.room.Query
@Dao
interface ChatMessageDao {
@Query("SELECT * FROM chat_messages ORDER BY timestampMillis ASC")
suspend fun listAll(): List<ChatMessageEntity>
@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)
@ -16,16 +16,27 @@ interface ChatMessageDao {
@Query(
"""
DELETE FROM chat_messages
WHERE id NOT IN (
WHERE serverKey = :serverKey
AND id NOT IN (
SELECT id
FROM chat_messages
WHERE serverKey = :serverKey
ORDER BY timestampMillis DESC
LIMIT :limit
)
"""
)
suspend fun trimToLatest(limit: Int)
suspend fun trimToLatest(serverKey: String, limit: Int)
@Query("DELETE FROM chat_messages")
suspend fun clearAll()
@Query("DELETE FROM chat_messages WHERE serverKey = :serverKey")
suspend fun clearAll(serverKey: String)
@Query("SELECT COUNT(*) FROM chat_messages WHERE serverKey = :serverKey")
suspend fun countByServer(serverKey: String): Int
@Query("SELECT COUNT(*) FROM chat_messages WHERE serverKey = ''")
suspend fun countLegacyMessages(): Int
@Query("UPDATE chat_messages SET serverKey = :serverKey, id = :idPrefix || id WHERE serverKey = ''")
suspend fun migrateLegacyMessagesToServer(serverKey: String, idPrefix: String)
}

@ -6,10 +6,14 @@ import androidx.room.PrimaryKey
@Entity(tableName = "chat_messages")
data class ChatMessageEntity(
@PrimaryKey val id: String,
val serverKey: String,
val role: String,
val sender: String,
val subtitle: String,
val content: String,
val channel: String,
val timestampMillis: Long
val timestampMillis: Long,
val contentType: String,
val audioBase64: String,
val audioDurationMillis: Long
)

@ -24,8 +24,7 @@ data class UserPreferences(
val shouldAutoReconnect: Boolean,
val themeId: String = "blue",
val useDynamicColor: Boolean = true,
val language: String = "zh",
val notificationSound: String = "default"
val language: String = "zh" // 默认中文
)
class UserPreferencesRepository(
@ -51,8 +50,7 @@ class UserPreferencesRepository(
shouldAutoReconnect = prefs[KEY_SHOULD_AUTO_RECONNECT] ?: false,
themeId = prefs[KEY_THEME_ID] ?: "blue",
useDynamicColor = prefs[KEY_USE_DYNAMIC_COLOR] ?: true,
language = prefs[KEY_LANGUAGE] ?: "zh",
notificationSound = prefs[KEY_NOTIFICATION_SOUND] ?: "default"
language = prefs[KEY_LANGUAGE] ?: "zh"
)
}
@ -68,12 +66,6 @@ class UserPreferencesRepository(
}
}
suspend fun setNotificationSound(sound: String) {
context.dataStore.edit { prefs ->
prefs[KEY_NOTIFICATION_SOUND] = sound
}
}
suspend fun setUseDynamicColor(enabled: Boolean) {
context.dataStore.edit { prefs ->
prefs[KEY_USE_DYNAMIC_COLOR] = enabled
@ -163,6 +155,5 @@ class UserPreferencesRepository(
val KEY_THEME_ID: Preferences.Key<String> = stringPreferencesKey("theme_id")
val KEY_USE_DYNAMIC_COLOR: Preferences.Key<Boolean> = booleanPreferencesKey("use_dynamic_color")
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
)
@Serializable
data class AudioPayloadDto(
@SerialName("version") val version: Int = 1,
@SerialName("encoding") val encoding: String = "base64",
@SerialName("mimeType") val mimeType: String = "audio/mp4",
@SerialName("durationMillis") val durationMillis: Long,
@SerialName("data") val data: String
)
@Serializable
data class AudioChunkPayloadDto(
@SerialName("version") val version: Int = 1,
@SerialName("encoding") val encoding: String = "base64",
@SerialName("mimeType") val mimeType: String = "audio/mp4",
@SerialName("messageId") val messageId: String,
@SerialName("index") val index: Int,
@SerialName("total") val total: Int,
@SerialName("durationMillis") val durationMillis: Long,
@SerialName("data") val data: String
)
fun JsonElement?.asPayloadText(): String {
if (this == null || this is JsonNull) return ""
return if (this is JsonPrimitive && this.isString) {

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

@ -33,7 +33,6 @@ import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Forum
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.MusicNote
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
@ -74,8 +73,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.foundation.isSystemInDarkTheme
import android.os.Build
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import com.onlinemsg.client.ui.theme.OnlineMsgTheme
import java.time.Instant
import java.time.ZoneId
@ -112,7 +109,7 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) {
var tab by rememberSaveable { mutableStateOf(MainTab.CHAT) }
// 定义翻译函数 t
fun language(key: String) = LanguageManager.getString(key, state.language)
fun t(key: String) = LanguageManager.getString(key, state.language)
// 监听 ViewModel 发送的 UI 事件(如 Snackbar 消息)
LaunchedEffect(Unit) {
@ -149,7 +146,7 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) {
NavigationBarItem(
selected = tab == MainTab.CHAT,
onClick = { tab = MainTab.CHAT },
label = { Text(language(MainTab.CHAT.labelKey), style = MaterialTheme.typography.labelSmall) },
label = { Text(t(MainTab.CHAT.labelKey), style = MaterialTheme.typography.labelSmall) },
icon = {
Icon(
imageVector = Icons.Rounded.Forum,
@ -161,7 +158,7 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) {
NavigationBarItem(
selected = tab == MainTab.SETTINGS,
onClick = { tab = MainTab.SETTINGS },
label = { Text(language(MainTab.SETTINGS.labelKey), style = MaterialTheme.typography.labelSmall) },
label = { Text(t(MainTab.SETTINGS.labelKey), style = MaterialTheme.typography.labelSmall) },
icon = {
Icon(
imageVector = Icons.Rounded.Settings,
@ -214,8 +211,7 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) {
onClearMessages = viewModel::clearMessages,
onThemeChange = viewModel::updateTheme,
onUseDynamicColorChange = viewModel::updateUseDynamicColor,
onLanguageChange = viewModel::updateLanguage,
onNotificationSoundChange = viewModel::updateNotificationSound
onLanguageChange = viewModel::updateLanguage
)
}
}
@ -291,8 +287,8 @@ private fun ChatTab(
) {
val listState = rememberLazyListState()
// 定义语言函数 language
fun language(key: String) = LanguageManager.getString(key, state.language)
// 定义翻译函数 t
fun t(key: String) = LanguageManager.getString(key, state.language)
// 当消息列表新增消息时,自动滚动到底部
LaunchedEffect(state.visibleMessages.size) {
@ -316,12 +312,12 @@ private fun ChatTab(
FilterChip(
selected = !state.directMode,
onClick = { onToggleDirectMode(false) },
label = { Text(language("chat.broadcast")) }
label = { Text(t("chat.broadcast")) }
)
FilterChip(
selected = state.directMode,
onClick = { onToggleDirectMode(true) },
label = { Text(language("chat.private")) }
label = { Text(t("chat.private")) }
)
// 在这一行腾出的空间可以放置其他快捷操作,或者保持简洁
@ -340,8 +336,8 @@ private fun ChatTab(
value = state.targetKey,
onValueChange = onTargetKeyChange,
modifier = Modifier.fillMaxWidth(),
label = { Text(language("chat.target_key")) },
placeholder = { Text(language("chat.target_key")) },
label = { Text(t("chat.target_key")) },
placeholder = { Text(t("chat.target_key")) },
maxLines = 3
)
}
@ -366,7 +362,7 @@ private fun ChatTab(
)
) {
Text(
text = language("chat.empty_hint"),
text = t("chat.empty_hint"),
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodyMedium
)
@ -394,7 +390,7 @@ private fun ChatTab(
value = state.draft,
onValueChange = onDraftChange,
modifier = Modifier.weight(1f),
label = { Text(language("chat.input_placeholder")) },
label = { Text(t("chat.input_placeholder")) },
maxLines = 4,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(
@ -409,7 +405,7 @@ private fun ChatTab(
) {
Icon(Icons.AutoMirrored.Rounded.Send, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text(if (state.sending) "..." else language("chat.send"))
Text(if (state.sending) "..." else t("chat.send"))
}
}
}
@ -579,7 +575,6 @@ private fun MessageItem(
* @param onThemeChange 切换主题
* @param onUseDynamicColorChange 切换动态颜色
* @param onLanguageChange 切换语言
* @param onNotificationSoundChange 切换通知音效
*/
@Composable
private fun SettingsTab(
@ -596,10 +591,9 @@ private fun SettingsTab(
onClearMessages: () -> Unit,
onThemeChange: (String) -> Unit,
onUseDynamicColorChange: (Boolean) -> Unit,
onLanguageChange: (String) -> Unit,
onNotificationSoundChange: (String) -> Unit
onLanguageChange: (String) -> Unit
) {
fun language(key: String) = LanguageManager.getString(key, state.language)
fun t(key: String) = LanguageManager.getString(key, state.language)
val settingsCardModifier = Modifier.fillMaxWidth()
val settingsCardContentModifier = Modifier
@ -620,12 +614,12 @@ private fun SettingsTab(
modifier = settingsCardContentModifier,
verticalArrangement = settingsCardContentSpacing
) {
Text(language("settings.personal"), style = MaterialTheme.typography.titleMedium)
Text(t("settings.personal"), style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = state.displayName,
onValueChange = onDisplayNameChange,
modifier = Modifier.fillMaxWidth(),
label = { Text(language("settings.display_name")) },
label = { Text(t("settings.display_name")) },
maxLines = 1
)
}
@ -638,9 +632,9 @@ private fun SettingsTab(
modifier = settingsCardContentModifier,
verticalArrangement = settingsCardContentSpacing
) {
Text(language("settings.chat_data"), style = MaterialTheme.typography.titleMedium)
Text("聊天数据", style = MaterialTheme.typography.titleMedium)
OutlinedButton(onClick = onClearMessages) {
Text(language("settings.clear_msg"))
Text(t("settings.clear_msg"))
}
}
}
@ -652,26 +646,23 @@ private fun SettingsTab(
modifier = settingsCardContentModifier,
verticalArrangement = settingsCardContentSpacing
) {
Text(language("settings.server"), style = MaterialTheme.typography.titleMedium)
Text(t("settings.server"), style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = state.serverUrl,
onValueChange = onServerUrlChange,
modifier = Modifier.fillMaxWidth(),
label = { Text(language("settings.server_url")) },
label = { Text(t("settings.server_url")) },
maxLines = 1
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onSaveServer) { Text(language("settings.save_server")) }
OutlinedButton(onClick = onRemoveServer) { Text(language("settings.remove_current")) }
Button(onClick = onSaveServer) { Text(t("settings.save_server")) }
OutlinedButton(onClick = onRemoveServer) { Text(t("settings.remove_current")) }
}
if (state.serverUrls.isNotEmpty()) {
HorizontalDivider()
Text(language("settings.saved_servers"), style = MaterialTheme.typography.labelLarge)
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
state.serverUrls.forEach { url ->
Text(t("settings.saved_servers"), style = MaterialTheme.typography.labelLarge)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(state.serverUrls) { url ->
AssistChip(
onClick = { onSelectServer(url) },
label = { Text(url, maxLines = 1, overflow = TextOverflow.Ellipsis) }
@ -689,16 +680,16 @@ private fun SettingsTab(
modifier = settingsCardContentModifier,
verticalArrangement = settingsCardContentSpacing
) {
Text(language("settings.identity"), style = MaterialTheme.typography.titleMedium)
Text(t("settings.identity"), style = MaterialTheme.typography.titleMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onRevealPublicKey, enabled = !state.loadingPublicKey) {
Text(if (state.loadingPublicKey) "..." else language("settings.reveal_key"))
Text(if (state.loadingPublicKey) "..." else t("settings.reveal_key"))
}
OutlinedButton(
onClick = onCopyPublicKey,
enabled = state.myPublicKey.isNotBlank()
) {
Text(language("settings.copy_key"))
Text(t("settings.copy_key"))
}
}
OutlinedTextField(
@ -706,7 +697,7 @@ private fun SettingsTab(
onValueChange = {},
modifier = Modifier.fillMaxWidth(),
readOnly = true,
label = { Text(language("settings.my_key")) },
label = { Text(t("settings.my_key")) },
maxLines = 4
)
}
@ -719,7 +710,7 @@ private fun SettingsTab(
modifier = settingsCardContentModifier,
verticalArrangement = settingsCardContentSpacing
) {
Text(language("settings.language"), style = MaterialTheme.typography.titleMedium)
Text(t("settings.language"), style = MaterialTheme.typography.titleMedium)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(LanguageManager.supportedLanguages) { lang ->
FilterChip(
@ -746,40 +737,7 @@ private fun SettingsTab(
modifier = settingsCardContentModifier,
verticalArrangement = settingsCardContentSpacing
) {
Text(language("settings.notification_sound"), style = MaterialTheme.typography.titleMedium)
val sounds = listOf(
"default" to language("sound.default"),
"ding" to language("sound.ding"),
"nameit5" to language("sound.nameit5"),
"wind_chime" to language("sound.wind_chime")
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(sounds) { (id, label) ->
FilterChip(
selected = state.notificationSound == id,
onClick = { onNotificationSoundChange(id) },
label = { Text(label) },
leadingIcon = {
Icon(
Icons.Rounded.MusicNote,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
)
}
}
}
}
}
item {
Card(modifier = settingsCardModifier) {
Column(
modifier = settingsCardContentModifier,
verticalArrangement = settingsCardContentSpacing
) {
Text(language("settings.theme"), style = MaterialTheme.typography.titleMedium)
Text(t("settings.theme"), style = MaterialTheme.typography.titleMedium)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
Row(
verticalAlignment = Alignment.CenterVertically,
@ -789,18 +747,18 @@ private fun SettingsTab(
checked = state.useDynamicColor,
onCheckedChange = onUseDynamicColorChange
)
Text(language("settings.dynamic_color"))
Text(t("settings.dynamic_color"))
}
}
if (!state.useDynamicColor || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
Text(language("settings.preset_themes"), style = MaterialTheme.typography.labelLarge)
Text(t("settings.preset_themes"), style = MaterialTheme.typography.labelLarge)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(themeOptions) { option ->
val themeName = when (option.id) {
"blue" -> language("theme.blue")
"gray" -> language("theme.gray")
"green" -> language("theme.green")
"red" -> language("theme.red")
"blue" -> t("theme.blue")
"gray" -> t("theme.gray")
"green" -> t("theme.green")
"red" -> t("theme.red")
else -> option.name
}
FilterChip(
@ -828,16 +786,16 @@ private fun SettingsTab(
modifier = settingsCardContentModifier,
verticalArrangement = settingsCardContentSpacing
) {
Text(language("settings.diagnostics"), style = MaterialTheme.typography.titleMedium)
Text("${language("settings.status_hint")}${state.statusHint}")
Text("${language("settings.current_status")}${state.statusText}")
Text("${language("settings.cert_fingerprint")}${state.certFingerprint.ifBlank { "N/A" }}")
Text(t("settings.diagnostics"), style = MaterialTheme.typography.titleMedium)
Text("${t("settings.status_hint")}${state.statusHint}")
Text("${t("settings.current_status")}${state.statusText}")
Text("${t("settings.cert_fingerprint")}${state.certFingerprint.ifBlank { "N/A" }}")
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Switch(checked = state.showSystemMessages, onCheckedChange = onToggleShowSystem)
Text(language("settings.show_system"))
Text(t("settings.show_system"))
}
}
}

@ -5,12 +5,9 @@ import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.media.AudioAttributes
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
@ -51,10 +48,10 @@ import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.encodeToJsonElement
import com.onlinemsg.client.util.LanguageManager
/**
* 单例管理类负责整个聊天会话的生命周期网络连接消息收发状态维护和持久化
* 所有公开方法均通过 ViewModel 代理调用内部使用协程处理异步操作
*/
object ChatSessionManager {
@ -83,29 +80,28 @@ object ChatSessionManager {
private var identity: RsaCryptoManager.Identity? = null
// 连接相关内部状态
private var manualClose = false
private var fallbackTried = false
private var connectedUrl = ""
private var serverPublicKey = ""
private var helloTimeoutJob: Job? = null
private var authTimeoutJob: Job? = null
private var reconnectJob: Job? = null
private var reconnectAttempt: Int = 0
private val systemMessageExpiryJobs: MutableMap<String, Job> = mutableMapOf()
private var manualClose = false // 是否为手动断开
private var fallbackTried = false // 是否已尝试切换 ws/wss
private var connectedUrl = "" // 当前连接的服务器地址
private var serverPublicKey = "" // 服务端公钥(握手后获得)
private var helloTimeoutJob: Job? = null // 握手超时任务
private var authTimeoutJob: Job? = null // 认证超时任务
private var reconnectJob: Job? = null // 自动重连任务
private var reconnectAttempt: Int = 0 // 当前重连尝试次数
private val systemMessageExpiryJobs: MutableMap<String, Job> = mutableMapOf() // 系统消息自动过期任务
private var autoReconnectTriggered = false
@Volatile
private var keepAliveRequested = false
private var keepAliveRequested = false // 是否应保活(前台服务标志)
private var notificationIdSeed = 2000
// WebSocket 事件监听器
private val socketListener = object : OnlineMsgSocketClient.Listener {
override fun onOpen() {
scope.launch {
val lang = _uiState.value.language
_uiState.update {
it.copy(
status = ConnectionStatus.HANDSHAKING,
statusHint = LanguageManager.getString("status_hint.handshaking", lang)
statusHint = "已连接,正在准备聊天..."
)
}
addSystemMessage("连接已建立")
@ -126,8 +122,7 @@ object ChatSessionManager {
override fun onBinaryMessage(payload: ByteArray) {
scope.launch {
if (_uiState.value.status == ConnectionStatus.HANDSHAKING) {
val lang = _uiState.value.language
_uiState.update { it.copy(statusHint = LanguageManager.getString("status_hint.received_binary_handshake", lang)) }
_uiState.update { it.copy(statusHint = "收到二进制握手帧,正在尝试解析...") }
}
val utf8 = runCatching { String(payload, StandardCharsets.UTF_8) }.getOrNull().orEmpty()
@ -157,11 +152,10 @@ object ChatSessionManager {
if (manualClose) return@launch
val message = throwable.message?.takeIf { it.isNotBlank() } ?: "unknown"
addSystemMessage("连接异常:$message")
val lang = _uiState.value.language
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
statusHint = LanguageManager.getString("status_hint.connection_error_retrying", lang)
statusHint = "连接异常,正在重试"
)
}
scheduleReconnect("连接异常")
@ -169,6 +163,10 @@ object ChatSessionManager {
}
}
/**
* 初始化管理器必须在应用启动时调用一次
* @param application Application 实例
*/
@Synchronized
fun initialize(application: Application) {
if (initialized) return
@ -177,6 +175,7 @@ object ChatSessionManager {
preferencesRepository = UserPreferencesRepository(application, json)
cryptoManager = RsaCryptoManager(application)
historyRepository = ChatHistoryRepository(ChatDatabase.getInstance(application).chatMessageDao())
ensureMessageNotificationChannel()
scope.launch {
val pref = preferencesRepository.preferencesFlow.first()
@ -194,14 +193,10 @@ object ChatSessionManager {
themeId = pref.themeId,
useDynamicColor = pref.useDynamicColor,
language = pref.language,
notificationSound = pref.notificationSound,
messages = historyMessages,
statusHint = LanguageManager.getString("status_hint.click_to_connect", pref.language)
messages = historyMessages
)
}
ensureMessageNotificationChannel(pref.notificationSound)
// 如果上次会话启用了自动重连,则自动恢复连接
if (pref.shouldAutoReconnect && !autoReconnectTriggered) {
autoReconnectTriggered = true
ChatForegroundService.start(application)
@ -210,14 +205,10 @@ object ChatSessionManager {
}
}
fun updateNotificationSound(sound: String) {
_uiState.update { it.copy(notificationSound = sound) }
scope.launch {
preferencesRepository.setNotificationSound(sound)
ensureMessageNotificationChannel(sound)
}
}
/**
* 更新主题
* @param themeId 主题名
*/
fun updateTheme(themeId: String) {
_uiState.update { it.copy(themeId = themeId) }
scope.launch {
@ -225,6 +216,10 @@ object ChatSessionManager {
}
}
/**
* 更新语言
* @param language 语言代码
*/
fun updateLanguage(language: String) {
_uiState.update { it.copy(language = language) }
scope.launch {
@ -232,6 +227,10 @@ object ChatSessionManager {
}
}
/**
* 更改使用动态颜色
* @param enabled 主题名
*/
fun updateUseDynamicColor(enabled: Boolean) {
_uiState.update { it.copy(useDynamicColor = enabled) }
scope.launch {
@ -239,6 +238,10 @@ object ChatSessionManager {
}
}
/**
* 更新显示名称并持久化
* @param value 新名称自动截断至 64 字符
*/
fun updateDisplayName(value: String) {
val displayName = value.take(64)
_uiState.update { it.copy(displayName = displayName) }
@ -247,18 +250,34 @@ object ChatSessionManager {
}
}
/**
* 更新当前输入的服务器地址不持久化
* @param value 新地址
*/
fun updateServerUrl(value: String) {
_uiState.update { it.copy(serverUrl = value) }
}
/**
* 更新私聊目标公钥
* @param value 公钥字符串
*/
fun updateTargetKey(value: String) {
_uiState.update { it.copy(targetKey = value) }
}
/**
* 更新消息草稿
* @param value 草稿内容
*/
fun updateDraft(value: String) {
_uiState.update { it.copy(draft = value) }
}
/**
* 切换广播/私聊模式并持久化
* @param enabled true 为私聊模式
*/
fun toggleDirectMode(enabled: Boolean) {
_uiState.update { it.copy(directMode = enabled) }
scope.launch {
@ -266,6 +285,10 @@ object ChatSessionManager {
}
}
/**
* 切换是否显示系统消息并持久化
* @param show true 显示
*/
fun toggleShowSystemMessages(show: Boolean) {
_uiState.update { it.copy(showSystemMessages = show) }
scope.launch {
@ -273,6 +296,9 @@ object ChatSessionManager {
}
}
/**
* 清空所有消息并取消系统消息的过期任务
*/
fun clearMessages() {
cancelSystemMessageExpiryJobs()
_uiState.update { it.copy(messages = emptyList()) }
@ -283,6 +309,9 @@ object ChatSessionManager {
}
}
/**
* 保存当前服务器地址到历史列表并持久化
*/
fun saveCurrentServerUrl() {
val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl)
if (normalized.isBlank()) {
@ -293,12 +322,11 @@ object ChatSessionManager {
}
val nextUrls = ServerUrlFormatter.append(_uiState.value.serverUrls, normalized)
val lang = _uiState.value.language
_uiState.update {
it.copy(
serverUrl = normalized,
serverUrls = nextUrls,
statusHint = LanguageManager.getString("status_hint.server_saved", lang)
statusHint = "服务器地址已保存"
)
}
@ -308,6 +336,10 @@ object ChatSessionManager {
}
}
/**
* 从历史列表中移除当前服务器地址
* 如果列表清空则恢复默认地址
*/
fun removeCurrentServerUrl() {
val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl)
if (normalized.isBlank()) return
@ -319,16 +351,11 @@ object ChatSessionManager {
filtered
}
val lang = _uiState.value.language
_uiState.update {
it.copy(
serverUrls = nextUrls,
serverUrl = nextUrls.first(),
statusHint = if (filtered.isEmpty()) {
LanguageManager.getString("status_hint.server_removed_default", lang)
} else {
LanguageManager.getString("status_hint.server_removed", lang)
}
statusHint = if (filtered.isEmpty()) "已恢复默认服务器地址" else "已移除当前服务器地址"
)
}
@ -338,6 +365,9 @@ object ChatSessionManager {
}
}
/**
* 加载或生成本地身份密钥对并将公钥显示到 UI
*/
fun revealMyPublicKey() {
scope.launch {
_uiState.update { it.copy(loadingPublicKey = true) }
@ -357,10 +387,17 @@ object ChatSessionManager {
}
}
/**
* 主动连接服务器由用户点击连接触发
*/
fun connect() {
connectInternal(isAutoRestore = false)
}
/**
* 内部连接逻辑区分自动恢复和手动连接
* @param isAutoRestore 是否为应用启动时的自动恢复连接
*/
private fun connectInternal(isAutoRestore: Boolean) {
if (!initialized) return
val state = _uiState.value
@ -368,11 +405,10 @@ object ChatSessionManager {
val normalized = ServerUrlFormatter.normalize(state.serverUrl)
if (normalized.isBlank()) {
val lang = _uiState.value.language
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
statusHint = LanguageManager.getString("status_hint.invalid_server_url", lang)
statusHint = "请填写有效服务器地址"
)
}
return
@ -388,11 +424,10 @@ object ChatSessionManager {
cancelHelloTimeout()
cancelAuthTimeout()
val lang = _uiState.value.language
_uiState.update {
it.copy(
status = ConnectionStatus.CONNECTING,
statusHint = LanguageManager.getString("status_hint.connecting", lang),
statusHint = "正在连接服务器...",
serverUrl = normalized,
certFingerprint = ""
)
@ -411,17 +446,20 @@ object ChatSessionManager {
}
}
/**
* 主动断开连接
* @param stopService 是否同时停止前台服务默认 true
*/
fun disconnect(stopService: Boolean = true) {
manualClose = true
cancelReconnect()
cancelHelloTimeout()
cancelAuthTimeout()
socketClient.close(1000, "manual_close")
val lang = _uiState.value.language
_uiState.update {
it.copy(
status = ConnectionStatus.IDLE,
statusHint = LanguageManager.getString("status_hint.disconnected", lang)
statusHint = "连接已关闭"
)
}
autoReconnectTriggered = false
@ -435,6 +473,10 @@ object ChatSessionManager {
addSystemMessage("已断开连接")
}
/**
* 发送消息广播或私聊
* 执行签名加密并发送
*/
fun sendMessage() {
val current = _uiState.value
if (!current.canSend) return
@ -445,8 +487,7 @@ object ChatSessionManager {
val key = if (_uiState.value.directMode) _uiState.value.targetKey.trim() else ""
if (_uiState.value.directMode && key.isBlank()) {
val lang = _uiState.value.language
_uiState.update { it.copy(statusHint = LanguageManager.getString("status_hint.target_key_required", lang)) }
_uiState.update { it.copy(statusHint = "请先填写目标公钥,再发送私聊消息") }
return@launch
}
@ -493,12 +534,19 @@ object ChatSessionManager {
}
}
/**
* 消息复制成功后的回调显示已复制提示
*/
fun onMessageCopied() {
scope.launch {
_events.emit(UiEvent.ShowSnackbar("已复制"))
}
}
/**
* 确保本地身份已加载 or 创建
* @return 本地身份对象
*/
private suspend fun ensureIdentity(): RsaCryptoManager.Identity {
return identityMutex.withLock {
identity ?: withContext(Dispatchers.Default) {
@ -509,10 +557,13 @@ object ChatSessionManager {
}
}
/**
* 处理收到的原始文本消息可能是握手包 or 加密消息
* @param rawText 原始文本
*/
private suspend fun handleIncomingMessage(rawText: String) {
if (_uiState.value.status == ConnectionStatus.HANDSHAKING) {
val lang = _uiState.value.language
_uiState.update { it.copy(statusHint = LanguageManager.getString("status_hint.hello_received", lang)) }
_uiState.update { it.copy(statusHint = "已收到握手数据,正在解析...") }
}
val normalizedText = extractJsonCandidate(rawText)
@ -520,6 +571,7 @@ object ChatSessionManager {
json.decodeFromString<JsonElement>(normalizedText) as? JsonObject
}.getOrNull()
// 尝试直接解析为 HelloDataDto某些服务器可能直接发送不带外层
val directHello = rootObject?.let { obj ->
val hasPublicKey = obj["publicKey"] != null
val hasChallenge = obj["authChallenge"] != null
@ -535,6 +587,7 @@ object ChatSessionManager {
return
}
// 尝试解析为带外层的 EnvelopeDto
val plain = runCatching { json.decodeFromString<EnvelopeDto>(normalizedText) }.getOrNull()
if (plain?.type == "publickey") {
cancelHelloTimeout()
@ -542,11 +595,10 @@ object ChatSessionManager {
runCatching { json.decodeFromJsonElement<HelloDataDto>(it) }.getOrNull()
}
if (hello == null || hello.publicKey.isBlank() || hello.authChallenge.isBlank()) {
val lang = _uiState.value.language
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
statusHint = LanguageManager.getString("status_hint.handshake_failed_incomplete", lang)
statusHint = "握手失败:服务端响应不完整"
)
}
return
@ -555,17 +607,20 @@ object ChatSessionManager {
return
}
// 握手阶段收到非预期消息则报错
if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain != null) {
val lang = _uiState.value.language
_uiState.update { it.copy(statusHint = LanguageManager.getString("status_hint.handshake_failed_unexpected", lang)) }
_uiState.update { it.copy(statusHint = "握手失败:收到非预期消息") }
addSystemMessage("握手阶段收到非预期消息类型:${plain.type}")
} else if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain == null) {
val lang = _uiState.value.language
val preview = rawText.replace("\n", " ").replace("\r", " ").take(80)
_uiState.update { it.copy(statusHint = LanguageManager.getString("status_hint.handshake_failed_parse", lang)) }
val preview = rawText
.replace("\n", " ")
.replace("\r", " ")
.take(80)
_uiState.update { it.copy(statusHint = "握手失败:首包解析失败") }
addSystemMessage("握手包解析失败:$preview")
}
// 尝试解密(若已握手完成,收到的应是加密消息)
val id = ensureIdentity()
val decrypted = runCatching {
withContext(Dispatchers.Default) {
@ -583,14 +638,17 @@ object ChatSessionManager {
handleSecureMessage(secure)
}
/**
* 处理服务端发来的握手 Hello 数据
* @param hello 服务端公钥和挑战
*/
private suspend fun handleServerHello(hello: HelloDataDto) {
cancelHelloTimeout()
serverPublicKey = hello.publicKey
val lang = _uiState.value.language
_uiState.update {
it.copy(
status = ConnectionStatus.AUTHENTICATING,
statusHint = LanguageManager.getString("status_hint.authenticating", lang),
statusHint = "正在完成身份验证...",
certFingerprint = hello.certFingerprintSha256.orEmpty()
)
}
@ -599,11 +657,10 @@ object ChatSessionManager {
authTimeoutJob = scope.launch {
delay(AUTH_TIMEOUT_MS)
if (_uiState.value.status == ConnectionStatus.AUTHENTICATING) {
val lang = _uiState.value.language
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
statusHint = LanguageManager.getString("status_hint.auth_timeout", lang)
statusHint = "连接超时,请重试"
)
}
addSystemMessage("认证超时,请检查网络后重试")
@ -617,11 +674,10 @@ object ChatSessionManager {
addSystemMessage("已发送认证请求")
}.onFailure { error ->
cancelAuthTimeout()
val lang = _uiState.value.language
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
statusHint = LanguageManager.getString("status_hint.auth_failed", lang)
statusHint = "认证失败"
)
}
addSystemMessage("认证发送失败:${error.message ?: "unknown"}")
@ -629,6 +685,10 @@ object ChatSessionManager {
}
}
/**
* 发送认证消息包含签名后的身份信息
* @param challenge 服务端提供的挑战值
*/
private suspend fun sendAuth(challenge: String) {
val id = ensureIdentity()
val displayName = _uiState.value.displayName.trim().ifBlank { createGuestName() }
@ -672,17 +732,20 @@ object ChatSessionManager {
check(socketClient.send(cipher)) { "连接不可用" }
}
/**
* 处理安全通道建立后的业务消息广播私聊认证结果等
* @param message 解密后的 EnvelopeDto
*/
private fun handleSecureMessage(message: EnvelopeDto) {
when (message.type) {
"auth_ok" -> {
cancelAuthTimeout()
cancelReconnect()
reconnectAttempt = 0
val lang = _uiState.value.language
_uiState.update {
it.copy(
status = ConnectionStatus.READY,
statusHint = LanguageManager.getString("status_hint.ready", lang)
statusHint = "已连接,可以开始聊天"
)
}
addSystemMessage("连接准备完成")
@ -712,25 +775,39 @@ object ChatSessionManager {
}
}
/**
* 处理 WebSocket 连接关闭事件
* @param code 关闭状态码
* @param reason 关闭原因
*/
private fun handleSocketClosed(code: Int, reason: String) {
cancelHelloTimeout()
cancelAuthTimeout()
if (manualClose || reason == "reconnect" || reconnectJob?.isActive == true) return
if (manualClose) {
return
}
if (reason == "reconnect") {
return
}
if (reconnectJob?.isActive == true) {
return
}
val currentStatus = _uiState.value.status
val allowFallback = !fallbackTried && currentStatus != ConnectionStatus.READY
// 尝试切换 ws/wss 协议重试(仅限非就绪状态)
if (allowFallback) {
val fallbackUrl = ServerUrlFormatter.toggleWsProtocol(connectedUrl)
if (fallbackUrl.isNotBlank()) {
fallbackTried = true
connectedUrl = fallbackUrl
val lang = _uiState.value.language
_uiState.update {
it.copy(
status = ConnectionStatus.CONNECTING,
statusHint = LanguageManager.getString("status_hint.reconnecting", lang),
statusHint = "正在自动重试连接...",
serverUrl = fallbackUrl
)
}
@ -740,17 +817,20 @@ object ChatSessionManager {
}
}
val lang = _uiState.value.language
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
statusHint = LanguageManager.getString("status_hint.connection_interrupted_retrying", lang)
statusHint = "连接已中断,正在重试"
)
}
addSystemMessage("连接关闭 ($code)${reason.ifBlank { "连接中断" }}")
scheduleReconnect("连接已中断")
}
/**
* 添加一条系统消息自动按 TTL 过期
* @param content 消息内容
*/
private fun addSystemMessage(content: String) {
val message = UiMessage(
role = MessageRole.SYSTEM,
@ -763,6 +843,13 @@ object ChatSessionManager {
scheduleSystemMessageExpiry(message.id)
}
/**
* 添加一条接收到的用户消息
* @param sender 发送者名称
* @param subtitle 附加说明如私聊来源
* @param content 消息内容
* @param channel 消息通道广播/私聊
*/
private fun addIncomingMessage(
sender: String,
subtitle: String,
@ -784,6 +871,12 @@ object ChatSessionManager {
)
}
/**
* 添加一条发出的消息
* @param content 消息内容
* @param subtitle 附加说明如私聊目标
* @param channel 消息通道
*/
private fun addOutgoingMessage(
content: String,
subtitle: String,
@ -800,11 +893,15 @@ object ChatSessionManager {
)
}
/**
* 将消息追加到列表尾部并清理超出数量限制的消息
* @param message 要追加的消息
*/
private fun appendMessage(message: UiMessage) {
_uiState.update { current ->
val next = (current.messages + message).takeLast(MAX_MESSAGES)
val aliveIds = next.asSequence().map { it.id }.toSet()
val removedIds = systemMessageExpiryJobs.keys.filterNot { id -> id in aliveIds }
val removedIds = systemMessageExpiryJobs.keys.filterNot { it in aliveIds }
removedIds.forEach { id ->
systemMessageExpiryJobs.remove(id)?.cancel()
}
@ -818,28 +915,30 @@ object ChatSessionManager {
}
}
/**
* 取消认证超时任务
*/
private fun cancelAuthTimeout() {
authTimeoutJob?.cancel()
authTimeoutJob = null
}
/**
* 安排自动重连指数退避
* @param reason 触发重连的原因
*/
private fun scheduleReconnect(reason: String) {
if (manualClose || reconnectJob?.isActive == true) return
if (manualClose) return
if (reconnectJob?.isActive == true) return
reconnectAttempt += 1
val exponential = 1 shl minOf(reconnectAttempt - 1, 5)
val delaySeconds = minOf(MAX_RECONNECT_DELAY_SECONDS, exponential)
addSystemMessage("$reason${delaySeconds}s 后自动重连(第 $reconnectAttempt 次)")
val lang = _uiState.value.language
val hint = String.format(
LanguageManager.getString("status_hint.reconnect_countdown", lang),
delaySeconds,
reconnectAttempt
)
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
statusHint = hint
statusHint = "${delaySeconds}s 后自动重连(第 $reconnectAttempt 次)"
)
}
@ -851,11 +950,10 @@ object ChatSessionManager {
ServerUrlFormatter.normalize(_uiState.value.serverUrl)
}
if (target.isBlank()) {
val lang = _uiState.value.language
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
statusHint = LanguageManager.getString("status_hint.reconnect_failed_invalid_url", lang)
statusHint = "重连失败:服务器地址无效"
)
}
return@launch
@ -876,11 +974,18 @@ object ChatSessionManager {
}
}
/**
* 取消自动重连任务
*/
private fun cancelReconnect() {
reconnectJob?.cancel()
reconnectJob = null
}
/**
* 为系统消息安排过期自动删除
* @param messageId 消息唯一 ID
*/
private fun scheduleSystemMessageExpiry(messageId: String) {
systemMessageExpiryJobs.remove(messageId)?.cancel()
systemMessageExpiryJobs[messageId] = scope.launch {
@ -893,22 +998,27 @@ object ChatSessionManager {
}
}
/**
* 取消所有系统消息的过期任务
*/
private fun cancelSystemMessageExpiryJobs() {
systemMessageExpiryJobs.values.forEach { it.cancel() }
systemMessageExpiryJobs.clear()
}
/**
* 启动握手超时计时器
*/
private fun startHelloTimeout() {
cancelHelloTimeout()
helloTimeoutJob = scope.launch {
delay(HELLO_TIMEOUT_MS)
if (_uiState.value.status == ConnectionStatus.HANDSHAKING) {
val currentUrl = connectedUrl.ifBlank { "unknown" }
val lang = _uiState.value.language
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
statusHint = LanguageManager.getString("status_hint.hello_timeout", lang)
statusHint = "握手超时,请检查地址路径与反向代理"
)
}
addSystemMessage("握手超时:未收到服务端 publickey 首包(当前地址:$currentUrl")
@ -917,29 +1027,56 @@ object ChatSessionManager {
}
}
/**
* 取消握手超时任务
*/
private fun cancelHelloTimeout() {
helloTimeoutJob?.cancel()
helloTimeoutJob = null
}
/**
* 缩写显示公钥取前后各8字符
* @param key 完整公钥
* @return 缩写字符串
*/
private fun summarizeKey(key: String): String {
if (key.length <= 16) return key
return "${key.take(8)}...${key.takeLast(8)}"
}
/**
* 生成访客名称 guest-123456
* @return 随机名称
*/
private fun createGuestName(): String {
val rand = (100000..999999).random()
return "guest-$rand"
}
/**
* 从可能包含前缀的原始文本中提取 JSON 对象部分
* @param rawText 原始文本
* @return 最外层的 JSON 字符串
*/
private fun extractJsonCandidate(rawText: String): String {
val trimmed = rawText.trim()
if (trimmed.startsWith("{") && trimmed.endsWith("}")) return trimmed
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
return trimmed
}
val start = rawText.indexOf('{')
val end = rawText.lastIndexOf('}')
return if (start in 0 until end) rawText.substring(start, end + 1) else rawText
return if (start in 0 until end) {
rawText.substring(start, end + 1)
} else {
rawText
}
}
/**
* 关闭所有资源用于应用退出时
*/
fun shutdownAll() {
cancelSystemMessageExpiryJobs()
cancelReconnect()
@ -948,6 +1085,9 @@ object ChatSessionManager {
socketClient.shutdown()
}
/**
* 前台服务停止时的回调
*/
fun onForegroundServiceStopped() {
keepAliveRequested = false
if (_uiState.value.status != ConnectionStatus.IDLE) {
@ -959,57 +1099,52 @@ object ChatSessionManager {
}
}
/**
* 判断前台服务是否应该运行
* @return true 表示应保持服务运行
*/
fun shouldForegroundServiceRun(): Boolean = keepAliveRequested
private fun ensureMessageNotificationChannel(soundCode: String = "default") {
/**
* 创建消息通知渠道Android O+
*/
private fun ensureMessageNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channelId = "${MESSAGE_CHANNEL_ID}_$soundCode"
if (manager.getNotificationChannel(channelId) != null) return
val soundUri = getSoundUri(soundCode)
val channel = NotificationChannel(
channelId,
MESSAGE_CHANNEL_ID,
"OnlineMsg 消息提醒",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "收到服务器新消息时提醒"
if (soundUri != null) {
setSound(soundUri, AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build())
}
}
manager.createNotificationChannel(channel)
}
private fun getSoundUri(code: String): Uri? {
val resId = when(code) {
"ding" -> R.raw.load
"nameit5" -> R.raw.nameit5
"wind_chime" -> R.raw.notification_sound_effects
"default" -> R.raw.default_sound
else -> return null
}
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + app.packageName + "/" + resId)
}
/**
* 显示新消息到达的通知
* @param title 通知标题
* @param body 通知正文
*/
private fun showIncomingNotification(title: String, body: String) {
if (!initialized) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(app, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) return
) {
return
}
val launchIntent = Intent(app, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(app, 0, launchIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val soundCode = _uiState.value.notificationSound
val channelId = "${MESSAGE_CHANNEL_ID}_$soundCode"
val pendingIntent = PendingIntent.getActivity(
app,
0,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(app, channelId)
val notification = NotificationCompat.Builder(app, MESSAGE_CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_chat)
.setContentTitle(title.ifBlank { "OnlineMsg" })
.setContentText(body.take(120))
@ -1022,12 +1157,17 @@ object ChatSessionManager {
NotificationManagerCompat.from(app).notify(nextMessageNotificationId(), notification)
}
/**
* 生成下一个通知 ID线程安全递增
* @return 新的通知 ID
*/
@Synchronized
private fun nextMessageNotificationId(): Int {
notificationIdSeed += 1
return notificationIdSeed
}
// 常量定义
private const val HELLO_TIMEOUT_MS = 12_000L
private const val AUTH_TIMEOUT_MS = 20_000L
private const val MAX_MESSAGES = 500

@ -1,9 +1,6 @@
package com.onlinemsg.client.ui
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import java.util.UUID
import com.onlinemsg.client.util.LanguageManager
/**
* 连接状态枚举
@ -70,10 +67,7 @@ data class UiMessage(
* @property myPublicKey 本地公钥
* @property sending 是否正在发送消息用于禁用按钮
* @property loadingPublicKey 是否正在加载公钥
* @property themeId 当前选中的主题 ID
* @property useDynamicColor 是否使用 Android 12+ 动态颜色
* @property language 当前选择的语言代码
* @property notificationSound 当前选择的通知音效代号
* @property language 当前选择的语言代码 ( "zh", "en", "ja")
*/
data class ChatUiState(
val status: ConnectionStatus = ConnectionStatus.IDLE,
@ -92,8 +86,7 @@ data class ChatUiState(
val loadingPublicKey: Boolean = false,
val themeId: String = "blue",
val useDynamicColor: Boolean = true,
val language: String = "zh",
val notificationSound: String = "default"
val language: String = "zh"
) {
/**
* 是否允许连接
@ -121,12 +114,12 @@ data class ChatUiState(
*/
val statusText: String
get() = when (status) {
ConnectionStatus.IDLE -> LanguageManager.getString("status.idle", language)
ConnectionStatus.IDLE -> "未连接"
ConnectionStatus.CONNECTING,
ConnectionStatus.HANDSHAKING,
ConnectionStatus.AUTHENTICATING -> LanguageManager.getString("status.connecting", language)
ConnectionStatus.READY -> LanguageManager.getString("status.ready", language)
ConnectionStatus.ERROR -> LanguageManager.getString("status.error", language)
ConnectionStatus.AUTHENTICATING -> "连接中"
ConnectionStatus.READY -> "已连接"
ConnectionStatus.ERROR -> "异常断开"
}
/**

@ -35,5 +35,4 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
fun updateTheme(themeId: String) = ChatSessionManager.updateTheme(themeId)
fun updateUseDynamicColor(enabled: Boolean) = ChatSessionManager.updateUseDynamicColor(enabled)
fun updateLanguage(language: String) = ChatSessionManager.updateLanguage(language)
fun updateNotificationSound(sound: String) = ChatSessionManager.updateNotificationSound(sound)
}

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

@ -2,7 +2,7 @@ package com.onlinemsg.client.util
/**
* 语言管理类统一存储应用内的多语言词条
* 类似于 Minecraft language 文件映射
* 类似于 Minecraft 语言文件映射
*/
object LanguageManager {
@ -12,7 +12,6 @@ object LanguageManager {
"tab.settings" to "设置",
"settings.personal" to "个人设置",
"settings.display_name" to "显示名称",
"settings.chat_data" to "聊天数据",
"settings.server" to "服务器",
"settings.server_url" to "服务器地址",
"settings.save_server" to "保存地址",
@ -30,13 +29,10 @@ object LanguageManager {
"settings.current_status" to "当前状态",
"settings.cert_fingerprint" to "证书指纹",
"settings.show_system" to "显示系统消息",
"settings.connect" to "连接",
"settings.disconnect" to "断开",
"settings.clear_msg" to "清空消息",
"settings.dynamic_color" to "使用动态颜色",
"settings.notification_sound" to "通知音效",
"sound.default" to "默认",
"sound.ding" to "",
"sound.nameit5" to "音效5",
"sound.wind_chime" to "风铃",
"chat.broadcast" to "广播",
"chat.private" to "私聊",
"chat.target_key" to "目标公钥",
@ -50,43 +46,13 @@ object LanguageManager {
"theme.gray" to "商务灰",
"theme.green" to "翠绿",
"theme.red" to "绯红",
"theme.warm" to "温暖",
"status.idle" to "连接断开",
"status.connecting" to "连接中",
"status.ready" to "已连接",
"status.error" to "异常断开",
"status_hint.ready" to "已连接,可以开始聊天",
"status_hint.click_to_connect" to "点击连接开始聊天",
"status_hint.handshaking" to "已连接,正在准备聊天...",
"status_hint.received_binary_handshake" to "收到二进制握手帧,正在尝试解析...",
"status_hint.connection_error_retrying" to "连接异常,正在重试",
"status_hint.invalid_server_url" to "请填写有效服务器地址",
"status_hint.connecting" to "正在连接服务器...",
"status_hint.disconnected" to "连接已关闭",
"status_hint.server_saved" to "服务器地址已保存",
"status_hint.server_removed_default" to "已恢复默认服务器地址",
"status_hint.server_removed" to "已移除当前服务器地址",
"status_hint.target_key_required" to "请先填写目标公钥,再发送私聊消息",
"status_hint.hello_received" to "已收到握手数据,正在解析...",
"status_hint.handshake_failed_incomplete" to "握手失败:服务端响应不完整",
"status_hint.handshake_failed_unexpected" to "握手失败:收到非预期消息",
"status_hint.handshake_failed_parse" to "握手失败:首包解析失败",
"status_hint.authenticating" to "正在完成身份验证...",
"status_hint.auth_timeout" to "连接超时,请重试",
"status_hint.auth_failed" to "认证失败",
"status_hint.reconnecting" to "正在自动重试连接...",
"status_hint.connection_interrupted_retrying" to "连接已中断,正在重试",
"status_hint.reconnect_countdown" to "%d秒后自动重连第 %d 次)",
"status_hint.reconnect_failed_invalid_url" to "重连失败:服务器地址无效",
"status_hint.hello_timeout" to "握手超时,请检查地址路径与反向代理"
"theme.warm" to "温暖"
),
"en" to mapOf(
"tab.chat" to "Chat",
"tab.settings" to "Settings",
"settings.personal" to "Personal",
"settings.display_name" to "Display Name",
"settings.chat_data" to "Chat Data",
"settings.server" to "Server",
"settings.server_url" to "Server Address",
"settings.save_server" to "Save",
@ -104,13 +70,10 @@ object LanguageManager {
"settings.current_status" to "Status",
"settings.cert_fingerprint" to "Fingerprint",
"settings.show_system" to "Show System Messages",
"settings.connect" to "Link",
"settings.disconnect" to "Dislink",
"settings.clear_msg" to "ClearMsg",
"settings.dynamic_color" to "Use dynamic color",
"settings.notification_sound" to "Notification Sound",
"sound.default" to "Default",
"sound.ding" to "Ding",
"sound.nameit5" to "Sound 5",
"sound.wind_chime" to "Wind Chime",
"chat.broadcast" to "Broadcast",
"chat.private" to "Private",
"chat.target_key" to "Target Public Key",
@ -124,43 +87,13 @@ object LanguageManager {
"theme.gray" to "Business Gray",
"theme.green" to "Green",
"theme.red" to "Red",
"theme.warm" to "Warm",
"status.idle" to "Idle",
"status.connecting" to "Connecting",
"status.ready" to "Connected",
"status.error" to "Error",
"status_hint.ready" to "Ready, you can start chatting",
"status_hint.click_to_connect" to "Click connect to start chatting",
"status_hint.handshaking" to "Connected, preparing chat...",
"status_hint.received_binary_handshake" to "Received binary handshake frame, parsing...",
"status_hint.connection_error_retrying" to "Connection error, retrying",
"status_hint.invalid_server_url" to "Please enter a valid server address",
"status_hint.connecting" to "Connecting to server...",
"status_hint.disconnected" to "Connection closed",
"status_hint.server_saved" to "Server address saved",
"status_hint.server_removed_default" to "Restored default server address",
"status_hint.server_removed" to "Removed current server address",
"status_hint.target_key_required" to "Please enter target public key first",
"status_hint.hello_received" to "Handshake data received, parsing...",
"status_hint.handshake_failed_incomplete" to "Handshake failed: incomplete server response",
"status_hint.handshake_failed_unexpected" to "Handshake failed: unexpected message",
"status_hint.handshake_failed_parse" to "Handshake failed: first packet parse error",
"status_hint.authenticating" to "Authenticating...",
"status_hint.auth_timeout" to "Connection timeout, please retry",
"status_hint.auth_failed" to "Authentication failed",
"status_hint.reconnecting" to "Reconnecting...",
"status_hint.connection_interrupted_retrying" to "Connection interrupted, retrying",
"status_hint.reconnect_countdown" to "Reconnecting in %d seconds (attempt %d)",
"status_hint.reconnect_failed_invalid_url" to "Reconnect failed: invalid server address",
"status_hint.hello_timeout" to "Handshake timeout, check server path and reverse proxy"
"theme.warm" to "Warm"
),
"ja" to mapOf(
"tab.chat" to "チャット",
"tab.settings" to "設定",
"settings.personal" to "個人設定",
"settings.display_name" to "表示名",
"settings.chat_data" to "チャットデータ",
"settings.server" to "サーバー",
"settings.server_url" to "アドレス",
"settings.save_server" to "保存",
@ -178,13 +111,10 @@ object LanguageManager {
"settings.current_status" to "ステータス",
"settings.cert_fingerprint" to "証明書指紋",
"settings.show_system" to "システムメッセージを表示",
"settings.connect" to "接続",
"settings.disconnect" to "切断",
"settings.clear_msg" to "履歴を消去",
"settings.dynamic_color" to "動的カラーを使用",
"settings.notification_sound" to "通知音",
"sound.default" to "デフォルト",
"sound.ding" to "ディン",
"sound.nameit5" to "音効5",
"sound.wind_chime" to "風鈴",
"chat.broadcast" to "全体",
"chat.private" to "個人",
"chat.target_key" to "相手の公開鍵",
@ -198,43 +128,13 @@ object LanguageManager {
"theme.gray" to "ビジネスグレー",
"theme.green" to "グリーン",
"theme.red" to "レッド",
"theme.warm" to "ウォーム",
"status.idle" to "未接続",
"status.connecting" to "接続中",
"status.ready" to "接続済み",
"status.error" to "エラー",
"status_hint.ready" to "接続済み、チャットを開始できます",
"status_hint.click_to_connect" to "接続してチャットを開始",
"status_hint.handshaking" to "接続しました、準備中...",
"status_hint.received_binary_handshake" to "バイナリハンドシェイクを受信、解析中...",
"status_hint.connection_error_retrying" to "接続エラー、再試行中",
"status_hint.invalid_server_url" to "有効なサーバーアドレスを入力してください",
"status_hint.connecting" to "サーバーに接続中...",
"status_hint.disconnected" to "接続が切断されました",
"status_hint.server_saved" to "サーバーアドレスを保存しました",
"status_hint.server_removed_default" to "デフォルトサーバーに戻しました",
"status_hint.server_removed" to "現在のサーバーを削除しました",
"status_hint.target_key_required" to "相手の公開鍵を入力してください",
"status_hint.hello_received" to "ハンドシェイクデータを受信、解析中...",
"status_hint.handshake_failed_incomplete" to "ハンドシェイク失敗:サーバー応答が不完全",
"status_hint.handshake_failed_unexpected" to "ハンドシェイク失敗:予期しないメッセージ",
"status_hint.handshake_failed_parse" to "ハンドシェイク失敗:最初のパケット解析エラー",
"status_hint.authenticating" to "認証中...",
"status_hint.auth_timeout" to "接続タイムアウト、再試行してください",
"status_hint.auth_failed" to "認証に失敗しました",
"status_hint.reconnecting" to "自動再接続中...",
"status_hint.connection_interrupted_retrying" to "接続が切断されました、再試行中",
"status_hint.reconnect_countdown" to "%d秒後に再接続%d回目",
"status_hint.reconnect_failed_invalid_url" to "再接続失敗:サーバーアドレスが無効",
"status_hint.hello_timeout" to "ハンドシェイクタイムアウト、サーバーパスを確認してください"
"theme.warm" to "ウォーム"
),
"ko" to mapOf(
"tab.chat" to "채팅",
"tab.settings" to "설정",
"settings.personal" to "개인 설정",
"settings.display_name" to "표시 이름",
"settings.chat_data" to "채팅 데이터",
"settings.server" to "서버",
"settings.server_url" to "서버 주소",
"settings.save_server" to "주소 저장",
@ -252,13 +152,10 @@ object LanguageManager {
"settings.current_status" to "현재 상태",
"settings.cert_fingerprint" to "인증서 지문",
"settings.show_system" to "시스템 메시지 표시",
"settings.connect" to "연결",
"settings.disconnect" to "연결 끊기",
"settings.clear_msg" to "정보 삭제",
"settings.dynamic_color" to "동적 색상 사용",
"settings.notification_sound" to "알림 소리",
"sound.default" to "기본값",
"sound.ding" to "",
"sound.nameit5" to "효과음 5",
"sound.wind_chime" to "풍경",
"chat.broadcast" to "브로드캐스트",
"chat.private" to "비공개 채팅",
"chat.target_key" to "대상 공개키",
@ -272,111 +169,7 @@ object LanguageManager {
"theme.gray" to "비즈니스 그레이",
"theme.green" to "초록",
"theme.red" to "빨강",
"theme.warm" to "따뜻함",
"status.idle" to "연결 안 됨",
"status.connecting" to "연결 중",
"status.ready" to "연결됨",
"status.error" to "오류",
"status_hint.ready" to "연결됨, 채팅을 시작할 수 있습니다",
"status_hint.click_to_connect" to "연결하여 채팅 시작",
"status_hint.handshaking" to "연결됨, 채팅 준비 중...",
"status_hint.received_binary_handshake" to "바이너리 핸드셰이크 수신, 분석 중...",
"status_hint.connection_error_retrying" to "연결 오류, 재시도 중",
"status_hint.invalid_server_url" to "유효한 서버 주소를 입력하세요",
"status_hint.connecting" to "서버에 연결 중...",
"status_hint.disconnected" to "연결이 종료됨",
"status_hint.server_saved" to "서버 주소가 저장됨",
"status_hint.server_removed_default" to "기본 서버 주소로 복원됨",
"status_hint.server_removed" to "현재 서버 주소가 제거됨",
"status_hint.target_key_required" to "대상 공개키를 먼저 입력하세요",
"status_hint.hello_received" to "핸드셰이크 데이터 수신, 분석 중...",
"status_hint.handshake_failed_incomplete" to "핸드셰이크 실패: 서버 응답 불완전",
"status_hint.handshake_failed_unexpected" to "핸드셰이크 실패: 예상치 못한 메시지",
"status_hint.handshake_failed_parse" to "핸드셰이크 실패: 첫 패킷 구문 분석 오류",
"status_hint.authenticating" to "인증 중...",
"status_hint.auth_timeout" to "연결 시간 초과, 다시 시도하세요",
"status_hint.auth_failed" to "인증 실패",
"status_hint.reconnecting" to "자동 재연결 중...",
"status_hint.connection_interrupted_retrying" to "연결이 끊어짐, 재시도 중",
"status_hint.reconnect_countdown" to "%d초 후 자동 재연결 (시도 %d회)",
"status_hint.reconnect_failed_invalid_url" to "재연결 실패: 서버 주소가 유효하지 않음",
"status_hint.hello_timeout" to "핸드셰이크 시간 초과, 서버 경로와 리버스 프록시를 확인하세요"
),
// 新增繁体中文
"zh-Hant" to mapOf(
"tab.chat" to "聊天",
"tab.settings" to "設定",
"settings.personal" to "個人設定",
"settings.display_name" to "顯示名稱",
"settings.chat_data" to "聊天資料",
"settings.server" to "伺服器",
"settings.server_url" to "伺服器位址",
"settings.save_server" to "儲存位址",
"settings.remove_current" to "刪除目前",
"settings.saved_servers" to "已儲存位址",
"settings.identity" to "身份與安全",
"settings.reveal_key" to "檢視/產生公鑰",
"settings.copy_key" to "複製公鑰",
"settings.my_key" to "我的公鑰",
"settings.theme" to "主題",
"settings.preset_themes" to "預設主題",
"settings.language" to "語言",
"settings.diagnostics" to "診斷",
"settings.status_hint" to "連線提示",
"settings.current_status" to "目前狀態",
"settings.cert_fingerprint" to "憑證指紋",
"settings.show_system" to "顯示系統訊息",
"settings.clear_msg" to "清空訊息",
"settings.dynamic_color" to "使用動態顏色",
"settings.notification_sound" to "通知音效",
"sound.default" to "預設",
"sound.ding" to "",
"sound.nameit5" to "音效5",
"sound.wind_chime" to "風鈴",
"chat.broadcast" to "廣播",
"chat.private" to "私訊",
"chat.target_key" to "目標公鑰",
"chat.input_placeholder" to "輸入訊息",
"chat.send" to "傳送",
"chat.sending" to "傳送中",
"chat.empty_hint" to "連線後即可聊天。預設為廣播,切換到私訊後可填寫目標公鑰。",
"common.copied" to "已複製",
"common.unknown" to "未知",
"theme.blue" to "蔚藍",
"theme.gray" to "商務灰",
"theme.green" to "翠綠",
"theme.red" to "緋紅",
"theme.warm" to "溫暖",
"status.idle" to "連線中斷",
"status.connecting" to "連線中",
"status.ready" to "已連線",
"status.error" to "異常中斷",
"status_hint.ready" to "已連線,可以開始聊天",
"status_hint.click_to_connect" to "點擊連線開始聊天",
"status_hint.handshaking" to "已連線,正在準備聊天...",
"status_hint.received_binary_handshake" to "收到二進位握手幀,正在嘗試解析...",
"status_hint.connection_error_retrying" to "連線異常,正在重試",
"status_hint.invalid_server_url" to "請填寫有效伺服器位址",
"status_hint.connecting" to "正在連線伺服器...",
"status_hint.disconnected" to "連線已關閉",
"status_hint.server_saved" to "伺服器位址已儲存",
"status_hint.server_removed_default" to "已恢復預設伺服器位址",
"status_hint.server_removed" to "已移除目前伺服器位址",
"status_hint.target_key_required" to "請先填寫目標公鑰,再傳送私訊",
"status_hint.hello_received" to "已收到握手資料,正在解析...",
"status_hint.handshake_failed_incomplete" to "握手失敗:伺服器回應不完整",
"status_hint.handshake_failed_unexpected" to "握手失敗:收到非預期訊息",
"status_hint.handshake_failed_parse" to "握手失敗:首包解析失敗",
"status_hint.authenticating" to "正在完成身份驗證...",
"status_hint.auth_timeout" to "連線超時,請重試",
"status_hint.auth_failed" to "認證失敗",
"status_hint.reconnecting" to "正在自動重試連線...",
"status_hint.connection_interrupted_retrying" to "連線已中斷,正在重試",
"status_hint.reconnect_countdown" to "%d秒後自動重連第 %d 次)",
"status_hint.reconnect_failed_invalid_url" to "重連失敗:伺服器位址無效",
"status_hint.hello_timeout" to "握手超時,請檢查位址路徑與反向代理"
"theme.warm" to "따뜻함"
)
)
@ -385,8 +178,7 @@ object LanguageManager {
}
val supportedLanguages = listOf(
LanguageOption("zh", "中文简体"),
LanguageOption("zh-Hant", "繁體中文"),
LanguageOption("zh", "中文"),
LanguageOption("en", "English"),
LanguageOption("ja", "日本语"),
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>

@ -23,6 +23,15 @@ const STORAGE_CURRENT_SERVER_URL_KEY = "oms_current_server_url";
const MAX_SERVER_URLS = 8;
const CHANNEL_BROADCAST = "broadcast";
const CHANNEL_PRIVATE = "private";
const CONTENT_TEXT = "text";
const CONTENT_AUDIO = "audio";
const AUDIO_MESSAGE_PREFIX = "[[OMS_AUDIO_V1]]";
const AUDIO_CHUNK_MESSAGE_PREFIX = "[[OMS_AUDIO_CHUNK_V1]]";
const AUDIO_CHUNK_BASE64_SIZE = 20_000;
const MAX_AUDIO_CHUNK_COUNT = 30;
const AUDIO_CHUNK_BUFFER_TTL_MS = 180_000;
const MIN_AUDIO_DURATION_MS = 350;
const AUDIO_CANCEL_TRIGGER_PX = 96;
function isLikelyLocalHost(host) {
const value = (host || "").toLowerCase();
@ -156,6 +165,118 @@ function summarizeKey(key = "") {
return `${key.slice(0, 8)}...${key.slice(-8)}`;
}
function asPayloadText(data) {
if (typeof data === "string") return data;
if (data == null) return "";
if (typeof data === "object" && typeof data.payload === "string") {
return data.payload;
}
return String(data);
}
function splitAudioBase64(base64, chunkSize) {
if (!base64 || chunkSize <= 0) return [];
if (base64.length <= chunkSize) return [base64];
const chunks = [];
for (let i = 0; i < base64.length; i += chunkSize) {
chunks.push(base64.slice(i, i + chunkSize));
}
return chunks;
}
function parseAudioPayload(payloadText) {
if (!payloadText.startsWith(AUDIO_MESSAGE_PREFIX)) return null;
const encoded = payloadText.slice(AUDIO_MESSAGE_PREFIX.length).trim();
if (!encoded) return null;
const parsed = safeJsonParse(encoded);
if (!parsed || !parsed.data) {
return null;
}
const encoding = String(parsed.encoding || "base64").toLowerCase();
if (encoding !== "base64") {
return null;
}
return {
mimeType: parsed.mimeType || "audio/mp4",
durationMillis: Number(parsed.durationMillis) || 0,
data: String(parsed.data || "")
};
}
function parseAudioChunkPayload(payloadText) {
if (!payloadText.startsWith(AUDIO_CHUNK_MESSAGE_PREFIX)) return null;
const encoded = payloadText.slice(AUDIO_CHUNK_MESSAGE_PREFIX.length).trim();
if (!encoded) return null;
const parsed = safeJsonParse(encoded);
if (!parsed) {
return null;
}
const encoding = String(parsed.encoding || "base64").toLowerCase();
if (encoding !== "base64") {
return null;
}
const total = Number(parsed.total);
const index = Number(parsed.index);
const messageId = String(parsed.messageId || "");
const data = String(parsed.data || "");
if (!messageId || !data || !Number.isInteger(total) || !Number.isInteger(index)) return null;
if (total < 1 || total > MAX_AUDIO_CHUNK_COUNT || index < 0 || index >= total) return null;
return {
mimeType: parsed.mimeType || "audio/mp4",
messageId,
index,
total,
durationMillis: Number(parsed.durationMillis) || 0,
data
};
}
function formatAudioDuration(durationMillis) {
const totalSeconds = Math.max(0, Math.floor((Number(durationMillis) || 0) / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes > 0 ? `${minutes}:${String(seconds).padStart(2, "0")}` : `${seconds}s`;
}
function formatRecordingElapsed(durationMillis) {
const clamped = Math.max(0, Number(durationMillis) || 0);
const seconds = Math.floor(clamped / 1000);
const tenths = Math.floor((clamped % 1000) / 100);
return `${seconds}.${tenths}s`;
}
function base64ToBytes(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = String(reader.result || "");
const marker = "base64,";
const index = result.indexOf(marker);
resolve(index >= 0 ? result.slice(index + marker.length) : result);
};
reader.onerror = () => reject(reader.error || new Error("读取录音失败"));
reader.readAsDataURL(blob);
});
}
function pickRecordingMimeType() {
if (typeof MediaRecorder === "undefined" || typeof MediaRecorder.isTypeSupported !== "function") {
return "";
}
// Android mp4/aac
const candidates = ["audio/mp4;codecs=mp4a.40.2", "audio/mp4"];
return candidates.find((item) => MediaRecorder.isTypeSupported(item)) || "";
}
function createLocalId() {
const c = globalThis.crypto;
if (c?.randomUUID) {
@ -206,6 +327,14 @@ export default function App() {
const targetComposingRef = useRef(false);
const messageListRef = useRef(null);
const stickToBottomRef = useRef(true);
const incomingAudioChunkBuffersRef = useRef(new Map());
const audioPlayerRef = useRef(null);
const audioObjectUrlRef = useRef("");
const recordingSessionRef = useRef(null);
const recordingTimerRef = useRef(0);
const recordingStartedAtRef = useRef(0);
const recordPressDownYRef = useRef(0);
const transientStatusTimerRef = useRef(0);
const [status, setStatus] = useState("idle");
const [statusHint, setStatusHint] = useState("点击连接开始聊天");
@ -219,6 +348,11 @@ export default function App() {
const [messages, setMessages] = useState([]);
const [showSystemMessages, setShowSystemMessages] = useState(false);
const [sending, setSending] = useState(false);
const [inputMode, setInputMode] = useState(CONTENT_TEXT);
const [isRecording, setIsRecording] = useState(false);
const [cancelOnRelease, setCancelOnRelease] = useState(false);
const [recordingElapsedMillis, setRecordingElapsedMillis] = useState(0);
const [playingMessageId, setPlayingMessageId] = useState("");
const [certFingerprint, setCertFingerprint] = useState("");
const [myPublicKey, setMyPublicKey] = useState("");
const [publicKeyBusy, setPublicKeyBusy] = useState(false);
@ -230,6 +364,7 @@ export default function App() {
const canConnect = status === "idle" || status === "error";
const canDisconnect = status !== "idle" && status !== "error";
const canSend = isConnected && draft.trim().length > 0 && !sending;
const canHoldToRecord = isConnected && !sending && (!directMode || targetKey.trim().length > 0);
const activeChannel = directMode ? CHANNEL_PRIVATE : CHANNEL_BROADCAST;
const mobileConnectText = useMemo(() => {
if (status === "ready") return "已连接";
@ -309,6 +444,44 @@ export default function App() {
clearTimeout(messageCopyTimerRef.current);
messageCopyTimerRef.current = 0;
}
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
recordingTimerRef.current = 0;
}
if (transientStatusTimerRef.current) {
clearTimeout(transientStatusTimerRef.current);
transientStatusTimerRef.current = 0;
}
if (recordingSessionRef.current) {
try {
if (recordingSessionRef.current.recorder?.state !== "inactive") {
recordingSessionRef.current.recorder.stop();
}
} catch {
// ignore
}
recordingSessionRef.current.stream?.getTracks?.().forEach((track) => {
try {
track.stop();
} catch {
// ignore
}
});
recordingSessionRef.current = null;
}
if (audioPlayerRef.current) {
try {
audioPlayerRef.current.pause();
} catch {
// ignore
}
audioPlayerRef.current.src = "";
audioPlayerRef.current = null;
}
if (audioObjectUrlRef.current) {
URL.revokeObjectURL(audioObjectUrlRef.current);
audioObjectUrlRef.current = "";
}
};
}, []);
@ -362,7 +535,7 @@ export default function App() {
]);
}
function pushIncoming(sender, text, subtitle = "", channel = CHANNEL_BROADCAST) {
function pushIncoming(sender, text, subtitle = "", channel = CHANNEL_BROADCAST, options = {}) {
setMessages((prev) => [
...prev,
{
@ -372,12 +545,16 @@ export default function App() {
subtitle,
channel,
content: text,
contentType: options.contentType || CONTENT_TEXT,
audioBase64: options.audioBase64 || "",
audioDurationMillis: Number(options.audioDurationMillis) || 0,
audioMimeType: options.audioMimeType || "",
ts: Date.now()
}
]);
}
function pushOutgoing(text, subtitle = "", channel = CHANNEL_BROADCAST) {
function pushOutgoing(text, subtitle = "", channel = CHANNEL_BROADCAST, options = {}) {
setMessages((prev) => [
...prev,
{
@ -387,11 +564,47 @@ export default function App() {
subtitle,
channel,
content: text,
contentType: options.contentType || CONTENT_TEXT,
audioBase64: options.audioBase64 || "",
audioDurationMillis: Number(options.audioDurationMillis) || 0,
audioMimeType: options.audioMimeType || "",
ts: Date.now()
}
]);
}
function pushIncomingAudio(sender, subtitle, audioBase64, durationMillis, mimeType, channel) {
pushIncoming(sender, "语音消息", subtitle, channel, {
contentType: CONTENT_AUDIO,
audioBase64,
audioDurationMillis: durationMillis,
audioMimeType: mimeType
});
}
function pushOutgoingAudio(subtitle, audioBase64, durationMillis, mimeType, channel) {
pushOutgoing("语音消息", subtitle, channel, {
contentType: CONTENT_AUDIO,
audioBase64,
audioDurationMillis: durationMillis,
audioMimeType: mimeType
});
}
function showTransientStatusHint(text, durationMs = 2200) {
setStatusHint(text);
if (transientStatusTimerRef.current) {
clearTimeout(transientStatusTimerRef.current);
transientStatusTimerRef.current = 0;
}
transientStatusTimerRef.current = window.setTimeout(() => {
transientStatusTimerRef.current = 0;
if (statusRef.current === "ready") {
setStatusHint("已连接,可以开始聊天");
}
}, durationMs);
}
async function ensureIdentity() {
if (identityRef.current) {
return identityRef.current;
@ -484,6 +697,399 @@ export default function App() {
}, 1600);
}
function purgeExpiredAudioChunkBuffers(nowMillis = Date.now()) {
const map = incomingAudioChunkBuffersRef.current;
if (!map.size) return;
for (const [key, value] of map.entries()) {
if (nowMillis - value.createdAtMillis >= AUDIO_CHUNK_BUFFER_TTL_MS) {
map.delete(key);
}
}
}
function ingestIncomingAudioChunk(sender, subtitle, channel, chunk) {
const now = Date.now();
purgeExpiredAudioChunkBuffers(now);
const key = `${channel}:${sender}:${chunk.messageId}`;
const map = incomingAudioChunkBuffersRef.current;
const existing = map.get(key);
const active =
!existing || existing.total !== chunk.total
? {
sender,
subtitle,
channel,
total: chunk.total,
durationMillis: Math.max(0, Number(chunk.durationMillis) || 0),
mimeType: chunk.mimeType || "audio/mp4",
createdAtMillis: now,
chunks: Array.from({ length: chunk.total }, () => "")
}
: existing;
if (!existing || existing.total !== chunk.total) {
map.set(key, active);
} else if (existing.sender !== sender || existing.channel !== channel) {
return;
}
active.chunks[chunk.index] = chunk.data;
if (active.chunks.some((part) => !part)) return;
map.delete(key);
const merged = active.chunks.join("");
if (!merged) return;
pushIncomingAudio(
active.sender,
active.subtitle,
merged,
active.durationMillis,
active.mimeType,
active.channel
);
}
function releaseAudioObjectUrl() {
if (!audioObjectUrlRef.current) return;
URL.revokeObjectURL(audioObjectUrlRef.current);
audioObjectUrlRef.current = "";
}
function stopAudioPlayback() {
if (audioPlayerRef.current) {
try {
audioPlayerRef.current.pause();
} catch {
// ignore
}
audioPlayerRef.current.currentTime = 0;
audioPlayerRef.current.src = "";
}
releaseAudioObjectUrl();
setPlayingMessageId("");
}
async function togglePlayAudioMessage(item) {
if (!item?.audioBase64) return;
if (playingMessageId === item.id) {
stopAudioPlayback();
return;
}
stopAudioPlayback();
try {
const bytes = base64ToBytes(item.audioBase64);
if (!bytes.length) {
pushSystem("语音播放失败:空数据");
return;
}
const mimeType = item.audioMimeType || "audio/mp4";
const blob = new Blob([bytes], { type: mimeType });
const url = URL.createObjectURL(blob);
releaseAudioObjectUrl();
audioObjectUrlRef.current = url;
if (!audioPlayerRef.current) {
audioPlayerRef.current = new Audio();
}
const player = audioPlayerRef.current;
player.onended = () => {
stopAudioPlayback();
};
player.onerror = () => {
stopAudioPlayback();
pushSystem("语音播放失败:浏览器不支持该音频格式");
};
player.src = url;
await player.play();
setPlayingMessageId(item.id);
} catch (error) {
stopAudioPlayback();
pushSystem(`语音播放失败:${error?.message || "unknown error"}`);
}
}
async function sendSignedPayload(type, key, payloadText) {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) {
throw new Error("连接不可用");
}
const identity = identityRef.current;
const serverPublicKey = serverPublicKeyRef.current;
if (!identity || !serverPublicKey) {
throw new Error("身份或服务端公钥未就绪");
}
const timestamp = unixSecondsNow();
const nonce = createNonce();
const signInput = [type, key, payloadText, timestamp, nonce].join("\n");
const signature = await signText(identity.signPrivateKey, signInput);
const envelope = {
type,
key,
data: {
payload: payloadText,
timestamp,
nonce,
signature
}
};
const cipher = await rsaEncryptChunked(serverPublicKey, JSON.stringify(envelope));
ws.send(cipher);
}
async function sendAudioMessage(audioBase64, durationMillis, mimeType = "audio/mp4") {
if (!isConnected || sending) return;
const normalized = String(audioBase64 || "").trim();
if (!normalized) return;
const key = directMode ? targetKey.trim() : "";
if (directMode && !key) {
showTransientStatusHint("请先填写目标公钥,再发送私聊消息");
return;
}
const type = key ? "forward" : "broadcast";
const channel = key ? CHANNEL_PRIVATE : CHANNEL_BROADCAST;
const subtitle = key ? `私聊 ${summarizeKey(key)}` : "";
const safeDuration = Math.max(0, Number(durationMillis) || 0);
const chunks = splitAudioBase64(normalized, AUDIO_CHUNK_BASE64_SIZE);
if (chunks.length > MAX_AUDIO_CHUNK_COUNT) {
showTransientStatusHint("语音过长,超过可发送分片上限");
pushSystem("语音过长,已取消发送");
return;
}
setSending(true);
try {
if (chunks.length === 1) {
const payloadText =
AUDIO_MESSAGE_PREFIX +
JSON.stringify({
version: 1,
encoding: "base64",
mimeType: mimeType || "audio/mp4",
durationMillis: safeDuration,
data: normalized
});
await sendSignedPayload(type, key, payloadText);
} else {
const messageId = createLocalId();
for (let i = 0; i < chunks.length; i += 1) {
const payloadText =
AUDIO_CHUNK_MESSAGE_PREFIX +
JSON.stringify({
version: 1,
encoding: "base64",
mimeType: mimeType || "audio/mp4",
messageId,
index: i,
total: chunks.length,
durationMillis: safeDuration,
data: chunks[i]
});
await sendSignedPayload(type, key, payloadText);
}
}
pushOutgoingAudio(subtitle, normalized, safeDuration, mimeType || "audio/mp4", channel);
} catch (error) {
const message = error?.message || "unknown error";
showTransientStatusHint(`语音发送失败:${message}`);
pushSystem(`语音发送失败:${message}`);
} finally {
setSending(false);
}
}
function clearRecordingTick() {
if (!recordingTimerRef.current) return;
clearInterval(recordingTimerRef.current);
recordingTimerRef.current = 0;
}
async function startRecording() {
if (recordingSessionRef.current || isRecording) return;
if (!canHoldToRecord) {
if (directMode && !targetKey.trim()) {
showTransientStatusHint("请先填写目标公钥,再发送私聊消息");
}
return;
}
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") {
showTransientStatusHint("当前浏览器不支持语音录制");
pushSystem("语音录制失败:浏览器不支持 MediaRecorder");
return;
}
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (error) {
showTransientStatusHint("请先授予麦克风权限");
pushSystem(`语音录制失败:${error?.message || "未获得权限"}`);
return;
}
const mimeType = pickRecordingMimeType();
if (!mimeType) {
stream.getTracks().forEach((track) => {
try {
track.stop();
} catch {
// ignore
}
});
showTransientStatusHint("当前浏览器不支持 MP4 语音录制");
pushSystem("语音录制失败:当前浏览器不支持 MP4/AAC 编码Android 端可能无法播放");
return;
}
const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
const session = {
stream,
recorder,
chunks: [],
mimeType: recorder.mimeType || "audio/mp4",
sendOnStop: true
};
recordingSessionRef.current = session;
recordingStartedAtRef.current = Date.now();
setRecordingElapsedMillis(0);
setIsRecording(true);
setCancelOnRelease(false);
clearRecordingTick();
recordingTimerRef.current = window.setInterval(() => {
setRecordingElapsedMillis(Math.max(0, Date.now() - recordingStartedAtRef.current));
}, 100);
recorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
session.chunks.push(event.data);
}
};
recorder.onerror = () => {
showTransientStatusHint("录音失败,请重试");
pushSystem("语音录制失败MediaRecorder 发生错误");
};
recorder.onstop = async () => {
clearRecordingTick();
const recordedDuration = Math.max(0, Date.now() - recordingStartedAtRef.current);
recordingStartedAtRef.current = 0;
setIsRecording(false);
setCancelOnRelease(false);
setRecordingElapsedMillis(0);
recordingSessionRef.current = null;
session.stream.getTracks().forEach((track) => {
try {
track.stop();
} catch {
// ignore
}
});
if (!session.sendOnStop) {
showTransientStatusHint("已取消语音发送", 1600);
return;
}
if (recordedDuration < MIN_AUDIO_DURATION_MS) {
showTransientStatusHint("录音时间太短", 1800);
return;
}
if (session.chunks.length === 0) {
showTransientStatusHint("录音失败,请重试");
pushSystem("语音录制失败:未采集到音频数据");
return;
}
try {
const blob = new Blob(session.chunks, { type: session.mimeType || "audio/webm" });
const base64 = await blobToBase64(blob);
await sendAudioMessage(base64, recordedDuration, session.mimeType || "audio/webm");
} catch (error) {
showTransientStatusHint("录音失败,请重试");
pushSystem(`语音录制失败:${error?.message || "unknown error"}`);
}
};
try {
recorder.start();
} catch (error) {
clearRecordingTick();
recordingSessionRef.current = null;
recordingStartedAtRef.current = 0;
setIsRecording(false);
setCancelOnRelease(false);
setRecordingElapsedMillis(0);
stream.getTracks().forEach((track) => {
try {
track.stop();
} catch {
// ignore
}
});
showTransientStatusHint("录音失败,请重试");
pushSystem(`语音录制失败:${error?.message || "unknown error"}`);
return;
}
}
function finishRecording(send) {
const session = recordingSessionRef.current;
if (!session) return;
session.sendOnStop = Boolean(send);
clearRecordingTick();
setIsRecording(false);
setRecordingElapsedMillis(0);
try {
if (session.recorder.state !== "inactive") {
session.recorder.stop();
}
} catch {
session.stream.getTracks().forEach((track) => {
try {
track.stop();
} catch {
// ignore
}
});
recordingSessionRef.current = null;
showTransientStatusHint("录音失败,请重试");
}
}
async function onHoldToTalkPointerDown(event) {
event.preventDefault();
recordPressDownYRef.current = event.clientY;
setCancelOnRelease(false);
try {
event.currentTarget.setPointerCapture?.(event.pointerId);
} catch {
// ignore
}
await startRecording();
}
function onHoldToTalkPointerMove(event) {
if (!isRecording) return;
const deltaY = recordPressDownYRef.current - event.clientY;
setCancelOnRelease(deltaY > AUDIO_CANCEL_TRIGGER_PX);
}
function onHoldToTalkPointerUp(event) {
if (!isRecording) return;
const shouldSend = !cancelOnRelease;
finishRecording(shouldSend);
try {
event.currentTarget.releasePointerCapture?.(event.pointerId);
} catch {
// ignore
}
}
function onHoldToTalkPointerCancel() {
if (!isRecording) return;
finishRecording(false);
}
async function connect() {
if (!canConnect) return;
const cryptoIssue = getCryptoIssueMessage();
@ -579,6 +1185,10 @@ export default function App() {
function disconnect() {
manualCloseRef.current = true;
if (isRecording) {
finishRecording(false);
}
stopAudioPlayback();
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
@ -688,13 +1298,52 @@ export default function App() {
}
if (message.type === "broadcast") {
pushIncoming(message.key || "匿名用户", String(message.data ?? ""), "", CHANNEL_BROADCAST);
const sender = message.key || "匿名用户";
const payloadText = asPayloadText(message.data);
const audioChunk = parseAudioChunkPayload(payloadText);
if (audioChunk) {
ingestIncomingAudioChunk(sender, "", CHANNEL_BROADCAST, audioChunk);
return;
}
const audio = parseAudioPayload(payloadText);
if (audio) {
pushIncomingAudio(
sender,
"",
audio.data,
audio.durationMillis,
audio.mimeType || "audio/mp4",
CHANNEL_BROADCAST
);
} else {
pushIncoming(sender, payloadText, "", CHANNEL_BROADCAST);
}
return;
}
if (message.type === "forward") {
const sourceKey = String(message.key || "");
const sender = "私聊消息";
pushIncoming(sender, String(message.data ?? ""), "", CHANNEL_PRIVATE);
const subtitle = sourceKey ? `来自 ${summarizeKey(sourceKey)}` : "";
const payloadText = asPayloadText(message.data);
const audioChunk = parseAudioChunkPayload(payloadText);
if (audioChunk) {
ingestIncomingAudioChunk(sender, subtitle, CHANNEL_PRIVATE, audioChunk);
return;
}
const audio = parseAudioPayload(payloadText);
if (audio) {
pushIncomingAudio(
sender,
subtitle,
audio.data,
audio.durationMillis,
audio.mimeType || "audio/mp4",
CHANNEL_PRIVATE
);
} else {
pushIncoming(sender, payloadText, subtitle, CHANNEL_PRIVATE);
}
return;
}
@ -727,24 +1376,7 @@ export default function App() {
setSending(true);
try {
const timestamp = unixSecondsNow();
const nonce = createNonce();
const signInput = [type, key, text, timestamp, nonce].join("\n");
const signature = await signText(identity.signPrivateKey, signInput);
const envelope = {
type,
key,
data: {
payload: text,
timestamp,
nonce,
signature
}
};
const cipher = await rsaEncryptChunked(serverPublicKey, JSON.stringify(envelope));
ws.send(cipher);
await sendSignedPayload(type, key, text);
pushOutgoing(text, subtitle, channel);
setDraft("");
} catch (error) {
@ -921,17 +1553,43 @@ export default function App() {
</>
) : (
<>
<div className="msg-head">
<strong>{item.sender}</strong>
{item.subtitle ? <span>{item.subtitle}</span> : null}
<time>{formatTime(item.ts)}</time>
</div>
<p>{item.content}</p>
<div className="msg-actions">
<button className="btn btn-copy" onClick={() => copyMessageText(item.id, item.content)}>
{copiedMessageId === item.id ? "已复制" : "复制"}
</button>
</div>
{(() => {
const isAudioMessage = item.contentType === CONTENT_AUDIO && item.audioBase64;
return (
<>
<div className="msg-head">
<strong>{item.sender}</strong>
{item.subtitle ? <span>{item.subtitle}</span> : null}
<time>{formatTime(item.ts)}</time>
</div>
{isAudioMessage ? (
<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>
@ -939,25 +1597,64 @@ export default function App() {
)}
</div>
<div className="composer">
<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 ? "发送中..." : "发送"}
<div className={`composer ${inputMode === CONTENT_AUDIO ? "audio-mode" : ""}`}>
<button
className={`btn btn-ghost btn-input-switch ${inputMode === CONTENT_AUDIO ? "active" : ""}`}
type="button"
onClick={() => {
if (inputMode === CONTENT_AUDIO && isRecording) {
finishRecording(false);
}
setInputMode((prev) => (prev === CONTENT_TEXT ? CONTENT_AUDIO : CONTENT_TEXT));
}}
title={inputMode === CONTENT_TEXT ? "切换到语音输入" : "切换到文字输入"}
>
{inputMode === CONTENT_TEXT ? "语音" : "键盘"}
</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>
</section>

@ -326,6 +326,94 @@ body {
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 {
margin-top: 0;
display: flex;
@ -361,12 +449,17 @@ body {
border-top: 1px solid var(--card-border);
padding: 10px 12px;
display: grid;
grid-template-columns: 1fr auto;
grid-template-columns: auto 1fr auto;
gap: 10px;
align-items: center;
background: rgba(255, 255, 255, 0.96);
box-shadow: inset 0 1px 0 rgba(19, 35, 58, 0.06);
}
.composer.audio-mode {
grid-template-columns: auto 1fr;
}
.composer-input-wrap {
border: 1px solid rgba(19, 35, 58, 0.18);
border-radius: 15px;
@ -374,6 +467,41 @@ body {
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 {
width: 100%;
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) {
html,
body,
@ -805,7 +942,7 @@ select:focus {
.composer {
position: sticky;
bottom: 56px;
grid-template-columns: 1fr auto;
grid-template-columns: auto 1fr auto;
align-items: center;
padding: 6px 8px;
gap: 6px;
@ -813,6 +950,10 @@ select:focus {
border-top: 1px solid rgba(19, 35, 58, 0.08);
}
.composer.audio-mode {
grid-template-columns: auto 1fr;
}
.btn-send {
width: auto;
min-width: 56px;
@ -831,6 +972,20 @@ select:focus {
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 {
min-height: 20px;
max-height: 88px;

Loading…
Cancel
Save