fix(android): prevent crash when starting broadcast from preview

Detach preview callbacks through the retained surface holder before deferred native view destruction. Cover native preview teardown followed by camera capture and document the regression check.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-11-05 11:54:03 +00:00
commit b98f82663d
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
5 changed files with 99 additions and 11 deletions

View file

@ -5,6 +5,7 @@
### Added ### 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.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/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. * [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.

View file

@ -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. 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. 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 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 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 : dans la matrice `SurfaceTexture`, ainsi qu'un miroir pour la caméra avant :

View file

@ -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 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, Attendre `PASS` : au moins trois segments, 8 a 16 secondes capturees,
pistes H.264 et AAC presentes, et progression des segments en arriere-plan. 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 Ce test detecte notamment les captures de zero seconde dues au decalage entre

View file

@ -32,7 +32,7 @@ internal sealed class AndroidCapturePreview : Grid, IDisposable
private sealed class PreviewHost(AndroidCameraFrameRotator rotator, Action<Exception> failed) private sealed class PreviewHost(AndroidCameraFrameRotator rotator, Action<Exception> failed)
: NativeControlHost, IDisposable : NativeControlHost, IDisposable
{ {
private SurfaceView? _view; private ISurfaceHolder? _holder;
private SurfaceCallback? _callback; private SurfaceCallback? _callback;
private bool _disposed; private bool _disposed;
@ -40,10 +40,15 @@ internal sealed class AndroidCapturePreview : Grid, IDisposable
{ {
var activity = MainActivity.Current var activity = MainActivity.Current
?? throw new InvalidOperationException("Activite Android indisponible pour l'apercu."); ?? throw new InvalidOperationException("Activite Android indisponible pour l'apercu.");
_view = new SurfaceView(activity); var view = new SurfaceView(activity);
_callback = new SurfaceCallback(this); if (!_disposed)
_view.Holder!.AddCallback(_callback); {
return new AndroidViewControlHandle(_view); _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) 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) protected override void DestroyNativeControlCore(IPlatformHandle control)
{
ReleaseSurface();
base.DestroyNativeControlCore(control);
}
private void ReleaseSurface()
{ {
Detach(); Detach();
if (_callback is not null) 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.Dispose();
_callback = null; _callback = null;
} }
_view = null; _holder?.Dispose();
base.DestroyNativeControlCore(control); _holder = null;
} }
public void Dispose() public void Dispose()
{ {
if (_disposed) return; if (_disposed) return;
_disposed = true; _disposed = true;
Detach(); ReleaseSurface();
} }
private sealed class SurfaceCallback(PreviewHost owner) : Java.Lang.Object, ISurfaceHolderCallback private sealed class SurfaceCallback(PreviewHost owner) : Java.Lang.Object, ISurfaceHolderCallback

View file

@ -9,7 +9,9 @@ using Android.App;
using Android.Content; using Android.Content;
using Android.Media; using Android.Media;
using Android.OS; using Android.OS;
using Avalonia.Controls;
using Avalonia.Threading; using Avalonia.Threading;
using Avalonia.VisualTree;
using PostIt.Android; using PostIt.Android;
using PostIt.Android.Services; using PostIt.Android.Services;
using PostIt.Services; using PostIt.Services;
@ -23,12 +25,14 @@ public sealed class AndroidPodcastCaptureTestActivity : Activity
{ {
private bool _microphone = true; private bool _microphone = true;
private bool _notificationStop; private bool _notificationStop;
private bool _previewTransition;
private int _cameraIndex; private int _cameraIndex;
protected override async void OnCreate(Bundle? state) protected override async void OnCreate(Bundle? state)
{ {
base.OnCreate(state); base.OnCreate(state);
_microphone = Intent?.GetStringExtra("microphone") != "false"; _microphone = Intent?.GetStringExtra("microphone") != "false";
_notificationStop = Intent?.GetStringExtra("notificationStop") == "true"; _notificationStop = Intent?.GetStringExtra("notificationStop") == "true";
_previewTransition = Intent?.GetStringExtra("previewTransition") == "true";
_cameraIndex = Intent?.GetStringExtra("camera") == "front" ? 1 : 0; _cameraIndex = Intent?.GetStringExtra("camera") == "front" ? 1 : 0;
var resultPath = Path.Combine(FilesDir!.AbsolutePath, "podcast-capture-test-result.txt"); var resultPath = Path.Combine(FilesDir!.AbsolutePath, "podcast-capture-test-result.txt");
File.WriteAllText(resultPath, "RUNNING"); File.WriteAllText(resultPath, "RUNNING");
@ -36,7 +40,7 @@ public sealed class AndroidPodcastCaptureTestActivity : Activity
try try
{ {
await RunAsync(result); 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) catch (Exception ex)
{ {
@ -58,12 +62,26 @@ public sealed class AndroidPodcastCaptureTestActivity : Activity
Directory.CreateDirectory(directory); Directory.CreateDirectory(directory);
var devices = AndroidPodcastCapture.GetDevices(); var devices = AndroidPodcastCapture.GetDevices();
if (devices.Cameras.Count == 0) throw new InvalidOperationException("No emulator camera."); 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; IPodcastCapture? capture = null;
try try
{ {
if (_previewTransition)
{
navigation = (Avalonia.Application.Current as App)?.View?.FindControl<NavigationPage>("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( capture = await Dispatcher.UIThread.InvokeAsync(() => AndroidPodcastCapture.StartAsync(
devices.Cameras[Math.Min(_cameraIndex, devices.Cameras.Count - 1)].Value, camera,
_microphone ? "microphone" : null, directory, CancellationToken.None)); _microphone ? "microphone" : null, directory, CancellationToken.None));
if (page is not null)
await Dispatcher.UIThread.InvokeAsync(() => { page.Content = capture.Preview; });
await Task.Delay(3000); await Task.Delay(3000);
var before = SegmentCount(directory); var before = SegmentCount(directory);
context.StartActivity(new Intent(Intent.ActionMain).AddCategory(Intent.CategoryHome).AddFlags(ActivityFlags.NewTask)); context.StartActivity(new Intent(Intent.ActionMain).AddCategory(Intent.CategoryHome).AddFlags(ActivityFlags.NewTask));
@ -112,7 +130,50 @@ public sealed class AndroidPodcastCaptureTestActivity : Activity
} }
finally finally
{ {
if (page is not null)
await Dispatcher.UIThread.InvokeAsync(() => { page.Content = null; });
if (capture is not null) await capture.DisposeAsync(); 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<NativeControlHost>().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; });
} }
} }