Files
voice-cat/clients/apple/VoiceCat.iOS/AppModel.cs
T

334 lines
17 KiB
C#
Raw Normal View History

2026-09-19 15:43:37 +02:00
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);
2026-09-19 19:33:10 +02:00
internal sealed record ActivityEntry(DateTime Timestamp, string Text);
2026-09-19 15:43:37 +02:00
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 = [];
2026-09-19 19:33:10 +02:00
private readonly List<ActivityEntry> activity = [];
private readonly IosSettings settings = new();
private readonly EventFeedback feedback;
2026-09-19 15:43:37 +02:00
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;
2026-09-19 19:33:10 +02:00
private Timer? levelTimer;
private bool lastTalking;
private bool restoringVoice;
private uint restoreChannel;
private bool restoreMuted;
private bool restoreDeafened;
2026-09-19 20:09:00 +02:00
private bool backgrounded;
2026-09-21 14:14:15 +02:00
private int diagnosticPolls;
2026-09-19 15:43:37 +02:00
internal event Action? Changed;
internal event Action<ServerIdentityChallenge>? IdentityRequested;
internal IReadOnlyList<ServerProfile> Profiles => profiles;
internal IReadOnlyList<ChatEntry> Messages => messages;
2026-09-19 19:33:10 +02:00
internal IReadOnlyList<ActivityEntry> Activity => activity;
internal IosSettings Settings => settings;
2026-09-19 15:43:37 +02:00
internal VoiceCatClient? Client => client;
internal bool IsConnected => client?.State == ClientConnectionState.Connected;
internal bool IsConnecting { get; private set; }
internal bool VoiceJoined => microphoneStream != 0;
2026-09-19 19:33:10 +02:00
internal bool ScreenSharing => broadcast?.IsActive == true;
2026-09-19 20:09:00 +02:00
internal bool IsBackgrounded => backgrounded;
2026-09-19 19:33:10 +02:00
internal float MicrophoneLevel { get; private set; }
2026-09-19 15:43:37 +02:00
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 ?? [];
2026-09-19 19:33:10 +02:00
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(); }
2026-09-19 15:43:37 +02:00
2026-09-19 20:09:00 +02:00
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();
}
2026-09-19 15:43:37 +02:00
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);
2026-09-20 03:03:07 +02:00
next.ConnectionStateChanged += state =>
{
if (state == ClientConnectionState.Disconnected && next.ConnectionFailure is { } failure)
Console.Error.WriteLine($"VoiceCat control connection failed: {failure}");
};
2026-09-19 15:43:37 +02:00
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";
2026-09-19 19:33:10 +02:00
ApplyAudioSettings(next);
2026-09-19 15:43:37 +02:00
IosAudioEngine.Shared.StartListening(next);
2026-09-19 19:33:10 +02:00
broadcast = new(); broadcast.Changed += BroadcastChanged; broadcast.Start(next);
2026-09-19 15:43:37 +02:00
_ = PumpEventsAsync(next, lifetime.Token);
2026-09-19 19:33:10 +02:00
levelTimer?.Dispose(); levelTimer = new(_ => PollAudio(), null, 50, 50);
feedback.Play(SoundEvent.Login); feedback.Speak(restoring ? "Reconnected" : "Connected");
if (restoring && restoreChannel != 0) await RestoreSessionAsync(next);
2026-09-19 15:43:37 +02:00
Notify();
}
catch (Exception exception)
{
2026-09-20 03:03:07 +02:00
System.Diagnostics.Debug.WriteLine($"Connection failed: {exception}");
IsConnecting = false; Status = exception.Message;
2026-09-19 15:43:37 +02:00
await next.DisposeAsync(); if (ReferenceEquals(client, next)) client = null;
2026-09-20 03:03:07 +02:00
Notify();
2026-09-19 15:43:37 +02:00
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;
2026-09-19 19:33:10 +02:00
if (envelope.JoinChannelResult is { Ok: false } joinFailure) AddActivity("Join failed: " + joinFailure.Error);
2026-09-19 15:43:37 +02:00
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);
2026-09-19 19:33:10 +02:00
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}");
2026-09-19 15:43:37 +02:00
}
2026-09-19 19:33:10 +02:00
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);
2026-09-19 15:43:37 +02:00
UIApplication.SharedApplication.BeginInvokeOnMainThread(Notify);
}
}
catch (OperationCanceledException) { }
finally
{
if (ReferenceEquals(client, owner) && !explicitDisconnect)
{
2026-09-19 19:33:10 +02:00
CaptureRestoreState(owner); await StopSessionResourcesAsync(); client = null; Status = "Connection lost";
feedback.Play(SoundEvent.ConnectionLost); feedback.Speak("Connection lost, reconnecting"); UIApplication.SharedApplication.BeginInvokeOnMainThread(Notify);
2026-09-19 15:43:37 +02:00
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;
2026-09-19 19:33:10 +02:00
await active.SubscribeVoiceAsync(false); MicrophoneLevel = 0; feedback.Play(SoundEvent.VoiceOff); Notify(); return;
2026-09-19 15:43:37 +02:00
}
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);
2026-09-19 19:33:10 +02:00
microphoneStream = stream.StreamId; IosAudioEngine.Shared.StartMicrophone(stream.StreamId, channels); feedback.Play(SoundEvent.VoiceOn); Notify();
}
2026-09-19 20:09:00 +02:00
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();
}
}
2026-09-19 19:33:10 +02:00
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<GenericResult> RunAdminAsync(Func<VoiceCatClient, Task<GenericResult>> 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;
2026-09-19 15:43:37 +02:00
}
internal async Task DisconnectAsync()
{
2026-09-19 19:33:10 +02:00
explicitDisconnect = true; lifetime?.Cancel(); await StopSessionResourcesAsync();
2026-09-19 15:43:37 +02:00
VoiceCatClient? old = client; client = null; microphoneStream = 0; IsConnecting = false; Status = "Not connected"; Notify();
2026-09-19 19:33:10 +02:00
if (old is not null) await old.DisposeAsync(); feedback.Play(SoundEvent.Logout); feedback.Speak("Disconnected");
2026-09-19 15:43:37 +02:00
}
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();
2026-09-19 19:33:10 +02:00
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;
owner.Audio.DeviceBufferMilliseconds = settings.AudioBufferMilliseconds;
IosAudioEngine.Shared.BufferMilliseconds = settings.AudioBufferMilliseconds;
2026-09-19 19:33:10 +02:00
}
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;
2026-09-21 14:14:15 +02:00
if (++diagnosticPolls >= 20)
{
diagnosticPolls = 0;
LocalAudioDiagnostics audio = owner.Audio.GetLocalDiagnostics(stream);
Console.Error.WriteLine($"VC_AUDIO {IosAudioEngine.Shared.CaptureDiagnostics()} cycles={audio.Cycles} starved={audio.StarvedCycles} " +
$"encoded={audio.EncodedPackets} packetDrops={audio.RejectedPackets} queued={audio.BufferedFrames}");
}
2026-09-19 19:33:10 +02:00
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}";
2026-09-21 14:14:15 +02:00
private void BroadcastChanged()
{
UIApplication.SharedApplication.BeginInvokeOnMainThread(() =>
{
// Presenting either system picker can replace or interrupt the app's audio session.
// Rebuild after the producer becomes active so playback and an existing mic tap are
// attached to the session that will remain in use for the broadcast.
if (ScreenSharing) IosAudioRouter.Shared.Recover("screen sharing started");
Notify();
});
}
2026-09-19 19:33:10 +02:00
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(); }
}
2026-09-19 15:43:37 +02:00
}