Add managed UIKit iOS client
.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:
@@ -0,0 +1,175 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user