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:
Paul Schneider 2026-10-26 04:15:19 +00:00
commit a676ef3575
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
19 changed files with 718 additions and 105 deletions

View file

@ -0,0 +1,117 @@
using System;
using System.Linq;
using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using PostIt.Services;
using Yavsc.Abstract.Chat.Calls;
namespace PostIt.Android.Services;
[SupportedOSPlatform("android26.0")]
internal sealed class AndroidLanCallMedia : ILanCallMedia
{
private readonly LibVlcLanCallMedia _media;
private readonly ContentControl _local = new();
private readonly AndroidVideoSurface _remote;
private readonly CancellationTokenSource _lifetime = new();
private readonly TaskCompletionSource _captureEnded = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly Task _receiving;
private IPodcastCapture? _capture;
private bool _disposed;
public Control LocalVideo => _local;
public Control RemoteVideo => _remote;
public LanCallEndpoint Endpoint => _media.Endpoint;
public bool HasRemoteMedia => !_disposed && _media.HasRemoteMedia;
public Task Completion { get; }
public static PodcastCaptureDevices GetDevices()
{
var devices = AndroidPodcastCapture.GetDevices();
return new(devices.Cameras.OrderByDescending(c => c.Name == "Camera avant").ToArray(),
devices.Microphones.Where(m => m.Value == "microphone").ToArray());
}
public AndroidLanCallMedia(string localAddress, int port)
{
_media = new LibVlcLanCallMedia(localAddress, port);
_remote = new AndroidVideoSurface(_media.Receiver, replay: false);
_receiving = ReceiveAsync();
Completion = ObserveAsync();
}
private async Task ReceiveAsync()
{
await _remote.Ready.WaitAsync(TimeSpan.FromSeconds(10), _lifetime.Token);
await _media.StartReceivingAsync(_lifetime.Token);
}
private async Task ObserveAsync()
{
var first = await Task.WhenAny(_media.Completion, _captureEnded.Task, _receiving);
await first;
if (first == _receiving)
{
var finished = await Task.WhenAny(_media.Completion, _captureEnded.Task);
await finished;
}
}
public async Task StartSendingAsync(LanCallEndpoint remote, string camera, string microphone, CancellationToken ct)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_capture != null) throw new InvalidOperationException("La capture de cet appel est deja demarree.");
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _lifetime.Token);
_capture = await AndroidPodcastCapture.StartCallAsync(camera, microphone,
(fd, token) => _media.StartEncodedSendingAsync(remote, fd, token), _media.StopAsync, linked.Token);
_local.Content = _capture.Preview;
_ = ObserveCaptureAsync(_capture);
}
private async Task ObserveCaptureAsync(IPodcastCapture capture)
{
try
{
await capture.Completion;
if (!_disposed)
_captureEnded.TrySetException(new InvalidOperationException("Capture de l'appel arretee."));
}
catch (Exception error) { _captureEnded.TrySetException(error); }
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
_lifetime.Cancel();
var stop = _capture?.StopAsync();
try
{
if (stop != null) await stop;
}
finally
{
try
{
if (_capture != null) await _capture.DisposeAsync();
}
finally
{
_local.Content = null;
try { await _media.StopAsync(); }
finally
{
_remote.Dispose();
try { await _media.DisposeAsync(); }
finally
{
_lifetime.Dispose();
_captureEnded.TrySetResult();
}
}
}
}
}
}

View file

@ -13,6 +13,7 @@ using Android.OS;
using Android.Views;
using Microsoft.Win32.SafeHandles;
using PostIt.Services;
using Stream = System.IO.Stream;
namespace PostIt.Android.Services;
@ -33,6 +34,7 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
private ParcelFileDescriptor? _readPipe;
private ParcelFileDescriptor? _writePipe;
private Task _pump = Task.CompletedTask;
private Func<Task>? _stopOutput;
private Exception? _failure;
private bool _stopped;
private bool _stopRequested;
@ -71,16 +73,26 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
}
public static async Task<IPodcastCapture> StartAsync(string camera, string? microphone, string directory, CancellationToken ct)
=> await StartCoreAsync(camera, microphone,
(stream, token) => new TransportStreamCapture(directory).RunAsync(stream, token), null, null, ct);
internal static Task<IPodcastCapture> StartCallAsync(string camera, string microphone,
Func<int, CancellationToken, Task> output, Func<Task> stopOutput, CancellationToken ct) =>
StartCoreAsync(camera, microphone, null, output, stopOutput, ct);
private static async Task<IPodcastCapture> StartCoreAsync(string camera, string? microphone,
Func<Stream, CancellationToken, Task>? output,
Func<int, CancellationToken, Task>? pipeOutput, Func<Task>? stopOutput, CancellationToken ct)
{
await RequestPermissionsAsync(microphone, ct);
var activity = MainActivity.Current ?? throw new InvalidOperationException("Activite Android indisponible.");
var service = await PodcastCaptureService.StartAsync(activity, microphone is not null, ct);
var capture = new AndroidPodcastCapture(service);
var service = await PodcastCaptureService.StartAsync(activity, microphone is not null, ct, pipeOutput != null);
var capture = new AndroidPodcastCapture(service) { _stopOutput = stopOutput };
service.Capture = capture;
try
{
ct.ThrowIfCancellationRequested();
await capture.StartNativeAsync(camera, microphone is not null, directory, ct);
await capture.StartNativeAsync(camera, microphone is not null, output, pipeOutput, ct);
return capture;
}
catch
@ -90,14 +102,18 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
}
}
private async Task StartNativeAsync(string cameraId, bool microphone, string directory, CancellationToken ct)
private async Task StartNativeAsync(string cameraId, bool microphone,
Func<Stream, CancellationToken, Task>? output,
Func<int, CancellationToken, Task>? pipeOutput, CancellationToken ct)
{
await _stopGate.WaitAsync(ct);
try { await ConfigureNativeAsync(cameraId, microphone, directory, ct); }
try { await ConfigureNativeAsync(cameraId, microphone, output, pipeOutput, ct); }
finally { _stopGate.Release(); }
}
private async Task ConfigureNativeAsync(string cameraId, bool microphone, string directory, CancellationToken ct)
private async Task ConfigureNativeAsync(string cameraId, bool microphone,
Func<Stream, CancellationToken, Task>? output,
Func<int, CancellationToken, Task>? pipeOutput, CancellationToken ct)
{
var manager = GetManager(_service);
using var characteristics = manager.GetCameraCharacteristics(cameraId);
@ -179,28 +195,34 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
using var request = _camera.CreateCaptureRequest(CameraTemplate.Record);
request.AddTarget(cameraSurface);
using var built = request.Build();
var handle = new SafeFileHandle((IntPtr)_readPipe.Fd, ownsHandle: false);
var stream = new FileStream(handle, FileAccess.Read);
_pump = Task.Run(async () =>
if (pipeOutput != null)
await pipeOutput(_readPipe.Fd, ct);
else
{
using (handle)
using (stream)
var consume = output ?? throw new InvalidOperationException("Destination de capture absente.");
var handle = new SafeFileHandle((IntPtr)_readPipe.Fd, ownsHandle: false);
var stream = new FileStream(handle, FileAccess.Read);
_pump = Task.Run(async () =>
{
try
using (handle)
using (stream)
{
await new TransportStreamCapture(directory).RunAsync(stream, CancellationToken.None);
if (_failure is not null) throw new IOException("Capture Android interrompue.", _failure);
if (!_stopped) throw new IOException("Le flux de capture Android s'est ferme prematurement.");
_completion.TrySetResult();
try
{
await consume(stream, CancellationToken.None);
if (_failure is not null) throw new IOException("Capture Android interrompue.", _failure);
if (!_stopped) throw new IOException("Le flux de capture Android s'est ferme prematurement.");
_completion.TrySetResult();
}
catch (Exception ex)
{
_completion.TrySetException(ex);
// Drain until MediaRecorder stops, so its writer cannot block Stop().
await stream.CopyToAsync(Stream.Null);
}
}
catch (Exception ex)
{
_completion.TrySetException(ex);
// Drain until MediaRecorder stops, so its writer cannot block Stop().
await stream.CopyToAsync(System.IO.Stream.Null);
}
}
});
});
}
await Task.Run(_recorder.Start, ct);
_started = true;
await _rotator.StartAsync();
@ -289,14 +311,48 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
{
if (_stopped) return;
_stopped = true;
if (_preview is not null)
await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(_preview.Dispose);
await Task.Run(StopNative);
await _pump;
try
{
if (_preview is not null)
await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(_preview.Dispose);
}
finally
{
try { await StopOutputAsync(); }
finally
{
await Task.Run(StopNative);
await _pump;
}
}
_completion.TrySetResult();
}
catch (Exception error)
{
_completion.TrySetException(error);
throw;
}
finally { _stopGate.Release(); }
}
private async Task StopOutputAsync()
{
if (_stopOutput == null || _readPipe == null) return;
try { await _stopOutput(); }
finally
{
// The native sender has released its reader. Drain so the recorder can always stop.
var handle = new SafeFileHandle((IntPtr)_readPipe.Fd, ownsHandle: false);
var stream = new FileStream(handle, FileAccess.Read);
_pump = Task.Run(async () =>
{
using (handle)
using (stream)
await stream.CopyToAsync(Stream.Null);
});
}
}
private void StopNative()
{
try

View file

@ -1,4 +1,5 @@
using System;
using System.Threading.Tasks;
using Avalonia.Android;
using Avalonia.Controls;
using Avalonia.Platform;
@ -18,6 +19,8 @@ internal sealed class AndroidVideoSurface(MediaPlayer player, bool replay) : Nat
private VideoView? _video;
private ReplayLayout? _layout;
private bool _disposed;
private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
internal Task Ready => _ready.Task;
protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
{
@ -25,6 +28,10 @@ internal sealed class AndroidVideoSurface(MediaPlayer player, bool replay) : Nat
?? throw new InvalidOperationException("L'activite Android est indisponible.");
var video = new VideoView(activity) { MediaPlayer = _disposed ? null : player };
_video = video;
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
if (!_disposed) _ready.TrySetResult();
}, Avalonia.Threading.DispatcherPriority.Loaded);
if (replay && !_disposed)
{
_layout = new ReplayLayout(activity, video, player);
@ -45,6 +52,7 @@ internal sealed class AndroidVideoSurface(MediaPlayer player, bool replay) : Nat
public void Dispose()
{
_disposed = true;
_ready.TrySetCanceled();
_layout?.Stop();
if (_video is not null) _video.MediaPlayer = null;
}

View file

@ -21,14 +21,16 @@ public sealed class PodcastCaptureService : Service
private PowerManager.WakeLock? _wakeLock;
internal AndroidPodcastCapture? Capture { get; set; }
public static async Task<PodcastCaptureService> StartAsync(Activity activity, bool microphone, CancellationToken ct)
public static async Task<PodcastCaptureService> StartAsync(Activity activity, bool microphone,
CancellationToken ct, bool call = false)
{
if (_pending is not null) throw new InvalidOperationException("Un direct Android est deja en cours.");
var pending = new TaskCompletionSource<PodcastCaptureService>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending = pending;
try
{
var intent = new Intent(activity, typeof(PodcastCaptureService)).PutExtra("microphone", microphone);
var intent = new Intent(activity, typeof(PodcastCaptureService))
.PutExtra("microphone", microphone).PutExtra("call", call);
activity.StartForegroundService(intent);
return await pending.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
}
@ -65,11 +67,13 @@ public sealed class PodcastCaptureService : Service
PendingIntentFlags.UpdateCurrent | PendingIntentFlags.Immutable);
var builder = new NotificationCompat.Builder(this, Channel);
builder.SetSmallIcon(global::Android.Resource.Drawable.IcMenuCamera);
builder.SetContentTitle("Podcast en direct");
builder.SetContentText("Camera active. Toucher pour revenir au direct.");
var call = intent?.GetBooleanExtra("call", false) == true;
builder.SetContentTitle(call ? "Appel audio-video LAN/VPN" : "Podcast en direct");
builder.SetContentText(call ? "Camera et microphone actifs. Toucher pour revenir a l'appel."
: "Camera active. Toucher pour revenir au direct.");
builder.SetContentIntent(open);
builder.SetOngoing(true);
builder.AddAction(global::Android.Resource.Drawable.IcMediaPause, "Arreter et publier", stop);
builder.AddAction(global::Android.Resource.Drawable.IcMediaPause, call ? "Raccrocher" : "Arreter et publier", stop);
var notification = builder.Build();
var type = (ForegroundService)0;
if (OperatingSystem.IsAndroidVersionAtLeast(30))