Compare commits
No commits in common. '4ed194af0e70f62f5fdb151861f5dd9757f31132' and 'cb866dbc28ad09f667575a06e0961b0f02c509cf' have entirely different histories.
4ed194af0e
...
cb866dbc28
@ -1,56 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,689 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
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::"
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
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
@ -1,89 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
<?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