yavsc/src/PostIt/PostIt.Tests/LinuxLanCallMediaTests.cs
Paul Schneider a676ef3575
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>
2026-10-26 04:15:19 +00:00

190 lines
8.7 KiB
C#

using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.IO.Pipes;
using System.Runtime.InteropServices;
using LibVLCSharp.Shared;
using PostIt.Services;
namespace PostIt.Tests;
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))]
[InlineData(false, false)]
[InlineData(true, false)]
[InlineData(false, true)]
public async Task Two_peers_decode_bidirectional_encrypted_audio_video_and_reject_wrong_keys(bool wrongKey, bool preview)
{
var ct = TestContext.Current.CancellationToken;
var firstPort = FreePort();
var secondPort = FreePort(firstPort);
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);
await second.StartReceivingAsync(ct);
await Task.Delay(300, ct);
Media Source(LibVLC vlc)
{
var media = new Media(vlc, new Uri(Path.GetFullPath(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA")!)));
media.AddOption(":input-repeat=5");
return media;
}
await first.StartSendingAsync(wrongKey ? second.Endpoint with { Key = new string('0', 32) } : second.Endpoint,
Source, preview, ct);
await second.StartSendingAsync(first.Endpoint, Source, preview, ct);
var elapsed = Stopwatch.StartNew();
while (elapsed.Elapsed < TimeSpan.FromSeconds(wrongKey ? 8 : 20))
{
if (first.Completion.IsCompleted) await first.Completion;
if (second.Completion.IsCompleted) await second.Completion;
if (!wrongKey && HasMedia(first, firstVideo) && HasMedia(second, secondVideo) &&
first.HasRemoteMedia && second.HasRemoteMedia) break;
await Task.Delay(100, ct);
}
var a = first.Statistics;
var b = second.Statistics;
output.WriteLine($"A: audioSamples={firstVideo.AudioSamples}, rendered={firstVideo.Frames}; " +
$"B: audioSamples={secondVideo.AudioSamples}, rendered={secondVideo.Frames}; elapsed={elapsed.ElapsedMilliseconds}ms");
Assert.True(HasMedia(first, firstVideo), "First peer must decode received audio and video.");
Assert.True(first.HasRemoteMedia, "The UI must detect initial audio/video reception.");
if (wrongKey)
{
Assert.Equal(0, b.DecodedVideo);
Assert.Equal(0, b.DecodedAudio);
Assert.Equal(0, secondVideo.Frames);
Assert.Equal(0, secondVideo.AudioSamples);
Assert.False(second.HasRemoteMedia);
}
else
{
Assert.True(HasMedia(second, secondVideo), "Second peer must decode received audio and video.");
Assert.True(second.HasRemoteMedia);
}
}
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)
{
for (var port = 32000; port < 60000; port += 2)
{
if (port == except) continue;
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
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.");
}
private sealed class DecodedVideo : IDisposable
{
private readonly IntPtr _pixels = Marshal.AllocHGlobal(640 * 480 * 4);
private int _frames;
private long _audioSamples;
public int Frames => Volatile.Read(ref _frames);
public long AudioSamples => Interlocked.Read(ref _audioSamples);
private readonly MediaPlayer _player;
public DecodedVideo(MediaPlayer player)
{
_player = player;
player.SetAudioFormat("S16N", 48000, 1);
player.SetAudioCallbacks((_, _, count, _) => Interlocked.Add(ref _audioSamples, count),
null, null, null, null);
player.SetVideoFormat("RV32", 640, 480, 640 * 4);
player.SetVideoCallbacks((_, planes) =>
{
Marshal.WriteIntPtr(planes, _pixels);
return IntPtr.Zero;
}, null, (_, _) => Interlocked.Increment(ref _frames));
}
public void Dispose()
{
_player.Stop();
Marshal.FreeHGlobal(_pixels);
}
}
}