147 lines
7.9 KiB
C#
147 lines
7.9 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();
|
|
if (Environment.GetEnvironmentVariable("VOICECAT_SIM_SMOKE_MODE") == "stereo")
|
|
IosAudioRouter.Shared.SelectPreset(IosAudioPreset.StereoMicrophone);
|
|
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
|