[Android] MediaPicker: Fix photo picker completion from child activities - #35944
Conversation
…ity launcher registry Commit 0c25c62 introduced a guard in ActivityForResultRequest.Register() that returns early when any still-alive activity already holds a registration. This prevented child activities from ever receiving their own ActivityResultLauncher, so MediaPicker.PickPhotosAsync() called from a child activity hung indefinitely on Android API 36 (task completion source is never resolved). Fix: Replace the single shared launcher + WeakReference guard with a ConditionalWeakTable<ComponentActivity, ActivityResultLauncher> that maps one launcher per activity instance. Register() is now idempotent per-activity (safe to call again after a config change) but never blocks a different activity from registering. Launch() resolves the launcher for the current foreground activity at call time via ActivityStateManager.GetCurrentActivity(). This also preserves the fix for dotnet#32845: each activity owns its entry in the table so a temporary ComponentActivity can no longer overwrite the main activity's launcher. Adds: - UI test (Issue35826) that opens a child AppCompatActivity, triggers PickPhotosAsync, cancels, and asserts the result is delivered (not hung). Test skips on Android API < 36 where the hang is not reproducible. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com>
Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com>
Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com>
Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com>
Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com>
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 35944Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 35944" |
|
Hey there @@KarthikRajaKalaimani! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed. |
|
/review -b feature/enhanced-reviewer -p android |
This comment has been minimized.
This comment has been minimized.
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 3 findings
See inline comments for details.
| // TaskCompletionSource is never resolved. With the fix it updates promptly. | ||
| // Also accept "Error" as a valid outcome: what matters is that the call returns | ||
| // (doesn't hang), not the specific result — emulators may not have a photo picker. | ||
| var returned = App.WaitForTextToBePresentInElement(ChildActivityResultLabel, "Cancelled", |
There was a problem hiding this comment.
[moderate] Regression Prevention — The regression test accepts any Error result as success and skips below API 36, so it does not prove the PR's photo-picker lifecycle path returns the expected cancellation result. A broken launcher path that immediately throws/cancels changes the label to Error and still passes. Assert the expected Cancelled outcome on a device/API where the picker is available, and use a separate assumption/skip for environments without a photo picker.
|
Addressed Ai summary concerns. |
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 4 findings
See inline comments for details.
| var registeredActivity = componentActivity; | ||
| var callback = new ActivityResultCallback<TResult>(result => | ||
| { | ||
| if (_pendingRequests.TryGetValue(registeredActivity, out var tcs)) |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[major] Android Platform Specifics / Async and Threading Safety — The pending request is keyed by the ComponentActivity instance captured when the launcher was registered. On a configuration change while the photo picker is open, AndroidX restores the ActivityResultRegistry state into the recreated activity and delivers the result to the new registration callback, not to the destroyed activity instance. That callback will look up _pendingRequests with the new registeredActivity, while the awaiting TCS was stored under the old activity in Launch(launchingActivity, ...), so the result is dropped and the original PickPhotosAsync can hang again. The pending operation needs to survive recreation by ActivityResultRegistry key/request identity rather than by activity object identity, or otherwise explicitly cancel/complete the old request during recreation.
| // Rejection semantics would block all future launches from this activity forever. | ||
| Trace.WriteLine("ActivityForResultRequest: canceling overlapping pending request and launching new request."); | ||
| _pendingRequests.Remove(launchingActivity); | ||
| existingTcs?.TrySetCanceled(); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[major] Async and Threading Safety — Replacing an existing pending request for the same activity cancels the first caller while its Android picker launch is still in flight, then reuses the same ActivityResultLauncher for the second caller. If two PickPhotosAsync calls overlap (for example a double tap/programmatic retry), the first result can complete the second TCS or be dropped after the first TCS was already canceled. This branch also does not address process death or rotation: those create a different activity/process and therefore will not find the old entry by the same ComponentActivity key. Prefer rejecting/serializing overlapping launches before starting another picker, and handle orphan cleanup through lifecycle/result ownership instead of cancel-and-replace on the same launcher.
| if (App is AppiumApp appiumApp) | ||
| { | ||
| var apiLevel = (long?)appiumApp.Driver.Capabilities.GetCapability("deviceApiLevel") ?? 0; | ||
| if (apiLevel < 36) |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[moderate] Regression Prevention — Test Coverage — The regression test is skipped on every Android device below API 36. Because this PR changes shared ActivityResultLauncher/TCS ownership for all Android photo-picker calls, and the available PR environment does not reproduce/run this API 36-only path, the test will not catch regressions in the gate that validates the PR. Please either add coverage that exercises the ownership bug on a CI-supported API level, add an API 36 lane for this test, or document the remaining coverage gap explicitly.
|
|
||
| [Test] | ||
| [Category(UITestCategories.Essentials)] | ||
| public void PickPhotosAsyncShouldReturnFromChildActivity() |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[moderate] Regression Prevention — Test Coverage — This test only covers the child-activity cancel path. The fix also changes adjacent high-risk behavior for activity recreation while a picker is open and overlapping launches from the same activity, but neither scenario is exercised here. Those are the places where the new per-activity pending-request table is most likely to strand or misroute a result, so they need regression coverage or an explicit acknowledged limitation before this plumbing change is safe to merge.
kubaflo
left a comment
There was a problem hiding this comment.
Could you please check the ai's suggestions?
This comment has been minimized.
This comment has been minimized.
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 3 findings
See inline comments for details.
| var registeredActivity = componentActivity; | ||
| var callback = new ActivityResultCallback<TResult>(result => | ||
| { | ||
| if (_pendingRequests.TryGetValue(registeredActivity, out var tcs)) |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[critical] Layout/Lifecycle Logic and Correctness — Async and Threading Safety — The rotation invariant claimed in the type doc comment (lines 30-36: "delivery therefore does NOT depend on whichever activity is current at delivery time") does not hold, and this reintroduces the exact hang this PR is fixing, triggered by device rotation instead of a child activity.
Concrete scenario: Activity A calls PickPhotosAsync(), which adds a pending TCS keyed by A (_pendingRequests.Add(launchingActivity, tcs) at line 149) and calls launcher.Launch(input) on A's launcher. The user rotates the device while the system photo picker is open. Android destroys A with IsFinishing == false (config change), so ActivityStateManager.android.cs correctly skips CancelPendingRequest. Android recreates the activity as instance B, whose OnCreate calls Platform.Init(B, bundle) → Register(B), which registers a new ActivityResultLauncher/callback closing over B (this method, registeredActivity = B).
AndroidX's ActivityResultRegistry delivers the eventual result to whichever callback is registered for the same restored key at delivery time — i.e., the newly registered callback for B, not the original callback captured for A (this is documented AndroidX behavior: registerForActivityResult must be re-invoked identically every onCreate, and the framework rebinds delivery to the latest registration). When that fires, this code does _pendingRequests.TryGetValue(registeredActivity=B, out var tcs), but the actual pending entry is stored under key A (never migrated/re-keyed to B). The lookup fails silently, tcs is never resolved, and MediaPicker.PickPhotosAsync() hangs forever after any rotation while the picker is open.
This needs either: (1) transferring/re-keying the pending request from the old activity instance to the newly-registered one in Register(), or (2) resolving/cancelling the pending request on the old activity in OnActivityDestroyed even for non-finishing (config-change) destroys and having the caller re-launch after recreation, or (3) tracking pending requests by a stable identifier (e.g., a saved-instance-state token) instead of the transient ComponentActivity instance.
| [Test] | ||
| [Category(UITestCategories.Essentials)] | ||
| public void PickPhotosAsyncShouldReturnFromChildActivity() | ||
| { |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[moderate] Regression Prevention and Test Coverage — This is the only regression test added for this fix, and it covers just the child-activity launch scenario; it does not cover the config-change/rotation-while-picker-is-open scenario (see the critical finding on ActivityForResultRequest.android.cs:86), nor concurrent picker requests from two activities, which the PR's own doc comments explicitly claim to have solved. In addition, the test unconditionally Assert.Ignores (lines 28-30) on any device below API 36, so on CI lanes running API 30 emulator images (referenced elsewhere in this test suite, e.g. UITest.cs), this test provides zero signal and will always report as ignored rather than passed or failed — masking the true regression-coverage gap. Please add a rotation-during-picker regression test (or explicitly document why it cannot be automated) and consider whether the API-gate can be narrowed rather than skipping the whole scenario.
| /// currently focused activity. | ||
| /// </summary> | ||
| protected bool IsRegistered => launcher is not null; | ||
| protected bool HasLauncherForCurrentActivity => GetLauncherForCurrentActivity() is not null; |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[minor] Complexity Reduction — Dead Code — HasLauncherForCurrentActivity and its backing GetLauncherForCurrentActivity() (line 191) are no longer referenced anywhere after this change; all call sites now use the explicit Launch(ComponentActivity, T) overload and _activityLaunchers/_pendingRequests lookups keyed by an explicit activity. Consider removing both members, or if they're kept intentionally for external/future use, note why since they add unused surface area to an internal type.
MauiBot
left a comment
There was a problem hiding this comment.
AI Review Summary
@KarthikRajaKalaimani — new AI review results are available based on this last commit:
d5a1ae0. To request a fresh review after new comments or commits, comment/review rerun.
🗂️ Review Sessions — click to expand
🚦 Gate — Test Before & After Fix
Gate Result: ✅ PASSED
Platform: ANDROID · Base: main · Merge base: 5535b43c
| Test | Without Fix (expect FAIL) | With Fix (expect PASS) |
|---|---|---|
🖥️ Issue35826 Issue35826 |
✅ FAIL — 1065s | ✅ PASS — 792s |
🔴 Without fix — 🖥️ Issue35826: FAIL ✅ · 1065s
(truncated to last 15,000 chars)
in/Controls.Core/Debug/net10.0-android36.0/Microsoft.Maui.Controls.dll
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Controls.Maps -> /home/vsts/work/1/s/artifacts/bin/Controls.Maps/Debug/net10.0-android36.0/Microsoft.Maui.Controls.Maps.dll
Controls.Foldable -> /home/vsts/work/1/s/artifacts/bin/Controls.Foldable/Debug/net10.0-android36.0/Microsoft.Maui.Controls.Foldable.dll
Controls.Xaml -> /home/vsts/work/1/s/artifacts/bin/Controls.Xaml/Debug/net10.0-android36.0/Microsoft.Maui.Controls.Xaml.dll
Microsoft.AspNetCore.Components.WebView.Maui -> /home/vsts/work/1/s/artifacts/bin/Microsoft.AspNetCore.Components.WebView.Maui/Debug/net10.0-android36.0/Microsoft.AspNetCore.Components.WebView.Maui.dll
Controls.TestCases.HostApp -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Controls.TestCases.HostApp.dll
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Graphics -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Graphics.dll
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Essentials -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Essentials.dll
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Core -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.dll
Controls.BindingSourceGen -> /home/vsts/work/1/s/artifacts/bin/Controls.BindingSourceGen/Debug/netstandard2.0/Microsoft.Maui.Controls.BindingSourceGen.dll
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Maps -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Maps.dll
Controls.Core -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Controls.dll
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Controls.Foldable -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Controls.Foldable.dll
Microsoft.AspNetCore.Components.WebView.Maui -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.AspNetCore.Components.WebView.Maui.dll
Controls.Xaml -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Controls.Xaml.dll
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Controls.Maps -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Controls.Maps.dll
Build succeeded.
0 Warning(s)
0 Error(s)
Time Elapsed 00:09:52.64
Broadcasting: Intent { act=android.intent.action.CLOSE_SYSTEM_DIALOGS flg=0x400000 }
Broadcast completed: result=0
Broadcasting: Intent { act=android.intent.action.CLOSE_SYSTEM_DIALOGS flg=0x400000 }
Broadcast completed: result=0
Broadcasting: Intent { act=android.intent.action.CLOSE_SYSTEM_DIALOGS flg=0x400000 }
Broadcast completed: result=0
Starting: Intent { act=android.settings.SETTINGS }
Broadcasting: Intent { act=android.intent.action.CLOSE_SYSTEM_DIALOGS flg=0x400000 }
Broadcast completed: result=0
Determining projects to restore...
Restored /home/vsts/work/1/s/src/Controls/tests/CustomAttributes/Controls.CustomAttributes.csproj (in 1.26 sec).
Restored /home/vsts/work/1/s/src/TestUtils/src/VisualTestUtils/VisualTestUtils.csproj (in 7 ms).
Restored /home/vsts/work/1/s/src/TestUtils/src/VisualTestUtils.MagickNet/VisualTestUtils.MagickNet.csproj (in 7.76 sec).
Restored /home/vsts/work/1/s/src/Controls/tests/TestCases.Android.Tests/Controls.TestCases.Android.Tests.csproj (in 9.25 sec).
Restored /home/vsts/work/1/s/src/TestUtils/src/UITest.Core/UITest.Core.csproj (in 2 ms).
Restored /home/vsts/work/1/s/src/TestUtils/src/UITest.Appium/UITest.Appium.csproj (in 2 ms).
Restored /home/vsts/work/1/s/src/TestUtils/src/UITest.NUnit/UITest.NUnit.csproj (in 347 ms).
Restored /home/vsts/work/1/s/src/TestUtils/src/UITest.Analyzers/UITest.Analyzers.csproj (in 2.69 sec).
5 of 13 projects are up-to-date for restore.
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Graphics -> /home/vsts/work/1/s/artifacts/bin/Graphics/Debug/net10.0/Microsoft.Maui.Graphics.dll
Controls.CustomAttributes -> /home/vsts/work/1/s/artifacts/bin/Controls.CustomAttributes/Debug/net10.0/Controls.CustomAttributes.dll
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Essentials -> /home/vsts/work/1/s/artifacts/bin/Essentials/Debug/net10.0/Microsoft.Maui.Essentials.dll
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Core -> /home/vsts/work/1/s/artifacts/bin/Core/Debug/net10.0/Microsoft.Maui.dll
Controls.BindingSourceGen -> /home/vsts/work/1/s/artifacts/bin/Controls.BindingSourceGen/Debug/netstandard2.0/Microsoft.Maui.Controls.BindingSourceGen.dll
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Controls.Core -> /home/vsts/work/1/s/artifacts/bin/Controls.Core/Debug/net10.0/Microsoft.Maui.Controls.dll
UITest.Core -> /home/vsts/work/1/s/artifacts/bin/UITest.Core/Debug/net10.0/UITest.Core.dll
VisualTestUtils -> /home/vsts/work/1/s/artifacts/bin/VisualTestUtils/Debug/netstandard2.0/VisualTestUtils.dll
UITest.NUnit -> /home/vsts/work/1/s/artifacts/bin/UITest.NUnit/Debug/net10.0/UITest.NUnit.dll
VisualTestUtils.MagickNet -> /home/vsts/work/1/s/artifacts/bin/VisualTestUtils.MagickNet/Debug/netstandard2.0/VisualTestUtils.MagickNet.dll
UITest.Appium -> /home/vsts/work/1/s/artifacts/bin/UITest.Appium/Debug/net10.0/UITest.Appium.dll
UITest.Analyzers -> /home/vsts/work/1/s/artifacts/bin/UITest.Analyzers/Debug/netstandard2.0/UITest.Analyzers.dll
Controls.TestCases.Android.Tests -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.Android.Tests/Debug/net10.0/Controls.TestCases.Android.Tests.dll
Test run for /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.Android.Tests/Debug/net10.0/Controls.TestCases.Android.Tests.dll (.NETCoreApp,Version=v10.0)
VSTest version 18.0.1 (x64)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
NUnit Adapter 4.5.0.0: Test execution started
Running selected tests in /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.Android.Tests/Debug/net10.0/Controls.TestCases.Android.Tests.dll
NUnit3TestExecutor discovered 1 of 1 NUnit test cases using Current Discovery mode, Non-Explicit run
�[38;5;207m[d0c36728]�[0m�[38;5;160m[Logcat]�[0m Logcat terminated with code 1, signal null
�[38;5;118m[8f7946c5]�[0m�[38;5;160m[Logcat]�[0m Logcat terminated with code 1, signal null
�[38;5;50m[29b753b0]�[0m�[38;5;160m[Logcat]�[0m Logcat terminated with code 1, signal null
�[38;5;18m[9ca2050d]�[0m�[38;5;160m[Logcat]�[0m Logcat terminated with code 1, signal null
�[38;5;211m[6390a802]�[0m�[38;5;160m[Logcat]�[0m Logcat terminated with code 1, signal null
�[38;5;123m[df4cc863]�[0m�[38;5;160m[Logcat]�[0m Logcat terminated with code 1, signal null
�[38;5;58m[c7512fb8]�[0m�[38;5;160m[Logcat]�[0m Logcat terminated with code 1, signal null
�[38;5;76m[46130486]�[0m�[38;5;160m[Logcat]�[0m Logcat terminated with code 1, signal null
�[38;5;55m[e8034ea5]�[0m�[38;5;160m[Logcat]�[0m Logcat terminated with code 1, signal null
�[38;5;23m[ea5092a2]�[0m�[38;5;160m[Logcat]�[0m Logcat terminated with code 1, signal null
>>>>> 07/09/2026 15:20:30 The SaveDeviceDiagnosticInfo threw an exception during Issue35826(Android).
Exception details: System.InvalidOperationException: Call InitialSetup before accessing the App property.
at UITest.Appium.NUnit.UITestContextBase.get_App() in /_/src/TestUtils/src/UITest.NUnit/UITestContextBase.cs:line 32
at UITest.Appium.NUnit.UITestBase.SaveDeviceDiagnosticInfo(String note, Boolean storeForReattachment) in /_/src/TestUtils/src/UITest.NUnit/UITestBase.cs:line 255
TearDown failed for test fixture Microsoft.Maui.TestCases.Tests.Issues.Issue35826(Android)
OpenQA.Selenium.UnknownErrorException : An unknown server-side error occurred while processing the command. Original error: Error executing adbExec. Original error: 'Command '/usr/local/lib/android/sdk/platform-tools/adb -P 5037 -s emulator-5554 install -r --no-incremental /home/vsts/work/1/s/.appium/node_modules/appium-uiautomator2-driver/node_modules/appium-uiautomator2-server/apks/appium-uiautomator2-server-v7.4.1.apk' timed out after 20000ms'. Try to increase the 20000ms adb execution timeout represented by 'uiautomator2ServerInstallTimeout' capability
TearDown : System.InvalidOperationException : Call InitialSetup before accessing the App property.
StackTrace: at OpenQA.Selenium.WebDriver.UnpackAndThrowOnError(Response errorResponse, String commandToExecute)
at OpenQA.Selenium.WebDriver.ExecuteAsync(String driverCommandToExecute, Dictionary`2 parameters)
at OpenQA.Selenium.WebDriver.Execute(String driverCommandToExecute, Dictionary`2 parameters)
at OpenQA.Selenium.Appium.AppiumDriver.Execute(String driverCommandToExecute, Dictionary`2 parameters)
at OpenQA.Selenium.WebDriver.StartSession(ICapabilities capabilities)
at OpenQA.Selenium.WebDriver..ctor(ICommandExecutor executor, ICapabilities capabilities)
at OpenQA.Selenium.Appium.AppiumDriver..ctor(ICommandExecutor commandExecutor, ICapabilities appiumOptions)
at OpenQA.Selenium.Appium.AppiumDriver..ctor(Uri remoteAddress, ICapabilities appiumOptions, TimeSpan commandTimeout, AppiumClientConfig clientConfig)
at OpenQA.Selenium.Appium.AppiumDriver..ctor(Uri remoteAddress, ICapabilities appiumOptions, TimeSpan commandTimeout)
at OpenQA.Selenium.Appium.AppiumDriver..ctor(Uri remoteAddress, ICapabilities appiumOptions)
at OpenQA.Selenium.Appium.Android.AndroidDriver..ctor(Uri remoteAddress, DriverOptions driverOptions)
at UITest.Appium.AppiumAndroidApp..ctor(Uri remoteAddress, IConfig config) in /_/src/TestUtils/src/UITest.Appium/AppiumAndroidApp.cs:line 11
at UITest.Appium.AppiumAndroidApp.CreateAndroidApp(Uri remoteAddress, IConfig config) in /_/src/TestUtils/src/UITest.Appium/AppiumAndroidApp.cs:line 41
at UITest.Appium.AppiumServerContext.CreateUIClientContext(IConfig config) in /_/src/TestUtils/src/UITest.Appium/AppiumServerContext.cs:line 42
at UITest.Appium.NUnit.UITestContextBase.InitialSetup(IServerContext context, Boolean reset) in /_/src/TestUtils/src/UITest.NUnit/UITestContextBase.cs:line 77
at UITest.Appium.NUnit.UITestContextBase.InitialSetup(IServerContext context) in /_/src/TestUtils/src/UITest.NUnit/UITestContextBase.cs:line 55
at UITest.Appium.NUnit.UITestBase.OneTimeSetup() in /_/src/TestUtils/src/UITest.NUnit/UITestBase.cs:line 215
at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)
--TearDown
at UITest.Appium.NUnit.UITestContextBase.get_App() in /_/src/TestUtils/src/UITest.NUnit/UITestContextBase.cs:line 32
at UITest.Appium.NUnit.UITestBase.OneTimeTearDown() in /_/src/TestUtils/src/UITest.NUnit/UITestBase.cs:line 244
at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)
NUnit Adapter 4.5.0.0: Test execution complete
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.8.2+699d445a1a (64-bit .NET 10.0.0)
[xUnit.net 00:00:00.12] Discovering: Controls.TestCases.Android.Tests
Failed PickPhotosAsyncShouldReturnFromChildActivity [4 m 28 s]
Error Message:
OneTimeSetUp: OpenQA.Selenium.UnknownErrorException : An unknown server-side error occurred while processing the command. Original error: Error executing adbExec. Original error: 'Command '/usr/local/lib/android/sdk/platform-tools/adb -P 5037 -s emulator-5554 install -r --no-incremental /home/vsts/work/1/s/.appium/node_modules/appium-uiautomator2-driver/node_modules/appium-uiautomator2-server/apks/appium-uiautomator2-server-v7.4.1.apk' timed out after 20000ms'. Try to increase the 20000ms adb execution timeout represented by 'uiautomator2ServerInstallTimeout' capability
Stack Trace:
at OpenQA.Selenium.WebDriver.UnpackAndThrowOnError(Response errorResponse, String commandToExecute)
at OpenQA.Selenium.WebDriver.ExecuteAsync(String driverCommandToExecute, Dictionary`2 parameters)
at OpenQA.Selenium.WebDriver.Execute(String driverCommandToExecute, Dictionary`2 parameters)
at OpenQA.Selenium.Appium.AppiumDriver.Execute(String driverCommandToExecute, Dictionary`2 parameters)
at OpenQA.Selenium.WebDriver.StartSession(ICapabilities capabilities)
at OpenQA.Selenium.WebDriver..ctor(ICommandExecutor executor, ICapabilities capabilities)
at OpenQA.Selenium.Appium.AppiumDriver..ctor(ICommandExecutor commandExecutor, ICapabilities appiumOptions)
at OpenQA.Selenium.Appium.AppiumDriver..ctor(Uri remoteAddress, ICapabilities appiumOptions, TimeSpan commandTimeout, AppiumClientConfig clientConfig)
at OpenQA.Selenium.Appium.AppiumDriver..ctor(Uri remoteAddress, ICapabilities appiumOptions, TimeSpan commandTimeout)
at OpenQA.Selenium.Appium.AppiumDriver..ctor(Uri remoteAddress, ICapabilities appiumOptions)
at OpenQA.Selenium.Appium.Android.AndroidDriver..ctor(Uri remoteAddress, DriverOptions driverOptions)
at UITest.Appium.AppiumAndroidApp..ctor(Uri remoteAddress, IConfig config) in /_/src/TestUtils/src/UITest.Appium/AppiumAndroidApp.cs:line 11
at UITest.Appium.AppiumAndroidApp.CreateAndroidApp(Uri remoteAddress, IConfig config) in /_/src/TestUtils/src/UITest.Appium/AppiumAndroidApp.cs:line 41
at UITest.Appium.AppiumServerContext.CreateUIClientContext(IConfig config) in /_/src/TestUtils/src/UITest.Appium/AppiumServerContext.cs:line 42
at UITest.Appium.NUnit.UITestContextBase.InitialSetup(IServerContext context, Boolean reset) in /_/src/TestUtils/src/UITest.NUnit/UITestContextBase.cs:line 77
at UITest.Appium.NUnit.UITestContextBase.InitialSetup(IServerContext context) in /_/src/TestUtils/src/UITest.NUnit/UITestContextBase.cs:line 55
at UITest.Appium.NUnit.UITestBase.OneTimeSetup() in /_/src/TestUtils/src/UITest.NUnit/UITestBase.cs:line 215
at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)
[xUnit.net 00:00:00.65] Discovered: Controls.TestCases.Android.Tests
Results File: /home/vsts/work/1/s/CustomAgentLogsTmp/UITests/TestResults/Issue35826.trx
Total tests: 1
Failed: 1
Test Run Failed.
Total time: 4.6461 Minutes
>>> TRX_RESULT_FILE: /home/vsts/work/1/s/CustomAgentLogsTmp/UITests/TestResults/Issue35826.trx
🟢 With fix — 🖥️ Issue35826: PASS ✅ · 792s
Determining projects to restore...
All projects are up-to-date for restore.
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Graphics -> /home/vsts/work/1/s/artifacts/bin/Graphics/Debug/net10.0-android36.0/Microsoft.Maui.Graphics.dll
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Essentials -> /home/vsts/work/1/s/artifacts/bin/Essentials/Debug/net10.0-android36.0/Microsoft.Maui.Essentials.dll
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Core -> /home/vsts/work/1/s/artifacts/bin/Core/Debug/net10.0-android36.0/Microsoft.Maui.dll
Controls.BindingSourceGen -> /home/vsts/work/1/s/artifacts/bin/Controls.BindingSourceGen/Debug/netstandard2.0/Microsoft.Maui.Controls.BindingSourceGen.dll
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Maps -> /home/vsts/work/1/s/artifacts/bin/Maps/Debug/net10.0-android36.0/Microsoft.Maui.Maps.dll
Controls.Core -> /home/vsts/work/1/s/artifacts/bin/Controls.Core/Debug/net10.0-android36.0/Microsoft.Maui.Controls.dll
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Microsoft.AspNetCore.Components.WebView.Maui -> /home/vsts/work/1/s/artifacts/bin/Microsoft.AspNetCore.Components.WebView.Maui/Debug/net10.0-android36.0/Microsoft.AspNetCore.Components.WebView.Maui.dll
Controls.Foldable -> /home/vsts/work/1/s/artifacts/bin/Controls.Foldable/Debug/net10.0-android36.0/Microsoft.Maui.Controls.Foldable.dll
Controls.Xaml -> /home/vsts/work/1/s/artifacts/bin/Controls.Xaml/Debug/net10.0-android36.0/Microsoft.Maui.Controls.Xaml.dll
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Controls.Maps -> /home/vsts/work/1/s/artifacts/bin/Controls.Maps/Debug/net10.0-android36.0/Microsoft.Maui.Controls.Maps.dll
Controls.TestCases.HostApp -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Controls.TestCases.HostApp.dll
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Graphics -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Graphics.dll
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Essentials -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Essentials.dll
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Core -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.dll
Controls.BindingSourceGen -> /home/vsts/work/1/s/artifacts/bin/Controls.BindingSourceGen/Debug/netstandard2.0/Microsoft.Maui.Controls.BindingSourceGen.dll
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Maps -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Maps.dll
Controls.Core -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Controls.dll
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Controls.Maps -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Controls.Maps.dll
Microsoft.AspNetCore.Components.WebView.Maui -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.AspNetCore.Components.WebView.Maui.dll
Controls.Xaml -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Controls.Xaml.dll
Controls.Foldable -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Controls.Foldable.dll
Build succeeded.
0 Warning(s)
0 Error(s)
Time Elapsed 00:10:29.86
Broadcasting: Intent { act=android.intent.action.CLOSE_SYSTEM_DIALOGS flg=0x400000 }
Broadcast completed: result=0
Broadcasting: Intent { act=android.intent.action.CLOSE_SYSTEM_DIALOGS flg=0x400000 }
Broadcast completed: result=0
Broadcasting: Intent { act=android.intent.action.CLOSE_SYSTEM_DIALOGS flg=0x400000 }
Broadcast completed: result=0
Starting: Intent { act=android.settings.SETTINGS }
Broadcasting: Intent { act=android.intent.action.CLOSE_SYSTEM_DIALOGS flg=0x400000 }
Broadcast completed: result=0
Determining projects to restore...
All projects are up-to-date for restore.
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Graphics -> /home/vsts/work/1/s/artifacts/bin/Graphics/Debug/net10.0/Microsoft.Maui.Graphics.dll
Controls.CustomAttributes -> /home/vsts/work/1/s/artifacts/bin/Controls.CustomAttributes/Debug/net10.0/Controls.CustomAttributes.dll
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Essentials -> /home/vsts/work/1/s/artifacts/bin/Essentials/Debug/net10.0/Microsoft.Maui.Essentials.dll
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Core -> /home/vsts/work/1/s/artifacts/bin/Core/Debug/net10.0/Microsoft.Maui.dll
Controls.BindingSourceGen -> /home/vsts/work/1/s/artifacts/bin/Controls.BindingSourceGen/Debug/netstandard2.0/Microsoft.Maui.Controls.BindingSourceGen.dll
##vso[build.updatebuildnumber]10.0.90-ci+azdo.14611549
Controls.Core -> /home/vsts/work/1/s/artifacts/bin/Controls.Core/Debug/net10.0/Microsoft.Maui.Controls.dll
UITest.Core -> /home/vsts/work/1/s/artifacts/bin/UITest.Core/Debug/net10.0/UITest.Core.dll
VisualTestUtils -> /home/vsts/work/1/s/artifacts/bin/VisualTestUtils/Debug/netstandard2.0/VisualTestUtils.dll
UITest.NUnit -> /home/vsts/work/1/s/artifacts/bin/UITest.NUnit/Debug/net10.0/UITest.NUnit.dll
VisualTestUtils.MagickNet -> /home/vsts/work/1/s/artifacts/bin/VisualTestUtils.MagickNet/Debug/netstandard2.0/VisualTestUtils.MagickNet.dll
UITest.Appium -> /home/vsts/work/1/s/artifacts/bin/UITest.Appium/Debug/net10.0/UITest.Appium.dll
UITest.Analyzers -> /home/vsts/work/1/s/artifacts/bin/UITest.Analyzers/Debug/netstandard2.0/UITest.Analyzers.dll
Controls.TestCases.Android.Tests -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.Android.Tests/Debug/net10.0/Controls.TestCases.Android.Tests.dll
Test run for /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.Android.Tests/Debug/net10.0/Controls.TestCases.Android.Tests.dll (.NETCoreApp,Version=v10.0)
VSTest version 18.0.1 (x64)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
NUnit Adapter 4.5.0.0: Test execution started
Running selected tests in /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.Android.Tests/Debug/net10.0/Controls.TestCases.Android.Tests.dll
NUnit3TestExecutor discovered 1 of 1 NUnit test cases using Current Discovery mode, Non-Explicit run
>>>>> 07/09/2026 15:33:31 FixtureSetup for Issue35826(Android)
>>>>> 07/09/2026 15:33:34 PickPhotosAsyncShouldReturnFromChildActivity Start
>>>>> 07/09/2026 15:33:34 PickPhotosAsyncShouldReturnFromChildActivity Stop
PickPhotosAsyncShouldReturnFromChildActivity: Issue #35826 only manifests on Android API 36+. Current device API: 30.
Skipped PickPhotosAsyncShouldReturnFromChildActivity [1 s]
NUnit Adapter 4.5.0.0: Test execution complete
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.8.2+699d445a1a (64-bit .NET 10.0.0)
[xUnit.net 00:00:00.16] Discovering: Controls.TestCases.Android.Tests
[xUnit.net 00:00:00.65] Discovered: Controls.TestCases.Android.Tests
Results File: /home/vsts/work/1/s/CustomAgentLogsTmp/UITests/TestResults/Issue35826.trx
Test Run Successful.
Total tests: 1
Skipped: 1
Total time: 24.9861 Seconds
>>> TRX_RESULT_FILE: /home/vsts/work/1/s/CustomAgentLogsTmp/UITests/TestResults/Issue35826.trx
📁 Fix files reverted (3 files)
src/Essentials/src/MediaPicker/MediaPicker.android.cssrc/Essentials/src/Platform/ActivityForResultRequest.android.cssrc/Essentials/src/Platform/ActivityStateManager.android.cs
📱 UI Tests — Essentials
Detected UI test categories: Essentials
⏭️ Deep UI tests — 0 passed, 0 failed across 1 category on platform-pool agent (replaces in-process counts above).
🧪 UI Test Execution Results (deep, platform pool)
| Category | Tests | Snapshot diffs |
|---|---|---|
Essentials |
0/1 ✓ | — |
📎 Download drop-deep-uitests artifact (TRX + snapshot diffs) |
📋 Pre-Flight — Context & Validation
Issue: #35826 - PickPhotosAsync hangs when called from a child activity
PR: #35944 - Android MediaPicker child-activity ActivityResult fix
Platforms Affected: Android
Files Changed: 3 implementation, 2 test
Key Findings
- GitHub metadata/comments/reviews were unavailable because
ghis unauthenticated; local branchpr-review-35944has squashed PR commit4c4f58aa7eon base5535b43c. - PR implementation changes Android Essentials
ActivityForResultRequest,ActivityStateManager, andMediaPickerto register and launch photo-picker contracts perComponentActivity. - Regression coverage is Android UI test
Issue35826in categoryEssentials; prior gate says without-fix failed and with-fix passed, so gate was not re-run. - Existing PR fix approach:
ConditionalWeakTable<ComponentActivity, ActivityResultLauncher>plus per-activity pending TCS, explicitLaunch(ComponentActivity, input), and finish-time cancellation.
Code Review Summary
Verdict: NEEDS_CHANGES
Confidence: low
Errors: 1 | Warnings: 0 | Suggestions: 0
Key code review findings:
- ✗
ActivityForResultRequest.android.cs:86/:149: pending requests are keyed by the launching activity instance, but AndroidX may deliver a result to the recreated activity callback after configuration change. BecauseOnActivityDestroyedintentionally does not cancel non-finishing destroys, rotation during picker can leave the original task pending.
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| PR | PR #35944 | Per-activity launcher and pending-request maps keyed by ComponentActivity, with explicit launching activity passed from MediaPicker. |
✅ PASSED (Gate) | MediaPicker.android.cs, ActivityForResultRequest.android.cs, ActivityStateManager.android.cs |
Original PR; code review found a configuration-change gap. |
🔬 Code Review — Deep Analysis
Code Review — PR #35944
Independent Assessment
What this changes: Reworks Android photo-picker ActivityResultLauncher storage from one global launcher/TCS to per-ComponentActivity launchers and pending requests, updates MediaPicker to launch against the current activity, cancels pending picker requests when an activity is finishing, and adds an Android UI regression test for child activities.
Inferred motivation: Fix PickPhotosAsync hanging when invoked from a child ComponentActivity because the existing registration guard kept only the main activity’s launcher.
Reconciliation with PR Narrative
Author claims: PR metadata was unavailable because gh is unauthenticated. Local code/test comments claim this fixes child-activity photo picker hangs on Android API 36+.
Agreement/disagreement: The child-activity motivation matches the code. However, the implementation introduces an unresolved configuration-change result-routing hole.
Prior Review Reconciliation
| Prior ❌ Error Finding | Source | Status | Evidence |
|---|---|---|---|
| Prior review data unavailable | gh reviews / inline comments / issue comments |
Unknown | All GitHub CLI metadata calls failed with gh auth login requirement. |
Blast Radius Assessment
- Runs for all instances: Partly. Registration runs for every Android
ComponentActivitypassed throughPlatform.Initwhen photo picker is available; launch behavior affects MediaPicker photo/video picker calls. - Startup impact: Yes, launcher registration happens during activity initialization.
- Static/shared state: Yes, singleton picker request objects now hold per-activity activity-result state.
CI Status
- Required-check result: unavailable
- Classification: undetermined
- Action taken: GitHub CLI unavailable; continued local-only per instruction and capped overall confidence.
Findings
❌ Error — Photo picker can still hang across configuration changes
src/Essentials/src/Platform/ActivityForResultRequest.android.cs:86
Pending requests are keyed by the specific ComponentActivity instance used at launch (_pendingRequests.Add(launchingActivity, tcs) at line 149), and the result callback resolves using the activity instance captured during Register (line 86).
Failure scenario:
PickPhotosAsync()launches from Activity A.- Device rotates while Android’s photo picker is open.
- Activity A is destroyed with
IsFinishing == false, soActivityStateManager.android.cs:217intentionally does not cancel the pending request. - Activity B is recreated and registers a new launcher/callback.
- AndroidX activity-result delivery after recreation is delivered to the newly registered callback.
- That callback looks up
_pendingRequestsusing Activity B, but the pending TCS is stored under Activity A, so the result is dropped and the original task remains pending.
This contradicts the new comments claiming rotation/configuration changes are preserved and reintroduces a picker hang.
Failure-Mode Probing
- Child activity without rotation: likely fixed because each child activity gets its own launcher and pending request.
- Activity destroyed by Back/Finish: pending request is canceled when
IsFinishingis true. - Rotation/config change during picker: unresolved; pending request remains under the old activity while result delivery can occur through the recreated activity.
- Concurrent requests from different activities: improved versus prior global TCS, assuming no recreation.
Verdict: NEEDS_CHANGES
Confidence: low overall due unavailable CI/prior-review metadata; the code finding itself is high-confidence.
Summary: The PR addresses the child-activity launcher issue but leaves a concrete Android lifecycle hang during configuration changes. The pending request must be keyed/transferred across recreation or canceled instead of left pending.
🛠️ Fix — Analysis & Comparison
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| 1 | try-fix | Activity-scoped launcher list with one shared pending TCS per request type. | ❌ Rejected after review; build PASS, UI runner PASS/SKIPPED on API 30 | 1 file | Expert found cross-activity TCS clobbering, missing finish cancellation, stale retention. |
| 2 | try-fix | Per-registration launcher/TCS with same-type config-change pending transfer and finish cancellation. | ✅ Available tests PASS; API 36 body not exercised locally | 2 files | Final expert review found no high-confidence blockers; better by lifecycle reasoning than PR fix for rotation. |
| PR | PR #35944 | ConditionalWeakTable per-activity launcher/TCS maps, explicit Launch(ComponentActivity, input), finish cancellation. |
✅ PASSED (Gate) | 3 files | Original PR; pre-flight code review found config-change result-routing risk. |
Cross-Pollination
| Model | Round | New Ideas? | Details |
|---|---|---|---|
| maui-expert-reviewer | 1 | Yes | Suggested activity-scoped registration objects, direct ActivityResultRegistry lifecycle registration, and proxy/intermediate activity ownership. |
| maui-expert-reviewer | 2 | Yes | Rejected try-fix-1 shared-TCS approach; recommended preserving per-activity pending ownership and finish cancellation. |
| maui-expert-reviewer | 3 | Yes | Rejected initial try-fix-2 retention/transfer gaps; revised to remove transferred registrations and scope transfer to same activity type. |
| maui-expert-reviewer | 4 | No | Final review found no high-confidence blockers in revised try-fix-2. Remaining alternatives (raw registry/proxy activity) are more invasive and not clearly better. |
Exhausted: Yes — meaningfully different lower-risk approaches were explored; remaining alternatives are materially more invasive and not justified without API 36 empirical evidence.
Selected Fix: Candidate #2 — structurally better than the PR fix for configuration-change result delivery while preserving per-activity pending ownership. Selection is conditional because local Android emulator was API 30, so the API 36+ regression body was skipped.
📝 Recommended PR Title & Description
Assessment: ✏️ Recommend updating — the current title is good, but the description specifically describes the raw ConditionalWeakTable<ComponentActivity, ActivityResultLauncher> implementation and would be stale if the winning pr-plus-reviewer lifecycle fix is adopted.
Recommended title
[Android] MediaPicker: Fix photo picker completion from child activities
Recommended description
### Issue Details:
MediaPicker - PickPhotosAsync doesn't return on Android when using multiple Activities.
### Root Cause:
Commit [0c25c62](https://github.com/dotnet/maui/commit/0c25c624659182fb3e14ef4693a3c5c3ae43cc08) introduced a guard in ActivityForResultRequest.Register() that checked whether an existing ActivityResultLauncher was already registered for a still-alive activity. If one existed, the method returned early without registering a new launcher. This guard was designed to prevent duplicate registrations (fixing issue #32845), but it had an unintended side effect: when a child AppCompatActivity called Platform.Init(), the guard saw the main activity's launcher as still alive and silently skipped registration entirely. As a result, the child activity had no ActivityResultLauncher of its own. On Android API 36, launcher ownership is strictly enforced — the system only delivers an ActivityResult to the launcher that belongs to the activity that launched the intent. Since the child activity had no launcher, PickPhotosAsync() launched the photo picker but its TaskCompletionSource was never resolved, causing the await to hang indefinitely.
### Description of Change:
The fix replaces the single global launcher slot and its guard with activity-scoped ActivityResult registrations. Each ComponentActivity that calls Platform.Init() gets its own ActivityResultLauncher, so the main activity and child AppCompatActivity instances cannot overwrite or block each other's launcher registration. Each registration owns its own pending TaskCompletionSource, preventing concurrent picker requests from different activities from clobbering one another.
The lifecycle handling also distinguishes finishing destroys from configuration-change destroys. Pending picker requests are canceled when the owning activity is truly finishing, but a pending request from a destroyed non-finishing activity can be transferred to the recreated same-type activity during registration. This avoids leaving a pending request keyed to a transient old activity instance when AndroidX delivers the result to the recreated activity's callback after rotation/configuration change.
MediaPicker's Android photo-picker paths continue to launch through the ActivityResult APIs for both single and multiple photo/video selection, and ActivityStateManager registers the picker launchers for each ComponentActivity during Platform.Init().
**Tested the behavior in the following platforms:**
- [x] Android
- [ ] Windows
- [ ] iOS
- [ ] Mac
### Reference:
N/A
### Issues Fixed:
Fixes #35826
### Screenshots
| Before | After |
|---------|--------|
| <Video src="https://github.com/user-attachments/assets/79983dcf-bc04-4536-af48-2b1ff2215611" Width="600" Height="300"/> | <Video src="https://github.com/user-attachments/assets/8775de80-9200-496d-ac8d-33d049502425" Width="600" Height="300"/> |
🏁 Report — Final Recommendation
Comparative Analysis — PR #35944
Candidate ranking
| Rank | Candidate | Regression result | Assessment |
|---|---|---|---|
| 1 | pr-plus-reviewer |
Not independently re-run; based on PR gate plus reviewer-applied sandbox design | Best overall. Preserves the PR's per-activity launcher ownership and applies the expert reviewer's lifecycle fix by transferring pending state across same-type configuration-change recreation. This removes the raw PR's concrete rotation hang while retaining child-activity behavior. |
| 2 | try-fix-2 |
✅ Build PASS; UI runner PASS/SKIPPED on API 30; expert found no high-confidence blockers | Strongest standalone try-fix. It uses per-registration launcher/TCS ownership, same-type config-change transfer, and finish cancellation. It is effectively the same lifecycle model as pr-plus-reviewer, but was not the PR-derived candidate. |
| 3 | pr |
✅ Gate PASSED for Issue35826 child-activity scenario | Fixes the reported no-rotation child-activity hang, but expert review found a critical configuration-change hole: pending requests remain keyed to the old activity instance while AndroidX may deliver results to the recreated activity's callback. |
| 4 | try-fix-1 |
❌ Rejected after review; build PASS, UI runner PASS/SKIPPED on API 30 | Must rank below passing candidates. Its single shared pending TCS can be clobbered by concurrent same-request launches from different activities, callbacks can complete the wrong activity's task, and finishing activity cleanup is incomplete. |
Key comparison points
Child activity launcher ownership: pr, pr-plus-reviewer, and try-fix-2 all solve the root child-activity issue by ensuring a child ComponentActivity has a launcher of its own. try-fix-1 has launchers per activity, but shares pending state globally and is unsafe under overlap.
Configuration changes: The raw pr candidate fails this lifecycle probe because pending requests are stored under the original activity instance and are not transferred. pr-plus-reviewer and try-fix-2 handle the scenario by moving pending state from a destroyed non-finishing predecessor to the recreated same-type activity. try-fix-1 does not safely preserve ownership.
Cancellation semantics: pr, pr-plus-reviewer, and try-fix-2 cancel pending work when the activity is finishing. pr-plus-reviewer/try-fix-2 additionally avoid leaving a non-finishing destroyed activity as the only owner of an unresolved TCS.
Test evidence: The gate proves the submitted PR catches and fixes Issue35826's child-activity hang. try-fix-2 passed available build/UI-runner checks, but the local runner skipped the API 36 body. No candidate has an empirical rotation-during-picker regression run; the ranking therefore relies on lifecycle correctness for that edge.
Winner
Winner: pr-plus-reviewer
pr-plus-reviewer is the best candidate because it preserves the PR's gate-proven child-activity fix while applying the expert reviewer's actionable lifecycle correction. It is structurally equivalent to the successful revised try-fix-2 model for configuration changes and avoids the raw PR's activity-instance-keyed pending request hang.
🧭 Next Steps — review latest findings
No alternative fix was selected for this run. Review the session findings and CI results before merging.
…ies (#35944) <!-- Please let the below note in for people that find this PR --> > [!NOTE] > Are you waiting for the changes in this PR to be merged? > It would be very helpful if you could [test the resulting artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! ### Issue Details: MediaPicker - PickPhotosAsync doesn't return on Android when using multiple Activities ### Root Cause: Commit [0c25c62](0c25c62) introduced a guard in ActivityForResultRequest.Register() that checked whether an existing ActivityResultLauncher was already registered for a still-alive activity. If one existed, the method returned early without registering a new launcher. This guard was designed to prevent duplicate registrations (fixing issue #32845), but it had an unintended side effect: when a child AppCompatActivity called Platform.Init(), the guard saw the main activity's launcher as still alive and silently skipped registration entirely. As a result, the child activity had no ActivityResultLauncher of its own. On Android API 36, launcher ownership is strictly enforced — the system only delivers an ActivityResult to the launcher that belongs to the activity that launched the intent. Since the child activity had no launcher, PickPhotosAsync() launched the photo picker but its TaskCompletionSource was never resolved, causing the await to hang indefinitely. ### Description of Change: The fix replaces the single global launcher slot and its guard with a ConditionalWeakTable<ComponentActivity, ActivityResultLauncher>. This table stores one launcher entry per activity instance. Because ConditionalWeakTable uses weak keys, entries are automatically eligible for garbage collection when the activity is destroyed, preventing memory leaks. The Register() method now checks whether the specific calling activity already has an entry (idempotent per-instance), and if not, creates and stores a new launcher for it. At call time, GetLauncherForCurrentActivity() resolves the current foreground activity and retrieves its entry from the table. This ensures every activity — whether the main activity or any child — gets its own launcher, fixing the hang while also preserving the original #32845 fix since no activity can overwrite another's entry. **Tested the behavior in the following platforms:** - [x] Android - [ ] Windows - [ ] iOS - [ ] Mac ### Reference: N/A ### Issues Fixed: Fixes #35826 ### Screenshots | Before | After | |---------|--------| | <Video src="https://github.com/user-attachments/assets/79983dcf-bc04-4536-af48-2b1ff2215611" Width="600" Height="300"/> | <Video src="https://github.com/user-attachments/assets/8775de80-9200-496d-ac8d-33d049502425" Width="600" Height="300"/> | --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ies (#35944) <!-- Please let the below note in for people that find this PR --> > [!NOTE] > Are you waiting for the changes in this PR to be merged? > It would be very helpful if you could [test the resulting artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! ### Issue Details: MediaPicker - PickPhotosAsync doesn't return on Android when using multiple Activities ### Root Cause: Commit [0c25c62](0c25c62) introduced a guard in ActivityForResultRequest.Register() that checked whether an existing ActivityResultLauncher was already registered for a still-alive activity. If one existed, the method returned early without registering a new launcher. This guard was designed to prevent duplicate registrations (fixing issue #32845), but it had an unintended side effect: when a child AppCompatActivity called Platform.Init(), the guard saw the main activity's launcher as still alive and silently skipped registration entirely. As a result, the child activity had no ActivityResultLauncher of its own. On Android API 36, launcher ownership is strictly enforced — the system only delivers an ActivityResult to the launcher that belongs to the activity that launched the intent. Since the child activity had no launcher, PickPhotosAsync() launched the photo picker but its TaskCompletionSource was never resolved, causing the await to hang indefinitely. ### Description of Change: The fix replaces the single global launcher slot and its guard with a ConditionalWeakTable<ComponentActivity, ActivityResultLauncher>. This table stores one launcher entry per activity instance. Because ConditionalWeakTable uses weak keys, entries are automatically eligible for garbage collection when the activity is destroyed, preventing memory leaks. The Register() method now checks whether the specific calling activity already has an entry (idempotent per-instance), and if not, creates and stores a new launcher for it. At call time, GetLauncherForCurrentActivity() resolves the current foreground activity and retrieves its entry from the table. This ensures every activity — whether the main activity or any child — gets its own launcher, fixing the hang while also preserving the original #32845 fix since no activity can overwrite another's entry. **Tested the behavior in the following platforms:** - [x] Android - [ ] Windows - [ ] iOS - [ ] Mac ### Reference: N/A ### Issues Fixed: Fixes #35826 ### Screenshots | Before | After | |---------|--------| | <Video src="https://github.com/user-attachments/assets/79983dcf-bc04-4536-af48-2b1ff2215611" Width="600" Height="300"/> | <Video src="https://github.com/user-attachments/assets/8775de80-9200-496d-ac8d-33d049502425" Width="600" Height="300"/> | --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ies (#35944) <!-- Please let the below note in for people that find this PR --> > [!NOTE] > Are you waiting for the changes in this PR to be merged? > It would be very helpful if you could [test the resulting artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! ### Issue Details: MediaPicker - PickPhotosAsync doesn't return on Android when using multiple Activities ### Root Cause: Commit [0c25c62](0c25c62) introduced a guard in ActivityForResultRequest.Register() that checked whether an existing ActivityResultLauncher was already registered for a still-alive activity. If one existed, the method returned early without registering a new launcher. This guard was designed to prevent duplicate registrations (fixing issue #32845), but it had an unintended side effect: when a child AppCompatActivity called Platform.Init(), the guard saw the main activity's launcher as still alive and silently skipped registration entirely. As a result, the child activity had no ActivityResultLauncher of its own. On Android API 36, launcher ownership is strictly enforced — the system only delivers an ActivityResult to the launcher that belongs to the activity that launched the intent. Since the child activity had no launcher, PickPhotosAsync() launched the photo picker but its TaskCompletionSource was never resolved, causing the await to hang indefinitely. ### Description of Change: The fix replaces the single global launcher slot and its guard with a ConditionalWeakTable<ComponentActivity, ActivityResultLauncher>. This table stores one launcher entry per activity instance. Because ConditionalWeakTable uses weak keys, entries are automatically eligible for garbage collection when the activity is destroyed, preventing memory leaks. The Register() method now checks whether the specific calling activity already has an entry (idempotent per-instance), and if not, creates and stores a new launcher for it. At call time, GetLauncherForCurrentActivity() resolves the current foreground activity and retrieves its entry from the table. This ensures every activity — whether the main activity or any child — gets its own launcher, fixing the hang while also preserving the original #32845 fix since no activity can overwrite another's entry. **Tested the behavior in the following platforms:** - [x] Android - [ ] Windows - [ ] iOS - [ ] Mac ### Reference: N/A ### Issues Fixed: Fixes #35826 ### Screenshots | Before | After | |---------|--------| | <Video src="https://github.com/user-attachments/assets/79983dcf-bc04-4536-af48-2b1ff2215611" Width="600" Height="300"/> | <Video src="https://github.com/user-attachments/assets/8775de80-9200-496d-ac8d-33d049502425" Width="600" Height="300"/> | --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ies (#35944) <!-- Please let the below note in for people that find this PR --> > [!NOTE] > Are you waiting for the changes in this PR to be merged? > It would be very helpful if you could [test the resulting artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! ### Issue Details: MediaPicker - PickPhotosAsync doesn't return on Android when using multiple Activities ### Root Cause: Commit [0c25c62](0c25c62) introduced a guard in ActivityForResultRequest.Register() that checked whether an existing ActivityResultLauncher was already registered for a still-alive activity. If one existed, the method returned early without registering a new launcher. This guard was designed to prevent duplicate registrations (fixing issue #32845), but it had an unintended side effect: when a child AppCompatActivity called Platform.Init(), the guard saw the main activity's launcher as still alive and silently skipped registration entirely. As a result, the child activity had no ActivityResultLauncher of its own. On Android API 36, launcher ownership is strictly enforced — the system only delivers an ActivityResult to the launcher that belongs to the activity that launched the intent. Since the child activity had no launcher, PickPhotosAsync() launched the photo picker but its TaskCompletionSource was never resolved, causing the await to hang indefinitely. ### Description of Change: The fix replaces the single global launcher slot and its guard with a ConditionalWeakTable<ComponentActivity, ActivityResultLauncher>. This table stores one launcher entry per activity instance. Because ConditionalWeakTable uses weak keys, entries are automatically eligible for garbage collection when the activity is destroyed, preventing memory leaks. The Register() method now checks whether the specific calling activity already has an entry (idempotent per-instance), and if not, creates and stores a new launcher for it. At call time, GetLauncherForCurrentActivity() resolves the current foreground activity and retrieves its entry from the table. This ensures every activity — whether the main activity or any child — gets its own launcher, fixing the hang while also preserving the original #32845 fix since no activity can overwrite another's entry. **Tested the behavior in the following platforms:** - [x] Android - [ ] Windows - [ ] iOS - [ ] Mac ### Reference: N/A ### Issues Fixed: Fixes #35826 ### Screenshots | Before | After | |---------|--------| | <Video src="https://github.com/user-attachments/assets/79983dcf-bc04-4536-af48-2b1ff2215611" Width="600" Height="300"/> | <Video src="https://github.com/user-attachments/assets/8775de80-9200-496d-ac8d-33d049502425" Width="600" Height="300"/> | --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ies (#35944) <!-- Please let the below note in for people that find this PR --> > [!NOTE] > Are you waiting for the changes in this PR to be merged? > It would be very helpful if you could [test the resulting artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! ### Issue Details: MediaPicker - PickPhotosAsync doesn't return on Android when using multiple Activities ### Root Cause: Commit [0c25c62](0c25c62) introduced a guard in ActivityForResultRequest.Register() that checked whether an existing ActivityResultLauncher was already registered for a still-alive activity. If one existed, the method returned early without registering a new launcher. This guard was designed to prevent duplicate registrations (fixing issue #32845), but it had an unintended side effect: when a child AppCompatActivity called Platform.Init(), the guard saw the main activity's launcher as still alive and silently skipped registration entirely. As a result, the child activity had no ActivityResultLauncher of its own. On Android API 36, launcher ownership is strictly enforced — the system only delivers an ActivityResult to the launcher that belongs to the activity that launched the intent. Since the child activity had no launcher, PickPhotosAsync() launched the photo picker but its TaskCompletionSource was never resolved, causing the await to hang indefinitely. ### Description of Change: The fix replaces the single global launcher slot and its guard with a ConditionalWeakTable<ComponentActivity, ActivityResultLauncher>. This table stores one launcher entry per activity instance. Because ConditionalWeakTable uses weak keys, entries are automatically eligible for garbage collection when the activity is destroyed, preventing memory leaks. The Register() method now checks whether the specific calling activity already has an entry (idempotent per-instance), and if not, creates and stores a new launcher for it. At call time, GetLauncherForCurrentActivity() resolves the current foreground activity and retrieves its entry from the table. This ensures every activity — whether the main activity or any child — gets its own launcher, fixing the hang while also preserving the original #32845 fix since no activity can overwrite another's entry. **Tested the behavior in the following platforms:** - [x] Android - [ ] Windows - [ ] iOS - [ ] Mac ### Reference: N/A ### Issues Fixed: Fixes #35826 ### Screenshots | Before | After | |---------|--------| | <Video src="https://github.com/user-attachments/assets/79983dcf-bc04-4536-af48-2b1ff2215611" Width="600" Height="300"/> | <Video src="https://github.com/user-attachments/assets/8775de80-9200-496d-ac8d-33d049502425" Width="600" Height="300"/> | --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…created while Photo Picker is open (#36767) <!-- Please let the below note in for people that find this PR --> > [!NOTE] > Are you waiting for the changes in this PR to be merged? > It would be very helpful if you could [test the resulting artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! <!-- !!!!!!! MAIN IS THE ONLY ACTIVE BRANCH. MAKE SURE THIS PR IS TARGETING MAIN. !!!!!!! --> ### Issue Details: - MediaPicker.PickPhotosAsync() hangs forever when the device is rotated while the photo picker is open. ### Root Cause of the issue **Regression:** - Introduced by PR #35944, which changed ActivityForResultRequest from a single shared TaskCompletionSource to per-activity storage using ConditionalWeakTable<ComponentActivity, TCS> . Reproduced on API 33 and 36, not on API 30 (different code path). - After rotation, Android destroys Activity-1 and creates Activity-2. The TCS is stored under Activity-1's key, but Activity-2's callback looks up Activity-2's key → miss → result silently dropped → task hangs permanently. Additionally, ConditionalWeakTable uses weak keys, so the GC can silently collect Activity-1's entry (including the TCS) with no error. ### Description of Change **Bug fix for activity recreation and pending requests:** * Added a strong reference (`_inFlightActivity`) to the launching `ComponentActivity` within `ActivityForResultRequest` to prevent garbage collection and ensure that pending requests survive configuration changes such as device rotation. * Implemented `MigratePendingRequests`, which transfers any pending `TaskCompletionSource` from the old activity to the new one during a configuration change, preventing hangs when the activity is recreated. This migration is triggered in `Register` and ensures the result callback can still complete the task. * Updated code paths in `Launch` and the activity result callback to clear `_inFlightActivity` when the request completes, is canceled, or fails, ensuring proper cleanup and preventing memory leaks. **Test coverage:** * Added a new shared test case and UI test (`Issue36523`) that reproduces the rotation scenario and verifies that `PickPhotosAsync` completes (rather than hanging) after device rotation and picker cancellation. ### Issues Fixed <!-- Please make sure that there is a bug logged for the issue being fixed. The bug should describe the problem and how to reproduce it. --> Fixes #36523 ### Tested the behavior in the following platforms - [ ] Windows - [x] Android - [ ] iOS - [ ] Mac ### Output | Before | After | |----------|----------| | <img src="https://github.com/user-attachments/assets/b054d308-c65e-4cb0-ba84-0a06cf5268e7"> | <img src="https://github.com/user-attachments/assets/c819e6e5-3b14-4a6d-b7e1-b5230c694d71"> | <!-- Are you targeting main? All PRs should target the main branch unless otherwise noted. -->
…ies (#35944) <!-- Please let the below note in for people that find this PR --> > [!NOTE] > Are you waiting for the changes in this PR to be merged? > It would be very helpful if you could [test the resulting artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! ### Issue Details: MediaPicker - PickPhotosAsync doesn't return on Android when using multiple Activities ### Root Cause: Commit [0c25c62](0c25c62) introduced a guard in ActivityForResultRequest.Register() that checked whether an existing ActivityResultLauncher was already registered for a still-alive activity. If one existed, the method returned early without registering a new launcher. This guard was designed to prevent duplicate registrations (fixing issue #32845), but it had an unintended side effect: when a child AppCompatActivity called Platform.Init(), the guard saw the main activity's launcher as still alive and silently skipped registration entirely. As a result, the child activity had no ActivityResultLauncher of its own. On Android API 36, launcher ownership is strictly enforced — the system only delivers an ActivityResult to the launcher that belongs to the activity that launched the intent. Since the child activity had no launcher, PickPhotosAsync() launched the photo picker but its TaskCompletionSource was never resolved, causing the await to hang indefinitely. ### Description of Change: The fix replaces the single global launcher slot and its guard with a ConditionalWeakTable<ComponentActivity, ActivityResultLauncher>. This table stores one launcher entry per activity instance. Because ConditionalWeakTable uses weak keys, entries are automatically eligible for garbage collection when the activity is destroyed, preventing memory leaks. The Register() method now checks whether the specific calling activity already has an entry (idempotent per-instance), and if not, creates and stores a new launcher for it. At call time, GetLauncherForCurrentActivity() resolves the current foreground activity and retrieves its entry from the table. This ensures every activity — whether the main activity or any child — gets its own launcher, fixing the hang while also preserving the original #32845 fix since no activity can overwrite another's entry. **Tested the behavior in the following platforms:** - [x] Android - [ ] Windows - [ ] iOS - [ ] Mac ### Reference: N/A ### Issues Fixed: Fixes #35826 ### Screenshots | Before | After | |---------|--------| | <Video src="https://github.com/user-attachments/assets/79983dcf-bc04-4536-af48-2b1ff2215611" Width="600" Height="300"/> | <Video src="https://github.com/user-attachments/assets/8775de80-9200-496d-ac8d-33d049502425" Width="600" Height="300"/> | --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…created while Photo Picker is open (#36767) <!-- Please let the below note in for people that find this PR --> > [!NOTE] > Are you waiting for the changes in this PR to be merged? > It would be very helpful if you could [test the resulting artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! <!-- !!!!!!! MAIN IS THE ONLY ACTIVE BRANCH. MAKE SURE THIS PR IS TARGETING MAIN. !!!!!!! --> ### Issue Details: - MediaPicker.PickPhotosAsync() hangs forever when the device is rotated while the photo picker is open. ### Root Cause of the issue **Regression:** - Introduced by PR #35944, which changed ActivityForResultRequest from a single shared TaskCompletionSource to per-activity storage using ConditionalWeakTable<ComponentActivity, TCS> . Reproduced on API 33 and 36, not on API 30 (different code path). - After rotation, Android destroys Activity-1 and creates Activity-2. The TCS is stored under Activity-1's key, but Activity-2's callback looks up Activity-2's key → miss → result silently dropped → task hangs permanently. Additionally, ConditionalWeakTable uses weak keys, so the GC can silently collect Activity-1's entry (including the TCS) with no error. ### Description of Change **Bug fix for activity recreation and pending requests:** * Added a strong reference (`_inFlightActivity`) to the launching `ComponentActivity` within `ActivityForResultRequest` to prevent garbage collection and ensure that pending requests survive configuration changes such as device rotation. * Implemented `MigratePendingRequests`, which transfers any pending `TaskCompletionSource` from the old activity to the new one during a configuration change, preventing hangs when the activity is recreated. This migration is triggered in `Register` and ensures the result callback can still complete the task. * Updated code paths in `Launch` and the activity result callback to clear `_inFlightActivity` when the request completes, is canceled, or fails, ensuring proper cleanup and preventing memory leaks. **Test coverage:** * Added a new shared test case and UI test (`Issue36523`) that reproduces the rotation scenario and verifies that `PickPhotosAsync` completes (rather than hanging) after device rotation and picker cancellation. ### Issues Fixed <!-- Please make sure that there is a bug logged for the issue being fixed. The bug should describe the problem and how to reproduce it. --> Fixes #36523 ### Tested the behavior in the following platforms - [ ] Windows - [x] Android - [ ] iOS - [ ] Mac ### Output | Before | After | |----------|----------| | <img src="https://github.com/user-attachments/assets/b054d308-c65e-4cb0-ba84-0a06cf5268e7"> | <img src="https://github.com/user-attachments/assets/c819e6e5-3b14-4a6d-b7e1-b5230c694d71"> | <!-- Are you targeting main? All PRs should target the main branch unless otherwise noted. -->
Note
Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!
Issue Details:
MediaPicker - PickPhotosAsync doesn't return on Android when using multiple Activities
Root Cause:
Commit 0c25c62 introduced a guard in ActivityForResultRequest.Register() that checked whether an existing ActivityResultLauncher was already registered for a still-alive activity. If one existed, the method returned early without registering a new launcher. This guard was designed to prevent duplicate registrations (fixing issue #32845), but it had an unintended side effect: when a child AppCompatActivity called Platform.Init(), the guard saw the main activity's launcher as still alive and silently skipped registration entirely. As a result, the child activity had no ActivityResultLauncher of its own. On Android API 36, launcher ownership is strictly enforced — the system only delivers an ActivityResult to the launcher that belongs to the activity that launched the intent. Since the child activity had no launcher, PickPhotosAsync() launched the photo picker but its TaskCompletionSource was never resolved, causing the await to hang indefinitely.
Description of Change:
The fix replaces the single global launcher slot and its guard with a ConditionalWeakTable<ComponentActivity, ActivityResultLauncher>. This table stores one launcher entry per activity instance. Because ConditionalWeakTable uses weak keys, entries are automatically eligible for garbage collection when the activity is destroyed, preventing memory leaks. The Register() method now checks whether the specific calling activity already has an entry (idempotent per-instance), and if not, creates and stores a new launcher for it. At call time, GetLauncherForCurrentActivity() resolves the current foreground activity and retrieves its entry from the table. This ensures every activity — whether the main activity or any child — gets its own launcher, fixing the hang while also preserving the original #32845 fix since no activity can overwrite another's entry.
Tested the behavior in the following platforms:
Reference:
N/A
Issues Fixed:
Fixes #35826
Screenshots
Before_35826.mov
After_35826.mov