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; 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 Surface? _surface; private ParcelFileDescriptor? _readPipe; private ParcelFileDescriptor? _writePipe; private Task _pump = Task.CompletedTask; private Exception? _failure; private bool _stopped; private bool _stopRequested; private bool _started; private bool _disposed; public Task Completion => _completion.Task; 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(); 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 StartAsync(string camera, string? microphone, string directory, 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); service.Capture = capture; try { ct.ThrowIfCancellationRequested(); await capture.StartNativeAsync(camera, microphone is not null, directory, ct); return capture; } catch { await capture.DisposeAsync(); throw; } } private async Task StartNativeAsync(string cameraId, bool microphone, string directory, CancellationToken ct) { await _stopGate.WaitAsync(ct); try { await ConfigureNativeAsync(cameraId, microphone, directory, ct); } finally { _stopGate.Release(); } } private async Task ConfigureNativeAsync(string cameraId, bool microphone, string directory, 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."); #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(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."); var opened = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); manager.OpenCamera(cameraId, new DeviceCallback(this, opened), _handler); _camera = await opened.Task.WaitAsync(TimeSpan.FromSeconds(10), ct); var configured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); #pragma warning disable CS0618, CA1422 _camera.CreateCaptureSession(new List { _surface }, 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(_surface); using var built = request.Build(); _session.SetRepeatingRequest(built, null, _handler); 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 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(); } 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; _writePipe.Close(); _writePipe.Dispose(); _writePipe = null; } private sealed class DeviceCallback(AndroidPodcastCapture capture, TaskCompletionSource 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 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; await Task.Run(StopNative); await _pump; } finally { _stopGate.Release(); } } private void StopNative() { try { 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; 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(); } } }