649 lines
20 KiB
C#
649 lines
20 KiB
C#
using RelayClient.Crypto;
|
|
using RelayClient.Models;
|
|
using WebSocketSharp;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using System.Text.Json.Serialization.Metadata;
|
|
|
|
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<string, List<ChatMessage>> _messagesByChannel = new();
|
|
private readonly List<ChannelItem> _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 = "client_encrypted_chat",
|
|
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 (!root.TryGetProperty("Type", out var typeElement))
|
|
return;
|
|
|
|
var type = typeElement.GetString();
|
|
|
|
if (type == "channel_list")
|
|
{
|
|
var channelList = JsonSerializer.Deserialize<SocketChannelList>(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 == "server_public_key")
|
|
{
|
|
var serverKeyMessage = JsonSerializer.Deserialize<ServerPublicKeyMessage>(e.Data);
|
|
if (serverKeyMessage is not null)
|
|
{
|
|
_serverPublicKey = serverKeyMessage.PublicKey;
|
|
Console.WriteLine($"[{_username}] loaded server public key.");
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (type == "encrypted_rtc_signal")
|
|
{
|
|
var payload = JsonSerializer.Deserialize<SocketRtcSignalMessage>(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 == "rtc_offer_updated" || type == "rtc_answer_updated" || type == "rtc_candidate_added" || type == "rtc_call_left")
|
|
{
|
|
var rtcNotification = JsonSerializer.Deserialize<RtcNotificationMessage>(e.Data);
|
|
if (rtcNotification is null)
|
|
return;
|
|
|
|
var notificationType = rtcNotification.Type ?? string.Empty;
|
|
var notificationChannelId = rtcNotification.ChannelId ?? string.Empty;
|
|
|
|
if (notificationChannelId != _currentChannelId)
|
|
return;
|
|
|
|
SafeSendRawToWebView("RTC notification received: " + notificationType + " for " + notificationChannelId);
|
|
|
|
MainThread.BeginInvokeOnMainThread(async () =>
|
|
{
|
|
switch (notificationType)
|
|
{
|
|
case "rtc_offer_updated":
|
|
{
|
|
var offer = await GetRtcOffer();
|
|
await SendRtcSignalToJsAsync(offer);
|
|
break;
|
|
}
|
|
case "rtc_answer_updated":
|
|
{
|
|
var answer = await ServerAPI.GetAnswerForChannelAsync(_currentChannelId);
|
|
if (answer is not null)
|
|
{
|
|
await AnswerCallback(answer);
|
|
}
|
|
break;
|
|
}
|
|
case "rtc_candidate_added":
|
|
{
|
|
try
|
|
{
|
|
IceCandidate? iceCandidate = JsonSerializer.Deserialize<IceCandidate>(rtcNotification.Direction);
|
|
IceCandidateCallback(iceCandidate);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
SafeSendRawToWebView($"Candidate rejected: {ex.Message}");
|
|
}
|
|
|
|
break;
|
|
}
|
|
case "rtc_call_left":
|
|
{
|
|
SafeSendRawToWebView("RTC call left notification received.");
|
|
RtcLeaveCallback();
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
if (type != "encrypted_chat")
|
|
return;
|
|
|
|
var pyload = JsonSerializer.Deserialize<SocketEncryptedMessage>(e.Data);
|
|
if (pyload is null)
|
|
return;
|
|
|
|
if (pyload.RecipientUsername == _username)
|
|
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<bool> JoinRtcChannel()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(_currentChannelId))
|
|
return false;
|
|
|
|
_wsc.Send($"RTC_JOIN_CHANNEL|{_username}|{_currentChannelId}");
|
|
|
|
SafeSendRawToWebView($"Attempting to join RTC Channel {_currentChannelName} | {_currentChannelId} ");
|
|
|
|
bool active = await ServerAPI.GetIsChannelActiveAsync(_currentChannelId);
|
|
|
|
// SafeSendRawToWebView($"Rtc Channel {_currentChannelName} | {_currentChannelId} is active: {active}");
|
|
|
|
return active;
|
|
}
|
|
|
|
public void LeaveRtcChannel()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(_currentChannelId))
|
|
return;
|
|
|
|
_wsc.Send($"RTC_LEAVE_CHANNEL|{_username}|{_currentChannelId}");
|
|
}
|
|
|
|
public async void WriteRtcOffer(string json)
|
|
{
|
|
try
|
|
{
|
|
RtcDescription? description = JsonSerializer.Deserialize<RtcDescription>(json);
|
|
DBOffer offer = new DBOffer
|
|
{
|
|
ChannelId = _currentChannelId,
|
|
Username = _username,
|
|
SessionDescription = description
|
|
};
|
|
await ServerAPI.PostOfferAsync(offer);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
SafeSendRawToWebView(ex.Message);
|
|
}
|
|
|
|
}
|
|
public async Task<string> GetRtcOffer()
|
|
{
|
|
RtcDescription? offer = await ServerAPI.GetOffersForChannelAsync(_currentChannelId);
|
|
return JsonSerializer.Serialize(offer);
|
|
}
|
|
|
|
public async void WriteRtcAnswer(string json)
|
|
{
|
|
// SafeSendRawToWebView("WriteRtcAnswer entered with: " + json);
|
|
|
|
try
|
|
{
|
|
RtcDescription? description = JsonSerializer.Deserialize<RtcDescription>(json);
|
|
DBOffer answer = new DBOffer
|
|
{
|
|
ChannelId = _currentChannelId,
|
|
Username = _username,
|
|
SessionDescription = description
|
|
};
|
|
await ServerAPI.PostAnswerAsync(answer);
|
|
SafeSendRawToWebView("WriteRtcAnswer posted successfully");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
SafeSendRawToWebView("WriteRtcAnswer failed: " + ex.Message);
|
|
}
|
|
}
|
|
|
|
public async void WriteIceCandidate(string json)
|
|
{
|
|
try
|
|
{
|
|
IceCandidate? candidate = JsonSerializer.Deserialize<IceCandidate>(json);
|
|
DBIceCandidate DBCandidate = new DBIceCandidate
|
|
{
|
|
ChannelId = _currentChannelId,
|
|
Username = _username,
|
|
Candidate = candidate
|
|
};
|
|
if (candidate == null) return;
|
|
await ServerAPI.PostIceCandidateAsync(DBCandidate);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
SafeSendRawToWebView("WriteIceCandidate failed: " + ex.Message);
|
|
}
|
|
}
|
|
|
|
public async void IceCandidateCallback(IceCandidate candidate)
|
|
{
|
|
try
|
|
{
|
|
await hybridWebView.InvokeJavaScriptAsync("IceCandidateAdded", [candidate], [HybridJSType.Default.IceCandidate]);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
SafeSendRawToWebView("WriteIceCandidate failed: " + ex.Message);
|
|
}
|
|
}
|
|
public async Task AnswerCallback(RtcDescription answer)
|
|
{
|
|
answer.sdp = answer.sdp.Replace("\r\n", "(rn)");
|
|
try
|
|
{
|
|
await hybridWebView.InvokeJavaScriptAsync("AnswerCallbackJS", [answer], [HybridJSType.Default.RtcDescription]);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
SafeSendRawToWebView("AnswerCallback failed: " + ex.Message);
|
|
}
|
|
}
|
|
|
|
public async void RtcLeaveCallback()
|
|
{
|
|
try
|
|
{
|
|
await hybridWebView.InvokeJavaScriptAsync("RtcLeaveCall", [], []);
|
|
}
|
|
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})");
|
|
} //Remove?
|
|
|
|
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<RtcSignalMessage>(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 = "encrypted_rtc_signal",
|
|
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(RtcDescription))]
|
|
[JsonSerializable(typeof(List<RtcSignalMessage>))]
|
|
[JsonSerializable(typeof(IceCandidate))]
|
|
[JsonSerializable(typeof(List<IceCandidate>))]
|
|
[JsonSerializable(typeof(string))]
|
|
internal partial class HybridJSType : JsonSerializerContext
|
|
{
|
|
// This type's attributes specify JSON serialization info to preserve type structure
|
|
// for trimmed builds.
|
|
}
|
|
|
|
} |