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:
parent
fa8038c676
commit
b98f82663d
5 changed files with 99 additions and 11 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 :
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ internal sealed class AndroidCapturePreview : Grid, IDisposable
|
|||
private sealed class PreviewHost(AndroidCameraFrameRotator rotator, Action<Exception> 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
|
||||
|
|
|
|||
|
|
@ -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<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(
|
||||
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<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; });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue