Files
Relay/RelayClient/MainPage.xaml.cs

691 lines
23 KiB
C#

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<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 = 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<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 == SignalType.ServerPublicKey)
{
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 == SignalType.EncryptedSignal)
{
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 == SignalType.OfferUpdated || type == SignalType.AnswerUpdated || type == SignalType.CandidateAdded || type == SignalType.CallLeft)
{
var rtcNotification = JsonSerializer.Deserialize<RtcNotificationMessage>(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<IceCandidate>(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<SocketEncryptedMessage>(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<string> 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<RtcOffer>(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<RtcAnswer>(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<DBIceCandidate>(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<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 = 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;
}
}