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 record ActivityEntry(DateTime Timestamp, string Text); internal sealed class AppModel { internal static AppModel Shared { get; } = new(); private readonly IosStorage storage = new(); private readonly List profiles = []; private readonly List messages = []; private readonly List activity = []; private readonly IosSettings settings = new(); private readonly EventFeedback feedback; private CancellationTokenSource? lifetime; private VoiceCatClient? client; private TaskCompletionSource? identityDecision; private ServerProfile? connectedProfile; private int reconnectAttempt; private bool explicitDisconnect; private uint microphoneStream; private BroadcastAudioPump? broadcast; private Timer? levelTimer; private bool lastTalking; private bool restoringVoice; private uint restoreChannel; private bool restoreMuted; private bool restoreDeafened; private bool backgrounded; internal event Action? Changed; internal event Action? IdentityRequested; internal IReadOnlyList Profiles => profiles; internal IReadOnlyList Messages => messages; internal IReadOnlyList Activity => activity; internal IosSettings Settings => settings; internal VoiceCatClient? Client => client; internal bool IsConnected => client?.State == ClientConnectionState.Connected; internal bool IsConnecting { get; private set; } internal bool VoiceJoined => microphoneStream != 0; internal bool ScreenSharing => broadcast?.IsActive == true; internal bool IsBackgrounded => backgrounded; internal float MicrophoneLevel { get; private set; } 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() { feedback = new(settings); } internal void Load() { profiles.Clear(); profiles.AddRange(storage.LoadProfiles()); settings.Load(); IosAudioRouter.Shared.Load(); Notify(); } internal void Save() { storage.SaveProfiles(profiles); settings.Save(); } internal void DidEnterBackground() { backgrounded = true; Save(); // Do not stop or rebuild AVAudioEngine here. With the `audio` background mode and an // active PlayAndRecord session, capture, playback, media UDP, and screen-ring draining // remain live while the scene is backgrounded or the device is locked. } internal void WillEnterForeground() { backgrounded = false; IosAudioRouter.Shared.Recover("foreground"); } internal void DidBecomeActive() { backgrounded = false; IosAudioRouter.Shared.EnsureAudio("active scene"); Notify(); } 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"; ApplyAudioSettings(next); IosAudioEngine.Shared.StartListening(next); broadcast = new(); broadcast.Changed += BroadcastChanged; broadcast.Start(next); _ = PumpEventsAsync(next, lifetime.Token); levelTimer?.Dispose(); levelTimer = new(_ => PollAudio(), null, 50, 50); feedback.Play(SoundEvent.Login); feedback.Speak(restoring ? "Reconnected" : "Connected"); if (restoring && restoreChannel != 0) await RestoreSessionAsync(next); 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.JoinChannelResult is { Ok: false } joinFailure) AddActivity("Join failed: " + joinFailure.Error); 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); bool self = text.SenderId == SelfUserId; feedback.Play(text.Scope == TextScope.TextPrivate ? self ? SoundEvent.PrivateSent : SoundEvent.PrivateReceived : self ? SoundEvent.ChannelSent : SoundEvent.ChannelReceived); if (!self) feedback.Speak(text.Scope == TextScope.TextPrivate ? $"Private message from {sender?.Nickname}: {text.Body}" : $"{sender?.Nickname}: {text.Body}"); } if (envelope.UserEvent is { } userEvent) HandleUserEvent(owner, userEvent); if (envelope.StreamState is { } streamState) AddActivity($"{Name(owner, streamState.UserId)} {(streamState.Talking ? "started" : "stopped")} talking"); if (envelope.StreamAnnounceResult?.Ok == false) AddActivity("Stream failed: " + envelope.StreamAnnounceResult.Error); if (envelope.GenericResult is { Ok: false } result) AddActivity("Operation failed: " + result.Message); if (envelope.Disconnect is { } disconnected) AddActivity("Disconnected: " + disconnected.Reason); UIApplication.SharedApplication.BeginInvokeOnMainThread(Notify); } } catch (OperationCanceledException) { } finally { if (ReferenceEquals(client, owner) && !explicitDisconnect) { CaptureRestoreState(owner); await StopSessionResourcesAsync(); client = null; Status = "Connection lost"; feedback.Play(SoundEvent.ConnectionLost); feedback.Speak("Connection lost, reconnecting"); 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); MicrophoneLevel = 0; feedback.Play(SoundEvent.VoiceOff); 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); feedback.Play(SoundEvent.VoiceOn); Notify(); } internal void ToggleScreenAudio() { if (!OperatingSystem.IsIOSVersionAtLeast(27)) throw new PlatformNotSupportedException("Use the ReplayKit broadcast picker on this iOS version."); if (ScreenSharing) { IosScreenCapture.Stop(); broadcast?.RequestStop(); } else { if (!IsConnected || CurrentChannelId == 0) throw new InvalidOperationException("Connect and join a channel before sharing screen audio."); IosScreenCapture.Present(); } } internal void SetSelfAudio(bool muted, bool deafened) { client?.SetSelfAudioState(muted, deafened); Notify(); } internal void SetPushToTalk(bool active) { if (client is null) return; client.Audio.PushToTalk = active; if (active) feedback.Play(SoundEvent.PushToTalk); Notify(); } internal void ApplyVoiceSettings() { if (client is { } active) ApplyAudioSettings(active); settings.Save(); Notify(); } internal async Task RunAdminAsync(Func> action) { GenericResult result = await action(client ?? throw new InvalidOperationException("Not connected.")); AddActivity(result.Ok ? (string.IsNullOrEmpty(result.Message) ? "Operation completed." : result.Message) : "Operation failed: " + result.Message); Notify(); return result; } internal async Task DisconnectAsync() { explicitDisconnect = true; lifetime?.Cancel(); await StopSessionResourcesAsync(); VoiceCatClient? old = client; client = null; microphoneStream = 0; IsConnecting = false; Status = "Not connected"; Notify(); if (old is not null) await old.DisposeAsync(); feedback.Play(SoundEvent.Logout); feedback.Speak("Disconnected"); } 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(); private void ApplyAudioSettings(VoiceCatClient owner) { owner.Audio.InputMode = settings.InputMode; owner.Audio.VadThreshold = settings.VadThreshold; owner.Audio.InputGain = settings.InputGain; owner.Audio.OutputGain = settings.OutputGain; owner.Audio.InputNoiseReduction = settings.InputNoiseReduction; } private void PollAudio() { VoiceCatClient? owner = client; uint stream = microphoneStream; if (owner is null || stream == 0) return; (float level, bool talking) = owner.Audio.GetLocalLevel(stream); MicrophoneLevel = level; if (talking != lastTalking) { lastTalking = talking; owner.PublishStreamState(stream, talking); feedback.Play(talking ? SoundEvent.VoiceStart : SoundEvent.VoiceStop); } UIApplication.SharedApplication.BeginInvokeOnMainThread(Notify); } private void HandleUserEvent(VoiceCatClient owner, UserEvent value) { string name = value.User?.Nickname ?? Name(owner, value.LeftId); if (value.Kind == UserEvent.Types.Kind.Joined && value.User?.ChannelId == CurrentChannelId && value.User.Id != SelfUserId) { AddActivity($"{name} joined"); feedback.Play(SoundEvent.ChannelJoin); feedback.Speak($"{name} joined"); } else if (value.Kind == UserEvent.Types.Kind.Left) { AddActivity($"{name} left"); feedback.Play(SoundEvent.ChannelLeave); feedback.Speak($"{name} left"); } User? self = owner.Users.FirstOrDefault(user => user.Id == SelfUserId); if (self is not null) CurrentChannelId = self.ChannelId; } private void AddActivity(string text) { activity.Add(new(DateTime.Now, text)); if (activity.Count > 500) activity.RemoveAt(0); } private static string Name(VoiceCatClient owner, uint id) => owner.Users.FirstOrDefault(user => user.Id == id)?.Nickname ?? $"User {id}"; private void BroadcastChanged() => UIApplication.SharedApplication.BeginInvokeOnMainThread(Notify); private void CaptureRestoreState(VoiceCatClient owner) { restoreChannel = CurrentChannelId; restoringVoice = microphoneStream != 0; restoreMuted = owner.Audio.MicMuted; restoreDeafened = owner.Audio.Deafened; } private async Task RestoreSessionAsync(VoiceCatClient owner) { Envelope joined = await owner.RequestAsync(new() { JoinChannel = new() { ChannelId = restoreChannel } }); if (joined.JoinChannelResult?.Ok != true) { AddActivity("Could not restore the previous channel."); return; } CurrentChannelId = restoreChannel; if (restoringVoice) await ToggleVoiceAsync(); owner.SetSelfAudioState(restoreMuted, restoreDeafened); AddActivity($"Restored to channel {restoreChannel}{(restoringVoice ? " with voice" : "")}"); } private async Task StopSessionResourcesAsync() { levelTimer?.Dispose(); levelTimer = null; IosAudioEngine.Shared.Stop(); microphoneStream = 0; MicrophoneLevel = 0; if (broadcast is { } pump) { broadcast = null; pump.Changed -= BroadcastChanged; await pump.DisposeAsync(); } } }