From 0bf2de8c119a50b601bf9e98e9b98300a692c487 Mon Sep 17 00:00:00 2001 From: praveenkumarkarunanithi Date: Thu, 29 Jan 2026 10:40:44 +0530 Subject: [PATCH 1/9] Update Window.cs --- src/Controls/src/Core/Window/Window.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Controls/src/Core/Window/Window.cs b/src/Controls/src/Core/Window/Window.cs index 9c0015232793..938a77efd27c 100644 --- a/src/Controls/src/Core/Window/Window.cs +++ b/src/Controls/src/Core/Window/Window.cs @@ -549,8 +549,11 @@ void IWindow.Destroying() var mauiContext = Handler?.MauiContext as MauiContext; Handler?.DisconnectHandler(); - // Dispose the window-scoped service scope +#if !ANDROID + // Desktop platforms dispose scope as windows are independently created and destroyed. + // Android skips disposal as windows are reused in single-Activity model. mauiContext?.DisposeWindowScope(); +#endif } void IWindow.Resumed() From dd09e044ef551a6e6189a9175d0348f344681bc4 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Mon, 2 Feb 2026 11:27:02 -0600 Subject: [PATCH 2/9] - attempt a device tests --- .../Elements/Window/WindowTests.Issue33187.cs | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Issue33187.cs diff --git a/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Issue33187.cs b/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Issue33187.cs new file mode 100644 index 000000000000..e3a8e3fc7d1a --- /dev/null +++ b/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Issue33187.cs @@ -0,0 +1,205 @@ +#nullable enable +using System; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Handlers; +using Microsoft.Maui.DeviceTests.Stubs; +using Microsoft.Maui.Handlers; +using Microsoft.Maui.Hosting; +using Xunit; + +#if ANDROID || IOS || MACCATALYST +using ShellHandler = Microsoft.Maui.Controls.Handlers.Compatibility.ShellRenderer; +#endif + +namespace Microsoft.Maui.DeviceTests +{ + /// + /// Device tests for https://github.com/dotnet/maui/issues/33187 + /// + /// Bug: On Android, when the app goes to background (e.g., notification tap scenario), + /// Window.Destroying() is called which used to dispose the service provider. + /// When the user returns to the app and navigates, ContentPage.UpdateHideSoftInputOnTapped + /// would throw ObjectDisposedException trying to resolve services. + /// + /// Fix: The fix adds #if !ANDROID to skip DisposeWindowScope() on Android since windows + /// are reused in the single-Activity model. + /// + [Category(TestCategory.Window)] +#if ANDROID || IOS || MACCATALYST + [Collection(ControlsHandlerTestBase.RunInNewWindowCollection)] +#endif + public class WindowTestsIssue33187 : ControlsHandlerTestBase + { + void SetupBuilder() + { + EnsureHandlerCreated(builder => + { + builder.ConfigureMauiHandlers(handlers => + { + SetupShellHandlers(handlers); + +#if ANDROID || WINDOWS + handlers.AddHandler(typeof(NavigationPage), typeof(NavigationViewHandler)); +#else + handlers.AddHandler(typeof(NavigationPage), typeof(Microsoft.Maui.Controls.Handlers.Compatibility.NavigationRenderer)); +#endif + + handlers.AddHandler(); + handlers.AddHandler(); + handlers.AddHandler(); + handlers.AddHandler(); + }); + }); + } + + /// + /// Key test for Issue #33187: + /// + /// On Android, calling Window.Destroying() should NOT dispose the window scope. + /// On other platforms, it SHOULD dispose the scope (since windows are independently created/destroyed). + /// + /// This test verifies that calling DisposeWindowScope has the expected behavior on each platform + /// by directly testing MauiContext.DisposeWindowScope. + /// + [Fact] + public async Task DisposeWindowScopeWorksCorrectly() + { + SetupBuilder(); + + var page = new ContentPage { Title = "Test Page" }; + var window = new Window(page); + + await CreateHandlerAndAddToWindow(window, async (handler) => + { + await OnLoadedAsync(page); + + // Get MauiContext + var mauiContext = handler.MauiContext as MauiContext; + Assert.NotNull(mauiContext); + + // Get the internal _windowScope field + var windowScopeField = typeof(MauiContext).GetField("_windowScope", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(windowScopeField); + + // The test stub doesn't set up window scope, so first set one up for testing + var scope = mauiContext.Services.CreateScope(); + var setWindowScope = typeof(MauiContext).GetMethod("SetWindowScope", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(setWindowScope); + setWindowScope.Invoke(mauiContext, new object[] { scope }); + + // Verify window scope is now set + var scopeBeforeDispose = windowScopeField.GetValue(mauiContext); + Assert.NotNull(scopeBeforeDispose); + + // Call DisposeWindowScope - this is what Window.Destroying() does (or should skip on Android) + var disposeWindowScope = typeof(MauiContext).GetMethod("DisposeWindowScope", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(disposeWindowScope); + disposeWindowScope.Invoke(mauiContext, null); + + // Check if the window scope is null after disposal + var scopeAfterDispose = windowScopeField.GetValue(mauiContext); + + // DisposeWindowScope should always clear the _windowScope field (on all platforms) + // The key is that on Android, Window.Destroying() doesn't CALL DisposeWindowScope + Assert.Null(scopeAfterDispose); + }); + } + + /// + /// Test that Destroying() has the expected behavior per platform. + /// On Android with the fix, Destroying() should NOT call DisposeWindowScope. + /// On other platforms, Destroying() SHOULD call DisposeWindowScope. + /// + [Fact] +#if ANDROID + public async Task AndroidWindowDestroyingDoesNotDisposeWindowScope() +#else + public async Task NonAndroidWindowDestroyingDisposesWindowScope() +#endif + { + SetupBuilder(); + + var page = new ContentPage { Title = "Test Page" }; + var window = new Window(page); + + await CreateHandlerAndAddToWindow(window, async (handler) => + { + await OnLoadedAsync(page); + + // Get MauiContext + var mauiContext = handler.MauiContext as MauiContext; + Assert.NotNull(mauiContext); + + // Get the internal _windowScope field + var windowScopeField = typeof(MauiContext).GetField("_windowScope", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(windowScopeField); + + // The test stub doesn't set up window scope, so first set one up for testing + var scope = mauiContext.Services.CreateScope(); + var setWindowScope = typeof(MauiContext).GetMethod("SetWindowScope", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(setWindowScope); + setWindowScope.Invoke(mauiContext, new object[] { scope }); + + // Verify window scope is now set + var scopeBeforeDestroying = windowScopeField.GetValue(mauiContext); + Console.WriteLine($"TEST: scopeBeforeDestroying is {(scopeBeforeDestroying == null ? "null" : "not null")}"); + Assert.NotNull(scopeBeforeDestroying); + + // Call Destroying() - this is what happens when Android activity goes through lifecycle + var iWindow = (IWindow)window; + Console.WriteLine("TEST: About to call Destroying()"); + iWindow.Destroying(); + Console.WriteLine("TEST: Called Destroying()"); + + // Check if the window scope is null after Destroying + var scopeAfterDestroying = windowScopeField.GetValue(mauiContext); + Console.WriteLine($"TEST: scopeAfterDestroying is {(scopeAfterDestroying == null ? "null" : "not null")}"); + +#if ANDROID + // On Android with the fix, the scope should NOT be disposed (still not null) + // Note: The fix skips calling DisposeWindowScope() entirely on Android + Assert.NotNull(scopeAfterDestroying); +#else + // On other platforms, the scope IS disposed and set to null + Assert.Null(scopeAfterDestroying); +#endif + }); + } + + /// + /// Test that basic navigation works without crashes. + /// This validates the core navigation scenario from issue #33187. + /// + [Fact] + public async Task NavigationPagePushPopDoesNotCrash() + { + SetupBuilder(); + + var firstPage = new ContentPage { Title = "First" }; + var navigationPage = new NavigationPage(firstPage); + var window = new Window(navigationPage); + + await CreateHandlerAndAddToWindow(window, async (handler) => + { + // Wait for page to be fully loaded + await OnLoadedAsync(firstPage); + + // Push a new page - this triggers ContentPage.NavigatedTo which calls UpdateHideSoftInputOnTapped + var secondPage = new ContentPage { Title = "Second" }; + await navigationPage.PushAsync(secondPage); + await OnLoadedAsync(secondPage); + + // Pop back - this also exercises navigation lifecycle + await navigationPage.PopAsync(); + await OnLoadedAsync(navigationPage.CurrentPage); + + // If we got here, no ObjectDisposedException was thrown + Assert.NotNull(navigationPage.CurrentPage); + Assert.Equal("First", navigationPage.CurrentPage.Title); + }); + } + } +} From bf7652aec4e152bb4f7ae36e255a34e64ad1776e Mon Sep 17 00:00:00 2001 From: praveenkumarkarunanithi Date: Tue, 3 Feb 2026 16:15:55 +0530 Subject: [PATCH 3/9] updated fix and device test --- .../src/Core/Application/Application.cs | 6 ++ src/Controls/src/Core/Window/Window.cs | 10 +-- .../Elements/Window/WindowTests.Android.cs | 71 +++++++++++++++++++ 3 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Android.cs diff --git a/src/Controls/src/Core/Application/Application.cs b/src/Controls/src/Core/Application/Application.cs index b5aa0570f5c8..1e7dcd5508dc 100644 --- a/src/Controls/src/Core/Application/Application.cs +++ b/src/Controls/src/Core/Application/Application.cs @@ -468,6 +468,12 @@ IWindow IApplication.CreateWindow(IActivationState? activationState) } } + // On Android, reuse existing window when Activity is recreated due to lifecycle changes +#if ANDROID + if (window == null && _windows.Count > 0) + window = _windows[0]; +#endif + // create a new one if there is no pending windows if (window == null) { diff --git a/src/Controls/src/Core/Window/Window.cs b/src/Controls/src/Core/Window/Window.cs index 938a77efd27c..af8ceb47d948 100644 --- a/src/Controls/src/Core/Window/Window.cs +++ b/src/Controls/src/Core/Window/Window.cs @@ -544,14 +544,16 @@ void IWindow.Destroying() OnDestroying(); AlertManager.Unsubscribe(); - Application?.RemoveWindow(this); - + // On Android, preserve window in collection to enable reuse when Activity is recreated +#if !ANDROID + Application?.RemoveWindow(this); +#endif + var mauiContext = Handler?.MauiContext as MauiContext; Handler?.DisconnectHandler(); + // On Android, preserve window scope to enable reuse when Activity is recreated #if !ANDROID - // Desktop platforms dispose scope as windows are independently created and destroyed. - // Android skips disposal as windows are reused in single-Activity model. mauiContext?.DisposeWindowScope(); #endif } diff --git a/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Android.cs b/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Android.cs new file mode 100644 index 000000000000..a8d4b134634e --- /dev/null +++ b/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Android.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Maui.Controls; +using Microsoft.Maui.DeviceTests.Stubs; +using Xunit; + +namespace Microsoft.Maui.DeviceTests +{ + public partial class WindowTests + { + [Fact] + public async Task WindowDestroyingPreservesWindowScopeOnAndroid() + { + // https://github.com/dotnet/maui/issues/33597 + SetupBuilder(); + + var window = new Window(new ContentPage()); + + await CreateHandlerAndAddToWindow(window, async handler => + { + await OnLoadedAsync(window.Page); + + var mauiContext = handler.MauiContext as MauiContext; + Assert.NotNull(mauiContext); + + var windowScopeField = typeof(MauiContext).GetField("_windowScope", BindingFlags.NonPublic | BindingFlags.Instance); + var setWindowScope = typeof(MauiContext).GetMethod("SetWindowScope", BindingFlags.NonPublic | BindingFlags.Instance); + + var newScope = mauiContext.Services.CreateScope(); + setWindowScope.Invoke(mauiContext, new[] { newScope }); + Assert.NotNull(windowScopeField.GetValue(mauiContext)); + + ((IWindow)window).Destroying(); + + Assert.NotNull(windowScopeField.GetValue(mauiContext)); + }); + } + + [Fact] + public async Task WindowDestroyingPreservesWindowCollectionOnAndroid() + { + // https://github.com/dotnet/maui/issues/33597 + SetupBuilder(); + + var app = Application.Current; + var window = new Window(new ContentPage()); + + await CreateHandlerAndAddToWindow(window, async handler => + { + await OnLoadedAsync(window.Page); + + window.Parent = app; + + var windowsField = typeof(Application).GetField("_windows", BindingFlags.NonPublic | BindingFlags.Instance); + var windowsList = windowsField.GetValue(app) as IList; + + if (!windowsList.Contains(window)) + windowsList.Add(window); + + var countBefore = windowsList.Count; + + ((IWindow)window).Destroying(); + + Assert.Equal(countBefore, windowsList.Count); + Assert.Contains(window, windowsList); + }); + } + } +} From 1dec6a8d1e304c963390abc30a9d2a6e7e622b4a Mon Sep 17 00:00:00 2001 From: praveenkumarkarunanithi Date: Wed, 4 Feb 2026 16:50:21 +0530 Subject: [PATCH 4/9] Delete WindowTests.Issue33187.cs --- .../Elements/Window/WindowTests.Issue33187.cs | 205 ------------------ 1 file changed, 205 deletions(-) delete mode 100644 src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Issue33187.cs diff --git a/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Issue33187.cs b/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Issue33187.cs deleted file mode 100644 index e3a8e3fc7d1a..000000000000 --- a/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Issue33187.cs +++ /dev/null @@ -1,205 +0,0 @@ -#nullable enable -using System; -using System.Reflection; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Maui.Controls; -using Microsoft.Maui.Controls.Handlers; -using Microsoft.Maui.DeviceTests.Stubs; -using Microsoft.Maui.Handlers; -using Microsoft.Maui.Hosting; -using Xunit; - -#if ANDROID || IOS || MACCATALYST -using ShellHandler = Microsoft.Maui.Controls.Handlers.Compatibility.ShellRenderer; -#endif - -namespace Microsoft.Maui.DeviceTests -{ - /// - /// Device tests for https://github.com/dotnet/maui/issues/33187 - /// - /// Bug: On Android, when the app goes to background (e.g., notification tap scenario), - /// Window.Destroying() is called which used to dispose the service provider. - /// When the user returns to the app and navigates, ContentPage.UpdateHideSoftInputOnTapped - /// would throw ObjectDisposedException trying to resolve services. - /// - /// Fix: The fix adds #if !ANDROID to skip DisposeWindowScope() on Android since windows - /// are reused in the single-Activity model. - /// - [Category(TestCategory.Window)] -#if ANDROID || IOS || MACCATALYST - [Collection(ControlsHandlerTestBase.RunInNewWindowCollection)] -#endif - public class WindowTestsIssue33187 : ControlsHandlerTestBase - { - void SetupBuilder() - { - EnsureHandlerCreated(builder => - { - builder.ConfigureMauiHandlers(handlers => - { - SetupShellHandlers(handlers); - -#if ANDROID || WINDOWS - handlers.AddHandler(typeof(NavigationPage), typeof(NavigationViewHandler)); -#else - handlers.AddHandler(typeof(NavigationPage), typeof(Microsoft.Maui.Controls.Handlers.Compatibility.NavigationRenderer)); -#endif - - handlers.AddHandler(); - handlers.AddHandler(); - handlers.AddHandler(); - handlers.AddHandler(); - }); - }); - } - - /// - /// Key test for Issue #33187: - /// - /// On Android, calling Window.Destroying() should NOT dispose the window scope. - /// On other platforms, it SHOULD dispose the scope (since windows are independently created/destroyed). - /// - /// This test verifies that calling DisposeWindowScope has the expected behavior on each platform - /// by directly testing MauiContext.DisposeWindowScope. - /// - [Fact] - public async Task DisposeWindowScopeWorksCorrectly() - { - SetupBuilder(); - - var page = new ContentPage { Title = "Test Page" }; - var window = new Window(page); - - await CreateHandlerAndAddToWindow(window, async (handler) => - { - await OnLoadedAsync(page); - - // Get MauiContext - var mauiContext = handler.MauiContext as MauiContext; - Assert.NotNull(mauiContext); - - // Get the internal _windowScope field - var windowScopeField = typeof(MauiContext).GetField("_windowScope", BindingFlags.NonPublic | BindingFlags.Instance); - Assert.NotNull(windowScopeField); - - // The test stub doesn't set up window scope, so first set one up for testing - var scope = mauiContext.Services.CreateScope(); - var setWindowScope = typeof(MauiContext).GetMethod("SetWindowScope", BindingFlags.NonPublic | BindingFlags.Instance); - Assert.NotNull(setWindowScope); - setWindowScope.Invoke(mauiContext, new object[] { scope }); - - // Verify window scope is now set - var scopeBeforeDispose = windowScopeField.GetValue(mauiContext); - Assert.NotNull(scopeBeforeDispose); - - // Call DisposeWindowScope - this is what Window.Destroying() does (or should skip on Android) - var disposeWindowScope = typeof(MauiContext).GetMethod("DisposeWindowScope", BindingFlags.NonPublic | BindingFlags.Instance); - Assert.NotNull(disposeWindowScope); - disposeWindowScope.Invoke(mauiContext, null); - - // Check if the window scope is null after disposal - var scopeAfterDispose = windowScopeField.GetValue(mauiContext); - - // DisposeWindowScope should always clear the _windowScope field (on all platforms) - // The key is that on Android, Window.Destroying() doesn't CALL DisposeWindowScope - Assert.Null(scopeAfterDispose); - }); - } - - /// - /// Test that Destroying() has the expected behavior per platform. - /// On Android with the fix, Destroying() should NOT call DisposeWindowScope. - /// On other platforms, Destroying() SHOULD call DisposeWindowScope. - /// - [Fact] -#if ANDROID - public async Task AndroidWindowDestroyingDoesNotDisposeWindowScope() -#else - public async Task NonAndroidWindowDestroyingDisposesWindowScope() -#endif - { - SetupBuilder(); - - var page = new ContentPage { Title = "Test Page" }; - var window = new Window(page); - - await CreateHandlerAndAddToWindow(window, async (handler) => - { - await OnLoadedAsync(page); - - // Get MauiContext - var mauiContext = handler.MauiContext as MauiContext; - Assert.NotNull(mauiContext); - - // Get the internal _windowScope field - var windowScopeField = typeof(MauiContext).GetField("_windowScope", BindingFlags.NonPublic | BindingFlags.Instance); - Assert.NotNull(windowScopeField); - - // The test stub doesn't set up window scope, so first set one up for testing - var scope = mauiContext.Services.CreateScope(); - var setWindowScope = typeof(MauiContext).GetMethod("SetWindowScope", BindingFlags.NonPublic | BindingFlags.Instance); - Assert.NotNull(setWindowScope); - setWindowScope.Invoke(mauiContext, new object[] { scope }); - - // Verify window scope is now set - var scopeBeforeDestroying = windowScopeField.GetValue(mauiContext); - Console.WriteLine($"TEST: scopeBeforeDestroying is {(scopeBeforeDestroying == null ? "null" : "not null")}"); - Assert.NotNull(scopeBeforeDestroying); - - // Call Destroying() - this is what happens when Android activity goes through lifecycle - var iWindow = (IWindow)window; - Console.WriteLine("TEST: About to call Destroying()"); - iWindow.Destroying(); - Console.WriteLine("TEST: Called Destroying()"); - - // Check if the window scope is null after Destroying - var scopeAfterDestroying = windowScopeField.GetValue(mauiContext); - Console.WriteLine($"TEST: scopeAfterDestroying is {(scopeAfterDestroying == null ? "null" : "not null")}"); - -#if ANDROID - // On Android with the fix, the scope should NOT be disposed (still not null) - // Note: The fix skips calling DisposeWindowScope() entirely on Android - Assert.NotNull(scopeAfterDestroying); -#else - // On other platforms, the scope IS disposed and set to null - Assert.Null(scopeAfterDestroying); -#endif - }); - } - - /// - /// Test that basic navigation works without crashes. - /// This validates the core navigation scenario from issue #33187. - /// - [Fact] - public async Task NavigationPagePushPopDoesNotCrash() - { - SetupBuilder(); - - var firstPage = new ContentPage { Title = "First" }; - var navigationPage = new NavigationPage(firstPage); - var window = new Window(navigationPage); - - await CreateHandlerAndAddToWindow(window, async (handler) => - { - // Wait for page to be fully loaded - await OnLoadedAsync(firstPage); - - // Push a new page - this triggers ContentPage.NavigatedTo which calls UpdateHideSoftInputOnTapped - var secondPage = new ContentPage { Title = "Second" }; - await navigationPage.PushAsync(secondPage); - await OnLoadedAsync(secondPage); - - // Pop back - this also exercises navigation lifecycle - await navigationPage.PopAsync(); - await OnLoadedAsync(navigationPage.CurrentPage); - - // If we got here, no ObjectDisposedException was thrown - Assert.NotNull(navigationPage.CurrentPage); - Assert.Equal("First", navigationPage.CurrentPage.Title); - }); - } - } -} From e9c8d4153e126b3014d8a47ec509f3f118426aea Mon Sep 17 00:00:00 2001 From: praveenkumarkarunanithi Date: Thu, 29 Jan 2026 10:40:44 +0530 Subject: [PATCH 5/9] Update Window.cs --- src/Controls/src/Core/Window/Window.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Controls/src/Core/Window/Window.cs b/src/Controls/src/Core/Window/Window.cs index 9c0015232793..938a77efd27c 100644 --- a/src/Controls/src/Core/Window/Window.cs +++ b/src/Controls/src/Core/Window/Window.cs @@ -549,8 +549,11 @@ void IWindow.Destroying() var mauiContext = Handler?.MauiContext as MauiContext; Handler?.DisconnectHandler(); - // Dispose the window-scoped service scope +#if !ANDROID + // Desktop platforms dispose scope as windows are independently created and destroyed. + // Android skips disposal as windows are reused in single-Activity model. mauiContext?.DisposeWindowScope(); +#endif } void IWindow.Resumed() From 724fdfe8a34087d6f99e300f9d0f4670f377dd50 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Mon, 2 Feb 2026 11:27:02 -0600 Subject: [PATCH 6/9] - attempt a device tests --- .../Elements/Window/WindowTests.Issue33187.cs | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Issue33187.cs diff --git a/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Issue33187.cs b/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Issue33187.cs new file mode 100644 index 000000000000..e3a8e3fc7d1a --- /dev/null +++ b/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Issue33187.cs @@ -0,0 +1,205 @@ +#nullable enable +using System; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Handlers; +using Microsoft.Maui.DeviceTests.Stubs; +using Microsoft.Maui.Handlers; +using Microsoft.Maui.Hosting; +using Xunit; + +#if ANDROID || IOS || MACCATALYST +using ShellHandler = Microsoft.Maui.Controls.Handlers.Compatibility.ShellRenderer; +#endif + +namespace Microsoft.Maui.DeviceTests +{ + /// + /// Device tests for https://github.com/dotnet/maui/issues/33187 + /// + /// Bug: On Android, when the app goes to background (e.g., notification tap scenario), + /// Window.Destroying() is called which used to dispose the service provider. + /// When the user returns to the app and navigates, ContentPage.UpdateHideSoftInputOnTapped + /// would throw ObjectDisposedException trying to resolve services. + /// + /// Fix: The fix adds #if !ANDROID to skip DisposeWindowScope() on Android since windows + /// are reused in the single-Activity model. + /// + [Category(TestCategory.Window)] +#if ANDROID || IOS || MACCATALYST + [Collection(ControlsHandlerTestBase.RunInNewWindowCollection)] +#endif + public class WindowTestsIssue33187 : ControlsHandlerTestBase + { + void SetupBuilder() + { + EnsureHandlerCreated(builder => + { + builder.ConfigureMauiHandlers(handlers => + { + SetupShellHandlers(handlers); + +#if ANDROID || WINDOWS + handlers.AddHandler(typeof(NavigationPage), typeof(NavigationViewHandler)); +#else + handlers.AddHandler(typeof(NavigationPage), typeof(Microsoft.Maui.Controls.Handlers.Compatibility.NavigationRenderer)); +#endif + + handlers.AddHandler(); + handlers.AddHandler(); + handlers.AddHandler(); + handlers.AddHandler(); + }); + }); + } + + /// + /// Key test for Issue #33187: + /// + /// On Android, calling Window.Destroying() should NOT dispose the window scope. + /// On other platforms, it SHOULD dispose the scope (since windows are independently created/destroyed). + /// + /// This test verifies that calling DisposeWindowScope has the expected behavior on each platform + /// by directly testing MauiContext.DisposeWindowScope. + /// + [Fact] + public async Task DisposeWindowScopeWorksCorrectly() + { + SetupBuilder(); + + var page = new ContentPage { Title = "Test Page" }; + var window = new Window(page); + + await CreateHandlerAndAddToWindow(window, async (handler) => + { + await OnLoadedAsync(page); + + // Get MauiContext + var mauiContext = handler.MauiContext as MauiContext; + Assert.NotNull(mauiContext); + + // Get the internal _windowScope field + var windowScopeField = typeof(MauiContext).GetField("_windowScope", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(windowScopeField); + + // The test stub doesn't set up window scope, so first set one up for testing + var scope = mauiContext.Services.CreateScope(); + var setWindowScope = typeof(MauiContext).GetMethod("SetWindowScope", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(setWindowScope); + setWindowScope.Invoke(mauiContext, new object[] { scope }); + + // Verify window scope is now set + var scopeBeforeDispose = windowScopeField.GetValue(mauiContext); + Assert.NotNull(scopeBeforeDispose); + + // Call DisposeWindowScope - this is what Window.Destroying() does (or should skip on Android) + var disposeWindowScope = typeof(MauiContext).GetMethod("DisposeWindowScope", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(disposeWindowScope); + disposeWindowScope.Invoke(mauiContext, null); + + // Check if the window scope is null after disposal + var scopeAfterDispose = windowScopeField.GetValue(mauiContext); + + // DisposeWindowScope should always clear the _windowScope field (on all platforms) + // The key is that on Android, Window.Destroying() doesn't CALL DisposeWindowScope + Assert.Null(scopeAfterDispose); + }); + } + + /// + /// Test that Destroying() has the expected behavior per platform. + /// On Android with the fix, Destroying() should NOT call DisposeWindowScope. + /// On other platforms, Destroying() SHOULD call DisposeWindowScope. + /// + [Fact] +#if ANDROID + public async Task AndroidWindowDestroyingDoesNotDisposeWindowScope() +#else + public async Task NonAndroidWindowDestroyingDisposesWindowScope() +#endif + { + SetupBuilder(); + + var page = new ContentPage { Title = "Test Page" }; + var window = new Window(page); + + await CreateHandlerAndAddToWindow(window, async (handler) => + { + await OnLoadedAsync(page); + + // Get MauiContext + var mauiContext = handler.MauiContext as MauiContext; + Assert.NotNull(mauiContext); + + // Get the internal _windowScope field + var windowScopeField = typeof(MauiContext).GetField("_windowScope", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(windowScopeField); + + // The test stub doesn't set up window scope, so first set one up for testing + var scope = mauiContext.Services.CreateScope(); + var setWindowScope = typeof(MauiContext).GetMethod("SetWindowScope", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(setWindowScope); + setWindowScope.Invoke(mauiContext, new object[] { scope }); + + // Verify window scope is now set + var scopeBeforeDestroying = windowScopeField.GetValue(mauiContext); + Console.WriteLine($"TEST: scopeBeforeDestroying is {(scopeBeforeDestroying == null ? "null" : "not null")}"); + Assert.NotNull(scopeBeforeDestroying); + + // Call Destroying() - this is what happens when Android activity goes through lifecycle + var iWindow = (IWindow)window; + Console.WriteLine("TEST: About to call Destroying()"); + iWindow.Destroying(); + Console.WriteLine("TEST: Called Destroying()"); + + // Check if the window scope is null after Destroying + var scopeAfterDestroying = windowScopeField.GetValue(mauiContext); + Console.WriteLine($"TEST: scopeAfterDestroying is {(scopeAfterDestroying == null ? "null" : "not null")}"); + +#if ANDROID + // On Android with the fix, the scope should NOT be disposed (still not null) + // Note: The fix skips calling DisposeWindowScope() entirely on Android + Assert.NotNull(scopeAfterDestroying); +#else + // On other platforms, the scope IS disposed and set to null + Assert.Null(scopeAfterDestroying); +#endif + }); + } + + /// + /// Test that basic navigation works without crashes. + /// This validates the core navigation scenario from issue #33187. + /// + [Fact] + public async Task NavigationPagePushPopDoesNotCrash() + { + SetupBuilder(); + + var firstPage = new ContentPage { Title = "First" }; + var navigationPage = new NavigationPage(firstPage); + var window = new Window(navigationPage); + + await CreateHandlerAndAddToWindow(window, async (handler) => + { + // Wait for page to be fully loaded + await OnLoadedAsync(firstPage); + + // Push a new page - this triggers ContentPage.NavigatedTo which calls UpdateHideSoftInputOnTapped + var secondPage = new ContentPage { Title = "Second" }; + await navigationPage.PushAsync(secondPage); + await OnLoadedAsync(secondPage); + + // Pop back - this also exercises navigation lifecycle + await navigationPage.PopAsync(); + await OnLoadedAsync(navigationPage.CurrentPage); + + // If we got here, no ObjectDisposedException was thrown + Assert.NotNull(navigationPage.CurrentPage); + Assert.Equal("First", navigationPage.CurrentPage.Title); + }); + } + } +} From 3eba2b9aa6e53b8c42c3547ff944460f1361e5d5 Mon Sep 17 00:00:00 2001 From: praveenkumarkarunanithi Date: Tue, 3 Feb 2026 16:15:55 +0530 Subject: [PATCH 7/9] updated fix and device test --- .../src/Core/Application/Application.cs | 6 ++ src/Controls/src/Core/Window/Window.cs | 10 +-- .../Elements/Window/WindowTests.Android.cs | 71 +++++++++++++++++++ 3 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Android.cs diff --git a/src/Controls/src/Core/Application/Application.cs b/src/Controls/src/Core/Application/Application.cs index b5aa0570f5c8..1e7dcd5508dc 100644 --- a/src/Controls/src/Core/Application/Application.cs +++ b/src/Controls/src/Core/Application/Application.cs @@ -468,6 +468,12 @@ IWindow IApplication.CreateWindow(IActivationState? activationState) } } + // On Android, reuse existing window when Activity is recreated due to lifecycle changes +#if ANDROID + if (window == null && _windows.Count > 0) + window = _windows[0]; +#endif + // create a new one if there is no pending windows if (window == null) { diff --git a/src/Controls/src/Core/Window/Window.cs b/src/Controls/src/Core/Window/Window.cs index 938a77efd27c..af8ceb47d948 100644 --- a/src/Controls/src/Core/Window/Window.cs +++ b/src/Controls/src/Core/Window/Window.cs @@ -544,14 +544,16 @@ void IWindow.Destroying() OnDestroying(); AlertManager.Unsubscribe(); - Application?.RemoveWindow(this); - + // On Android, preserve window in collection to enable reuse when Activity is recreated +#if !ANDROID + Application?.RemoveWindow(this); +#endif + var mauiContext = Handler?.MauiContext as MauiContext; Handler?.DisconnectHandler(); + // On Android, preserve window scope to enable reuse when Activity is recreated #if !ANDROID - // Desktop platforms dispose scope as windows are independently created and destroyed. - // Android skips disposal as windows are reused in single-Activity model. mauiContext?.DisposeWindowScope(); #endif } diff --git a/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Android.cs b/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Android.cs new file mode 100644 index 000000000000..a8d4b134634e --- /dev/null +++ b/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Android.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Maui.Controls; +using Microsoft.Maui.DeviceTests.Stubs; +using Xunit; + +namespace Microsoft.Maui.DeviceTests +{ + public partial class WindowTests + { + [Fact] + public async Task WindowDestroyingPreservesWindowScopeOnAndroid() + { + // https://github.com/dotnet/maui/issues/33597 + SetupBuilder(); + + var window = new Window(new ContentPage()); + + await CreateHandlerAndAddToWindow(window, async handler => + { + await OnLoadedAsync(window.Page); + + var mauiContext = handler.MauiContext as MauiContext; + Assert.NotNull(mauiContext); + + var windowScopeField = typeof(MauiContext).GetField("_windowScope", BindingFlags.NonPublic | BindingFlags.Instance); + var setWindowScope = typeof(MauiContext).GetMethod("SetWindowScope", BindingFlags.NonPublic | BindingFlags.Instance); + + var newScope = mauiContext.Services.CreateScope(); + setWindowScope.Invoke(mauiContext, new[] { newScope }); + Assert.NotNull(windowScopeField.GetValue(mauiContext)); + + ((IWindow)window).Destroying(); + + Assert.NotNull(windowScopeField.GetValue(mauiContext)); + }); + } + + [Fact] + public async Task WindowDestroyingPreservesWindowCollectionOnAndroid() + { + // https://github.com/dotnet/maui/issues/33597 + SetupBuilder(); + + var app = Application.Current; + var window = new Window(new ContentPage()); + + await CreateHandlerAndAddToWindow(window, async handler => + { + await OnLoadedAsync(window.Page); + + window.Parent = app; + + var windowsField = typeof(Application).GetField("_windows", BindingFlags.NonPublic | BindingFlags.Instance); + var windowsList = windowsField.GetValue(app) as IList; + + if (!windowsList.Contains(window)) + windowsList.Add(window); + + var countBefore = windowsList.Count; + + ((IWindow)window).Destroying(); + + Assert.Equal(countBefore, windowsList.Count); + Assert.Contains(window, windowsList); + }); + } + } +} From 34bc0e499d523cf4ee928ff810c23e85c68a7564 Mon Sep 17 00:00:00 2001 From: praveenkumarkarunanithi Date: Wed, 4 Feb 2026 16:50:21 +0530 Subject: [PATCH 8/9] Delete WindowTests.Issue33187.cs --- .../Elements/Window/WindowTests.Issue33187.cs | 205 ------------------ 1 file changed, 205 deletions(-) delete mode 100644 src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Issue33187.cs diff --git a/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Issue33187.cs b/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Issue33187.cs deleted file mode 100644 index e3a8e3fc7d1a..000000000000 --- a/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Issue33187.cs +++ /dev/null @@ -1,205 +0,0 @@ -#nullable enable -using System; -using System.Reflection; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Maui.Controls; -using Microsoft.Maui.Controls.Handlers; -using Microsoft.Maui.DeviceTests.Stubs; -using Microsoft.Maui.Handlers; -using Microsoft.Maui.Hosting; -using Xunit; - -#if ANDROID || IOS || MACCATALYST -using ShellHandler = Microsoft.Maui.Controls.Handlers.Compatibility.ShellRenderer; -#endif - -namespace Microsoft.Maui.DeviceTests -{ - /// - /// Device tests for https://github.com/dotnet/maui/issues/33187 - /// - /// Bug: On Android, when the app goes to background (e.g., notification tap scenario), - /// Window.Destroying() is called which used to dispose the service provider. - /// When the user returns to the app and navigates, ContentPage.UpdateHideSoftInputOnTapped - /// would throw ObjectDisposedException trying to resolve services. - /// - /// Fix: The fix adds #if !ANDROID to skip DisposeWindowScope() on Android since windows - /// are reused in the single-Activity model. - /// - [Category(TestCategory.Window)] -#if ANDROID || IOS || MACCATALYST - [Collection(ControlsHandlerTestBase.RunInNewWindowCollection)] -#endif - public class WindowTestsIssue33187 : ControlsHandlerTestBase - { - void SetupBuilder() - { - EnsureHandlerCreated(builder => - { - builder.ConfigureMauiHandlers(handlers => - { - SetupShellHandlers(handlers); - -#if ANDROID || WINDOWS - handlers.AddHandler(typeof(NavigationPage), typeof(NavigationViewHandler)); -#else - handlers.AddHandler(typeof(NavigationPage), typeof(Microsoft.Maui.Controls.Handlers.Compatibility.NavigationRenderer)); -#endif - - handlers.AddHandler(); - handlers.AddHandler(); - handlers.AddHandler(); - handlers.AddHandler(); - }); - }); - } - - /// - /// Key test for Issue #33187: - /// - /// On Android, calling Window.Destroying() should NOT dispose the window scope. - /// On other platforms, it SHOULD dispose the scope (since windows are independently created/destroyed). - /// - /// This test verifies that calling DisposeWindowScope has the expected behavior on each platform - /// by directly testing MauiContext.DisposeWindowScope. - /// - [Fact] - public async Task DisposeWindowScopeWorksCorrectly() - { - SetupBuilder(); - - var page = new ContentPage { Title = "Test Page" }; - var window = new Window(page); - - await CreateHandlerAndAddToWindow(window, async (handler) => - { - await OnLoadedAsync(page); - - // Get MauiContext - var mauiContext = handler.MauiContext as MauiContext; - Assert.NotNull(mauiContext); - - // Get the internal _windowScope field - var windowScopeField = typeof(MauiContext).GetField("_windowScope", BindingFlags.NonPublic | BindingFlags.Instance); - Assert.NotNull(windowScopeField); - - // The test stub doesn't set up window scope, so first set one up for testing - var scope = mauiContext.Services.CreateScope(); - var setWindowScope = typeof(MauiContext).GetMethod("SetWindowScope", BindingFlags.NonPublic | BindingFlags.Instance); - Assert.NotNull(setWindowScope); - setWindowScope.Invoke(mauiContext, new object[] { scope }); - - // Verify window scope is now set - var scopeBeforeDispose = windowScopeField.GetValue(mauiContext); - Assert.NotNull(scopeBeforeDispose); - - // Call DisposeWindowScope - this is what Window.Destroying() does (or should skip on Android) - var disposeWindowScope = typeof(MauiContext).GetMethod("DisposeWindowScope", BindingFlags.NonPublic | BindingFlags.Instance); - Assert.NotNull(disposeWindowScope); - disposeWindowScope.Invoke(mauiContext, null); - - // Check if the window scope is null after disposal - var scopeAfterDispose = windowScopeField.GetValue(mauiContext); - - // DisposeWindowScope should always clear the _windowScope field (on all platforms) - // The key is that on Android, Window.Destroying() doesn't CALL DisposeWindowScope - Assert.Null(scopeAfterDispose); - }); - } - - /// - /// Test that Destroying() has the expected behavior per platform. - /// On Android with the fix, Destroying() should NOT call DisposeWindowScope. - /// On other platforms, Destroying() SHOULD call DisposeWindowScope. - /// - [Fact] -#if ANDROID - public async Task AndroidWindowDestroyingDoesNotDisposeWindowScope() -#else - public async Task NonAndroidWindowDestroyingDisposesWindowScope() -#endif - { - SetupBuilder(); - - var page = new ContentPage { Title = "Test Page" }; - var window = new Window(page); - - await CreateHandlerAndAddToWindow(window, async (handler) => - { - await OnLoadedAsync(page); - - // Get MauiContext - var mauiContext = handler.MauiContext as MauiContext; - Assert.NotNull(mauiContext); - - // Get the internal _windowScope field - var windowScopeField = typeof(MauiContext).GetField("_windowScope", BindingFlags.NonPublic | BindingFlags.Instance); - Assert.NotNull(windowScopeField); - - // The test stub doesn't set up window scope, so first set one up for testing - var scope = mauiContext.Services.CreateScope(); - var setWindowScope = typeof(MauiContext).GetMethod("SetWindowScope", BindingFlags.NonPublic | BindingFlags.Instance); - Assert.NotNull(setWindowScope); - setWindowScope.Invoke(mauiContext, new object[] { scope }); - - // Verify window scope is now set - var scopeBeforeDestroying = windowScopeField.GetValue(mauiContext); - Console.WriteLine($"TEST: scopeBeforeDestroying is {(scopeBeforeDestroying == null ? "null" : "not null")}"); - Assert.NotNull(scopeBeforeDestroying); - - // Call Destroying() - this is what happens when Android activity goes through lifecycle - var iWindow = (IWindow)window; - Console.WriteLine("TEST: About to call Destroying()"); - iWindow.Destroying(); - Console.WriteLine("TEST: Called Destroying()"); - - // Check if the window scope is null after Destroying - var scopeAfterDestroying = windowScopeField.GetValue(mauiContext); - Console.WriteLine($"TEST: scopeAfterDestroying is {(scopeAfterDestroying == null ? "null" : "not null")}"); - -#if ANDROID - // On Android with the fix, the scope should NOT be disposed (still not null) - // Note: The fix skips calling DisposeWindowScope() entirely on Android - Assert.NotNull(scopeAfterDestroying); -#else - // On other platforms, the scope IS disposed and set to null - Assert.Null(scopeAfterDestroying); -#endif - }); - } - - /// - /// Test that basic navigation works without crashes. - /// This validates the core navigation scenario from issue #33187. - /// - [Fact] - public async Task NavigationPagePushPopDoesNotCrash() - { - SetupBuilder(); - - var firstPage = new ContentPage { Title = "First" }; - var navigationPage = new NavigationPage(firstPage); - var window = new Window(navigationPage); - - await CreateHandlerAndAddToWindow(window, async (handler) => - { - // Wait for page to be fully loaded - await OnLoadedAsync(firstPage); - - // Push a new page - this triggers ContentPage.NavigatedTo which calls UpdateHideSoftInputOnTapped - var secondPage = new ContentPage { Title = "Second" }; - await navigationPage.PushAsync(secondPage); - await OnLoadedAsync(secondPage); - - // Pop back - this also exercises navigation lifecycle - await navigationPage.PopAsync(); - await OnLoadedAsync(navigationPage.CurrentPage); - - // If we got here, no ObjectDisposedException was thrown - Assert.NotNull(navigationPage.CurrentPage); - Assert.Equal("First", navigationPage.CurrentPage.Title); - }); - } - } -} From aded00e42e7d75c7741f1cd86f89ccc465a6b964 Mon Sep 17 00:00:00 2001 From: praveenkumarkarunanithi Date: Wed, 11 Feb 2026 17:39:27 +0530 Subject: [PATCH 9/9] reverting unnecessary fix codes --- .../src/Core/Application/Application.cs | 6 ---- src/Controls/src/Core/Window/Window.cs | 5 +--- .../Elements/Window/WindowTests.Android.cs | 29 ------------------- 3 files changed, 1 insertion(+), 39 deletions(-) diff --git a/src/Controls/src/Core/Application/Application.cs b/src/Controls/src/Core/Application/Application.cs index 1e7dcd5508dc..b5aa0570f5c8 100644 --- a/src/Controls/src/Core/Application/Application.cs +++ b/src/Controls/src/Core/Application/Application.cs @@ -468,12 +468,6 @@ IWindow IApplication.CreateWindow(IActivationState? activationState) } } - // On Android, reuse existing window when Activity is recreated due to lifecycle changes -#if ANDROID - if (window == null && _windows.Count > 0) - window = _windows[0]; -#endif - // create a new one if there is no pending windows if (window == null) { diff --git a/src/Controls/src/Core/Window/Window.cs b/src/Controls/src/Core/Window/Window.cs index af8ceb47d948..429eccb62f12 100644 --- a/src/Controls/src/Core/Window/Window.cs +++ b/src/Controls/src/Core/Window/Window.cs @@ -544,10 +544,7 @@ void IWindow.Destroying() OnDestroying(); AlertManager.Unsubscribe(); - // On Android, preserve window in collection to enable reuse when Activity is recreated -#if !ANDROID - Application?.RemoveWindow(this); -#endif + Application?.RemoveWindow(this); var mauiContext = Handler?.MauiContext as MauiContext; Handler?.DisconnectHandler(); diff --git a/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Android.cs b/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Android.cs index a8d4b134634e..1d46e8637726 100644 --- a/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Android.cs +++ b/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Android.cs @@ -38,34 +38,5 @@ await CreateHandlerAndAddToWindow(window, async handler => }); } - [Fact] - public async Task WindowDestroyingPreservesWindowCollectionOnAndroid() - { - // https://github.com/dotnet/maui/issues/33597 - SetupBuilder(); - - var app = Application.Current; - var window = new Window(new ContentPage()); - - await CreateHandlerAndAddToWindow(window, async handler => - { - await OnLoadedAsync(window.Page); - - window.Parent = app; - - var windowsField = typeof(Application).GetField("_windows", BindingFlags.NonPublic | BindingFlags.Instance); - var windowsList = windowsField.GetValue(app) as IList; - - if (!windowsList.Contains(window)) - windowsList.Add(window); - - var countBefore = windowsList.Count; - - ((IWindow)window).Destroying(); - - Assert.Equal(countBefore, windowsList.Count); - Assert.Contains(window, windowsList); - }); - } } }