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 profiles = []; private readonly List messages = []; private CancellationTokenSource? lifetime; private VoiceCatClient? client; private TaskCompletionSource? identityDecision; private ServerProfile? connectedProfile; private int reconnectAttempt; private bool explicitDisconnect; private uint microphoneStream; private BroadcastAudioPump? broadcast; internal event Action? Changed; internal event Action? IdentityRequested; internal IReadOnlyList Profiles => profiles; internal IReadOnlyList 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 Channels => client?.Channels ?? []; internal IReadOnlyList 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 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(); }