diff --git a/src/BlazorWebView/src/Maui/Android/BlazorAndroidWebView.cs b/src/BlazorWebView/src/Maui/Android/BlazorAndroidWebView.cs index c10c462824cb..fb98ef7ed23a 100644 --- a/src/BlazorWebView/src/Maui/Android/BlazorAndroidWebView.cs +++ b/src/BlazorWebView/src/Maui/Android/BlazorAndroidWebView.cs @@ -9,6 +9,8 @@ namespace Microsoft.AspNetCore.Components.WebView.Maui /// internal class BlazorAndroidWebView : AWebView { + internal bool BackNavigationHandled { get; set; } + /// /// Initializes a new instance of /// @@ -22,8 +24,10 @@ public override bool OnKeyDown(Keycode keyCode, KeyEvent? e) if (keyCode == Keycode.Back && CanGoBack() && e?.RepeatCount == 0) { GoBack(); + BackNavigationHandled = true; return true; } + BackNavigationHandled = false; return false; } } diff --git a/src/BlazorWebView/src/Maui/Android/BlazorWebViewHandler.Android.cs b/src/BlazorWebView/src/Maui/Android/BlazorWebViewHandler.Android.cs index 26788b72781d..daab566211a6 100644 --- a/src/BlazorWebView/src/Maui/Android/BlazorWebViewHandler.Android.cs +++ b/src/BlazorWebView/src/Maui/Android/BlazorWebViewHandler.Android.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using Android.Window; using Android.Webkit; using Android.Widget; using Microsoft.Extensions.DependencyInjection; @@ -9,6 +10,7 @@ using Microsoft.Maui; using Microsoft.Maui.Dispatching; using Microsoft.Maui.Handlers; +using Microsoft.Maui.LifecycleEvents; using static global::Android.Views.ViewGroup; using AWebView = global::Android.Webkit.WebView; using Path = System.IO.Path; @@ -21,10 +23,27 @@ public partial class BlazorWebViewHandler : ViewHandler _webviewManager; + private AndroidLifecycle.OnBackPressed? _onBackPressedHandler; + BlazorWebViewPredictiveBackCallback? _predictiveBackCallback; private ILogger? _logger; internal ILogger Logger => _logger ??= Services!.GetService>() ?? NullLogger.Instance; + /// + /// Gets the concrete LifecycleEventService to access internal RemoveEvent method. + /// RemoveEvent is internal because it's not part of the public ILifecycleEventService contract, + /// but is needed for proper cleanup of lifecycle event handlers. + /// + private LifecycleEventService? TryGetLifecycleEventService() + { + var services = MauiContext?.Services; + if (services != null) + { + return services.GetService() as LifecycleEventService; + } + return null; + } + protected override AWebView CreatePlatformView() { Logger.CreatingAndroidWebkitWebView(); @@ -60,10 +79,89 @@ protected override AWebView CreatePlatformView() return blazorAndroidWebView; } + /// + /// Connects the handler to the Android and registers platform-specific + /// back navigation handling so that the WebView can consume back presses before the page is popped. + /// + /// The native Android instance associated with this handler. + /// + /// This override calls the base implementation and then registers an + /// lifecycle event handler. The handler checks and, when possible, navigates + /// back within the WebView instead of allowing the back press (or predictive back gesture on Android 13+) + /// to propagate and pop the containing page. + /// + /// When multiple BlazorWebView instances exist, the handler includes focus and visibility checks to ensure + /// only the currently visible and focused WebView handles the back navigation, preventing conflicts between instances. + /// + /// Inheritors that override this method should call the base implementation to preserve this back navigation + /// behavior unless they intentionally replace it. + /// + protected override void ConnectHandler(AWebView platformView) + { + base.ConnectHandler(platformView); + + // Register OnBackPressed lifecycle event handler to check WebView's back navigation + // This ensures predictive back gesture (Android 13+) checks WebView.CanGoBack() before popping page + var lifecycleService = TryGetLifecycleEventService(); + if (lifecycleService != null) + { + // Create a weak reference to avoid memory leaks + var weakPlatformView = new WeakReference(platformView); + + AndroidLifecycle.OnBackPressed handler = (activity) => + { + // Check if WebView is still alive, attached to window, and has focus + // This prevents non-visible or unfocused BlazorWebView instances from + // incorrectly intercepting back navigation when multiple instances exist + if (weakPlatformView.TryGetTarget(out var webView) && + webView.IsAttachedToWindow && + webView.HasWindowFocus && + webView.CanGoBack()) + { + webView.GoBack(); + return true; // Prevent back propagation - handled by WebView + } + + return false; // Allow back propagation - let page be popped + }; + + // Register with lifecycle service - will be invoked by HandleBackNavigation in MauiAppCompatActivity + lifecycleService.AddEvent(nameof(AndroidLifecycle.OnBackPressed), handler); + _onBackPressedHandler = handler; + } + + if (OperatingSystem.IsAndroidVersionAtLeast(33) && _predictiveBackCallback is null) + { + if (Microsoft.Maui.ApplicationModel.Platform.CurrentActivity is not null) + { + _predictiveBackCallback = new BlazorWebViewPredictiveBackCallback(this); + Microsoft.Maui.ApplicationModel.Platform.CurrentActivity?.OnBackInvokedDispatcher?.RegisterOnBackInvokedCallback(0, _predictiveBackCallback); + } + } + } + private const string AndroidFireAndForgetAsyncSwitch = "BlazorWebView.AndroidFireAndForgetAsync"; protected override void DisconnectHandler(AWebView platformView) { + if (OperatingSystem.IsAndroidVersionAtLeast(33) && _predictiveBackCallback is not null) + { + Microsoft.Maui.ApplicationModel.Platform.CurrentActivity?.OnBackInvokedDispatcher?.UnregisterOnBackInvokedCallback(_predictiveBackCallback); + _predictiveBackCallback.Dispose(); + _predictiveBackCallback = null; + } + + // Clean up lifecycle event handler to prevent memory leaks + if (_onBackPressedHandler != null) + { + var lifecycleService = TryGetLifecycleEventService(); + if (lifecycleService != null) + { + lifecycleService.RemoveEvent(nameof(AndroidLifecycle.OnBackPressed), _onBackPressedHandler); + _onBackPressedHandler = null; + } + } + platformView.StopLoading(); if (_webviewManager != null) @@ -182,5 +280,43 @@ public virtual async Task TryDispatchAsync(Action workIt return await _webviewManager.TryDispatchAsync(workItem); } + + sealed class BlazorWebViewPredictiveBackCallback : Java.Lang.Object, IOnBackInvokedCallback + { + WeakReference _weakBlazorWebViewHandler; + + public BlazorWebViewPredictiveBackCallback(BlazorWebViewHandler handler) + { + _weakBlazorWebViewHandler = new WeakReference(handler); + } + + public void OnBackInvoked() + { + // KeyDown for Back button is handled in BlazorAndroidWebView. + // Here we just need to check if it was handled there. + // If not, we propagate the back press to the Activity's OnBackPressedDispatcher. + if (_weakBlazorWebViewHandler is not null && _weakBlazorWebViewHandler.TryGetTarget(out var handler)) + { + var webView = handler.PlatformView as BlazorAndroidWebView; + if (webView is not null) + { + var wasBackNavigationHandled = webView.BackNavigationHandled; + // reset immediately for next back event + webView.BackNavigationHandled = false; + + if (!wasBackNavigationHandled) + { + if (webView.CanGoBack()) // If we can go back in WeView, Navigate back + { + webView.GoBack(); + return; + } + // Otherwise propagate back press to Activity + (Microsoft.Maui.ApplicationModel.Platform.CurrentActivity as AndroidX.AppCompat.App.AppCompatActivity)?.OnBackPressedDispatcher?.OnBackPressed(); + } + } + } + } + } } } diff --git a/src/BlazorWebView/src/Maui/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/BlazorWebView/src/Maui/PublicAPI/net-android/PublicAPI.Unshipped.txt index 7dc5c58110bf..51e039fc88da 100644 --- a/src/BlazorWebView/src/Maui/PublicAPI/net-android/PublicAPI.Unshipped.txt +++ b/src/BlazorWebView/src/Maui/PublicAPI/net-android/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +override Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewHandler.ConnectHandler(Android.Webkit.WebView! platformView) -> void diff --git a/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.BackNavigation.cs b/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.BackNavigation.cs new file mode 100644 index 000000000000..227fa6d74b8b --- /dev/null +++ b/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.BackNavigation.cs @@ -0,0 +1,111 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.WebView.Maui; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Maui.LifecycleEvents; +using Xunit; + +namespace Microsoft.Maui.MauiBlazorWebView.DeviceTests.Elements; + +public partial class BlazorWebViewTests +{ +#if ANDROID + /// + /// Verifies that BlazorWebViewHandler registers an OnBackPressed lifecycle event handler + /// when connected on Android. This handler is essential for proper back navigation within + /// the BlazorWebView on Android 13+ with predictive back gestures. + /// See: https://github.com/dotnet/maui/issues/32767 + /// + [Fact] + public async Task BlazorWebViewRegistersOnBackPressedHandler() + { + EnsureHandlerCreated(additionalCreationActions: appBuilder => + { + appBuilder.Services.AddMauiBlazorWebView(); + }); + + var bwv = new BlazorWebViewWithCustomFiles + { + HostPage = "wwwroot/index.html", + CustomFiles = new Dictionary + { + { "index.html", TestStaticFilesContents.DefaultMauiIndexHtmlContent }, + }, + }; + bwv.RootComponents.Add(new RootComponent { ComponentType = typeof(MauiBlazorWebView.DeviceTests.Components.NoOpComponent), Selector = "#app", }); + + await InvokeOnMainThreadAsync(async () => + { + var bwvHandler = CreateHandler(bwv); + var platformWebView = bwvHandler.PlatformView; + await WebViewHelpers.WaitForWebViewReady(platformWebView); + + // Get the lifecycle event service and verify OnBackPressed handler is registered + var lifecycleService = MauiContext.Services.GetService() as LifecycleEventService; + Assert.NotNull(lifecycleService); + + // Verify the OnBackPressed event has been registered + Assert.True(lifecycleService.ContainsEvent(nameof(AndroidLifecycle.OnBackPressed)), + "BlazorWebViewHandler should register an OnBackPressed lifecycle event handler on Android"); + }); + } + + /// + /// Verifies that BlazorWebViewHandler properly cleans up the OnBackPressed lifecycle event handler + /// when disconnected. This prevents memory leaks and ensures proper cleanup. + /// See: https://github.com/dotnet/maui/issues/32767 + /// + [Fact] + public async Task BlazorWebViewCleansUpOnBackPressedHandlerOnDisconnect() + { + EnsureHandlerCreated(additionalCreationActions: appBuilder => + { + appBuilder.Services.AddMauiBlazorWebView(); + }); + + var bwv = new BlazorWebViewWithCustomFiles + { + HostPage = "wwwroot/index.html", + CustomFiles = new Dictionary + { + { "index.html", TestStaticFilesContents.DefaultMauiIndexHtmlContent }, + }, + }; + bwv.RootComponents.Add(new RootComponent { ComponentType = typeof(MauiBlazorWebView.DeviceTests.Components.NoOpComponent), Selector = "#app", }); + + await InvokeOnMainThreadAsync(async () => + { + var bwvHandler = CreateHandler(bwv); + var platformWebView = bwvHandler.PlatformView; + await WebViewHelpers.WaitForWebViewReady(platformWebView); + + var lifecycleService = MauiContext.Services.GetService() as LifecycleEventService; + Assert.NotNull(lifecycleService); + + // Verify handler is registered after connect + Assert.True(lifecycleService.ContainsEvent(nameof(AndroidLifecycle.OnBackPressed)), + "OnBackPressed handler should be registered after ConnectHandler"); + + // Count the handlers before disconnect + var handlersBefore = lifecycleService.GetEventDelegates(nameof(AndroidLifecycle.OnBackPressed)); + int countBefore = 0; + foreach (var _ in handlersBefore) + countBefore++; + + // Disconnect the handler by setting the BlazorWebView's Handler to null + // This triggers DisconnectHandler internally + bwv.Handler = null; + + // Count the handlers after disconnect + var handlersAfter = lifecycleService.GetEventDelegates(nameof(AndroidLifecycle.OnBackPressed)); + int countAfter = 0; + foreach (var _ in handlersAfter) + countAfter++; + + // Verify the handler count decreased (cleanup happened) + Assert.True(countAfter < countBefore, + $"OnBackPressed handler should be removed after DisconnectHandler. Before: {countBefore}, After: {countAfter}"); + }); + } +#endif +} diff --git a/src/Core/src/LifecycleEvents/LifecycleEventService.cs b/src/Core/src/LifecycleEvents/LifecycleEventService.cs index e861166f2798..4a35b494987a 100644 --- a/src/Core/src/LifecycleEvents/LifecycleEventService.cs +++ b/src/Core/src/LifecycleEvents/LifecycleEventService.cs @@ -39,5 +39,15 @@ public IEnumerable GetEventDelegates(string eventName) public bool ContainsEvent(string eventName) => _mapper.TryGetValue(eventName, out var delegates) && delegates?.Count > 0; + + internal void RemoveEvent(string eventName, TDelegate action) + where TDelegate : Delegate + { + if (_mapper.TryGetValue(eventName, out var delegates) && delegates != null) + { + if (delegates.Remove(action) && delegates.Count == 0) + _mapper.Remove(eventName); + } + } } } \ No newline at end of file