Merge branch 'main' into linran
commit
4ed194af0e
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,104 @@
|
|||||||
|
using OnlineMsgServer.Core;
|
||||||
|
using WebSocketSharp.Server;
|
||||||
|
|
||||||
|
namespace OnlineMsgServer.Common
|
||||||
|
{
|
||||||
|
class RenameMessage : Message
|
||||||
|
{
|
||||||
|
public RenameMessage()
|
||||||
|
{
|
||||||
|
Type = "rename";
|
||||||
|
Key = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task Handler(string wsid, WebSocketSessionManager Sessions)
|
||||||
|
{
|
||||||
|
return Task.Run(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!UserService.IsAuthenticated(wsid))
|
||||||
|
{
|
||||||
|
Log.Security("rename_denied_unauthenticated", $"wsid={wsid}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string key = Key?.Trim() ?? "";
|
||||||
|
if (!SignedMessagePayload.TryParse(Data, out SignedMessagePayload payload, out string parseError))
|
||||||
|
{
|
||||||
|
Log.Security("rename_payload_invalid", $"wsid={wsid} reason={parseError}");
|
||||||
|
SendEncryptedResult(Sessions, wsid, "rename_error", "rename payload invalid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SecurityValidator.VerifySignedMessage(wsid, Type, key, payload, out string securityReason))
|
||||||
|
{
|
||||||
|
Log.Security("rename_security_failed", $"wsid={wsid} reason={securityReason}");
|
||||||
|
SendEncryptedResult(Sessions, wsid, "rename_error", "rename signature invalid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string nextName = payload.Payload.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(nextName))
|
||||||
|
{
|
||||||
|
Log.Security("rename_invalid_name", $"wsid={wsid} reason=blank");
|
||||||
|
SendEncryptedResult(Sessions, wsid, "rename_error", "display name cannot be empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!UserService.IsPeerNodeSession(wsid) && PeerNetworkService.IsPeerUserName(nextName))
|
||||||
|
{
|
||||||
|
Log.Security("rename_invalid_name", $"wsid={wsid} reason=peer_prefix");
|
||||||
|
SendEncryptedResult(Sessions, wsid, "rename_error", "display name uses reserved prefix");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!UserService.TryUpdateUserName(wsid, nextName, out string appliedName))
|
||||||
|
{
|
||||||
|
Log.Security("rename_update_failed", $"wsid={wsid}");
|
||||||
|
SendEncryptedResult(Sessions, wsid, "rename_error", "display name update failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Security("rename_success", $"wsid={wsid} user={appliedName}");
|
||||||
|
SendEncryptedResult(Sessions, wsid, "rename_ok", appliedName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Security("rename_error", $"wsid={wsid} error={ex.Message}");
|
||||||
|
SendEncryptedResult(Sessions, wsid, "rename_error", "display name update failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SendEncryptedResult(
|
||||||
|
WebSocketSessionManager sessions,
|
||||||
|
string wsid,
|
||||||
|
string type,
|
||||||
|
string data
|
||||||
|
)
|
||||||
|
{
|
||||||
|
string? publicKey = UserService.GetUserPublicKeyByID(wsid);
|
||||||
|
if (string.IsNullOrWhiteSpace(publicKey))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Message result = new()
|
||||||
|
{
|
||||||
|
Type = type,
|
||||||
|
Data = data
|
||||||
|
};
|
||||||
|
string encrypted = RsaService.EncryptForClient(publicKey, result.ToJsonString());
|
||||||
|
|
||||||
|
foreach (IWebSocketSession session in sessions.Sessions)
|
||||||
|
{
|
||||||
|
if (session.ID == wsid)
|
||||||
|
{
|
||||||
|
session.Context.WebSocket.Send(encrypted);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
package com.onlinemsg.client.data.local
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
@Database(
|
||||||
|
entities = [ChatMessageEntity::class],
|
||||||
|
version = 3,
|
||||||
|
exportSchema = false
|
||||||
|
)
|
||||||
|
abstract class ChatDatabase : RoomDatabase() {
|
||||||
|
abstract fun chatMessageDao(): ChatMessageDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val DB_NAME = "onlinemsg_chat.db"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: ChatDatabase? = null
|
||||||
|
|
||||||
|
private val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE chat_messages ADD COLUMN contentType TEXT NOT NULL DEFAULT 'TEXT'")
|
||||||
|
db.execSQL("ALTER TABLE chat_messages ADD COLUMN audioBase64 TEXT NOT NULL DEFAULT ''")
|
||||||
|
db.execSQL("ALTER TABLE chat_messages ADD COLUMN audioDurationMillis INTEGER NOT NULL DEFAULT 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE chat_messages ADD COLUMN serverKey TEXT NOT NULL DEFAULT ''")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getInstance(context: Context): ChatDatabase {
|
||||||
|
return instance ?: synchronized(this) {
|
||||||
|
instance ?: Room.databaseBuilder(
|
||||||
|
context.applicationContext,
|
||||||
|
ChatDatabase::class.java,
|
||||||
|
DB_NAME
|
||||||
|
)
|
||||||
|
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
|
||||||
|
.build().also { db ->
|
||||||
|
instance = db
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
package com.onlinemsg.client.data.local
|
||||||
|
|
||||||
|
import com.onlinemsg.client.ui.MessageChannel
|
||||||
|
import com.onlinemsg.client.ui.MessageContentType
|
||||||
|
import com.onlinemsg.client.ui.MessageRole
|
||||||
|
import com.onlinemsg.client.ui.UiMessage
|
||||||
|
|
||||||
|
class ChatHistoryRepository(private val messageDao: ChatMessageDao) {
|
||||||
|
suspend fun loadMessages(serverKey: String, limit: Int): List<UiMessage> {
|
||||||
|
migrateLegacyMessagesIfNeeded(serverKey)
|
||||||
|
return messageDao.listByServer(serverKey)
|
||||||
|
.asSequence()
|
||||||
|
.mapNotNull { entity -> entity.toUiMessageOrNull() }
|
||||||
|
.toList()
|
||||||
|
.takeLast(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun appendMessage(serverKey: String, message: UiMessage, limit: Int) {
|
||||||
|
messageDao.upsert(message.toEntity(serverKey))
|
||||||
|
messageDao.trimToLatest(serverKey, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clearAll(serverKey: String) {
|
||||||
|
messageDao.clearAll(serverKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun migrateLegacyMessagesIfNeeded(serverKey: String) {
|
||||||
|
if (messageDao.countByServer(serverKey) > 0) return
|
||||||
|
if (messageDao.countLegacyMessages() == 0) return
|
||||||
|
messageDao.migrateLegacyMessagesToServer(
|
||||||
|
serverKey = serverKey,
|
||||||
|
idPrefix = storageIdPrefix(serverKey)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun UiMessage.toEntity(serverKey: String): ChatMessageEntity {
|
||||||
|
return ChatMessageEntity(
|
||||||
|
id = toStorageId(serverKey, id),
|
||||||
|
serverKey = serverKey,
|
||||||
|
role = role.name,
|
||||||
|
sender = sender,
|
||||||
|
subtitle = subtitle,
|
||||||
|
content = content,
|
||||||
|
channel = channel.name,
|
||||||
|
timestampMillis = timestampMillis,
|
||||||
|
contentType = contentType.name,
|
||||||
|
audioBase64 = audioBase64,
|
||||||
|
audioDurationMillis = audioDurationMillis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ChatMessageEntity.toUiMessageOrNull(): UiMessage? {
|
||||||
|
val parsedRole = runCatching { MessageRole.valueOf(role) }.getOrNull() ?: return null
|
||||||
|
val parsedChannel = runCatching { MessageChannel.valueOf(channel) }.getOrNull() ?: return null
|
||||||
|
val parsedContentType = runCatching { MessageContentType.valueOf(contentType) }.getOrNull()
|
||||||
|
?: MessageContentType.TEXT
|
||||||
|
return UiMessage(
|
||||||
|
id = id,
|
||||||
|
role = parsedRole,
|
||||||
|
sender = sender,
|
||||||
|
subtitle = subtitle,
|
||||||
|
content = content,
|
||||||
|
channel = parsedChannel,
|
||||||
|
timestampMillis = timestampMillis,
|
||||||
|
contentType = parsedContentType,
|
||||||
|
audioBase64 = audioBase64,
|
||||||
|
audioDurationMillis = audioDurationMillis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toStorageId(serverKey: String, messageId: String): String {
|
||||||
|
return if (messageId.startsWith(storageIdPrefix(serverKey))) {
|
||||||
|
messageId
|
||||||
|
} else {
|
||||||
|
storageIdPrefix(serverKey) + messageId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun storageIdPrefix(serverKey: String): String = "$serverKey::"
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
package com.onlinemsg.client.data.local
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ChatMessageDao {
|
||||||
|
@Query("SELECT * FROM chat_messages WHERE serverKey = :serverKey ORDER BY timestampMillis ASC")
|
||||||
|
suspend fun listByServer(serverKey: String): List<ChatMessageEntity>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(message: ChatMessageEntity)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
DELETE FROM chat_messages
|
||||||
|
WHERE serverKey = :serverKey
|
||||||
|
AND id NOT IN (
|
||||||
|
SELECT id
|
||||||
|
FROM chat_messages
|
||||||
|
WHERE serverKey = :serverKey
|
||||||
|
ORDER BY timestampMillis DESC
|
||||||
|
LIMIT :limit
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
suspend fun trimToLatest(serverKey: String, limit: Int)
|
||||||
|
|
||||||
|
@Query("DELETE FROM chat_messages WHERE serverKey = :serverKey")
|
||||||
|
suspend fun clearAll(serverKey: String)
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM chat_messages WHERE serverKey = :serverKey")
|
||||||
|
suspend fun countByServer(serverKey: String): Int
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM chat_messages WHERE serverKey = ''")
|
||||||
|
suspend fun countLegacyMessages(): Int
|
||||||
|
|
||||||
|
@Query("UPDATE chat_messages SET serverKey = :serverKey, id = :idPrefix || id WHERE serverKey = ''")
|
||||||
|
suspend fun migrateLegacyMessagesToServer(serverKey: String, idPrefix: String)
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package com.onlinemsg.client.data.local
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(tableName = "chat_messages")
|
||||||
|
data class ChatMessageEntity(
|
||||||
|
@PrimaryKey val id: String,
|
||||||
|
val serverKey: String,
|
||||||
|
val role: String,
|
||||||
|
val sender: String,
|
||||||
|
val subtitle: String,
|
||||||
|
val content: String,
|
||||||
|
val channel: String,
|
||||||
|
val timestampMillis: Long,
|
||||||
|
val contentType: String,
|
||||||
|
val audioBase64: String,
|
||||||
|
val audioDurationMillis: Long
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue