yavsc/src/PostIt/PostIt.Android/Services/AndroidPodcastCapture.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

409 lines
17 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;
using Android.Content;
using Android.Hardware.Camera2;
using Android.Hardware.Camera2.Params;
using Android.Media;
using Android.OS;
using Android.Views;
using Microsoft.Win32.SafeHandles;
using PostIt.Services;
using Stream = System.IO.Stream;
namespace PostIt.Android.Services;
[SupportedOSPlatform("android26.0")]
internal sealed class AndroidPodcastCapture : IPodcastCapture
{
private readonly PodcastCaptureService _service;
private readonly SemaphoreSlim _stopGate = new(1);
private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly Handler _handler = new(Looper.MainLooper!);
private CameraDevice? _camera;
private CameraCaptureSession? _session;
private MediaRecorder? _recorder;
private AndroidCameraFrameRotator? _rotator;
private AndroidCapturePreview? _preview;
public Avalonia.Controls.Control? Preview => _preview;
private Surface? _surface;
private ParcelFileDescriptor? _readPipe;
private ParcelFileDescriptor? _writePipe;
private Task _pump = Task.CompletedTask;
private Func<Task>? _stopOutput;
private Exception? _failure;
private bool _stopped;
private bool _stopRequested;
private bool _started;
private bool _disposed;
public Task Completion => _completion.Task;
internal string OrientationDiagnostics { get; private set; } = "";
internal string? TextureTransform => _rotator?.FirstTextureTransform;
private AndroidPodcastCapture(PodcastCaptureService service) => _service = service;
private static CameraManager GetManager(Context context)
=> context.GetSystemService(Context.CameraService) as CameraManager
?? throw new InvalidOperationException("Service camera Android indisponible.");
public static PodcastCaptureDevices GetDevices()
{
var activity = MainActivity.Current ?? throw new InvalidOperationException("Activite Android indisponible.");
var manager = GetManager(activity);
var cameras = new List<CaptureDevice>();
foreach (var id in manager.GetCameraIdList())
{
using var characteristics = manager.GetCameraCharacteristics(id);
using var facing = characteristics.Get(CameraCharacteristics.LensFacing) as Java.Lang.Integer;
cameras.Add(new(facing?.IntValue() == (int)LensFacing.Front ? "Camera avant" : "Camera arriere", id));
}
return new(cameras, [new("Sans microphone", ""), new("Microphone Android", "microphone")]);
}
public static async Task RequestPermissionsAsync(string? microphone, CancellationToken ct)
{
var activity = MainActivity.Current ?? throw new InvalidOperationException("Activite Android indisponible.");
await PodcastPermissions.EnsureAsync(activity, microphone is not null, ct);
if (!MainActivity.IsForeground)
throw new InvalidOperationException("Revenez dans PostIt pour demarrer la camera.");
}
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, pipeOutput != null);
var capture = new AndroidPodcastCapture(service) { _stopOutput = stopOutput };
service.Capture = capture;
try
{
ct.ThrowIfCancellationRequested();
await capture.StartNativeAsync(camera, microphone is not null, output, pipeOutput, ct);
return capture;
}
catch
{
await capture.DisposeAsync();
throw;
}
}
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, output, pipeOutput, ct); }
finally { _stopGate.Release(); }
}
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);
using var formats = characteristics.Get(CameraCharacteristics.ScalerStreamConfigurationMap) as StreamConfigurationMap
?? throw new InvalidOperationException("Formats camera indisponibles.");
var sizes = formats.GetOutputSizes(Java.Lang.Class.FromType(typeof(MediaRecorder)))
?? throw new InvalidOperationException("Aucun format video disponible.");
var size = sizes.Where(s => s.Width <= 640 && s.Height <= 480)
.OrderByDescending(s => s.Width * s.Height).FirstOrDefault()
?? throw new InvalidOperationException("La camera ne propose aucun format jusqu'a 480p.");
using var sensor = characteristics.Get(CameraCharacteristics.SensorOrientation) as Java.Lang.Integer
?? throw new InvalidOperationException("Orientation du capteur camera indisponible.");
using var facing = characteristics.Get(CameraCharacteristics.LensFacing) as Java.Lang.Integer
?? throw new InvalidOperationException("Position du capteur camera indisponible.");
var activity = MainActivity.Current ?? throw new InvalidOperationException("Activite Android indisponible.");
#pragma warning disable CS0618, CA1422
var display = OperatingSystem.IsAndroidVersionAtLeast(30)
? activity.Display : activity.WindowManager?.DefaultDisplay;
#pragma warning restore CS0618, CA1422
var displayRotation = display?.Rotation
?? throw new InvalidOperationException("Orientation de l'ecran Android indisponible.");
var displayDegrees = displayRotation switch
{
SurfaceOrientation.Rotation0 => 0,
SurfaceOrientation.Rotation90 => 90,
SurfaceOrientation.Rotation180 => 180,
SurfaceOrientation.Rotation270 => 270,
_ => throw new InvalidOperationException($"Rotation d'ecran Android inconnue : {displayRotation}.")
};
var rotation = CameraFrameOrientation.GetRotation(sensor.IntValue(), displayDegrees,
facing.IntValue() == (int)LensFacing.Front);
global::Android.Util.Log.Info("PostIt.Podcast",
$"Camera orientation: sensor={sensor.IntValue()}, display={displayDegrees}, facing={facing.IntValue()}, rotation={rotation}");
OrientationDiagnostics = $"sensor={sensor.IntValue()}, display={displayDegrees}, facing={facing.IntValue()}, rotation={rotation}";
#pragma warning disable CS0618
_recorder = OperatingSystem.IsAndroidVersionAtLeast(31)
? new MediaRecorder(_service) : new MediaRecorder();
#pragma warning restore CS0618
_recorder.Error += OnRecorderError;
if (microphone) _recorder.SetAudioSource(AudioSource.Mic);
_recorder.SetVideoSource(VideoSource.Surface);
_recorder.SetOutputFormat(OutputFormat.Mpeg2Ts);
_recorder.SetVideoSize(rotation is 90 or 270 ? size.Height : size.Width,
rotation is 90 or 270 ? size.Width : size.Height);
_recorder.SetVideoFrameRate(30);
_recorder.SetVideoEncodingBitRate(1200000);
_recorder.SetVideoEncoder(VideoEncoder.H264);
if (microphone)
{
_recorder.SetAudioSamplingRate(48000);
_recorder.SetAudioChannels(1);
_recorder.SetAudioEncodingBitRate(96000);
_recorder.SetAudioEncoder(AudioEncoder.Aac);
}
var pipe = ParcelFileDescriptor.CreatePipe()
?? throw new IOException("Creation du canal MPEG-TS impossible.");
_readPipe = pipe[0];
_writePipe = pipe[1];
_recorder.SetOutputFile(_writePipe.FileDescriptor);
await Task.Run(_recorder.Prepare, ct);
ct.ThrowIfCancellationRequested();
if (_stopRequested) throw new System.OperationCanceledException("Demarrage de la capture annule.");
_surface = _recorder.Surface ?? throw new InvalidOperationException("Surface encodeur indisponible.");
_rotator = await AndroidCameraFrameRotator.CreateAsync(_surface, size.Width, size.Height, rotation, Fail);
_preview = new AndroidCapturePreview(_rotator);
ct.ThrowIfCancellationRequested();
if (_stopRequested) throw new System.OperationCanceledException("Demarrage de la capture annule.");
var cameraSurface = _rotator.CameraSurface;
var opened = new TaskCompletionSource<CameraDevice>(TaskCreationOptions.RunContinuationsAsynchronously);
manager.OpenCamera(cameraId, new DeviceCallback(this, opened), _handler);
_camera = await opened.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
var configured = new TaskCompletionSource<CameraCaptureSession>(TaskCreationOptions.RunContinuationsAsynchronously);
#pragma warning disable CS0618, CA1422
_camera.CreateCaptureSession(new List<Surface> { cameraSurface }, new SessionCallback(this, configured), _handler);
#pragma warning restore CS0618, CA1422
_session = await configured.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
ct.ThrowIfCancellationRequested();
if (_stopRequested) throw new System.OperationCanceledException("Demarrage de la capture annule.");
using var request = _camera.CreateCaptureRequest(CameraTemplate.Record);
request.AddTarget(cameraSurface);
using var built = request.Build();
if (pipeOutput != null)
await pipeOutput(_readPipe.Fd, ct);
else
{
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 () =>
{
using (handle)
using (stream)
{
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);
}
}
});
}
await Task.Run(_recorder.Start, ct);
_started = true;
await _rotator.StartAsync();
_session.SetRepeatingRequest(built, null, _handler);
_writePipe.Close();
_writePipe.Dispose();
_writePipe = null;
}
private sealed class DeviceCallback(AndroidPodcastCapture capture, TaskCompletionSource<CameraDevice> opened)
: CameraDevice.StateCallback
{
public override void OnOpened(CameraDevice camera)
{
if (capture._stopRequested || opened.Task.IsCompleted)
{
camera.Close();
opened.TrySetException(new IOException("Ouverture camera annulee."));
}
else
{
capture._camera = camera;
opened.TrySetResult(camera);
}
}
public override void OnDisconnected(CameraDevice camera) => Fail(camera, "Camera Android deconnectee.");
public override void OnError(CameraDevice camera, CameraError error) => Fail(camera, $"Erreur camera Android : {error}.");
private void Fail(CameraDevice camera, string message)
{
camera.Close();
var error = new IOException(message);
opened.TrySetException(error);
capture.Fail(error);
}
}
private sealed class SessionCallback(AndroidPodcastCapture capture, TaskCompletionSource<CameraCaptureSession> configured)
: CameraCaptureSession.StateCallback
{
public override void OnConfigured(CameraCaptureSession session)
{
if (capture._stopRequested || configured.Task.IsCompleted)
{
session.Close();
configured.TrySetException(new IOException("Configuration camera annulee."));
}
else
{
capture._session = session;
configured.TrySetResult(session);
}
}
public override void OnConfigureFailed(CameraCaptureSession session)
{
session.Close();
var error = new IOException("Configuration de la capture Android impossible.");
configured.TrySetException(error);
capture.Fail(error);
}
}
private void OnRecorderError(object? sender, MediaRecorder.ErrorEventArgs e)
=> Fail(new IOException($"Erreur encodeur Android : {e.What}/{e.Extra}."));
private void Fail(Exception error)
{
if (_stopped) return;
_failure = error;
_completion.TrySetException(error);
RequestStop();
}
internal async void RequestStop()
{
try { await StopAsync(); }
catch (Exception ex) { global::Android.Util.Log.Error("PostIt.Podcast", ex.ToString()); }
}
public async Task StopAsync()
{
_stopRequested = true;
await _stopGate.WaitAsync();
try
{
if (_stopped) return;
_stopped = true;
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
{
// Keep frames flowing until MediaRecorder has drained its encoder.
if (_started) _recorder?.Stop();
}
catch (Java.Lang.RuntimeException ex)
{
_failure = ex;
_completion.TrySetException(new IOException("L'encodeur Android n'a pas pu finaliser la capture.", ex));
}
finally
{
_started = false;
_session?.Close();
_session?.Dispose();
_session = null;
_camera?.Close();
_camera?.Dispose();
_camera = null;
_rotator?.Dispose();
_rotator = null;
if (_recorder is not null)
{
_recorder.Error -= OnRecorderError;
_recorder.Release();
_recorder.Dispose();
_recorder = null;
}
_surface?.Dispose();
_surface = null;
_writePipe?.Close();
_writePipe?.Dispose();
_writePipe = null;
}
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
try { await StopAsync(); }
finally
{
_readPipe?.Dispose();
_readPipe = null;
_service.Capture = null;
_service.StopSelf();
_handler.Dispose();
_stopGate.Dispose();
}
}
}