Files
voice-cat/clients/apple/VoiceCat.iOS/SimulatorSmoke.cs
T
Talon 4cc13a27a0
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s
Stabilize iOS reconnect and screen audio sessions
2026-09-26 20:30:24 +02:00

145 lines
7.7 KiB
C#

#if DEBUG
using System.Buffers.Binary;
using System.Runtime.InteropServices;
using Foundation;
using UIKit;
using VoiceCat.Core;
namespace VoiceCat.iOS;
// An opt-in simulator gate for the real UIKit model and audio graph. Local development only.
internal static class SimulatorSmoke
{
internal static void RunIfRequested()
{
string? endpoint = Environment.GetEnvironmentVariable("VOICECAT_SIM_SMOKE");
if (endpoint is null || !RuntimeInformation.RuntimeIdentifier.StartsWith("iossimulator", StringComparison.Ordinal)) return;
UIApplication.SharedApplication.BeginInvokeOnMainThread(async () =>
{
try
{
string[] parts = endpoint.Split(':', 3);
Guid id = Guid.Parse("959da92f-f088-452d-a870-4c15007a6fd3");
ServerProfile guest = ServerProfile.Create(parts[0], ushort.Parse(parts[1]), ServerAuthentication.Guest, nickname: "smoke", id: id);
ServerProfile profile = ServerProfile.Create(parts[0], ushort.Parse(parts[1]), ServerAuthentication.Account, "smoke", id: id);
AppModel model = AppModel.Shared;
bool share = Environment.GetEnvironmentVariable("VOICECAT_SIM_SMOKE_MODE") == "share";
string? ring = share ? PrepareRing() : null;
model.IdentityRequested += _ => model.ResolveIdentity(true);
if (Environment.GetEnvironmentVariable("VOICECAT_SIM_SMOKE_MODE") != "resume")
{
model.UpsertProfile(guest, null);
model.UpsertProfile(profile, parts[2]);
}
model.Load();
profile = model.Profiles.Single(value => value.Id == id);
Console.Error.WriteLine("VC_SIM phase=connect");
await model.ConnectAsync(profile);
Console.Error.WriteLine($"VC_SIM phase=connected audioRunning={IosAudioEngine.Shared.IsRunning}");
Require(model.IsConnected, "Account login did not connect.");
if (ring is not null)
{
// Start a synthetic producer while the microphone is off. This exercises
// screen-only subscription and the real BroadcastChanged audio recovery.
await Task.Delay(300);
Require(!model.ScreenRingMapped && !model.ScreenSharing,
"An unchanged ring from a previous broadcast remained mapped.");
WriteRing(ring, active: true, write: 3840);
await WaitUntil(() => model.ScreenSharing, "Screen stream did not start.");
Require(IosAudioEngine.Shared.IsRunning, "Screen sharing stopped the audio graph.");
WriteRing(ring, active: false, write: 3840);
await WaitUntil(() => !model.ScreenSharing, "Screen stream did not stop.");
Require(!model.ScreenRingMapped, "The stopped screen ring remained mapped.");
Console.Error.WriteLine("VC_SIM phase=screenSharePassed");
}
await model.ToggleVoiceAsync();
Console.Error.WriteLine($"VC_SIM phase=joined audioRunning={IosAudioEngine.Shared.IsRunning}");
await Task.Delay(2500);
Require(model.VoiceJoined && IosAudioEngine.Shared.IsRunning, "The first voice graph stopped.");
long beforeShare = IosAudioEngine.Shared.RenderCallbacks;
IosAudioRouter.Shared.Recover("simulated screen sharing start", force: false);
await Task.Delay(1000);
Require(IosAudioEngine.Shared.IsRunning && IosAudioEngine.Shared.RenderCallbacks > beforeShare,
"Screen sharing recovery stopped the live audio graph.");
if (ring is not null)
{
WriteRing(ring, active: true, write: 3840);
await Task.Delay(300);
WriteRing(ring, active: true, write: 5760);
await WaitUntil(() => model.ScreenSharing, "Screen stream did not start during a voice call.");
Require(IosAudioEngine.Shared.IsRunning, "Starting screen sharing stopped the voice graph.");
}
await model.ToggleVoiceAsync();
if (ring is not null)
{
Require(model.ScreenSharing, "Leaving the microphone call unsubscribed active screen audio.");
WriteRing(ring, active: false, write: 5760);
await WaitUntil(() => !model.ScreenSharing, "The second screen stream did not stop.");
}
await model.DisconnectAsync();
await Task.Delay(3000);
model.Load();
profile = model.Profiles.Single(value => value.Id == id);
Console.Error.WriteLine("VC_SIM phase=reconnect");
await model.ConnectAsync(profile);
Console.Error.WriteLine($"VC_SIM phase=reconnected audioRunning={IosAudioEngine.Shared.IsRunning}");
Require(model.IsConnected, "Saved account login did not reconnect.");
await Task.Delay(2500);
Console.Error.WriteLine($"VC_SIM phase=reconnectedSettled audioRunning={IosAudioEngine.Shared.IsRunning}");
Require(IosAudioEngine.Shared.IsRunning, "The reconnect audio graph stopped.");
await model.ToggleVoiceAsync();
Console.Error.WriteLine($"VC_SIM phase=rejoined audioRunning={IosAudioEngine.Shared.IsRunning}");
await Task.Delay(2500);
Console.Error.WriteLine($"VC_SIM phase=rejoinedSettled audioRunning={IosAudioEngine.Shared.IsRunning}");
Require(model.VoiceJoined && IosAudioEngine.Shared.IsRunning, "The rejoined voice graph stopped.");
await model.DisconnectAsync();
Console.Error.WriteLine("VC_SIM phase=passed");
}
catch (Exception exception) { Console.Error.WriteLine($"VC_SIM phase=failed exception={exception}"); }
});
}
private static void Require(bool condition, string message)
{
if (!condition) throw new InvalidOperationException(message);
}
private static string PrepareRing()
{
string root = NSFileManager.DefaultManager.GetContainerUrl(IosConstants.AppGroup)?.Path
?? throw new InvalidOperationException("Simulator App Group is unavailable.");
string path = Path.Combine(root, "voicecat", "broadcast_audio.ring");
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
using var file = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.ReadWrite);
file.SetLength(64 + 96_000 * sizeof(short));
byte[] header = new byte[20];
BinaryPrimitives.WriteUInt32LittleEndian(header, 0x56434252);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(4), 1);
file.Write(header);
file.Dispose();
WriteRing(path, active: true, write: 1920);
return path;
}
private static void WriteRing(string path, bool active, ulong write)
{
using var file = new FileStream(path, FileMode.Open, FileAccess.Write, FileShare.ReadWrite);
byte[] value = new byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(value, write);
file.Position = 24; file.Write(value);
BinaryPrimitives.WriteUInt32LittleEndian(value, active ? 1u : 0u);
file.Position = 16; file.Write(value, 0, 4);
}
private static async Task WaitUntil(Func<bool> condition, string error)
{
for (int attempt = 0; attempt < 30; attempt++)
{
if (condition()) return;
await Task.Delay(100);
}
throw new InvalidOperationException(error);
}
}
#endif