Files
voice-cat/clients/apple/VoiceCat.iOS/MainControllers.cs
T
Talon 0b81b81c0c
Build and test / test (macos-latest) (push) Waiting to run
Build and test / test (ubuntu-24.04) (push) Waiting to run
Build and test / test (windows-latest) (push) Waiting to run
Build and test / apple-client (push) Waiting to run
fix(ios): rebuild audio only when needed and stop VoiceOver list churn
Two iOS bugs with the same shape: unconditional rebuilds where a
conditional check belongs.

The audio graph was torn down on every unintentional disconnect and every
foreground transition. A lost connection ran the same teardown as an
explicit disconnect, deactivating the AVAudioSession and so dropping the
Bluetooth HFP link for a transport blip, and foregrounding always called
Reconfigure even though the `audio` background mode keeps the graph live.
Both cost seconds of dead audio on a headset.

Split "session ended" from "transport blipped". Detach unbinds the client
but keeps the session, graph, and route, so a reconnect rebinds to a live
HFP link; the route is parked on stream id 0 so capture cannot feed the
next connection a stream it never announced. StartListening reuses a
running graph, StartMicrophone reuses a running tap of the same width, and
Reconfigure gained a non-forcing mode that no-ops when tap presence,
channel width, and voice processing all still match. Foregrounding now
ensures the graph is running and only reconfigures if it actually stopped.
Route changes, media-services resets, and the stall watchdog still force a
full rebuild.

Every list also reloaded on a model event raised 20 times a second by the
microphone level timer. ReloadData recreates the accessibility element
tree, so VoiceOver explore mode re-announced the row under a dragging
finger and a double tap landed on an element that no longer existed. No
controller ever unsubscribed, so popped controllers kept reloading too.

Move the level to its own LevelChanged event, and reload lists through
ListRefresher, which subscribes only while on screen and only reloads when
the rendered content signature changed. The voice bar publishes its
accessibility value on 5% steps, MoveUserController reloads just its two
checkmark rows, and the chat transcripts skip reassigning identical text.

The changed logic sits on UIKit and AVFoundation types the net10.0 test
project cannot reference, so this carries no tests; the Bluetooth
reconnect and foreground paths need device verification.
2026-09-25 17:00:25 +02:00

250 lines
21 KiB
C#

using UIKit;
using VoiceCat.Audio;
using Voicecat.V1;
using Channel = Voicecat.V1.Channel;
namespace VoiceCat.iOS;
internal sealed class MainTabController : UITabBarController
{
internal MainTabController(AppModel model)
{
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);
private int renderedLevel = -1;
internal VoiceControlsView(AppModel model) { this.model = model; model.Changed += Refresh; model.LevelChanged += RefreshLevel; Build(); }
protected override void Dispose(bool disposing)
{
if (disposing) { model.Changed -= Refresh; model.LevelChanged -= RefreshLevel; }
base.Dispose(disposing);
}
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.AccessibilityLabel = "Microphone level"; RefreshLevel();
}
// Driven at 20 Hz. Rewriting AccessibilityValue on every tick makes VoiceOver re-announce the
// meter continuously while it is focused, so only publish a value that actually changed.
private void RefreshLevel()
{
float progress = Math.Clamp(model.MicrophoneLevel * 10, 0, 1); level.Progress = progress;
int percent = (int)MathF.Round(progress * 100 / 5) * 5;
if (percent == renderedLevel) return;
renderedLevel = percent; level.AccessibilityValue = $"{percent}%";
}
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; private readonly ListRefresher refresher; private IReadOnlyList<(Channel Channel, int Depth)> Visible => Flatten();
internal ChannelsController(AppModel model)
{
this.model = model; Title = "Channels";
refresher = new(this, Signature, handler => model.Changed += handler, handler => model.Changed -= handler);
}
public override void ViewDidLoad()
{
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))];
}
public override void ViewWillAppear(bool animated) { base.ViewWillAppear(animated); refresher.Start(); UpdateActions(); }
public override void ViewDidDisappear(bool animated) { refresher.Stop(); base.ViewDidDisappear(animated); }
private string Signature()
{
UpdateActions();
return string.Join('\n', Visible.Select(entry =>
$"{entry.Channel.Id}|{entry.Depth}|{entry.Channel.Name}|{entry.Channel.Topic}|{entry.Channel.PasswordProtected}|" +
$"{entry.Channel.Id == model.CurrentChannelId}|{model.Users.Count(user => user.ChannelId == entry.Channel.Id)}"));
}
// The add button is not part of the table, so it can follow every notification cheaply.
private void UpdateActions()
{
if (NavigationItem.RightBarButtonItems is not { Length: > 1 } items) return;
items[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)
{
(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 async void RowSelected(UITableView tableView, NSIndexPath indexPath)
{
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;
}
}
internal sealed class ChatController : UIViewController
{
private readonly AppModel model; private readonly UITextView log = new(); private readonly UITextField compose = UiHelpers.Field("Message");
internal ChatController(AppModel model) { this.model = model; Title = "Chat"; }
public override void ViewWillAppear(bool animated) { base.ViewWillAppear(animated); model.Changed += Refresh; Refresh(); }
public override void ViewDidDisappear(bool animated) { model.Changed -= Refresh; base.ViewDidDisappear(animated); }
public override void ViewDidLoad()
{
base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemBackground; log.Editable = false; log.Font = UIFont.PreferredBody; log.AccessibilityLabel = "Chat and activity timeline"; log.TranslatesAutoresizingMaskIntoConstraints = false;
NavigationItem.RightBarButtonItem = new("Private Chats", UIBarButtonItemStyle.Plain, (_, _) => NavigationController?.PushViewController(new PrivateChatsController(model), true));
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()
{
IEnumerable<(DateTime Time, string Text)> chat = model.Messages.Where(message => !message.Private).Select(message => (message.Timestamp, $"{message.Sender}: {message.Text}"));
IEnumerable<(DateTime Time, string Text)> activity = model.Activity.Select(value => (value.Timestamp, $"• {value.Text}"));
string text = string.Join("\n", chat.Concat(activity).OrderBy(value => value.Time).Select(value => $"[{value.Time:t}] {value.Text}"));
// Reassigning identical text still resets the text view's accessibility state, which
// interrupts VoiceOver mid-read of the transcript.
if (text == log.Text) return;
log.Text = text; if (text.Length > 0) log.ScrollRangeToVisible(new(text.Length - 1, 1));
}
}
internal sealed class PrivateChatsController : UITableViewController
{
private readonly AppModel model;
private IReadOnlyList<(uint Id, string Name, ChatEntry? Latest, bool Online)> Peers
{
get
{
var online = model.Users.Where(user => user.Id != model.SelfUserId).ToDictionary(user => user.Id);
var history = model.Messages.Where(message => message.Private).GroupBy(message => message.PeerUserId).ToDictionary(group => group.Key, group => group.OrderByDescending(message => message.Timestamp).First());
return online.Keys.Concat(history.Keys).Distinct().Select(id =>
{
online.TryGetValue(id, out User? user); history.TryGetValue(id, out ChatEntry? latest);
string name = user?.Nickname ?? latest?.Peer ?? $"User {id}"; return (Id: id, Name: name, Latest: latest, Online: user is not null);
}).OrderByDescending(peer => peer.Latest?.Timestamp ?? DateTime.MinValue).ThenBy(peer => peer.Name).ToArray();
}
}
private readonly ListRefresher refresher;
internal PrivateChatsController(AppModel model)
{
this.model = model; Title = "Private Chats";
refresher = new(this, Signature, handler => model.Changed += handler, handler => model.Changed -= handler);
}
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "private-peer"); }
public override void ViewWillAppear(bool animated) { base.ViewWillAppear(animated); refresher.Start(); }
public override void ViewDidDisappear(bool animated) { refresher.Stop(); base.ViewDidDisappear(animated); }
private string Signature()
{
IReadOnlyList<(uint Id, string Name, ChatEntry? Latest, bool Online)> peers = Peers;
// The empty-state view is not a table row, so it is kept current outside the reload.
bool empty = peers.Count == 0, shown = TableView.BackgroundView is not null;
if (empty != shown)
TableView.BackgroundView = empty ? new UILabel { Text = "No other users or private conversations", TextAlignment = UITextAlignment.Center, AccessibilityLabel = "No other users or private conversations" } : null;
return string.Join('\n', peers.Select(peer => $"{peer.Id}|{peer.Name}|{peer.Online}|{peer.Latest?.Text}"));
}
public override nint RowsInSection(UITableView tableView, nint section) => Peers.Count;
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath)
{
(uint _, string name, ChatEntry? latest, bool online) = Peers[indexPath.Row]; UITableViewCell cell = tableView.DequeueReusableCell("private-peer", indexPath); var content = cell.DefaultContentConfiguration;
content.Text = name; content.SecondaryText = latest?.Text ?? "Start a conversation"; content.Image = UIImage.GetSystemImage(online ? "person.crop.circle.fill" : "person.crop.circle.badge.xmark"); cell.ContentConfiguration = content;
cell.Accessory = UITableViewCellAccessory.DisclosureIndicator; cell.AccessibilityLabel = $"{name}, {(online ? "online" : "offline")}, {content.SecondaryText}"; return cell;
}
public override void RowSelected(UITableView tableView, NSIndexPath indexPath)
{
(uint id, string name, _, _) = Peers[indexPath.Row]; tableView.DeselectRow(indexPath, true); NavigationController?.PushViewController(new PrivateConversationController(model, id, name), true);
}
}
internal sealed class PrivateConversationController : UIViewController
{
private readonly AppModel model; private readonly uint peerUserId; private readonly string fallbackName;
private readonly UITextView transcript = new(); private readonly UITextField compose = UiHelpers.Field("Private message"); private readonly UIButton send = UIButton.FromType(UIButtonType.System);
private User? Peer => model.Users.FirstOrDefault(user => user.Id == peerUserId);
internal PrivateConversationController(AppModel model, uint peerUserId, string fallbackName)
{ this.model = model; this.peerUserId = peerUserId; this.fallbackName = fallbackName; Title = fallbackName; }
public override void ViewWillAppear(bool animated) { base.ViewWillAppear(animated); model.Changed += Refresh; Refresh(); }
public override void ViewDidDisappear(bool animated) { model.Changed -= Refresh; base.ViewDidDisappear(animated); }
public override void ViewDidLoad()
{
base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemBackground; transcript.Editable = false; transcript.Font = UIFont.PreferredBody; transcript.AccessibilityLabel = $"Private conversation with {fallbackName}"; transcript.TranslatesAutoresizingMaskIntoConstraints = false;
compose.AccessibilityLabel = $"Message to {fallbackName}"; compose.TranslatesAutoresizingMaskIntoConstraints = false;
send.SetTitle("Send", UIControlState.Normal); send.AccessibilityLabel = $"Send message to {fallbackName}"; send.TranslatesAutoresizingMaskIntoConstraints = false; send.TouchUpInside += (_, _) => Send();
NavigationItem.RightBarButtonItem = new("User", UIBarButtonItemStyle.Plain, (_, _) => NavigationController?.PushViewController(new UserDetailController(model, peerUserId), true)) { AccessibilityLabel = $"Audio and user settings for {fallbackName}" };
View.AddSubviews(transcript, compose, send);
NSLayoutConstraint.ActivateConstraints([transcript.TopAnchor.ConstraintEqualTo(View.SafeAreaLayoutGuide.TopAnchor), transcript.LeadingAnchor.ConstraintEqualTo(View.LeadingAnchor, 12), transcript.TrailingAnchor.ConstraintEqualTo(View.TrailingAnchor, -12), compose.TopAnchor.ConstraintEqualTo(transcript.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 Send() { string text = compose.Text ?? ""; if (string.IsNullOrWhiteSpace(text) || Peer is null) return; model.SendText(text, peerUserId); compose.Text = ""; }
private void Refresh()
{
if (!IsViewLoaded) return; string name = Peer?.Nickname ?? fallbackName; Title = name;
IEnumerable<ChatEntry> messages = model.Messages.Where(message => message.Private && message.PeerUserId == peerUserId).OrderBy(message => message.Timestamp);
string text = string.Join("\n", messages.Select(message => $"[{message.Timestamp:t}] {(message.SenderId == model.SelfUserId ? "You" : message.Sender)}: {message.Text}"));
if (text != transcript.Text)
{
transcript.Text = text; if (text.Length > 0) transcript.ScrollRangeToVisible(new(text.Length - 1, 1));
}
bool online = Peer is not null; compose.Enabled = online; send.Enabled = online; NavigationItem.RightBarButtonItem!.Enabled = online;
}
}