feat(calls): add Android LAN peer calls using Podcast capture
Share the LibVLC SRTP engine with Linux and feed Android MediaRecorder output through its native pipe without transcoding or HLS files. Reuse front-camera capture, preview, remote rendering and foreground service with consent-based controls and background cleanup. Use separate RTP/RTCP ports to avoid the diagnosed Android VLC SDP crash. Add physical-device media checks and lifecycle regressions; document LAN/VPN scope and remaining separate-device validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
cc28ee8b29
commit
a676ef3575
19 changed files with 718 additions and 105 deletions
158
src/PostIt/PostIt.Tests/AndroidLanCallTestActivity.cs
Normal file
158
src/PostIt/PostIt.Tests/AndroidLanCallTestActivity.cs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
#if ANDROID
|
||||
using System;
|
||||
using Stopwatch = System.Diagnostics.Stopwatch;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.OS;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using LibVLCSharp.Shared;
|
||||
using PostIt.Android;
|
||||
using PostIt.Android.Services;
|
||||
using PostIt.Services;
|
||||
using Path = System.IO.Path;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>Debug-only physical camera/SRTP test; no account, API or media recording.</summary>
|
||||
[Activity(Name = "fr.pschneider.postit.LanCallTestActivity", Exported = true)]
|
||||
[SupportedOSPlatform("android26.0")]
|
||||
public sealed class AndroidLanCallTestActivity : Activity
|
||||
{
|
||||
protected override async void OnCreate(Bundle? state)
|
||||
{
|
||||
base.OnCreate(state);
|
||||
var result = Path.Combine(FilesDir!.AbsolutePath, "lan-call-test-result.txt");
|
||||
File.WriteAllText(result, "RUNNING");
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(55));
|
||||
var outcome = await RunAsync(result, Intent?.GetStringExtra("wrongKey") == "true", timeout.Token);
|
||||
File.WriteAllText(result, outcome);
|
||||
}
|
||||
catch (Exception error) { File.WriteAllText(result, "FAIL\n" + error); }
|
||||
finally { Finish(); }
|
||||
}
|
||||
|
||||
private async Task<string> RunAsync(string result, bool wrongKey, CancellationToken ct)
|
||||
{
|
||||
StartActivity(new Intent(this, typeof(MainActivity)).AddFlags(ActivityFlags.NewTask));
|
||||
while (!MainActivity.IsForeground || (Avalonia.Application.Current as App)?.View == null)
|
||||
await Task.Delay(100, ct);
|
||||
await Task.Delay(1000, ct);
|
||||
var fixture = Path.Combine(FilesDir!.AbsolutePath, "lan-call-fixture.ts");
|
||||
if (!File.Exists(fixture)) throw new IOException("Explicit H.264/AAC MPEG-TS fixture required.");
|
||||
using var pipe = new AnonymousPipeServerStream(PipeDirection.Out);
|
||||
await using var peer = new LibVlcLanCallMedia("127.0.0.1", 35102, headless: true);
|
||||
using var decoded = new DecodedFrames(peer.Receiver);
|
||||
AndroidLanCallMedia? call = null;
|
||||
Task? pump = null;
|
||||
using var sending = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
var source = File.OpenRead(fixture);
|
||||
var app = (App)Avalonia.Application.Current!;
|
||||
var navigation = app.View!.FindControl<NavigationPage>("NavRoot")
|
||||
?? throw new InvalidOperationException("Main navigation unavailable.");
|
||||
var attached = false;
|
||||
try
|
||||
{
|
||||
await peer.StartReceivingAsync(ct);
|
||||
await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
call = new AndroidLanCallMedia("127.0.0.1", 35100);
|
||||
var grid = new Grid { RowDefinitions = new RowDefinitions("*,*") };
|
||||
grid.Children.Add(call.LocalVideo);
|
||||
Grid.SetRow(call.RemoteVideo, 1);
|
||||
grid.Children.Add(call.RemoteVideo);
|
||||
await navigation.PushAsync(new ContentPage { Content = grid });
|
||||
attached = true;
|
||||
});
|
||||
if (call == null) throw new InvalidOperationException("Call UI not created.");
|
||||
File.WriteAllText(result, "RUNNING\nstage=return source pump");
|
||||
pump = Task.Run(async () =>
|
||||
{
|
||||
var buffer = new byte[1316];
|
||||
while (await source.ReadAsync(buffer, sending.Token) is var count && count > 0)
|
||||
{
|
||||
await pipe.WriteAsync(buffer.AsMemory(0, count), sending.Token);
|
||||
await Task.Delay(4, sending.Token);
|
||||
}
|
||||
}, ct);
|
||||
File.WriteAllText(result, "RUNNING\nstage=return SRTP sender");
|
||||
await peer.StartEncodedSendingAsync(call.Endpoint,
|
||||
pipe.ClientSafePipeHandle.DangerousGetHandle().ToInt32(), ct);
|
||||
File.WriteAllText(result, "RUNNING\nstage=enumerating cameras");
|
||||
var devices = AndroidLanCallMedia.GetDevices();
|
||||
var front = devices.Cameras.FirstOrDefault(c => c.Name == "Camera avant")
|
||||
?? throw new InvalidOperationException("Front camera required; no rear-camera fallback.");
|
||||
var endpoint = wrongKey ? peer.Endpoint with { Key = new string('0', 32) } : peer.Endpoint;
|
||||
File.WriteAllText(result, "RUNNING\nstage=front camera and SRTP");
|
||||
await Dispatcher.UIThread.InvokeAsync(() => call.StartSendingAsync(endpoint, front.Value, "microphone", ct));
|
||||
File.WriteAllText(result, "RUNNING\nstage=receiving camera and return media");
|
||||
var elapsed = Stopwatch.StartNew();
|
||||
while (elapsed.Elapsed < TimeSpan.FromSeconds(wrongKey ? 10 : 25))
|
||||
{
|
||||
if (call.Completion.IsCompleted) await call.Completion;
|
||||
if (peer.Completion.IsCompleted) await peer.Completion;
|
||||
if (!wrongKey && elapsed.Elapsed >= TimeSpan.FromSeconds(10) &&
|
||||
decoded.Frames >= 100 && decoded.Samples > 144000 && call.HasRemoteMedia) break;
|
||||
await Task.Delay(100, ct);
|
||||
}
|
||||
if (!call.HasRemoteMedia) throw new IOException("Android did not decode return audio/video.");
|
||||
if (wrongKey ? decoded.Frames != 0 || decoded.Samples != 0
|
||||
: decoded.Frames < 100 || decoded.Samples <= 144000)
|
||||
throw new IOException($"Unexpected camera decode: frames={decoded.Frames}, samples={decoded.Samples}.");
|
||||
return $"PASS\nfrontCamera=true\nwrongKey={wrongKey}\n" +
|
||||
$"cameraFrames={decoded.Frames}\ncameraAudioSamples={decoded.Samples}\nelapsedMs={elapsed.ElapsedMilliseconds}\nreturnAudioVideo=true\n" +
|
||||
"transport=SRTP/UDP loopback\npreview=attached\nsource=camera+microphone,return=explicit fixture\ncleanup=complete";
|
||||
}
|
||||
finally
|
||||
{
|
||||
sending.Cancel();
|
||||
File.WriteAllText(result, "RUNNING\nstage=stop return source");
|
||||
if (pump != null)
|
||||
{
|
||||
try { await pump; }
|
||||
catch (System.OperationCanceledException) when (sending.IsCancellationRequested) { }
|
||||
}
|
||||
File.WriteAllText(result, "RUNNING\nstage=dispose call");
|
||||
if (call != null) await Dispatcher.UIThread.InvokeAsync(async () => await call.DisposeAsync());
|
||||
File.WriteAllText(result, "RUNNING\nstage=stop return peer");
|
||||
await peer.StopAsync();
|
||||
source.Dispose();
|
||||
File.WriteAllText(result, "RUNNING\nstage=detach test page");
|
||||
if (attached) await Dispatcher.UIThread.InvokeAsync(async () => await navigation.PopAsync());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DecodedFrames : IDisposable
|
||||
{
|
||||
private readonly MediaPlayer _player;
|
||||
private readonly IntPtr _buffer = Marshal.AllocHGlobal(640 * 640 * 4);
|
||||
private int _frames;
|
||||
private long _samples;
|
||||
public int Frames => Volatile.Read(ref _frames);
|
||||
public long Samples => Interlocked.Read(ref _samples);
|
||||
public DecodedFrames(MediaPlayer player)
|
||||
{
|
||||
_player = player;
|
||||
player.SetVideoFormat("RV32", 640, 640, 640 * 4);
|
||||
player.SetVideoCallbacks((_, planes) =>
|
||||
{
|
||||
Marshal.WriteIntPtr(planes, _buffer);
|
||||
return IntPtr.Zero;
|
||||
}, null, (_, _) => Interlocked.Increment(ref _frames));
|
||||
player.SetAudioFormat("S16N", 48000, 1);
|
||||
player.SetAudioCallbacks((_, _, count, _) => Interlocked.Add(ref _samples, count),
|
||||
null, null, null, null);
|
||||
}
|
||||
public void Dispose() { _player.Stop(); Marshal.FreeHGlobal(_buffer); }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -105,6 +105,57 @@ public sealed class LanCallViewModelTests
|
|||
Assert.False(vm.Busy);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Platform_devices_and_factory_are_used_and_backgrounding_ends_capture()
|
||||
{
|
||||
var previousDevices = Platform.GetLanCallDevices;
|
||||
var previousFactory = Platform.CreateLanCallMedia;
|
||||
var signaling = new Signaling();
|
||||
var media = new FakeMedia();
|
||||
var creations = 0;
|
||||
try
|
||||
{
|
||||
Platform.GetLanCallDevices = () => new(
|
||||
[new("Camera avant", "front"), new("Camera arriere", "rear")],
|
||||
[new("Sans microphone", ""), new("Microphone Android", "microphone")]);
|
||||
Platform.CreateLanCallMedia = (_, _) => { creations++; return media; };
|
||||
await using var vm = new LanCallViewModel(signaling);
|
||||
vm.RefreshDevicesCommand.Execute(null);
|
||||
Assert.Equal("front", vm.Camera?.Value);
|
||||
Assert.Equal("microphone", vm.Microphone?.Value);
|
||||
Assert.Single(vm.Microphones);
|
||||
Assert.Equal(0, creations);
|
||||
signaling.Invite(vm, signaling.Call);
|
||||
await WaitAsync(() => vm.Incoming);
|
||||
Assert.True(vm.CanConfigure);
|
||||
vm.RefreshDevicesCommand.Execute(null);
|
||||
Assert.Equal("front", vm.Camera?.Value);
|
||||
vm.Address = "127.0.0.1";
|
||||
if (!vm.Addresses.Contains(vm.Address)) vm.Addresses.Add(vm.Address);
|
||||
vm.AllowAddressDisclosure = true;
|
||||
vm.Room = "General";
|
||||
vm.Target = "bob";
|
||||
await vm.AcceptCommand.ExecuteAsync(null);
|
||||
await WaitAsync(() => vm.LocalVideo != null);
|
||||
Assert.Equal(1, creations);
|
||||
Assert.False(vm.CanConfigure);
|
||||
signaling.RemoteEndpoint(signaling.Call.Id);
|
||||
await WaitAsync(() => media.Sending);
|
||||
Platform.SuspendPlayback();
|
||||
await WaitAsync(() => media.Disposed);
|
||||
Assert.False(vm.Busy);
|
||||
Assert.Contains(signaling.Call.Id, signaling.EndedCalls);
|
||||
Platform.ResumePlayback();
|
||||
await Task.Delay(30, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(1, creations);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Platform.GetLanCallDevices = previousDevices;
|
||||
Platform.CreateLanCallMedia = previousFactory;
|
||||
}
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Hangup_cancels_pending_native_start_without_waiting_for_startup_timeout()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.IO.Pipes;
|
||||
using System.Runtime.InteropServices;
|
||||
using LibVLCSharp.Shared;
|
||||
using PostIt.Services;
|
||||
|
|
@ -11,6 +12,76 @@ public sealed class LinuxLanCallMediaTests(ITestOutputHelper output)
|
|||
{
|
||||
public static bool HasFixture => OperatingSystem.IsLinux() &&
|
||||
File.Exists(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA"));
|
||||
public static bool HasTsFixture => OperatingSystem.IsLinux() &&
|
||||
File.Exists(Environment.GetEnvironmentVariable("POSTIT_TEST_TS"));
|
||||
|
||||
[Fact(Skip = "Requires Linux LibVLC.", SkipUnless = nameof(HasFixture))]
|
||||
public async Task Ports_are_released_and_credentials_regenerated_after_disposal()
|
||||
{
|
||||
var port = FreePort();
|
||||
var first = new LibVlcLanCallMedia("127.0.0.1", port, headless: true);
|
||||
var endpoint = first.Endpoint;
|
||||
await first.StartReceivingAsync(TestContext.Current.CancellationToken);
|
||||
await first.DisposeAsync();
|
||||
await first.DisposeAsync();
|
||||
await using var next = new LibVlcLanCallMedia("127.0.0.1", port, headless: true);
|
||||
Assert.NotEqual(endpoint.Key, next.Endpoint.Key);
|
||||
Assert.NotEqual(endpoint.Salt, next.Endpoint.Salt);
|
||||
await next.StartReceivingAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires Linux LibVLC.", SkipUnless = nameof(HasFixture))]
|
||||
public void Occupied_rtcp_port_is_rejected_before_capture_starts()
|
||||
{
|
||||
var port = FreePort();
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||
socket.Bind(new IPEndPoint(IPAddress.Loopback, port + 1));
|
||||
var error = Assert.Throws<SocketException>(() => new LibVlcLanCallMedia("127.0.0.1", port, headless: true));
|
||||
Assert.Equal(SocketError.AddressAlreadyInUse, error.SocketErrorCode);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Set POSTIT_TEST_TS to an H.264/AAC MPEG-TS file.", SkipUnless = nameof(HasTsFixture))]
|
||||
public async Task Already_encoded_capture_is_sent_over_srtp_without_transcoding()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var firstPort = FreePort();
|
||||
await using var sender = new LibVlcLanCallMedia("127.0.0.1", firstPort, headless: true);
|
||||
await using var receiver = new LibVlcLanCallMedia("127.0.0.1", FreePort(firstPort), headless: true);
|
||||
using var decoded = new DecodedVideo(receiver.Receiver);
|
||||
using var pipe = new AnonymousPipeServerStream(PipeDirection.Out);
|
||||
await using var source = File.OpenRead(Environment.GetEnvironmentVariable("POSTIT_TEST_TS")!);
|
||||
await receiver.StartReceivingAsync(ct);
|
||||
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
var pump = Task.Run(async () =>
|
||||
{
|
||||
var buffer = new byte[1316];
|
||||
while (await source.ReadAsync(buffer, lifetime.Token) is var count && count > 0)
|
||||
{
|
||||
await pipe.WriteAsync(buffer.AsMemory(0, count), lifetime.Token);
|
||||
await Task.Delay(4, lifetime.Token);
|
||||
}
|
||||
}, ct);
|
||||
try
|
||||
{
|
||||
await sender.StartEncodedSendingAsync(receiver.Endpoint,
|
||||
pipe.ClientSafePipeHandle.DangerousGetHandle().ToInt32(), ct);
|
||||
var elapsed = Stopwatch.StartNew();
|
||||
while (!HasMedia(receiver, decoded) && elapsed.Elapsed < TimeSpan.FromSeconds(20))
|
||||
{
|
||||
if (sender.Completion.IsCompleted) await sender.Completion;
|
||||
if (receiver.Completion.IsCompleted) await receiver.Completion;
|
||||
await Task.Delay(100, ct);
|
||||
}
|
||||
Assert.True(HasMedia(receiver, decoded));
|
||||
}
|
||||
finally
|
||||
{
|
||||
lifetime.Cancel();
|
||||
try { await pump; }
|
||||
catch (OperationCanceledException) when (lifetime.IsCancellationRequested) { }
|
||||
await sender.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory(Skip = "Set POSTIT_TEST_MEDIA to an audio/video file; requires Linux LibVLC SRTP and H.264/AAC encoders.",
|
||||
SkipUnless = nameof(HasFixture))]
|
||||
|
|
@ -22,8 +93,8 @@ public sealed class LinuxLanCallMediaTests(ITestOutputHelper output)
|
|||
var ct = TestContext.Current.CancellationToken;
|
||||
var firstPort = FreePort();
|
||||
var secondPort = FreePort(firstPort);
|
||||
await using var first = new LinuxLanCallMedia("127.0.0.1", firstPort, headless: true);
|
||||
await using var second = new LinuxLanCallMedia("127.0.0.1", secondPort, headless: true);
|
||||
await using var first = new LibVlcLanCallMedia("127.0.0.1", firstPort, headless: true);
|
||||
await using var second = new LibVlcLanCallMedia("127.0.0.1", secondPort, headless: true);
|
||||
using var firstVideo = new DecodedVideo(first.Receiver);
|
||||
using var secondVideo = new DecodedVideo(second.Receiver);
|
||||
await first.StartReceivingAsync(ct);
|
||||
|
|
@ -68,7 +139,7 @@ public sealed class LinuxLanCallMediaTests(ITestOutputHelper output)
|
|||
}
|
||||
}
|
||||
|
||||
private static bool HasMedia(LinuxLanCallMedia peer, DecodedVideo video) =>
|
||||
private static bool HasMedia(LibVlcLanCallMedia peer, DecodedVideo video) =>
|
||||
peer.Statistics.DecodedAudio > 0 && video.AudioSamples > 4800 && video.Frames >= 10;
|
||||
|
||||
private static int FreePort(int except = 0)
|
||||
|
|
@ -77,7 +148,13 @@ public sealed class LinuxLanCallMediaTests(ITestOutputHelper output)
|
|||
{
|
||||
if (port == except) continue;
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||
try { socket.Bind(new IPEndPoint(IPAddress.Loopback, port)); return port; }
|
||||
using var rtcp = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||
try
|
||||
{
|
||||
socket.Bind(new IPEndPoint(IPAddress.Loopback, port));
|
||||
rtcp.Bind(new IPEndPoint(IPAddress.Loopback, port + 1));
|
||||
return port;
|
||||
}
|
||||
catch (SocketException ex) when (ex.SocketErrorCode == SocketError.AddressAlreadyInUse) { }
|
||||
}
|
||||
throw new IOException("No free RTP port available.");
|
||||
|
|
|
|||
Loading…
Reference in a new issue