Bring managed iOS client to feature parity
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
This commit is contained in:
@@ -7,6 +7,7 @@ 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
|
||||
{
|
||||
@@ -14,6 +15,9 @@ internal sealed class AppModel
|
||||
private readonly IosStorage storage = new();
|
||||
private readonly List<ServerProfile> profiles = [];
|
||||
private readonly List<ChatEntry> messages = [];
|
||||
private readonly List<ActivityEntry> activity = [];
|
||||
private readonly IosSettings settings = new();
|
||||
private readonly EventFeedback feedback;
|
||||
private CancellationTokenSource? lifetime;
|
||||
private VoiceCatClient? client;
|
||||
private TaskCompletionSource<bool>? identityDecision;
|
||||
@@ -22,24 +26,34 @@ internal sealed class AppModel
|
||||
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;
|
||||
|
||||
internal event Action? Changed;
|
||||
internal event Action<ServerIdentityChallenge>? IdentityRequested;
|
||||
internal IReadOnlyList<ServerProfile> Profiles => profiles;
|
||||
internal IReadOnlyList<ChatEntry> Messages => messages;
|
||||
internal IReadOnlyList<ActivityEntry> 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 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<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);
|
||||
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 UpsertProfile(ServerProfile profile, string? password)
|
||||
{
|
||||
@@ -70,9 +84,13 @@ internal sealed class AppModel
|
||||
: 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.Start(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)
|
||||
@@ -101,12 +119,23 @@ internal sealed class AppModel
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -115,7 +144,8 @@ internal sealed class AppModel
|
||||
{
|
||||
if (ReferenceEquals(client, owner) && !explicitDisconnect)
|
||||
{
|
||||
IosAudioEngine.Shared.Stop(); client = null; Status = "Connection lost"; UIApplication.SharedApplication.BeginInvokeOnMainThread(Notify);
|
||||
CaptureRestoreState(owner); await StopSessionResourcesAsync(); client = null; Status = "Connection lost";
|
||||
feedback.Play(SoundEvent.ConnectionLost); feedback.Speak("Connection lost, reconnecting"); UIApplication.SharedApplication.BeginInvokeOnMainThread(Notify);
|
||||
ScheduleReconnect();
|
||||
}
|
||||
}
|
||||
@@ -145,7 +175,7 @@ internal sealed class AppModel
|
||||
if (microphoneStream != 0)
|
||||
{
|
||||
IosAudioEngine.Shared.StopMicrophone(); active.StopStream(microphoneStream); microphoneStream = 0;
|
||||
await active.SubscribeVoiceAsync(false); Notify(); return;
|
||||
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);
|
||||
@@ -153,15 +183,32 @@ internal sealed class AppModel
|
||||
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();
|
||||
microphoneStream = stream.StreamId; IosAudioEngine.Shared.StartMicrophone(stream.StreamId, channels); feedback.Play(SoundEvent.VoiceOn); Notify();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
internal async Task DisconnectAsync()
|
||||
{
|
||||
explicitDisconnect = true; lifetime?.Cancel(); IosAudioEngine.Shared.Stop();
|
||||
if (broadcast is { } pump) { broadcast = null; await pump.DisposeAsync(); }
|
||||
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();
|
||||
if (old is not null) await old.DisposeAsync(); feedback.Play(SoundEvent.Logout); feedback.Speak("Disconnected");
|
||||
}
|
||||
|
||||
private void ScheduleReconnect()
|
||||
@@ -172,4 +219,54 @@ internal sealed class AppModel
|
||||
}
|
||||
|
||||
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(); }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user