diff --git a/CHANGELOG.md b/CHANGELOG.md index f0cd45fe7..337e74288 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added +* [PostIt.Android] Fix the crash when starting a podcast broadcast with camera preview active: detach preview callbacks using the retained surface holder, before deferred native-view destruction, without accessing an already disposed SurfaceView. Add a device regression covering preview teardown followed by local capture. * [PostIt.Android] Keep the screen awake during foreground video playback and release it on pause, stop, completion, error or video teardown. Add a persisted, default-off podcast option to keep the screen awake during camera preview/live capture; release on capture end and allow normal sleep in the background without changing the broadcast service. Include native LibVLC libraries when building with a single runtime identifier (including physical ARM64 phones). * [PostIt/Podcasts] Preview the selected camera before starting a live broadcast, without creating an emission, encoding files or opening the microphone. Release preview on stop, navigation, logout and Android backgrounding, and before starting the actual broadcast. Correct Android portrait orientation for rear and front cameras: subtract the sensor rotation already carried by Camera2's SurfaceTexture matrix, reversing the remaining rotation when that matrix mirrors the front camera (it was turned 90° or upside down); track display changes for both preview and encoded frames, preserving aspect ratio and fixed live stream dimensions. * [PostIt] Schedule silent OIDC renewal independently of API traffic and SignalR reconnects, while the app is active or background chat is enabled. Derive the wait from the access-token expiration minus the existing 60-second safety margin instead of polling; recalculate after token rotation, login/logout, activity and preference changes. Persist rotated tokens and serialize with request-driven renewal/logout. Run the same maintenance in headless Android chat; background execution and absolute refresh-token lifetime remain subject to Android/identity-provider limits. diff --git a/doc/architecture/postit.md b/doc/architecture/postit.md index 7286985b8..9a0ae000e 100644 --- a/doc/architecture/postit.md +++ b/doc/architecture/postit.md @@ -69,6 +69,9 @@ sans lancer de service de capture ni de notification de direct. Les Podcasts en direct conservent leur service caméra/microphone existant. Le chemin Android partage le rendu Camera2/EGL entre aperçu seul et encodage. +L'arrêt de l'aperçu détache ses callbacks via le `SurfaceHolder` conservé, +avant la destruction différée du contrôle natif ; le nettoyage ne relit pas +la propriété `Holder` d'une `SurfaceView` dont le wrapper Java peut être libéré. La rotation physique utilise l'orientation du capteur, la caméra avant/arrière et la rotation de l'écran. Camera2 peut déjà inclure la rotation du capteur dans la matrice `SurfaceTexture`, ainsi qu'un miroir pour la caméra avant : diff --git a/doc/testing.md b/doc/testing.md index 141162822..7a8d1eba2 100644 --- a/doc/testing.md +++ b/doc/testing.md @@ -182,6 +182,17 @@ adb shell am start -n fr.pschneider.postit/fr.pschneider.postit.PodcastCaptureTe adb shell run-as fr.pschneider.postit cat files/podcast-capture-test-result.txt ``` +Pour verifier la transition apercu vers diffusion sans contacter le serveur : + +```bash +adb shell am start -S -n fr.pschneider.postit/fr.pschneider.postit.PodcastCaptureTestActivity --es microphone false --es previewTransition true +``` + +Cette variante affiche puis retire deux apercus natifs avant la capture : +nettoyage normal, puis nettoyage apres liberation du wrapper Java de la vue. +Elle attend la destruction differee Avalonia et verifie que la capture suivante +produit toujours des segments. Le resultat doit inclure `previewTransition=True`. + Attendre `PASS` : au moins trois segments, 8 a 16 secondes capturees, pistes H.264 et AAC presentes, et progression des segments en arriere-plan. Ce test detecte notamment les captures de zero seconde dues au decalage entre diff --git a/src/PostIt/PostIt.Android/Services/AndroidCapturePreview.cs b/src/PostIt/PostIt.Android/Services/AndroidCapturePreview.cs index b66037510..4c3f9c9e2 100644 --- a/src/PostIt/PostIt.Android/Services/AndroidCapturePreview.cs +++ b/src/PostIt/PostIt.Android/Services/AndroidCapturePreview.cs @@ -32,7 +32,7 @@ internal sealed class AndroidCapturePreview : Grid, IDisposable private sealed class PreviewHost(AndroidCameraFrameRotator rotator, Action failed) : NativeControlHost, IDisposable { - private SurfaceView? _view; + private ISurfaceHolder? _holder; private SurfaceCallback? _callback; private bool _disposed; @@ -40,10 +40,15 @@ internal sealed class AndroidCapturePreview : Grid, IDisposable { var activity = MainActivity.Current ?? throw new InvalidOperationException("Activite Android indisponible pour l'apercu."); - _view = new SurfaceView(activity); - _callback = new SurfaceCallback(this); - _view.Holder!.AddCallback(_callback); - return new AndroidViewControlHandle(_view); + var view = new SurfaceView(activity); + if (!_disposed) + { + _holder = view.Holder + ?? throw new InvalidOperationException("Surface Android indisponible pour l'apercu."); + _callback = new SurfaceCallback(this); + _holder.AddCallback(_callback); + } + return new AndroidViewControlHandle(view); } private void SetSurface(ISurfaceHolder holder, int width, int height) @@ -60,23 +65,30 @@ internal sealed class AndroidCapturePreview : Grid, IDisposable } protected override void DestroyNativeControlCore(IPlatformHandle control) + { + ReleaseSurface(); + base.DestroyNativeControlCore(control); + } + + private void ReleaseSurface() { Detach(); if (_callback is not null) { - _view?.Holder?.RemoveCallback(_callback); + // The native view wrapper may already be disposed during deferred host destruction. + _holder?.RemoveCallback(_callback); _callback.Dispose(); _callback = null; } - _view = null; - base.DestroyNativeControlCore(control); + _holder?.Dispose(); + _holder = null; } public void Dispose() { if (_disposed) return; _disposed = true; - Detach(); + ReleaseSurface(); } private sealed class SurfaceCallback(PreviewHost owner) : Java.Lang.Object, ISurfaceHolderCallback diff --git a/src/PostIt/PostIt.Tests/AndroidPodcastCaptureTestActivity.cs b/src/PostIt/PostIt.Tests/AndroidPodcastCaptureTestActivity.cs index cab2d9f6f..f9d62ce0c 100644 --- a/src/PostIt/PostIt.Tests/AndroidPodcastCaptureTestActivity.cs +++ b/src/PostIt/PostIt.Tests/AndroidPodcastCaptureTestActivity.cs @@ -9,7 +9,9 @@ using Android.App; using Android.Content; using Android.Media; using Android.OS; +using Avalonia.Controls; using Avalonia.Threading; +using Avalonia.VisualTree; using PostIt.Android; using PostIt.Android.Services; using PostIt.Services; @@ -23,12 +25,14 @@ public sealed class AndroidPodcastCaptureTestActivity : Activity { private bool _microphone = true; private bool _notificationStop; + private bool _previewTransition; private int _cameraIndex; protected override async void OnCreate(Bundle? state) { base.OnCreate(state); _microphone = Intent?.GetStringExtra("microphone") != "false"; _notificationStop = Intent?.GetStringExtra("notificationStop") == "true"; + _previewTransition = Intent?.GetStringExtra("previewTransition") == "true"; _cameraIndex = Intent?.GetStringExtra("camera") == "front" ? 1 : 0; var resultPath = Path.Combine(FilesDir!.AbsolutePath, "podcast-capture-test-result.txt"); File.WriteAllText(resultPath, "RUNNING"); @@ -36,7 +40,7 @@ public sealed class AndroidPodcastCaptureTestActivity : Activity try { await RunAsync(result); - File.WriteAllText(resultPath, $"PASS\nsegments={result.GetInt("segments")}\ndurationSeconds={result.GetDouble("durationSeconds")}\naudio={result.GetBoolean("audio")}\ndirectory={result.GetString("directory")}\nbackground={result.GetString("background")}\norientation={result.GetString("orientation")}\ntextureTransform={result.GetString("textureTransform")}"); + File.WriteAllText(resultPath, $"PASS\nsegments={result.GetInt("segments")}\ndurationSeconds={result.GetDouble("durationSeconds")}\naudio={result.GetBoolean("audio")}\ndirectory={result.GetString("directory")}\nbackground={result.GetString("background")}\norientation={result.GetString("orientation")}\ntextureTransform={result.GetString("textureTransform")}\npreviewTransition={_previewTransition}"); } catch (Exception ex) { @@ -58,12 +62,26 @@ public sealed class AndroidPodcastCaptureTestActivity : Activity Directory.CreateDirectory(directory); var devices = AndroidPodcastCapture.GetDevices(); if (devices.Cameras.Count == 0) throw new InvalidOperationException("No emulator camera."); + var camera = devices.Cameras[Math.Min(_cameraIndex, devices.Cameras.Count - 1)].Value; + NavigationPage? navigation = null; + ContentPage? page = null; IPodcastCapture? capture = null; try { + if (_previewTransition) + { + navigation = (Avalonia.Application.Current as App)?.View?.FindControl("NavRoot") + ?? throw new InvalidOperationException("Main navigation unavailable."); + page = new ContentPage(); + await Dispatcher.UIThread.InvokeAsync(async () => await navigation.PushAsync(page)); + foreach (var disposeViewFirst in new[] { false, true }) + await VerifyPreviewTeardownAsync(camera, page, disposeViewFirst); + } capture = await Dispatcher.UIThread.InvokeAsync(() => AndroidPodcastCapture.StartAsync( - devices.Cameras[Math.Min(_cameraIndex, devices.Cameras.Count - 1)].Value, + camera, _microphone ? "microphone" : null, directory, CancellationToken.None)); + if (page is not null) + await Dispatcher.UIThread.InvokeAsync(() => { page.Content = capture.Preview; }); await Task.Delay(3000); var before = SegmentCount(directory); context.StartActivity(new Intent(Intent.ActionMain).AddCategory(Intent.CategoryHome).AddFlags(ActivityFlags.NewTask)); @@ -112,7 +130,50 @@ public sealed class AndroidPodcastCaptureTestActivity : Activity } finally { + if (page is not null) + await Dispatcher.UIThread.InvokeAsync(() => { page.Content = null; }); if (capture is not null) await capture.DisposeAsync(); + if (navigation is not null) + await Dispatcher.UIThread.InvokeAsync(async () => await navigation.PopAsync()); + await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.SystemIdle); + } + } + + private static async Task VerifyPreviewTeardownAsync(string camera, ContentPage page, bool disposeViewFirst) + { + await using var preview = await Dispatcher.UIThread.InvokeAsync( + () => AndroidPodcastCapture.StartPreviewAsync(camera, CancellationToken.None)); + var control = preview.Preview ?? throw new InvalidOperationException("Camera preview unavailable."); + await Dispatcher.UIThread.InvokeAsync(() => { page.Content = control; }); + try + { + // NativeControlHost does not expose its handle publicly. + var handleProperty = typeof(NativeControlHost).GetProperty("NativeControlHandle", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Native preview handle property unavailable."); + Avalonia.Android.AndroidViewControlHandle? handle = null; + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + while (handle is null) + { + handle = await Dispatcher.UIThread.InvokeAsync(() => + handleProperty.GetValue(control.GetVisualDescendants().OfType().Single()) + as Avalonia.Android.AndroidViewControlHandle); + if (handle is null) await Task.Delay(50, timeout.Token); + } + await Task.Delay(500, timeout.Token); + await Dispatcher.UIThread.InvokeAsync(() => + { + // Reproduce teardown after Avalonia has already released the Java view wrapper. + if (disposeViewFirst) handle.View.Dispose(); + page.Content = null; + }); + await preview.DisposeAsync(); + await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.SystemIdle); + await preview.Completion; + } + finally + { + await Dispatcher.UIThread.InvokeAsync(() => { page.Content = null; }); } }