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

This commit is contained in:
2026-09-19 19:33:10 +02:00
parent c6715028c1
commit 9fc5598e8e
30 changed files with 959 additions and 144 deletions
+11 -6
View File
@@ -10,7 +10,7 @@ up instantly. Newest status at the top.
## ▶ Where we left off / next action
- **In progress (2026-09-19): managed iOS/UIKit replacement.** Chose native UIKit over MAUI
- **In progress (2026-09-19): managed iOS/UIKit replacement at feature parity.** Chose native UIKit over MAUI
to preserve direct AVAudioSession/AVAudioEngine control and native VoiceOver semantics.
Added the .NET 10 iOS application, App Group profile/TOFU/Keychain migration, saved-server
and connected channel/chat/settings flows, managed client event/reconnect handling, and
@@ -18,11 +18,16 @@ up instantly. Newest status at the top.
The Opus/RNNoise shim now cross-compiles as merged static device and simulator archives and
binds through `__Internal`. The Swift ReplayKit upload extension is retained, its ring ABI
is documented/versioned, and the managed pump drains it into a screen-audio stream; MSBuild
builds and embeds the appex. Corrected the historical App Group and extension bundle IDs.
CI now installs the iOS workload and builds both managed Apple clients. **Next:** run the
complete managed/native suite and Release/device packaging, then finish the remaining
advanced administration/audio controls and the physical-device VoiceOver/live-call matrix
before treating Swift as removable.
builds and embeds the appex. Added hierarchical protected-channel management, full channel
codec editing, roster moderation and per-user receive tuning, permissions, account
administration, persistent VAD/PTT/always-on voice controls, event feedback, advanced iOS
audio routing, reconnect restoration, and an always-visible voice bar. iOS 27 uses a small
dynamically loaded ScreenCaptureKit audio bridge; iOS 1826 retain ReplayKit, with both producers
feeding the frozen ring ABI. Added physical-device build/deploy wrappers and expanded the
managed administration round-trip test. Corrected the historical App Group and extension
bundle IDs. CI installs the iOS workload and builds both managed Apple clients. **Next:** run
the physical-device VoiceOver, route-change, background/lock and real multi-human call matrix;
retain the Swift app as release oracle until those observable gates pass.
- **In progress (2026-09-19): managed macOS functional-parity checkpoint.** Extended the
.NET AppKit client across the remaining Swift desktop surface: persistent audio and
+17
View File
@@ -26,6 +26,23 @@ The iOS build first stages static device and simulator Opus/RNNoise archives, th
dotnet build clients/apple/dotnet/VoiceCat.iOS/VoiceCat.iOS.csproj -c Debug -r iossimulator-arm64
```
For a physical device, use the checked-in build and deployment wrappers. The iPhone must be
paired and trusted, and Xcode must have an Apple Development identity and provisioning profile
for both `me.iamtalon.voicecat` and `me.iamtalon.voicecat.broadcast`. Let automatic signing
select them, or set `VOICECAT_CODESIGN_KEY` and `VOICECAT_CODESIGN_PROVISION` before building.
```bash
clients/apple/dotnet/build-ios-device.sh --configuration Debug
clients/apple/dotnet/deploy-ios-device.sh --list
clients/apple/dotnet/deploy-ios-device.sh --device "My iPhone" --configuration Debug --console
```
The build stages the verified app at `dist/ios-managed-device/VoiceCat.iOS.app`. Pass
`--no-build` to the deployment script for quick reinstall cycles. On iOS 1826, screen audio
uses the retained ReplayKit extension. On iOS 27 and newer, the host uses a small dynamically
loaded ScreenCaptureKit bridge and writes the same versioned ring; this keeps one managed consumer and
allows the app to remain launchable on older supported systems.
Profiles, TOFU state, passwords and the ReplayKit ring use the signed App Group `group.me.iamtalon.voicecat`; the managed client migrates the old app-private profile files on first use. The shared ring ABI is frozen in [`docs/broadcast-ring-format.md`](../../../docs/broadcast-ring-format.md).
The native build stages an `osx-arm64` `libvoicecat_media.dylib`; that shim contains only Opus/RNNoise. Debug builds deliberately omit hardened runtime so an ad-hoc-signed local app can load the separately ad-hoc-signed .NET runtime libraries without an Apple Development identity. Release builds retain hardened runtime for Developer ID signing and notarization. Only a macOS host can link, launch, grant microphone access and verify live devices. Final audio quality is validated with real multi-human calls after the feature surface is complete; a synthetic ten-minute sine-wave listen is deliberately not a release gate.
@@ -0,0 +1,95 @@
using UIKit;
using Voicecat.V1;
using Channel = Voicecat.V1.Channel;
namespace VoiceCat.iOS;
internal sealed class UsersController : UITableViewController
{
private readonly AppModel model; private IReadOnlyList<User> Visible => model.CurrentChannelId == 0 ? model.Users : model.Users.Where(user => user.ChannelId == model.CurrentChannelId).ToArray();
internal UsersController(AppModel model) { this.model = model; Title = "Users"; model.Changed += () => TableView.ReloadData(); }
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "user"); }
public override nint RowsInSection(UITableView tableView, nint section) => Visible.Count;
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath)
{
User user = Visible[indexPath.Row]; UITableViewCell cell = tableView.DequeueReusableCell("user", indexPath); var content = cell.DefaultContentConfiguration;
content.Text = user.Nickname + (user.Id == model.SelfUserId ? " (you)" : "");
content.SecondaryText = user.ServerDeafened ? "server deafened" : user.ServerMuted ? "server muted" : user.SelfDeafened ? "deafened" : user.SelfMicMuted ? "muted" : user.IsGuest ? "guest" : "account";
content.Image = UIImage.GetSystemImage(user.ServerMuted || user.SelfMicMuted ? "mic.slash.fill" : "mic.fill"); cell.ContentConfiguration = content; cell.AccessibilityLabel = $"{content.Text}, {content.SecondaryText}";
if (user.Id != model.SelfUserId) cell.AccessibilityCustomActions = Actions(user).Select(value => new UIAccessibilityCustomAction(value.Title, (Func<UIAccessibilityCustomAction, bool>)(_ => { value.Run(); return true; }))).ToArray(); return cell;
}
public override void RowSelected(UITableView tableView, NSIndexPath indexPath)
{
User user = Visible[indexPath.Row]; tableView.DeselectRow(indexPath, true); if (user.Id == model.SelfUserId) return;
UIAlertController menu = UIAlertController.Create(user.Nickname, null, UIAlertControllerStyle.ActionSheet);
foreach ((string title, Action run, bool destructive) in Actions(user)) menu.AddAction(UIAlertAction.Create(title, destructive ? UIAlertActionStyle.Destructive : UIAlertActionStyle.Default, _ => run()));
menu.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); menu.PopoverPresentationController!.SourceView = (UIView?)tableView.CellAt(indexPath) ?? tableView; PresentViewController(menu, true, null);
}
private IReadOnlyList<(string Title, Action Run, bool Destructive)> Actions(User user)
{
Permissions permissions = model.Client?.Permissions ?? new(); var result = new List<(string, Action, bool)>
{ ("Private message", () => PromptPrivate(user), false), ("Volume and noise reduction", () => NavigationController?.PushViewController(new PerUserTuningController(model, user), true), false) };
if (permissions.CanKick || permissions.IsAdmin) result.Add(("Kick", () => PromptReason(user, false), true));
if (permissions.CanBan || permissions.IsAdmin) result.Add(("Ban", () => NavigationController?.PushViewController(new BanUserController(model, user), true), true));
if (permissions.CanMoveUsers || permissions.IsAdmin) result.Add(("Move to channel", () => NavigationController?.PushViewController(new MoveUserController(model, user), true), false));
if (permissions.IsAdmin)
{
result.Add((user.ServerMuted ? "Server unmute" : "Server mute", () => Run(() => model.Client!.SetServerMuteAsync(user.Id, !user.ServerMuted, user.ServerDeafened)), false));
result.Add((user.ServerDeafened ? "Server undeafen" : "Server deafen", () => Run(() => model.Client!.SetServerMuteAsync(user.Id, user.ServerMuted, !user.ServerDeafened)), false));
result.Add(("Permissions", () => NavigationController?.PushViewController(new PermissionsController(model, user), true), false));
}
return result;
}
private void PromptPrivate(User user) { UIAlertController prompt = UIAlertController.Create($"Message {user.Nickname}", null, UIAlertControllerStyle.Alert); prompt.AddTextField(field => field.AccessibilityLabel = "Private message"); prompt.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); prompt.AddAction(UIAlertAction.Create("Send", UIAlertActionStyle.Default, _ => model.SendText(prompt.TextFields?[0].Text ?? "", user.Id))); PresentViewController(prompt, true, null); }
private void PromptReason(User user, bool ban) { UIAlertController prompt = UIAlertController.Create(ban ? "Ban user" : "Kick user", "Reason (optional)", UIAlertControllerStyle.Alert); prompt.AddTextField(field => field.AccessibilityLabel = "Reason"); prompt.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); prompt.AddAction(UIAlertAction.Create(ban ? "Ban" : "Kick", UIAlertActionStyle.Destructive, _ => Run(() => model.Client!.KickUserAsync(user.Id, prompt.TextFields?[0].Text ?? "")))); PresentViewController(prompt, true, null); }
private async void Run(Func<Task<GenericResult>> command) { try { GenericResult result = await model.RunAdminAsync(_ => command()); if (!result.Ok) throw new InvalidOperationException(result.Message); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } }
}
internal abstract class FormController : UITableViewController
{
protected FormController(string title) : base(UITableViewStyle.InsetGrouped) { Title = title; }
protected static UITableViewCell TextCell(UITableView table, NSIndexPath path, string id, string text, string? detail = null)
{ UITableViewCell cell = table.DequeueReusableCell(id, path); var content = cell.DefaultContentConfiguration; content.Text = text; content.SecondaryText = detail; cell.ContentConfiguration = content; return cell; }
protected static UISwitch Switch(bool value, string label, EventHandler handler) { var toggle = new UISwitch { On = value, AccessibilityLabel = label }; toggle.ValueChanged += handler; return toggle; }
}
internal sealed class PermissionsController : FormController
{
private readonly AppModel model; private readonly User user; private readonly string[] names = ["Create temporary channels", "Kick users", "Ban users", "Move users", "Manage accounts", "Administrator"];
private readonly bool[] values = new bool[6];
internal PermissionsController(AppModel model, User user) : base($"Permissions — {user.Nickname}") { this.model = model; this.user = user; }
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "permission"); NavigationItem.RightBarButtonItem = new("Save", UIBarButtonItemStyle.Done, async (_, _) => await Save()); }
public override nint RowsInSection(UITableView tableView, nint section) => names.Length;
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) { UITableViewCell cell = TextCell(tableView, indexPath, "permission", names[indexPath.Row]); int row = indexPath.Row; cell.AccessoryView = Switch(values[row], names[row], (_, _) => values[row] = ((UISwitch)cell.AccessoryView!).On); return cell; }
private async Task Save() { try { var permissions = new Permissions { CanCreateTempChannel = values[0], CanKick = values[1], CanBan = values[2], CanMoveUsers = values[3], CanAdminAccounts = values[4], IsAdmin = values[5] }; GenericResult result = await model.RunAdminAsync(client => client.SetPermissionsAsync(user.Id, permissions)); if (!result.Ok) throw new InvalidOperationException(result.Message); NavigationController?.PopViewController(true); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } }
}
internal sealed class MoveUserController : FormController
{
private readonly AppModel model; private readonly User user; private uint selected;
internal MoveUserController(AppModel model, User user) : base($"Move {user.Nickname}") { this.model = model; this.user = user; selected = user.ChannelId; }
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "channel"); NavigationItem.RightBarButtonItem = new("Move", UIBarButtonItemStyle.Done, async (_, _) => await Move()); }
public override nint RowsInSection(UITableView tableView, nint section) => model.Channels.Count;
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) { Channel channel = model.Channels[indexPath.Row]; UITableViewCell cell = TextCell(tableView, indexPath, "channel", channel.Name); cell.Accessory = channel.Id == selected ? UITableViewCellAccessory.Checkmark : UITableViewCellAccessory.None; cell.AccessibilityLabel = channel.Name + (channel.Id == selected ? ", selected" : ""); return cell; }
public override void RowSelected(UITableView tableView, NSIndexPath indexPath) { selected = model.Channels[indexPath.Row].Id; tableView.ReloadData(); }
private async Task Move() { try { GenericResult result = await model.RunAdminAsync(client => client.MoveUserAsync(user.Id, selected)); if (!result.Ok) throw new InvalidOperationException(result.Message); NavigationController?.PopViewController(true); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } }
}
internal sealed class BanUserController : UIViewController
{
private readonly AppModel model; private readonly User user; private readonly UITextField reason = UiHelpers.Field("Reason (optional)"); private readonly UISegmentedControl duration = new(["Permanent", "1 hour", "1 day", "1 week"]);
internal BanUserController(AppModel model, User user) { this.model = model; this.user = user; Title = $"Ban {user.Nickname}"; }
public override void ViewDidLoad() { base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemGroupedBackground; duration.SelectedSegment = 0; duration.AccessibilityLabel = "Ban duration"; UIStackView stack = new([reason, duration]) { Axis = UILayoutConstraintAxis.Vertical, Spacing = 16, TranslatesAutoresizingMaskIntoConstraints = false }; View.AddSubview(stack); NSLayoutConstraint.ActivateConstraints([stack.TopAnchor.ConstraintEqualTo(View.SafeAreaLayoutGuide.TopAnchor, 24), stack.LeadingAnchor.ConstraintEqualTo(View.LayoutMarginsGuide.LeadingAnchor), stack.TrailingAnchor.ConstraintEqualTo(View.LayoutMarginsGuide.TrailingAnchor)]); NavigationItem.RightBarButtonItem = new("Ban", UIBarButtonItemStyle.Done, async (_, _) => await Ban()); }
private async Task Ban() { try { ulong expires = duration.SelectedSegment switch { 1 => (ulong)DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds(), 2 => (ulong)DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeMilliseconds(), 3 => (ulong)DateTimeOffset.UtcNow.AddDays(7).ToUnixTimeMilliseconds(), _ => 0 }; GenericResult result = await model.RunAdminAsync(client => client.BanUserAsync(user.Id, reason.Text ?? "", expires)); if (!result.Ok) throw new InvalidOperationException(result.Message); NavigationController?.PopViewController(true); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } }
}
internal sealed class PerUserTuningController : FormController
{
private readonly AppModel model; private readonly User user; private readonly UISlider gain = new() { MinValue = 0, MaxValue = 4, Value = 1 };
private bool muted, noise;
internal PerUserTuningController(AppModel model, User user) : base(user.Nickname) { this.model = model; this.user = user; }
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "tuning"); gain.AccessibilityLabel = $"Volume gain for {user.Nickname}"; gain.ValueChanged += (_, _) => Apply(); if (user.Streams.FirstOrDefault() is { } stream && model.Client?.Audio.GetRemotePlayback(user.Id, stream.StreamId) is { } state) { gain.Value = state.Gain; muted = state.Muted; noise = state.NoiseReduction; } }
public override nint RowsInSection(UITableView tableView, nint section) => 3;
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) { string title = indexPath.Row switch { 0 => "Gain", 1 => "Mute", _ => "Noise reduction" }; UITableViewCell cell = TextCell(tableView, indexPath, "tuning", title); if (indexPath.Row == 0) cell.AccessoryView = gain; else { int row = indexPath.Row; cell.AccessoryView = Switch(row == 1 ? muted : noise, title, (_, _) => { bool value = ((UISwitch)cell.AccessoryView!).On; if (row == 1) muted = value; else noise = value; Apply(); }); } return cell; }
private void Apply() { if (model.Client is not { } client) return; foreach (StreamInfo stream in user.Streams) client.Audio.SetRemotePlayback(user.Id, stream.StreamId, gain.Value, muted, noise); }
}
@@ -9,7 +9,6 @@ internal sealed class AppDelegate : UIApplicationDelegate
public override bool FinishedLaunching(UIApplication application, NSDictionary? launchOptions)
{
AppModel.Shared.Load();
IosAudioRouter.Shared.Load();
return true;
}
+107 -10
View File
@@ -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(); }
}
}
@@ -12,8 +12,12 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable
private Task worker = Task.CompletedTask;
private VoiceCatClient? client;
private uint streamId;
private bool active;
private readonly short[] scratch = new short[Frame * 2];
internal event Action? Changed;
internal bool IsActive => active;
internal void Start(VoiceCatClient owner) { client = owner; worker = RunAsync(stop.Token); }
private async Task RunAsync(CancellationToken token)
@@ -35,12 +39,12 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable
using MemoryMappedViewAccessor view = map.CreateViewAccessor(0, Header + Capacity * sizeof(short), MemoryMappedFileAccess.ReadWrite);
if (view.ReadUInt32(0) != Magic || view.ReadUInt32(4) != Version) throw new InvalidDataException("Unsupported broadcast ring.");
bool active = view.ReadUInt32(16) != 0;
if (!active) { StopStream(); return; }
if (!active) { StopStream(); SetActive(false); return; }
VoiceCatClient owner = client ?? throw new IOException("Client disconnected.");
if (streamId == 0)
{
StreamInfo stream = await owner.StartStreamAsync(StreamKind.StreamScreenAudio, "Screen audio", 2, token).ConfigureAwait(false);
streamId = stream.StreamId; view.Write(32, view.ReadUInt64(24));
streamId = stream.StreamId; view.Write(32, view.ReadUInt64(24)); SetActive(true);
}
ulong write = view.ReadUInt64(24), read = view.ReadUInt64(32);
if (write - read > Capacity) read = write - Capacity;
@@ -63,8 +67,10 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable
try { client.StopStream(id); } catch (Exception exception) when (exception is IOException or InvalidOperationException) { }
}
private void SetActive(bool value) { if (active == value) return; active = value; Changed?.Invoke(); }
public async ValueTask DisposeAsync()
{
stop.Cancel(); try { await worker.ConfigureAwait(false); } catch (OperationCanceledException) { } StopStream(); stop.Dispose();
stop.Cancel(); try { await worker.ConfigureAwait(false); } catch (OperationCanceledException) { } StopStream(); SetActive(false); stop.Dispose();
}
}
@@ -0,0 +1,48 @@
using UIKit;
using Voicecat.V1;
using Channel = Voicecat.V1.Channel;
namespace VoiceCat.iOS;
internal sealed class ChannelEditorController : UIViewController
{
private readonly AppModel model; private readonly Channel? existing;
private readonly UITextField name = UiHelpers.Field("Channel name"), topic = UiHelpers.Field("Topic (optional)"), maximum = UiHelpers.Field("Maximum users, 0 is unlimited"), order = UiHelpers.Field("Sort order"), password = UiHelpers.Field("Password; blank preserves existing", true), bitrate = UiHelpers.Field("Bitrate in bits per second"), loss = UiHelpers.Field("Expected packet loss percent"), complexity = UiHelpers.Field("Complexity 0 through 10");
private readonly UISegmentedControl type = new(["Permanent", "Temporary"]), mode = new(["Mono", "Stereo"]), application = new(["VoIP", "Audio", "Low delay"]);
private readonly UIButton parent = UIButton.FromType(UIButtonType.System), sampleRate = UIButton.FromType(UIButtonType.System), frame = UIButton.FromType(UIButtonType.System);
private readonly UISwitch fec = new(), dtx = new(), dred = new(); private uint parentId;
internal ChannelEditorController(AppModel model, Channel? existing) { this.model = model; this.existing = existing; Title = existing is null ? "New Channel" : "Edit Channel"; }
public override void ViewDidLoad()
{
base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemGroupedBackground; UIScrollView scroll = new() { TranslatesAutoresizingMaskIntoConstraints = false }; UIStackView stack = new() { Axis = UILayoutConstraintAxis.Vertical, Spacing = 12, TranslatesAutoresizingMaskIntoConstraints = false };
View.AddSubview(scroll); scroll.AddSubview(stack); NSLayoutConstraint.ActivateConstraints([scroll.TopAnchor.ConstraintEqualTo(View.SafeAreaLayoutGuide.TopAnchor), scroll.BottomAnchor.ConstraintEqualTo(View.BottomAnchor), scroll.LeadingAnchor.ConstraintEqualTo(View.LeadingAnchor), scroll.TrailingAnchor.ConstraintEqualTo(View.TrailingAnchor), stack.TopAnchor.ConstraintEqualTo(scroll.ContentLayoutGuide.TopAnchor, 16), stack.BottomAnchor.ConstraintEqualTo(scroll.ContentLayoutGuide.BottomAnchor, -24), stack.LeadingAnchor.ConstraintEqualTo(scroll.FrameLayoutGuide.LeadingAnchor, 20), stack.TrailingAnchor.ConstraintEqualTo(scroll.FrameLayoutGuide.TrailingAnchor, -20)]);
foreach (UIView row in new UIView[] { name, topic, PickerRow("Parent channel", parent), PickerRow("Channel type", type), maximum, order, password, PickerRow("Channel mode", mode), PickerRow("Sample rate", sampleRate), PickerRow("Frame duration", frame), PickerRow("Opus application", application), bitrate, loss, complexity, SwitchRow("Forward error correction", fec), SwitchRow("Discontinuous transmission", dtx), SwitchRow("Deep redundancy", dred) }) stack.AddArrangedSubview(row);
maximum.KeyboardType = order.KeyboardType = bitrate.KeyboardType = loss.KeyboardType = complexity.KeyboardType = UIKeyboardType.NumberPad;
parent.Menu = UIMenu.Create(model.Channels.Where(channel => channel.Id != existing?.Id).OrderBy(channel => channel.Name).Select(channel => UIAction.Create(channel.Name, null, null, _ => { parentId = channel.Id; parent.SetTitle(channel.Name, UIControlState.Normal); })).Prepend(UIAction.Create("Root", null, null, _ => { parentId = 0; parent.SetTitle("Root", UIControlState.Normal); })).ToArray()); parent.ShowsMenuAsPrimaryAction = true;
sampleRate.Menu = Choice(sampleRate, ["48000"]); sampleRate.ShowsMenuAsPrimaryAction = true; frame.Menu = Choice(frame, ["5", "10", "20", "40", "60"]); frame.ShowsMenuAsPrimaryAction = true;
Load(); NavigationItem.RightBarButtonItem = new("Save", UIBarButtonItemStyle.Done, async (_, _) => await Save());
}
private void Load()
{
Channel channel = existing ?? new Channel { Audio = new AudioConfig { SampleRate = 48000, BitrateBps = 64000, FrameMs = 20, Fec = true, Complexity = 10, ExpectedPacketLoss = 5 } };
name.Text = channel.Name; topic.Text = channel.Topic; maximum.Text = channel.MaxUsers.ToString(); order.Text = channel.Order.ToString(); parentId = channel.ParentId;
parent.SetTitle(model.Channels.FirstOrDefault(value => value.Id == parentId)?.Name ?? "Root", UIControlState.Normal); type.SelectedSegment = channel.Type == ChannelType.ChannelTemporary ? 1 : 0; mode.SelectedSegment = channel.Audio?.Mode == ChannelMode.ModeStereo ? 1 : 0;
sampleRate.SetTitle((channel.Audio?.SampleRate ?? 48000).ToString(), UIControlState.Normal); frame.SetTitle((channel.Audio?.FrameMs ?? 20).ToString(), UIControlState.Normal); application.SelectedSegment = (nint)(channel.Audio?.Application ?? OpusApplication.OpusVoip);
bitrate.Text = (channel.Audio?.BitrateBps ?? 64000).ToString(); loss.Text = (channel.Audio?.ExpectedPacketLoss ?? 5).ToString(); complexity.Text = (channel.Audio?.Complexity ?? 10).ToString(); fec.On = channel.Audio?.Fec ?? true; dtx.On = channel.Audio?.Dtx ?? false; dred.On = channel.Audio?.Dred ?? false;
}
private async Task Save()
{
try
{
if (string.IsNullOrWhiteSpace(name.Text) || !uint.TryParse(maximum.Text, out uint max) || !int.TryParse(order.Text, out int sort) || !uint.TryParse(sampleRate.Title(UIControlState.Normal), out uint rate) || !uint.TryParse(frame.Title(UIControlState.Normal), out uint frameMs) || !uint.TryParse(bitrate.Text, out uint bits) || !uint.TryParse(loss.Text, out uint packetLoss) || !uint.TryParse(complexity.Text, out uint cpu) || packetLoss > 100 || cpu > 10) throw new ArgumentException("Enter valid channel and Opus settings.");
var channel = new Channel { Id = existing?.Id ?? 0, ParentId = parentId, Name = name.Text.Trim(), Topic = topic.Text?.Trim() ?? "", MaxUsers = max, Order = sort, Type = type.SelectedSegment == 1 ? ChannelType.ChannelTemporary : ChannelType.ChannelPermanent,
Audio = new AudioConfig { Codec = 0, Mode = mode.SelectedSegment == 1 ? ChannelMode.ModeStereo : ChannelMode.ModeMono, SampleRate = rate, FrameMs = frameMs, Application = (OpusApplication)(int)application.SelectedSegment, BitrateBps = bits, ExpectedPacketLoss = packetLoss, Complexity = cpu, Fec = fec.On, Dtx = dtx.On, Dred = dred.On } };
GenericResult result = await model.RunAdminAsync(client => existing is null ? client.CreateChannelAsync(channel, password.Text ?? "") : client.EditChannelAsync(channel, password.Text ?? "")); if (!result.Ok) throw new InvalidOperationException(result.Message); NavigationController?.PopViewController(true);
}
catch (Exception exception) { UiHelpers.ShowError(this, exception); }
}
private static UIView PickerRow(string label, UIView control) { var row = new UIStackView { Axis = UILayoutConstraintAxis.Horizontal, Distribution = UIStackViewDistribution.FillEqually, Spacing = 8 }; var text = new UILabel { Text = label }; control.AccessibilityLabel = label; row.AddArrangedSubview(text); row.AddArrangedSubview(control); return row; }
private static UIView SwitchRow(string label, UISwitch toggle) { var row = new UIStackView { Axis = UILayoutConstraintAxis.Horizontal, Distribution = UIStackViewDistribution.EqualSpacing }; row.AddArrangedSubview(new UILabel { Text = label }); toggle.AccessibilityLabel = label; row.AddArrangedSubview(toggle); return row; }
private static UIMenu Choice(UIButton button, string[] values) => UIMenu.Create(values.Select(value => UIAction.Create(value, null, null, _ => button.SetTitle(value, UIControlState.Normal))).ToArray());
}
@@ -0,0 +1,49 @@
using AVFoundation;
using Foundation;
namespace VoiceCat.iOS;
internal enum SoundEvent { ChannelJoin, ChannelLeave, ChannelReceived, ChannelSent, PrivateReceived, PrivateSent, Login, Logout, ConnectionLost, VoiceOn, VoiceOff, VoiceStart, VoiceStop, PushToTalk }
internal sealed class EventFeedback : IDisposable
{
private readonly IosSettings settings;
private readonly AVSpeechSynthesizer speech = new();
private readonly Dictionary<SoundEvent, AVAudioPlayer> players = [];
internal EventFeedback(IosSettings settings) => this.settings = settings;
internal void Play(SoundEvent sound)
{
if (!settings.EventSounds || settings.EventVolume <= 0 ||
(sound is SoundEvent.VoiceStart or SoundEvent.VoiceStop && !settings.SelfTalkSounds) ||
(sound == SoundEvent.PushToTalk && !settings.PushToTalkSound)) return;
if (!players.TryGetValue(sound, out AVAudioPlayer? player))
{
string path = Path.Combine(NSBundle.MainBundle.ResourcePath ?? "", "Sounds", FileName(sound) + ".wav");
if (!File.Exists(path)) return;
player = AVAudioPlayer.FromUrl(NSUrl.FromFilename(path));
if (player is null) return;
player.PrepareToPlay(); players[sound] = player;
}
player.Volume = settings.EventVolume; player.CurrentTime = 0; player.Play();
}
internal void Speak(string text)
{
if (!settings.SpokenEvents || string.IsNullOrWhiteSpace(text)) return;
speech.SpeakUtterance(new AVSpeechUtterance(text.Trim()));
}
private static string FileName(SoundEvent value) => value switch
{
SoundEvent.ChannelJoin => "channel_join", SoundEvent.ChannelLeave => "channel_leave",
SoundEvent.ChannelReceived => "channel_recv", SoundEvent.ChannelSent => "channel_sent",
SoundEvent.PrivateReceived => "pm_recv", SoundEvent.PrivateSent => "pm_sent",
SoundEvent.Login => "login", SoundEvent.Logout => "logout", SoundEvent.ConnectionLost => "connection_lost",
SoundEvent.VoiceOn => "voice_on", SoundEvent.VoiceOff => "voice_off", SoundEvent.VoiceStart => "va_start",
SoundEvent.VoiceStop => "va_stop", _ => "ptt"
};
public void Dispose() { foreach (AVAudioPlayer player in players.Values) player.Dispose(); speech.Dispose(); }
}
@@ -1,60 +1,137 @@
using AVFoundation;
using Foundation;
using UIKit;
namespace VoiceCat.iOS;
internal enum IosAudioPreset { VoiceChat, StereoMicrophone, MonoMicrophone, Advanced }
internal enum IosBluetoothMode { HfpVoice, BuiltInMicA2dp, BuiltInMicSpeaker }
internal enum IosMicMode { Standard, Raw }
internal sealed record IosAudioPort(string Id, string Name, string Type);
internal sealed record IosAudioDataSource(string Id, string Name, IReadOnlyList<AVAudioDataSourcePolarPattern> Patterns);
internal sealed class IosAudioRouter
{
internal static IosAudioRouter Shared { get; } = new();
private readonly NSUserDefaults defaults = NSUserDefaults.StandardUserDefaults;
private bool applying;
internal event Action? Changed;
internal IosAudioPreset Preset { get; private set; } = IosAudioPreset.VoiceChat;
internal bool ForceSpeaker { get; set; }
internal bool VoiceProcessing { get; set; } = true;
internal bool AutomaticGainControl { get; set; } = true;
internal int CaptureChannels => Preset == IosAudioPreset.StereoMicrophone ? 2 : 1;
internal bool UsesVoiceProcessing => VoiceProcessing && CaptureChannels == 1 && Preset != IosAudioPreset.MonoMicrophone;
internal IosBluetoothMode BluetoothMode { get; private set; } = IosBluetoothMode.HfpVoice;
internal IosMicMode MicMode { get; private set; } = IosMicMode.Standard;
internal bool ForceSpeaker { get; private set; }
internal bool VoiceProcessing { get; private set; } = true;
internal bool AutomaticGainControl { get; private set; } = true;
internal int CaptureChannels { get; private set; } = 1;
internal string? SelectedInputId { get; private set; }
internal string? SelectedDataSourceId { get; private set; }
internal AVAudioDataSourcePolarPattern SelectedPolarPattern { get; private set; } = AVAudioDataSourcePolarPattern.Unknown;
internal IReadOnlyList<IosAudioPort> Inputs { get; private set; } = [];
internal IReadOnlyList<IosAudioPort> Outputs { get; private set; } = [];
internal bool VoiceProcessingAvailable => CaptureChannels == 1 && MicMode == IosMicMode.Standard && BluetoothMode != IosBluetoothMode.BuiltInMicA2dp;
internal bool UsesVoiceProcessing => VoiceProcessing && VoiceProcessingAvailable;
private IosAudioRouter()
{
NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.RouteChangeNotification, _ => Recover());
NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.InterruptionNotification, note => HandleInterruption(note));
NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.InterruptionNotification, HandleInterruption);
NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.MediaServicesWereResetNotification, _ => Recover());
}
internal void Load()
{
string? preset = defaults.StringForKey("cat.voice.audio.preset");
if (Enum.TryParse(preset, true, out IosAudioPreset value)) Preset = value;
if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.preset"), true, out IosAudioPreset preset)) Preset = preset;
if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.bluetoothMode"), true, out IosBluetoothMode bluetooth)) BluetoothMode = bluetooth;
if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.micMode"), true, out IosMicMode mic)) MicMode = mic;
ForceSpeaker = defaults.BoolForKey("cat.voice.audio.forceSpeaker");
VoiceProcessing = defaults.ValueForKey(new NSString("cat.voice.audio.voiceProcessing")) is null || defaults.BoolForKey("cat.voice.audio.voiceProcessing");
AutomaticGainControl = defaults.ValueForKey(new NSString("cat.voice.audio.agc")) is null || defaults.BoolForKey("cat.voice.audio.agc");
CaptureChannels = defaults.IntForKey("cat.voice.audio.captureChannels") == 2 ? 2 : Preset == IosAudioPreset.StereoMicrophone ? 2 : 1;
SelectedInputId = defaults.StringForKey("cat.voice.audio.inputPortId"); SelectedDataSourceId = defaults.StringForKey("cat.voice.audio.dataSourceId");
if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.polarPattern"), true, out AVAudioDataSourcePolarPattern pattern)) SelectedPolarPattern = pattern;
RefreshRoutes();
}
internal void SelectPreset(IosAudioPreset preset)
{
Preset = preset; defaults.SetString(preset.ToString(), "cat.voice.audio.preset"); defaults.Synchronize();
if (IosAudioEngine.Shared.IsConnected) IosAudioEngine.Shared.Reconfigure();
Preset = preset;
if (preset != IosAudioPreset.Advanced)
{
CaptureChannels = preset == IosAudioPreset.StereoMicrophone ? 2 : 1; MicMode = IosMicMode.Standard;
BluetoothMode = preset == IosAudioPreset.VoiceChat ? IosBluetoothMode.HfpVoice : IosBluetoothMode.BuiltInMicA2dp;
if (preset is IosAudioPreset.StereoMicrophone or IosAudioPreset.MonoMicrophone)
SelectedInputId = AVAudioSession.SharedInstance().AvailableInputs?.FirstOrDefault(value => value.PortType == AVAudioSession.PortBuiltInMic)?.UID;
}
SaveAndReconfigure();
}
internal void SetForceSpeaker(bool value) { ForceSpeaker = value; SaveAndReconfigure(); }
internal void SetVoiceProcessing(bool value) { VoiceProcessing = value; SaveAndReconfigure(); }
internal void SetAutomaticGainControl(bool value) { AutomaticGainControl = value; SaveAndReconfigure(); }
internal void SetCaptureChannels(int value) { CaptureChannels = value == 2 ? 2 : 1; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); }
internal void SetBluetoothMode(IosBluetoothMode value) { BluetoothMode = value; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); }
internal void SetMicMode(IosMicMode value) { MicMode = value; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); }
internal void SelectInput(string? id) { SelectedInputId = string.IsNullOrEmpty(id) ? null : id; SelectedDataSourceId = null; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); }
internal void SelectDataSource(string? id) { SelectedDataSourceId = string.IsNullOrEmpty(id) ? null : id; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); }
internal void SelectPolarPattern(AVAudioDataSourcePolarPattern pattern) { SelectedPolarPattern = pattern; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); }
internal IReadOnlyList<IosAudioDataSource> DataSources()
{
AVAudioSessionPortDescription? port = AVAudioSession.SharedInstance().AvailableInputs?.FirstOrDefault(value => value.UID == SelectedInputId);
return port?.DataSources?.Select(value => new IosAudioDataSource(value.DataSourceID.ToString(), value.DataSourceName,
value.SupportedPolarPatterns?.ToArray() ?? [])).ToArray() ?? [];
}
internal void RefreshRoutes()
{
AVAudioSession session = AVAudioSession.SharedInstance();
Inputs = session.AvailableInputs?.Select(value => new IosAudioPort(value.UID, value.PortName, value.PortType.ToString())).ToArray() ?? [];
Outputs = session.CurrentRoute.Outputs.Select(value => new IosAudioPort(value.UID, value.PortName, value.PortType.ToString())).ToArray();
SelectedInputId ??= session.PreferredInput?.UID; Changed?.Invoke();
}
internal void Apply()
{
AVAudioSession session = AVAudioSession.SharedInstance();
AVAudioSessionCategoryOptions options = AVAudioSessionCategoryOptions.AllowBluetooth;
if (Preset is IosAudioPreset.StereoMicrophone or IosAudioPreset.MonoMicrophone)
options = AVAudioSessionCategoryOptions.AllowBluetoothA2DP | AVAudioSessionCategoryOptions.DefaultToSpeaker;
if (!session.SetCategory(AVAudioSessionCategory.PlayAndRecord, AVAudioSessionMode.Default, options, out NSError? categoryError))
throw new InvalidOperationException(categoryError.LocalizedDescription);
session.SetPreferredSampleRate(48_000, out _);
session.SetPreferredIOBufferDuration(0.02, out _);
if (!session.SetActive(true, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out NSError? activeError))
throw new InvalidOperationException(activeError.LocalizedDescription);
session.OverrideOutputAudioPort(ForceSpeaker ? AVAudioSessionPortOverride.Speaker : AVAudioSessionPortOverride.None, out _);
if (applying) return; applying = true;
try
{
AVAudioSession session = AVAudioSession.SharedInstance(); AVAudioSessionCategoryOptions options = AVAudioSessionCategoryOptions.MixWithOthers;
if (BluetoothMode == IosBluetoothMode.HfpVoice) options |= AVAudioSessionCategoryOptions.AllowBluetooth | AVAudioSessionCategoryOptions.AllowBluetoothA2DP | AVAudioSessionCategoryOptions.AllowAirPlay;
if (BluetoothMode == IosBluetoothMode.BuiltInMicA2dp) options |= AVAudioSessionCategoryOptions.AllowBluetoothA2DP | AVAudioSessionCategoryOptions.AllowAirPlay;
if (BluetoothMode == IosBluetoothMode.BuiltInMicSpeaker || ForceSpeaker && BluetoothMode != IosBluetoothMode.BuiltInMicA2dp) options |= AVAudioSessionCategoryOptions.DefaultToSpeaker;
AVAudioSessionMode mode = CaptureChannels == 2 ? AVAudioSessionMode.Default : MicMode == IosMicMode.Raw ? AVAudioSessionMode.Measurement
: BluetoothMode == IosBluetoothMode.BuiltInMicA2dp ? AVAudioSessionMode.VideoRecording : AVAudioSessionMode.VoiceChat;
if (!session.SetCategory(AVAudioSessionCategory.PlayAndRecord, mode, options, out NSError? categoryError)) throw new InvalidOperationException(categoryError.LocalizedDescription);
session.SetPreferredSampleRate(48_000, out _); session.SetPreferredIOBufferDuration(0.02, out _); ApplyInput(session);
if (!session.SetActive(true, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out NSError? activeError)) throw new InvalidOperationException(activeError.LocalizedDescription);
session.OverrideOutputAudioPort(ForceSpeaker && BluetoothMode != IosBluetoothMode.BuiltInMicA2dp ? AVAudioSessionPortOverride.Speaker : AVAudioSessionPortOverride.None, out _); RefreshRoutes();
}
finally { applying = false; }
}
private void ApplyInput(AVAudioSession session)
{
AVAudioSessionPortDescription? port = session.AvailableInputs?.FirstOrDefault(value => value.UID == SelectedInputId);
if (CaptureChannels == 2) port ??= session.AvailableInputs?.FirstOrDefault(value => value.PortType == AVAudioSession.PortBuiltInMic);
if (port is null) return; session.SetPreferredInput(port, out _);
AVAudioSessionDataSourceDescription? source = port.DataSources?.FirstOrDefault(value => value.DataSourceID.ToString() == SelectedDataSourceId);
if (CaptureChannels == 2) source ??= port.DataSources?.FirstOrDefault(value => value.SupportedPolarPatterns?.Any(pattern => pattern.ToString().Contains("Stereo", StringComparison.OrdinalIgnoreCase)) == true);
if (source is null) return; port.SetPreferredDataSource(source, out _); session.SetInputDataSource(source, out _);
if (CaptureChannels != 2 && SelectedPolarPattern != AVAudioDataSourcePolarPattern.Unknown) source.SetPreferredPolarPattern(SelectedPolarPattern, out _);
}
private void SaveAndReconfigure()
{
defaults.SetString(Preset.ToString(), "cat.voice.audio.preset"); defaults.SetString(BluetoothMode.ToString(), "cat.voice.audio.bluetoothMode");
defaults.SetString(MicMode.ToString(), "cat.voice.audio.micMode"); defaults.SetBool(ForceSpeaker, "cat.voice.audio.forceSpeaker");
defaults.SetBool(VoiceProcessing, "cat.voice.audio.voiceProcessing"); defaults.SetBool(AutomaticGainControl, "cat.voice.audio.agc"); defaults.SetInt(CaptureChannels, "cat.voice.audio.captureChannels");
Set("cat.voice.audio.inputPortId", SelectedInputId); Set("cat.voice.audio.dataSourceId", SelectedDataSourceId); defaults.SetString(SelectedPolarPattern.ToString(), "cat.voice.audio.polarPattern"); defaults.Synchronize();
if (IosAudioEngine.Shared.IsConnected) IosAudioEngine.Shared.Reconfigure(); Changed?.Invoke();
}
private void Set(string key, string? value) { if (value is null) defaults.RemoveObject(key); else defaults.SetString(value, key); }
internal void Deactivate() => AVAudioSession.SharedInstance().SetActive(false, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out _);
private void Recover() { if (IosAudioEngine.Shared.IsConnected) UIApplication.SharedApplication.BeginInvokeOnMainThread(IosAudioEngine.Shared.Reconfigure); }
private void Recover() { RefreshRoutes(); if (IosAudioEngine.Shared.IsConnected) UIApplication.SharedApplication.BeginInvokeOnMainThread(IosAudioEngine.Shared.Reconfigure); }
private void HandleInterruption(NSNotification note)
{
NSNumber? type = note.UserInfo?[new NSString("AVAudioSessionInterruptionTypeKey")] as NSNumber;
@@ -0,0 +1,23 @@
using System.Runtime.InteropServices;
using Foundation;
namespace VoiceCat.iOS;
internal static partial class IosScreenCapture
{
internal static void Present()
{
if (!OperatingSystem.IsIOSVersionAtLeast(27)) return;
NSUrl? root = NSFileManager.DefaultManager.GetContainerUrl(IosConstants.AppGroup);
if (root?.Path is null) throw new InvalidOperationException("The VoiceCat App Group is unavailable.");
string path = Path.Combine(root.Path, "voicecat", "broadcast_audio.ring"); Directory.CreateDirectory(Path.GetDirectoryName(path)!);
if (Available() == 0) throw new InvalidOperationException("Screen audio sharing is unavailable on this device."); PresentNative(path);
}
[LibraryImport("__Internal", EntryPoint = "vc_ios_screen_capture_available")]
private static partial int Available();
[LibraryImport("__Internal", EntryPoint = "vc_ios_screen_capture_present", StringMarshalling = StringMarshalling.Utf8)]
private static partial void PresentNative(string ringPath);
[LibraryImport("__Internal", EntryPoint = "vc_ios_screen_capture_stop")]
internal static partial void Stop();
}
@@ -0,0 +1,49 @@
using Foundation;
using VoiceCat.Audio;
namespace VoiceCat.iOS;
internal sealed class IosSettings
{
private readonly NSUserDefaults values = NSUserDefaults.StandardUserDefaults;
internal AudioInputMode InputMode { get; set; } = AudioInputMode.VoiceActivation;
internal float VadThreshold { get; set; } = 0.025f;
internal float InputGain { get; set; } = 1f;
internal float OutputGain { get; set; } = 1f;
internal bool InputNoiseReduction { get; set; }
internal bool EventSounds { get; set; } = true;
internal bool SpokenEvents { get; set; }
internal float EventVolume { get; set; } = 1f;
internal bool SelfTalkSounds { get; set; }
internal bool PushToTalkSound { get; set; }
internal void Load()
{
NSString[] keys = [(NSString)"voice.inputMode", (NSString)"voice.vadThreshold", (NSString)"voice.inputGain",
(NSString)"voice.outputGain", (NSString)"feedback.sounds", (NSString)"feedback.volume"];
NSObject[] defaults = [NSNumber.FromInt32((int)AudioInputMode.VoiceActivation), NSNumber.FromFloat(0.025f),
NSNumber.FromFloat(1f), NSNumber.FromFloat(1f), NSNumber.FromBoolean(true), NSNumber.FromFloat(1f)];
values.RegisterDefaults(new NSDictionary<NSString, NSObject>(keys, defaults));
int inputMode = checked((int)values.IntForKey("voice.inputMode"));
InputMode = Enum.IsDefined(typeof(AudioInputMode), inputMode) ? (AudioInputMode)inputMode : AudioInputMode.VoiceActivation;
VadThreshold = Math.Clamp(values.FloatForKey("voice.vadThreshold"), 0.001f, 0.1f);
InputGain = Math.Clamp(values.FloatForKey("voice.inputGain"), 0f, 4f);
OutputGain = Math.Clamp(values.FloatForKey("voice.outputGain"), 0f, 1f);
InputNoiseReduction = values.BoolForKey("voice.inputNoiseReduction");
EventSounds = values.BoolForKey("feedback.sounds");
SpokenEvents = values.BoolForKey("feedback.speech");
EventVolume = Math.Clamp(values.FloatForKey("feedback.volume"), 0f, 1f);
SelfTalkSounds = values.BoolForKey("feedback.selfTalk");
PushToTalkSound = values.BoolForKey("feedback.ptt");
}
internal void Save()
{
values.SetInt((int)InputMode, "voice.inputMode"); values.SetFloat(VadThreshold, "voice.vadThreshold");
values.SetFloat(InputGain, "voice.inputGain"); values.SetFloat(OutputGain, "voice.outputGain");
values.SetBool(InputNoiseReduction, "voice.inputNoiseReduction"); values.SetBool(EventSounds, "feedback.sounds");
values.SetBool(SpokenEvents, "feedback.speech"); values.SetFloat(EventVolume, "feedback.volume");
values.SetBool(SelfTalkSounds, "feedback.selfTalk"); values.SetBool(PushToTalkSound, "feedback.ptt"); values.Synchronize();
}
}
@@ -1,5 +1,5 @@
using ReplayKit;
using UIKit;
using VoiceCat.Audio;
using Voicecat.V1;
using Channel = Voicecat.V1.Channel;
@@ -9,64 +9,104 @@ internal sealed class MainTabController : UITabBarController
{
internal MainTabController(AppModel model)
{
UIViewController channels = new UINavigationController(new ChannelsController(model));
channels.TabBarItem = new("Channels", UIImage.GetSystemImage("list.bullet.indent"), 0);
UIViewController chat = new UINavigationController(new ChatController(model));
chat.TabBarItem = new("Chat", UIImage.GetSystemImage("message"), 1);
UIViewController settings = new UINavigationController(new SettingsController(model));
settings.TabBarItem = new("Settings", UIImage.GetSystemImage("gear"), 2);
UIViewController channels = Wrap(new ChannelsController(model), "Channels", "list.bullet.indent", 0);
UIViewController chat = Wrap(new ChatController(model), "Chat", "message", 1);
UIViewController settings = Wrap(new SettingsController(model), "Settings", "gear", 2);
ViewControllers = [channels, chat, settings];
}
private static UIViewController Wrap(UIViewController content, string title, string image, nint tag)
{
var root = new UIViewController(); UIView rootView = root.View!; rootView.BackgroundColor = UIColor.SystemBackground;
var navigation = new UINavigationController(content); UIView navigationView = navigation.View!; navigationView.TranslatesAutoresizingMaskIntoConstraints = false;
root.AddChildViewController(navigation); rootView.AddSubview(navigationView); navigation.DidMoveToParentViewController(root);
var voice = new VoiceControlsView(AppModel.Shared) { TranslatesAutoresizingMaskIntoConstraints = false }; rootView.AddSubview(voice);
NSLayoutConstraint.ActivateConstraints([navigationView.TopAnchor.ConstraintEqualTo(rootView.TopAnchor), navigationView.LeadingAnchor.ConstraintEqualTo(rootView.LeadingAnchor),
navigationView.TrailingAnchor.ConstraintEqualTo(rootView.TrailingAnchor), navigationView.BottomAnchor.ConstraintEqualTo(voice.TopAnchor),
voice.LeadingAnchor.ConstraintEqualTo(rootView.LeadingAnchor), voice.TrailingAnchor.ConstraintEqualTo(rootView.TrailingAnchor),
voice.BottomAnchor.ConstraintEqualTo(rootView.SafeAreaLayoutGuide.BottomAnchor), voice.HeightAnchor.ConstraintEqualTo(58)]);
root.TabBarItem = new(title, UIImage.GetSystemImage(image), tag); return root;
}
}
internal sealed class VoiceControlsView : UIView
{
private readonly AppModel model; private readonly UIButton join = UIButton.FromType(UIButtonType.System);
private readonly UIButton ptt = UIButton.FromType(UIButtonType.System); private readonly UIButton mute = UIButton.FromType(UIButtonType.System);
private readonly UIButton deafen = UIButton.FromType(UIButtonType.System); private readonly UIProgressView level = new(UIProgressViewStyle.Default);
internal VoiceControlsView(AppModel model) { this.model = model; model.Changed += Refresh; Build(); }
private void Build()
{
BackgroundColor = UIColor.SecondarySystemBackground; join.TouchUpInside += async (_, _) => await Run(model.ToggleVoiceAsync);
ptt.TouchDown += (_, _) => model.SetPushToTalk(true); ptt.TouchUpInside += (_, _) => model.SetPushToTalk(false);
ptt.TouchUpOutside += (_, _) => model.SetPushToTalk(false); ptt.TouchCancel += (_, _) => model.SetPushToTalk(false);
mute.TouchUpInside += (_, _) => model.SetSelfAudio(!(model.Client?.Audio.MicMuted ?? false), model.Client?.Audio.Deafened ?? false);
deafen.TouchUpInside += (_, _) => model.SetSelfAudio(model.Client?.Audio.MicMuted ?? false, !(model.Client?.Audio.Deafened ?? false));
UIStackView stack = new([join, ptt, level, mute, deafen]) { Axis = UILayoutConstraintAxis.Horizontal, Alignment = UIStackViewAlignment.Center,
Distribution = UIStackViewDistribution.Fill, Spacing = 12, TranslatesAutoresizingMaskIntoConstraints = false };
AddSubview(stack); level.WidthAnchor.ConstraintEqualTo(65).Active = true;
NSLayoutConstraint.ActivateConstraints([stack.LeadingAnchor.ConstraintEqualTo(LeadingAnchor, 12), stack.TrailingAnchor.ConstraintEqualTo(TrailingAnchor, -12), stack.CenterYAnchor.ConstraintEqualTo(CenterYAnchor)]); Refresh();
}
private void Refresh()
{
join.SetTitle(model.VoiceJoined ? "Leave Voice" : "Join Voice", UIControlState.Normal); join.AccessibilityLabel = join.Title(UIControlState.Normal); join.Enabled = model.CurrentChannelId != 0;
ptt.SetTitle("Hold to Talk", UIControlState.Normal); ptt.AccessibilityLabel = "Push to talk, hold to transmit"; ptt.Hidden = model.Settings.InputMode != AudioInputMode.PushToTalk; ptt.Enabled = model.VoiceJoined;
bool muted = model.Client?.Audio.MicMuted == true, deafened = model.Client?.Audio.Deafened == true;
mute.SetImage(UIImage.GetSystemImage(muted ? "mic.slash.fill" : "mic.fill"), UIControlState.Normal); mute.AccessibilityLabel = muted ? "Unmute microphone" : "Mute microphone"; mute.Enabled = model.VoiceJoined;
deafen.SetImage(UIImage.GetSystemImage(deafened ? "headphones.slash" : "headphones"), UIControlState.Normal); deafen.AccessibilityLabel = deafened ? "Undeafen" : "Deafen"; deafen.Enabled = model.VoiceJoined;
level.Progress = Math.Clamp(model.MicrophoneLevel * 10, 0, 1); level.AccessibilityLabel = "Microphone level"; level.AccessibilityValue = $"{level.Progress:P0}";
}
private async Task Run(Func<Task> operation) { try { await operation(); } catch (Exception exception) { if (Window?.RootViewController is { } owner) UiHelpers.ShowError(owner, exception); } }
}
internal sealed class ChannelsController : UITableViewController
{
private readonly AppModel model;
internal ChannelsController(AppModel model) { this.model = model; Title = "Channels"; model.Changed += () => TableView.ReloadData(); }
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "channel"); NavigationItem.RightBarButtonItem = new("Users", UIBarButtonItemStyle.Plain, (_, _) => NavigationController?.PushViewController(new UsersController(model), true)); }
public override nint RowsInSection(UITableView tableView, nint section) => model.Channels.Count;
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath)
private readonly AppModel model; private IReadOnlyList<(Channel Channel, int Depth)> Visible => Flatten();
internal ChannelsController(AppModel model) { this.model = model; Title = "Channels"; model.Changed += Reload; }
public override void ViewDidLoad()
{
Channel channel = model.Channels.OrderBy(c => c.Order).ThenBy(c => c.Name).ElementAt(indexPath.Row);
UITableViewCell cell = tableView.DequeueReusableCell("channel", indexPath); int count = model.Users.Count(u => u.ChannelId == channel.Id);
var content = cell.DefaultContentConfiguration; content.Text = channel.Name; content.SecondaryText = $"{count} users" + (channel.PasswordProtected ? " • protected" : ""); content.Image = UIImage.GetSystemImage(channel.Id == model.CurrentChannelId ? "checkmark.circle.fill" : "bubble.left"); cell.ContentConfiguration = content; cell.AccessibilityLabel = $"{channel.Name}, {count} users"; return cell;
base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "channel");
NavigationItem.RightBarButtonItems = [new("Users", UIBarButtonItemStyle.Plain, (_, _) => NavigationController?.PushViewController(new UsersController(model), true)),
new(UIBarButtonSystemItem.Add, (_, _) => NavigationController?.PushViewController(new ChannelEditorController(model, null), true))]; Reload();
}
public override async void RowSelected(UITableView tableView, NSIndexPath indexPath)
{
Channel channel = model.Channels.OrderBy(c => c.Order).ThenBy(c => c.Name).ElementAt(indexPath.Row); tableView.DeselectRow(indexPath, true);
if (channel.PasswordProtected)
{
UIAlertController prompt = UIAlertController.Create("Channel Password", channel.Name, UIAlertControllerStyle.Alert); prompt.AddTextField(f => { f.SecureTextEntry = true; f.Placeholder = "Password"; });
prompt.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); prompt.AddAction(UIAlertAction.Create("Join", UIAlertActionStyle.Default, async _ => { try { await model.JoinChannelAsync(channel.Id, prompt.TextFields![0].Text ?? ""); } catch (Exception e) { UiHelpers.ShowError(this, e); } })); PresentViewController(prompt, true, null); return;
}
try { await model.JoinChannelAsync(channel.Id); } catch (Exception e) { UiHelpers.ShowError(this, e); }
}
}
internal sealed class UsersController : UITableViewController
{
private readonly AppModel model;
private IReadOnlyList<User> Visible => model.Users.Where(u => u.ChannelId == model.CurrentChannelId).ToArray();
internal UsersController(AppModel model) { this.model = model; Title = "Users"; model.Changed += () => TableView.ReloadData(); }
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "user"); }
private void Reload() { TableView.ReloadData(); NavigationItem.RightBarButtonItems![1].Enabled = model.Client?.Permissions is { } p && (p.IsAdmin || p.CanCreateTempChannel); }
public override nint RowsInSection(UITableView tableView, nint section) => Visible.Count;
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath)
{
User user = Visible[indexPath.Row]; UITableViewCell cell = tableView.DequeueReusableCell("user", indexPath);
var content = cell.DefaultContentConfiguration; content.Text = user.Nickname + (user.Id == model.SelfUserId ? " (you)" : ""); content.SecondaryText = user.ServerMuted ? "server muted" : user.SelfMicMuted ? "muted" : user.IsGuest ? "guest" : "account"; content.Image = UIImage.GetSystemImage(user.ServerMuted || user.SelfMicMuted ? "mic.slash.fill" : "mic.fill"); cell.ContentConfiguration = content; cell.AccessibilityLabel = $"{content.Text}, {content.SecondaryText}"; return cell;
(Channel channel, int depth) = Visible[indexPath.Row]; UITableViewCell cell = tableView.DequeueReusableCell("channel", indexPath);
int count = model.Users.Count(user => user.ChannelId == channel.Id); var content = cell.DefaultContentConfiguration;
content.Text = new string(' ', depth * 3) + channel.Name; content.SecondaryText = string.IsNullOrEmpty(channel.Topic) ? $"{count} users" : $"{channel.Topic} • {count} users";
content.Image = UIImage.GetSystemImage(channel.Id == model.CurrentChannelId ? "checkmark.circle.fill" : channel.PasswordProtected ? "lock.fill" : "bubble.left"); cell.ContentConfiguration = content;
cell.AccessibilityLabel = $"{channel.Name}{(channel.Id == model.CurrentChannelId ? ", current" : "")}{(channel.PasswordProtected ? ", password protected" : "")}, {count} users"; return cell;
}
public override void RowSelected(UITableView tableView, NSIndexPath indexPath)
public override async void RowSelected(UITableView tableView, NSIndexPath indexPath)
{
User user = Visible[indexPath.Row]; tableView.DeselectRow(indexPath, true); if (user.Id == model.SelfUserId) return;
UIAlertController menu = UIAlertController.Create(user.Nickname, null, UIAlertControllerStyle.ActionSheet);
menu.AddAction(UIAlertAction.Create("Private message", UIAlertActionStyle.Default, _ => PromptPrivate(user)));
if (model.Client?.Permissions.CanKick == true || model.Client?.Permissions.IsAdmin == true) menu.AddAction(UIAlertAction.Create("Kick", UIAlertActionStyle.Destructive, async _ => await Run(() => model.Client!.KickUserAsync(user.Id))));
if (model.Client?.Permissions.CanBan == true || model.Client?.Permissions.IsAdmin == true) menu.AddAction(UIAlertAction.Create("Ban", UIAlertActionStyle.Destructive, async _ => await Run(() => model.Client!.BanUserAsync(user.Id))));
if (model.Client?.Permissions.IsAdmin == true) menu.AddAction(UIAlertAction.Create(user.ServerMuted ? "Server unmute" : "Server mute", UIAlertActionStyle.Default, async _ => await Run(() => model.Client!.SetServerMuteAsync(user.Id, !user.ServerMuted, user.ServerDeafened))));
menu.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); menu.PopoverPresentationController!.SourceView = tableView.CellAt(indexPath); PresentViewController(menu, true, null);
Channel channel = Visible[indexPath.Row].Channel; tableView.DeselectRow(indexPath, true);
if (channel.PasswordProtected) { PromptPassword(channel); return; }
try { await model.JoinChannelAsync(channel.Id); } catch (Exception exception) { UiHelpers.ShowError(this, exception); }
}
public override UISwipeActionsConfiguration? GetTrailingSwipeActionsConfiguration(UITableView tableView, NSIndexPath indexPath)
{
if (model.Client?.Permissions.IsAdmin != true) return null; Channel channel = Visible[indexPath.Row].Channel;
UIContextualAction edit = UIContextualAction.FromContextualActionStyle(UIContextualActionStyle.Normal, "Edit", (_, _, done) => { NavigationController?.PushViewController(new ChannelEditorController(model, channel), true); done(true); });
UIContextualAction delete = UIContextualAction.FromContextualActionStyle(UIContextualActionStyle.Destructive, "Delete", (_, _, done) => { ConfirmDelete(channel); done(true); });
delete.BackgroundColor = UIColor.SystemRed; return UISwipeActionsConfiguration.FromActions(channel.Id == 1 ? [edit] : [delete, edit]);
}
private void PromptPassword(Channel channel)
{
UIAlertController prompt = UIAlertController.Create("Channel Password", channel.Name, UIAlertControllerStyle.Alert); prompt.AddTextField(field => { field.SecureTextEntry = true; field.AccessibilityLabel = "Channel password"; });
prompt.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); prompt.AddAction(UIAlertAction.Create("Join", UIAlertActionStyle.Default, async _ => { try { await model.JoinChannelAsync(channel.Id, prompt.TextFields?[0].Text ?? ""); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } })); PresentViewController(prompt, true, null);
}
private void ConfirmDelete(Channel channel)
{
UIAlertController alert = UIAlertController.Create("Delete channel?", channel.Name, UIAlertControllerStyle.Alert); alert.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null));
alert.AddAction(UIAlertAction.Create("Delete", UIAlertActionStyle.Destructive, async _ => { try { GenericResult result = await model.RunAdminAsync(client => client.DeleteChannelAsync(channel.Id)); if (!result.Ok) throw new InvalidOperationException(result.Message); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } })); PresentViewController(alert, true, null);
}
private IReadOnlyList<(Channel, int)> Flatten()
{
var result = new List<(Channel, int)>(); void Add(uint parent, int depth) { foreach (Channel value in model.Channels.Where(channel => channel.ParentId == parent).OrderBy(channel => channel.Order).ThenBy(channel => channel.Name)) { result.Add((value, depth)); Add(value.Id, depth + 1); } }
Add(0, 0); return result;
}
private void PromptPrivate(User user) { UIAlertController p = UIAlertController.Create($"Message {user.Nickname}", null, UIAlertControllerStyle.Alert); p.AddTextField(f => f.Placeholder = "Message"); p.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); p.AddAction(UIAlertAction.Create("Send", UIAlertActionStyle.Default, _ => model.SendText(p.TextFields![0].Text ?? "", user.Id))); PresentViewController(p, true, null); }
private async Task Run(Func<Task<GenericResult>> command) { try { GenericResult result = await command(); if (!result.Ok) throw new InvalidOperationException(result.Message); } catch (Exception e) { UiHelpers.ShowError(this, e); } }
}
internal sealed class ChatController : UIViewController
@@ -75,54 +115,15 @@ internal sealed class ChatController : UIViewController
internal ChatController(AppModel model) { this.model = model; Title = "Chat"; model.Changed += Refresh; }
public override void ViewDidLoad()
{
base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemBackground; log.Editable = false; log.Font = UIFont.PreferredBody; log.TranslatesAutoresizingMaskIntoConstraints = false;
base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemBackground; log.Editable = false; log.Font = UIFont.PreferredBody; log.AccessibilityLabel = "Chat and activity timeline"; log.TranslatesAutoresizingMaskIntoConstraints = false;
UIButton send = UIButton.FromType(UIButtonType.System); send.SetTitle("Send", UIControlState.Normal); send.AccessibilityLabel = "Send message"; send.TranslatesAutoresizingMaskIntoConstraints = false; send.TouchUpInside += (_, _) => { model.SendText(compose.Text ?? ""); compose.Text = ""; };
compose.TranslatesAutoresizingMaskIntoConstraints = false; View.AddSubviews(log, compose, send);
NSLayoutConstraint.ActivateConstraints([log.TopAnchor.ConstraintEqualTo(View.SafeAreaLayoutGuide.TopAnchor), log.LeadingAnchor.ConstraintEqualTo(View.LeadingAnchor, 12), log.TrailingAnchor.ConstraintEqualTo(View.TrailingAnchor, -12), compose.TopAnchor.ConstraintEqualTo(log.BottomAnchor, 8), compose.LeadingAnchor.ConstraintEqualTo(View.LeadingAnchor, 12), compose.BottomAnchor.ConstraintEqualTo(View.KeyboardLayoutGuide.TopAnchor, -8), send.LeadingAnchor.ConstraintEqualTo(compose.TrailingAnchor, 8), send.TrailingAnchor.ConstraintEqualTo(View.TrailingAnchor, -12), send.CenterYAnchor.ConstraintEqualTo(compose.CenterYAnchor), compose.WidthAnchor.ConstraintGreaterThanOrEqualTo(120)]); Refresh();
}
private void Refresh() { log.Text = string.Join("\n", model.Messages.Select(m => $"[{m.Timestamp:t}] {(m.Private ? "[private] " : "")}{m.Sender}: {m.Text}")); if (log.Text.Length > 0) log.ScrollRangeToVisible(new(log.Text.Length - 1, 1)); }
}
internal sealed class SettingsController : UITableViewController
{
private readonly AppModel model; private readonly string[] rows = ["Join Voice", "Audio Preset", "Speaker Output", "Mute Microphone", "Deafen", "Share Screen Audio", "Accounts", "Disconnect"];
internal SettingsController(AppModel model) : base(UITableViewStyle.InsetGrouped) { this.model = model; Title = "Settings"; model.Changed += () => TableView.ReloadData(); }
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "setting"); }
public override nint RowsInSection(UITableView tableView, nint section) => rows.Length;
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath)
private void Refresh()
{
UITableViewCell cell = tableView.DequeueReusableCell("setting", indexPath); string title = rows[indexPath.Row];
if (indexPath.Row == 0) title = model.VoiceJoined ? "Leave Voice" : "Join Voice";
var content = cell.DefaultContentConfiguration; content.Text = title; content.SecondaryText = indexPath.Row == 1 ? IosAudioRouter.Shared.Preset.ToString() : null; cell.ContentConfiguration = content; cell.Accessory = indexPath.Row is 1 or 6 ? UITableViewCellAccessory.DisclosureIndicator : UITableViewCellAccessory.None; return cell;
IEnumerable<(DateTime Time, string Text)> chat = model.Messages.Select(message => (message.Timestamp, $"{(message.Private ? "[private] " : "")}{message.Sender}: {message.Text}"));
IEnumerable<(DateTime Time, string Text)> activity = model.Activity.Select(value => (value.Timestamp, $"• {value.Text}"));
log.Text = string.Join("\n", chat.Concat(activity).OrderBy(value => value.Time).Select(value => $"[{value.Time:t}] {value.Text}")); if (log.Text.Length > 0) log.ScrollRangeToVisible(new(log.Text.Length - 1, 1));
}
public override async void RowSelected(UITableView tableView, NSIndexPath indexPath)
{
tableView.DeselectRow(indexPath, true); try
{
switch (indexPath.Row)
{
case 0: await model.ToggleVoiceAsync(); break;
case 1: ShowPresets(); break;
case 2: IosAudioRouter.Shared.ForceSpeaker = !IosAudioRouter.Shared.ForceSpeaker; IosAudioEngine.Shared.Reconfigure(); break;
case 3: if (model.Client is { } c) c.SetSelfAudioState(!c.Audio.MicMuted, c.Audio.Deafened); break;
case 4: if (model.Client is { } d) d.SetSelfAudioState(d.Audio.MicMuted, !d.Audio.Deafened); break;
case 5: ShowBroadcastPicker(); break;
case 6: NavigationController?.PushViewController(new AccountsController(model), true); break;
case 7: await model.DisconnectAsync(); break;
}
}
catch (Exception e) { UiHelpers.ShowError(this, e); }
}
private void ShowPresets() { UIAlertController a = UIAlertController.Create("Audio Preset", null, UIAlertControllerStyle.ActionSheet); foreach (IosAudioPreset p in Enum.GetValues<IosAudioPreset>()) a.AddAction(UIAlertAction.Create(p.ToString(), UIAlertActionStyle.Default, _ => { IosAudioRouter.Shared.SelectPreset(p); TableView.ReloadData(); })); a.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); a.PopoverPresentationController!.SourceView = View; PresentViewController(a, true, null); }
private void ShowBroadcastPicker() { var picker = new RPSystemBroadcastPickerView(new CoreGraphics.CGRect(0, 0, 60, 60)) { PreferredExtension = IosConstants.BroadcastExtension, ShowsMicrophoneButton = false }; UIAlertController a = UIAlertController.Create("Screen Audio", "Tap the broadcast button, then choose Start Broadcast.", UIAlertControllerStyle.Alert); a.View.AddSubview(picker); picker.Center = new(a.View.Bounds.GetMidX(), 110); a.AddAction(UIAlertAction.Create("Done", UIAlertActionStyle.Cancel, null)); PresentViewController(a, true, null); }
}
internal sealed class AccountsController : UITableViewController
{
private readonly AppModel model; private IReadOnlyList<AccountEntry> accounts = [];
internal AccountsController(AppModel model) { this.model = model; Title = "Accounts"; }
public override async void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "account"); NavigationItem.RightBarButtonItem = new(UIBarButtonSystemItem.Add, (_, _) => PromptCreate()); try { accounts = await model.Client!.ListAccountsAsync(); TableView.ReloadData(); } catch (Exception e) { UiHelpers.ShowError(this, e); } }
public override nint RowsInSection(UITableView tableView, nint section) => accounts.Count;
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) { UITableViewCell c = tableView.DequeueReusableCell("account", indexPath); var x = c.DefaultContentConfiguration; x.Text = accounts[indexPath.Row].Username; x.SecondaryText = accounts[indexPath.Row].IsAdmin ? "Administrator" : "Account"; c.ContentConfiguration = x; return c; }
private void PromptCreate() { UIAlertController p = UIAlertController.Create("Create Account", null, UIAlertControllerStyle.Alert); p.AddTextField(f => f.Placeholder = "Username"); p.AddTextField(f => { f.Placeholder = "Password"; f.SecureTextEntry = true; }); p.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); p.AddAction(UIAlertAction.Create("Create", UIAlertActionStyle.Default, async _ => { try { GenericResult result = await model.Client!.CreateAccountAsync(p.TextFields![0].Text ?? "", p.TextFields[1].Text ?? ""); if (!result.Ok) throw new InvalidOperationException(result.Message); accounts = await model.Client.ListAccountsAsync(); TableView.ReloadData(); } catch (Exception e) { UiHelpers.ShowError(this, e); } })); PresentViewController(p, true, null); }
}
@@ -15,8 +15,9 @@ internal sealed class RootViewController : UIViewController
bool main = model.IsConnected;
if (current is MainTabController && main || current is UINavigationController && !main) return;
UIViewController next = main ? new MainTabController(model) : new UINavigationController(new ServerListController(model));
if (current is not null) { current.WillMoveToParentViewController(null); current.View.RemoveFromSuperview(); current.RemoveFromParentViewController(); }
AddChildViewController(next); next.View.Frame = View!.Bounds; next.View.AutoresizingMask = UIViewAutoresizing.All; View.AddSubview(next.View); next.DidMoveToParentViewController(this); current = next;
if (current is not null) { current.WillMoveToParentViewController(null); current.View!.RemoveFromSuperview(); current.RemoveFromParentViewController(); }
UIView rootView = View!, nextView = next.View!;
AddChildViewController(next); nextView.Frame = rootView.Bounds; nextView.AutoresizingMask = UIViewAutoresizing.All; rootView.AddSubview(nextView); next.DidMoveToParentViewController(this); current = next;
}
private void ShowIdentity(ServerIdentityChallenge challenge)
@@ -0,0 +1,95 @@
using AVFoundation;
using AVKit;
using ReplayKit;
using UIKit;
using VoiceCat.Audio;
using Voicecat.V1;
namespace VoiceCat.iOS;
internal sealed class SettingsController : UITableViewController
{
private readonly AppModel model;
internal SettingsController(AppModel model) : base(UITableViewStyle.InsetGrouped) { this.model = model; Title = "Settings"; model.Changed += Reload; IosAudioRouter.Shared.Changed += Reload; }
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "setting"); }
private void Reload() => TableView.ReloadData();
public override nint NumberOfSections(UITableView tableView) => 5;
public override nint RowsInSection(UITableView tableView, nint section) => section switch { 0 => 4, 1 => 4, 2 => 5, 3 => model.Client?.Permissions is { } p && (p.IsAdmin || p.CanAdminAccounts) ? 1 : 0, _ => 2 };
public override string? TitleForHeader(UITableView tableView, nint section) => section switch { 0 => "Audio", 1 => "Voice", 2 => "Notifications", 3 => "Administration", _ => "Server" };
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath path)
{
UITableViewCell cell = tableView.DequeueReusableCell("setting", path); cell.AccessoryView = null; cell.Accessory = UITableViewCellAccessory.None;
string title = path.Section switch
{
0 => path.Row switch { 0 => "Audio preset", 1 => "Speaker output", 2 => "Advanced audio", _ => "Share screen audio" },
1 => path.Row switch { 0 => "Input mode", 1 => $"VAD threshold: {model.Settings.VadThreshold:0.000}", 2 => $"Microphone volume: {model.Settings.InputGain:P0}", _ => "Microphone noise reduction" },
2 => path.Row switch { 0 => "Event sounds", 1 => $"Sound volume: {model.Settings.EventVolume:P0}", 2 => "Speak events", 3 => "Own voice activity sounds", _ => "Push-to-talk cue" },
3 => "Manage accounts", _ => path.Row == 0 ? "Disconnect" : "VoiceCat 0.0.1"
};
var content = cell.DefaultContentConfiguration; content.Text = title; content.SecondaryText = path.Section == 0 && path.Row == 0 ? IosAudioRouter.Shared.Preset.ToString() : path.Section == 1 && path.Row == 0 ? model.Settings.InputMode.ToString() : path.Section == 0 && path.Row == 3 && model.ScreenSharing ? "Sharing" : null; cell.ContentConfiguration = content; cell.AccessibilityLabel = title;
if (path.Section == 0 && path.Row == 1) cell.AccessoryView = Toggle(IosAudioRouter.Shared.ForceSpeaker, "Speaker output", (_, _) => IosAudioRouter.Shared.SetForceSpeaker(((UISwitch)cell.AccessoryView!).On));
else if (path.Section == 1 && path.Row == 3) cell.AccessoryView = Toggle(model.Settings.InputNoiseReduction, title, (_, _) => { model.Settings.InputNoiseReduction = ((UISwitch)cell.AccessoryView!).On; model.ApplyVoiceSettings(); });
else if (path.Section == 2 && path.Row is 0 or 2 or 3 or 4) { bool value = path.Row switch { 0 => model.Settings.EventSounds, 2 => model.Settings.SpokenEvents, 3 => model.Settings.SelfTalkSounds, _ => model.Settings.PushToTalkSound }; int row = path.Row; cell.AccessoryView = Toggle(value, title, (_, _) => { bool on = ((UISwitch)cell.AccessoryView!).On; if (row == 0) model.Settings.EventSounds = on; else if (row == 2) model.Settings.SpokenEvents = on; else if (row == 3) model.Settings.SelfTalkSounds = on; else model.Settings.PushToTalkSound = on; model.Save(); }); }
else if (path.Section == 0 && path.Row == 2 || path.Section == 3) cell.Accessory = UITableViewCellAccessory.DisclosureIndicator;
return cell;
}
public override async void RowSelected(UITableView tableView, NSIndexPath path)
{
tableView.DeselectRow(path, true); try
{
if (path.Section == 0 && path.Row == 0) Choice("Audio preset", Enum.GetValues<IosAudioPreset>().Select(value => value.ToString()).ToArray(), index => IosAudioRouter.Shared.SelectPreset(Enum.GetValues<IosAudioPreset>()[index]));
else if (path.Section == 0 && path.Row == 2) NavigationController?.PushViewController(new AdvancedAudioController(), true);
else if (path.Section == 0 && path.Row == 3) ShowScreenSharing();
else if (path.Section == 1 && path.Row == 0) Choice("Input mode", ["Voice activation", "Push to talk", "Always on"], index => { model.Settings.InputMode = (AudioInputMode)index; model.ApplyVoiceSettings(); });
else if (path.Section == 1 && path.Row == 1) Slider("Voice activation threshold", 0.001f, 0.1f, model.Settings.VadThreshold, value => { model.Settings.VadThreshold = value; model.ApplyVoiceSettings(); });
else if (path.Section == 1 && path.Row == 2) Slider("Microphone volume", 0, 4, model.Settings.InputGain, value => { model.Settings.InputGain = value; model.ApplyVoiceSettings(); });
else if (path.Section == 2 && path.Row == 1) Slider("Sound volume", 0, 1, model.Settings.EventVolume, value => { model.Settings.EventVolume = value; model.Save(); });
else if (path.Section == 3) NavigationController?.PushViewController(new AccountsController(model), true);
else if (path.Section == 4 && path.Row == 0) await model.DisconnectAsync();
}
catch (Exception exception) { UiHelpers.ShowError(this, exception); }
}
private static UISwitch Toggle(bool value, string label, EventHandler changed) { var toggle = new UISwitch { On = value, AccessibilityLabel = label }; toggle.ValueChanged += changed; return toggle; }
private void Choice(string title, string[] values, Action<int> selected) { UIAlertController alert = UIAlertController.Create(title, null, UIAlertControllerStyle.ActionSheet); for (int i = 0; i < values.Length; i++) { int index = i; alert.AddAction(UIAlertAction.Create(values[i], UIAlertActionStyle.Default, _ => selected(index))); } alert.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); alert.PopoverPresentationController!.SourceView = View!; PresentViewController(alert, true, null); }
private void Slider(string title, float minimum, float maximum, float current, Action<float> changed) { var slider = new UISlider(new CoreGraphics.CGRect(16, 48, 238, 28)) { MinValue = minimum, MaxValue = maximum, Value = current, AccessibilityLabel = title }; UIAlertController alert = UIAlertController.Create(title, null, UIAlertControllerStyle.Alert); alert.View!.AddSubview(slider); alert.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); alert.AddAction(UIAlertAction.Create("Apply", UIAlertActionStyle.Default, _ => changed(slider.Value))); PresentViewController(alert, true, null); }
private void ShowScreenSharing()
{
if (OperatingSystem.IsIOSVersionAtLeast(27)) { IosScreenCapture.Present(); return; }
#pragma warning disable CA1422
var picker = new RPSystemBroadcastPickerView(new CoreGraphics.CGRect(0, 0, 60, 60)) { PreferredExtension = IosConstants.BroadcastExtension, ShowsMicrophoneButton = false };
#pragma warning restore CA1422
foreach (UIView view in picker.Subviews) view.AccessibilityLabel = model.ScreenSharing ? "Stop sharing screen audio" : "Share screen audio";
UIAlertController alert = UIAlertController.Create("Screen Audio", "Tap the broadcast button, then choose Start Broadcast.", UIAlertControllerStyle.Alert); UIView alertView = alert.View!; alertView.AddSubview(picker); picker.Center = new(alertView.Bounds.GetMidX(), 110); alert.AddAction(UIAlertAction.Create("Done", UIAlertActionStyle.Cancel, null)); PresentViewController(alert, true, null);
}
}
internal sealed class AdvancedAudioController : UITableViewController
{
private readonly IosAudioRouter router = IosAudioRouter.Shared;
internal AdvancedAudioController() : base(UITableViewStyle.InsetGrouped) { Title = "Advanced Audio"; router.Changed += () => TableView.ReloadData(); }
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "audio"); router.RefreshRoutes(); }
public override nint RowsInSection(UITableView tableView, nint section) => 8;
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath path)
{
string title = path.Row switch { 0 => "Input port", 1 => "Microphone orientation", 2 => "Polar pattern", 3 => "Microphone mode", 4 => "Capture channels", 5 => "Bluetooth mode", 6 => "Voice processing and AGC", _ => "Current outputs" };
UITableViewCell cell = tableView.DequeueReusableCell("audio", path); var content = cell.DefaultContentConfiguration; content.Text = title; content.SecondaryText = path.Row switch { 0 => router.Inputs.FirstOrDefault(value => value.Id == router.SelectedInputId)?.Name ?? "Default", 1 => router.DataSources().FirstOrDefault(value => value.Id == router.SelectedDataSourceId)?.Name ?? "Default", 2 => router.SelectedPolarPattern.ToString(), 3 => router.MicMode.ToString(), 4 => router.CaptureChannels == 2 ? "Stereo" : "Mono", 5 => router.BluetoothMode.ToString(), 6 => router.UsesVoiceProcessing ? "AEC/NS on" + (router.AutomaticGainControl ? ", AGC on" : ", AGC off") : "Unavailable or off", _ => string.Join(", ", router.Outputs.Select(value => value.Name)) }; cell.ContentConfiguration = content; cell.AccessibilityLabel = title + ", " + content.SecondaryText; cell.Accessory = path.Row < 7 ? UITableViewCellAccessory.DisclosureIndicator : UITableViewCellAccessory.None; return cell;
}
public override void RowSelected(UITableView tableView, NSIndexPath path) { tableView.DeselectRow(path, true); if (path.Row == 0) Menu("Input port", router.Inputs.Select(value => value.Name).Prepend("Default").ToArray(), index => router.SelectInput(index == 0 ? null : router.Inputs[index - 1].Id)); else if (path.Row == 1) { var values = router.DataSources(); Menu("Microphone orientation", values.Select(value => value.Name).Prepend("Default").ToArray(), index => router.SelectDataSource(index == 0 ? null : values[index - 1].Id)); } else if (path.Row == 2) Menu("Polar pattern", Enum.GetValues<AVAudioDataSourcePolarPattern>().Select(value => value.ToString()).ToArray(), index => router.SelectPolarPattern(Enum.GetValues<AVAudioDataSourcePolarPattern>()[index])); else if (path.Row == 3) Menu("Microphone mode", Enum.GetValues<IosMicMode>().Select(value => value.ToString()).ToArray(), index => router.SetMicMode(Enum.GetValues<IosMicMode>()[index])); else if (path.Row == 4) Menu("Capture channels", ["Mono", "Stereo"], index => router.SetCaptureChannels(index + 1)); else if (path.Row == 5) Menu("Bluetooth mode", Enum.GetValues<IosBluetoothMode>().Select(value => value.ToString()).ToArray(), index => router.SetBluetoothMode(Enum.GetValues<IosBluetoothMode>()[index])); else if (path.Row == 6) Menu("Voice processing", ["Off", "On without AGC", "On with AGC"], index => { router.SetVoiceProcessing(index != 0); router.SetAutomaticGainControl(index == 2); }); }
private void Menu(string title, string[] values, Action<int> selected) { UIAlertController alert = UIAlertController.Create(title, null, UIAlertControllerStyle.ActionSheet); for (int i = 0; i < values.Length; i++) { int index = i; alert.AddAction(UIAlertAction.Create(values[i], UIAlertActionStyle.Default, _ => selected(index))); } alert.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); alert.PopoverPresentationController!.SourceView = View!; PresentViewController(alert, true, null); }
}
internal sealed class AccountsController : UITableViewController
{
private readonly AppModel model; private IReadOnlyList<AccountEntry> accounts = [];
internal AccountsController(AppModel model) { this.model = model; Title = "Accounts"; }
public override async void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "account"); NavigationItem.RightBarButtonItem = new(UIBarButtonSystemItem.Add, (_, _) => PromptCreate()); await Refresh(); }
public override nint RowsInSection(UITableView tableView, nint section) => accounts.Count;
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath path) { AccountEntry account = accounts[path.Row]; UITableViewCell cell = tableView.DequeueReusableCell("account", path); var content = cell.DefaultContentConfiguration; content.Text = account.Username; content.SecondaryText = $"{(account.IsAdmin ? "Administrator" : "Account")} • created {DateTimeOffset.FromUnixTimeMilliseconds((long)account.CreatedAtUnixMs):d}"; cell.ContentConfiguration = content; cell.AccessibilityLabel = content.Text + ", " + content.SecondaryText; return cell; }
public override UISwipeActionsConfiguration GetTrailingSwipeActionsConfiguration(UITableView tableView, NSIndexPath path) { AccountEntry account = accounts[path.Row]; UIContextualAction reset = UIContextualAction.FromContextualActionStyle(UIContextualActionStyle.Normal, "Reset password", (_, _, done) => { PromptReset(account); done(true); }); UIContextualAction delete = UIContextualAction.FromContextualActionStyle(UIContextualActionStyle.Destructive, "Delete", (_, _, done) => { ConfirmDelete(account); done(true); }); return UISwipeActionsConfiguration.FromActions([delete, reset]); }
private async Task Refresh() { try { accounts = await model.Client!.ListAccountsAsync(); TableView.ReloadData(); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } }
private void PromptCreate() => Credentials("Create Account", "Create", async (name, password) => await Execute(client => client.CreateAccountAsync(name, password)));
private void PromptReset(AccountEntry account) => Credentials($"Reset {account.Username}", "Reset", async (_, password) => await Execute(client => client.ResetPasswordAsync(account.Username, password)), account.Username, false);
private void ConfirmDelete(AccountEntry account) { UIAlertController alert = UIAlertController.Create("Delete account?", account.Username, UIAlertControllerStyle.Alert); alert.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); alert.AddAction(UIAlertAction.Create("Delete", UIAlertActionStyle.Destructive, async _ => await Execute(client => client.DeleteAccountAsync(account.Username)))); PresentViewController(alert, true, null); }
private void Credentials(string title, string action, Func<string, string, Task> run, string username = "", bool editName = true) { UIAlertController prompt = UIAlertController.Create(title, null, UIAlertControllerStyle.Alert); prompt.AddTextField(field => { field.Placeholder = "Username"; field.Text = username; field.Enabled = editName; field.AccessibilityLabel = "Username"; }); prompt.AddTextField(field => { field.Placeholder = "Password"; field.SecureTextEntry = true; field.AccessibilityLabel = "New password"; }); prompt.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); prompt.AddAction(UIAlertAction.Create(action, UIAlertActionStyle.Default, async _ => await run(prompt.TextFields?[0].Text ?? "", prompt.TextFields?[1].Text ?? ""))); PresentViewController(prompt, true, null); }
private async Task Execute(Func<VoiceCat.Core.VoiceCatClient, Task<GenericResult>> operation) { try { GenericResult result = await model.RunAdminAsync(operation); if (!result.Ok) throw new InvalidOperationException(result.Message); await Refresh(); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } }
}
@@ -23,6 +23,7 @@
<VoiceCatBroadcastSdk Condition="'$(VoiceCatBroadcastSdk)' == ''">iphoneos</VoiceCatBroadcastSdk>
<VoiceCatBroadcastArch>arm64</VoiceCatBroadcastArch>
<VoiceCatBroadcastOutput>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)obj/$(Configuration)/$(TargetFramework)/$(RuntimeIdentifier)/broadcast'))</VoiceCatBroadcastOutput>
<VoiceCatIosCaptureOutput>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)obj/$(Configuration)/$(TargetFramework)/$(RuntimeIdentifier)/capture'))</VoiceCatIosCaptureOutput>
<_ComputePublishLocationDependsOn>VoiceCatPrepareIosNativeAssets;$(_ComputePublishLocationDependsOn)</_ComputePublishLocationDependsOn>
</PropertyGroup>
<ItemGroup>
@@ -31,10 +32,15 @@
<Kind>Static</Kind>
<ForceLoad>true</ForceLoad>
</NativeReference>
<NativeReference Include="$(VoiceCatIosCaptureOutput)/libvoicecat_ios_capture.a">
<Kind>Static</Kind>
<ForceLoad>true</ForceLoad>
<Frameworks Condition="!$([System.String]::Copy('$(RuntimeIdentifier)').StartsWith('iossimulator'))">AVFoundation CoreMedia</Frameworks>
</NativeReference>
<AdditionalAppExtensions Include="$(VoiceCatBroadcastOutput)">
<Name>VoiceCatBroadcast</Name>
<BuildOutput>.</BuildOutput>
<CodesignEntitlements>$(MSBuildThisFileDirectory)../../iOS/VoiceCatBroadcast/VoiceCatBroadcast.entitlements</CodesignEntitlements>
<CodesignEntitlements>$(VoiceCatBroadcastOutput)/VoiceCatBroadcast.xcent</CodesignEntitlements>
</AdditionalAppExtensions>
<BundleResource Include="../../Sources/VoiceCatCore/Sounds/*.wav" Link="Sounds/%(Filename)%(Extension)" />
<ImageAsset Include="../../iOS/VoiceCatiOS/Assets.xcassets/**" Link="Assets.xcassets/%(RecursiveDir)%(Filename)%(Extension)" />
@@ -42,6 +48,9 @@
<Target Name="VoiceCatBuildBroadcastExtension" BeforeTargets="_ResolveAppExtensionReferences">
<Exec Command="&quot;$(MSBuildThisFileDirectory)build-broadcast-extension.sh&quot; &quot;$(Configuration)&quot; &quot;$(VoiceCatBroadcastSdk)&quot; &quot;$(VoiceCatBroadcastArch)&quot; &quot;$(VoiceCatBroadcastOutput)&quot;" />
</Target>
<Target Name="VoiceCatBuildIosCaptureBridge" BeforeTargets="PrepareForBuild">
<Exec Command="&quot;$(MSBuildThisFileDirectory)build-screen-capture-bridge.sh&quot; &quot;$(VoiceCatBroadcastSdk)&quot; &quot;$(VoiceCatBroadcastArch)&quot; &quot;$(VoiceCatIosCaptureOutput)&quot;" />
</Target>
<Target Name="VoiceCatPrepareIosNativeAssets">
<ItemGroup>
<ResolvedFileToPublish Remove="@(ResolvedFileToPublish)" Condition="'%(Filename)%(Extension)' == 'libvoicecat_media.dylib' or '%(Filename)%(Extension)' == 'voicecat_media.dll' or '%(Filename)%(Extension)' == 'libvoicecat_media.so'" />
@@ -9,6 +9,10 @@ script_dir="${0:A:h}"
project="$script_dir/../../iOS/VoiceCatiOS.xcodeproj"
mkdir -p "$output"
signing=()
if [[ "$sdk" == "iphonesimulator" ]]; then
signing+=(CODE_SIGNING_ALLOWED=NO)
fi
xcodebuild \
-project "$project" \
-target VoiceCatBroadcast \
@@ -16,5 +20,12 @@ xcodebuild \
-sdk "$sdk" \
-arch "$architecture" \
CONFIGURATION_BUILD_DIR="$output" \
CODE_SIGNING_ALLOWED=NO \
"${signing[@]}" \
build
xcent="$script_dir/../../iOS/build/VoiceCatiOS.build/${configuration}-${sdk}/VoiceCatBroadcast.build/VoiceCatBroadcast.appex.xcent"
if [[ -f "$xcent" ]]; then
cp "$xcent" "$output/VoiceCatBroadcast.xcent"
else
cp "$script_dir/../../iOS/VoiceCatBroadcast/VoiceCatBroadcast.entitlements" "$output/VoiceCatBroadcast.xcent"
fi
@@ -0,0 +1,17 @@
#!/bin/sh
set -eu
sdk="$1"
arch="$2"
output="$3"
root="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
mkdir -p "$output"
sdk_path="$(xcrun --sdk "$sdk" --show-sdk-path)"
target="arm64-apple-ios18.0"
[ "$sdk" = "iphonesimulator" ] && target="arm64-apple-ios18.0-simulator"
source="$root/native/ios_screen_capture.m"
flags="-fobjc-arc -fmodules -fmodules-cache-path=$output/module-cache -Wno-unguarded-availability-new"
if [ "$sdk" = "iphonesimulator" ]; then source="$root/native/ios_screen_capture_stub.c"; flags=""; fi
# shellcheck disable=SC2086
xcrun --sdk "$sdk" clang -target "$target" -isysroot "$sdk_path" $flags -Werror -c "$source" -o "$output/ios_screen_capture.o"
xcrun --sdk "$sdk" ar rcs "$output/libvoicecat_ios_capture.a" "$output/ios_screen_capture.o"
@@ -0,0 +1,123 @@
#import <AVFoundation/AVFoundation.h>
#import <CoreMedia/CoreMedia.h>
#import <Foundation/Foundation.h>
#import <ScreenCaptureKit/ScreenCaptureKit.h>
#import <fcntl.h>
#import <stdatomic.h>
#import <sys/mman.h>
#import <sys/stat.h>
#import <unistd.h>
enum { VCRingHeader = 64, VCRingCapacity = 96000 };
@interface VCScreenCaptureBridge : NSObject <SCContentSharingPickerObserver, SCStreamOutput, SCStreamDelegate>
@property(nonatomic) SCStream *stream;
@property(nonatomic) AVAudioConverter *converter;
@property(nonatomic) AVAudioFormat *inputFormat;
@property(nonatomic) AVAudioFormat *outputFormat;
@property(nonatomic) NSString *ringPath;
@property(nonatomic) int ringFd;
@property(nonatomic) void *ringMap;
@end
@implementation VCScreenCaptureBridge
static SCContentSharingPicker *VCSharedPicker(void) {
Class pickerClass = NSClassFromString(@"SCContentSharingPicker");
return pickerClass ? [pickerClass performSelector:@selector(sharedPicker)] : nil;
}
- (instancetype)init {
if ((self = [super init])) {
_ringFd = -1;
_outputFormat = [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatInt16 sampleRate:48000 channels:2 interleaved:YES];
}
return self;
}
- (void)openRing {
if (_ringMap || !_ringPath) return;
[[NSFileManager defaultManager] createDirectoryAtPath:[_ringPath stringByDeletingLastPathComponent] withIntermediateDirectories:YES attributes:nil error:nil];
_ringFd = open(_ringPath.fileSystemRepresentation, O_RDWR | O_CREAT, 0644);
size_t size = VCRingHeader + VCRingCapacity * sizeof(int16_t);
if (_ringFd < 0 || ftruncate(_ringFd, (off_t)size) != 0) return;
_ringMap = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, _ringFd, 0);
if (_ringMap == MAP_FAILED) _ringMap = NULL;
if (_ringMap && *(uint32_t *)_ringMap != 0x56434252) {
memset(_ringMap, 0, VCRingHeader); *(uint32_t *)((uint8_t *)_ringMap + 4) = 1; atomic_thread_fence(memory_order_seq_cst); *(uint32_t *)_ringMap = 0x56434252;
}
}
- (void)setRingActive:(BOOL)active {
[self openRing]; if (!_ringMap) return;
if (active) { *(uint32_t *)((uint8_t *)_ringMap + 8) = 2; *(uint32_t *)((uint8_t *)_ringMap + 12) = 48000; }
atomic_thread_fence(memory_order_seq_cst); *(uint32_t *)((uint8_t *)_ringMap + 16) = active ? 1 : 0;
}
- (void)push:(const int16_t *)samples count:(NSUInteger)count {
if (!_ringMap || count == 0 || count > VCRingCapacity) return;
uint8_t *base = _ringMap; uint64_t write = *(uint64_t *)(base + 24); atomic_thread_fence(memory_order_seq_cst); uint64_t read = *(uint64_t *)(base + 32);
if (VCRingCapacity - (write - read) < count) return;
int16_t *data = (int16_t *)(base + VCRingHeader); NSUInteger index = write % VCRingCapacity;
NSUInteger first = MIN(count, VCRingCapacity - index); memcpy(data + index, samples, first * sizeof(int16_t));
if (first < count) memcpy(data, samples + first, (count - first) * sizeof(int16_t));
atomic_thread_fence(memory_order_seq_cst); *(uint64_t *)(base + 24) = write + count;
}
- (void)present:(NSString *)path API_AVAILABLE(ios(27.0)) {
self.ringPath = path; [self openRing]; SCContentSharingPicker *picker = VCSharedPicker();
Class configurationClass = NSClassFromString(@"SCContentSharingPickerConfiguration");
SCContentSharingPickerConfiguration *configuration = [configurationClass new];
if (!picker || !configuration) return;
configuration.showsMicrophoneControl = NO; configuration.showsCameraControl = NO; picker.defaultConfiguration = configuration;
[picker addObserver:self]; picker.active = YES; [picker presentPickerUsingContentStyle:SCShareableContentStyleDisplay];
}
- (void)stop {
[self.stream stopCaptureWithCompletionHandler:^(__unused NSError *error) {}]; self.stream = nil; [self setRingActive:NO];
}
- (void)contentSharingPicker:(SCContentSharingPicker *)picker didUpdateWithFilter:(SCContentFilter *)filter forStream:(SCStream *)stream API_AVAILABLE(ios(27.0)) {
[self stop]; Class configurationClass = NSClassFromString(@"SCStreamConfiguration"); Class streamClass = NSClassFromString(@"SCStream");
SCStreamConfiguration *configuration = [configurationClass new]; if (!configuration || !streamClass) return; configuration.capturesAudio = YES;
configuration.excludesCurrentProcessAudio = YES; configuration.sampleRate = 48000; configuration.channelCount = 2;
configuration.width = 2; configuration.height = 2;
self.stream = [[streamClass alloc] initWithFilter:filter configuration:configuration delegate:self]; NSError *error = nil;
if (![self.stream addStreamOutput:self type:SCStreamOutputTypeAudio sampleHandlerQueue:dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0) error:&error]) { self.stream = nil; return; }
[self.stream startCaptureWithCompletionHandler:^(NSError *captureError) { [self setRingActive:captureError == nil]; }];
}
- (void)contentSharingPicker:(SCContentSharingPicker *)picker didCancelForStream:(SCStream *)stream API_AVAILABLE(ios(27.0)) { [self stop]; }
- (void)contentSharingPickerStartDidFailWithError:(NSError *)error API_AVAILABLE(ios(27.0)) { [self stop]; }
- (void)stream:(SCStream *)stream didStopWithError:(NSError *)error { [self stop]; }
- (void)stream:(SCStream *)stream didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer ofType:(SCStreamOutputType)type {
if (type != SCStreamOutputTypeAudio || !CMSampleBufferDataIsReady(sampleBuffer)) return;
CMAudioFormatDescriptionRef description = CMSampleBufferGetFormatDescription(sampleBuffer); const AudioStreamBasicDescription *asbd = CMAudioFormatDescriptionGetStreamBasicDescription(description);
if (!asbd) return; AVAudioFormat *format = [[AVAudioFormat alloc] initWithStreamDescription:asbd]; AVAudioFrameCount frames = (AVAudioFrameCount)CMSampleBufferGetNumSamples(sampleBuffer);
AVAudioPCMBuffer *input = [[AVAudioPCMBuffer alloc] initWithPCMFormat:format frameCapacity:frames]; input.frameLength = frames;
if (CMSampleBufferCopyPCMDataIntoAudioBufferList(sampleBuffer, 0, frames, input.mutableAudioBufferList) != noErr) return;
if (!self.converter || ![self.inputFormat isEqual:format]) { self.inputFormat = format; self.converter = [[AVAudioConverter alloc] initFromFormat:format toFormat:self.outputFormat]; }
AVAudioFrameCount capacity = (AVAudioFrameCount)ceil(frames * 48000.0 / format.sampleRate) + 64; AVAudioPCMBuffer *output = [[AVAudioPCMBuffer alloc] initWithPCMFormat:self.outputFormat frameCapacity:capacity];
__block BOOL supplied = NO; NSError *error = nil; AVAudioConverterOutputStatus status = [self.converter convertToBuffer:output error:&error withInputFromBlock:^AVAudioBuffer *(AVAudioPacketCount count, AVAudioConverterInputStatus *inputStatus) { if (supplied) { *inputStatus = AVAudioConverterInputStatus_NoDataNow; return nil; } supplied = YES; *inputStatus = AVAudioConverterInputStatus_HaveData; return input; }];
if (status == AVAudioConverterOutputStatus_Error || output.frameLength == 0 || !output.int16ChannelData) return;
[self push:output.int16ChannelData[0] count:output.frameLength * 2];
}
- (void)dealloc {
[self setRingActive:NO]; if (_ringMap) munmap(_ringMap, VCRingHeader + VCRingCapacity * sizeof(int16_t)); if (_ringFd >= 0) close(_ringFd);
}
@end
static VCScreenCaptureBridge *bridge;
int vc_ios_screen_capture_available(void) {
if (@available(iOS 27.0, *)) return VCSharedPicker().available ? 1 : 0;
return 0;
}
void vc_ios_screen_capture_present(const char *ring_path) {
if (@available(iOS 27.0, *)) { if (!bridge) bridge = [VCScreenCaptureBridge new]; [bridge present:[NSString stringWithUTF8String:ring_path]]; }
}
void vc_ios_screen_capture_stop(void) { [bridge stop]; }
@@ -0,0 +1,3 @@
int vc_ios_screen_capture_available(void) { return 0; }
void vc_ios_screen_capture_present(const char *ring_path) { (void)ring_path; }
void vc_ios_screen_capture_stop(void) {}
+36
View File
@@ -0,0 +1,36 @@
#!/bin/sh
set -eu
root="$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd)"
configuration="${CONFIGURATION:-Debug}"
output="$root/dist/ios-managed-device"
dotnet_host="${VOICECAT_DOTNET:-/usr/local/share/dotnet/dotnet}"
while [ "$#" -gt 0 ]; do
case "$1" in
--configuration) configuration="$2"; shift 2 ;;
--output) output="$2"; shift 2 ;;
-h|--help)
echo "usage: $0 [--configuration Debug|Release] [--output DIR]"
echo "optional env: VOICECAT_DOTNET, VOICECAT_CODESIGN_KEY, VOICECAT_CODESIGN_PROVISION"
exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
project="$root/clients/apple/dotnet/VoiceCat.iOS/VoiceCat.iOS.csproj"
"$root/dotnet/build-native-ios.sh"
"$dotnet_host" restore "$project" --locked-mode -p:VoiceCatIosStatic=true
"$dotnet_host" restore "$project" --locked-mode -r ios-arm64 --no-dependencies
set -- build "$project" -c "$configuration" -r ios-arm64 --no-restore -m:1
if [ -n "${VOICECAT_CODESIGN_KEY:-}" ]; then set -- "$@" -p:CodesignKey="$VOICECAT_CODESIGN_KEY"; fi
if [ -n "${VOICECAT_CODESIGN_PROVISION:-}" ]; then set -- "$@" -p:CodesignProvision="$VOICECAT_CODESIGN_PROVISION"; fi
"$dotnet_host" "$@"
app="$root/clients/apple/dotnet/VoiceCat.iOS/bin/$configuration/net10.0-ios27.0/ios-arm64/VoiceCat.iOS.app"
[ -d "$app" ] || { echo "device app was not produced at $app" >&2; exit 1; }
mkdir -p "$output"
/usr/bin/ditto "$app" "$output/VoiceCat.iOS.app"
/usr/bin/codesign --verify --deep --strict "$output/VoiceCat.iOS.app"
echo "$output/VoiceCat.iOS.app"
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
set -eu
root="$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd)"
device="${VOICECAT_IOS_DEVICE:-}"
configuration="${CONFIGURATION:-Debug}"
build=true
console=false
while [ "$#" -gt 0 ]; do
case "$1" in
--device) device="$2"; shift 2 ;;
--configuration) configuration="$2"; shift 2 ;;
--no-build) build=false; shift ;;
--console) console=true; shift ;;
--list) xcrun devicectl list devices; exit 0 ;;
-h|--help)
echo "usage: $0 --device NAME-OR-UDID [--configuration Debug|Release] [--no-build] [--console]"
echo "use --list to show devices, or set VOICECAT_IOS_DEVICE"
exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
[ -n "$device" ] || { echo "pass --device NAME-OR-UDID or set VOICECAT_IOS_DEVICE" >&2; exit 2; }
app="$root/dist/ios-managed-device/VoiceCat.iOS.app"
if $build; then "$root/clients/apple/dotnet/build-ios-device.sh" --configuration "$configuration"; fi
[ -d "$app" ] || { echo "managed device app not found at $app; run build-ios-device.sh first" >&2; exit 1; }
xcrun devicectl device install app --device "$device" "$app"
if $console; then
xcrun devicectl device process launch --device "$device" --terminate-existing --console me.iamtalon.voicecat
else
xcrun devicectl device process launch --device "$device" --terminate-existing me.iamtalon.voicecat
fi
+4 -3
View File
@@ -1,8 +1,9 @@
# iOS broadcast-audio ring format
The ReplayKit extension and host app exchange audio through `broadcast_audio.ring` in the
`group.me.iamtalon.voicecat` App Group. Version 1 is a 64-byte little-endian header followed by
96,000 signed 16-bit PCM samples (one second of stereo at 48 kHz).
The ReplayKit extension or iOS 27 ScreenCaptureKit host producer exchanges audio with the
managed host consumer through `broadcast_audio.ring` in the `group.me.iamtalon.voicecat` App
Group. Only one producer is active at a time. Version 1 is a 64-byte little-endian header
followed by 96,000 signed 16-bit PCM samples (one second of stereo at 48 kHz).
| Offset | Type | Meaning |
|---:|---|---|
+1
View File
@@ -9,5 +9,6 @@
</PropertyGroup>
<PropertyGroup Condition="'$(VoiceCatIosStatic)' == 'true'">
<DefineConstants>$(DefineConstants);VOICECAT_IOS_STATIC</DefineConstants>
<RuntimeIdentifiers>ios-arm64;iossimulator-arm64</RuntimeIdentifiers>
</PropertyGroup>
</Project>
+2 -1
View File
@@ -20,6 +20,7 @@
}
}
},
"net10.0/ios-arm64": {},
"net10.0/iossimulator-arm64": {}
}
}
}
+2 -1
View File
@@ -2,6 +2,7 @@
"version": 1,
"dependencies": {
"net10.0": {},
"net10.0/ios-arm64": {},
"net10.0/iossimulator-arm64": {}
}
}
}
+2 -1
View File
@@ -40,6 +40,7 @@
}
}
},
"net10.0/ios-arm64": {},
"net10.0/iossimulator-arm64": {}
}
}
}
@@ -20,6 +20,7 @@
}
}
},
"net10.0/ios-arm64": {},
"net10.0/iossimulator-arm64": {}
}
}
}
+2 -1
View File
@@ -2,6 +2,7 @@
"version": 1,
"dependencies": {
"net10.0": {},
"net10.0/ios-arm64": {},
"net10.0/iossimulator-arm64": {}
}
}
}
@@ -15,6 +15,7 @@
"contentHash": "vK2Go/83W0v2Nn7tTP9fGrX4IjmOa93s3M0SZeFimU1vIIr2wL9yNJlIyK21y85SGm3++JncB8IF751cjoLHuQ=="
}
},
"net10.0/ios-arm64": {},
"net10.0/iossimulator-arm64": {}
}
}
}
@@ -89,6 +89,10 @@ public class ManagedClientTests
Assert.True((await admin.AuthenticateUserAsync("Admin", "secret")).Ok);
await Event(admin, envelope => envelope.ServerState is not null);
Assert.True(admin.Permissions.IsAdmin);
await using var member = NewClient(fixture, "ManagedMember"); await Connect(member, fixture);
AuthResult memberAuth = await member.AuthenticateGuestAsync("ManagedMember"); Assert.True(memberAuth.Ok);
await Event(member, envelope => envelope.ServerState is not null);
await Event(admin, envelope => envelope.UserEvent?.User?.Id == memberAuth.Self.Id);
Assert.True((await admin.CreateAccountAsync("managed-ui", "first")).Ok);
Assert.Contains(await admin.ListAccountsAsync(), account => account.Username == "managed-ui");
@@ -97,6 +101,13 @@ public class ManagedClientTests
{ SampleRate = 48000, BitrateBps = 64000, FrameMs = 20, Complexity = 10, Fec = true } };
Assert.True((await admin.CreateChannelAsync(room, "protected")).Ok);
Envelope created = await Event(admin, envelope => envelope.ChannelEvent?.Channel?.Name == room.Name);
Channel edited = created.ChannelEvent.Channel.Clone(); edited.Topic = "Edited from managed UI";
Assert.True((await admin.EditChannelAsync(edited)).Ok);
Assert.True((await admin.SetPermissionsAsync(memberAuth.Self.Id, new() { CanCreateTempChannel = true })).Ok);
Assert.True((await admin.SetServerMuteAsync(memberAuth.Self.Id, true, true)).Ok);
Assert.True((await admin.SetServerMuteAsync(memberAuth.Self.Id, false, false)).Ok);
Assert.True((await admin.MoveUserAsync(memberAuth.Self.Id, created.ChannelEvent.Channel.Id)).Ok);
Assert.True((await admin.KickUserAsync(memberAuth.Self.Id, "managed helper test")).Ok);
Assert.True((await admin.DeleteChannelAsync(created.ChannelEvent.Channel.Id)).Ok);
Assert.True((await admin.DeleteAccountAsync("managed-ui")).Ok);
}