176 lines
8.5 KiB
C#
176 lines
8.5 KiB
C#
using UIKit;
|
|||
|
|
using VoiceCat.Audio;
|
||
|
|
using VoiceCat.Core;
|
||
|
|
using Voicecat.V1;
|
||
|
|
using Channel = Voicecat.V1.Channel;
|
||
|
|
|
||
|
|
namespace VoiceCat.iOS;
|
||
|
|
|
||
|
|
internal sealed record ChatEntry(DateTime Timestamp, string Sender, string Text, bool Private);
|
||
|
|
|
||
|
|
internal sealed class AppModel
|
||
|
|
{
|
||
|
|
internal static AppModel Shared { get; } = new();
|
||
|
|
private readonly IosStorage storage = new();
|
||
|
|
private readonly List<ServerProfile> profiles = [];
|
||
|
|
private readonly List<ChatEntry> messages = [];
|
||
|
|
private CancellationTokenSource? lifetime;
|
||
|
|
private VoiceCatClient? client;
|
||
|
|
private TaskCompletionSource<bool>? identityDecision;
|
||
|
|
private ServerProfile? connectedProfile;
|
||
|
|
private int reconnectAttempt;
|
||
|
|
private bool explicitDisconnect;
|
||
|
|
private uint microphoneStream;
|
||
|
|
private BroadcastAudioPump? broadcast;
|
||
|
|
|
||
|
|
internal event Action? Changed;
|
||
|
|
internal event Action<ServerIdentityChallenge>? IdentityRequested;
|
||
|
|
internal IReadOnlyList<ServerProfile> Profiles => profiles;
|
||
|
|
internal IReadOnlyList<ChatEntry> Messages => messages;
|
||
|
|
internal VoiceCatClient? Client => client;
|
||
|
|
internal bool IsConnected => client?.State == ClientConnectionState.Connected;
|
||
|
|
internal bool IsConnecting { get; private set; }
|
||
|
|
internal bool VoiceJoined => microphoneStream != 0;
|
||
|
|
internal string Status { get; private set; } = "Not connected";
|
||
|
|
internal uint CurrentChannelId { get; private set; }
|
||
|
|
internal uint SelfUserId => client?.Authentication?.Self.Id ?? 0;
|
||
|
|
internal IReadOnlyList<Channel> Channels => client?.Channels ?? [];
|
||
|
|
internal IReadOnlyList<User> Users => client?.Users ?? [];
|
||
|
|
|
||
|
|
private AppModel() { }
|
||
|
|
internal void Load() { profiles.Clear(); profiles.AddRange(storage.LoadProfiles()); Notify(); }
|
||
|
|
internal void Save() => storage.SaveProfiles(profiles);
|
||
|
|
|
||
|
|
internal void UpsertProfile(ServerProfile profile, string? password)
|
||
|
|
{
|
||
|
|
int index = profiles.FindIndex(item => item.Id == profile.Id);
|
||
|
|
if (index < 0) profiles.Add(profile); else profiles[index] = profile;
|
||
|
|
if (!string.IsNullOrEmpty(password)) storage.SavePassword(profile.Id, password);
|
||
|
|
Save(); Notify();
|
||
|
|
}
|
||
|
|
|
||
|
|
internal void RemoveProfile(ServerProfile profile)
|
||
|
|
{
|
||
|
|
storage.RemovePassword(profile.Id); profiles.RemoveAll(item => item.Id == profile.Id); Save(); Notify();
|
||
|
|
}
|
||
|
|
|
||
|
|
internal async Task ConnectAsync(ServerProfile profile, string? suppliedPassword = null, bool restoring = false)
|
||
|
|
{
|
||
|
|
if (IsConnecting || IsConnected) return;
|
||
|
|
explicitDisconnect = false; IsConnecting = true; connectedProfile = profile;
|
||
|
|
Status = restoring ? "Reconnecting…" : "Connecting…"; Notify();
|
||
|
|
lifetime?.Cancel(); lifetime?.Dispose(); lifetime = new();
|
||
|
|
VoiceCatClient next = new("VoiceCat-iOS", "0.0.1", storage.TofuPath);
|
||
|
|
client = next;
|
||
|
|
try
|
||
|
|
{
|
||
|
|
await next.ConnectAsync(profile.Host, profile.Port, ConfirmIdentityAsync, lifetime.Token);
|
||
|
|
AuthResult auth = profile.Authentication == ServerAuthentication.Guest
|
||
|
|
? await next.AuthenticateGuestAsync(profile.Nickname ?? "iOS User", lifetime.Token)
|
||
|
|
: await next.AuthenticateUserAsync(profile.Username!, suppliedPassword ?? storage.LoadPassword(profile) ?? "", lifetime.Token);
|
||
|
|
if (!auth.Ok) throw new InvalidOperationException(auth.Error);
|
||
|
|
CurrentChannelId = auth.Self.ChannelId; reconnectAttempt = 0; IsConnecting = false; Status = "Connected";
|
||
|
|
IosAudioEngine.Shared.StartListening(next);
|
||
|
|
broadcast = new(); broadcast.Start(next);
|
||
|
|
_ = PumpEventsAsync(next, lifetime.Token);
|
||
|
|
Notify();
|
||
|
|
}
|
||
|
|
catch (Exception exception)
|
||
|
|
{
|
||
|
|
IsConnecting = false; Status = exception.Message; Notify();
|
||
|
|
await next.DisposeAsync(); if (ReferenceEquals(client, next)) client = null;
|
||
|
|
if (restoring && !explicitDisconnect) ScheduleReconnect();
|
||
|
|
else throw;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private ValueTask<bool> ConfirmIdentityAsync(ServerIdentityChallenge challenge, CancellationToken token)
|
||
|
|
{
|
||
|
|
identityDecision = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||
|
|
token.Register(() => identityDecision.TrySetCanceled(token));
|
||
|
|
UIApplication.SharedApplication.BeginInvokeOnMainThread(() => IdentityRequested?.Invoke(challenge));
|
||
|
|
return new(identityDecision.Task);
|
||
|
|
}
|
||
|
|
|
||
|
|
internal void ResolveIdentity(bool accepted) { identityDecision?.TrySetResult(accepted); identityDecision = null; }
|
||
|
|
|
||
|
|
private async Task PumpEventsAsync(VoiceCatClient owner, CancellationToken token)
|
||
|
|
{
|
||
|
|
try
|
||
|
|
{
|
||
|
|
await foreach (Envelope envelope in owner.ReadEventsAsync(token))
|
||
|
|
{
|
||
|
|
if (envelope.JoinChannelResult?.Ok == true) CurrentChannelId = envelope.JoinChannelResult.ChannelId;
|
||
|
|
if (envelope.TextMessage is { } text)
|
||
|
|
{
|
||
|
|
User? sender = owner.Users.FirstOrDefault(user => user.Id == text.SenderId);
|
||
|
|
messages.Add(new(DateTime.Now, sender?.Nickname ?? $"User {text.SenderId}", text.Body, text.Scope == TextScope.TextPrivate));
|
||
|
|
if (messages.Count > 500) messages.RemoveAt(0);
|
||
|
|
}
|
||
|
|
UIApplication.SharedApplication.BeginInvokeOnMainThread(Notify);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (OperationCanceledException) { }
|
||
|
|
finally
|
||
|
|
{
|
||
|
|
if (ReferenceEquals(client, owner) && !explicitDisconnect)
|
||
|
|
{
|
||
|
|
IosAudioEngine.Shared.Stop(); client = null; Status = "Connection lost"; UIApplication.SharedApplication.BeginInvokeOnMainThread(Notify);
|
||
|
|
ScheduleReconnect();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
internal async Task JoinChannelAsync(uint channelId, string password = "")
|
||
|
|
{
|
||
|
|
VoiceCatClient active = client ?? throw new InvalidOperationException("Not connected.");
|
||
|
|
Envelope response = await active.RequestAsync(new() { JoinChannel = new() { ChannelId = channelId, Password = password } });
|
||
|
|
if (response.JoinChannelResult?.Ok != true) throw new InvalidOperationException(response.JoinChannelResult?.Error ?? "Join failed.");
|
||
|
|
CurrentChannelId = channelId; Notify();
|
||
|
|
}
|
||
|
|
|
||
|
|
internal void SendText(string body, uint targetUser = 0)
|
||
|
|
{
|
||
|
|
if (string.IsNullOrWhiteSpace(body)) return;
|
||
|
|
(client ?? throw new InvalidOperationException("Not connected.")).Send(new()
|
||
|
|
{
|
||
|
|
TextMessage = new() { Scope = targetUser == 0 ? TextScope.TextChannel : TextScope.TextPrivate,
|
||
|
|
TargetId = targetUser == 0 ? CurrentChannelId : targetUser, Body = body.Trim(), ClientMsgId = Guid.NewGuid().ToString("N") }
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
internal async Task ToggleVoiceAsync()
|
||
|
|
{
|
||
|
|
VoiceCatClient active = client ?? throw new InvalidOperationException("Not connected.");
|
||
|
|
if (microphoneStream != 0)
|
||
|
|
{
|
||
|
|
IosAudioEngine.Shared.StopMicrophone(); active.StopStream(microphoneStream); microphoneStream = 0;
|
||
|
|
await active.SubscribeVoiceAsync(false); Notify(); return;
|
||
|
|
}
|
||
|
|
if (AVFoundation.AVCaptureDevice.GetAuthorizationStatus(AVFoundation.AVAuthorizationMediaType.Audio) == AVFoundation.AVAuthorizationStatus.NotDetermined)
|
||
|
|
await AVFoundation.AVCaptureDevice.RequestAccessForMediaTypeAsync(AVFoundation.AVAuthorizationMediaType.Audio);
|
||
|
|
VoiceSubscriptionResult subscribed = await active.SubscribeVoiceAsync();
|
||
|
|
if (!subscribed.Ok) throw new InvalidOperationException(subscribed.Error);
|
||
|
|
int channels = IosAudioRouter.Shared.CaptureChannels;
|
||
|
|
StreamInfo stream = await active.StartStreamAsync(StreamKind.StreamMic, "Microphone", channels);
|
||
|
|
microphoneStream = stream.StreamId; IosAudioEngine.Shared.StartMicrophone(stream.StreamId, channels); Notify();
|
||
|
|
}
|
||
|
|
|
||
|
|
internal async Task DisconnectAsync()
|
||
|
|
{
|
||
|
|
explicitDisconnect = true; lifetime?.Cancel(); IosAudioEngine.Shared.Stop();
|
||
|
|
if (broadcast is { } pump) { broadcast = null; await pump.DisposeAsync(); }
|
||
|
|
VoiceCatClient? old = client; client = null; microphoneStream = 0; IsConnecting = false; Status = "Not connected"; Notify();
|
||
|
|
if (old is not null) await old.DisposeAsync();
|
||
|
|
}
|
||
|
|
|
||
|
|
private void ScheduleReconnect()
|
||
|
|
{
|
||
|
|
ServerProfile? profile = connectedProfile; if (profile is null || explicitDisconnect) return;
|
||
|
|
int delay = Math.Min(1 << Math.Min(reconnectAttempt++, 5), 30);
|
||
|
|
_ = Task.Run(async () => { try { await Task.Delay(TimeSpan.FromSeconds(delay), lifetime?.Token ?? default); await ConnectAsync(profile, restoring: true); } catch { } });
|
||
|
|
}
|
||
|
|
|
||
|
|
private void Notify() => Changed?.Invoke();
|
||
|
|
}
|