Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/BlazorWebView/src/Maui/Android/BlazorAndroidWebView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ namespace Microsoft.AspNetCore.Components.WebView.Maui
/// </summary>
internal class BlazorAndroidWebView : AWebView
{
internal bool BackNavigationHandled { get; set; }

/// <summary>
/// Initializes a new instance of <see cref="BlazorAndroidWebView"/>
/// </summary>
Expand All @@ -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;
}
}
Expand Down
136 changes: 136 additions & 0 deletions src/BlazorWebView/src/Maui/Android/BlazorWebViewHandler.Android.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Threading.Tasks;
using Android.Window;
using Android.Webkit;
using Android.Widget;
using Microsoft.Extensions.DependencyInjection;
Expand All @@ -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;
Expand All @@ -21,10 +23,27 @@ public partial class BlazorWebViewHandler : ViewHandler<IBlazorWebView, AWebView
private WebChromeClient? _webChromeClient;
private AndroidWebKitWebViewManager? _webviewManager;
internal AndroidWebKitWebViewManager? WebviewManager => _webviewManager;
private AndroidLifecycle.OnBackPressed? _onBackPressedHandler;
BlazorWebViewPredictiveBackCallback? _predictiveBackCallback;

private ILogger? _logger;
internal ILogger Logger => _logger ??= Services!.GetService<ILogger<BlazorWebViewHandler>>() ?? NullLogger<BlazorWebViewHandler>.Instance;

/// <summary>
/// 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.
/// </summary>
private LifecycleEventService? TryGetLifecycleEventService()
{
var services = MauiContext?.Services;
if (services != null)
{
return services.GetService<ILifecycleEventService>() as LifecycleEventService;
}
return null;
}

protected override AWebView CreatePlatformView()
{
Logger.CreatingAndroidWebkitWebView();
Expand Down Expand Up @@ -60,10 +79,89 @@ protected override AWebView CreatePlatformView()
return blazorAndroidWebView;
}

/// <summary>
/// Connects the handler to the Android <see cref="AWebView"/> and registers platform-specific
/// back navigation handling so that the WebView can consume back presses before the page is popped.
/// </summary>
/// <param name="platformView">The native Android <see cref="AWebView"/> instance associated with this handler.</param>
/// <remarks>
/// This override calls the base implementation and then registers an <see cref="AndroidLifecycle.OnBackPressed"/>
/// lifecycle event handler. The handler checks <see cref="AWebView.CanGoBack"/> 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.
/// <para>
/// 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.
/// </para>
/// Inheritors that override this method should call the base implementation to preserve this back navigation
/// behavior unless they intentionally replace it.
/// </remarks>
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<AWebView>(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)
Expand Down Expand Up @@ -182,5 +280,43 @@ public virtual async Task<bool> TryDispatchAsync(Action<IServiceProvider> workIt

return await _webviewManager.TryDispatchAsync(workItem);
}

sealed class BlazorWebViewPredictiveBackCallback : Java.Lang.Object, IOnBackInvokedCallback
{
WeakReference<BlazorWebViewHandler> _weakBlazorWebViewHandler;

public BlazorWebViewPredictiveBackCallback(BlazorWebViewHandler handler)
{
_weakBlazorWebViewHandler = new WeakReference<BlazorWebViewHandler>(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();
}
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#nullable enable
override Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewHandler.ConnectHandler(Android.Webkit.WebView! platformView) -> void
Original file line number Diff line number Diff line change
@@ -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
/// <summary>
/// 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
/// </summary>
[Fact]
public async Task BlazorWebViewRegistersOnBackPressedHandler()
{
EnsureHandlerCreated(additionalCreationActions: appBuilder =>
{
appBuilder.Services.AddMauiBlazorWebView();
});

var bwv = new BlazorWebViewWithCustomFiles
{
HostPage = "wwwroot/index.html",
CustomFiles = new Dictionary<string, string>
{
{ "index.html", TestStaticFilesContents.DefaultMauiIndexHtmlContent },
},
};
bwv.RootComponents.Add(new RootComponent { ComponentType = typeof(MauiBlazorWebView.DeviceTests.Components.NoOpComponent), Selector = "#app", });

await InvokeOnMainThreadAsync(async () =>
{
var bwvHandler = CreateHandler<BlazorWebViewHandler>(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<ILifecycleEventService>() 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");
});
}

/// <summary>
/// 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
/// </summary>
[Fact]
public async Task BlazorWebViewCleansUpOnBackPressedHandlerOnDisconnect()
{
EnsureHandlerCreated(additionalCreationActions: appBuilder =>
{
appBuilder.Services.AddMauiBlazorWebView();
});

var bwv = new BlazorWebViewWithCustomFiles
{
HostPage = "wwwroot/index.html",
CustomFiles = new Dictionary<string, string>
{
{ "index.html", TestStaticFilesContents.DefaultMauiIndexHtmlContent },
},
};
bwv.RootComponents.Add(new RootComponent { ComponentType = typeof(MauiBlazorWebView.DeviceTests.Components.NoOpComponent), Selector = "#app", });

await InvokeOnMainThreadAsync(async () =>
{
var bwvHandler = CreateHandler<BlazorWebViewHandler>(bwv);
var platformWebView = bwvHandler.PlatformView;
await WebViewHelpers.WaitForWebViewReady(platformWebView);

var lifecycleService = MauiContext.Services.GetService<ILifecycleEventService>() 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<AndroidLifecycle.OnBackPressed>(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<AndroidLifecycle.OnBackPressed>(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
}
10 changes: 10 additions & 0 deletions src/Core/src/LifecycleEvents/LifecycleEventService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,15 @@ public IEnumerable<TDelegate> GetEventDelegates<TDelegate>(string eventName)

public bool ContainsEvent(string eventName) =>
_mapper.TryGetValue(eventName, out var delegates) && delegates?.Count > 0;

internal void RemoveEvent<TDelegate>(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);
}
}
}
}
Loading