using RelayClient.Crypto; using WebSocketSharp; using System.Text.Json; using System.Text.Json.Serialization; using RelayShared.Rtc; namespace RelayClient; public partial class MainPage : ContentPage { private readonly string _username; private readonly WebSocket _wsc; private string? _serverPublicKey; private string? _currentChannelId; private string? _currentChannelName; private readonly Dictionary> _messagesByChannel = new(); private readonly List _channels = []; public MainPage(string username) { InitializeComponent(); _username = username; UserLabel.Text = $"Logged in as: {_username}"; if (!KeyStorage.HasKeys(_username)) { var keys = E2EeHelper.GenerateRsaKeyPair(); KeyStorage.SavePrivateKey(_username, keys.privateKey); KeyStorage.SavePublicKey(_username, keys.publicKey); } _wsc = new WebSocket("ws://localhost:1337/"); _wsc.OnMessage += WscOnMessage; _wsc.Connect(); var publicKey = KeyStorage.LoadPublicKey(_username); _wsc.Send($"REGISTER_KEY|{_username}|{publicKey}"); _wsc.Send("GET_SERVER_KEY"); _wsc.Send("GET_CHANNELS"); hybridWebView.SetInvokeJavaScriptTarget(this); ServerAPI.setupClient(); } private void SendButton_OnClicked(object? sender, EventArgs e) { SendMessage(); } private void MessageEntry_OnCompleted(object? sender, EventArgs e) { SendMessage(); } private void SendMessage() { var text = MessageEntry.Text?.Trim(); if (string.IsNullOrWhiteSpace(text)) return; if (string.IsNullOrWhiteSpace(_serverPublicKey)) { Console.WriteLine("Server public key not loaded yet."); return; } if (string.IsNullOrWhiteSpace(_currentChannelId)) { Console.WriteLine("No channel selected yet."); return; } var encrypted = E2EeHelper.EncryptForRecipient(text, _serverPublicKey); var payload = new SocketEncryptedMessage { ChannelId = _currentChannelId!, Type = SignalType.ClientEncryptedChat, SenderUsername = _username, CipherText = encrypted.CipherText, Nonce = encrypted.Nonce, Tag = encrypted.Tag, EncryptedKey = encrypted.EncryptedKey }; var json = JsonSerializer.Serialize(payload); _wsc.Send(json); Console.WriteLine($"[{_username}] sent encrypted message."); MessageEntry.Text = string.Empty; MessageEntry.Focus(); } private void WscOnMessage(object? sender, MessageEventArgs e) { if (e.Data.StartsWith("SERVER:REGISTERED_KEY:")) { Console.WriteLine(e.Data); return; } SafeSendRawToWebView($"[{_username}] RAW WS DATA: {e.Data}"); Console.WriteLine($"[{_username}] RAW WS DATA: {e.Data}"); try { using var doc = JsonDocument.Parse(e.Data); var root = doc.RootElement; if (!TryReadSignalType(root, out var type)) return; if (type == SignalType.ChannelList) { var channelList = JsonSerializer.Deserialize(e.Data); if (channelList is null) return; _channels.Clear(); _channels.AddRange(channelList.Channels.OrderBy(c => c.CreatedAt)); var defaultChannel = _channels .Where(c => c.Name.Equals("welcome", StringComparison.OrdinalIgnoreCase)) .OrderBy(c => c.CreatedAt) .FirstOrDefault() ?? _channels.OrderBy(c => c.CreatedAt).FirstOrDefault(); if (defaultChannel is not null) { _currentChannelId = defaultChannel.ChannelId; _currentChannelName = defaultChannel.Name; MainThread.BeginInvokeOnMainThread(async () => { ChannelLabel.Text = $"#{_currentChannelName}"; RenderChannelList(); await PushRtcContextToJsAsync(); }); _wsc.Send($"GET_HISTORY|{_username}|{_currentChannelId}"); } return; } if (type == SignalType.ServerPublicKey) { var serverKeyMessage = JsonSerializer.Deserialize(e.Data); if (serverKeyMessage is not null) { _serverPublicKey = serverKeyMessage.PublicKey; Console.WriteLine($"[{_username}] loaded server public key."); } return; } if (type == SignalType.EncryptedSignal) { var payload = JsonSerializer.Deserialize(e.Data); if (payload is null) return; if (payload.ChannelId != _currentChannelId) return; if (payload.SenderUsername == _username) return; var privateKey = KeyStorage.LoadPrivateKey(_username); var decryptedJson = E2EeHelper.DecryptForRecipient( new EncryptedPayload { CipherText = payload.CipherText, Nonce = payload.Nonce, Tag = payload.Tag, EncryptedKey = payload.EncryptedKey }, privateKey ); MainThread.BeginInvokeOnMainThread(async () => { await SendRtcSignalToJsAsync(decryptedJson); }); return; } if (type == SignalType.OfferUpdated || type == SignalType.AnswerUpdated || type == SignalType.CandidateAdded || type == SignalType.CallLeft) { var rtcNotification = JsonSerializer.Deserialize(e.Data); if (rtcNotification is null) return; var notificationType = rtcNotification.Type ?? null; var notificationChannelId = rtcNotification.ChannelId ?? string.Empty; if (notificationChannelId != _currentChannelId) return; SafeSendRawToWebView("RTC notification received: " + notificationType + " for " + notificationChannelId); MainThread.BeginInvokeOnMainThread(async () => { switch (notificationType) { case SignalType.OfferUpdated : { if (!string.Equals(rtcNotification.TargetUsername, _username, StringComparison.OrdinalIgnoreCase)) break; if (string.IsNullOrWhiteSpace(_currentChannelId) || string.IsNullOrWhiteSpace(rtcNotification.Username)) break; var offer = await ServerAPI.GetOfferForChannelAsync(_currentChannelId, rtcNotification.Username, _username); if (offer is not null) { await SendRtcOfferToJsAsync(rtcNotification.Username, offer); } break; } case SignalType.AnswerUpdated: { if (!string.Equals(rtcNotification.TargetUsername, _username, StringComparison.OrdinalIgnoreCase)) break; if (string.IsNullOrWhiteSpace(_currentChannelId) || string.IsNullOrWhiteSpace(rtcNotification.Username)) break; var answer = await ServerAPI.GetAnswerForChannelAsync(_currentChannelId, rtcNotification.Username, _username); if (answer is not null) { await SendRtcAnswerToJsAsync(rtcNotification.Username, answer); } break; } case SignalType.CandidateAdded: { if (!string.Equals(rtcNotification.TargetUsername, _username, StringComparison.OrdinalIgnoreCase)) break; try { IceCandidate? iceCandidate = JsonSerializer.Deserialize(rtcNotification.Direction); if (iceCandidate is not null && !string.IsNullOrWhiteSpace(rtcNotification.Username)) { await SendRtcCandidateToJsAsync(rtcNotification.Username, iceCandidate); } } catch (Exception ex) { SafeSendRawToWebView($"Candidate rejected: {ex.Message}"); } break; } case SignalType.CallLeft: { SafeSendRawToWebView("RTC call left notification received."); if (!string.IsNullOrWhiteSpace(rtcNotification.Username)) { RtcLeaveCallback(rtcNotification.Username); } break; } } }); return; } if (type != SignalType.EncryptedChat) return; var pyload = JsonSerializer.Deserialize(e.Data); if (pyload is null) return; if (!string.Equals(pyload.RecipientUsername, _username, StringComparison.OrdinalIgnoreCase)) return; Console.WriteLine($"[{_username}] received encrypted payload for {pyload.RecipientUsername}"); var privKey = KeyStorage.LoadPrivateKey(_username); var decryptedText = E2EeHelper.DecryptForRecipient( new EncryptedPayload { CipherText = pyload.CipherText, Nonce = pyload.Nonce, Tag = pyload.Tag, EncryptedKey = pyload.EncryptedKey }, privKey ); Console.WriteLine($"[{_username}] decrypted message from {pyload.SenderUsername}: {decryptedText}"); var message = new ChatMessage { SenderUsername = pyload.SenderUsername, Text = decryptedText, Timestamp = DateTime.Now }; if (!_messagesByChannel.ContainsKey(pyload.ChannelId)) { _messagesByChannel[pyload.ChannelId] = []; } _messagesByChannel[pyload.ChannelId].Add(message); if (pyload.ChannelId == _currentChannelId) { MainThread.BeginInvokeOnMainThread(() => { RenderSingleMessage(message); }); } } catch (Exception ex) { Console.WriteLine($"[{_username}] failed to process websocket message: {ex.Message}"); } } protected override void OnDisappearing() { _wsc.OnMessage -= WscOnMessage; _wsc.Close(); base.OnDisappearing(); } private void RenderChannelList() { SidebarList.Children.Clear(); foreach (var channel in _channels.OrderBy(c => c.CreatedAt)) { var button = new Button { Text = $"#{channel.Name}" }; button.Clicked += (_, _) => { _currentChannelId = channel.ChannelId; _currentChannelName = channel.Name; MainThread.BeginInvokeOnMainThread(async () => { await PushRtcContextToJsAsync(); }); ChannelLabel.Text = $"#{_currentChannelName}"; RenderCurrentChannelMessages(); if (!_messagesByChannel.ContainsKey(channel.ChannelId)) { _wsc.Send($"GET_HISTORY|{_username}|{channel.ChannelId}"); } }; SidebarList.Children.Add(button); } } private void RenderCurrentChannelMessages() { MessagesLayout.Children.Clear(); if (_currentChannelId is null) return; if (!_messagesByChannel.TryGetValue(_currentChannelId, out var messages)) return; foreach (var message in messages.OrderBy(m => m.Timestamp)) { RenderSingleMessage(message); } } private async void RenderSingleMessage(ChatMessage message) { bool isOwnMessage = message.SenderUsername == _username; var bubble = new Border { StrokeThickness = 1, Padding = 10, Margin = isOwnMessage ? new Thickness(40, 0, 0, 0) : new Thickness(0, 0, 40, 0), HorizontalOptions = isOwnMessage ? LayoutOptions.End : LayoutOptions.Start, Content = new VerticalStackLayout { Spacing = 2, Children = { new Label { Text = message.SenderUsername, FontAttributes = FontAttributes.Bold, FontSize = 12 }, new Label { Text = message.Text, FontSize = 14 }, new Label { Text = message.Timestamp.ToString("h:mm tt"), FontSize = 10 } } } }; MessagesLayout.Children.Add(bubble); await MessagesScrollView.ScrollToAsync(MessagesLayout, ScrollToPosition.End, true); } private void SwapView_OnClicked(object? sender, EventArgs e) { if (RtcView.IsVisible) { MessagesScrollView.IsVisible = true; RtcView.IsVisible = false; ViewSwapped.Text = "Swap to Web View"; } else { MessagesScrollView.IsVisible = false; RtcView.IsVisible = true; ViewSwapped.Text = "Swap to Message View"; } } #region RTC Functions public async Task JoinRtcChannel() { if (string.IsNullOrWhiteSpace(_currentChannelId)) return "[]"; _wsc.Send($"RTC_JOIN_CHANNEL|{_username}|{_currentChannelId}"); SafeSendRawToWebView($"Attempting to join RTC Channel {_currentChannelName} | {_currentChannelId} "); var participants = await ServerAPI.GetParticipantsForChannelAsync(_currentChannelId); var otherParticipants = participants .Where(x => !string.Equals(x, _username, StringComparison.OrdinalIgnoreCase)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); SafeSendRawToWebView($"RTC participants in {_currentChannelName}: {string.Join(", ", otherParticipants)}"); return JsonSerializer.Serialize(otherParticipants); } public void LeaveRtcChannel() { if (string.IsNullOrWhiteSpace(_currentChannelId)) return; _wsc.Send($"RTC_LEAVE_CHANNEL|{_username}|{_currentChannelId}"); } public async Task WriteRtcOffer(string json) { try { RtcOffer? offer = JsonSerializer.Deserialize(json); if (offer is null) return; await ServerAPI.PostOfferAsync(offer); } catch (Exception ex) { SafeSendRawToWebView(ex.Message); } } public async Task WriteRtcAnswer(string json) { try { RtcAnswer? answer = JsonSerializer.Deserialize(json); if (answer is null) return; await ServerAPI.PostAnswerAsync(answer); SafeSendRawToWebView("WriteRtcAnswer posted successfully"); } catch (Exception ex) { SafeSendRawToWebView("WriteRtcAnswer failed: " + ex.Message); } } public async Task WriteIceCandidate(string json) { try { DBIceCandidate? dbCandidate = JsonSerializer.Deserialize(json); if (dbCandidate is null) return; await ServerAPI.PostIceCandidateAsync(dbCandidate); } catch (Exception ex) { SafeSendRawToWebView("WriteIceCandidate failed: " + ex.Message); } } private async Task SendRtcOfferToJsAsync(string remoteUsername, RtcSessionDescription offer) { var remoteUsernameJson = JsonSerializer.Serialize(remoteUsername); var offerJson = JsonSerializer.Serialize(offer); await hybridWebView.EvaluateJavaScriptAsync($"window.handleRtcOffer({remoteUsernameJson}, {offerJson})"); } private async Task SendRtcAnswerToJsAsync(string remoteUsername, RtcSessionDescription answer) { var remoteUsernameJson = JsonSerializer.Serialize(remoteUsername); var answerJson = JsonSerializer.Serialize(answer); await hybridWebView.EvaluateJavaScriptAsync($"window.handleRtcAnswer({remoteUsernameJson}, {answerJson})"); } private async Task SendRtcCandidateToJsAsync(string remoteUsername, IceCandidate candidate) { var remoteUsernameJson = JsonSerializer.Serialize(remoteUsername); var candidateJson = JsonSerializer.Serialize(candidate); await hybridWebView.EvaluateJavaScriptAsync($"window.handleRtcCandidate({remoteUsernameJson}, {candidateJson})"); } public async void RtcLeaveCallback(string username) { try { var usernameJson = JsonSerializer.Serialize(username); await hybridWebView.EvaluateJavaScriptAsync($"window.handleRtcParticipantLeft({usernameJson})"); } catch (Exception ex) { SafeSendRawToWebView("RtcLeaveCallback failed: " + ex.Message); } } private async Task SendRtcSignalToJsAsync(string rawJson) { var jsArg = JsonSerializer.Serialize(rawJson); await hybridWebView.EvaluateJavaScriptAsync($"window.handleRtcSignal?.({jsArg})"); } private async Task PushRtcContextToJsAsync() { var usernameJson = JsonSerializer.Serialize(_username); var channelIdJson = JsonSerializer.Serialize(_currentChannelId); await hybridWebView.EvaluateJavaScriptAsync($"window.setUsername({usernameJson})"); await hybridWebView.EvaluateJavaScriptAsync($"window.setChannelId({channelIdJson})"); Console.WriteLine($"[{_username}] pushed RTC context into HybridWebView."); } //Remove? public void SendRtcSignal(string json) { if (string.IsNullOrWhiteSpace(_serverPublicKey)) { Console.WriteLine("Server public key not loaded yet."); return; } RtcSignalMessage? rtcSignal; try { rtcSignal = JsonSerializer.Deserialize(json); } catch (Exception ex) { Console.WriteLine($"Failed to parse RTC signal from JS: {ex.Message}"); return; } if (rtcSignal is null) return; var encrypted = E2EeHelper.EncryptForRecipient(json, _serverPublicKey); var payload = new SocketRtcSignalMessage { Type = SignalType.EncryptedSignal, SenderUsername = _username, ChannelId = rtcSignal.ChannelId, CipherText = encrypted.CipherText, Nonce = encrypted.Nonce, Tag = encrypted.Tag, EncryptedKey = encrypted.EncryptedKey }; _wsc.Send(JsonSerializer.Serialize(payload)); Console.WriteLine($"[{_username}] sent RTC signal: {rtcSignal.Type} -> {rtcSignal.ChannelId}"); } //Remove? #endregion private void OnSendMessageButtonClicked(object sender, EventArgs e) { SafeSendRawToWebView($"Hello from C#!"); } private async void OnHybridWebViewRawMessageReceived(object sender, HybridWebViewRawMessageReceivedEventArgs e) { if (e.Message == "rtc_page_ready") { await PushRtcContextToJsAsync(); return; } await DisplayAlertAsync("Raw Message Received", e.Message, "OK"); } private void SafeSendRawToWebView(string message) { MainThread.BeginInvokeOnMainThread(() => { try { hybridWebView.SendRawMessage(message); } catch (Exception ex) { Console.WriteLine($"[{_username}] failed to send raw message to HybridWebView: {ex.Message}"); } }); } [JsonSourceGenerationOptions(WriteIndented = false)] [JsonSerializable(typeof(RtcSessionDescription))] [JsonSerializable(typeof(IceCandidate))] [JsonSerializable(typeof(string))] internal partial class HybridJSType : JsonSerializerContext { // This type's attributes specify JSON serialization info to preserve type structure // for trimmed builds. } private static bool TryReadSignalType(JsonElement root, out SignalType type) { if (TryGetProperty(root, "type", out var typeElement)) { if (typeElement.ValueKind == JsonValueKind.String && Enum.TryParse(typeElement.GetString(), true, out SignalType parsedType)) { type = parsedType; return true; } if (typeElement.ValueKind == JsonValueKind.Number && typeElement.TryGetInt32(out var rawValue)) { type = (SignalType)rawValue; return true; } } type = default; return false; } private static bool TryGetProperty(JsonElement root, string propertyName, out JsonElement value) { foreach (var property in root.EnumerateObject()) { if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase)) { value = property.Value; return true; } } value = default; return false; } }