diff --git a/PROGRESS.md b/PROGRESS.md index 74fad92..792fe92 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -30,7 +30,16 @@ still needs device validation. The iOS user list now opens a remote-user detail view with independent tuning for each active audio stream. Private messages are grouped into per-user conversations with direct access to the -same user and audio controls. +same user and audio controls. Lists reload only when their rendered content actually changed and +only while on screen, and the 20 Hz microphone level is a separate signal from the general model +change, so VoiceOver explore mode no longer re-announces the row under a dragging finger or loses +the element a double tap was aimed at. + +The iOS audio graph is rebuilt only when the audio configuration changed. A lost connection +unbinds the client but keeps the session, graph, and route alive, so a reconnect rebinds to a +live Bluetooth HFP link instead of renegotiating one; restoring voice reuses a running capture +tap of the same width; and foregrounding ensures the graph is running rather than rebuilding it. +Route changes, media-services resets, and the stall watchdog still force a full rebuild. The media path now survives changing networks. A client whose source address changes proves possession of its media key from the new address with an authenticated `Rebind` frame and the diff --git a/clients/apple/VoiceCat.iOS/AdministrationControllers.cs b/clients/apple/VoiceCat.iOS/AdministrationControllers.cs index 8ef8ce4..2e80ee3 100644 --- a/clients/apple/VoiceCat.iOS/AdministrationControllers.cs +++ b/clients/apple/VoiceCat.iOS/AdministrationControllers.cs @@ -6,9 +6,18 @@ namespace VoiceCat.iOS; internal sealed class UsersController : UITableViewController { - private readonly AppModel model; private IReadOnlyList 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(); } + private readonly AppModel model; private readonly ListRefresher refresher; + private IReadOnlyList Visible => model.CurrentChannelId == 0 ? model.Users : model.Users.Where(user => user.ChannelId == model.CurrentChannelId).ToArray(); + internal UsersController(AppModel model) + { + this.model = model; Title = "Users"; + refresher = new(this, Signature, handler => model.Changed += handler, handler => model.Changed -= handler); + } public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "user"); } + 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() => string.Join('\n', Visible.Select(user => + $"{user.Id}|{user.Nickname}|{user.Id == model.SelfUserId}|{user.ServerDeafened}|{user.ServerMuted}|{user.SelfDeafened}|{user.SelfMicMuted}|{user.IsGuest}")); public override nint RowsInSection(UITableView tableView, nint section) => Visible.Count; public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) { @@ -34,7 +43,7 @@ internal sealed class UsersController : UITableViewController internal sealed class UserDetailController : FormController { - private readonly AppModel model; private readonly uint userId; + private readonly AppModel model; private readonly uint userId; private readonly ListRefresher refresher; private User? User => model.Users.FirstOrDefault(value => value.Id == userId); private IReadOnlyList<(string Title, Action Run, bool Destructive)> AdminActions { @@ -55,8 +64,20 @@ internal sealed class UserDetailController : FormController } } internal UserDetailController(AppModel model, uint userId) : base(model.Users.FirstOrDefault(value => value.Id == userId)?.Nickname ?? $"User {userId}") - { this.model = model; this.userId = userId; model.Changed += Reload; } - public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "detail"); Reload(); } + { + this.model = model; this.userId = userId; + refresher = new(this, Signature, handler => model.Changed += handler, handler => model.Changed -= handler); + } + public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "detail"); } + 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() + { + User? user = User; Title = user?.Nickname ?? $"User {userId}"; + string streams = user is null ? "offline" : string.Join(',', user.Streams.Select(stream => + $"{stream.StreamId}:{stream.Kind}:{stream.Label}:{model.Client?.Audio.GetRemotePlayback(userId, stream.StreamId)}")); + return $"{Title}|{streams}|{string.Join(',', AdminActions.Select(action => action.Title))}"; + } public override nint NumberOfSections(UITableView tableView) => AdminActions.Count == 0 ? 2 : 3; public override nint RowsInSection(UITableView tableView, nint section) => section switch { 0 => Math.Max(User?.Streams.Count ?? 0, 1), 1 => 1, _ => AdminActions.Count }; @@ -93,7 +114,6 @@ internal sealed class UserDetailController : FormController else if (indexPath.Section == 1) NavigationController?.PushViewController(new PrivateConversationController(model, user.Id, user.Nickname), true); else if (indexPath.Section == 2) AdminActions[indexPath.Row].Run(); } - private void Reload() { Title = User?.Nickname ?? $"User {userId}"; if (IsViewLoaded) TableView.ReloadData(); } 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> command) { try { GenericResult result = await model.RunAdminAsync(_ => command()); if (!result.Ok) throw new InvalidOperationException(result.Message); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } } } @@ -124,7 +144,16 @@ internal sealed class MoveUserController : FormController 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(); } + // Only the two checkmarks move. Reloading the whole table would rebuild every accessibility + // element and drop VoiceOver's focus on the row that was just chosen. + public override void RowSelected(UITableView tableView, NSIndexPath indexPath) + { + int previous = model.Channels.ToList().FindIndex(channel => channel.Id == selected); + selected = model.Channels[indexPath.Row].Id; + NSIndexPath[] rows = previous >= 0 && previous != indexPath.Row + ? [indexPath, NSIndexPath.FromRowSection(previous, 0)] : [indexPath]; + tableView.ReloadRows(rows, UITableViewRowAnimation.None); + } 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); } } } diff --git a/clients/apple/VoiceCat.iOS/AppModel.cs b/clients/apple/VoiceCat.iOS/AppModel.cs index 44d7f38..99c7581 100644 --- a/clients/apple/VoiceCat.iOS/AppModel.cs +++ b/clients/apple/VoiceCat.iOS/AppModel.cs @@ -36,6 +36,8 @@ internal sealed class AppModel private int diagnosticPolls; internal event Action? Changed; + // Raised by the 20 Hz level timer only. Subscribers must be cheap and must not reload a list. + internal event Action? LevelChanged; internal event Action? IdentityRequested; internal IReadOnlyList Profiles => profiles; internal IReadOnlyList Messages => messages; @@ -137,7 +139,7 @@ internal sealed class AppModel { System.Diagnostics.Debug.WriteLine($"Connection failed: {exception}"); IsConnecting = false; Status = exception.Message; - if (ReferenceEquals(client, next)) { client = null; await StopSessionResourcesAsync(); } + if (ReferenceEquals(client, next)) { client = null; await StopSessionResourcesAsync(releaseAudio: !restoring); } await next.DisposeAsync(); Notify(); if (restoring && !explicitDisconnect) ScheduleReconnect(); @@ -199,7 +201,7 @@ internal sealed class AppModel // Handle that state change directly so a Wi-Fi transition cannot strand this session. CaptureRestoreState(owner); client = null; Status = "Connection lost"; - try { await StopSessionResourcesAsync(); } + try { await StopSessionResourcesAsync(releaseAudio: false); } catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio cleanup failed: {exception}"); } try { await owner.DisposeAsync(); } catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Connection cleanup failed: {exception}"); } @@ -273,7 +275,7 @@ internal sealed class AppModel internal async Task DisconnectAsync() { - explicitDisconnect = true; lifetime?.Cancel(); await StopSessionResourcesAsync(); + explicitDisconnect = true; lifetime?.Cancel(); await StopSessionResourcesAsync(releaseAudio: true); VoiceCatClient? old = client; client = null; microphoneStream = 0; IsConnecting = false; Status = "Not connected"; Notify(); if (old is not null) await old.DisposeAsync(); feedback.Play(SoundEvent.Logout); feedback.Speak("Disconnected"); } @@ -325,7 +327,10 @@ internal sealed class AppModel catch (Exception exception) when (exception is IOException or InvalidOperationException or ObjectDisposedException) { return; } lastTalking = talking; feedback.Play(talking ? SoundEvent.VoiceStart : SoundEvent.VoiceStop); } - Notify(); + // Only the voice bar renders the level. Raising the general Changed event at 20 Hz made + // every list reload itself that often, which tears down VoiceOver's element tree under an + // exploring finger; keep the fast signal on its own event. + LevelChanged?.Invoke(); } private void HandleUserEvent(VoiceCatClient owner, UserEvent value) @@ -367,9 +372,15 @@ internal sealed class AppModel owner.SetSelfAudioState(restoreMuted, restoreDeafened); AddActivity($"Restored to channel {restoreChannel}{(restoringVoice ? " with voice" : "")}"); } - private async Task StopSessionResourcesAsync() + // `releaseAudio` distinguishes an ended session from an interrupted one. Ending releases the + // AVAudioSession, which on Bluetooth drops the HFP link and costs seconds of renegotiation on + // the way back; a lost connection is a transport event that changed nothing about the audio + // configuration, so it only unbinds the client and leaves the live route in place. + private async Task StopSessionResourcesAsync(bool releaseAudio) { - levelTimer?.Dispose(); levelTimer = null; IosAudioEngine.Shared.Stop(); microphoneStream = 0; MicrophoneLevel = 0; lastTalking = false; + levelTimer?.Dispose(); levelTimer = null; + if (releaseAudio) IosAudioEngine.Shared.Stop(); else IosAudioEngine.Shared.Detach(); + microphoneStream = 0; MicrophoneLevel = 0; lastTalking = false; if (broadcast is { } pump) { broadcast = null; pump.Changed -= BroadcastChanged; await pump.DisposeAsync(); } } } diff --git a/clients/apple/VoiceCat.iOS/IosAudioEngine.cs b/clients/apple/VoiceCat.iOS/IosAudioEngine.cs index bcc37e0..89a6b77 100644 --- a/clients/apple/VoiceCat.iOS/IosAudioEngine.cs +++ b/clients/apple/VoiceCat.iOS/IosAudioEngine.cs @@ -39,6 +39,9 @@ internal sealed class IosAudioEngine private AVAudioConverterInputHandler? inputProvider; private bool inputProvided; private bool tapInstalled; + // The voice-processing state the live graph was actually built with, so Reconfigure can tell + // a settings change apart from a re-check of an unchanged graph. + private bool voiceProcessing; private long captureCallbacks, capturedFrames, convertedFrames, rejectedFeeds, converterFailures, stereoFrames, stereoDifferentFrames; private long renderCallbacks, lastRenderTimestamp; internal bool IsConnected { get; private set; } @@ -50,26 +53,60 @@ internal sealed class IosAudioEngine internal void StartListening(VoiceCatClient owner) { - Stop(); client = owner; IsConnected = true; owner.Audio.MixedPcm += ReceiveMixedPcm; Rebuild(); + if (client is { } previous) previous.Audio.MixedPcm -= ReceiveMixedPcm; + client = owner; IsConnected = true; owner.Audio.MixedPcm += ReceiveMixedPcm; + // A reconnect after Detach finds the session active and the graph already running on the + // right hardware. Rebuilding it there would release an HFP headset and pay a Bluetooth + // profile renegotiation for a transport blip that changed no audio configuration. + if (engine?.Running == true) { playbackRing.Resynchronize(); return; } + Volatile.Write(ref microphone, null); Rebuild(); + } + + // Unbinds the client without touching the session or the graph, for a connection that was + // lost rather than ended. Capture keeps feeding a ring nobody drains and Render emits silence + // until StartListening rebinds, which keeps the route and its Bluetooth profile alive. + internal void Detach() + { + if (client is { } owner) owner.Audio.MixedPcm -= ReceiveMixedPcm; + client = null; playbackRing.Resynchronize(); + // The route's stream id belongs to the connection that just died. Keep the tap and its + // hardware, but park the route on the unbound id so a rebind cannot feed the next + // connection a stream it never announced. + if (Volatile.Read(ref microphone) is { } stale && stale.StreamId != 0) + Volatile.Write(ref microphone, CreateMicrophoneRoute(0, stale.Channels)); } internal void StartMicrophone(uint streamId, int channels) { + MicrophoneRoute? current = Volatile.Read(ref microphone); + // Restoring voice after a reconnect only changes which stream id the existing capture + // feeds. Reuse a running tap of the same width instead of rebuilding the graph around it. + if (current is { StreamId: 0 } && current.Channels == Math.Clamp(channels, 1, 2) && tapInstalled && engine?.Running == true) + { + current.Ring.Resynchronize(); + Volatile.Write(ref microphone, CreateMicrophoneRoute(streamId, channels)); return; + } Volatile.Write(ref microphone, CreateMicrophoneRoute(streamId, channels)); Rebuild(); } internal void StopMicrophone() { Volatile.Write(ref microphone, null); Rebuild(); } - internal void Reconfigure() + + // `force` rebuilds unconditionally, which is what a route change, a media-services reset and + // the stall watchdog all need. Callers that are only re-checking a graph they expect to be + // healthy — foregrounding, above all — pass false and get a no-op when nothing has changed. + internal void Reconfigure(bool force = true) { if (!IsConnected) return; MicrophoneRoute? current = Volatile.Read(ref microphone); int channels = IosAudioRouter.Shared.CaptureChannels; + if (!force && engine?.Running == true && tapInstalled == (current is not null) && + (current?.Channels ?? channels) == channels && voiceProcessing == IosAudioRouter.Shared.UsesVoiceProcessing) return; if (current is not null) { // Stop the old tap before publishing a route with a different sample width. Otherwise // an in-flight callback could interpret its old converter buffer using the new width. DestroyGraph(); - if (current.Channels != channels) client!.Audio.SetCaptureChannels(current.StreamId, channels); + if (current.Channels != channels && current.StreamId != 0) client?.Audio.SetCaptureChannels(current.StreamId, channels); Volatile.Write(ref microphone, CreateMicrophoneRoute(current.StreamId, channels)); } Rebuild(); @@ -88,6 +125,7 @@ internal sealed class IosAudioEngine private void Rebuild() { DestroyGraph(); MicrophoneRoute? route = Volatile.Read(ref microphone); IosAudioRouter.Shared.Apply(route is not null); + voiceProcessing = IosAudioRouter.Shared.UsesVoiceProcessing; var next = new AVAudioEngine(); AVAudioInputNode? input = null; if (route is not null) @@ -189,7 +227,8 @@ internal sealed class IosAudioEngine private void PumpMicrophoneChunk(VoiceCatClient owner) { MicrophoneRoute? route = Volatile.Read(ref microphone); - if (route is null) return; + // Stream id 0 is a route parked by Detach: still capturing, not yet bound to a connection. + if (route is null || route.StreamId == 0) return; int required = 960 * route.Channels; if (route.Ring.Read(microphoneFrame.AsSpan(0, required)) == required && ReferenceEquals(route, Volatile.Read(ref microphone)) && diff --git a/clients/apple/VoiceCat.iOS/IosAudioRouter.cs b/clients/apple/VoiceCat.iOS/IosAudioRouter.cs index 07c0042..256b9e0 100644 --- a/clients/apple/VoiceCat.iOS/IosAudioRouter.cs +++ b/clients/apple/VoiceCat.iOS/IosAudioRouter.cs @@ -240,13 +240,13 @@ internal sealed class IosAudioRouter catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio recovery ({reason}) failed: {exception}"); } } - internal void Recover(string reason) + internal void Recover(string reason, bool force = true) { RefreshRoutes(); if (!IosAudioEngine.Shared.IsConnected || interrupted) return; UIApplication.SharedApplication.BeginInvokeOnMainThread(() => { - try { IosAudioEngine.Shared.Reconfigure(); } + try { IosAudioEngine.Shared.Reconfigure(force); } catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio recovery ({reason}) failed: {exception}"); } }); } @@ -262,8 +262,17 @@ internal sealed class IosAudioRouter private void ResetWatchdog() { lastRenderCallbacks = -1; watchdogMisses = 0; } - // An interruption that ends while the app is suspended never delivers its Ended notification. - internal void ResumeForeground() { interrupted = false; ResetWatchdog(); Recover("foreground"); } + // An interruption that ends while the app is suspended never delivers its Ended notification, + // so foregrounding still has to check. It must not rebuild unconditionally though: the `audio` + // background mode keeps the session and graph live across a backgrounding, so the graph is + // almost always healthy here and a rebuild costs a visible glitch plus, on Bluetooth, an HFP + // renegotiation. Ensure it is running, and only reconfigure when it actually stopped. + internal void ResumeForeground() + { + interrupted = false; ResetWatchdog(); RefreshRoutes(); + if (IosAudioEngine.Shared.IsConnected && !IosAudioEngine.Shared.IsRunning) Recover("foreground"); + else EnsureAudio("foreground"); + } private void TickWatchdog() { diff --git a/clients/apple/VoiceCat.iOS/MainControllers.cs b/clients/apple/VoiceCat.iOS/MainControllers.cs index fe09772..072f750 100644 --- a/clients/apple/VoiceCat.iOS/MainControllers.cs +++ b/clients/apple/VoiceCat.iOS/MainControllers.cs @@ -34,7 +34,13 @@ 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 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); @@ -54,22 +60,50 @@ internal sealed class VoiceControlsView : UIView 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}"; + 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 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 IReadOnlyList<(Channel Channel, int Depth)> Visible => Flatten(); - internal ChannelsController(AppModel model) { this.model = model; Title = "Channels"; model.Changed += Reload; } + 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))]; Reload(); + 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); } - 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) { @@ -112,7 +146,9 @@ internal sealed class ChannelsController : UITableViewController 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"; model.Changed += Refresh; } + 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; @@ -125,7 +161,11 @@ internal sealed class ChatController : UIViewController { 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}")); - 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)); + 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)); } } @@ -145,8 +185,24 @@ internal sealed class PrivateChatsController : UITableViewController }).OrderByDescending(peer => peer.Latest?.Timestamp ?? DateTime.MinValue).ThenBy(peer => peer.Name).ToArray(); } } - internal PrivateChatsController(AppModel model) { this.model = model; Title = "Private Chats"; model.Changed += Reload; } - public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "private-peer"); Reload(); } + 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) { @@ -158,7 +214,6 @@ internal sealed class PrivateChatsController : UITableViewController { (uint id, string name, _, _) = Peers[indexPath.Row]; tableView.DeselectRow(indexPath, true); NavigationController?.PushViewController(new PrivateConversationController(model, id, name), true); } - private void Reload() { if (IsViewLoaded) { TableView.ReloadData(); TableView.BackgroundView = Peers.Count == 0 ? new UILabel { Text = "No other users or private conversations", TextAlignment = UITextAlignment.Center, AccessibilityLabel = "No other users or private conversations" } : null; } } } internal sealed class PrivateConversationController : UIViewController @@ -167,7 +222,9 @@ internal sealed class PrivateConversationController : UIViewController 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; model.Changed += Refresh; } + { 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; @@ -182,8 +239,11 @@ internal sealed class PrivateConversationController : UIViewController { if (!IsViewLoaded) return; string name = Peer?.Nickname ?? fallbackName; Title = name; IEnumerable messages = model.Messages.Where(message => message.Private && message.PeerUserId == peerUserId).OrderBy(message => message.Timestamp); - transcript.Text = string.Join("\n", messages.Select(message => $"[{message.Timestamp:t}] {(message.SenderId == model.SelfUserId ? "You" : message.Sender)}: {message.Text}")); - if (transcript.Text.Length > 0) transcript.ScrollRangeToVisible(new(transcript.Text.Length - 1, 1)); + 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; } } diff --git a/clients/apple/VoiceCat.iOS/ServerListController.cs b/clients/apple/VoiceCat.iOS/ServerListController.cs index cb13b0c..dce72b3 100644 --- a/clients/apple/VoiceCat.iOS/ServerListController.cs +++ b/clients/apple/VoiceCat.iOS/ServerListController.cs @@ -5,12 +5,24 @@ namespace VoiceCat.iOS; internal sealed class ServerListController : UITableViewController { - private readonly AppModel model; - internal ServerListController(AppModel model) { this.model = model; Title = "Servers"; TabBarItem = new(UITabBarSystemItem.Favorites, 0); } + private readonly AppModel model; private readonly ListRefresher refresher; + internal ServerListController(AppModel model) + { + this.model = model; Title = "Servers"; TabBarItem = new(UITabBarSystemItem.Favorites, 0); + refresher = new(this, Signature, handler => model.Changed += handler, handler => model.Changed -= handler); + } public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "server"); - NavigationItem.RightBarButtonItem = new(UIBarButtonSystemItem.Add, (_, _) => PresentEditor(null)); model.Changed += Reload; + NavigationItem.RightBarButtonItem = new(UIBarButtonSystemItem.Add, (_, _) => PresentEditor(null)); + } + 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() + { + // The connection prompt is navigation-bar chrome, not a row, so it follows every change. + NavigationItem.Prompt = model.IsConnecting ? model.Status : null; + return string.Join('\n', model.Profiles.Select(profile => $"{profile.Id}|{profile.DisplayName}|{profile.Authentication}")); } public override nint RowsInSection(UITableView tableView, nint section) => model.Profiles.Count; public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) @@ -32,7 +44,6 @@ internal sealed class ServerListController : UITableViewController return UISwipeActionsConfiguration.FromActions([delete, edit]); } private void PresentEditor(ServerProfile? profile) => PresentViewController(new UINavigationController(new ServerEditorController(model, profile)), true, null); - private void Reload() { TableView.ReloadData(); NavigationItem.Prompt = model.IsConnecting ? model.Status : null; } } internal sealed class ServerEditorController : UIViewController diff --git a/clients/apple/VoiceCat.iOS/SettingsController.cs b/clients/apple/VoiceCat.iOS/SettingsController.cs index 5875954..eadc9e7 100644 --- a/clients/apple/VoiceCat.iOS/SettingsController.cs +++ b/clients/apple/VoiceCat.iOS/SettingsController.cs @@ -9,10 +9,22 @@ 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; } + private readonly AppModel model; private readonly ListRefresher refresher; + internal SettingsController(AppModel model) : base(UITableViewStyle.InsetGrouped) + { + this.model = model; Title = "Settings"; + refresher = new(this, Signature, + handler => { model.Changed += handler; IosAudioRouter.Shared.Changed += handler; }, + handler => { model.Changed -= handler; IosAudioRouter.Shared.Changed -= handler; }); + } public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "setting"); } - private void Reload() => TableView.ReloadData(); + 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() => + $"{IosAudioRouter.Shared.Preset}|{IosAudioRouter.Shared.ForceSpeaker}|{model.Settings.AudioBufferMilliseconds}|{model.ScreenSharing}|" + + $"{model.Settings.InputMode}|{model.Settings.VadThreshold}|{model.Settings.InputGain}|{model.Settings.InputNoiseReduction}|" + + $"{model.Settings.EventSounds}|{model.Settings.EventVolume}|{model.Settings.SpokenEvents}|{model.Settings.SelfTalkSounds}|{model.Settings.PushToTalkSound}|" + + $"{model.Client?.Permissions is { } p && (p.IsAdmin || p.CanAdminAccounts)}"; public override nint NumberOfSections(UITableView tableView) => 5; public override nint RowsInSection(UITableView tableView, nint section) => section switch { 0 => 5, 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" }; @@ -69,9 +81,19 @@ internal sealed class SettingsController : UITableViewController internal sealed class AdvancedAudioController : UITableViewController { - private readonly IosAudioRouter router = IosAudioRouter.Shared; - internal AdvancedAudioController() : base(UITableViewStyle.InsetGrouped) { Title = "Advanced Audio"; router.Changed += () => TableView.ReloadData(); } + private readonly IosAudioRouter router = IosAudioRouter.Shared; private readonly ListRefresher refresher; + internal AdvancedAudioController() : base(UITableViewStyle.InsetGrouped) + { + Title = "Advanced Audio"; + refresher = new(this, Signature, handler => IosAudioRouter.Shared.Changed += handler, handler => IosAudioRouter.Shared.Changed -= handler); + } public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "audio"); router.RefreshRoutes(); } + 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() => + $"{router.SelectedInputId}|{router.SelectedDataSourceId}|{router.SelectedPolarPattern}|{router.MicMode}|{router.CaptureChannels}|" + + $"{router.BluetoothMode}|{router.UsesVoiceProcessing}|{router.AutomaticGainControl}|" + + $"{string.Join(',', router.Inputs.Select(value => value.Name))}|{string.Join(',', router.Outputs.Select(value => value.Name))}"; public override nint RowsInSection(UITableView tableView, nint section) => 8; public override UITableViewCell GetCell(UITableView tableView, NSIndexPath path) { diff --git a/clients/apple/VoiceCat.iOS/UiHelpers.cs b/clients/apple/VoiceCat.iOS/UiHelpers.cs index fb94087..02c51bc 100644 --- a/clients/apple/VoiceCat.iOS/UiHelpers.cs +++ b/clients/apple/VoiceCat.iOS/UiHelpers.cs @@ -18,3 +18,41 @@ internal static class UiHelpers field.AccessibilityLabel = placeholder; return field; } } + +// VoiceOver rebuilds its element tree from scratch on every UITableView.ReloadData. The model +// notifies far more often than any list's content actually changes - the microphone level timer +// alone ticks at 20 Hz - so reloading unconditionally re-announces the row under an exploring +// finger and destroys the element a double tap was aimed at. Reload only when the rendered text +// actually differs, and only while the view is on screen, so an off-screen or popped controller +// stops reloading instead of holding a subscription for the lifetime of the connection. +internal sealed class ListRefresher(UITableViewController owner, Func signature, Action subscribe, Action unsubscribe) +{ + // Null means nothing has been rendered yet. A signature is never null, so the first + // Refresh after Start or Invalidate always reloads. + private string? rendered; + private bool observing; + + internal void Start() + { + if (observing) return; + observing = true; subscribe(Refresh); Refresh(); + } + + internal void Stop() + { + if (!observing) return; + observing = false; unsubscribe(Refresh); + } + + // Forces the next Refresh to reload even when the signature is unchanged, for state the + // signature cannot see. + internal void Invalidate() => rendered = null; + + internal void Refresh() + { + if (!observing || !owner.IsViewLoaded) return; + string next = signature(); + if (next == rendered) return; + rendered = next; owner.TableView.ReloadData(); + } +}