Skip to content

Fix Android MediaPicker result recovery#35455

Open
AdamEssenmacher wants to merge 17 commits into
dotnet:net11.0from
AdamEssenmacher:35308
Open

Fix Android MediaPicker result recovery#35455
AdamEssenmacher wants to merge 17 commits into
dotnet:net11.0from
AdamEssenmacher:35308

Conversation

@AdamEssenmacher
Copy link
Copy Markdown

@AdamEssenmacher AdamEssenmacher commented May 15, 2026

Description of Change

Android can destroy or recreate an app process while another activity is in front. This is especially relevant for camera flows: the Android Activity Result documentation explicitly calls out memory-intensive operations such as camera usage as cases where the launching process/activity may be destroyed, and says result callbacks must be registered unconditionally when the activity is recreated:

https://developer.android.com/training/basics/intents/result

When this happens today, MAUI’s original MediaPicker task is gone. The user can successfully finish the system camera or picker UI, but the app has no reliable way to receive the result. On affected device/app configurations, this effectively makes photo and/or video capture unusable through MediaPicker.

This change adds Android-only recovery support for AndroidX-backed MediaPicker activity results. MAUI now registers the relevant AndroidX activity-result launchers early, persists the active MediaPicker operation before launch, durably records accepted AndroidX callback results, and exposes an opt-in recovery surface so apps can retrieve results after process/activity recreation.

The recovery surface is additive and Android-only:

  • MediaPicker.GetRecoveredMediaPickerResultsAsync()
  • MediaPicker.WaitForRecoveredMediaPickerResultsAsync(CancellationToken)
  • MediaPicker.ClearRecoveredMediaPickerResultAsync(string id)

Recovery covers AndroidX-backed MediaPicker flows across the board:

  • photo capture
  • video capture
  • single photo/video pick
  • multiple photo/video pick

Picker URI handling follows Android’s photo picker guidance around persisted media access:

https://developer.android.com/training/data-storage/shared/photo-picker#persist-media-file-access

Normal live-process behavior is unchanged: existing MediaPicker and IMediaPicker methods still complete normally when the app survives, and no duplicate recovered result is queued.

This also adds Android device-test coverage for the recovery state machine, callback routing, cancellation/wait behavior, duplicate prevention, picker URI materialization, and capture photo processing safety. I also smoke-tested the happy path manually on an API 36 AVD for photo capture, video capture, photo pick, video pick, and multi-photo pick.

Issues Fixed

Fixes #35308

@dotnet-policy-service dotnet-policy-service Bot added the community ✨ Community Contribution label May 15, 2026
@dotnet-policy-service
Copy link
Copy Markdown
Contributor

Hey there @@AdamEssenmacher! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 15, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 35455

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 35455"

@github-actions github-actions Bot added the area-essentials Essentials: Device, Display, Connectivity, Secure Storage, Sensors, App Info label May 15, 2026
@jfversluis
Copy link
Copy Markdown
Member

/azp run

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 3 pipeline(s).

@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented May 19, 2026

/review -b feature/regression-check -p android

@MauiBot MauiBot added s/agent-review-incomplete s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review) labels May 19, 2026
@dotnet dotnet deleted a comment from MauiBot May 19, 2026
@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented May 19, 2026

/review -b feature/regression-check -p android

1 similar comment
@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented May 19, 2026

/review -b feature/regression-check -p android

Copy link
Copy Markdown
Collaborator

@MauiBot MauiBot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expert Review — 10 findings

See inline comments for details.

Comment thread src/Essentials/src/Platform/PickVisualMediaForResult.android.cs Outdated
Comment thread src/Essentials/src/Platform/PickMultipleVisualMediaForResult.android.cs Outdated
Comment thread src/Essentials/src/Platform/ActivityForResultRequest.android.cs
Comment thread src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs Outdated
Comment thread src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs Outdated
Comment thread src/Essentials/src/MediaPicker/MediaPickerRecovery.android.cs
Copy link
Copy Markdown
Collaborator

@MauiBot MauiBot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated review — alternative fix proposed

The expert-reviewer evaluation compared the PR fix against #1 automatically generated candidates and selected try-fix-1 as the strongest fix.

Why: try-fix-1 is the smallest correct fix: it ships the PR's Java pruning (the actual .NET 8->10.0.60 regression root cause) plus the AndroidX ActivityResultLauncher switch, while deleting the 3265-line speculative cross-process recovery layer (state machine, hand-rolled v1-v5 serializer, 3-method public API, 50+ device tests). It resolves all 10 reviewer findings (most as N/A), eliminates the gate's CS0260/CS0115 build errors and the failing MediaPickerRecovery_Tests, and ships zero new public API. The PR (and pr-plus-reviewer) failed the gate; try-fix candidates were predicted-pass (no Helix infra in this session, disclosed in the report).

Please consider applying the candidate diff below (or use it as guidance). Once you push an update, this workflow will re-trigger and re-evaluate.

Candidate diff (`try-fix-1`)
diff --git a/src/Core/AndroidNative/maui/src/main/java/com/microsoft/maui/PlatformMauiAppCompatActivity.java b/src/Core/AndroidNative/maui/src/main/java/com/microsoft/maui/PlatformMauiAppCompatActivity.java
index 3b2b9d5551..72dd2e65fa 100644
--- a/src/Core/AndroidNative/maui/src/main/java/com/microsoft/maui/PlatformMauiAppCompatActivity.java
+++ b/src/Core/AndroidNative/maui/src/main/java/com/microsoft/maui/PlatformMauiAppCompatActivity.java
@@ -1,7 +1,5 @@
 package com.microsoft.maui;
 
-import android.content.Context;
-import android.content.res.Resources;
 import android.content.res.TypedArray;
 
 import androidx.appcompat.app.AppCompatActivity;
@@ -12,11 +10,28 @@ import android.os.Bundle;
  * Class for batching native method calls within the MauiAppCompatActivity implementation
  */
 public class PlatformMauiAppCompatActivity {
+    // These are Android framework / AndroidX saved-instance-state keys. MAUI does not create
+    // the bundles stored under these keys; it only removes or preserves them before AppCompat
+    // restores saved state. AndroidX does not expose public constants for these values.
+    //
+    // ComponentActivity saves pending ActivityResultRegistry state here. Preserving this bundle
+    // lets AndroidX replay pending activity results after activity or process recreation.
+    private static final String ACTIVITY_RESULT_REGISTRY_KEY = "android:support:activity-result";
+
+    // Framework FragmentManager and AndroidX FragmentManager saved-state keys. MAUI removes these
+    // when fragment restore is disabled because restoring old platform fragments can conflict with
+    // MAUI's own navigation/window reconstruction.
+    private static final String ANDROID_FRAGMENTS_KEY = "android:fragments";
+    private static final String SUPPORT_FRAGMENTS_KEY = "android:support:fragments";
+
+    // SavedStateRegistry's top-level bundle key. Older MAUI behavior removed this whole bundle to
+    // suppress fragment restore side effects, but that also discarded ActivityResultRegistry state.
+    private static final String SAVED_STATE_REGISTRY_KEY = "androidx.lifecycle.BundlableSavedStateRegistry.key";
+
     public static void onCreate(AppCompatActivity activity, Bundle savedInstanceState, boolean allowFragmentRestore, int splashAttr, int mauiTheme)
     {
         if (!allowFragmentRestore && savedInstanceState != null) {
-            savedInstanceState.remove("android:support:fragments");
-            savedInstanceState.remove("androidx.lifecycle.BundlableSavedStateRegistry.key");
+            removeFragmentRestoreState(savedInstanceState);
         }
 
         boolean mauiSplashAttrValue = false;
@@ -33,4 +48,30 @@ public class PlatformMauiAppCompatActivity {
             activity.setTheme(mauiTheme);
         }
     }
+
+    private static void removeFragmentRestoreState(Bundle savedInstanceState)
+    {
+        // First remove the direct fragment entries that may be present in the activity state.
+        savedInstanceState.remove(ANDROID_FRAGMENTS_KEY);
+        savedInstanceState.remove(SUPPORT_FRAGMENTS_KEY);
+
+        Bundle savedStateRegistry = savedInstanceState.getBundle(SAVED_STATE_REGISTRY_KEY);
+        if (savedStateRegistry != null) {
+            // The saved-state registry is a shared AndroidX container. Extract the activity-result
+            // entry before removing the container so pending activity results are not lost with the
+            // fragment-related providers.
+            Bundle activityResultRegistryState = savedStateRegistry.getBundle(ACTIVITY_RESULT_REGISTRY_KEY);
+
+            savedInstanceState.remove(SAVED_STATE_REGISTRY_KEY);
+
+            if (activityResultRegistryState != null) {
+                // Keep only the AndroidX ActivityResultRegistry state needed to replay pending
+                // results after activity/process recreation. Other saved-state providers may
+                // contain fragment state that MAUI cannot safely restore.
+                Bundle prunedSavedStateRegistry = new Bundle();
+                prunedSavedStateRegistry.putBundle(ACTIVITY_RESULT_REGISTRY_KEY, activityResultRegistryState);
+                savedInstanceState.putBundle(SAVED_STATE_REGISTRY_KEY, prunedSavedStateRegistry);
+            }
+        }
+    }
 }

diff --git a/CustomAgentLogsTmp/PRState/35455/PRAgent/try-fix-1/csharp-sketch.md b/CustomAgentLogsTmp/PRState/35455/PRAgent/try-fix-1/csharp-sketch.md
new file mode 100644
index 0000000..1111111
--- /dev/null
+++ b/CustomAgentLogsTmp/PRState/35455/PRAgent/try-fix-1/csharp-sketch.md
@@ -0,0 +1,30 @@
+# try-fix-1 C# sketch (companion to Java diff)
+
+The Java hunk above IS the regression fix. The C# delta required to use AndroidX
+launchers (so AndroidX has a registered launcher to replay results into) is:
+
+1. Add three small files (no recovery coupling):
+   * `src/Essentials/src/Platform/CapturePhotoForResult.android.cs` -- thin
+     `ActivityForResultRequest<TakePicture, JavaBoolean>` singleton, registered
+     in `ActivityStateManager` at activity-create time.
+   * `src/Essentials/src/Platform/CaptureVideoForResult.android.cs` -- same
+     pattern for `CaptureVideo` contract.
+   * `src/Essentials/src/Platform/PickVisualMediaForResult.android.cs` /
+     `PickMultipleVisualMediaForResult.android.cs` -- thin AndroidX launcher
+     wrappers that materialize URIs **on Task.Run** (fixes finding F1/F2).
+
+2. Modify `src/Essentials/src/MediaPicker/MediaPicker.android.cs` to use
+   `CapturePhotoForResult.Instance.Launch(outputUri)` /
+   `CaptureVideoForResult.Instance.Launch(outputUri)` instead of the legacy
+   `IntermediateActivity.StartAsync(captureIntent, requestCode, ...)`. The
+   AndroidX launcher MUST be registered before STARTED, which is achieved by
+   adding a one-line registration in `ActivityStateManager.OnActivityCreated`.
+
+3. NO new public API. NO `MediaPickerRecovery.android.cs`. NO
+   `MediaPickerRecoveryManager.android.cs`. NO `MediaPickerRecoveryStore`.
+   NO new entries in `PublicAPI.Unshipped.txt`. NO `MediaPickerRecovery_Tests`.
+
+4. On orphaned-result (process death + AndroidX replay): the registered
+   launcher's no-op `OnActivityResultForOrphanedLaunch` swallows the boolean
+   result; the captured photo file is left on disk in the app's cache and the
+   user re-taps the camera button. Accepted UX trade-off.

@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented May 19, 2026

/review -b feature/regression-check -p android

@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented May 22, 2026

🔬 Multimodal Expert Review — PR #35455

4 independent expert reviewers analyzed this PR across different dimensions using top-tier models:

Reviewer Model Focus
Architecture & State Machine Claude Opus 4.7 (xhigh reasoning) Concurrency, state transitions, lock ordering
Android Platform Claude Opus 4.7 (high reasoning) AndroidX integration, lifecycle, Java interop
API Safety & Design Claude Opus 4.7 Public API surface, serialization, backward compat
Test Coverage GPT 5.5 Test quality, gaps, flakiness risk

Overall Assessment: The architecture is fundamentally sound — durable record + AndroidX replay + orphaned-result dispatch is the right pattern. The InProcessOperationIds discrimination, consistent lock ordering (semaphore before lock), TaskCompletionSource(RunContinuationsAsynchronously), and JSON source generators for AOT are all well done. Below are de-duplicated findings across all 4 reviewers, organized by severity.


🔴 Critical / High

1. ARM Memory Model Issue in SetCancellationRegistration
📍 MediaPickerRecoveryManager.android.cs:964-977 · 🔎 Architecture review

The double-checked pattern relies on Volatile.Read providing acquire semantics, but this does not force the prior cancellationRegistration = registration write to be globally visible on ARM. A concurrent TrySetResult calling Interlocked.Exchange(ref completed, 1) could see the default struct, making Dispose() a no-op and leaking the registration.

Fix: Insert Interlocked.MemoryBarrier() between the struct write and the second Volatile.Read, or use a small lock.


2. Persistable URI Permissions Are Never Released
📍 MediaPickerRecoveryManager.android.cs:482-499 · 🔎 Platform review + Architecture review

TakePersistableUriPermission is called for every picker URI, but there is no corresponding ReleasePersistableUriPermission anywhere in the diff. Android imposes a per-app cap (128–512 depending on API level). Long-running apps that pick frequently — exactly the workload this feature targets — will eventually hit SecurityException.

Fix: After MaterializeAcceptedFilePathsAsync successfully copies the URI to a local path, call ContentResolver.ReleasePersistableUriPermission(uri, ActivityFlags.GrantReadUriPermission).


3. Saved-State Surgery Drops ALL Non-Activity-Result Providers
📍 PlatformMauiAppCompatActivity.java:60-83 · 🔎 Platform review + API review

removeFragmentRestoreState extracts only android:support:activity-result from the BundlableSavedStateRegistry bundle and discards everything else. This silently breaks SavedStateHandle (used by AndroidX ViewModels in MAUI Blazor/Hybrid apps) and any 3rd-party registerSavedStateProvider consumers. Before this PR the whole bundle was dropped, so it's no worse — but the PR encodes an opinion that only one key is safe.

Suggestion: Invert the policy — drop only fragment-restore-related providers, preserve everything else. Or at minimum document the known limitation.


4. Register() Early-Return Bug During Config-Change Recreate
📍 ActivityForResultRequest.android.cs:49-64 · 🔎 Platform review

Pre-existing but now load-bearing: during configuration-change recreation (rotation, dark mode), the new activity's OnCreate runs while the old activity is still alive (IsDestroyed = false, IsFinishing = false). The guard early-returns, leaving the launcher bound to the soon-to-be-destroyed old activity. This PR registers 4 launchers this way and relies on them for AndroidX recovery.

Fix: Compare existingActivity reference equality to componentActivity and only skip when they're the same instance. Or drop the early return — registerForActivityResult is idempotent per key.


5. BeginOperation Race with Orphan AndroidX Callback
📍 MediaPickerRecoveryManager.android.cs:56-76, 177, 209, 295, 354 · 🔎 Architecture review + Platform review

Between RecoverOperationIfAvailableAsync() and BeginOperation(), an orphan callback can flip state Pending → ResultAccepted. The user's next call throws a generic InvalidOperationException even though they just called recovery. If URI materialization fails repeatedly, the operation stays stuck in ResultAccepted and every subsequent MediaPicker call throws permanently.

Suggestions:

  • Use a dedicated exception type (MediaPickerRecoveryPendingException) so callers can retry
  • Fold RecoverOperationIfAvailableAsync into BeginOperation as a single semaphore-protected path
  • Auto-discard after N failures or N minutes rather than blocking forever

6. Debug-Only Reflection Check for AndroidX Constant
📍 PlatformMauiAppCompatActivity.java:93-95 · 🔎 Architecture review

The hardcoded "android:support:activity-result" key is the entire mechanism for recovery. If AndroidX renames it, release builds silently break with no observable failure path. The reflection cost is trivial (one field lookup at app start).

Suggestion: Run the check unconditionally. On mismatch, prefer the reflected key as source of truth, with the hardcoded constant as fallback.


7. Unbounded Recovery Result Accumulation
📍 MediaPickerRecoveryManager.android.cs:726-733 · 🔎 API review + Architecture review

SharedPreferences records grow indefinitely if the app never calls ClearRecoveredMediaPickerResultAsync. Recovered files in CacheDir can be evicted by Android, leaving zombie records pointing at non-existent files.

Fix: Add expiration/LRU eviction. In ReadPublicRecoveredResultsUnderLock, filter out records whose files no longer exist and rewrite the list.


8. WaitForRecoveredMediaPickerResultsAsync(CancellationToken.None) Leaks Forever
📍 MediaPickerRecovery.android.cs:107 · 🔎 API review + Architecture review

Passing CancellationToken.None creates a waiter that's never removed if no orphaned callback fires. Each such call grows RecoveryWaiters permanently.

Fix: Either reject CancellationToken.None (throw ArgumentException), add a default timeout overload, or document clearly that callers MUST pass a cancellable token.


🟡 Medium

# Finding File Reviewer
9 Intermediate temp file leakProcessPhotoPreservingSourceAsync rotates to a temp file, compresses to another, but never deletes the rotated intermediate. DeleteOnExit doesn't fire on Android process kill. MediaPicker.android.cs:58-78 Platform
10 Silent fallback to unprocessed photo — if ProcessPhotoPreservingSourceAsync throws during recovery, the original unprocessed path is used with no flag for callers. MediaPickerRecoveryManager.android.cs:542-558 Architecture
11 Stale unknown-version records never cleaned — version mismatch records in recovered_results SharedPreferences accumulate forever on downgrade. MediaPickerRecoveryManager.android.cs:744-769 Architecture
12 API naming — method names redundantly include "MediaPicker" (e.g. GetRecoveredMediaPickerResultsAsyncGetRecoveredResultsAsync). MediaPickerRecovery.android.cs API
13 PickPhoto vs PickPhotos enum confusion — the distinction is confusing for consumers. MediaPickerRecovery.android.cs API
14 DiscardPendingMediaPickerOperationAsync too unsafe — discards without requiring an ID; should have stronger naming or require explicit confirmation. MediaPickerRecovery.android.cs API
15 Serialization version check too strict — uses != 1 instead of < 1, blocking future migration. MediaPickerRecoveryManager.android.cs API
16 CaptureVideoWithActivityResultAsync missing static — inconsistent with CapturePhotoWithActivityResultAsync. MediaPicker.android.cs:203 Platform
17 ObserveOrphanedRecoveryTask ignores cancellation — only fires OnlyOnFaulted; cancelled tasks are silently swallowed. MediaPickerRecoveryManager.android.cs:269-280 Platform
18 Stale activeLaunchCompletionSource across recreationsLazy<T> singletons hold stale TCS; in-flight Task from old activity may hang forever. ActivityForResultRequest.android.cs Platform
19 URI permission leak on raceTakePersistableUriPermission is called before re-validating the active op under lock; if op was replaced, grants leak. MediaPickerRecoveryManager.android.cs:361-377 Architecture
20 TakePersistableUriPermission noisy on non-document URIs — fallback contract URIs may not support persistable permissions, generating log noise. MediaPickerRecoveryManager.android.cs Platform
21 Lock thrashing in RecoverOperationIfAvailableUnderSemaphoreAsync — acquires Locker 3 times in succession; could be a single lock-and-decide block. MediaPickerRecoveryManager.android.cs:314-327 Architecture
22 Enum.IsDefined(typeof(T), value) should use generic overload for AOT safety. MediaPickerRecoveryManager.android.cs API

📋 Test Coverage Assessment

Verdict: ⚠️ Tests need improvement — broad state-machine coverage but real-world paths under-tested.

The 1527-line device test file provides substantial coverage of the state machine, waiters, and cancel/empty/missing-file scenarios. Identified gaps:

Priority Gap
🔴 High No content:// URI materialization test — real picker URIs are content://, tests only use file://
🔴 High No test for Java removeFragmentRestoreState Bundle pruning behavior beyond the key constant check
🟡 Medium No sequential multi-recovery-cycle tests (recover → pick → recover again)
🟡 Medium No video picker success recovery tests (capture video → kill → recover)
🟡 Medium No malformed/corrupt SharedPreferences JSON tests
🟡 Medium Limited concurrency stress testing (parallel picks, rapid cancel/retry)

Flakiness risk: The 5-second WaitForCompletion timeout in tests is tight for CI. Reflection-based test cleanup (SimulateProcessRecreation) is fragile across refactors.


🟢 Positive Architecture Notes

All 4 reviewers independently recognized strong design decisions:

  • Lock ordering is consistent — semaphore before lock, no async under lock → eliminates deadlocks
  • TaskCompletionSource(RunContinuationsAsynchronously) correctly used everywhere
  • InProcessOperationIds is the right discrimination pattern for current-vs-prior-process
  • TakeActiveLaunchCompletionSource is atomic — exactly one of active/orphaned handlers runs per callback
  • JSON source generators (not reflection) for trimming/AOT correctness
  • Clean separation between RecoveryManager (orchestration), RecoveryStore (persistence), and ForResult classes (AndroidX wiring)
  • Java surgical preserve is a well-targeted improvement over the prior "blow away everything" approach
  • Substantial test coverage — 1527 lines covering state machine, waiters, and edge cases

This review was generated by 4 independent AI reviewers (Claude Opus 4.7 xhigh, Opus 4.7 high, Opus 4.7, GPT 5.5). Findings were de-duplicated and cross-validated. The top recommendations for merge-readiness are #1 (ARM memory barrier), #2 (URI permission leak), #4 (Register early-return), and #5 (BeginOperation race).

Copy link
Copy Markdown
Contributor

@kubaflo kubaflo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please look at the review comment

@AdamEssenmacher
Copy link
Copy Markdown
Author

Review finding #1 was addressed in AdamEssenmacher@7dabf2f.

@AdamEssenmacher
Copy link
Copy Markdown
Author

Addressed review findings #2 and #19 in df8a51d. The commit makes persistable picker URI grants explicitly owned and releases them after materialization, active-operation clearing, or a lost active-operation race.

@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented May 22, 2026

/review -b feature/refactor-copilot-yml

@AdamEssenmacher
Copy link
Copy Markdown
Author

Addressed review finding #5 in commit AdamEssenmacher/maui@2327791.

The live Android MediaPicker paths now use an atomic BeginOperationWithRecoveryAsync(...) flow that holds the recovery promotion semaphore while it promotes any accepted recreated result and starts the next operation. If an orphan AndroidX callback flips Pending to ResultAccepted between the recovery pass and the active-operation check, the method loops, promotes that result, completes recovery waiters, and only then creates the new pending operation. Recreated Pending operations still block with the existing pending-replay behavior, and no public API surface was changed.

@AdamEssenmacher
Copy link
Copy Markdown
Author

Addressed #7 in 78b1c1e (78b1c1e).

This adds recovered-result normalization that prunes missing cached files, drops empty records, caps stored recovered results to the newest 32 entries, and covers the behavior with MediaPicker recovery device tests.

@AdamEssenmacher
Copy link
Copy Markdown
Author

Addressed review finding #8 in 39a8529.\n\nThe wait API now preserves immediate-return behavior for CancellationToken.None, but throws before registering a waiter when the call would otherwise block with a non-cancellable token. I also added regression coverage to verify no waiter is retained in that case.

@AdamEssenmacher
Copy link
Copy Markdown
Author

Addressed review finding #9 in commit 6acd98f. The change deletes the rotated intermediate temp file after compression/resizing writes a separate final file, while preserving the original recovery source file. I also added a regression device test for the rotation + compression path.

@dotnet dotnet deleted a comment from MauiBot May 23, 2026
@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented May 23, 2026

🔍 Multimodal Code Review — PR #35455

Reviewed with: Claude Opus 4.7 (expert reviewer + rubber-duck critique)
Scope: Architecture, concurrency, public API surface, platform compatibility, error handling, test coverage


Overall Assessment

This is a well-architected solution to a genuinely hard Android problem (process death during camera/picker flows). The state machine is clean (Pending → ResultAccepted → Recovered), the durable SharedPreferences store is appropriate, and the separation between the recovery manager, public API surface, and ActivityResult infrastructure is thoughtful. The ~2000 lines of device tests show comprehensive coverage of the state machine, race conditions, and edge cases.

The Java-side change to selectively preserve android:support:activity-result while still suppressing fragment restore is the right surgical fix. JSON source-gen context is properly used (AOT-safe). Test isolation hooks are internal, not leaking into the public surface.

That said, both reviewers independently flagged several concrete issues worth addressing.


🔴 Issues to Address Before Merge

1. Lazy<T> singletons are eagerly constructed (Bug)

Files: CapturePhotoForResult.android.cs:11, CaptureVideoForResult.android.cs:11, PickVisualMediaForResult.android.cs:10, PickMultipleVisualMediaForResult.android.cs:12

static readonly Lazy<CapturePhotoForResult> LazyInstance = new(new CapturePhotoForResult());

This passes a pre-constructed instance to Lazy<T>, defeating the lazy pattern entirely. All four singletons are constructed when any of these types is first touched by the JIT — before the activity exists. Fix:

static readonly Lazy<CapturePhotoForResult> LazyInstance = new(() => new CapturePhotoForResult());

Today the order happens to work because ActivityStateManager.RegisterActivityResultLaunchers triggers .Instance, but the intent is clearly lazy construction and a future refactor could break on this.

2. WaitForRecoveredMediaPickerResultsAsync — non-cancellable token nondeterministically succeeds or throws

File: MediaPickerRecoveryManager.android.cs:133-167

The ArgumentException for non-cancellable tokens is only thrown on the slow path (line 162). If results are already available, CancellationToken.None works fine. This means:

// Works when results are queued, throws ArgumentException otherwise
await MediaPicker.WaitForRecoveredMediaPickerResultsAsync(CancellationToken.None);

An app developer who tests on a device with a queued result ships code that throws on every other device. Fix: Validate upfront at line 133 (fail-fast, deterministic behavior regardless of state).

Additionally, the cancellation token is NOT threaded through RecoverOperationIfAvailableCoreAsyncRecoveryPromotionSemaphore.WaitAsync() (line 136). A caller who cancels during a long materialization (copying large video from content:// URI) gets no responsiveness until the semaphore is released. Consider threading the token through the semaphore acquire.

3. Launch uses SetException/SetCanceled instead of Try-variants

File: ActivityForResultRequest.android.cs:157, 168

If an AndroidX result callback fires synchronously during launcher.Launch(input) (rare but not forbidden by the API — e.g., a test fake or permission-denied short-circuit), the callback consumes the TCS via TakeActiveLaunchCompletionSource. The subsequent catch block then calls SetException(ex) on an already-completed TCS → unhandled InvalidOperationException. Same risk in the not-registered branch. Fix: Use TrySetException/TrySetCanceled defensively.

4. while (true) loop in BeginOperationWithRecoveryAsync appears to be dead code

File: MediaPickerRecoveryManager.android.cs:73-96

The loop body either returns (line 88 — BeginOperationUnderLock) or throws via ThrowIfActiveOperationBlocksNewOperation (line 93 — which always throws). There is no code path that reaches the next iteration. If retry is intended (e.g., to wait out a recently-promoted operation), the missing continue and exit condition are bugs. If not, drop the while(true) — it's misleading to future readers and a maintenance trap.

Additionally, the non-async BeginOperation (line 42) bypasses the semaphore entirely, which could violate the termination invariant of the loop. It's currently test-only — consider marking it clearly as such with a guard or [VisibleForTesting]-equivalent comment.


🟡 Worth Discussing

5. SharedPreferences writes on main thread under lock — ANR risk

File: MediaPickerRecoveryManager.android.cs — every lock (Locker) that calls WriteActiveOperation/WriteRecoveredResults

AndroidX delivers activity results on the main thread. RecordCaptureCallbackResult (line 254) and the orphaned-callback paths both write SharedPreferences synchronously (Preferences.SetSharedPreferences.Editor.Commit) while holding Locker and on the UI thread. Combined with TakePersistableUriPermission (another binder call), the result callback can spend material time blocking the main thread.

This may be an acceptable trade-off given the small data sizes, but worth either (a) documenting the decision, (b) switching to Apply() instead of Commit() where atomicity isn't strictly needed, or (c) moving writes off-thread.

6. Orphaned-recovery failure silently completes waiters with empty results

File: MediaPickerRecoveryManager.android.cs:626-655

If recordResult() succeeds (writes ResultAccepted to SharedPreferences) but RecoverOperationIfAvailableCoreAsync throws (e.g., IOException during file copy), the catch block sets waiterResults = ReadPublicRecoveredResults() (which may be empty/stale) and calls CompleteRecoveryWaitersForReconciliation. A waiter genuinely awaiting this result wakes up with an empty list, even though the durable ResultAccepted record is still on disk and a subsequent call would recover it. The only signal is Trace.WriteLine.

Fix: On failure, don't complete waiters with empty results — let them keep waiting for the next reconciliation attempt. Or surface the exception to waiters via TrySetException.

7. ObserveOrphanedRecoveryTaskTrace.WriteLine is insufficient error visibility

File: MediaPickerRecoveryManager.android.cs:320-331

Every orphaned-result recovery path goes through this fire-and-forget continuation. Trace.WriteLine doesn't reach logcat by default in release builds on Android. A user's captured photo can be permanently lost with zero observability. Suggestion: Route through Microsoft.Maui logging infrastructure, not just Trace.

8. PlatformMauiAppCompatActivity.onCreate — replay implications for all AndroidX ActivityResultLauncher registrations

File: PlatformMauiAppCompatActivity.java:60-83

Previously both fragment state AND SavedStateRegistry were fully cleared. Now android:support:activity-result is preserved. This means any third-party AndroidX component that registers via ActivityResultRegistry against the MAUI activity will now see its callback replayed after process death — which wasn't happening before. This is generally correct behavior, but apps that relied on the previous "no replay" behavior may see new duplicate-result deliveries. Worth mentioning in release notes.

9. Debug-only reflection check on ACTIVITY_RESULT_TAG — silent breakage in release

Files: PlatformMauiAppCompatActivity.java:93-107, AndroidXActivityResultRegistryTests.Android.cs

The reflection check is gated on FLAG_DEBUGGABLE, so a release-mode app on a future AndroidX where the constant changes would have completely broken recovery with no warning. The CI test (AndroidXActivityResultRegistryTests) is the safety net — please confirm it runs on every PR build. Also consider catching NoSuchFieldException and demoting to "unable to verify" (which you partially do via the catch (Throwable) fallback).


🟢 Minor / Suggestions

10. ReadRecoveredResults silently swallows JSON deserialization errors

File: MediaPickerRecoveryManager.android.cs:984-987 — a corrupted SharedPreferences entry returns [] with no log. After a future schema change, "recovery silently stopped working" would be very hard to diagnose. Add at minimum a Trace.WriteLine and a Preferences.Remove to self-heal.

11. Naming asymmetry: Clear vs Discard

ClearRecoveredMediaPickerResultAsync(id) removes a consumed result; DiscardPendingMediaPickerOperationAsync() drops a pending op. The distinction is correct but subtle for app developers — consider documenting the asymmetry explicitly in the XML docs.

12. Recovery semaphore serializes long-running materialization

While the semaphore is held during MaterializePickerUrisAsyncEnsurePhysicalPathAsync (file I/O from content:// URIs), all other callers (BeginOperationWithRecoveryAsync, GetRecoveredResultsAsync, etc.) are blocked. For multi-file picks with large videos this could be seconds. Worth a comment explaining the intentional serialization.

13. Redundant null check

File: MediaPickerRecoveryManager.android.cs:415

if (TryPersistPickerUriReadAccess(uri) && uri is not null)

TryPersistPickerUriReadAccess already returns false for null. Consider reordering: if (uri is not null && TryPersistPickerUriReadAccess(uri)) for clearer intent.

14. Cross-platform compile gotcha

The new recovery APIs only exist in net-android. Apps with multi-target code must #if ANDROID every reference. The XML doc remarks mention "Android MediaPicker recovery APIs" but don't guide the reader on which types/namespace to look for or warn about the platform restriction.


✅ Things Done Well

  • State machine design — Small, total state machine (Pending → ResultAccepted → removal) with transitions guarded by active-operation ID matching
  • Concurrency modelLock for short critical sections + SemaphoreSlim for async serialization of recovery promotion. Lock ordering is consistent (no deadlock risk found)
  • URI permission lifecycleWithAcceptedFiles resets PickerUriStrings = [], and prior URIs are released after WriteActiveOperation succeeds (clean ownership transfer)
  • Waiter race handlingMediaPickerRecoveryWaiter's registration/dispose race is correctly handled by checking completed
  • AOT safety — JSON source-gen context properly used, no runtime reflection in trim-sensitive paths
  • Test coverage — ~2000 lines of device tests covering state machine transitions, concurrent operations, recreation scenarios, cancellation, URI materialization, and photo processing
  • Test isolation — Internal hooks (SetPickerUriPermissionHandlersForTests, SetBeginOperationWithRecoveryCheckpointForTests) don't leak to public API
  • PublicAPI.Unshipped.txt — All new entries present with correct nullability annotations ✅

Recommendation: Address items #1-4 (concrete bugs/contract issues) before merge. Items #5-9 are worth author discussion but shouldn't block. The rest are minor improvements. This is a strong contribution tackling a notoriously difficult Android lifecycle problem.

@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented May 24, 2026

/review -b feature/refactor-copilot-yml

@MauiBot
Copy link
Copy Markdown
Collaborator

MauiBot commented May 24, 2026

⚠️ Merge Conflict Detected — This PR has merge conflicts with its target branch. Please rebase onto the target branch and resolve the conflicts.

@AdamEssenmacher AdamEssenmacher marked this pull request as draft May 25, 2026 08:55
@AdamEssenmacher
Copy link
Copy Markdown
Author

I've re-reviewed all of the individual items listed above and think this PR is probably in a pretty good state in its current form. Re-opening.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-essentials Essentials: Device, Display, Connectivity, Secure Storage, Sensors, App Info area-essentials-mediapicker community ✨ Community Contribution platform/android s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review) t/bug Something isn't working t/enhancement ☀️ New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Capturing a photo using MediaPicker.CapturePhotoAsync crashes if the user takes too long to take the picture.

5 participants