From d788f415b025d186e594f7f30340c8f6a9160fb9 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Fri, 15 May 2026 03:55:37 -0400 Subject: [PATCH 01/17] Fix Android MediaPicker result recovery --- .../maui/PlatformMauiAppCompatActivity.java | 49 +- .../src/MediaPicker/MediaPicker.android.cs | 311 +++- .../src/MediaPicker/MediaPicker.shared.cs | 6 +- .../MediaPickerRecovery.android.cs | 125 ++ .../MediaPickerRecoveryManager.android.cs | 1150 +++++++++++++ .../ActivityForResultRequest.android.cs | 112 +- .../Platform/ActivityStateManager.android.cs | 27 +- .../Platform/CapturePhotoForResult.android.cs | 19 + .../Platform/CaptureVideoForResult.android.cs | 19 + .../Platform/MediaCaptureForResult.android.cs | 30 + ...ickMultipleVisualMediaForResult.android.cs | 30 +- .../PickVisualMediaForResult.android.cs | 10 +- .../net-android/PublicAPI.Unshipped.txt | 15 + .../Android/MediaPickerRecovery_Tests.cs | 1452 +++++++++++++++++ 14 files changed, 3259 insertions(+), 96 deletions(-) create mode 100644 src/Essentials/src/MediaPicker/MediaPickerRecovery.android.cs create mode 100644 src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs create mode 100644 src/Essentials/src/Platform/CapturePhotoForResult.android.cs create mode 100644 src/Essentials/src/Platform/CaptureVideoForResult.android.cs create mode 100644 src/Essentials/src/Platform/MediaCaptureForResult.android.cs create mode 100644 src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs 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 3b2b9d555134..72dd2e65fa60 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 @@ * 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 static void onCreate(AppCompatActivity activity, Bundle savedInstanceStat 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/src/Essentials/src/MediaPicker/MediaPicker.android.cs b/src/Essentials/src/MediaPicker/MediaPicker.android.cs index 08a3de2da51b..2f3525136405 100644 --- a/src/Essentials/src/MediaPicker/MediaPicker.android.cs +++ b/src/Essentials/src/MediaPicker/MediaPicker.android.cs @@ -8,6 +8,7 @@ using Android.Content.PM; using Android.Graphics; using Android.Provider; +using AndroidX.Activity; using AndroidX.Activity.Result; using AndroidX.Activity.Result.Contract; using Microsoft.Maui.ApplicationModel; @@ -25,18 +26,65 @@ public bool IsCaptureSupported static async Task RotateImageInPlace(string filePath, MediaPickerOptions options) { - using var inputStream = File.OpenRead(filePath); + await using var inputStream = File.OpenRead(filePath); var fileName = System.IO.Path.GetFileName(filePath); - using var rotatedStream = await ImageProcessor.RotateImageAsync(inputStream, fileName); + await using var rotatedStream = await ImageProcessor.RotateImageAsync(inputStream, fileName); rotatedStream.Position = 0; inputStream.Dispose(); // explicit close before delete try { File.Delete(filePath); } catch { } - using var outputStream = File.Create(filePath); + await using var outputStream = File.Create(filePath); await rotatedStream.CopyToAsync(outputStream); } + internal static async Task ProcessPhotoAsync(string imagePath, MediaPickerOptions options) + { + // Apply rotation if needed for photos + if (imagePath is not null && ImageProcessor.IsRotationNeeded(options)) + { + await RotateImageInPlace(imagePath, options); + } + + // Apply compression/resizing if needed for photos + if (imagePath is not null && ImageProcessor.IsProcessingNeeded(options?.MaximumWidth, options?.MaximumHeight, options?.CompressionQuality ?? 100)) + { + imagePath = await CompressImageIfNeeded(imagePath, options); + } + + return imagePath; + } + + internal static async Task ProcessPhotoPreservingSourceAsync(string imagePath, PersistedPhotoProcessingOptions options) + { + if (imagePath is null) + { + return null; + } + + // Recovery-sensitive MediaPicker paths must leave the original file intact until the + // active recovery record has been cleared or promoted. + if (options.RotateImage) + { + imagePath = await RotateImageToNewFileAsync(imagePath); + } + + if (ImageProcessor.IsProcessingNeeded(options.MaximumWidth, options.MaximumHeight, options.CompressionQuality)) + { + imagePath = await CompressImageIfNeeded(imagePath, options, preserveSource: true); + } + + return imagePath; + } + + internal static PersistedPhotoProcessingOptions GetPhotoProcessingOptions(MediaPickerOptions options) + => new( + options?.MaximumWidth, + options?.MaximumHeight, + options?.CompressionQuality ?? 100, + options?.RotateImage ?? false, + options?.PreserveMetaData ?? true); + internal static bool IsPhotoPickerAvailable => PickVisualMedia.InvokeIsPhotoPickerAvailable(Platform.AppContext); @@ -94,28 +142,25 @@ public async Task CaptureAsync(MediaPickerOptions options, bool phot { var activity = ActivityStateManager.Default.GetCurrentActivity(true); - string captureResult = null; + string capturePath = null; + + var useActivityResultCapture = activity is ComponentActivity; if (photo) { - captureResult = await CapturePhotoAsync(captureIntent); - // Apply rotation if needed for photos - if (captureResult is not null && ImageProcessor.IsRotationNeeded(options)) - await RotateImageInPlace(captureResult, options); - - // Apply compression/resizing if needed for photos - if (captureResult is not null && ImageProcessor.IsProcessingNeeded(options?.MaximumWidth, options?.MaximumHeight, options?.CompressionQuality ?? 100)) - { - captureResult = await CompressImageIfNeeded(captureResult, options); - } + capturePath = useActivityResultCapture + ? await CapturePhotoWithActivityResultAsync(options) + : await ProcessPhotoAsync(await CapturePhotoAsync(captureIntent), options); } else { - captureResult = await CaptureVideoAsync(captureIntent); + capturePath = useActivityResultCapture + ? await CaptureVideoWithActivityResultAsync(options) + : await CaptureVideoAsync(captureIntent); } // Return the file that we just captured - return captureResult is not null ? new FileResult(captureResult) : null; + return capturePath is not null ? new FileResult(capturePath) : null; } catch (OperationCanceledException) { @@ -123,6 +168,69 @@ public async Task CaptureAsync(MediaPickerOptions options, bool phot } } + static async Task CapturePhotoWithActivityResultAsync(MediaPickerOptions options) + { + var fileName = Guid.NewGuid().ToString("N") + FileExtensions.Jpg; + var captureFile = FileSystemUtils.GetTemporaryFile(Application.Context.CacheDir, fileName); + var outputUri = FileProvider.GetUriForFile(captureFile); + + await MediaPickerRecoveryManager.RecoverOperationIfAvailableAsync(); + + var processingOptions = GetPhotoProcessingOptions(options); + var pendingOperation = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [captureFile.AbsolutePath], + processingOptions); + + try + { + var result = await CapturePhotoForResult.Instance.Launch(outputUri); + + if (result?.BooleanValue() != true || !MediaPickerRecoveryManager.IsFileAvailable(captureFile.AbsolutePath)) + { + return null; + } + + return await ProcessPhotoPreservingSourceAsync(captureFile.AbsolutePath, processingOptions); + } + finally + { + // The live task completed or failed, so prevent the same capture from being published as recovered later. + MediaPickerRecoveryManager.ClearActiveOperation(pendingOperation.Id); + } + } + + async Task CaptureVideoWithActivityResultAsync(MediaPickerOptions options) + { + var fileName = Guid.NewGuid().ToString("N") + FileExtensions.Mp4; + var captureFile = FileSystemUtils.GetTemporaryFile(Application.Context.CacheDir, fileName); + var outputUri = FileProvider.GetUriForFile(captureFile); + + await MediaPickerRecoveryManager.RecoverOperationIfAvailableAsync(); + + var pendingOperation = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CaptureVideo, + [captureFile.AbsolutePath], + PersistedPhotoProcessingOptions.Default); + + try + { + var result = await CaptureVideoForResult.Instance.Launch(outputUri); + + if (result?.BooleanValue() != true || !MediaPickerRecoveryManager.IsFileAvailable(captureFile.AbsolutePath)) + { + return null; + } + + return captureFile.AbsolutePath; + } + finally + { + // The live task completed or failed, so prevent the same capture from being published as recovered later. + MediaPickerRecoveryManager.ClearActiveOperation(pendingOperation.Id); + } + } + async Task PickUsingIntermediateActivity(MediaPickerOptions options, bool photo) { var intent = new Intent(Intent.ActionGetContent); @@ -175,35 +283,47 @@ void OnResult(Intent intent) } } - async Task PickUsingPhotoPicker(MediaPickerOptions options, bool photo) + async Task PickUsingPhotoPicker( + MediaPickerOptions options, + bool photo, + RecoveredMediaPickerResultKind? operationKind = null) { var pickVisualMediaRequest = new PickVisualMediaRequest.Builder() .SetMediaType(photo ? ActivityResultContracts.PickVisualMedia.ImageOnly.Instance : ActivityResultContracts.PickVisualMedia.VideoOnly.Instance) .Build(); - var androidUri = await PickVisualMediaForResult.Instance.Launch(pickVisualMediaRequest); + await MediaPickerRecoveryManager.RecoverOperationIfAvailableAsync(); - if (androidUri?.Equals(AndroidUri.Empty) ?? true) + var processingOptions = GetPhotoProcessingOptions(options); + var pendingOperation = MediaPickerRecoveryManager.BeginOperation( + operationKind ?? (photo ? RecoveredMediaPickerResultKind.PickPhoto : RecoveredMediaPickerResultKind.PickVideo), + [], + processingOptions); + + try { - return null; - } + var androidUri = await PickVisualMediaForResult.Instance.Launch(pickVisualMediaRequest); - var path = FileSystemUtils.EnsurePhysicalPath(androidUri); + if (androidUri?.Equals(AndroidUri.Empty) ?? true) + { + return null; + } - if (photo) - { - // Apply rotation if needed - if (ImageProcessor.IsRotationNeeded(options)) - await RotateImageInPlace(path, options); + var acceptedPaths = MediaPickerRecoveryManager.MaterializeAcceptedFilePaths(pendingOperation.Id, throwOnMaterializationFailure: true); + var path = acceptedPaths.FirstOrDefault() ?? FileSystemUtils.EnsurePhysicalPath(androidUri); - // Apply compression/resizing if needed - if (ImageProcessor.IsProcessingNeeded(options?.MaximumWidth, options?.MaximumHeight, options?.CompressionQuality ?? 100)) + if (photo) { - path = await CompressImageIfNeeded(path, options); + path = await ProcessPhotoPreservingSourceAsync(path, processingOptions); } - } - return new FileResult(path); + return new FileResult(path); + } + finally + { + // The live task completed or failed, so prevent the same pick from being published as recovered later. + MediaPickerRecoveryManager.ClearActiveOperation(pendingOperation.Id); + } } async Task> PickMultipleUsingPhotoPicker(MediaPickerOptions options, bool photo) @@ -214,7 +334,10 @@ async Task> PickMultipleUsingPhotoPicker(MediaPickerOptions opt int selectionLimit = options?.SelectionLimit ?? 1; if (selectionLimit == 1) { - var singleResult = await PickUsingPhotoPicker(options, photo); + var singleResult = await PickUsingPhotoPicker( + options, + photo, + photo ? RecoveredMediaPickerResultKind.PickPhotos : RecoveredMediaPickerResultKind.PickVideos); return singleResult is not null ? [singleResult] : []; } @@ -228,49 +351,50 @@ async Task> PickMultipleUsingPhotoPicker(MediaPickerOptions opt pickVisualMediaRequestBuilder.SetMaxItems(selectionLimit); } - var pickVisualMediaRequest = pickVisualMediaRequestBuilder.Build(); + await MediaPickerRecoveryManager.RecoverOperationIfAvailableAsync(); - var androidUris = await PickMultipleVisualMediaForResult.Instance.Launch(pickVisualMediaRequest); + var processingOptions = GetPhotoProcessingOptions(options); + var pendingOperation = MediaPickerRecoveryManager.BeginOperation( + photo ? RecoveredMediaPickerResultKind.PickPhotos : RecoveredMediaPickerResultKind.PickVideos, + [], + processingOptions); - if (androidUris?.IsEmpty ?? true) + try { - return []; - } + var pickVisualMediaRequest = pickVisualMediaRequestBuilder.Build(); + var androidUris = await PickMultipleVisualMediaForResult.Instance.Launch(pickVisualMediaRequest); - var resultList = new List(); + if (androidUris?.IsEmpty ?? true) + return []; - for (var i = 0; i < androidUris.Size(); i++) - { - var uri = androidUris.Get(i) as AndroidUri; - if (!uri?.Equals(AndroidUri.Empty) ?? false) + var acceptedPaths = MediaPickerRecoveryManager.MaterializeAcceptedFilePaths(pendingOperation.Id, throwOnMaterializationFailure: true); + + var resultList = new List(); + + foreach (var acceptedPath in acceptedPaths) { - var path = FileSystemUtils.EnsurePhysicalPath(uri); + var path = acceptedPath; if (photo) - { - // Apply rotation if needed - if (ImageProcessor.IsRotationNeeded(options)) - await RotateImageInPlace(path, options); - - // Apply compression/resizing if needed - if (ImageProcessor.IsProcessingNeeded(options?.MaximumWidth, options?.MaximumHeight, options?.CompressionQuality ?? 100)) - { - path = await CompressImageIfNeeded(path, options); - } - } + path = await ProcessPhotoPreservingSourceAsync(path, processingOptions); resultList.Add(new FileResult(path)); } - } - return resultList; + return resultList; + } + finally + { + // The live task completed or failed, so prevent the same pick from being published as recovered later. + MediaPickerRecoveryManager.ClearActiveOperation(pendingOperation.Id); + } } async Task CapturePhotoAsync(Intent captureIntent) { // Create the temporary file var fileName = Guid.NewGuid().ToString("N") + FileExtensions.Jpg; - var tmpFile = FileSystemUtils.GetTemporaryFile(Application.Context.CacheDir, fileName); + var captureFile = FileSystemUtils.GetTemporaryFile(Application.Context.CacheDir, fileName); // Set up the content:// uri AndroidUri outputUri = null; @@ -280,19 +404,52 @@ void OnCreate(Intent intent) // Android requires that using a file provider to get a content:// uri for a file to be called // from within the context of the actual activity which may share that uri with another intent // it launches. - outputUri ??= FileProvider.GetUriForFile(tmpFile); + outputUri ??= FileProvider.GetUriForFile(captureFile); intent.PutExtra(MediaStore.ExtraOutput, outputUri); } await IntermediateActivity.StartAsync(captureIntent, PlatformUtils.requestCodeMediaCapture, OnCreate); - return tmpFile.AbsolutePath; + return captureFile.AbsolutePath; } - static async Task CompressImageIfNeeded(string imagePath, MediaPickerOptions options) + static async Task RotateImageToNewFileAsync(string imagePath) { - if (!ImageProcessor.IsProcessingNeeded(options?.MaximumWidth, options?.MaximumHeight, options?.CompressionQuality ?? 100) || string.IsNullOrEmpty(imagePath)) + if (string.IsNullOrEmpty(imagePath)) + { + return imagePath; + } + + if (!File.Exists(imagePath)) + { + return imagePath; + } + + await using var inputStream = File.OpenRead(imagePath); + var inputFileName = System.IO.Path.GetFileName(imagePath); + await using var rotatedStream = await ImageProcessor.RotateImageAsync(inputStream, inputFileName); + rotatedStream.Position = 0; + + var outputExtension = System.IO.Path.GetExtension(imagePath); + if (string.IsNullOrEmpty(outputExtension)) + { + outputExtension = FileExtensions.Jpg; + } + + var outputFile = FileSystemUtils.GetTemporaryFile(Application.Context.CacheDir, Guid.NewGuid().ToString("N") + outputExtension); + await using var outputStream = File.Create(outputFile.AbsolutePath); + await rotatedStream.CopyToAsync(outputStream); + + return outputFile.AbsolutePath; + } + + static Task CompressImageIfNeeded(string imagePath, MediaPickerOptions options, bool preserveSource = false) + => CompressImageIfNeeded(imagePath, GetPhotoProcessingOptions(options), preserveSource); + + static async Task CompressImageIfNeeded(string imagePath, PersistedPhotoProcessingOptions options, bool preserveSource = false) + { + if (!ImageProcessor.IsProcessingNeeded(options.MaximumWidth, options.MaximumHeight, options.CompressionQuality) || string.IsNullOrEmpty(imagePath)) return imagePath; try @@ -308,18 +465,18 @@ static async Task CompressImageIfNeeded(string imagePath, MediaPickerOpt var inputFileName = System.IO.Path.GetFileName(imagePath); using var processedStream = await ImageProcessor.ProcessImageAsync( inputStream, - options?.MaximumWidth, - options?.MaximumHeight, - options?.CompressionQuality ?? 100, + options.MaximumWidth, + options.MaximumHeight, + options.CompressionQuality, inputFileName, - options?.RotateImage ?? false, - options?.PreserveMetaData ?? true); + options.RotateImage, + options.PreserveMetaData); if (processedStream != null) { // Determine the correct output extension based on the processed format processedStream.Position = 0; - var outputExtension = ImageProcessor.DetermineOutputExtension(processedStream, options?.CompressionQuality ?? 100, inputFileName); + var outputExtension = ImageProcessor.DetermineOutputExtension(processedStream, options.CompressionQuality, inputFileName); var originalExtension = System.IO.Path.GetExtension(imagePath); // If format changed (e.g., PNG -> JPEG), use new extension @@ -329,10 +486,18 @@ static async Task CompressImageIfNeeded(string imagePath, MediaPickerOpt outputPath = System.IO.Path.ChangeExtension(imagePath, outputExtension); } - // Delete original file first - try - { originalFile.Delete(); } - catch { } + if (preserveSource) + { + var outputFile = FileSystemUtils.GetTemporaryFile(Application.Context.CacheDir, Guid.NewGuid().ToString("N") + outputExtension); + outputPath = outputFile.AbsolutePath; + } + else + { + // Delete original file first + try + { originalFile.Delete(); } + catch { } + } // Write processed image to output path with correct extension using var outputStream = File.Create(outputPath); @@ -461,4 +626,4 @@ void OnResult(Intent resultIntent) } } } -} \ No newline at end of file +} diff --git a/src/Essentials/src/MediaPicker/MediaPicker.shared.cs b/src/Essentials/src/MediaPicker/MediaPicker.shared.cs index 279184fd5037..81c836b80255 100644 --- a/src/Essentials/src/MediaPicker/MediaPicker.shared.cs +++ b/src/Essentials/src/MediaPicker/MediaPicker.shared.cs @@ -42,6 +42,7 @@ public interface IMediaPicker /// /// Pick options to use. /// A object containing details of the captured photo. When the operation was cancelled by the user, this will return . + /// On Android, the operating system may destroy the app process while the system camera is foregrounded. Apps that need to recover MediaPicker results after process recreation should persist their own workflow state before starting capture and use the Android MediaPicker recovery APIs after startup or resume. Task CapturePhotoAsync(MediaPickerOptions? options = null); /// @@ -70,13 +71,14 @@ public interface IMediaPicker /// /// Pick options to use. /// A object containing details of the captured video. When the operation was cancelled by the user, this will return . + /// On Android, the operating system may destroy the app process while the system camera is foregrounded. Apps that need to recover MediaPicker results after process recreation should persist their own workflow state before starting capture and use the Android MediaPicker recovery APIs after startup or resume. Task CaptureVideoAsync(MediaPickerOptions? options = null); } /// /// The MediaPicker API lets a user pick or take a photo or video on the device. /// - public static class MediaPicker + public static partial class MediaPicker { /// /// Gets a value indicating whether capturing media is supported on this device. @@ -103,6 +105,7 @@ public static Task> PickPhotosAsync(MediaPickerOptions? options /// /// Pick options to use. /// A object containing details of the captured photo. When the operation was cancelled by the user, this will return . + /// On Android, the operating system may destroy the app process while the system camera is foregrounded. Apps that need to recover MediaPicker results after process recreation should persist their own workflow state before starting capture and use the Android MediaPicker recovery APIs after startup or resume. public static Task CapturePhotoAsync(MediaPickerOptions? options = null) => Default.CapturePhotoAsync(options); @@ -125,6 +128,7 @@ public static Task> PickVideosAsync(MediaPickerOptions? options /// /// Pick options to use. /// A object containing details of the captured video. When the operation was cancelled by the user, this will return . + /// On Android, the operating system may destroy the app process while the system camera is foregrounded. Apps that need to recover MediaPicker results after process recreation should persist their own workflow state before starting capture and use the Android MediaPicker recovery APIs after startup or resume. public static Task CaptureVideoAsync(MediaPickerOptions? options = null) => Default.CaptureVideoAsync(options); diff --git a/src/Essentials/src/MediaPicker/MediaPickerRecovery.android.cs b/src/Essentials/src/MediaPicker/MediaPickerRecovery.android.cs new file mode 100644 index 000000000000..635b68d86f33 --- /dev/null +++ b/src/Essentials/src/MediaPicker/MediaPickerRecovery.android.cs @@ -0,0 +1,125 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Maui.Storage; + +namespace Microsoft.Maui.Media +{ + /// + /// A MediaPicker result that was recovered after the app process was recreated. + /// + public sealed class RecoveredMediaPickerResult + { + /// + /// Initializes a new instance of the class. + /// + /// The identifier of the recovered result. + /// The kind of MediaPicker operation that produced the result. + /// The recovered media files. + public RecoveredMediaPickerResult(string id, RecoveredMediaPickerResultKind kind, IReadOnlyList files) + { + Id = id ?? throw new ArgumentNullException(nameof(id)); + Kind = kind; + + ArgumentNullException.ThrowIfNull(files); + + var fileArray = files.ToArray(); + if (fileArray.Length == 0) + { + throw new ArgumentException("At least one recovered media file is required.", nameof(files)); + } + + if (fileArray.Any(file => file is null)) + { + throw new ArgumentException("Recovered media files cannot contain null values.", nameof(files)); + } + + Files = Array.AsReadOnly(fileArray); + } + + /// + /// Gets the identifier of the recovered result. + /// + public string Id { get; } + + /// + /// Gets the kind of MediaPicker operation that produced the recovered result. + /// + public RecoveredMediaPickerResultKind Kind { get; } + + /// + /// Gets the recovered media files. + /// + public IReadOnlyList Files { get; } + } + + /// + /// Describes the Android MediaPicker operation that produced a recovered result. + /// + public enum RecoveredMediaPickerResultKind + { + /// A captured photo. + CapturePhoto, + + /// A captured video. + CaptureVideo, + + /// A single picked photo. + PickPhoto, + + /// One or more picked photos. + PickPhotos, + + /// A single picked video. + PickVideo, + + /// One or more picked videos. + PickVideos + } + + /// + /// The MediaPicker API lets a user pick or take a photo or video on the device. + /// + public static partial class MediaPicker + { + /// + /// Gets Android MediaPicker results that were recovered after the app process was recreated. + /// + /// A non-consuming list of recovered MediaPicker results. + /// + /// The operating system may destroy the app process while a system picker or camera is foregrounded. AndroidX activity-result replay is the recovery signal; file existence alone does not publish a recovered result. Apps should persist their own workflow state before starting MediaPicker operations, then use this method during startup or resume to associate recovered media with that state. + /// + public static Task> GetRecoveredMediaPickerResultsAsync() + => MediaPickerRecoveryManager.GetRecoveredResultsAsync(); + + /// + /// Waits for Android MediaPicker recovery to reconcile a pending result. + /// + /// A token that cancels the wait and removes the recovery listener. + /// A non-consuming list of recovered MediaPicker results. + /// + /// If recovered results are already available, this method returns them immediately. Otherwise, it waits until AndroidX result replay publishes or terminally clears a pending MediaPicker result. This method is one-shot; apps that need continuous observation should call it again with a lifecycle-scoped cancellation token. + /// If AndroidX does not replay or reconcile a pending result, this method may wait until is canceled. + /// + public static Task> WaitForRecoveredMediaPickerResultsAsync(CancellationToken cancellationToken) + => MediaPickerRecoveryManager.WaitForRecoveredResultsAsync(cancellationToken); + + /// + /// Clears an Android MediaPicker result that was recovered after the app process was recreated. + /// + /// The identifier of the recovered result to clear. + /// A task that represents the asynchronous clear operation. + public static Task ClearRecoveredMediaPickerResultAsync(string id) + { + if (id is null) + { + throw new ArgumentNullException(nameof(id)); + } + + return MediaPickerRecoveryManager.ClearRecoveredResultAsync(id); + } + } +} diff --git a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs new file mode 100644 index 000000000000..93c0de219ba1 --- /dev/null +++ b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs @@ -0,0 +1,1150 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Android.App; +using Android.Content; +using Microsoft.Maui.Storage; +using AndroidUri = Android.Net.Uri; + +namespace Microsoft.Maui.Media; + +enum PendingMediaPickerState +{ + Pending, + ResultAccepted +} + +/// +/// Persists Android MediaPicker activity-result state so selected or captured files can be recovered +/// if the app process is recreated before the live API call can return. +/// +internal static class MediaPickerRecoveryManager +{ + static readonly Lock Locker = new(); + static readonly HashSet InProcessOperationIds = new(StringComparer.Ordinal); + static readonly List RecoveryWaiters = []; + static readonly SemaphoreSlim RecoveryPromotionSemaphore = new(1, 1); + // Lets waiters detect an empty recovery outcome that happened before they could be registered. + static long RecoveryReconciliationGeneration; + + internal static PendingMediaPickerOperation BeginOperation( + RecoveredMediaPickerResultKind kind, + IReadOnlyList filePaths, + PersistedPhotoProcessingOptions photoProcessingOptions) + { + if (!IsKnownKind(kind)) + { + throw new ArgumentOutOfRangeException(nameof(kind)); + } + + if (filePaths is null) + { + throw new ArgumentNullException(nameof(filePaths)); + } + + PendingMediaPickerOperation operation; + + // Persist the one active MediaPicker operation before launching AndroidX. Later AndroidX + // callbacks are matched back to this durable record, including after process recreation. + lock (Locker) + { + if (MediaPickerRecoveryStore.ReadActiveOperation() is { } activeOperation) + { + ThrowIfActiveOperationBlocksNewOperation(activeOperation); + } + + operation = new PendingMediaPickerOperation( + Guid.NewGuid().ToString("N"), + kind, + PendingMediaPickerState.Pending, + filePaths.ToArray(), + [], + photoProcessingOptions); + + MediaPickerRecoveryStore.WriteActiveOperation(operation); + InProcessOperationIds.Add(operation.Id); + } + + return operation; + } + + internal static void ClearActiveOperation(string id) + { + if (id is null) + { + return; + } + + lock (Locker) + { + var operation = MediaPickerRecoveryStore.ReadActiveOperation(); + if (operation?.Id == id) + { + ClearActiveOperationUnderLock(operation); + } + } + } + + internal static async Task> GetRecoveredResultsAsync() + { + var reconciliation = await RecoverOperationIfAvailableCoreAsync().ConfigureAwait(false); + return reconciliation.Results; + } + + internal static async Task> WaitForRecoveredResultsAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var observedReconciliationGeneration = GetRecoveryReconciliationGeneration(); + var reconciliation = await RecoverOperationIfAvailableCoreAsync().ConfigureAwait(false); + if (reconciliation.WasReconciled || reconciliation.Results.Count > 0) + { + return reconciliation.Results; + } + + var waiter = new MediaPickerRecoveryWaiter(cancellationToken); + + lock (Locker) + { + var results = ReadPublicRecoveredResultsUnderLock(); + if (results.Count > 0) + { + return results; + } + + if (observedReconciliationGeneration != RecoveryReconciliationGeneration) + { + return results; + } + + if (cancellationToken.IsCancellationRequested) + { + cancellationToken.ThrowIfCancellationRequested(); + } + + RecoveryWaiters.Add(waiter); + } + + waiter.SetCancellationRegistration(cancellationToken.Register(static state => + CancelRecoveryWaiter((MediaPickerRecoveryWaiter)state!), waiter)); + + if (cancellationToken.IsCancellationRequested) + { + CancelRecoveryWaiter(waiter); + } + + return await waiter.Task.ConfigureAwait(false); + } + + internal static Task ClearRecoveredResultAsync(string id) + { + lock (Locker) + { + var results = MediaPickerRecoveryStore.ReadRecoveredResults(); + var removed = results.RemoveAll(result => string.Equals(result.Id, id, StringComparison.Ordinal)) > 0; + + if (removed) + { + MediaPickerRecoveryStore.WriteRecoveredResults(results); + } + } + + return Task.CompletedTask; + } + + internal static void RecoverOrphanedCaptureResult(RecoveredMediaPickerResultKind kind, bool success) + => _ = RecoverOrphanedCaptureResultAsync(kind, success); + + internal static bool RecordCaptureCallbackResult(RecoveredMediaPickerResultKind kind, bool success) + { + if (!IsCaptureKind(kind)) + { + return false; + } + + lock (Locker) + { + var operation = MediaPickerRecoveryStore.ReadActiveOperation(); + + if (operation is null || operation.Kind != kind) + { + return false; + } + + var outputPath = operation.FilePaths.Count == 1 ? operation.FilePaths[0] : null; + if (!success || !IsFileAvailable(outputPath)) + { + ClearActiveOperationUnderLock(operation); + return true; + } + + // AndroidX accepted the capture result. Persist that fact before completing any live task so + // process death after this callback still leaves the recoverable state behind. + MediaPickerRecoveryStore.WriteActiveOperation(operation.WithState(PendingMediaPickerState.ResultAccepted)); + return true; + } + } + + // AndroidX calls this when it delivers a capture result, but the original launch task is not active in this process. + internal static async Task RecoverOrphanedCaptureResultAsync(RecoveredMediaPickerResultKind kind, bool success) + => await RecoverOrphanedOperationResultAsync( + () => RecordCaptureCallbackResult(kind, success), + "Unable to recover media capture result").ConfigureAwait(false); + + internal static bool RecordSinglePickCallbackResult(AndroidUri? uri, bool materializeImmediately) + => RecordPickCallbackResult( + operation => IsSinglePickCallbackKind(operation.Kind), + uri is null || uri.Equals(AndroidUri.Empty) ? Array.Empty() : new[] { uri }, + materializeImmediately); + + internal static bool RecordMultiplePickCallbackResult(IReadOnlyList? uris, bool materializeImmediately) + => RecordPickCallbackResult( + operation => IsMultiplePickCallbackKind(operation.Kind), + uris ?? [], + materializeImmediately); + + internal static void RecoverOrphanedSinglePickResult(AndroidUri? uri) + => _ = RecoverOrphanedSinglePickResultAsync(uri); + + internal static async Task RecoverOrphanedSinglePickResultAsync(AndroidUri? uri) + => await RecoverOrphanedOperationResultAsync( + () => RecordSinglePickCallbackResult(uri, materializeImmediately: false), + "Unable to recover picked media result").ConfigureAwait(false); + + internal static void RecoverOrphanedMultiplePickResult(IReadOnlyList? uris) + => _ = RecoverOrphanedMultiplePickResultAsync(uris); + + internal static async Task RecoverOrphanedMultiplePickResultAsync(IReadOnlyList? uris) + => await RecoverOrphanedOperationResultAsync( + () => RecordMultiplePickCallbackResult(uris, materializeImmediately: false), + "Unable to recover picked media results").ConfigureAwait(false); + + internal static async Task> RecoverOperationIfAvailableAsync() + { + var reconciliation = await RecoverOperationIfAvailableCoreAsync().ConfigureAwait(false); + return reconciliation.Results; + } + + // Promotes a recreated-process operation only after AndroidX has accepted the result. + // A Pending record means the result callback has not been replayed yet. + static async Task RecoverOperationIfAvailableCoreAsync() + { + await RecoveryPromotionSemaphore.WaitAsync().ConfigureAwait(false); + + try + { + var reconciliation = await RecoverOperationIfAvailableUnderSemaphoreAsync().ConfigureAwait(false); + if (reconciliation.WasReconciled) + { + CompleteRecoveryWaitersForReconciliation(reconciliation.Results); + } + + return reconciliation; + } + finally + { + RecoveryPromotionSemaphore.Release(); + } + } + + static async Task RecoverOperationIfAvailableUnderSemaphoreAsync() + { + PendingMediaPickerOperation? operation; + + lock (Locker) + { + operation = MediaPickerRecoveryStore.ReadActiveOperation(); + if (operation is null || !ShouldPromoteRecreatedOperation(operation)) + { + return new MediaPickerRecoveryReconciliation(ReadPublicRecoveredResultsUnderLock(), false); + } + } + + if (!HasAcceptedResultPayload(operation)) + { + ClearActiveOperation(operation.Id); + return new MediaPickerRecoveryReconciliation(ReadPublicRecoveredResults(), true); + } + + await PublishRecoveredOperationAsync(operation).ConfigureAwait(false); + return new MediaPickerRecoveryReconciliation(ReadPublicRecoveredResults(), true); + } + + static bool RecordPickCallbackResult( + Func matchesOperation, + IReadOnlyList uris, + bool materializeImmediately) + { + PendingMediaPickerOperation? operation; + + lock (Locker) + { + operation = MediaPickerRecoveryStore.ReadActiveOperation(); + if (operation is null || !matchesOperation(operation)) + { + return false; + } + } + + if (uris.Count == 0) + { + ClearActiveOperation(operation.Id); + return true; + } + + var uriStrings = GetPickerUriStrings(uris); + if (uriStrings.Count == 0) + { + ClearActiveOperation(operation.Id); + return true; + } + + lock (Locker) + { + var current = MediaPickerRecoveryStore.ReadActiveOperation(); + if (current?.Id != operation.Id) + { + return false; + } + + // AndroidX accepted the picker result. Persist the URI payload before copying from it so + // process death during materialization can still be retried after recreation. + MediaPickerRecoveryStore.WriteActiveOperation(current.WithAcceptedPickerUris(uriStrings)); + } + + foreach (var uri in uris) + { + TryPersistPickerUriReadAccess(uri); + } + + if (materializeImmediately) + { + MaterializeAcceptedFilePaths(operation.Id, throwOnMaterializationFailure: true); + } + + return true; + } + + internal static IReadOnlyList MaterializeAcceptedFilePaths(string id, bool throwOnMaterializationFailure) + { + if (id is null) + { + return []; + } + + PendingMediaPickerOperation? operation; + lock (Locker) + { + operation = MediaPickerRecoveryStore.ReadActiveOperation(); + if (operation?.Id != id || operation.State != PendingMediaPickerState.ResultAccepted) + { + return []; + } + + if (operation.FilePaths.Count > 0) + { + return operation.FilePaths.ToArray(); + } + + if (operation.PickerUriStrings.Count == 0) + { + return []; + } + } + + IReadOnlyList filePaths; + try + { + filePaths = MaterializePickerUris(operation.PickerUriStrings); + } + catch (Exception ex) + { + Trace.WriteLine($"Unable to materialize picked media result: {ex}"); + ClearActiveOperation(operation.Id); + + if (throwOnMaterializationFailure) + { + throw; + } + + return []; + } + + if (filePaths.Count == 0) + { + ClearActiveOperation(operation.Id); + return []; + } + + lock (Locker) + { + var current = MediaPickerRecoveryStore.ReadActiveOperation(); + if (current?.Id != operation.Id || current.State != PendingMediaPickerState.ResultAccepted) + { + return []; + } + + MediaPickerRecoveryStore.WriteActiveOperation(current.WithAcceptedFiles(filePaths)); + } + + return filePaths; + } + + static IReadOnlyList GetPickerUriStrings(IReadOnlyList uris) + => uris + .Where(uri => uri is not null && !uri.Equals(AndroidUri.Empty)) + .Select(uri => uri.ToString()) + .Where(uri => !string.IsNullOrWhiteSpace(uri)) + .Select(uri => uri!) + .ToArray(); + + static IReadOnlyList MaterializePickerUris(IReadOnlyList uriStrings) + { + var filePaths = new List(); + + foreach (var uriString in uriStrings) + { + if (string.IsNullOrWhiteSpace(uriString)) + { + continue; + } + + var uri = AndroidUri.Parse(uriString); + if (uri is null) + { + continue; + } + + var filePath = FileSystemUtils.EnsurePhysicalPath(uri); + if (!string.IsNullOrEmpty(filePath)) + { + filePaths.Add(filePath); + } + } + + return filePaths; + } + + static void TryPersistPickerUriReadAccess(AndroidUri? uri) + { + if (uri is null || + uri.Equals(AndroidUri.Empty) || + !string.Equals(uri.Scheme, FileSystemUtils.UriSchemeContent, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + try + { + Application.Context?.ContentResolver?.TakePersistableUriPermission(uri, ActivityFlags.GrantReadUriPermission); + } + catch (Exception ex) + { + Trace.WriteLine($"Unable to persist picked media URI access: {ex}"); + } + } + + static async Task RecoverOrphanedOperationResultAsync(Func recordResult, string failureMessage) + { + IReadOnlyList? waiterResults = null; + + try + { + if (!recordResult()) + { + return; + } + + var reconciliation = await RecoverOperationIfAvailableCoreAsync().ConfigureAwait(false); + if (!reconciliation.WasReconciled) + { + waiterResults = reconciliation.Results; + } + } + catch (Exception ex) + { + Trace.WriteLine($"{failureMessage}: {ex}"); + waiterResults = ReadPublicRecoveredResults(); + } + finally + { + if (waiterResults is not null) + { + CompleteRecoveryWaitersForReconciliation(waiterResults); + } + } + } + + // Publishes the accepted operation as a non-consuming recovered result. + static async Task PublishRecoveredOperationAsync(PendingMediaPickerOperation operation) + { + var recoveredPaths = new List(); + var acceptedFilePaths = MaterializeAcceptedFilePaths(operation.Id, throwOnMaterializationFailure: false); + + foreach (var filePath in acceptedFilePaths) + { + var recoveredPath = filePath; + + if (IsPhotoKind(operation.Kind)) + { + try + { + recoveredPath = await MediaPickerImplementation.ProcessPhotoPreservingSourceAsync(recoveredPath, operation.PhotoProcessingOptions).ConfigureAwait(false); + } + catch (Exception ex) + { + Trace.WriteLine($"Unable to process recovered photo result: {ex}"); + } + } + + if (IsFileAvailable(recoveredPath)) + { + recoveredPaths.Add(recoveredPath); + } + } + + lock (Locker) + { + var current = MediaPickerRecoveryStore.ReadActiveOperation(); + if (current?.Id != operation.Id) + { + return; + } + + if (recoveredPaths.Count == 0) + { + ClearActiveOperationUnderLock(operation); + return; + } + + var recoveredResult = new RecoveredMediaPickerRecord(operation.Id, operation.Kind, recoveredPaths); + var recoveredResults = MediaPickerRecoveryStore.ReadRecoveredResults(); + recoveredResults.RemoveAll(result => string.Equals(result.Id, recoveredResult.Id, StringComparison.Ordinal)); + recoveredResults.Add(recoveredResult); + + MediaPickerRecoveryStore.WriteRecoveredResults(recoveredResults); + ClearActiveOperationUnderLock(operation); + } + } + + internal static bool IsFileAvailable(string? filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + return false; + } + + var fileInfo = new FileInfo(filePath); + return fileInfo is { Exists: true, Length: > 0 }; + } + + static IReadOnlyList ReadPublicRecoveredResults() + { + lock (Locker) + { + return ReadPublicRecoveredResultsUnderLock(); + } + } + + static IReadOnlyList ReadPublicRecoveredResultsUnderLock() + => MediaPickerRecoveryStore.ReadRecoveredResults() + .Select(result => result.ToPublicResult()) + .ToArray(); + + static long GetRecoveryReconciliationGeneration() + { + lock (Locker) + { + return RecoveryReconciliationGeneration; + } + } + + static bool IsInProcessOperation(PendingMediaPickerOperation operation) + => InProcessOperationIds.Contains(operation.Id); + + static bool ShouldPromoteRecreatedOperation(PendingMediaPickerOperation operation) + { + if (IsInProcessOperation(operation)) + { + return false; + } + + return operation.State == PendingMediaPickerState.ResultAccepted; + } + + static bool HasAcceptedResultPayload(PendingMediaPickerOperation operation) + => operation.FilePaths.Count > 0 || operation.PickerUriStrings.Count > 0; + + static void ThrowIfActiveOperationBlocksNewOperation(PendingMediaPickerOperation activeOperation) + { + if (IsInProcessOperation(activeOperation)) + { + throw new InvalidOperationException("A MediaPicker operation is already in progress."); + } + + if (activeOperation.State == PendingMediaPickerState.ResultAccepted) + { + throw new InvalidOperationException("A MediaPicker result is pending recovery."); + } + + throw new InvalidOperationException("A MediaPicker operation is pending AndroidX result replay."); + } + + static void ClearActiveOperationUnderLock(PendingMediaPickerOperation operation) + { + InProcessOperationIds.Remove(operation.Id); + MediaPickerRecoveryStore.RemoveActiveOperation(); + } + + static void CancelRecoveryWaiter(MediaPickerRecoveryWaiter waiter) + { + lock (Locker) + { + RecoveryWaiters.Remove(waiter); + } + + waiter.TrySetCanceled(); + } + + static void CompleteRecoveryWaitersForReconciliation(IReadOnlyList results) + { + List waiters; + + lock (Locker) + { + waiters = MarkRecoveryReconciledAndTakeWaitersUnderLock(); + } + + CompleteRecoveryWaiters(waiters, results); + } + + static List MarkRecoveryReconciledAndTakeWaitersUnderLock() + { + RecoveryReconciliationGeneration++; + return TakeRecoveryWaitersUnderLock(); + } + + static List TakeRecoveryWaitersUnderLock() + { + var waiters = RecoveryWaiters.ToList(); + RecoveryWaiters.Clear(); + return waiters; + } + + static void CompleteRecoveryWaiters(List waiters, IReadOnlyList results) + { + foreach (var waiter in waiters) + { + waiter.TrySetResult(results); + } + } + + static bool IsCaptureKind(RecoveredMediaPickerResultKind kind) + => kind == RecoveredMediaPickerResultKind.CapturePhoto || + kind == RecoveredMediaPickerResultKind.CaptureVideo; + + static bool IsPhotoKind(RecoveredMediaPickerResultKind kind) + => kind == RecoveredMediaPickerResultKind.CapturePhoto || + kind == RecoveredMediaPickerResultKind.PickPhoto || + kind == RecoveredMediaPickerResultKind.PickPhotos; + + static bool IsSinglePickCallbackKind(RecoveredMediaPickerResultKind kind) + => kind == RecoveredMediaPickerResultKind.PickPhoto || + kind == RecoveredMediaPickerResultKind.PickVideo || + kind == RecoveredMediaPickerResultKind.PickPhotos || + kind == RecoveredMediaPickerResultKind.PickVideos; + + static bool IsMultiplePickCallbackKind(RecoveredMediaPickerResultKind kind) + => kind == RecoveredMediaPickerResultKind.PickPhotos || + kind == RecoveredMediaPickerResultKind.PickVideos; + + static bool IsKnownKind(RecoveredMediaPickerResultKind kind) + => Enum.IsDefined(typeof(RecoveredMediaPickerResultKind), kind); + +} + +/// +/// Stores active and recovered MediaPicker records in app-private preferences. +/// +internal static class MediaPickerRecoveryStore +{ + const string ActiveOperationKey = "active_operation"; + const string RecoveredResultsKey = "recovered_results"; + const string PreferencesFeatureName = "media_picker"; + const string PendingOperationSerializedRecordVersion = "5"; + const string PendingOperationWithoutPickerUrisSerializedRecordVersion = "4"; + const string LegacyPendingCaptureSerializedRecordVersion = "1"; + const string LegacyPendingCaptureWithStateAndOutputUriSerializedRecordVersion = "2"; + const string LegacyPendingCaptureWithStateSerializedRecordVersion = "3"; + const string RecoveredResultSerializedRecordVersion = "2"; + const string LegacyRecoveredCaptureResultSerializedRecordVersion = "1"; + const string FieldSeparator = "|"; + const string FilePathSeparator = ","; + const string RecoveredResultSeparator = "\n"; + + static readonly string PreferencesSharedName = Preferences.GetPrivatePreferencesSharedName(PreferencesFeatureName); + + internal static PendingMediaPickerOperation? ReadActiveOperation() + => DeserializePendingOperation(Preferences.Get(ActiveOperationKey, null, PreferencesSharedName)); + + internal static void WriteActiveOperation(PendingMediaPickerOperation operation) + => Preferences.Set(ActiveOperationKey, SerializePendingOperation(operation), PreferencesSharedName); + + internal static void RemoveActiveOperation() + => Preferences.Remove(ActiveOperationKey, PreferencesSharedName); + + internal static List ReadRecoveredResults() + { + var value = Preferences.Get(RecoveredResultsKey, null, PreferencesSharedName); + + if (string.IsNullOrWhiteSpace(value)) + { + return []; + } + + return value + .Split([RecoveredResultSeparator], StringSplitOptions.RemoveEmptyEntries) + .Select(DeserializeRecoveredResult) + .Where(result => result is not null) + .Cast() + .ToList(); + } + + internal static void WriteRecoveredResults(List results) + { + if (results.Count == 0) + { + Preferences.Remove(RecoveredResultsKey, PreferencesSharedName); + return; + } + + Preferences.Set(RecoveredResultsKey, string.Join(RecoveredResultSeparator, results.Select(SerializeRecoveredResult)), PreferencesSharedName); + } + + static string SerializePendingOperation(PendingMediaPickerOperation operation) + => string.Join(FieldSeparator, new[] + { + PendingOperationSerializedRecordVersion, + Encode(operation.Id), + ((int)operation.Kind).ToString(CultureInfo.InvariantCulture), + ((int)operation.State).ToString(CultureInfo.InvariantCulture), + EncodeMany(operation.FilePaths), + EncodeMany(operation.PickerUriStrings), + SerializeNullableInt(operation.PhotoProcessingOptions.MaximumWidth), + SerializeNullableInt(operation.PhotoProcessingOptions.MaximumHeight), + operation.PhotoProcessingOptions.CompressionQuality.ToString(CultureInfo.InvariantCulture), + SerializeBool(operation.PhotoProcessingOptions.RotateImage), + SerializeBool(operation.PhotoProcessingOptions.PreserveMetaData) + }); + + static PendingMediaPickerOperation? DeserializePendingOperation(string? value) + { + try + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var parts = value.Split([FieldSeparator], StringSplitOptions.None); + if (parts.Length == 11 && parts[0] == PendingOperationSerializedRecordVersion) + { + return DeserializeCurrentPendingOperation(parts, hasPickerUris: true); + } + + if (parts.Length == 10 && parts[0] == PendingOperationWithoutPickerUrisSerializedRecordVersion) + { + return DeserializeCurrentPendingOperation(parts, hasPickerUris: false); + } + + return DeserializeLegacyPendingCapture(parts); + } + catch + { + return null; + } + } + + static PendingMediaPickerOperation? DeserializeCurrentPendingOperation(string[] parts, bool hasPickerUris) + { + var maximumWidthIndex = hasPickerUris ? 6 : 5; + var maximumHeightIndex = hasPickerUris ? 7 : 6; + var compressionQualityIndex = hasPickerUris ? 8 : 7; + var rotateImageIndex = hasPickerUris ? 9 : 8; + var preserveMetaDataIndex = hasPickerUris ? 10 : 9; + + if (!TryDeserializeResultKind(parts[2], out var kind) || + !TryDeserializePendingState(parts[3], out var state) || + !int.TryParse(parts[compressionQualityIndex], NumberStyles.Integer, CultureInfo.InvariantCulture, out var compressionQuality)) + { + return null; + } + + return new PendingMediaPickerOperation( + Decode(parts[1]), + kind, + state, + DecodeMany(parts[4]), + hasPickerUris ? DecodeMany(parts[5]) : [], + new PersistedPhotoProcessingOptions( + DeserializeNullableInt(parts[maximumWidthIndex]), + DeserializeNullableInt(parts[maximumHeightIndex]), + compressionQuality, + DeserializeBool(parts[rotateImageIndex]), + DeserializeBool(parts[preserveMetaDataIndex]))); + } + + static PendingMediaPickerOperation? DeserializeLegacyPendingCapture(string[] parts) + { + var isLegacyPendingCapture = parts.Length == 10 && parts[0] == LegacyPendingCaptureSerializedRecordVersion; + var isPendingCaptureWithStateAndOutputUri = parts.Length == 11 && parts[0] == LegacyPendingCaptureWithStateAndOutputUriSerializedRecordVersion; + var isPendingCaptureWithState = parts.Length == 10 && parts[0] == LegacyPendingCaptureWithStateSerializedRecordVersion; + + if (!isLegacyPendingCapture && !isPendingCaptureWithStateAndOutputUri && !isPendingCaptureWithState) + { + return null; + } + + var state = PendingMediaPickerState.Pending; + var filePathIndex = 3; + var maximumWidthIndex = 5; + var maximumHeightIndex = 6; + var compressionQualityIndex = 7; + var rotateImageIndex = 8; + var preserveMetaDataIndex = 9; + + if (isPendingCaptureWithStateAndOutputUri) + { + if (!TryDeserializePendingState(parts[3], out state)) + { + return null; + } + + filePathIndex = 4; + maximumWidthIndex = 6; + maximumHeightIndex = 7; + compressionQualityIndex = 8; + rotateImageIndex = 9; + preserveMetaDataIndex = 10; + } + else if (isPendingCaptureWithState) + { + if (!TryDeserializePendingState(parts[3], out state)) + { + return null; + } + + filePathIndex = 4; + maximumWidthIndex = 5; + maximumHeightIndex = 6; + compressionQualityIndex = 7; + rotateImageIndex = 8; + preserveMetaDataIndex = 9; + } + + if (!TryDeserializeLegacyCaptureKind(parts[2], out var kind) || + !int.TryParse(parts[compressionQualityIndex], NumberStyles.Integer, CultureInfo.InvariantCulture, out var compressionQuality)) + { + return null; + } + + return new PendingMediaPickerOperation( + Decode(parts[1]), + kind, + state, + [Decode(parts[filePathIndex])], + [], + new PersistedPhotoProcessingOptions( + DeserializeNullableInt(parts[maximumWidthIndex]), + DeserializeNullableInt(parts[maximumHeightIndex]), + compressionQuality, + DeserializeBool(parts[rotateImageIndex]), + DeserializeBool(parts[preserveMetaDataIndex]))); + } + + static bool TryDeserializePendingState(string value, out PendingMediaPickerState state) + { + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var stateValue) && + Enum.IsDefined(typeof(PendingMediaPickerState), stateValue)) + { + state = (PendingMediaPickerState)stateValue; + return true; + } + + state = PendingMediaPickerState.Pending; + return false; + } + + static bool TryDeserializeResultKind(string value, out RecoveredMediaPickerResultKind kind) + { + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var kindValue) && + Enum.IsDefined(typeof(RecoveredMediaPickerResultKind), kindValue)) + { + kind = (RecoveredMediaPickerResultKind)kindValue; + return true; + } + + kind = RecoveredMediaPickerResultKind.CapturePhoto; + return false; + } + + static bool TryDeserializeLegacyCaptureKind(string value, out RecoveredMediaPickerResultKind kind) + { + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var mediaTypeValue)) + { + if (mediaTypeValue == 0) + { + kind = RecoveredMediaPickerResultKind.CapturePhoto; + return true; + } + + if (mediaTypeValue == 1) + { + kind = RecoveredMediaPickerResultKind.CaptureVideo; + return true; + } + } + + kind = RecoveredMediaPickerResultKind.CapturePhoto; + return false; + } + + static string SerializeRecoveredResult(RecoveredMediaPickerRecord result) + => string.Join(FieldSeparator, new[] + { + RecoveredResultSerializedRecordVersion, + Encode(result.Id), + ((int)result.Kind).ToString(CultureInfo.InvariantCulture), + EncodeMany(result.FilePaths) + }); + + static RecoveredMediaPickerRecord? DeserializeRecoveredResult(string value) + { + try + { + var parts = value.Split([FieldSeparator], StringSplitOptions.None); + if (parts.Length != 4) + { + return null; + } + + if (parts[0] == RecoveredResultSerializedRecordVersion) + { + if (!TryDeserializeResultKind(parts[2], out var kind)) + { + return null; + } + + var filePaths = DecodeMany(parts[3]); + return filePaths.Count > 0 ? new RecoveredMediaPickerRecord(Decode(parts[1]), kind, filePaths) : null; + } + + if (parts[0] == LegacyRecoveredCaptureResultSerializedRecordVersion && + TryDeserializeLegacyCaptureKind(parts[2], out var legacyKind)) + { + return new RecoveredMediaPickerRecord(Decode(parts[1]), legacyKind, [Decode(parts[3])]); + } + + return null; + } + catch + { + return null; + } + } + + static string EncodeMany(IReadOnlyList values) + => string.Join(FilePathSeparator, values.Select(Encode)); + + static IReadOnlyList DecodeMany(string value) + => string.IsNullOrEmpty(value) + ? [] + : value.Split([FilePathSeparator], StringSplitOptions.RemoveEmptyEntries) + .Select(Decode) + .ToArray(); + + static string Encode(string value) + => Convert.ToBase64String(Encoding.UTF8.GetBytes(value)); + + static string Decode(string value) + => Encoding.UTF8.GetString(Convert.FromBase64String(value)); + + static string SerializeNullableInt(int? value) + => value?.ToString(CultureInfo.InvariantCulture) ?? string.Empty; + + static int? DeserializeNullableInt(string value) + => string.IsNullOrEmpty(value) ? null : int.Parse(value, CultureInfo.InvariantCulture); + + static string SerializeBool(bool value) + => value ? "1" : "0"; + + static bool DeserializeBool(string value) + => value == "1"; +} + +/// +/// One-shot awaiter completed when AndroidX recovery reconciles a pending MediaPicker operation. +/// +internal sealed class MediaPickerRecoveryWaiter +{ + readonly CancellationToken cancellationToken; + readonly TaskCompletionSource> completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + CancellationTokenRegistration cancellationRegistration; + int completed; + + public MediaPickerRecoveryWaiter(CancellationToken cancellationToken) + { + this.cancellationToken = cancellationToken; + } + + public Task> Task => completionSource.Task; + + public void SetCancellationRegistration(CancellationTokenRegistration registration) + { + if (Volatile.Read(ref completed) == 0) + { + cancellationRegistration = registration; + + if (Volatile.Read(ref completed) == 0) + { + return; + } + } + + registration.Dispose(); + } + + public void TrySetResult(IReadOnlyList results) + { + if (Interlocked.Exchange(ref completed, 1) != 0) + { + return; + } + + cancellationRegistration.Dispose(); + completionSource.TrySetResult(results); + } + + public void TrySetCanceled() + { + if (Interlocked.Exchange(ref completed, 1) != 0) + { + return; + } + + cancellationRegistration.Dispose(); + completionSource.TrySetCanceled(cancellationToken); + } +} + +/// +/// Durable record for the single AndroidX MediaPicker operation currently in flight. +/// +internal sealed class PendingMediaPickerOperation +{ + public PendingMediaPickerOperation( + string id, + RecoveredMediaPickerResultKind kind, + PendingMediaPickerState state, + IReadOnlyList filePaths, + IReadOnlyList pickerUriStrings, + PersistedPhotoProcessingOptions photoProcessingOptions) + { + Id = id; + Kind = kind; + State = state; + FilePaths = filePaths.ToArray(); + PickerUriStrings = pickerUriStrings.ToArray(); + PhotoProcessingOptions = photoProcessingOptions; + } + + public string Id { get; } + + public RecoveredMediaPickerResultKind Kind { get; } + + public PendingMediaPickerState State { get; } + + public IReadOnlyList FilePaths { get; } + + public IReadOnlyList PickerUriStrings { get; } + + public PersistedPhotoProcessingOptions PhotoProcessingOptions { get; } + + public PendingMediaPickerOperation WithState(PendingMediaPickerState state) + => new(Id, Kind, state, FilePaths, PickerUriStrings, PhotoProcessingOptions); + + public PendingMediaPickerOperation WithAcceptedFiles(IReadOnlyList filePaths) + => new(Id, Kind, PendingMediaPickerState.ResultAccepted, filePaths, [], PhotoProcessingOptions); + + public PendingMediaPickerOperation WithAcceptedPickerUris(IReadOnlyList pickerUriStrings) + => new(Id, Kind, PendingMediaPickerState.ResultAccepted, [], pickerUriStrings, PhotoProcessingOptions); +} + +readonly struct MediaPickerRecoveryReconciliation +{ + public MediaPickerRecoveryReconciliation(IReadOnlyList results, bool wasReconciled) + { + Results = results; + WasReconciled = wasReconciled; + } + + public IReadOnlyList Results { get; } + + public bool WasReconciled { get; } +} + +/// +/// Durable queue item for a recovered MediaPicker result that app code has not cleared yet. +/// +internal sealed class RecoveredMediaPickerRecord +{ + public RecoveredMediaPickerRecord(string id, RecoveredMediaPickerResultKind kind, IReadOnlyList filePaths) + { + Id = id; + Kind = kind; + FilePaths = filePaths.ToArray(); + } + + public string Id { get; } + + public RecoveredMediaPickerResultKind Kind { get; } + + public IReadOnlyList FilePaths { get; } + + public RecoveredMediaPickerResult ToPublicResult() + => new(Id, Kind, FilePaths.Select(path => new FileResult(path)).ToArray()); +} + +/// +/// Durable photo post-processing policy needed to finish processing a recovered photo result. +/// +internal readonly struct PersistedPhotoProcessingOptions +{ + public static PersistedPhotoProcessingOptions Default { get; } = new(null, null, 100, false, true); + + public PersistedPhotoProcessingOptions(int? maximumWidth, int? maximumHeight, int compressionQuality, bool rotateImage, bool preserveMetaData) + { + MaximumWidth = maximumWidth; + MaximumHeight = maximumHeight; + CompressionQuality = compressionQuality; + RotateImage = rotateImage; + PreserveMetaData = preserveMetaData; + } + + public int? MaximumWidth { get; } + + public int? MaximumHeight { get; } + + public int CompressionQuality { get; } + + public bool RotateImage { get; } + + public bool PreserveMetaData { get; } +} diff --git a/src/Essentials/src/Platform/ActivityForResultRequest.android.cs b/src/Essentials/src/Platform/ActivityForResultRequest.android.cs index 89828d6ccc7b..17baa933dc74 100644 --- a/src/Essentials/src/Platform/ActivityForResultRequest.android.cs +++ b/src/Essentials/src/Platform/ActivityForResultRequest.android.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics; +using System.Threading; using System.Threading.Tasks; using AndroidX.Activity; using AndroidX.Activity.Result; @@ -10,7 +11,7 @@ namespace Microsoft.Maui.ApplicationModel; /// /// Represents a request for an activity result. -/// Provides a type-safe mechanism for registering and launching +/// Provides a type-safe mechanism for registering and launching /// activity result requests using the specified contract and callback. /// /// The type of the activity result contract. @@ -19,15 +20,22 @@ namespace Microsoft.Maui.ApplicationModel; /// /// Google docs /// +/// +/// A launch request is one in-process call to that is waiting for its AndroidX result. +/// AndroidX may also replay a result after activity or process recreation, after the original launch request is gone. +/// /// This must be unconditionally registered every time our activity is created. /// internal abstract class ActivityForResultRequest where TContract : ActivityResultContract, new() where TResult : JavaObject { - protected ActivityResultLauncher launcher; - protected TaskCompletionSource tcs = null; - protected WeakReference registeredActivity = null; + // Protects the active launch completion source so a launch, result callback, and launch failure + // cannot race to overwrite or clear the in-process request state. + readonly Lock activeLaunchLock = new(); + ActivityResultLauncher launcher; + TaskCompletionSource activeLaunchCompletionSource = null; + WeakReference registeredActivity = null; /// /// Gets a value indicating whether the request is registered. @@ -49,12 +57,74 @@ public void Register(ComponentActivity componentActivity) } var contract = new TContract(); - var callback = new ActivityResultCallback(result => tcs?.SetResult(result)); + var callback = new ActivityResultCallback(HandleActivityResult); launcher = componentActivity.RegisterForActivityResult(contract, callback); registeredActivity = new WeakReference(componentActivity); } + /// + /// Routes an AndroidX activity result to either the active launch task or orphaned-result handling. + /// + /// The activity result. + /// + /// An orphaned result is a pending AndroidX result replayed after activity or process recreation, + /// when the launch task that originally requested the result no longer exists in this process. + /// + protected void HandleActivityResult(TResult result) + { + var completionSource = TakeActiveLaunchCompletionSource(); + if (completionSource is null) + { + OnActivityResultForOrphanedLaunch(result); + return; + } + + try + { + OnActivityResultForActiveLaunch(result); + completionSource.TrySetResult(result); + } + catch (Exception ex) + { + completionSource.TrySetException(ex); + } + } + + /// + /// Handles a result delivered for an active launch request before the launch task is completed. + /// + /// The activity result. + protected virtual void OnActivityResultForActiveLaunch(TResult result) + { + } + + /// + /// Handles a result delivered when there is no active launch request in this process. + /// + /// The activity result. + /// + /// AndroidX may deliver a pending result after the app process was recreated. In that case, the original + /// launch task is gone and callers that persisted enough request state can reconcile the result here. + /// + protected virtual void OnActivityResultForOrphanedLaunch(TResult result) + { + } + + /// + /// Takes the active task completion source if this result belongs to a launch request in this process. + /// + /// The active launch task completion source, or when the result is orphaned. + TaskCompletionSource TakeActiveLaunchCompletionSource() + { + lock (activeLaunchLock) + { + var completionSource = activeLaunchCompletionSource; + activeLaunchCompletionSource = null; + return completionSource; + } + } + /// /// Launches the activity result request with the specified input. /// @@ -66,16 +136,26 @@ public void Register(ComponentActivity componentActivity) public Task Launch(T input) where T : JavaObject { - tcs = new TaskCompletionSource(); + var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + lock (activeLaunchLock) + { + if (activeLaunchCompletionSource is not null) + { + return Task.FromException(new InvalidOperationException("An activity result request is already in progress.")); + } + + activeLaunchCompletionSource = completionSource; + } if (!IsRegistered) { Trace.WriteLine(""" - ActivityForResultRequest is not registered; cancelling the request. + ActivityForResultRequest is not registered; cancelling the request. Ensure your Activity inherits from ComponentActivity and call Microsoft.Maui.ApplicationModel.Platform.Init(Activity, Bundle) in OnCreate. """); - tcs.SetCanceled(); - return tcs.Task; + ClearActiveLaunchCompletionSource(completionSource); + completionSource.SetCanceled(); + return completionSource.Task; } try @@ -84,9 +164,19 @@ Ensure your Activity inherits from ComponentActivity and call Microsoft.Maui.App } catch (Exception ex) { - tcs.SetException(ex); + ClearActiveLaunchCompletionSource(completionSource); + completionSource.SetException(ex); } - return tcs.Task; + return completionSource.Task; + } + + void ClearActiveLaunchCompletionSource(TaskCompletionSource completionSource) + { + lock (activeLaunchLock) + { + if (ReferenceEquals(activeLaunchCompletionSource, completionSource)) + activeLaunchCompletionSource = null; + } } } \ No newline at end of file diff --git a/src/Essentials/src/Platform/ActivityStateManager.android.cs b/src/Essentials/src/Platform/ActivityStateManager.android.cs index a66a1f2d7007..7ff0d3baf221 100644 --- a/src/Essentials/src/Platform/ActivityStateManager.android.cs +++ b/src/Essentials/src/Platform/ActivityStateManager.android.cs @@ -6,7 +6,6 @@ using Android.Content; using Android.OS; using AndroidX.Activity; -using Microsoft.Maui.Media; namespace Microsoft.Maui.ApplicationModel { @@ -82,10 +81,11 @@ public void Init(Activity activity, Bundle? bundle) if (activity.Application is not Application application) throw new InvalidOperationException("Activity was not attached to an application."); - if (activity is ComponentActivity componentActivity && MediaPickerImplementation.IsPhotoPickerAvailable) + if (activity is ComponentActivity componentActivity) { - PickVisualMediaForResult.Instance.Register(componentActivity); - PickMultipleVisualMediaForResult.Instance.Register(componentActivity); + // Register MediaPicker contracts so AndroidX can deliver pending results after activity/process recreation. + // Feature support is still checked before launch. + RegisterActivityResultLaunchers(componentActivity); } Init(application); @@ -121,6 +121,25 @@ void handler(object? sender, ActivityStateChangedEventArgs e) void OnActivityStateChanged(Activity activity, ActivityState ev) => ActivityStateChanged?.Invoke(null, new ActivityStateChangedEventArgs(activity, ev)); + + internal static void RegisterActivityResultLaunchers(ComponentActivity componentActivity) + => RegisterActivityResultLaunchers( + () => CapturePhotoForResult.Instance.Register(componentActivity), + () => CaptureVideoForResult.Instance.Register(componentActivity), + () => PickVisualMediaForResult.Instance.Register(componentActivity), + () => PickMultipleVisualMediaForResult.Instance.Register(componentActivity)); + + internal static void RegisterActivityResultLaunchers( + Action registerCapturePhoto, + Action registerCaptureVideo, + Action registerPickVisualMedia, + Action registerPickMultipleVisualMedia) + { + registerCapturePhoto(); + registerCaptureVideo(); + registerPickVisualMedia(); + registerPickMultipleVisualMedia(); + } } static class ActivityStateManagerExtensions diff --git a/src/Essentials/src/Platform/CapturePhotoForResult.android.cs b/src/Essentials/src/Platform/CapturePhotoForResult.android.cs new file mode 100644 index 000000000000..7c928fe81495 --- /dev/null +++ b/src/Essentials/src/Platform/CapturePhotoForResult.android.cs @@ -0,0 +1,19 @@ +using System; +using Microsoft.Maui.Media; +using static AndroidX.Activity.Result.Contract.ActivityResultContracts; + +namespace Microsoft.Maui.ApplicationModel; + +// Keep a separate singleton per AndroidX capture contract. Each contract needs its own +// registered launcher, while shared recovery behavior lives in MediaCaptureForResult. +internal class CapturePhotoForResult : MediaCaptureForResult +{ + static readonly Lazy LazyInstance = new(new CapturePhotoForResult()); + + public static CapturePhotoForResult Instance => LazyInstance.Value; + + CapturePhotoForResult() + : base(RecoveredMediaPickerResultKind.CapturePhoto) + { + } +} diff --git a/src/Essentials/src/Platform/CaptureVideoForResult.android.cs b/src/Essentials/src/Platform/CaptureVideoForResult.android.cs new file mode 100644 index 000000000000..6fcf0269e095 --- /dev/null +++ b/src/Essentials/src/Platform/CaptureVideoForResult.android.cs @@ -0,0 +1,19 @@ +using System; +using Microsoft.Maui.Media; +using static AndroidX.Activity.Result.Contract.ActivityResultContracts; + +namespace Microsoft.Maui.ApplicationModel; + +// Keep a separate singleton per AndroidX capture contract. Each contract needs its own +// registered launcher, while shared recovery behavior lives in MediaCaptureForResult. +internal class CaptureVideoForResult : MediaCaptureForResult +{ + static readonly Lazy LazyInstance = new(new CaptureVideoForResult()); + + public static CaptureVideoForResult Instance => LazyInstance.Value; + + CaptureVideoForResult() + : base(RecoveredMediaPickerResultKind.CaptureVideo) + { + } +} diff --git a/src/Essentials/src/Platform/MediaCaptureForResult.android.cs b/src/Essentials/src/Platform/MediaCaptureForResult.android.cs new file mode 100644 index 000000000000..b37ab3bcd35e --- /dev/null +++ b/src/Essentials/src/Platform/MediaCaptureForResult.android.cs @@ -0,0 +1,30 @@ +using System.Threading.Tasks; +using AndroidX.Activity.Result.Contract; +using Microsoft.Maui.Media; +using AndroidUri = Android.Net.Uri; +using JavaBoolean = Java.Lang.Boolean; + +namespace Microsoft.Maui.ApplicationModel; + +/// +/// Handles AndroidX boolean media capture contracts and routes orphaned results into MediaPicker recovery. +/// +internal abstract class MediaCaptureForResult : ActivityForResultRequest + where TContract : ActivityResultContract, new() +{ + readonly RecoveredMediaPickerResultKind resultKind; + + protected MediaCaptureForResult(RecoveredMediaPickerResultKind resultKind) + { + this.resultKind = resultKind; + } + + public Task Launch(AndroidUri input) + => base.Launch(input); + + protected override void OnActivityResultForActiveLaunch(JavaBoolean result) + => MediaPickerRecoveryManager.RecordCaptureCallbackResult(resultKind, result?.BooleanValue() == true); + + protected override void OnActivityResultForOrphanedLaunch(JavaBoolean result) + => MediaPickerRecoveryManager.RecoverOrphanedCaptureResult(resultKind, result?.BooleanValue() == true); +} diff --git a/src/Essentials/src/Platform/PickMultipleVisualMediaForResult.android.cs b/src/Essentials/src/Platform/PickMultipleVisualMediaForResult.android.cs index b8242f4e3a93..55ed61609e25 100644 --- a/src/Essentials/src/Platform/PickMultipleVisualMediaForResult.android.cs +++ b/src/Essentials/src/Platform/PickMultipleVisualMediaForResult.android.cs @@ -1,6 +1,9 @@ using System; +using System.Collections.Generic; using Android.Runtime; +using Microsoft.Maui.Media; using static AndroidX.Activity.Result.Contract.ActivityResultContracts; +using AndroidUri = Android.Net.Uri; namespace Microsoft.Maui.ApplicationModel; @@ -9,4 +12,29 @@ internal class PickMultipleVisualMediaForResult : ActivityForResultRequest LazyInstance = new(new PickMultipleVisualMediaForResult()); public static PickMultipleVisualMediaForResult Instance => LazyInstance.Value; -} \ No newline at end of file + + protected override void OnActivityResultForActiveLaunch(JavaList result) + => MediaPickerRecoveryManager.RecordMultiplePickCallbackResult(ToAndroidUris(result), materializeImmediately: true); + + protected override void OnActivityResultForOrphanedLaunch(JavaList result) + => MediaPickerRecoveryManager.RecoverOrphanedMultiplePickResult(ToAndroidUris(result)); + + static IReadOnlyList ToAndroidUris(JavaList result) + { + if (result is null || result.IsEmpty) + { + return Array.Empty(); + } + + var uris = new List(); + for (var i = 0; i < result.Size(); i++) + { + if (result.Get(i) is AndroidUri uri) + { + uris.Add(uri); + } + } + + return uris; + } +} diff --git a/src/Essentials/src/Platform/PickVisualMediaForResult.android.cs b/src/Essentials/src/Platform/PickVisualMediaForResult.android.cs index a29c3814a917..3c54d7a5893a 100644 --- a/src/Essentials/src/Platform/PickVisualMediaForResult.android.cs +++ b/src/Essentials/src/Platform/PickVisualMediaForResult.android.cs @@ -1,5 +1,5 @@ using System; -using AndroidX.Collection; +using Microsoft.Maui.Media; using static AndroidX.Activity.Result.Contract.ActivityResultContracts; using AndroidUri = Android.Net.Uri; @@ -10,4 +10,10 @@ internal class PickVisualMediaForResult : ActivityForResultRequest LazyInstance = new(new PickVisualMediaForResult()); public static PickVisualMediaForResult Instance => LazyInstance.Value; -} \ No newline at end of file + + protected override void OnActivityResultForActiveLaunch(AndroidUri result) + => MediaPickerRecoveryManager.RecordSinglePickCallbackResult(result, materializeImmediately: true); + + protected override void OnActivityResultForOrphanedLaunch(AndroidUri result) + => MediaPickerRecoveryManager.RecoverOrphanedSinglePickResult(result); +} diff --git a/src/Essentials/src/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/Essentials/src/PublicAPI/net-android/PublicAPI.Unshipped.txt index 3b89b0ef304a..b5b9ab35dd3f 100644 --- a/src/Essentials/src/PublicAPI/net-android/PublicAPI.Unshipped.txt +++ b/src/Essentials/src/PublicAPI/net-android/PublicAPI.Unshipped.txt @@ -1,5 +1,20 @@ #nullable enable +Microsoft.Maui.Media.RecoveredMediaPickerResult +Microsoft.Maui.Media.RecoveredMediaPickerResult.Files.get -> System.Collections.Generic.IReadOnlyList! +Microsoft.Maui.Media.RecoveredMediaPickerResult.Id.get -> string! +Microsoft.Maui.Media.RecoveredMediaPickerResult.Kind.get -> Microsoft.Maui.Media.RecoveredMediaPickerResultKind +Microsoft.Maui.Media.RecoveredMediaPickerResult.RecoveredMediaPickerResult(string! id, Microsoft.Maui.Media.RecoveredMediaPickerResultKind kind, System.Collections.Generic.IReadOnlyList! files) -> void +Microsoft.Maui.Media.RecoveredMediaPickerResultKind +Microsoft.Maui.Media.RecoveredMediaPickerResultKind.CapturePhoto = 0 -> Microsoft.Maui.Media.RecoveredMediaPickerResultKind +Microsoft.Maui.Media.RecoveredMediaPickerResultKind.CaptureVideo = 1 -> Microsoft.Maui.Media.RecoveredMediaPickerResultKind +Microsoft.Maui.Media.RecoveredMediaPickerResultKind.PickPhoto = 2 -> Microsoft.Maui.Media.RecoveredMediaPickerResultKind +Microsoft.Maui.Media.RecoveredMediaPickerResultKind.PickPhotos = 3 -> Microsoft.Maui.Media.RecoveredMediaPickerResultKind +Microsoft.Maui.Media.RecoveredMediaPickerResultKind.PickVideo = 4 -> Microsoft.Maui.Media.RecoveredMediaPickerResultKind +Microsoft.Maui.Media.RecoveredMediaPickerResultKind.PickVideos = 5 -> Microsoft.Maui.Media.RecoveredMediaPickerResultKind *REMOVED*Microsoft.Maui.Storage.IFilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task!>! +static Microsoft.Maui.Media.MediaPicker.ClearRecoveredMediaPickerResultAsync(string! id) -> System.Threading.Tasks.Task! +static Microsoft.Maui.Media.MediaPicker.GetRecoveredMediaPickerResultsAsync() -> System.Threading.Tasks.Task!>! +static Microsoft.Maui.Media.MediaPicker.WaitForRecoveredMediaPickerResultsAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! *REMOVED*static Microsoft.Maui.Storage.FilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task!>! Microsoft.Maui.Devices.Sensors.LocationTypeConverter Microsoft.Maui.Devices.Sensors.LocationTypeConverter.LocationTypeConverter() -> void diff --git a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs new file mode 100644 index 000000000000..7f97ccf57534 --- /dev/null +++ b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs @@ -0,0 +1,1452 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Maui.ApplicationModel; +using Microsoft.Maui.Media; +using Microsoft.Maui.Storage; +using Xunit; +using static AndroidX.Activity.Result.Contract.ActivityResultContracts; +using ABitmap = Android.Graphics.Bitmap; +using AColor = Android.Graphics.Color; +using AndroidUri = Android.Net.Uri; +using JavaBoolean = Java.Lang.Boolean; +using JavaList = Android.Runtime.JavaList; + +namespace Microsoft.Maui.Essentials.DeviceTests.Shared +{ + [Category("MediaPicker")] + public class MediaPickerRecovery_Tests : IDisposable + { + const string ActiveOperationPreferenceKey = "active_operation"; + const string RecoveredResultsPreferenceKey = "recovered_results"; + static readonly string RecoveryPreferencesSharedName = Preferences.GetPrivatePreferencesSharedName("media_picker"); + + // These tests drive the recovery manager and AndroidX callback wrappers directly. + // They avoid launching real camera/picker apps while still preserving the important + // process-recreation shape: preference-backed state survives, in-process launch state does not. + public MediaPickerRecovery_Tests() + => ResetRecoveryState(); + + public void Dispose() + => ResetRecoveryState(); + + [Fact] + public void Activity_State_Manager_Registers_All_MediaPicker_Launchers() + { + var capturePhotoRegistrations = 0; + var captureVideoRegistrations = 0; + var pickVisualMediaRegistrations = 0; + var pickMultipleVisualMediaRegistrations = 0; + + // MediaPicker launchers must be registered unconditionally so AndroidX can replay a + // pending picker or capture result after activity or process recreation. + ActivityStateManagerImplementation.RegisterActivityResultLaunchers( + () => capturePhotoRegistrations++, + () => captureVideoRegistrations++, + () => pickVisualMediaRegistrations++, + () => pickMultipleVisualMediaRegistrations++); + + Assert.Equal(1, capturePhotoRegistrations); + Assert.Equal(1, captureVideoRegistrations); + Assert.Equal(1, pickVisualMediaRegistrations); + Assert.Equal(1, pickMultipleVisualMediaRegistrations); + } + + [Fact] + public async Task Recovered_Results_Are_NonConsuming_And_Clear_By_Id() + { + var capturePath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + + var pendingCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + await MediaPickerRecoveryManager.RecoverOrphanedCaptureResultAsync(RecoveredMediaPickerResultKind.CapturePhoto, true); + + Assert.Null(GetActiveOperation()); + + var firstRead = await MediaPicker.GetRecoveredMediaPickerResultsAsync(); + var secondRead = await MediaPicker.GetRecoveredMediaPickerResultsAsync(); + + var result = Assert.Single(firstRead); + Assert.Single(secondRead); + Assert.Equal(pendingCapture.Id, result.Id); + Assert.Equal(capturePath, GetSingleRecoveredFile(result).FullPath); + Assert.StartsWith("image/", GetSingleRecoveredFile(result).ContentType, StringComparison.OrdinalIgnoreCase); + + await MediaPicker.ClearRecoveredMediaPickerResultAsync(result.Id); + + Assert.Empty(await MediaPicker.GetRecoveredMediaPickerResultsAsync()); + } + + [Fact] + public async Task Canceled_Capture_Clears_Active_State_Without_Recovered_Result() + { + var capturePath = CreateNonEmptyMediaFile(FileExtensions.Mp4); + + MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CaptureVideo, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + await MediaPickerRecoveryManager.RecoverOrphanedCaptureResultAsync(RecoveredMediaPickerResultKind.CaptureVideo, false); + + Assert.Null(GetActiveOperation()); + Assert.Empty(await MediaPicker.GetRecoveredMediaPickerResultsAsync()); + } + + [Fact] + public async Task Missing_Output_File_Clears_Active_State_Without_Recovered_Result() + { + var capturePath = CreateMissingMediaFilePath(FileExtensions.Jpg); + + MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + await MediaPickerRecoveryManager.RecoverOrphanedCaptureResultAsync(RecoveredMediaPickerResultKind.CapturePhoto, true); + + Assert.Null(GetActiveOperation()); + Assert.Empty(await MediaPicker.GetRecoveredMediaPickerResultsAsync()); + } + + [Fact] + public async Task Wait_For_Recovered_Results_Returns_Existing_Result() + { + var capturePath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + + var pendingCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + await MediaPickerRecoveryManager.RecoverOrphanedCaptureResultAsync(RecoveredMediaPickerResultKind.CapturePhoto, true); + + var results = await MediaPicker.WaitForRecoveredMediaPickerResultsAsync(CancellationToken.None); + + var result = Assert.Single(results); + Assert.Equal(pendingCapture.Id, result.Id); + Assert.Equal(capturePath, GetSingleRecoveredFile(result).FullPath); + } + + [Fact] + public async Task Wait_For_Recovered_Results_Completes_When_Capture_Is_Recovered() + { + var capturePath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + using var cancellationTokenSource = new CancellationTokenSource(); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + + Assert.False(waitTask.IsCompleted); + Assert.Equal(1, GetPendingWaiterCount()); + + var pendingCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + await MediaPickerRecoveryManager.RecoverOrphanedCaptureResultAsync(RecoveredMediaPickerResultKind.CapturePhoto, true); + + var results = await WaitForCompletion(waitTask); + var result = Assert.Single(results); + Assert.Equal(pendingCapture.Id, result.Id); + Assert.Equal(capturePath, GetSingleRecoveredFile(result).FullPath); + Assert.Equal(0, GetPendingWaiterCount()); + } + + [Fact] + public async Task Wait_For_Recovered_Results_Completes_Empty_When_Capture_Is_Canceled() + { + var capturePath = CreateNonEmptyMediaFile(FileExtensions.Mp4); + using var cancellationTokenSource = new CancellationTokenSource(); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + + MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CaptureVideo, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + await MediaPickerRecoveryManager.RecoverOrphanedCaptureResultAsync(RecoveredMediaPickerResultKind.CaptureVideo, false); + + Assert.Empty(await WaitForCompletion(waitTask)); + Assert.Equal(0, GetPendingWaiterCount()); + } + + [Fact] + public async Task Wait_For_Recovered_Results_Completes_Empty_When_Output_File_Is_Missing() + { + var capturePath = CreateMissingMediaFilePath(FileExtensions.Jpg); + using var cancellationTokenSource = new CancellationTokenSource(); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + + MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + await MediaPickerRecoveryManager.RecoverOrphanedCaptureResultAsync(RecoveredMediaPickerResultKind.CapturePhoto, true); + + Assert.Empty(await WaitForCompletion(waitTask)); + Assert.Equal(0, GetPendingWaiterCount()); + } + + [Fact] + public async Task Wait_For_Recovered_Results_Can_Be_Canceled() + { + using var cancellationTokenSource = new CancellationTokenSource(); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + + Assert.False(waitTask.IsCompleted); + Assert.Equal(1, GetPendingWaiterCount()); + + await cancellationTokenSource.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => waitTask); + Assert.Equal(0, GetPendingWaiterCount()); + } + + [Fact] + public async Task Wait_For_Recovered_Results_Completes_Multiple_Waiters() + { + var capturePath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + using var cancellationTokenSource = new CancellationTokenSource(); + + var firstWaitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + var secondWaitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + + Assert.Equal(2, GetPendingWaiterCount()); + + var pendingCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + await MediaPickerRecoveryManager.RecoverOrphanedCaptureResultAsync(RecoveredMediaPickerResultKind.CapturePhoto, true); + + var firstResults = await WaitForCompletion(firstWaitTask); + var secondResults = await WaitForCompletion(secondWaitTask); + + Assert.Equal(pendingCapture.Id, Assert.Single(firstResults).Id); + Assert.Equal(pendingCapture.Id, Assert.Single(secondResults).Id); + Assert.Equal(0, GetPendingWaiterCount()); + } + + [Fact] + public async Task App_Startup_Scan_Returns_Recovered_Result_Without_Active_Waiter() + { + var capturePath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + + var pendingCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + MediaPickerRecoveryManager.RecordCaptureCallbackResult(RecoveredMediaPickerResultKind.CapturePhoto, true); + SimulateProcessRecreation(); + + var results = await MediaPicker.GetRecoveredMediaPickerResultsAsync(); + + var result = Assert.Single(results); + Assert.Equal(pendingCapture.Id, result.Id); + Assert.Equal(capturePath, GetSingleRecoveredFile(result).FullPath); + Assert.Equal(0, GetPendingWaiterCount()); + Assert.Null(GetActiveOperation()); + } + + [Fact] + public async Task App_Startup_Scan_Does_Not_Promote_Pending_Capture_When_Callback_Is_Not_Replayed() + { + var capturePath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + + var pendingCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + + var results = await MediaPicker.GetRecoveredMediaPickerResultsAsync(); + + Assert.Empty(results); + + var activeCapture = Assert.IsType(GetActiveOperation()); + Assert.Equal(pendingCapture.Id, activeCapture.Id); + Assert.Equal(PendingMediaPickerState.Pending, activeCapture.State); + Assert.Equal(capturePath, GetSingleActiveOperationFilePath(activeCapture)); + } + + [Fact] + public async Task Wait_For_Recovered_Results_Does_Not_Complete_For_Pending_Capture_When_Callback_Is_Not_Replayed() + { + var capturePath = CreateNonEmptyMediaFile(FileExtensions.Mp4); + using var cancellationTokenSource = new CancellationTokenSource(); + + var pendingCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CaptureVideo, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + + Assert.False(waitTask.IsCompleted); + Assert.Equal(1, GetPendingWaiterCount()); + + var activeCapture = Assert.IsType(GetActiveOperation()); + Assert.Equal(pendingCapture.Id, activeCapture.Id); + Assert.Equal(PendingMediaPickerState.Pending, activeCapture.State); + Assert.Equal(capturePath, GetSingleActiveOperationFilePath(activeCapture)); + + await cancellationTokenSource.CancelAsync(); + await Assert.ThrowsAnyAsync(() => waitTask); + Assert.Equal(0, GetPendingWaiterCount()); + } + + [Fact] + public async Task App_Scoped_Wait_Completes_When_AndroidX_Publishes_Orphaned_Result() + { + var capturePath = CreateMissingMediaFilePath(FileExtensions.Mp4); + var captureForResult = new TestMediaCaptureForResult(RecoveredMediaPickerResultKind.CaptureVideo); + using var cancellationTokenSource = new CancellationTokenSource(); + + var pendingCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CaptureVideo, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + + Assert.Empty(await MediaPicker.GetRecoveredMediaPickerResultsAsync()); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + Assert.Equal(1, GetPendingWaiterCount()); + + WriteNonEmptyMediaFile(capturePath); + captureForResult.DispatchResultForTests(JavaBoolean.True); + + var results = await WaitForCompletion(waitTask); + var result = Assert.Single(results); + Assert.Equal(pendingCapture.Id, result.Id); + Assert.Equal(capturePath, GetSingleRecoveredFile(result).FullPath); + Assert.Equal(0, GetPendingWaiterCount()); + Assert.Null(GetActiveOperation()); + } + + [Fact] + public async Task App_Scoped_Wait_Cancellation_Does_Not_Strand_Recoverable_Result() + { + var capturePath = CreateMissingMediaFilePath(FileExtensions.Jpg); + using var cancellationTokenSource = new CancellationTokenSource(); + + var pendingCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + Assert.Equal(1, GetPendingWaiterCount()); + + await cancellationTokenSource.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => waitTask); + Assert.Equal(0, GetPendingWaiterCount()); + Assert.Equal(pendingCapture.Id, GetActiveOperation()?.Id); + + WriteNonEmptyMediaFile(capturePath); + await MediaPickerRecoveryManager.RecoverOrphanedCaptureResultAsync(RecoveredMediaPickerResultKind.CapturePhoto, true); + + var results = await MediaPicker.GetRecoveredMediaPickerResultsAsync(); + var result = Assert.Single(results); + Assert.Equal(pendingCapture.Id, result.Id); + Assert.Equal(capturePath, GetSingleRecoveredFile(result).FullPath); + Assert.Equal(0, GetPendingWaiterCount()); + Assert.Null(GetActiveOperation()); + } + + [Fact] + public async Task AndroidX_Orphaned_Success_Publishes_Recovered_Result_And_Completes_Waiter() + { + var capturePath = CreateNonEmptyMediaFile(FileExtensions.Mp4); + var captureForResult = new TestMediaCaptureForResult(RecoveredMediaPickerResultKind.CaptureVideo); + using var cancellationTokenSource = new CancellationTokenSource(); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + var pendingCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CaptureVideo, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + captureForResult.DispatchResultForTests(JavaBoolean.True); + + var recoveredResults = await WaitForCompletion(waitTask); + var recoveredResult = Assert.Single(recoveredResults); + Assert.Equal(pendingCapture.Id, recoveredResult.Id); + Assert.Equal(capturePath, GetSingleRecoveredFile(recoveredResult).FullPath); + Assert.Null(GetActiveOperation()); + } + + [Fact] + public async Task AndroidX_Orphaned_Canceled_Result_Clears_Active_State_And_Completes_Waiter_Empty() + { + var capturePath = CreateNonEmptyMediaFile(FileExtensions.Mp4); + var captureForResult = new TestMediaCaptureForResult(RecoveredMediaPickerResultKind.CaptureVideo); + using var cancellationTokenSource = new CancellationTokenSource(); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CaptureVideo, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + captureForResult.DispatchResultForTests(JavaBoolean.False); + + Assert.Empty(await WaitForCompletion(waitTask)); + Assert.Null(GetActiveOperation()); + } + + [Fact] + public async Task AndroidX_Orphaned_Missing_Output_Clears_Active_State_And_Completes_Waiter_Empty() + { + var capturePath = CreateMissingMediaFilePath(FileExtensions.Jpg); + var captureForResult = new TestMediaCaptureForResult(RecoveredMediaPickerResultKind.CapturePhoto); + using var cancellationTokenSource = new CancellationTokenSource(); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + captureForResult.DispatchResultForTests(JavaBoolean.True); + + Assert.Empty(await WaitForCompletion(waitTask)); + Assert.Null(GetActiveOperation()); + } + + [Fact] + public async Task AndroidX_Orphaned_Single_Photo_Pick_Publishes_Recovered_Result_And_Completes_Waiter() + { + var pickPath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + var pickForResult = new TestPickVisualMediaForResult(); + using var cancellationTokenSource = new CancellationTokenSource(); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + var pendingPick = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.PickPhoto, + [], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + pickForResult.DispatchResultForTests(CreateFileUri(pickPath)); + + var recoveredResult = Assert.Single(await WaitForCompletion(waitTask)); + Assert.Equal(pendingPick.Id, recoveredResult.Id); + Assert.Equal(RecoveredMediaPickerResultKind.PickPhoto, recoveredResult.Kind); + Assert.Equal(pickPath, GetSingleRecoveredFile(recoveredResult).FullPath); + Assert.Null(GetActiveOperation()); + } + + [Theory] + [InlineData(RecoveredMediaPickerResultKind.PickPhotos, FileExtensions.Jpg)] + [InlineData(RecoveredMediaPickerResultKind.PickVideos, FileExtensions.Mp4)] + public async Task AndroidX_Orphaned_Plural_Pick_From_Single_Picker_Publishes_Recovered_Result( + RecoveredMediaPickerResultKind kind, + string extension) + { + var pickPath = CreateNonEmptyMediaFile(extension); + var pickForResult = new TestPickVisualMediaForResult(); + using var cancellationTokenSource = new CancellationTokenSource(); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + var pendingPick = MediaPickerRecoveryManager.BeginOperation( + kind, + [], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + pickForResult.DispatchResultForTests(CreateFileUri(pickPath)); + + var recoveredResult = Assert.Single(await WaitForCompletion(waitTask)); + Assert.Equal(pendingPick.Id, recoveredResult.Id); + Assert.Equal(kind, recoveredResult.Kind); + Assert.Equal(pickPath, GetSingleRecoveredFile(recoveredResult).FullPath); + Assert.Null(GetActiveOperation()); + } + + [Fact] + public async Task AndroidX_Orphaned_Multiple_Photo_Pick_Publishes_Recovered_Result_With_Multiple_Files() + { + var firstPickPath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + var secondPickPath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + var pickForResult = new TestPickMultipleVisualMediaForResult(); + using var cancellationTokenSource = new CancellationTokenSource(); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + var pendingPick = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.PickPhotos, + [], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + pickForResult.DispatchResultForTests(CreateUriList(firstPickPath, secondPickPath)); + + var recoveredResult = Assert.Single(await WaitForCompletion(waitTask)); + Assert.Equal(pendingPick.Id, recoveredResult.Id); + Assert.Equal(RecoveredMediaPickerResultKind.PickPhotos, recoveredResult.Kind); + Assert.Equal(new[] { firstPickPath, secondPickPath }, recoveredResult.Files.Select(file => file.FullPath).ToArray()); + Assert.Null(GetActiveOperation()); + } + + [Fact] + public void Pick_Callback_Records_Accepted_Uri_Before_Materialization() + { + var pickPath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + var pickUri = CreateFileUri(pickPath); + var pendingPick = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.PickPhoto, + [], + PersistedPhotoProcessingOptions.Default); + + Assert.True(MediaPickerRecoveryManager.RecordSinglePickCallbackResult(pickUri, materializeImmediately: false)); + + var activePick = Assert.IsType(GetActiveOperation()); + Assert.Equal(pendingPick.Id, activePick.Id); + Assert.Equal(PendingMediaPickerState.ResultAccepted, activePick.State); + Assert.Empty(activePick.FilePaths); + Assert.Equal(pickUri.ToString(), GetSingleActiveOperationPickerUri(activePick)); + } + + [Fact] + public async Task Accepted_Pick_Materialization_Failure_Clears_Active_State_And_Completes_Waiter_Empty() + { + var invalidPickerUri = AndroidUri.Parse("content://maui-test/missing-picked-media") ?? throw new InvalidOperationException("Unable to create invalid picker URI."); + MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.PickPhoto, + [], + PersistedPhotoProcessingOptions.Default); + + Assert.True(MediaPickerRecoveryManager.RecordSinglePickCallbackResult(invalidPickerUri, materializeImmediately: false)); + SimulateProcessRecreation(); + + var results = await MediaPicker.WaitForRecoveredMediaPickerResultsAsync(CancellationToken.None); + + Assert.Empty(results); + Assert.Null(GetActiveOperation()); + } + + [Fact] + public async Task AndroidX_Orphaned_Single_Pick_Empty_Result_Clears_Active_State_And_Completes_Waiter_Empty() + { + var pickForResult = new TestPickVisualMediaForResult(); + using var cancellationTokenSource = new CancellationTokenSource(); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.PickVideo, + [], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + pickForResult.DispatchResultForTests(AndroidUri.Empty); + + Assert.Empty(await WaitForCompletion(waitTask)); + Assert.Null(GetActiveOperation()); + } + + [Fact] + public async Task AndroidX_Orphaned_Multiple_Pick_Empty_Result_Clears_Active_State_And_Completes_Waiter_Empty() + { + var pickForResult = new TestPickMultipleVisualMediaForResult(); + using var cancellationTokenSource = new CancellationTokenSource(); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.PickVideos, + [], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + pickForResult.DispatchResultForTests(new JavaList()); + + Assert.Empty(await WaitForCompletion(waitTask)); + Assert.Null(GetActiveOperation()); + } + + [Fact] + public async Task Live_Process_Pick_Clear_After_Accepted_Result_Prevents_Recovery() + { + var pickPath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + using var cancellationTokenSource = new CancellationTokenSource(); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + var pendingPick = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.PickPhoto, + [], + PersistedPhotoProcessingOptions.Default); + + Assert.True(MediaPickerRecoveryManager.RecordSinglePickCallbackResult(CreateFileUri(pickPath), materializeImmediately: true)); + MediaPickerRecoveryManager.ClearActiveOperation(pendingPick.Id); + SimulateProcessRecreation(); + + Assert.False(waitTask.IsCompleted); + Assert.Empty(await MediaPicker.GetRecoveredMediaPickerResultsAsync()); + + await cancellationTokenSource.CancelAsync(); + await Assert.ThrowsAnyAsync(() => waitTask); + } + + [Fact] + public async Task AndroidX_Orphaned_Mismatched_Pick_Callback_Does_Not_Complete_Waiter() + { + var pickPath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + var pickForResult = new TestPickMultipleVisualMediaForResult(); + using var cancellationTokenSource = new CancellationTokenSource(); + + var pendingPick = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.PickPhoto, + [], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + Assert.Equal(1, GetPendingWaiterCount()); + + pickForResult.DispatchResultForTests(CreateUriList(pickPath)); + + Assert.False(waitTask.IsCompleted); + Assert.Equal(1, GetPendingWaiterCount()); + + var activePick = Assert.IsType(GetActiveOperation()); + Assert.Equal(pendingPick.Id, activePick.Id); + Assert.Equal(RecoveredMediaPickerResultKind.PickPhoto, activePick.Kind); + Assert.Equal(PendingMediaPickerState.Pending, activePick.State); + + await cancellationTokenSource.CancelAsync(); + await Assert.ThrowsAnyAsync(() => waitTask); + Assert.Equal(0, GetPendingWaiterCount()); + } + + [Fact] + public async Task ActivityForResultRequest_Rejects_Concurrent_Launch() + { + var captureForResult = new TestMediaCaptureForResult(RecoveredMediaPickerResultKind.CapturePhoto); + var activeTaskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var activeLaunchCompletionSourceField = typeof(ActivityForResultRequest) + .GetField("activeLaunchCompletionSource", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(activeLaunchCompletionSourceField); + + // Seed the base request as if Launch already has one in-process activity result pending. + activeLaunchCompletionSourceField.SetValue(captureForResult, activeTaskCompletionSource); + + await Assert.ThrowsAsync(() => captureForResult.Launch(AndroidUri.Empty)); + } + + [Fact] + public async Task AndroidX_Orphaned_Mismatched_Media_Type_Does_Not_Complete_Waiter() + { + var capturePath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + using var cancellationTokenSource = new CancellationTokenSource(); + + var pendingCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + Assert.Equal(1, GetPendingWaiterCount()); + + await MediaPickerRecoveryManager.RecoverOrphanedCaptureResultAsync(RecoveredMediaPickerResultKind.CaptureVideo, true); + + Assert.False(waitTask.IsCompleted); + Assert.Equal(1, GetPendingWaiterCount()); + + var activeCapture = Assert.IsType(GetActiveOperation()); + Assert.Equal(pendingCapture.Id, activeCapture.Id); + Assert.Equal(RecoveredMediaPickerResultKind.CapturePhoto, activeCapture.Kind); + Assert.Equal(PendingMediaPickerState.Pending, activeCapture.State); + Assert.Equal(capturePath, GetSingleActiveOperationFilePath(activeCapture)); + + await cancellationTokenSource.CancelAsync(); + await Assert.ThrowsAnyAsync(() => waitTask); + Assert.Equal(0, GetPendingWaiterCount()); + } + + [Fact] + public void BeginOperation_Rejects_Second_InProcess_Capture_Before_Output_File_Exists() + { + var firstCapturePath = CreateMissingMediaFilePath(FileExtensions.Jpg); + var secondCapturePath = CreateMissingMediaFilePath(FileExtensions.Mp4); + + var firstCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [firstCapturePath], + PersistedPhotoProcessingOptions.Default); + + var exception = Assert.Throws(() => + MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CaptureVideo, + [secondCapturePath], + PersistedPhotoProcessingOptions.Default)); + + Assert.Equal("A MediaPicker operation is already in progress.", exception.Message); + + var activeCapture = Assert.IsType(GetActiveOperation()); + Assert.Equal(firstCapture.Id, activeCapture.Id); + Assert.Equal(RecoveredMediaPickerResultKind.CapturePhoto, activeCapture.Kind); + Assert.Equal(PendingMediaPickerState.Pending, activeCapture.State); + Assert.Equal(firstCapturePath, GetSingleActiveOperationFilePath(activeCapture)); + } + + [Fact] + public void Rejected_Concurrent_Capture_Does_Not_Overwrite_Active_Record() + { + var firstCapturePath = CreateMissingMediaFilePath(FileExtensions.Jpg); + var secondCapturePath = CreateNonEmptyMediaFile(FileExtensions.Mp4); + + var firstCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [firstCapturePath], + new PersistedPhotoProcessingOptions(640, null, 70, false, true)); + + Assert.Throws(() => + MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CaptureVideo, + [secondCapturePath], + new PersistedPhotoProcessingOptions(null, 480, 80, false, true))); + + var activeCapture = Assert.IsType(GetActiveOperation()); + Assert.Equal(firstCapture.Id, activeCapture.Id); + Assert.Equal(RecoveredMediaPickerResultKind.CapturePhoto, activeCapture.Kind); + Assert.Equal(firstCapturePath, GetSingleActiveOperationFilePath(activeCapture)); + Assert.Equal(640, activeCapture.PhotoProcessingOptions.MaximumWidth.GetValueOrDefault()); + Assert.Null(activeCapture.PhotoProcessingOptions.MaximumHeight); + Assert.Equal(70, activeCapture.PhotoProcessingOptions.CompressionQuality); + } + + [Fact] + public void Recreated_Pending_Capture_Blocks_New_Capture_Until_Replayed() + { + var firstCapturePath = CreateMissingMediaFilePath(FileExtensions.Jpg); + var secondCapturePath = CreateMissingMediaFilePath(FileExtensions.Mp4); + + var firstCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [firstCapturePath], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + + var exception = Assert.Throws(() => + MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CaptureVideo, + [secondCapturePath], + PersistedPhotoProcessingOptions.Default)); + + Assert.Equal("A MediaPicker operation is pending AndroidX result replay.", exception.Message); + + var activeCapture = Assert.IsType(GetActiveOperation()); + Assert.Equal(firstCapture.Id, activeCapture.Id); + Assert.Equal(RecoveredMediaPickerResultKind.CapturePhoto, activeCapture.Kind); + Assert.Equal(PendingMediaPickerState.Pending, activeCapture.State); + Assert.Equal(firstCapturePath, GetSingleActiveOperationFilePath(activeCapture)); + } + + [Fact] + public async Task Recreated_Pending_Capture_Blocked_New_Capture_Does_Not_Complete_Waiter() + { + var firstCapturePath = CreateMissingMediaFilePath(FileExtensions.Jpg); + var secondCapturePath = CreateMissingMediaFilePath(FileExtensions.Mp4); + using var cancellationTokenSource = new CancellationTokenSource(); + + var firstCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [firstCapturePath], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + Assert.Equal(1, GetPendingWaiterCount()); + + var exception = Assert.Throws(() => + MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CaptureVideo, + [secondCapturePath], + PersistedPhotoProcessingOptions.Default)); + + Assert.Equal("A MediaPicker operation is pending AndroidX result replay.", exception.Message); + Assert.False(waitTask.IsCompleted); + Assert.Equal(1, GetPendingWaiterCount()); + + var activeCapture = Assert.IsType(GetActiveOperation()); + Assert.Equal(firstCapture.Id, activeCapture.Id); + Assert.Equal(RecoveredMediaPickerResultKind.CapturePhoto, activeCapture.Kind); + Assert.Equal(PendingMediaPickerState.Pending, activeCapture.State); + Assert.Equal(firstCapturePath, GetSingleActiveOperationFilePath(activeCapture)); + + await cancellationTokenSource.CancelAsync(); + await Assert.ThrowsAnyAsync(() => waitTask); + Assert.Equal(0, GetPendingWaiterCount()); + } + + [Theory] + [InlineData(RecoveredMediaPickerResultKind.PickPhoto)] + [InlineData(RecoveredMediaPickerResultKind.PickPhotos)] + public async Task Recreated_Pending_Pick_Blocks_New_Operation_And_Does_Not_Complete_Waiter(RecoveredMediaPickerResultKind kind) + { + var capturePath = CreateMissingMediaFilePath(FileExtensions.Mp4); + using var cancellationTokenSource = new CancellationTokenSource(); + + var pendingPick = MediaPickerRecoveryManager.BeginOperation( + kind, + [], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + Assert.Equal(1, GetPendingWaiterCount()); + + var exception = Assert.Throws(() => + MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CaptureVideo, + [capturePath], + PersistedPhotoProcessingOptions.Default)); + + Assert.Equal("A MediaPicker operation is pending AndroidX result replay.", exception.Message); + Assert.False(waitTask.IsCompleted); + Assert.Equal(1, GetPendingWaiterCount()); + + var activePick = Assert.IsType(GetActiveOperation()); + Assert.Equal(pendingPick.Id, activePick.Id); + Assert.Equal(kind, activePick.Kind); + Assert.Equal(PendingMediaPickerState.Pending, activePick.State); + Assert.Empty(activePick.FilePaths); + Assert.Empty(activePick.PickerUriStrings); + + await cancellationTokenSource.CancelAsync(); + await Assert.ThrowsAnyAsync(() => waitTask); + Assert.Equal(0, GetPendingWaiterCount()); + } + + [Fact] + public void Recreated_Accepted_Capture_Blocks_New_Capture_Until_Recovered() + { + var firstCapturePath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + var secondCapturePath = CreateMissingMediaFilePath(FileExtensions.Mp4); + + var firstCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [firstCapturePath], + PersistedPhotoProcessingOptions.Default); + + MediaPickerRecoveryManager.RecordCaptureCallbackResult(RecoveredMediaPickerResultKind.CapturePhoto, true); + SimulateProcessRecreation(); + + var exception = Assert.Throws(() => + MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CaptureVideo, + [secondCapturePath], + PersistedPhotoProcessingOptions.Default)); + + Assert.Equal("A MediaPicker result is pending recovery.", exception.Message); + + var activeCapture = Assert.IsType(GetActiveOperation()); + Assert.Equal(firstCapture.Id, activeCapture.Id); + Assert.Equal(RecoveredMediaPickerResultKind.CapturePhoto, activeCapture.Kind); + Assert.Equal(PendingMediaPickerState.ResultAccepted, activeCapture.State); + Assert.Equal(firstCapturePath, GetSingleActiveOperationFilePath(activeCapture)); + } + + [Fact] + public async Task New_Capture_Can_Start_After_Recreated_Pending_Capture_Is_Canceled() + { + var firstCapturePath = CreateMissingMediaFilePath(FileExtensions.Jpg); + var secondCapturePath = CreateMissingMediaFilePath(FileExtensions.Mp4); + + var firstCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [firstCapturePath], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + + await MediaPickerRecoveryManager.RecoverOrphanedCaptureResultAsync(RecoveredMediaPickerResultKind.CapturePhoto, false); + + Assert.Null(GetActiveOperation()); + + var secondCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CaptureVideo, + [secondCapturePath], + PersistedPhotoProcessingOptions.Default); + + Assert.NotEqual(firstCapture.Id, secondCapture.Id); + Assert.Equal(secondCapture.Id, GetActiveOperation()?.Id); + } + + [Fact] + public void Pending_Capture_Persists_PhotoProcessingOptions() + { + var capturePath = CreateMissingMediaFilePath(FileExtensions.Jpg); + var options = new PersistedPhotoProcessingOptions(640, 480, 70, true, false); + + var pendingCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [capturePath], + options); + + SimulateProcessRecreation(); + + var activeCapture = Assert.IsType(GetActiveOperation()); + Assert.Equal(pendingCapture.Id, activeCapture.Id); + Assert.Equal(options.MaximumWidth, activeCapture.PhotoProcessingOptions.MaximumWidth); + Assert.Equal(options.MaximumHeight, activeCapture.PhotoProcessingOptions.MaximumHeight); + Assert.Equal(options.CompressionQuality, activeCapture.PhotoProcessingOptions.CompressionQuality); + Assert.Equal(options.RotateImage, activeCapture.PhotoProcessingOptions.RotateImage); + Assert.Equal(options.PreserveMetaData, activeCapture.PhotoProcessingOptions.PreserveMetaData); + } + + [Fact] + public void PhotoProcessingOptions_Are_Created_From_MediaPickerOptions() + { + var options = new MediaPickerOptions + { + MaximumWidth = 640, + MaximumHeight = 480, + CompressionQuality = 70, + RotateImage = true, + PreserveMetaData = false + }; + + var processingOptions = MediaPickerImplementation.GetPhotoProcessingOptions(options); + + Assert.Equal(options.MaximumWidth, processingOptions.MaximumWidth); + Assert.Equal(options.MaximumHeight, processingOptions.MaximumHeight); + Assert.Equal(options.CompressionQuality, processingOptions.CompressionQuality); + Assert.Equal(options.RotateImage, processingOptions.RotateImage); + Assert.Equal(options.PreserveMetaData, processingOptions.PreserveMetaData); + } + + [Theory] + [InlineData("1", (int)PendingMediaPickerState.Pending)] + [InlineData("2", (int)PendingMediaPickerState.ResultAccepted)] + public void Pending_Capture_Reads_Previous_Unshipped_Formats(string version, int expectedStateValue) + { + var expectedState = (PendingMediaPickerState)expectedStateValue; + var id = Guid.NewGuid().ToString("N"); + var capturePath = CreateMissingMediaFilePath(FileExtensions.Jpg); + var serializedCapture = version == "1" + ? SerializeLegacyPendingCaptureForTests(id, RecoveredMediaPickerResultKind.CapturePhoto, capturePath) + : SerializePendingCaptureWithOutputUriForTests(id, RecoveredMediaPickerResultKind.CapturePhoto, expectedState, capturePath); + + SetSerializedActiveOperation(serializedCapture); + + var activeCapture = Assert.IsType(GetActiveOperation()); + Assert.Equal(id, activeCapture.Id); + Assert.Equal(RecoveredMediaPickerResultKind.CapturePhoto, activeCapture.Kind); + Assert.Equal(expectedState, activeCapture.State); + Assert.Equal(capturePath, GetSingleActiveOperationFilePath(activeCapture)); + Assert.Equal(640, activeCapture.PhotoProcessingOptions.MaximumWidth.GetValueOrDefault()); + Assert.Equal(480, activeCapture.PhotoProcessingOptions.MaximumHeight.GetValueOrDefault()); + Assert.Equal(70, activeCapture.PhotoProcessingOptions.CompressionQuality); + Assert.True(activeCapture.PhotoProcessingOptions.RotateImage); + Assert.False(activeCapture.PhotoProcessingOptions.PreserveMetaData); + } + + [Fact] + public void Pending_Operation_Reads_Previous_Unshipped_Format_Without_PickerUris() + { + var id = Guid.NewGuid().ToString("N"); + var capturePath = CreateMissingMediaFilePath(FileExtensions.Jpg); + var serializedCapture = SerializePendingOperationWithoutPickerUrisForTests( + id, + RecoveredMediaPickerResultKind.CapturePhoto, + PendingMediaPickerState.ResultAccepted, + capturePath); + + SetSerializedActiveOperation(serializedCapture); + + var activeCapture = Assert.IsType(GetActiveOperation()); + Assert.Equal(id, activeCapture.Id); + Assert.Equal(PendingMediaPickerState.ResultAccepted, activeCapture.State); + Assert.Equal(capturePath, GetSingleActiveOperationFilePath(activeCapture)); + Assert.Empty(activeCapture.PickerUriStrings); + } + + [Fact] + public void Pending_Capture_Writes_Current_Format_Without_OutputUri() + { + var capturePath = CreateMissingMediaFilePath(FileExtensions.Jpg); + + var pendingCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [capturePath], + new PersistedPhotoProcessingOptions(640, 480, 70, true, false)); + + var serializedCapture = Assert.IsType(GetSerializedActiveOperation()); + var parts = serializedCapture.Split('|'); + + Assert.Equal("5", parts[0]); + Assert.Equal(11, parts.Length); + Assert.Equal(EncodePreferenceValueForTests(pendingCapture.Id), parts[1]); + Assert.Equal(((int)RecoveredMediaPickerResultKind.CapturePhoto).ToString(CultureInfo.InvariantCulture), parts[2]); + Assert.Equal(((int)PendingMediaPickerState.Pending).ToString(CultureInfo.InvariantCulture), parts[3]); + Assert.Equal(EncodePreferenceValueForTests(capturePath), parts[4]); + Assert.Empty(parts[5]); + Assert.Equal("640", parts[6]); + Assert.Equal("480", parts[7]); + Assert.Equal("70", parts[8]); + Assert.Equal("1", parts[9]); + Assert.Equal("0", parts[10]); + } + + [Fact] + public async Task ProcessPhotoPreservingSource_Writes_Separate_File_And_Leaves_Source_Intact() + { + var capturePath = CreateValidJpegMediaFile(); + var originalBytes = await File.ReadAllBytesAsync(capturePath); + + var processedPath = await MediaPickerImplementation.ProcessPhotoPreservingSourceAsync( + capturePath, + new PersistedPhotoProcessingOptions(16, null, 70, false, false)); + + Assert.NotEqual(capturePath, processedPath); + Assert.True(File.Exists(capturePath)); + Assert.Equal(originalBytes, await File.ReadAllBytesAsync(capturePath)); + Assert.True(new FileInfo(processedPath).Length > 0); + } + + [Fact] + public async Task ProcessPhotoPreservingSource_Propagates_Rotation_Failure() + { + var invalidJpegPath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + + await Assert.ThrowsAnyAsync(() => + MediaPickerImplementation.ProcessPhotoPreservingSourceAsync( + invalidJpegPath, + new PersistedPhotoProcessingOptions(null, null, 100, true, true))); + } + + [Fact] + public async Task Recovered_Photo_Processing_Queues_Processed_Result_And_Clears_Active_State() + { + var capturePath = CreateValidJpegMediaFile(); + var originalBytes = await File.ReadAllBytesAsync(capturePath); + + var pendingCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [capturePath], + new PersistedPhotoProcessingOptions(16, null, 70, false, false)); + + MediaPickerRecoveryManager.RecordCaptureCallbackResult(RecoveredMediaPickerResultKind.CapturePhoto, true); + SimulateProcessRecreation(); + + var results = await MediaPicker.GetRecoveredMediaPickerResultsAsync(); + + var result = Assert.Single(results); + Assert.Equal(pendingCapture.Id, result.Id); + Assert.NotEqual(capturePath, GetSingleRecoveredFile(result).FullPath); + Assert.True(File.Exists(capturePath)); + Assert.Equal(originalBytes, await File.ReadAllBytesAsync(capturePath)); + Assert.True(new FileInfo(GetSingleRecoveredFile(result).FullPath).Length > 0); + Assert.Null(GetActiveOperation()); + } + + [Fact] + public async Task Accepted_Result_From_Recreated_Process_Is_Promoted_By_Get() + { + var capturePath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + + var pendingCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + MediaPickerRecoveryManager.RecordCaptureCallbackResult(RecoveredMediaPickerResultKind.CapturePhoto, true); + SimulateProcessRecreation(); + + var results = await MediaPicker.GetRecoveredMediaPickerResultsAsync(); + + var result = Assert.Single(results); + Assert.Equal(pendingCapture.Id, result.Id); + Assert.Equal(capturePath, GetSingleRecoveredFile(result).FullPath); + Assert.Null(GetActiveOperation()); + } + + [Fact] + public async Task Accepted_Result_From_Recreated_Process_Completes_Wait() + { + var capturePath = CreateNonEmptyMediaFile(FileExtensions.Mp4); + + var pendingCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CaptureVideo, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + MediaPickerRecoveryManager.RecordCaptureCallbackResult(RecoveredMediaPickerResultKind.CaptureVideo, true); + SimulateProcessRecreation(); + + var results = await MediaPicker.WaitForRecoveredMediaPickerResultsAsync(CancellationToken.None); + + var result = Assert.Single(results); + Assert.Equal(pendingCapture.Id, result.Id); + Assert.Equal(capturePath, GetSingleRecoveredFile(result).FullPath); + Assert.Null(GetActiveOperation()); + } + + [Fact] + public async Task Accepted_Result_In_Current_Process_Is_Not_Promoted() + { + var capturePath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + + var pendingCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + MediaPickerRecoveryManager.RecordCaptureCallbackResult(RecoveredMediaPickerResultKind.CapturePhoto, true); + + Assert.Empty(await MediaPicker.GetRecoveredMediaPickerResultsAsync()); + + var activeCapture = Assert.IsType(GetActiveOperation()); + Assert.Equal(pendingCapture.Id, activeCapture.Id); + Assert.Equal(PendingMediaPickerState.ResultAccepted, activeCapture.State); + } + + [Fact] + public async Task Pending_Result_In_Current_Process_Is_Not_Promoted_From_Output_File() + { + var capturePath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + + var pendingCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + Assert.Empty(await MediaPicker.GetRecoveredMediaPickerResultsAsync()); + + var activeCapture = Assert.IsType(GetActiveOperation()); + Assert.Equal(pendingCapture.Id, activeCapture.Id); + Assert.Equal(PendingMediaPickerState.Pending, activeCapture.State); + } + + [Fact] + public async Task Live_Process_Capture_Clear_After_Accepted_Result_Prevents_Recovery() + { + var capturePath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + using var cancellationTokenSource = new CancellationTokenSource(); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + var pendingCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + MediaPickerRecoveryManager.RecordCaptureCallbackResult(RecoveredMediaPickerResultKind.CapturePhoto, true); + MediaPickerRecoveryManager.ClearActiveOperation(pendingCapture.Id); + SimulateProcessRecreation(); + + Assert.False(waitTask.IsCompleted); + Assert.Empty(await MediaPicker.GetRecoveredMediaPickerResultsAsync()); + + await cancellationTokenSource.CancelAsync(); + await Assert.ThrowsAnyAsync(() => waitTask); + } + + [Fact] + public async Task New_Capture_Can_Start_After_Recreated_Accepted_Result_Is_Recovered() + { + var firstCapturePath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + var secondCapturePath = CreateMissingMediaFilePath(FileExtensions.Mp4); + + var firstCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [firstCapturePath], + PersistedPhotoProcessingOptions.Default); + + MediaPickerRecoveryManager.RecordCaptureCallbackResult(RecoveredMediaPickerResultKind.CapturePhoto, true); + SimulateProcessRecreation(); + + var recoveredResults = await MediaPickerRecoveryManager.RecoverOperationIfAvailableAsync(); + + Assert.Equal(firstCapture.Id, Assert.Single(recoveredResults).Id); + + var secondCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CaptureVideo, + [secondCapturePath], + PersistedPhotoProcessingOptions.Default); + + Assert.NotEqual(firstCapture.Id, secondCapture.Id); + Assert.Equal(secondCapture.Id, GetActiveOperation()?.Id); + } + + [Fact] + public async Task Concurrent_Accepted_Result_Promotion_Queues_Single_Result() + { + var capturePath = CreateNonEmptyMediaFile(FileExtensions.Mp4); + + var pendingCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CaptureVideo, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + MediaPickerRecoveryManager.RecordCaptureCallbackResult(RecoveredMediaPickerResultKind.CaptureVideo, true); + SimulateProcessRecreation(); + + var recoveryTasks = Enumerable.Range(0, 8) + .Select(_ => MediaPicker.GetRecoveredMediaPickerResultsAsync()) + .ToArray(); + + var allResults = await Task.WhenAll(recoveryTasks); + var queuedResults = await MediaPicker.GetRecoveredMediaPickerResultsAsync(); + + foreach (var results in allResults) + { + Assert.Equal(pendingCapture.Id, Assert.Single(results).Id); + } + + Assert.Equal(pendingCapture.Id, Assert.Single(queuedResults).Id); + } + + static string CreateNonEmptyMediaFile(string extension) + { + var path = CreateCacheFilePath(extension); + WriteNonEmptyMediaFile(path); + return path; + } + + // Device-test hangs are expensive and obscure the failure, so expected completions go + // through a bounded wait instead of awaiting recovery waiters directly. + static async Task WaitForCompletion(Task task) + { + var completedTask = await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.Same(task, completedTask); + return await task; + } + + static void WriteNonEmptyMediaFile(string path) + => File.WriteAllBytes(path, new byte[] { 1, 2, 3, 4 }); + + // Some photo-processing tests need a real JPEG. The smaller dummy media files above are + // enough for recovery state tests, but not for bitmap processing. + static string CreateValidJpegMediaFile() + { + var path = CreateCacheFilePath(FileExtensions.Jpg); + + var bitmapConfig = ABitmap.Config.Argb8888 ?? throw new InvalidOperationException("Unable to create a bitmap config."); + using var bitmap = ABitmap.CreateBitmap(64, 64, bitmapConfig) ?? throw new InvalidOperationException("Unable to create a bitmap."); + bitmap.EraseColor(AColor.Red); + + var jpegFormat = ABitmap.CompressFormat.Jpeg ?? throw new InvalidOperationException("Unable to create a JPEG format."); + using var stream = File.Create(path); + Assert.True(bitmap.Compress(jpegFormat, 100, stream)); + + return path; + } + + static string CreateMissingMediaFilePath(string extension) + => CreateCacheFilePath(extension); + + static void SimulateProcessRecreation() + { + // Clear only in-memory operation ownership. Durable preference state remains so the next + // recovery scan behaves like a recreated app process receiving AndroidX result replay. + ClearInProcessOperationIds(); + } + + static void ResetRecoveryState() + { + ClearInProcessOperationIds(); + CompleteAndClearRecoveryWaiters(); + SetRecoveryReconciliationGeneration(0); + Preferences.Remove(ActiveOperationPreferenceKey, RecoveryPreferencesSharedName); + Preferences.Remove(RecoveredResultsPreferenceKey, RecoveryPreferencesSharedName); + } + + static PendingMediaPickerOperation GetActiveOperation() + => MediaPickerRecoveryStore.ReadActiveOperation(); + + static int GetPendingWaiterCount() + => GetRecoveryWaiters().Count; + + static string GetSerializedActiveOperation() + => Preferences.Get(ActiveOperationPreferenceKey, null, RecoveryPreferencesSharedName); + + static void SetSerializedActiveOperation(string value) + => Preferences.Set(ActiveOperationPreferenceKey, value, RecoveryPreferencesSharedName); + + static void ClearInProcessOperationIds() + { + var ids = GetPrivateStaticField("InProcessOperationIds"); + ids.GetType().GetMethod("Clear")?.Invoke(ids, null); + } + + static void CompleteAndClearRecoveryWaiters() + { + var waiters = GetRecoveryWaiters(); + var waiterSnapshot = waiters.Cast().ToArray(); + + foreach (var waiter in waiterSnapshot) + { + waiter.GetType() + .GetMethod("TrySetResult") + ?.Invoke(waiter, new object[] { Array.Empty() }); + } + + waiters.Clear(); + } + + static System.Collections.IList GetRecoveryWaiters() + => GetPrivateStaticField("RecoveryWaiters"); + + static void SetRecoveryReconciliationGeneration(long value) + { + var field = typeof(MediaPickerRecoveryManager) + .GetField("RecoveryReconciliationGeneration", BindingFlags.Static | BindingFlags.NonPublic); + Assert.NotNull(field); + field.SetValue(null, value); + } + + static T GetPrivateStaticField(string name) + { + var field = typeof(MediaPickerRecoveryManager) + .GetField(name, BindingFlags.Static | BindingFlags.NonPublic); + Assert.NotNull(field); + var value = field.GetValue(null); + Assert.NotNull(value); + return (T)value; + } + + static string CreateCacheFilePath(string extension) + { + var cacheDirectory = FileSystem.CacheDirectory ?? throw new InvalidOperationException("FileSystem.CacheDirectory is not available."); + return Path.Combine(cacheDirectory, $"{Guid.NewGuid():N}{extension}"); + } + + static AndroidUri CreateFileUri(string path) + => AndroidUri.FromFile(new Java.IO.File(path)) ?? throw new InvalidOperationException("Unable to create a file URI."); + + static JavaList CreateUriList(params string[] paths) + { + var list = new JavaList(); + + foreach (var path in paths) + { + list.Add(CreateFileUri(path)); + } + + return list; + } + + static FileResult GetSingleRecoveredFile(RecoveredMediaPickerResult result) + => Assert.Single(result.Files); + + static string GetSingleActiveOperationFilePath(PendingMediaPickerOperation operation) + => Assert.Single(operation.FilePaths); + + static string GetSingleActiveOperationPickerUri(PendingMediaPickerOperation operation) + => Assert.Single(operation.PickerUriStrings); + + static string SerializeLegacyPendingCaptureForTests(string id, RecoveredMediaPickerResultKind kind, string filePath) + => string.Join("|", new[] + { + "1", + EncodePreferenceValueForTests(id), + GetLegacyCaptureKindValue(kind).ToString(CultureInfo.InvariantCulture), + EncodePreferenceValueForTests(filePath), + EncodePreferenceValueForTests("content://maui-test/media-capture/legacy"), + "640", + "480", + "70", + "1", + "0" + }); + + static string SerializePendingCaptureWithOutputUriForTests(string id, RecoveredMediaPickerResultKind kind, PendingMediaPickerState state, string filePath) + => string.Join("|", new[] + { + "2", + EncodePreferenceValueForTests(id), + GetLegacyCaptureKindValue(kind).ToString(CultureInfo.InvariantCulture), + ((int)state).ToString(CultureInfo.InvariantCulture), + EncodePreferenceValueForTests(filePath), + EncodePreferenceValueForTests("content://maui-test/media-capture/previous"), + "640", + "480", + "70", + "1", + "0" + }); + + static string SerializePendingOperationWithoutPickerUrisForTests(string id, RecoveredMediaPickerResultKind kind, PendingMediaPickerState state, string filePath) + => string.Join("|", new[] + { + "4", + EncodePreferenceValueForTests(id), + ((int)kind).ToString(CultureInfo.InvariantCulture), + ((int)state).ToString(CultureInfo.InvariantCulture), + EncodePreferenceValueForTests(filePath), + "640", + "480", + "70", + "1", + "0" + }); + + static int GetLegacyCaptureKindValue(RecoveredMediaPickerResultKind kind) + => kind == RecoveredMediaPickerResultKind.CaptureVideo ? 1 : 0; + + static string EncodePreferenceValueForTests(string value) + => Convert.ToBase64String(Encoding.UTF8.GetBytes(value ?? string.Empty)); + + sealed class TestMediaCaptureForResult : MediaCaptureForResult + { + public TestMediaCaptureForResult(RecoveredMediaPickerResultKind kind) + : base(kind) + { + } + + public void DispatchResultForTests(JavaBoolean result) + => HandleActivityResult(result); + } + + sealed class TestPickVisualMediaForResult : PickVisualMediaForResult + { + public void DispatchResultForTests(AndroidUri result) + => HandleActivityResult(result); + } + + sealed class TestPickMultipleVisualMediaForResult : PickMultipleVisualMediaForResult + { + public void DispatchResultForTests(JavaList result) + => HandleActivityResult(result); + } + } +} From 0159a3a00517575bdf523ccf94a22a642cb9df28 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Fri, 15 May 2026 04:35:04 -0400 Subject: [PATCH 02/17] Fix MediaPicker recovery rotation test --- .../Tests/Android/MediaPickerRecovery_Tests.cs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs index 7f97ccf57534..f1cc8ef26a6e 100644 --- a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs +++ b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs @@ -1044,14 +1044,20 @@ public async Task ProcessPhotoPreservingSource_Writes_Separate_File_And_Leaves_S } [Fact] - public async Task ProcessPhotoPreservingSource_Propagates_Rotation_Failure() + public async Task ProcessPhotoPreservingSource_InvalidRotationInput_Leaves_Source_Intact() { var invalidJpegPath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + var originalBytes = await File.ReadAllBytesAsync(invalidJpegPath); - await Assert.ThrowsAnyAsync(() => - MediaPickerImplementation.ProcessPhotoPreservingSourceAsync( - invalidJpegPath, - new PersistedPhotoProcessingOptions(null, null, 100, true, true))); + var processedPath = await MediaPickerImplementation.ProcessPhotoPreservingSourceAsync( + invalidJpegPath, + new PersistedPhotoProcessingOptions(null, null, 100, true, true)); + + Assert.NotEqual(invalidJpegPath, processedPath); + Assert.True(File.Exists(invalidJpegPath)); + Assert.Equal(originalBytes, await File.ReadAllBytesAsync(invalidJpegPath)); + Assert.True(File.Exists(processedPath)); + Assert.Equal(originalBytes, await File.ReadAllBytesAsync(processedPath)); } [Fact] From 5e569610145caa97b65b68339f8fd89f995ca24b Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Tue, 19 May 2026 15:33:22 -0400 Subject: [PATCH 03/17] Avoid materializing picker results in callbacks --- .../src/FileSystem/FileSystemUtils.android.cs | 12 +++++ .../src/MediaPicker/MediaPicker.android.cs | 6 +-- .../MediaPickerRecoveryManager.android.cs | 32 +++++-------- ...ickMultipleVisualMediaForResult.android.cs | 2 +- .../PickVisualMediaForResult.android.cs | 2 +- .../Android/MediaPickerRecovery_Tests.cs | 46 +++++++++++++++++-- 6 files changed, 70 insertions(+), 30 deletions(-) diff --git a/src/Essentials/src/FileSystem/FileSystemUtils.android.cs b/src/Essentials/src/FileSystem/FileSystemUtils.android.cs index e80be6cd853e..9a56348899f9 100644 --- a/src/Essentials/src/FileSystem/FileSystemUtils.android.cs +++ b/src/Essentials/src/FileSystem/FileSystemUtils.android.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics; using System.IO; +using System.Threading.Tasks; using Android.App; using Android.Provider; using Android.Webkit; @@ -71,6 +72,17 @@ public static string EnsurePhysicalPath(AndroidUri uri, bool requireExtendedAcce throw new FileNotFoundException($"Unable to resolve absolute path or retrieve contents of URI '{uri}'."); } + public static Task EnsurePhysicalPathAsync(AndroidUri uri, bool requireExtendedAccess = true) + { + // file:// URIs do not need provider queries or stream copies. + if (string.Equals(uri.Scheme, UriSchemeFile, StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(uri.Path); + } + + return Task.Run(() => EnsurePhysicalPath(uri, requireExtendedAccess)); + } + static string ResolvePhysicalPath(AndroidUri uri, bool requireExtendedAccess = true) { if (uri.Scheme.Equals(UriSchemeFile, StringComparison.OrdinalIgnoreCase)) diff --git a/src/Essentials/src/MediaPicker/MediaPicker.android.cs b/src/Essentials/src/MediaPicker/MediaPicker.android.cs index 2f3525136405..57975fa50f1a 100644 --- a/src/Essentials/src/MediaPicker/MediaPicker.android.cs +++ b/src/Essentials/src/MediaPicker/MediaPicker.android.cs @@ -309,8 +309,8 @@ async Task PickUsingPhotoPicker( return null; } - var acceptedPaths = MediaPickerRecoveryManager.MaterializeAcceptedFilePaths(pendingOperation.Id, throwOnMaterializationFailure: true); - var path = acceptedPaths.FirstOrDefault() ?? FileSystemUtils.EnsurePhysicalPath(androidUri); + var acceptedPaths = await MediaPickerRecoveryManager.MaterializeAcceptedFilePathsAsync(pendingOperation.Id, throwOnMaterializationFailure: true); + var path = acceptedPaths.FirstOrDefault() ?? await FileSystemUtils.EnsurePhysicalPathAsync(androidUri); if (photo) { @@ -367,7 +367,7 @@ async Task> PickMultipleUsingPhotoPicker(MediaPickerOptions opt if (androidUris?.IsEmpty ?? true) return []; - var acceptedPaths = MediaPickerRecoveryManager.MaterializeAcceptedFilePaths(pendingOperation.Id, throwOnMaterializationFailure: true); + var acceptedPaths = await MediaPickerRecoveryManager.MaterializeAcceptedFilePathsAsync(pendingOperation.Id, throwOnMaterializationFailure: true); var resultList = new List(); diff --git a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs index 93c0de219ba1..81a97c003fb7 100644 --- a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs +++ b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs @@ -198,24 +198,22 @@ internal static async Task RecoverOrphanedCaptureResultAsync(RecoveredMediaPicke () => RecordCaptureCallbackResult(kind, success), "Unable to recover media capture result").ConfigureAwait(false); - internal static bool RecordSinglePickCallbackResult(AndroidUri? uri, bool materializeImmediately) + internal static bool RecordSinglePickCallbackResult(AndroidUri? uri) => RecordPickCallbackResult( operation => IsSinglePickCallbackKind(operation.Kind), - uri is null || uri.Equals(AndroidUri.Empty) ? Array.Empty() : new[] { uri }, - materializeImmediately); + uri is null || uri.Equals(AndroidUri.Empty) ? Array.Empty() : new[] { uri }); - internal static bool RecordMultiplePickCallbackResult(IReadOnlyList? uris, bool materializeImmediately) + internal static bool RecordMultiplePickCallbackResult(IReadOnlyList? uris) => RecordPickCallbackResult( operation => IsMultiplePickCallbackKind(operation.Kind), - uris ?? [], - materializeImmediately); + uris ?? []); internal static void RecoverOrphanedSinglePickResult(AndroidUri? uri) => _ = RecoverOrphanedSinglePickResultAsync(uri); internal static async Task RecoverOrphanedSinglePickResultAsync(AndroidUri? uri) => await RecoverOrphanedOperationResultAsync( - () => RecordSinglePickCallbackResult(uri, materializeImmediately: false), + () => RecordSinglePickCallbackResult(uri), "Unable to recover picked media result").ConfigureAwait(false); internal static void RecoverOrphanedMultiplePickResult(IReadOnlyList? uris) @@ -223,7 +221,7 @@ internal static void RecoverOrphanedMultiplePickResult(IReadOnlyList internal static async Task RecoverOrphanedMultiplePickResultAsync(IReadOnlyList? uris) => await RecoverOrphanedOperationResultAsync( - () => RecordMultiplePickCallbackResult(uris, materializeImmediately: false), + () => RecordMultiplePickCallbackResult(uris), "Unable to recover picked media results").ConfigureAwait(false); internal static async Task> RecoverOperationIfAvailableAsync() @@ -279,8 +277,7 @@ static async Task RecoverOperationIfAvailable static bool RecordPickCallbackResult( Func matchesOperation, - IReadOnlyList uris, - bool materializeImmediately) + IReadOnlyList uris) { PendingMediaPickerOperation? operation; @@ -324,15 +321,10 @@ static bool RecordPickCallbackResult( TryPersistPickerUriReadAccess(uri); } - if (materializeImmediately) - { - MaterializeAcceptedFilePaths(operation.Id, throwOnMaterializationFailure: true); - } - return true; } - internal static IReadOnlyList MaterializeAcceptedFilePaths(string id, bool throwOnMaterializationFailure) + internal static async Task> MaterializeAcceptedFilePathsAsync(string id, bool throwOnMaterializationFailure) { if (id is null) { @@ -362,7 +354,7 @@ internal static IReadOnlyList MaterializeAcceptedFilePaths(string id, bo IReadOnlyList filePaths; try { - filePaths = MaterializePickerUris(operation.PickerUriStrings); + filePaths = await MaterializePickerUrisAsync(operation.PickerUriStrings).ConfigureAwait(false); } catch (Exception ex) { @@ -405,7 +397,7 @@ static IReadOnlyList GetPickerUriStrings(IReadOnlyList uris) .Select(uri => uri!) .ToArray(); - static IReadOnlyList MaterializePickerUris(IReadOnlyList uriStrings) + static async Task> MaterializePickerUrisAsync(IReadOnlyList uriStrings) { var filePaths = new List(); @@ -422,7 +414,7 @@ static IReadOnlyList MaterializePickerUris(IReadOnlyList uriStri continue; } - var filePath = FileSystemUtils.EnsurePhysicalPath(uri); + var filePath = await FileSystemUtils.EnsurePhysicalPathAsync(uri).ConfigureAwait(false); if (!string.IsNullOrEmpty(filePath)) { filePaths.Add(filePath); @@ -486,7 +478,7 @@ static async Task RecoverOrphanedOperationResultAsync(Func recordResult, s static async Task PublishRecoveredOperationAsync(PendingMediaPickerOperation operation) { var recoveredPaths = new List(); - var acceptedFilePaths = MaterializeAcceptedFilePaths(operation.Id, throwOnMaterializationFailure: false); + var acceptedFilePaths = await MaterializeAcceptedFilePathsAsync(operation.Id, throwOnMaterializationFailure: false).ConfigureAwait(false); foreach (var filePath in acceptedFilePaths) { diff --git a/src/Essentials/src/Platform/PickMultipleVisualMediaForResult.android.cs b/src/Essentials/src/Platform/PickMultipleVisualMediaForResult.android.cs index 55ed61609e25..caaa149bb0e3 100644 --- a/src/Essentials/src/Platform/PickMultipleVisualMediaForResult.android.cs +++ b/src/Essentials/src/Platform/PickMultipleVisualMediaForResult.android.cs @@ -14,7 +14,7 @@ internal class PickMultipleVisualMediaForResult : ActivityForResultRequest LazyInstance.Value; protected override void OnActivityResultForActiveLaunch(JavaList result) - => MediaPickerRecoveryManager.RecordMultiplePickCallbackResult(ToAndroidUris(result), materializeImmediately: true); + => MediaPickerRecoveryManager.RecordMultiplePickCallbackResult(ToAndroidUris(result)); protected override void OnActivityResultForOrphanedLaunch(JavaList result) => MediaPickerRecoveryManager.RecoverOrphanedMultiplePickResult(ToAndroidUris(result)); diff --git a/src/Essentials/src/Platform/PickVisualMediaForResult.android.cs b/src/Essentials/src/Platform/PickVisualMediaForResult.android.cs index 3c54d7a5893a..c18c631a9e35 100644 --- a/src/Essentials/src/Platform/PickVisualMediaForResult.android.cs +++ b/src/Essentials/src/Platform/PickVisualMediaForResult.android.cs @@ -12,7 +12,7 @@ internal class PickVisualMediaForResult : ActivityForResultRequest LazyInstance.Value; protected override void OnActivityResultForActiveLaunch(AndroidUri result) - => MediaPickerRecoveryManager.RecordSinglePickCallbackResult(result, materializeImmediately: true); + => MediaPickerRecoveryManager.RecordSinglePickCallbackResult(result); protected override void OnActivityResultForOrphanedLaunch(AndroidUri result) => MediaPickerRecoveryManager.RecoverOrphanedSinglePickResult(result); diff --git a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs index f1cc8ef26a6e..8f287e50065b 100644 --- a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs +++ b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs @@ -524,14 +524,13 @@ public async Task AndroidX_Orphaned_Multiple_Photo_Pick_Publishes_Recovered_Resu [Fact] public void Pick_Callback_Records_Accepted_Uri_Before_Materialization() { - var pickPath = CreateNonEmptyMediaFile(FileExtensions.Jpg); - var pickUri = CreateFileUri(pickPath); + var pickUri = AndroidUri.Parse("content://maui-test/missing-picked-media") ?? throw new InvalidOperationException("Unable to create invalid picker URI."); var pendingPick = MediaPickerRecoveryManager.BeginOperation( RecoveredMediaPickerResultKind.PickPhoto, [], PersistedPhotoProcessingOptions.Default); - Assert.True(MediaPickerRecoveryManager.RecordSinglePickCallbackResult(pickUri, materializeImmediately: false)); + Assert.True(MediaPickerRecoveryManager.RecordSinglePickCallbackResult(pickUri)); var activePick = Assert.IsType(GetActiveOperation()); Assert.Equal(pendingPick.Id, activePick.Id); @@ -540,6 +539,43 @@ public void Pick_Callback_Records_Accepted_Uri_Before_Materialization() Assert.Equal(pickUri.ToString(), GetSingleActiveOperationPickerUri(activePick)); } + [Fact] + public async Task Accepted_Pick_Materialization_Writes_Accepted_File_Paths() + { + var pickPath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + var pendingPick = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.PickPhoto, + [], + PersistedPhotoProcessingOptions.Default); + + Assert.True(MediaPickerRecoveryManager.RecordSinglePickCallbackResult(CreateFileUri(pickPath))); + + var acceptedPaths = await MediaPickerRecoveryManager.MaterializeAcceptedFilePathsAsync(pendingPick.Id, throwOnMaterializationFailure: true); + + Assert.Equal(pickPath, Assert.Single(acceptedPaths)); + var activePick = Assert.IsType(GetActiveOperation()); + Assert.Equal(pendingPick.Id, activePick.Id); + Assert.Equal(PendingMediaPickerState.ResultAccepted, activePick.State); + Assert.Equal(pickPath, GetSingleActiveOperationFilePath(activePick)); + Assert.Empty(activePick.PickerUriStrings); + } + + [Fact] + public async Task Accepted_Pick_Materialization_Failure_Throws_And_Clears_Active_State() + { + var invalidPickerUri = AndroidUri.Parse("content://maui-test/missing-picked-media") ?? throw new InvalidOperationException("Unable to create invalid picker URI."); + var pendingPick = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.PickPhoto, + [], + PersistedPhotoProcessingOptions.Default); + + Assert.True(MediaPickerRecoveryManager.RecordSinglePickCallbackResult(invalidPickerUri)); + + await Assert.ThrowsAnyAsync(async () => + await MediaPickerRecoveryManager.MaterializeAcceptedFilePathsAsync(pendingPick.Id, throwOnMaterializationFailure: true)); + Assert.Null(GetActiveOperation()); + } + [Fact] public async Task Accepted_Pick_Materialization_Failure_Clears_Active_State_And_Completes_Waiter_Empty() { @@ -549,7 +585,7 @@ public async Task Accepted_Pick_Materialization_Failure_Clears_Active_State_And_ [], PersistedPhotoProcessingOptions.Default); - Assert.True(MediaPickerRecoveryManager.RecordSinglePickCallbackResult(invalidPickerUri, materializeImmediately: false)); + Assert.True(MediaPickerRecoveryManager.RecordSinglePickCallbackResult(invalidPickerUri)); SimulateProcessRecreation(); var results = await MediaPicker.WaitForRecoveredMediaPickerResultsAsync(CancellationToken.None); @@ -608,7 +644,7 @@ public async Task Live_Process_Pick_Clear_After_Accepted_Result_Prevents_Recover [], PersistedPhotoProcessingOptions.Default); - Assert.True(MediaPickerRecoveryManager.RecordSinglePickCallbackResult(CreateFileUri(pickPath), materializeImmediately: true)); + Assert.True(MediaPickerRecoveryManager.RecordSinglePickCallbackResult(CreateFileUri(pickPath))); MediaPickerRecoveryManager.ClearActiveOperation(pendingPick.Id); SimulateProcessRecreation(); From d197896444db339bf8c95bd8b65fe5a3f06fab4b Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Tue, 19 May 2026 16:48:39 -0400 Subject: [PATCH 04/17] Add diagnostics for AndroidX activity result state key --- .../maui/PlatformMauiAppCompatActivity.java | 52 +++++++++++++++---- ...oidXActivityResultRegistryTests.Android.cs | 24 +++++++++ 2 files changed, 66 insertions(+), 10 deletions(-) create mode 100644 src/Core/tests/DeviceTests/Platform/AndroidXActivityResultRegistryTests.Android.cs 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 72dd2e65fa60..0cb1fa798c65 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,36 +1,44 @@ package com.microsoft.maui; +import android.content.pm.ApplicationInfo; import android.content.res.TypedArray; +import android.os.Bundle; +import android.util.Log; +import androidx.activity.ComponentActivity; import androidx.appcompat.app.AppCompatActivity; -import android.os.Bundle; +import java.lang.reflect.Field; /** * 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. + private static final String TAG = "MauiAppCompat"; + + // These are 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"; + // AndroidX FragmentManager saved-state key. MAUI removes this when fragment restore is + // disabled because restoring old fragments can conflict with MAUI's own navigation/window + // reconstruction. 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"; + private static boolean activityResultRegistryKeyChecked; + public static void onCreate(AppCompatActivity activity, Bundle savedInstanceState, boolean allowFragmentRestore, int splashAttr, int mauiTheme) { if (!allowFragmentRestore && savedInstanceState != null) { + warnIfActivityResultRegistryKeyChanged(activity); removeFragmentRestoreState(savedInstanceState); } @@ -51,8 +59,7 @@ public static void onCreate(AppCompatActivity activity, Bundle savedInstanceStat 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); + // First remove the direct fragment entry that may be present in the activity state. savedInstanceState.remove(SUPPORT_FRAGMENTS_KEY); Bundle savedStateRegistry = savedInstanceState.getBundle(SAVED_STATE_REGISTRY_KEY); @@ -74,4 +81,29 @@ private static void removeFragmentRestoreState(Bundle savedInstanceState) } } } + + private static void warnIfActivityResultRegistryKeyChanged(AppCompatActivity activity) + { + if (activityResultRegistryKeyChecked) { + return; + } + + activityResultRegistryKeyChecked = true; + + if ((activity.getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) == 0) { + return; + } + + try { + Field field = ComponentActivity.class.getDeclaredField("ACTIVITY_RESULT_TAG"); + field.setAccessible(true); + Object value = field.get(null); + + if (!ACTIVITY_RESULT_REGISTRY_KEY.equals(value)) { + Log.w(TAG, "AndroidX ActivityResultRegistry saved-state key changed; MediaPicker recovery may be affected."); + } + } catch (Throwable ex) { + Log.w(TAG, "Unable to verify AndroidX ActivityResultRegistry saved-state key.", ex); + } + } } diff --git a/src/Core/tests/DeviceTests/Platform/AndroidXActivityResultRegistryTests.Android.cs b/src/Core/tests/DeviceTests/Platform/AndroidXActivityResultRegistryTests.Android.cs new file mode 100644 index 000000000000..8369b9eaf5ec --- /dev/null +++ b/src/Core/tests/DeviceTests/Platform/AndroidXActivityResultRegistryTests.Android.cs @@ -0,0 +1,24 @@ +using AndroidX.Activity; +using Xunit; +using JavaClass = Java.Lang.Class; + +namespace Microsoft.Maui.DeviceTests +{ + [Category(TestCategory.Application)] + public class AndroidXActivityResultRegistryTests + { + const string ExpectedActivityResultRegistryKey = "android:support:activity-result"; + + [Fact] + public void ComponentActivity_ActivityResultSavedStateKey_MatchesMauiRecoveryKey() + { + using var componentActivityClass = JavaClass.FromType(typeof(ComponentActivity)); + using var activityResultTagField = componentActivityClass.GetDeclaredField("ACTIVITY_RESULT_TAG"); + activityResultTagField.Accessible = true; + + var activityResultTag = activityResultTagField.Get(null)?.ToString(); + + Assert.Equal(ExpectedActivityResultRegistryKey, activityResultTag); + } + } +} From 0ccc43291fc13c43d710d231e618995b9c26c85d Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Tue, 19 May 2026 17:12:43 -0400 Subject: [PATCH 05/17] Add MediaPicker pending operation discard API --- .../MediaPickerRecovery.android.cs | 11 ++ .../MediaPickerRecoveryManager.android.cs | 36 ++++++ .../net-android/PublicAPI.Unshipped.txt | 1 + .../Android/MediaPickerRecovery_Tests.cs | 109 ++++++++++++++++++ 4 files changed, 157 insertions(+) diff --git a/src/Essentials/src/MediaPicker/MediaPickerRecovery.android.cs b/src/Essentials/src/MediaPicker/MediaPickerRecovery.android.cs index 635b68d86f33..c67f68c4f713 100644 --- a/src/Essentials/src/MediaPicker/MediaPickerRecovery.android.cs +++ b/src/Essentials/src/MediaPicker/MediaPickerRecovery.android.cs @@ -107,6 +107,17 @@ public static Task> GetRecoveredMediaP public static Task> WaitForRecoveredMediaPickerResultsAsync(CancellationToken cancellationToken) => MediaPickerRecoveryManager.WaitForRecoveredResultsAsync(cancellationToken); + /// + /// Discards an Android MediaPicker operation that is still pending recovery. + /// + /// A task that represents the asynchronous discard operation. + /// + /// This Android recovery escape hatch is intended for cases where AndroidX does not replay or reconcile a pending MediaPicker result. Calling this method may discard a result that AndroidX has not replayed or that has not yet been published as recovered. + /// This method does not cancel an in-process picker or capture operation. + /// + public static Task DiscardPendingMediaPickerOperationAsync() + => MediaPickerRecoveryManager.DiscardPendingOperationAsync(); + /// /// Clears an Android MediaPicker result that was recovered after the app process was recreated. /// diff --git a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs index 81a97c003fb7..802f925df442 100644 --- a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs +++ b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs @@ -159,6 +159,42 @@ internal static Task ClearRecoveredResultAsync(string id) return Task.CompletedTask; } + internal static async Task DiscardPendingOperationAsync() + { + IReadOnlyList? waiterResults = null; + + await RecoveryPromotionSemaphore.WaitAsync().ConfigureAwait(false); + + try + { + lock (Locker) + { + var operation = MediaPickerRecoveryStore.ReadActiveOperation(); + if (operation is null) + { + return; + } + + if (IsInProcessOperation(operation)) + { + throw new InvalidOperationException("A MediaPicker operation is already in progress."); + } + + ClearActiveOperationUnderLock(operation); + waiterResults = ReadPublicRecoveredResultsUnderLock(); + } + + if (waiterResults is not null) + { + CompleteRecoveryWaitersForReconciliation(waiterResults); + } + } + finally + { + RecoveryPromotionSemaphore.Release(); + } + } + internal static void RecoverOrphanedCaptureResult(RecoveredMediaPickerResultKind kind, bool success) => _ = RecoverOrphanedCaptureResultAsync(kind, success); diff --git a/src/Essentials/src/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/Essentials/src/PublicAPI/net-android/PublicAPI.Unshipped.txt index b5b9ab35dd3f..c0a57675ef03 100644 --- a/src/Essentials/src/PublicAPI/net-android/PublicAPI.Unshipped.txt +++ b/src/Essentials/src/PublicAPI/net-android/PublicAPI.Unshipped.txt @@ -13,6 +13,7 @@ Microsoft.Maui.Media.RecoveredMediaPickerResultKind.PickVideo = 4 -> Microsoft.M Microsoft.Maui.Media.RecoveredMediaPickerResultKind.PickVideos = 5 -> Microsoft.Maui.Media.RecoveredMediaPickerResultKind *REMOVED*Microsoft.Maui.Storage.IFilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task!>! static Microsoft.Maui.Media.MediaPicker.ClearRecoveredMediaPickerResultAsync(string! id) -> System.Threading.Tasks.Task! +static Microsoft.Maui.Media.MediaPicker.DiscardPendingMediaPickerOperationAsync() -> System.Threading.Tasks.Task! static Microsoft.Maui.Media.MediaPicker.GetRecoveredMediaPickerResultsAsync() -> System.Threading.Tasks.Task!>! static Microsoft.Maui.Media.MediaPicker.WaitForRecoveredMediaPickerResultsAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! *REMOVED*static Microsoft.Maui.Storage.FilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task!>! diff --git a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs index 8f287e50065b..8c9bf343e55a 100644 --- a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs +++ b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs @@ -892,6 +892,115 @@ public async Task Recreated_Pending_Pick_Blocks_New_Operation_And_Does_Not_Compl Assert.Equal(0, GetPendingWaiterCount()); } + [Fact] + public async Task Discard_Recreated_Pending_Capture_Allows_New_Operation() + { + var firstCapturePath = CreateMissingMediaFilePath(FileExtensions.Jpg); + var secondCapturePath = CreateMissingMediaFilePath(FileExtensions.Mp4); + + var firstCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [firstCapturePath], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + + await MediaPicker.DiscardPendingMediaPickerOperationAsync(); + + Assert.Null(GetActiveOperation()); + + var secondCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CaptureVideo, + [secondCapturePath], + PersistedPhotoProcessingOptions.Default); + + Assert.NotEqual(firstCapture.Id, secondCapture.Id); + Assert.Equal(secondCapture.Id, GetActiveOperation()?.Id); + } + + [Theory] + [InlineData(RecoveredMediaPickerResultKind.PickPhoto)] + [InlineData(RecoveredMediaPickerResultKind.PickPhotos)] + public async Task Discard_Recreated_Pending_Pick_Completes_Waiter_Empty(RecoveredMediaPickerResultKind kind) + { + using var cancellationTokenSource = new CancellationTokenSource(); + + MediaPickerRecoveryManager.BeginOperation( + kind, + [], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + Assert.Equal(1, GetPendingWaiterCount()); + + await MediaPicker.DiscardPendingMediaPickerOperationAsync(); + + Assert.Empty(await WaitForCompletion(waitTask)); + Assert.Null(GetActiveOperation()); + Assert.Equal(0, GetPendingWaiterCount()); + } + + [Fact] + public async Task Discard_InProcess_Operation_Throws_And_Leaves_Active_Record() + { + var capturePath = CreateMissingMediaFilePath(FileExtensions.Jpg); + + var pendingCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + var exception = await Assert.ThrowsAsync(() => + MediaPicker.DiscardPendingMediaPickerOperationAsync()); + + Assert.Equal("A MediaPicker operation is already in progress.", exception.Message); + + var activeCapture = Assert.IsType(GetActiveOperation()); + Assert.Equal(pendingCapture.Id, activeCapture.Id); + Assert.Equal(RecoveredMediaPickerResultKind.CapturePhoto, activeCapture.Kind); + Assert.Equal(PendingMediaPickerState.Pending, activeCapture.State); + Assert.Equal(capturePath, GetSingleActiveOperationFilePath(activeCapture)); + } + + [Fact] + public async Task Discard_Pending_Operation_Is_NoOp_When_No_Active_Operation() + { + await MediaPicker.DiscardPendingMediaPickerOperationAsync(); + + Assert.Null(GetActiveOperation()); + Assert.Empty(await MediaPicker.GetRecoveredMediaPickerResultsAsync()); + } + + [Fact] + public async Task Discard_Recreated_Accepted_Capture_Does_Not_Publish_Recovered_Result() + { + var firstCapturePath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + var secondCapturePath = CreateMissingMediaFilePath(FileExtensions.Mp4); + + var firstCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [firstCapturePath], + PersistedPhotoProcessingOptions.Default); + + MediaPickerRecoveryManager.RecordCaptureCallbackResult(RecoveredMediaPickerResultKind.CapturePhoto, true); + SimulateProcessRecreation(); + + await MediaPicker.DiscardPendingMediaPickerOperationAsync(); + + Assert.Null(GetActiveOperation()); + Assert.Empty(await MediaPicker.GetRecoveredMediaPickerResultsAsync()); + + var secondCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CaptureVideo, + [secondCapturePath], + PersistedPhotoProcessingOptions.Default); + + Assert.NotEqual(firstCapture.Id, secondCapture.Id); + Assert.Equal(secondCapture.Id, GetActiveOperation()?.Id); + } + [Fact] public void Recreated_Accepted_Capture_Blocks_New_Capture_Until_Recovered() { From 86f87bbcbcf614459607be103f4d7a9264f911ad Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Tue, 19 May 2026 17:42:31 -0400 Subject: [PATCH 06/17] Persist picker URI access before recovery state --- .../MediaPickerRecoveryManager.android.cs | 14 +++++++------- .../Tests/Android/MediaPickerRecovery_Tests.cs | 6 +++++- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs index 802f925df442..2d0f6f652097 100644 --- a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs +++ b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs @@ -339,6 +339,11 @@ static bool RecordPickCallbackResult( return true; } + foreach (var uri in uris) + { + TryPersistPickerUriReadAccess(uri); + } + lock (Locker) { var current = MediaPickerRecoveryStore.ReadActiveOperation(); @@ -347,16 +352,11 @@ static bool RecordPickCallbackResult( return false; } - // AndroidX accepted the picker result. Persist the URI payload before copying from it so - // process death during materialization can still be retried after recreation. + // AndroidX accepted the picker result. Take durable URI access first, then persist the + // URI payload before copying from it so process death during materialization can be retried. MediaPickerRecoveryStore.WriteActiveOperation(current.WithAcceptedPickerUris(uriStrings)); } - foreach (var uri in uris) - { - TryPersistPickerUriReadAccess(uri); - } - return true; } diff --git a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs index 8c9bf343e55a..a6d07903c1e2 100644 --- a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs +++ b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs @@ -530,8 +530,12 @@ public void Pick_Callback_Records_Accepted_Uri_Before_Materialization() [], PersistedPhotoProcessingOptions.Default); - Assert.True(MediaPickerRecoveryManager.RecordSinglePickCallbackResult(pickUri)); + var callbackRecorded = false; + var exception = Record.Exception(() => + callbackRecorded = MediaPickerRecoveryManager.RecordSinglePickCallbackResult(pickUri)); + Assert.Null(exception); + Assert.True(callbackRecorded); var activePick = Assert.IsType(GetActiveOperation()); Assert.Equal(pendingPick.Id, activePick.Id); Assert.Equal(PendingMediaPickerState.ResultAccepted, activePick.State); From f05d84c371516918492cc75f4194f6799ecf1dc2 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Tue, 19 May 2026 17:47:05 -0400 Subject: [PATCH 07/17] Document plural picker callback matching --- .../src/MediaPicker/MediaPickerRecoveryManager.android.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs index 2d0f6f652097..efdb2515ebcd 100644 --- a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs +++ b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs @@ -683,6 +683,9 @@ static bool IsPhotoKind(RecoveredMediaPickerResultKind kind) kind == RecoveredMediaPickerResultKind.PickPhoto || kind == RecoveredMediaPickerResultKind.PickPhotos; + // PickPhotos/PickVideos with SelectionLimit == 1 use AndroidX's single-picker launcher, but + // higher selection limits use the multiple-picker launcher. AndroidX replays the launcher that + // produced the result, so plural operation kinds intentionally match both callback shapes. static bool IsSinglePickCallbackKind(RecoveredMediaPickerResultKind kind) => kind == RecoveredMediaPickerResultKind.PickPhoto || kind == RecoveredMediaPickerResultKind.PickVideo || From 7eade577770335c5835e0b0fade6e93c51f7203e Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Tue, 19 May 2026 18:01:14 -0400 Subject: [PATCH 08/17] Observe orphaned MediaPicker recovery tasks --- .../MediaPickerRecoveryManager.android.cs | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs index efdb2515ebcd..3442df86a299 100644 --- a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs +++ b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs @@ -196,7 +196,9 @@ internal static async Task DiscardPendingOperationAsync() } internal static void RecoverOrphanedCaptureResult(RecoveredMediaPickerResultKind kind, bool success) - => _ = RecoverOrphanedCaptureResultAsync(kind, success); + => ObserveOrphanedRecoveryTask( + RecoverOrphanedCaptureResultAsync(kind, success), + "Unhandled media capture recovery task failure"); internal static bool RecordCaptureCallbackResult(RecoveredMediaPickerResultKind kind, bool success) { @@ -245,7 +247,9 @@ internal static bool RecordMultiplePickCallbackResult(IReadOnlyList? uris ?? []); internal static void RecoverOrphanedSinglePickResult(AndroidUri? uri) - => _ = RecoverOrphanedSinglePickResultAsync(uri); + => ObserveOrphanedRecoveryTask( + RecoverOrphanedSinglePickResultAsync(uri), + "Unhandled picked media recovery task failure"); internal static async Task RecoverOrphanedSinglePickResultAsync(AndroidUri? uri) => await RecoverOrphanedOperationResultAsync( @@ -253,13 +257,28 @@ internal static async Task RecoverOrphanedSinglePickResultAsync(AndroidUri? uri) "Unable to recover picked media result").ConfigureAwait(false); internal static void RecoverOrphanedMultiplePickResult(IReadOnlyList? uris) - => _ = RecoverOrphanedMultiplePickResultAsync(uris); + => ObserveOrphanedRecoveryTask( + RecoverOrphanedMultiplePickResultAsync(uris), + "Unhandled picked media recovery task failure"); internal static async Task RecoverOrphanedMultiplePickResultAsync(IReadOnlyList? uris) => await RecoverOrphanedOperationResultAsync( () => RecordMultiplePickCallbackResult(uris), "Unable to recover picked media results").ConfigureAwait(false); + static void ObserveOrphanedRecoveryTask(Task task, string failureMessage) + { + _ = task.ContinueWith( + static (faultedTask, state) => + { + Trace.WriteLine($"{state}: {faultedTask.Exception}"); + }, + failureMessage, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + internal static async Task> RecoverOperationIfAvailableAsync() { var reconciliation = await RecoverOperationIfAvailableCoreAsync().ConfigureAwait(false); From fe41ef0d57669e9a57fd9adde957dac88be689e0 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Tue, 19 May 2026 18:16:18 -0400 Subject: [PATCH 09/17] Simplify MediaPicker recovery serialization --- .../MediaPickerRecoveryManager.android.cs | 329 +++++++----------- .../Android/MediaPickerRecovery_Tests.cs | 156 ++------- 2 files changed, 155 insertions(+), 330 deletions(-) diff --git a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs index 3442df86a299..c449451d8226 100644 --- a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs +++ b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs @@ -2,10 +2,10 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; using System.IO; using System.Linq; -using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Android.App; @@ -728,16 +728,7 @@ internal static class MediaPickerRecoveryStore const string ActiveOperationKey = "active_operation"; const string RecoveredResultsKey = "recovered_results"; const string PreferencesFeatureName = "media_picker"; - const string PendingOperationSerializedRecordVersion = "5"; - const string PendingOperationWithoutPickerUrisSerializedRecordVersion = "4"; - const string LegacyPendingCaptureSerializedRecordVersion = "1"; - const string LegacyPendingCaptureWithStateAndOutputUriSerializedRecordVersion = "2"; - const string LegacyPendingCaptureWithStateSerializedRecordVersion = "3"; - const string RecoveredResultSerializedRecordVersion = "2"; - const string LegacyRecoveredCaptureResultSerializedRecordVersion = "1"; - const string FieldSeparator = "|"; - const string FilePathSeparator = ","; - const string RecoveredResultSeparator = "\n"; + const int SerializedRecordVersion = 1; static readonly string PreferencesSharedName = Preferences.GetPrivatePreferencesSharedName(PreferencesFeatureName); @@ -759,12 +750,22 @@ internal static List ReadRecoveredResults() return []; } - return value - .Split([RecoveredResultSeparator], StringSplitOptions.RemoveEmptyEntries) - .Select(DeserializeRecoveredResult) - .Where(result => result is not null) - .Cast() - .ToList(); + try + { + var records = JsonSerializer.Deserialize(value, MediaPickerRecoveryJsonContext.Default.RecoveredResults); + + return records is null + ? [] + : records + .Select(DeserializeRecoveredResult) + .Where(result => result is not null) + .Cast() + .ToList(); + } + catch + { + return []; + } } internal static void WriteRecoveredResults(List results) @@ -775,24 +776,12 @@ internal static void WriteRecoveredResults(List resu return; } - Preferences.Set(RecoveredResultsKey, string.Join(RecoveredResultSeparator, results.Select(SerializeRecoveredResult)), PreferencesSharedName); + var records = results.Select(ToPreferenceRecord).ToArray(); + Preferences.Set(RecoveredResultsKey, JsonSerializer.Serialize(records, MediaPickerRecoveryJsonContext.Default.RecoveredResults), PreferencesSharedName); } static string SerializePendingOperation(PendingMediaPickerOperation operation) - => string.Join(FieldSeparator, new[] - { - PendingOperationSerializedRecordVersion, - Encode(operation.Id), - ((int)operation.Kind).ToString(CultureInfo.InvariantCulture), - ((int)operation.State).ToString(CultureInfo.InvariantCulture), - EncodeMany(operation.FilePaths), - EncodeMany(operation.PickerUriStrings), - SerializeNullableInt(operation.PhotoProcessingOptions.MaximumWidth), - SerializeNullableInt(operation.PhotoProcessingOptions.MaximumHeight), - operation.PhotoProcessingOptions.CompressionQuality.ToString(CultureInfo.InvariantCulture), - SerializeBool(operation.PhotoProcessingOptions.RotateImage), - SerializeBool(operation.PhotoProcessingOptions.PreserveMetaData) - }); + => JsonSerializer.Serialize(ToPreferenceRecord(operation), MediaPickerRecoveryJsonContext.Default.PendingOperation); static PendingMediaPickerOperation? DeserializePendingOperation(string? value) { @@ -803,18 +792,29 @@ static string SerializePendingOperation(PendingMediaPickerOperation operation) return null; } - var parts = value.Split([FieldSeparator], StringSplitOptions.None); - if (parts.Length == 11 && parts[0] == PendingOperationSerializedRecordVersion) + var record = JsonSerializer.Deserialize(value, MediaPickerRecoveryJsonContext.Default.PendingOperation); + if (record is null || + record.Version != SerializedRecordVersion || + string.IsNullOrWhiteSpace(record.Id) || + record.PhotoProcessingOptions is null || + !TryDeserializeResultKind(record.Kind, out var kind) || + !TryDeserializePendingState(record.State, out var state)) { - return DeserializeCurrentPendingOperation(parts, hasPickerUris: true); - } - - if (parts.Length == 10 && parts[0] == PendingOperationWithoutPickerUrisSerializedRecordVersion) - { - return DeserializeCurrentPendingOperation(parts, hasPickerUris: false); + return null; } - return DeserializeLegacyPendingCapture(parts); + return new PendingMediaPickerOperation( + record.Id, + kind, + state, + GetValidStrings(record.FilePaths), + GetValidStrings(record.PickerUriStrings), + new PersistedPhotoProcessingOptions( + record.PhotoProcessingOptions.MaximumWidth, + record.PhotoProcessingOptions.MaximumHeight, + record.PhotoProcessingOptions.CompressionQuality, + record.PhotoProcessingOptions.RotateImage, + record.PhotoProcessingOptions.PreserveMetaData)); } catch { @@ -822,109 +822,61 @@ static string SerializePendingOperation(PendingMediaPickerOperation operation) } } - static PendingMediaPickerOperation? DeserializeCurrentPendingOperation(string[] parts, bool hasPickerUris) - { - var maximumWidthIndex = hasPickerUris ? 6 : 5; - var maximumHeightIndex = hasPickerUris ? 7 : 6; - var compressionQualityIndex = hasPickerUris ? 8 : 7; - var rotateImageIndex = hasPickerUris ? 9 : 8; - var preserveMetaDataIndex = hasPickerUris ? 10 : 9; - - if (!TryDeserializeResultKind(parts[2], out var kind) || - !TryDeserializePendingState(parts[3], out var state) || - !int.TryParse(parts[compressionQualityIndex], NumberStyles.Integer, CultureInfo.InvariantCulture, out var compressionQuality)) + static MediaPickerPendingOperationPreferenceRecord ToPreferenceRecord(PendingMediaPickerOperation operation) + => new() { - return null; - } - - return new PendingMediaPickerOperation( - Decode(parts[1]), - kind, - state, - DecodeMany(parts[4]), - hasPickerUris ? DecodeMany(parts[5]) : [], - new PersistedPhotoProcessingOptions( - DeserializeNullableInt(parts[maximumWidthIndex]), - DeserializeNullableInt(parts[maximumHeightIndex]), - compressionQuality, - DeserializeBool(parts[rotateImageIndex]), - DeserializeBool(parts[preserveMetaDataIndex]))); - } + Version = SerializedRecordVersion, + Id = operation.Id, + Kind = (int)operation.Kind, + State = (int)operation.State, + FilePaths = operation.FilePaths.ToArray(), + PickerUriStrings = operation.PickerUriStrings.ToArray(), + PhotoProcessingOptions = new MediaPickerPhotoProcessingOptionsPreferenceRecord + { + MaximumWidth = operation.PhotoProcessingOptions.MaximumWidth, + MaximumHeight = operation.PhotoProcessingOptions.MaximumHeight, + CompressionQuality = operation.PhotoProcessingOptions.CompressionQuality, + RotateImage = operation.PhotoProcessingOptions.RotateImage, + PreserveMetaData = operation.PhotoProcessingOptions.PreserveMetaData + } + }; - static PendingMediaPickerOperation? DeserializeLegacyPendingCapture(string[] parts) + static RecoveredMediaPickerRecord? DeserializeRecoveredResult(MediaPickerRecoveredResultPreferenceRecord? record) { - var isLegacyPendingCapture = parts.Length == 10 && parts[0] == LegacyPendingCaptureSerializedRecordVersion; - var isPendingCaptureWithStateAndOutputUri = parts.Length == 11 && parts[0] == LegacyPendingCaptureWithStateAndOutputUriSerializedRecordVersion; - var isPendingCaptureWithState = parts.Length == 10 && parts[0] == LegacyPendingCaptureWithStateSerializedRecordVersion; - - if (!isLegacyPendingCapture && !isPendingCaptureWithStateAndOutputUri && !isPendingCaptureWithState) + if (record is null || + record.Version != SerializedRecordVersion || + string.IsNullOrWhiteSpace(record.Id) || + !TryDeserializeResultKind(record.Kind, out var kind)) { return null; } - var state = PendingMediaPickerState.Pending; - var filePathIndex = 3; - var maximumWidthIndex = 5; - var maximumHeightIndex = 6; - var compressionQualityIndex = 7; - var rotateImageIndex = 8; - var preserveMetaDataIndex = 9; - - if (isPendingCaptureWithStateAndOutputUri) - { - if (!TryDeserializePendingState(parts[3], out state)) - { - return null; - } - - filePathIndex = 4; - maximumWidthIndex = 6; - maximumHeightIndex = 7; - compressionQualityIndex = 8; - rotateImageIndex = 9; - preserveMetaDataIndex = 10; - } - else if (isPendingCaptureWithState) - { - if (!TryDeserializePendingState(parts[3], out state)) - { - return null; - } - - filePathIndex = 4; - maximumWidthIndex = 5; - maximumHeightIndex = 6; - compressionQualityIndex = 7; - rotateImageIndex = 8; - preserveMetaDataIndex = 9; - } + var filePaths = GetValidStrings(record.FilePaths); + return filePaths.Count > 0 ? new RecoveredMediaPickerRecord(record.Id, kind, filePaths) : null; + } - if (!TryDeserializeLegacyCaptureKind(parts[2], out var kind) || - !int.TryParse(parts[compressionQualityIndex], NumberStyles.Integer, CultureInfo.InvariantCulture, out var compressionQuality)) + static MediaPickerRecoveredResultPreferenceRecord ToPreferenceRecord(RecoveredMediaPickerRecord result) + => new() { - return null; - } + Version = SerializedRecordVersion, + Id = result.Id, + Kind = (int)result.Kind, + FilePaths = result.FilePaths.ToArray() + }; - return new PendingMediaPickerOperation( - Decode(parts[1]), - kind, - state, - [Decode(parts[filePathIndex])], - [], - new PersistedPhotoProcessingOptions( - DeserializeNullableInt(parts[maximumWidthIndex]), - DeserializeNullableInt(parts[maximumHeightIndex]), - compressionQuality, - DeserializeBool(parts[rotateImageIndex]), - DeserializeBool(parts[preserveMetaDataIndex]))); - } + static IReadOnlyList GetValidStrings(string[]? values) + => values is null + ? [] + : values + .Where(value => !string.IsNullOrEmpty(value)) + .Select(value => value!) + .ToArray(); - static bool TryDeserializePendingState(string value, out PendingMediaPickerState state) + static bool TryDeserializePendingState(int value, out PendingMediaPickerState state) { - if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var stateValue) && - Enum.IsDefined(typeof(PendingMediaPickerState), stateValue)) + if (Enum.IsDefined(typeof(PendingMediaPickerState), value)) { - state = (PendingMediaPickerState)stateValue; + state = (PendingMediaPickerState)value; return true; } @@ -932,111 +884,64 @@ static bool TryDeserializePendingState(string value, out PendingMediaPickerState return false; } - static bool TryDeserializeResultKind(string value, out RecoveredMediaPickerResultKind kind) + static bool TryDeserializeResultKind(int value, out RecoveredMediaPickerResultKind kind) { - if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var kindValue) && - Enum.IsDefined(typeof(RecoveredMediaPickerResultKind), kindValue)) + if (Enum.IsDefined(typeof(RecoveredMediaPickerResultKind), value)) { - kind = (RecoveredMediaPickerResultKind)kindValue; + kind = (RecoveredMediaPickerResultKind)value; return true; } kind = RecoveredMediaPickerResultKind.CapturePhoto; return false; } +} - static bool TryDeserializeLegacyCaptureKind(string value, out RecoveredMediaPickerResultKind kind) - { - if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var mediaTypeValue)) - { - if (mediaTypeValue == 0) - { - kind = RecoveredMediaPickerResultKind.CapturePhoto; - return true; - } +internal sealed class MediaPickerPendingOperationPreferenceRecord +{ + public int Version { get; set; } - if (mediaTypeValue == 1) - { - kind = RecoveredMediaPickerResultKind.CaptureVideo; - return true; - } - } + public string? Id { get; set; } - kind = RecoveredMediaPickerResultKind.CapturePhoto; - return false; - } + public int Kind { get; set; } - static string SerializeRecoveredResult(RecoveredMediaPickerRecord result) - => string.Join(FieldSeparator, new[] - { - RecoveredResultSerializedRecordVersion, - Encode(result.Id), - ((int)result.Kind).ToString(CultureInfo.InvariantCulture), - EncodeMany(result.FilePaths) - }); + public int State { get; set; } - static RecoveredMediaPickerRecord? DeserializeRecoveredResult(string value) - { - try - { - var parts = value.Split([FieldSeparator], StringSplitOptions.None); - if (parts.Length != 4) - { - return null; - } + public string[]? FilePaths { get; set; } - if (parts[0] == RecoveredResultSerializedRecordVersion) - { - if (!TryDeserializeResultKind(parts[2], out var kind)) - { - return null; - } + public string[]? PickerUriStrings { get; set; } - var filePaths = DecodeMany(parts[3]); - return filePaths.Count > 0 ? new RecoveredMediaPickerRecord(Decode(parts[1]), kind, filePaths) : null; - } + public MediaPickerPhotoProcessingOptionsPreferenceRecord? PhotoProcessingOptions { get; set; } +} - if (parts[0] == LegacyRecoveredCaptureResultSerializedRecordVersion && - TryDeserializeLegacyCaptureKind(parts[2], out var legacyKind)) - { - return new RecoveredMediaPickerRecord(Decode(parts[1]), legacyKind, [Decode(parts[3])]); - } +internal sealed class MediaPickerRecoveredResultPreferenceRecord +{ + public int Version { get; set; } - return null; - } - catch - { - return null; - } - } + public string? Id { get; set; } - static string EncodeMany(IReadOnlyList values) - => string.Join(FilePathSeparator, values.Select(Encode)); + public int Kind { get; set; } - static IReadOnlyList DecodeMany(string value) - => string.IsNullOrEmpty(value) - ? [] - : value.Split([FilePathSeparator], StringSplitOptions.RemoveEmptyEntries) - .Select(Decode) - .ToArray(); + public string[]? FilePaths { get; set; } +} - static string Encode(string value) - => Convert.ToBase64String(Encoding.UTF8.GetBytes(value)); +internal sealed class MediaPickerPhotoProcessingOptionsPreferenceRecord +{ + public int? MaximumWidth { get; set; } - static string Decode(string value) - => Encoding.UTF8.GetString(Convert.FromBase64String(value)); + public int? MaximumHeight { get; set; } - static string SerializeNullableInt(int? value) - => value?.ToString(CultureInfo.InvariantCulture) ?? string.Empty; + public int CompressionQuality { get; set; } - static int? DeserializeNullableInt(string value) - => string.IsNullOrEmpty(value) ? null : int.Parse(value, CultureInfo.InvariantCulture); + public bool RotateImage { get; set; } - static string SerializeBool(bool value) - => value ? "1" : "0"; + public bool PreserveMetaData { get; set; } +} - static bool DeserializeBool(string value) - => value == "1"; +[JsonSerializable(typeof(MediaPickerPendingOperationPreferenceRecord), TypeInfoPropertyName = nameof(PendingOperation))] +[JsonSerializable(typeof(MediaPickerRecoveredResultPreferenceRecord[]), TypeInfoPropertyName = nameof(RecoveredResults))] +internal sealed partial class MediaPickerRecoveryJsonContext : JsonSerializerContext +{ } /// diff --git a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs index a6d07903c1e2..c03eaa45ba20 100644 --- a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs +++ b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs @@ -1,9 +1,8 @@ using System; -using System.Globalization; using System.IO; using System.Linq; using System.Reflection; -using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Maui.ApplicationModel; @@ -1103,77 +1102,53 @@ public void PhotoProcessingOptions_Are_Created_From_MediaPickerOptions() Assert.Equal(options.PreserveMetaData, processingOptions.PreserveMetaData); } - [Theory] - [InlineData("1", (int)PendingMediaPickerState.Pending)] - [InlineData("2", (int)PendingMediaPickerState.ResultAccepted)] - public void Pending_Capture_Reads_Previous_Unshipped_Formats(string version, int expectedStateValue) - { - var expectedState = (PendingMediaPickerState)expectedStateValue; - var id = Guid.NewGuid().ToString("N"); - var capturePath = CreateMissingMediaFilePath(FileExtensions.Jpg); - var serializedCapture = version == "1" - ? SerializeLegacyPendingCaptureForTests(id, RecoveredMediaPickerResultKind.CapturePhoto, capturePath) - : SerializePendingCaptureWithOutputUriForTests(id, RecoveredMediaPickerResultKind.CapturePhoto, expectedState, capturePath); - - SetSerializedActiveOperation(serializedCapture); - - var activeCapture = Assert.IsType(GetActiveOperation()); - Assert.Equal(id, activeCapture.Id); - Assert.Equal(RecoveredMediaPickerResultKind.CapturePhoto, activeCapture.Kind); - Assert.Equal(expectedState, activeCapture.State); - Assert.Equal(capturePath, GetSingleActiveOperationFilePath(activeCapture)); - Assert.Equal(640, activeCapture.PhotoProcessingOptions.MaximumWidth.GetValueOrDefault()); - Assert.Equal(480, activeCapture.PhotoProcessingOptions.MaximumHeight.GetValueOrDefault()); - Assert.Equal(70, activeCapture.PhotoProcessingOptions.CompressionQuality); - Assert.True(activeCapture.PhotoProcessingOptions.RotateImage); - Assert.False(activeCapture.PhotoProcessingOptions.PreserveMetaData); - } - [Fact] - public void Pending_Operation_Reads_Previous_Unshipped_Format_Without_PickerUris() + public void Pending_Operation_Writes_Json_Baseline_And_RoundTrips() { var id = Guid.NewGuid().ToString("N"); var capturePath = CreateMissingMediaFilePath(FileExtensions.Jpg); - var serializedCapture = SerializePendingOperationWithoutPickerUrisForTests( + var pickerUriString = "content://maui-test/picked-media"; + var operation = new PendingMediaPickerOperation( id, - RecoveredMediaPickerResultKind.CapturePhoto, + RecoveredMediaPickerResultKind.PickPhotos, PendingMediaPickerState.ResultAccepted, - capturePath); - - SetSerializedActiveOperation(serializedCapture); - - var activeCapture = Assert.IsType(GetActiveOperation()); - Assert.Equal(id, activeCapture.Id); - Assert.Equal(PendingMediaPickerState.ResultAccepted, activeCapture.State); - Assert.Equal(capturePath, GetSingleActiveOperationFilePath(activeCapture)); - Assert.Empty(activeCapture.PickerUriStrings); - } - - [Fact] - public void Pending_Capture_Writes_Current_Format_Without_OutputUri() - { - var capturePath = CreateMissingMediaFilePath(FileExtensions.Jpg); - - var pendingCapture = MediaPickerRecoveryManager.BeginOperation( - RecoveredMediaPickerResultKind.CapturePhoto, [capturePath], + [pickerUriString], new PersistedPhotoProcessingOptions(640, 480, 70, true, false)); + MediaPickerRecoveryStore.WriteActiveOperation(operation); + var serializedCapture = Assert.IsType(GetSerializedActiveOperation()); - var parts = serializedCapture.Split('|'); - - Assert.Equal("5", parts[0]); - Assert.Equal(11, parts.Length); - Assert.Equal(EncodePreferenceValueForTests(pendingCapture.Id), parts[1]); - Assert.Equal(((int)RecoveredMediaPickerResultKind.CapturePhoto).ToString(CultureInfo.InvariantCulture), parts[2]); - Assert.Equal(((int)PendingMediaPickerState.Pending).ToString(CultureInfo.InvariantCulture), parts[3]); - Assert.Equal(EncodePreferenceValueForTests(capturePath), parts[4]); - Assert.Empty(parts[5]); - Assert.Equal("640", parts[6]); - Assert.Equal("480", parts[7]); - Assert.Equal("70", parts[8]); - Assert.Equal("1", parts[9]); - Assert.Equal("0", parts[10]); + + using var jsonDocument = JsonDocument.Parse(serializedCapture); + var root = jsonDocument.RootElement; + Assert.Equal(1, root.GetProperty("Version").GetInt32()); + Assert.Equal(id, root.GetProperty("Id").GetString()); + Assert.Equal((int)RecoveredMediaPickerResultKind.PickPhotos, root.GetProperty("Kind").GetInt32()); + Assert.Equal((int)PendingMediaPickerState.ResultAccepted, root.GetProperty("State").GetInt32()); + Assert.Equal(capturePath, Assert.Single(root.GetProperty("FilePaths").EnumerateArray()).GetString()); + Assert.Equal(pickerUriString, Assert.Single(root.GetProperty("PickerUriStrings").EnumerateArray()).GetString()); + Assert.DoesNotContain("OutputUri", serializedCapture, StringComparison.Ordinal); + Assert.DoesNotContain("outputUri", serializedCapture, StringComparison.Ordinal); + + var photoProcessingOptions = root.GetProperty("PhotoProcessingOptions"); + Assert.Equal(640, photoProcessingOptions.GetProperty("MaximumWidth").GetInt32()); + Assert.Equal(480, photoProcessingOptions.GetProperty("MaximumHeight").GetInt32()); + Assert.Equal(70, photoProcessingOptions.GetProperty("CompressionQuality").GetInt32()); + Assert.True(photoProcessingOptions.GetProperty("RotateImage").GetBoolean()); + Assert.False(photoProcessingOptions.GetProperty("PreserveMetaData").GetBoolean()); + + var activeOperation = Assert.IsType(GetActiveOperation()); + Assert.Equal(id, activeOperation.Id); + Assert.Equal(RecoveredMediaPickerResultKind.PickPhotos, activeOperation.Kind); + Assert.Equal(PendingMediaPickerState.ResultAccepted, activeOperation.State); + Assert.Equal(capturePath, GetSingleActiveOperationFilePath(activeOperation)); + Assert.Equal(pickerUriString, GetSingleActiveOperationPickerUri(activeOperation)); + Assert.Equal(640, activeOperation.PhotoProcessingOptions.MaximumWidth.GetValueOrDefault()); + Assert.Equal(480, activeOperation.PhotoProcessingOptions.MaximumHeight.GetValueOrDefault()); + Assert.Equal(70, activeOperation.PhotoProcessingOptions.CompressionQuality); + Assert.True(activeOperation.PhotoProcessingOptions.RotateImage); + Assert.False(activeOperation.PhotoProcessingOptions.PreserveMetaData); } [Fact] @@ -1454,9 +1429,6 @@ static int GetPendingWaiterCount() static string GetSerializedActiveOperation() => Preferences.Get(ActiveOperationPreferenceKey, null, RecoveryPreferencesSharedName); - static void SetSerializedActiveOperation(string value) - => Preferences.Set(ActiveOperationPreferenceKey, value, RecoveryPreferencesSharedName); - static void ClearInProcessOperationIds() { var ids = GetPrivateStaticField("InProcessOperationIds"); @@ -1529,58 +1501,6 @@ static string GetSingleActiveOperationFilePath(PendingMediaPickerOperation opera static string GetSingleActiveOperationPickerUri(PendingMediaPickerOperation operation) => Assert.Single(operation.PickerUriStrings); - static string SerializeLegacyPendingCaptureForTests(string id, RecoveredMediaPickerResultKind kind, string filePath) - => string.Join("|", new[] - { - "1", - EncodePreferenceValueForTests(id), - GetLegacyCaptureKindValue(kind).ToString(CultureInfo.InvariantCulture), - EncodePreferenceValueForTests(filePath), - EncodePreferenceValueForTests("content://maui-test/media-capture/legacy"), - "640", - "480", - "70", - "1", - "0" - }); - - static string SerializePendingCaptureWithOutputUriForTests(string id, RecoveredMediaPickerResultKind kind, PendingMediaPickerState state, string filePath) - => string.Join("|", new[] - { - "2", - EncodePreferenceValueForTests(id), - GetLegacyCaptureKindValue(kind).ToString(CultureInfo.InvariantCulture), - ((int)state).ToString(CultureInfo.InvariantCulture), - EncodePreferenceValueForTests(filePath), - EncodePreferenceValueForTests("content://maui-test/media-capture/previous"), - "640", - "480", - "70", - "1", - "0" - }); - - static string SerializePendingOperationWithoutPickerUrisForTests(string id, RecoveredMediaPickerResultKind kind, PendingMediaPickerState state, string filePath) - => string.Join("|", new[] - { - "4", - EncodePreferenceValueForTests(id), - ((int)kind).ToString(CultureInfo.InvariantCulture), - ((int)state).ToString(CultureInfo.InvariantCulture), - EncodePreferenceValueForTests(filePath), - "640", - "480", - "70", - "1", - "0" - }); - - static int GetLegacyCaptureKindValue(RecoveredMediaPickerResultKind kind) - => kind == RecoveredMediaPickerResultKind.CaptureVideo ? 1 : 0; - - static string EncodePreferenceValueForTests(string value) - => Convert.ToBase64String(Encoding.UTF8.GetBytes(value ?? string.Empty)); - sealed class TestMediaCaptureForResult : MediaCaptureForResult { public TestMediaCaptureForResult(RecoveredMediaPickerResultKind kind) From 7dabf2fe401ac6833e6cc25236285a1998aca0c7 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Fri, 22 May 2026 16:25:12 -0400 Subject: [PATCH 10/17] Fix MediaPicker recovery waiter registration race --- .../MediaPickerRecoveryManager.android.cs | 47 +++++++++++++---- .../Android/MediaPickerRecovery_Tests.cs | 51 +++++++++++++++++++ 2 files changed, 87 insertions(+), 11 deletions(-) diff --git a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs index c449451d8226..3963db98ab35 100644 --- a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs +++ b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs @@ -951,8 +951,9 @@ internal sealed class MediaPickerRecoveryWaiter { readonly CancellationToken cancellationToken; readonly TaskCompletionSource> completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + readonly Lock completionLock = new(); CancellationTokenRegistration cancellationRegistration; - int completed; + bool completed; public MediaPickerRecoveryWaiter(CancellationToken cancellationToken) { @@ -963,40 +964,64 @@ public MediaPickerRecoveryWaiter(CancellationToken cancellationToken) public void SetCancellationRegistration(CancellationTokenRegistration registration) { - if (Volatile.Read(ref completed) == 0) - { - cancellationRegistration = registration; + var disposeRegistration = false; - if (Volatile.Read(ref completed) == 0) + lock (completionLock) + { + if (completed) { - return; + disposeRegistration = true; + } + else + { + cancellationRegistration = registration; } } - registration.Dispose(); + if (disposeRegistration) + { + registration.Dispose(); + } } public void TrySetResult(IReadOnlyList results) { - if (Interlocked.Exchange(ref completed, 1) != 0) + if (!TryComplete(out var registration)) { return; } - cancellationRegistration.Dispose(); + registration.Dispose(); completionSource.TrySetResult(results); } public void TrySetCanceled() { - if (Interlocked.Exchange(ref completed, 1) != 0) + if (!TryComplete(out var registration)) { return; } - cancellationRegistration.Dispose(); + registration.Dispose(); completionSource.TrySetCanceled(cancellationToken); } + + bool TryComplete(out CancellationTokenRegistration registration) + { + lock (completionLock) + { + if (completed) + { + registration = default; + return false; + } + + completed = true; + registration = cancellationRegistration; + cancellationRegistration = default; + return true; + } + } } /// diff --git a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs index c03eaa45ba20..a354ab8893d4 100644 --- a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs +++ b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs @@ -221,6 +221,57 @@ public async Task Wait_For_Recovered_Results_Can_Be_Canceled() Assert.Equal(0, GetPendingWaiterCount()); } + [Fact] + public async Task Recovery_Waiter_Result_Before_Registration_Disposes_Late_Registration() + { + using var cancellationTokenSource = new CancellationTokenSource(); + var callbackCount = 0; + var waiter = new MediaPickerRecoveryWaiter(cancellationTokenSource.Token); + + waiter.TrySetResult(Array.Empty()); + waiter.SetCancellationRegistration(cancellationTokenSource.Token.Register( + () => Interlocked.Increment(ref callbackCount))); + + Assert.Empty(await WaitForCompletion(waiter.Task)); + await cancellationTokenSource.CancelAsync(); + + Assert.Equal(0, Volatile.Read(ref callbackCount)); + } + + [Fact] + public async Task Recovery_Waiter_Cancel_Before_Registration_Disposes_Late_Registration() + { + using var cancellationTokenSource = new CancellationTokenSource(); + var callbackCount = 0; + var waiter = new MediaPickerRecoveryWaiter(cancellationTokenSource.Token); + + waiter.TrySetCanceled(); + waiter.SetCancellationRegistration(cancellationTokenSource.Token.Register( + () => Interlocked.Increment(ref callbackCount))); + + await Assert.ThrowsAnyAsync(() => waiter.Task); + await cancellationTokenSource.CancelAsync(); + + Assert.Equal(0, Volatile.Read(ref callbackCount)); + } + + [Fact] + public async Task Recovery_Waiter_Result_Disposes_Registered_Cancellation() + { + using var cancellationTokenSource = new CancellationTokenSource(); + var callbackCount = 0; + var waiter = new MediaPickerRecoveryWaiter(cancellationTokenSource.Token); + + waiter.SetCancellationRegistration(cancellationTokenSource.Token.Register( + () => Interlocked.Increment(ref callbackCount))); + waiter.TrySetResult(Array.Empty()); + + Assert.Empty(await WaitForCompletion(waiter.Task)); + await cancellationTokenSource.CancelAsync(); + + Assert.Equal(0, Volatile.Read(ref callbackCount)); + } + [Fact] public async Task Wait_For_Recovered_Results_Completes_Multiple_Waiters() { From df8a51dd7c8a99e07a47798dc5d061ad1bc3d425 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Fri, 22 May 2026 16:48:14 -0400 Subject: [PATCH 11/17] Fix persistable picker URI grant leaks --- .../MediaPickerRecoveryManager.android.cs | 132 +++++++++++++++--- .../Android/MediaPickerRecovery_Tests.cs | 97 +++++++++++++ 2 files changed, 209 insertions(+), 20 deletions(-) diff --git a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs index 3963db98ab35..19c8c36be9c2 100644 --- a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs +++ b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs @@ -31,6 +31,8 @@ internal static class MediaPickerRecoveryManager static readonly HashSet InProcessOperationIds = new(StringComparer.Ordinal); static readonly List RecoveryWaiters = []; static readonly SemaphoreSlim RecoveryPromotionSemaphore = new(1, 1); + static Func PersistPickerUriReadAccessHandler = PersistPickerUriReadAccessCore; + static Action ReleasePickerUriReadAccessHandler = ReleasePickerUriReadAccessCore; // Lets waiters detect an empty recovery outcome that happened before they could be registered. static long RecoveryReconciliationGeneration; @@ -82,14 +84,18 @@ internal static void ClearActiveOperation(string id) return; } + IReadOnlyList pickerUriStringsToRelease = []; + lock (Locker) { var operation = MediaPickerRecoveryStore.ReadActiveOperation(); if (operation?.Id == id) { - ClearActiveOperationUnderLock(operation); + pickerUriStringsToRelease = ClearActiveOperationUnderLock(operation); } } + + ReleasePickerUriReadAccess(pickerUriStringsToRelease); } internal static async Task> GetRecoveredResultsAsync() @@ -159,9 +165,18 @@ internal static Task ClearRecoveredResultAsync(string id) return Task.CompletedTask; } + internal static void SetPickerUriPermissionHandlersForTests( + Func? persistHandler, + Action? releaseHandler) + { + PersistPickerUriReadAccessHandler = persistHandler ?? PersistPickerUriReadAccessCore; + ReleasePickerUriReadAccessHandler = releaseHandler ?? ReleasePickerUriReadAccessCore; + } + internal static async Task DiscardPendingOperationAsync() { IReadOnlyList? waiterResults = null; + IReadOnlyList pickerUriStringsToRelease = []; await RecoveryPromotionSemaphore.WaitAsync().ConfigureAwait(false); @@ -180,10 +195,12 @@ internal static async Task DiscardPendingOperationAsync() throw new InvalidOperationException("A MediaPicker operation is already in progress."); } - ClearActiveOperationUnderLock(operation); + pickerUriStringsToRelease = ClearActiveOperationUnderLock(operation); waiterResults = ReadPublicRecoveredResultsUnderLock(); } + ReleasePickerUriReadAccess(pickerUriStringsToRelease); + if (waiterResults is not null) { CompleteRecoveryWaitersForReconciliation(waiterResults); @@ -219,7 +236,7 @@ internal static bool RecordCaptureCallbackResult(RecoveredMediaPickerResultKind var outputPath = operation.FilePaths.Count == 1 ? operation.FilePaths[0] : null; if (!success || !IsFileAvailable(outputPath)) { - ClearActiveOperationUnderLock(operation); + _ = ClearActiveOperationUnderLock(operation); return true; } @@ -358,22 +375,44 @@ static bool RecordPickCallbackResult( return true; } + var persistedUriStrings = new List(); foreach (var uri in uris) { - TryPersistPickerUriReadAccess(uri); + if (TryPersistPickerUriReadAccess(uri) && uri is not null) + { + var uriString = uri.ToString(); + if (!string.IsNullOrWhiteSpace(uriString)) + { + persistedUriStrings.Add(uriString); + } + } } - lock (Locker) + var callbackRecorded = false; + try { - var current = MediaPickerRecoveryStore.ReadActiveOperation(); - if (current?.Id != operation.Id) + lock (Locker) { - return false; + var current = MediaPickerRecoveryStore.ReadActiveOperation(); + if (current?.Id == operation.Id) + { + // AndroidX accepted the picker result. Take durable URI access first, then persist the + // URI payload before copying from it so process death during materialization can be retried. + MediaPickerRecoveryStore.WriteActiveOperation(current.WithAcceptedPickerUris(uriStrings)); + callbackRecorded = true; + } } + } + catch + { + ReleasePickerUriReadAccess(persistedUriStrings); + throw; + } - // AndroidX accepted the picker result. Take durable URI access first, then persist the - // URI payload before copying from it so process death during materialization can be retried. - MediaPickerRecoveryStore.WriteActiveOperation(current.WithAcceptedPickerUris(uriStrings)); + if (!callbackRecorded) + { + ReleasePickerUriReadAccess(persistedUriStrings); + return false; } return true; @@ -430,6 +469,7 @@ internal static async Task> MaterializeAcceptedFilePathsAs return []; } + IReadOnlyList pickerUriStringsToRelease; lock (Locker) { var current = MediaPickerRecoveryStore.ReadActiveOperation(); @@ -438,9 +478,11 @@ internal static async Task> MaterializeAcceptedFilePathsAs return []; } + pickerUriStringsToRelease = current.PickerUriStrings.ToArray(); MediaPickerRecoveryStore.WriteActiveOperation(current.WithAcceptedFiles(filePaths)); } + ReleasePickerUriReadAccess(pickerUriStringsToRelease); return filePaths; } @@ -479,25 +521,74 @@ static async Task> MaterializePickerUrisAsync(IReadOnlyLis return filePaths; } - static void TryPersistPickerUriReadAccess(AndroidUri? uri) + static bool TryPersistPickerUriReadAccess(AndroidUri? uri) { - if (uri is null || - uri.Equals(AndroidUri.Empty) || - !string.Equals(uri.Scheme, FileSystemUtils.UriSchemeContent, StringComparison.OrdinalIgnoreCase)) + if (!IsPersistablePickerUri(uri)) { - return; + return false; } try { - Application.Context?.ContentResolver?.TakePersistableUriPermission(uri, ActivityFlags.GrantReadUriPermission); + return PersistPickerUriReadAccessHandler(uri!); } catch (Exception ex) { Trace.WriteLine($"Unable to persist picked media URI access: {ex}"); + return false; } } + static bool PersistPickerUriReadAccessCore(AndroidUri uri) + { + var contentResolver = Application.Context?.ContentResolver; + if (contentResolver is null) + { + return false; + } + + contentResolver.TakePersistableUriPermission(uri, ActivityFlags.GrantReadUriPermission); + return true; + } + + static void ReleasePickerUriReadAccess(IReadOnlyList uriStrings) + { + foreach (var uriString in uriStrings) + { + if (string.IsNullOrWhiteSpace(uriString)) + { + continue; + } + + TryReleasePickerUriReadAccess(AndroidUri.Parse(uriString)); + } + } + + static void TryReleasePickerUriReadAccess(AndroidUri? uri) + { + if (!IsPersistablePickerUri(uri)) + { + return; + } + + try + { + ReleasePickerUriReadAccessHandler(uri!); + } + catch (Exception ex) + { + Trace.WriteLine($"Unable to release picked media URI access: {ex}"); + } + } + + static void ReleasePickerUriReadAccessCore(AndroidUri uri) + => Application.Context?.ContentResolver?.ReleasePersistableUriPermission(uri, ActivityFlags.GrantReadUriPermission); + + static bool IsPersistablePickerUri(AndroidUri? uri) + => uri is not null && + !uri.Equals(AndroidUri.Empty) && + string.Equals(uri.Scheme, FileSystemUtils.UriSchemeContent, StringComparison.OrdinalIgnoreCase); + static async Task RecoverOrphanedOperationResultAsync(Func recordResult, string failureMessage) { IReadOnlyList? waiterResults = null; @@ -567,7 +658,7 @@ static async Task PublishRecoveredOperationAsync(PendingMediaPickerOperation ope if (recoveredPaths.Count == 0) { - ClearActiveOperationUnderLock(operation); + _ = ClearActiveOperationUnderLock(operation); return; } @@ -577,7 +668,7 @@ static async Task PublishRecoveredOperationAsync(PendingMediaPickerOperation ope recoveredResults.Add(recoveredResult); MediaPickerRecoveryStore.WriteRecoveredResults(recoveredResults); - ClearActiveOperationUnderLock(operation); + _ = ClearActiveOperationUnderLock(operation); } } @@ -644,10 +735,11 @@ static void ThrowIfActiveOperationBlocksNewOperation(PendingMediaPickerOperation throw new InvalidOperationException("A MediaPicker operation is pending AndroidX result replay."); } - static void ClearActiveOperationUnderLock(PendingMediaPickerOperation operation) + static IReadOnlyList ClearActiveOperationUnderLock(PendingMediaPickerOperation operation) { InProcessOperationIds.Remove(operation.Id); MediaPickerRecoveryStore.RemoveActiveOperation(); + return operation.PickerUriStrings.ToArray(); } static void CancelRecoveryWaiter(MediaPickerRecoveryWaiter waiter) diff --git a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs index a354ab8893d4..a6a032be38f8 100644 --- a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs +++ b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; @@ -575,6 +576,7 @@ public async Task AndroidX_Orphaned_Multiple_Photo_Pick_Publishes_Recovered_Resu public void Pick_Callback_Records_Accepted_Uri_Before_Materialization() { var pickUri = AndroidUri.Parse("content://maui-test/missing-picked-media") ?? throw new InvalidOperationException("Unable to create invalid picker URI."); + using var permissions = TrackPickerUriPermissions(); var pendingPick = MediaPickerRecoveryManager.BeginOperation( RecoveredMediaPickerResultKind.PickPhoto, [], @@ -591,6 +593,8 @@ public void Pick_Callback_Records_Accepted_Uri_Before_Materialization() Assert.Equal(PendingMediaPickerState.ResultAccepted, activePick.State); Assert.Empty(activePick.FilePaths); Assert.Equal(pickUri.ToString(), GetSingleActiveOperationPickerUri(activePick)); + Assert.Equal(new[] { pickUri.ToString() }, permissions.Persisted); + Assert.Empty(permissions.Released); } [Fact] @@ -614,10 +618,37 @@ public async Task Accepted_Pick_Materialization_Writes_Accepted_File_Paths() Assert.Empty(activePick.PickerUriStrings); } + [Fact] + public async Task Accepted_Pick_Materialization_Releases_Persisted_Picker_Uri_Access() + { + var pickPath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + var pickUri = CreateContentUri(pickPath); + using var permissions = TrackPickerUriPermissions(); + var pendingPick = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.PickPhoto, + [], + PersistedPhotoProcessingOptions.Default); + + Assert.True(MediaPickerRecoveryManager.RecordSinglePickCallbackResult(pickUri)); + + var acceptedPaths = await MediaPickerRecoveryManager.MaterializeAcceptedFilePathsAsync(pendingPick.Id, throwOnMaterializationFailure: true); + + var acceptedPath = Assert.Single(acceptedPaths); + Assert.True(File.Exists(acceptedPath)); + var activePick = Assert.IsType(GetActiveOperation()); + Assert.Equal(pendingPick.Id, activePick.Id); + Assert.Equal(PendingMediaPickerState.ResultAccepted, activePick.State); + Assert.Equal(acceptedPath, GetSingleActiveOperationFilePath(activePick)); + Assert.Empty(activePick.PickerUriStrings); + Assert.Equal(new[] { pickUri.ToString() }, permissions.Persisted); + Assert.Equal(new[] { pickUri.ToString() }, permissions.Released); + } + [Fact] public async Task Accepted_Pick_Materialization_Failure_Throws_And_Clears_Active_State() { var invalidPickerUri = AndroidUri.Parse("content://maui-test/missing-picked-media") ?? throw new InvalidOperationException("Unable to create invalid picker URI."); + using var permissions = TrackPickerUriPermissions(); var pendingPick = MediaPickerRecoveryManager.BeginOperation( RecoveredMediaPickerResultKind.PickPhoto, [], @@ -628,6 +659,32 @@ public async Task Accepted_Pick_Materialization_Failure_Throws_And_Clears_Active await Assert.ThrowsAnyAsync(async () => await MediaPickerRecoveryManager.MaterializeAcceptedFilePathsAsync(pendingPick.Id, throwOnMaterializationFailure: true)); Assert.Null(GetActiveOperation()); + Assert.Equal(new[] { invalidPickerUri.ToString() }, permissions.Persisted); + Assert.Equal(new[] { invalidPickerUri.ToString() }, permissions.Released); + } + + [Fact] + public void Pick_Callback_Lost_Active_Operation_Race_Releases_Persisted_Uri_Access() + { + var pickUri = AndroidUri.Parse("content://maui-test/picked-media") ?? throw new InvalidOperationException("Unable to create picker URI."); + PendingMediaPickerOperation? pendingPick = null; + using var permissions = TrackPickerUriPermissions(_ => + { + if (pendingPick is not null) + { + MediaPickerRecoveryManager.ClearActiveOperation(pendingPick.Id); + } + }); + pendingPick = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.PickPhoto, + [], + PersistedPhotoProcessingOptions.Default); + + Assert.False(MediaPickerRecoveryManager.RecordSinglePickCallbackResult(pickUri)); + + Assert.Null(GetActiveOperation()); + Assert.Equal(new[] { pickUri.ToString() }, permissions.Persisted); + Assert.Equal(new[] { pickUri.ToString() }, permissions.Released); } [Fact] @@ -1455,6 +1512,9 @@ static string CreateValidJpegMediaFile() static string CreateMissingMediaFilePath(string extension) => CreateCacheFilePath(extension); + static AndroidUri CreateContentUri(string path) + => FileProvider.GetUriForFile(new Java.IO.File(path)) ?? throw new InvalidOperationException("Unable to create content URI."); + static void SimulateProcessRecreation() { // Clear only in-memory operation ownership. Durable preference state remains so the next @@ -1466,6 +1526,7 @@ static void ResetRecoveryState() { ClearInProcessOperationIds(); CompleteAndClearRecoveryWaiters(); + MediaPickerRecoveryManager.SetPickerUriPermissionHandlersForTests(null, null); SetRecoveryReconciliationGeneration(0); Preferences.Remove(ActiveOperationPreferenceKey, RecoveryPreferencesSharedName); Preferences.Remove(RecoveredResultsPreferenceKey, RecoveryPreferencesSharedName); @@ -1504,6 +1565,13 @@ static void CompleteAndClearRecoveryWaiters() static System.Collections.IList GetRecoveryWaiters() => GetPrivateStaticField("RecoveryWaiters"); + static PickerUriPermissionTracker TrackPickerUriPermissions(Action? onPersist = null) + { + var tracker = new PickerUriPermissionTracker(onPersist); + MediaPickerRecoveryManager.SetPickerUriPermissionHandlersForTests(tracker.Persist, tracker.Release); + return tracker; + } + static void SetRecoveryReconciliationGeneration(long value) { var field = typeof(MediaPickerRecoveryManager) @@ -1522,6 +1590,35 @@ static T GetPrivateStaticField(string name) return (T)value; } + sealed class PickerUriPermissionTracker : IDisposable + { + readonly Action? onPersist; + readonly List persisted = []; + readonly List released = []; + + public PickerUriPermissionTracker(Action? onPersist) + { + this.onPersist = onPersist; + } + + public IReadOnlyList Persisted => persisted; + + public IReadOnlyList Released => released; + + public bool Persist(AndroidUri uri) + { + persisted.Add(uri.ToString()); + onPersist?.Invoke(uri); + return true; + } + + public void Release(AndroidUri uri) + => released.Add(uri.ToString()); + + public void Dispose() + => MediaPickerRecoveryManager.SetPickerUriPermissionHandlersForTests(null, null); + } + static string CreateCacheFilePath(string extension) { var cacheDirectory = FileSystem.CacheDirectory ?? throw new InvalidOperationException("FileSystem.CacheDirectory is not available."); From 2327791ad8b4e9f075bcf57e706570e6b8b2d8f6 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Fri, 22 May 2026 17:08:10 -0400 Subject: [PATCH 12/17] Fix MediaPicker recovery begin race --- .../src/MediaPicker/MediaPicker.android.cs | 16 +- .../MediaPickerRecoveryManager.android.cs | 101 ++++++++--- .../Android/MediaPickerRecovery_Tests.cs | 161 ++++++++++++++++++ 3 files changed, 244 insertions(+), 34 deletions(-) diff --git a/src/Essentials/src/MediaPicker/MediaPicker.android.cs b/src/Essentials/src/MediaPicker/MediaPicker.android.cs index 57975fa50f1a..ce6ae7c93146 100644 --- a/src/Essentials/src/MediaPicker/MediaPicker.android.cs +++ b/src/Essentials/src/MediaPicker/MediaPicker.android.cs @@ -174,10 +174,8 @@ static async Task CapturePhotoWithActivityResultAsync(MediaPickerOptions var captureFile = FileSystemUtils.GetTemporaryFile(Application.Context.CacheDir, fileName); var outputUri = FileProvider.GetUriForFile(captureFile); - await MediaPickerRecoveryManager.RecoverOperationIfAvailableAsync(); - var processingOptions = GetPhotoProcessingOptions(options); - var pendingOperation = MediaPickerRecoveryManager.BeginOperation( + var pendingOperation = await MediaPickerRecoveryManager.BeginOperationWithRecoveryAsync( RecoveredMediaPickerResultKind.CapturePhoto, [captureFile.AbsolutePath], processingOptions); @@ -206,9 +204,7 @@ async Task CaptureVideoWithActivityResultAsync(MediaPickerOptions option var captureFile = FileSystemUtils.GetTemporaryFile(Application.Context.CacheDir, fileName); var outputUri = FileProvider.GetUriForFile(captureFile); - await MediaPickerRecoveryManager.RecoverOperationIfAvailableAsync(); - - var pendingOperation = MediaPickerRecoveryManager.BeginOperation( + var pendingOperation = await MediaPickerRecoveryManager.BeginOperationWithRecoveryAsync( RecoveredMediaPickerResultKind.CaptureVideo, [captureFile.AbsolutePath], PersistedPhotoProcessingOptions.Default); @@ -292,10 +288,8 @@ async Task PickUsingPhotoPicker( .SetMediaType(photo ? ActivityResultContracts.PickVisualMedia.ImageOnly.Instance : ActivityResultContracts.PickVisualMedia.VideoOnly.Instance) .Build(); - await MediaPickerRecoveryManager.RecoverOperationIfAvailableAsync(); - var processingOptions = GetPhotoProcessingOptions(options); - var pendingOperation = MediaPickerRecoveryManager.BeginOperation( + var pendingOperation = await MediaPickerRecoveryManager.BeginOperationWithRecoveryAsync( operationKind ?? (photo ? RecoveredMediaPickerResultKind.PickPhoto : RecoveredMediaPickerResultKind.PickVideo), [], processingOptions); @@ -351,10 +345,8 @@ async Task> PickMultipleUsingPhotoPicker(MediaPickerOptions opt pickVisualMediaRequestBuilder.SetMaxItems(selectionLimit); } - await MediaPickerRecoveryManager.RecoverOperationIfAvailableAsync(); - var processingOptions = GetPhotoProcessingOptions(options); - var pendingOperation = MediaPickerRecoveryManager.BeginOperation( + var pendingOperation = await MediaPickerRecoveryManager.BeginOperationWithRecoveryAsync( photo ? RecoveredMediaPickerResultKind.PickPhotos : RecoveredMediaPickerResultKind.PickVideos, [], processingOptions); diff --git a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs index 19c8c36be9c2..cbba6d039728 100644 --- a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs +++ b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs @@ -33,6 +33,7 @@ internal static class MediaPickerRecoveryManager static readonly SemaphoreSlim RecoveryPromotionSemaphore = new(1, 1); static Func PersistPickerUriReadAccessHandler = PersistPickerUriReadAccessCore; static Action ReleasePickerUriReadAccessHandler = ReleasePickerUriReadAccessCore; + static Action? BeginOperationWithRecoveryCheckpointHandler; // Lets waiters detect an empty recovery outcome that happened before they could be registered. static long RecoveryReconciliationGeneration; @@ -41,17 +42,7 @@ internal static PendingMediaPickerOperation BeginOperation( IReadOnlyList filePaths, PersistedPhotoProcessingOptions photoProcessingOptions) { - if (!IsKnownKind(kind)) - { - throw new ArgumentOutOfRangeException(nameof(kind)); - } - - if (filePaths is null) - { - throw new ArgumentNullException(nameof(filePaths)); - } - - PendingMediaPickerOperation operation; + ValidateBeginOperationArguments(kind, filePaths); // Persist the one active MediaPicker operation before launching AndroidX. Later AndroidX // callbacks are matched back to this durable record, including after process recreation. @@ -62,19 +53,50 @@ internal static PendingMediaPickerOperation BeginOperation( ThrowIfActiveOperationBlocksNewOperation(activeOperation); } - operation = new PendingMediaPickerOperation( - Guid.NewGuid().ToString("N"), - kind, - PendingMediaPickerState.Pending, - filePaths.ToArray(), - [], - photoProcessingOptions); - - MediaPickerRecoveryStore.WriteActiveOperation(operation); - InProcessOperationIds.Add(operation.Id); + return BeginOperationUnderLock(kind, filePaths, photoProcessingOptions); } + } - return operation; + internal static async Task BeginOperationWithRecoveryAsync( + RecoveredMediaPickerResultKind kind, + IReadOnlyList filePaths, + PersistedPhotoProcessingOptions photoProcessingOptions) + { + ValidateBeginOperationArguments(kind, filePaths); + + await RecoveryPromotionSemaphore.WaitAsync().ConfigureAwait(false); + + try + { + while (true) + { + var reconciliation = await RecoverOperationIfAvailableUnderSemaphoreAsync().ConfigureAwait(false); + if (reconciliation.WasReconciled) + { + CompleteRecoveryWaitersForReconciliation(reconciliation.Results); + } + + BeginOperationWithRecoveryCheckpointHandler?.Invoke(); + + lock (Locker) + { + var activeOperation = MediaPickerRecoveryStore.ReadActiveOperation(); + if (activeOperation is null) + { + return BeginOperationUnderLock(kind, filePaths, photoProcessingOptions); + } + + if (!ShouldPromoteRecreatedOperation(activeOperation)) + { + ThrowIfActiveOperationBlocksNewOperation(activeOperation); + } + } + } + } + finally + { + RecoveryPromotionSemaphore.Release(); + } } internal static void ClearActiveOperation(string id) @@ -173,6 +195,9 @@ internal static void SetPickerUriPermissionHandlersForTests( ReleasePickerUriReadAccessHandler = releaseHandler ?? ReleasePickerUriReadAccessCore; } + internal static void SetBeginOperationWithRecoveryCheckpointForTests(Action? checkpointHandler) + => BeginOperationWithRecoveryCheckpointHandler = checkpointHandler; + internal static async Task DiscardPendingOperationAsync() { IReadOnlyList? waiterResults = null; @@ -720,6 +745,38 @@ static bool ShouldPromoteRecreatedOperation(PendingMediaPickerOperation operatio static bool HasAcceptedResultPayload(PendingMediaPickerOperation operation) => operation.FilePaths.Count > 0 || operation.PickerUriStrings.Count > 0; + static void ValidateBeginOperationArguments(RecoveredMediaPickerResultKind kind, IReadOnlyList filePaths) + { + if (!IsKnownKind(kind)) + { + throw new ArgumentOutOfRangeException(nameof(kind)); + } + + if (filePaths is null) + { + throw new ArgumentNullException(nameof(filePaths)); + } + } + + static PendingMediaPickerOperation BeginOperationUnderLock( + RecoveredMediaPickerResultKind kind, + IReadOnlyList filePaths, + PersistedPhotoProcessingOptions photoProcessingOptions) + { + var operation = new PendingMediaPickerOperation( + Guid.NewGuid().ToString("N"), + kind, + PendingMediaPickerState.Pending, + filePaths.ToArray(), + [], + photoProcessingOptions); + + MediaPickerRecoveryStore.WriteActiveOperation(operation); + InProcessOperationIds.Add(operation.Id); + + return operation; + } + static void ThrowIfActiveOperationBlocksNewOperation(PendingMediaPickerOperation activeOperation) { if (IsInProcessOperation(activeOperation)) diff --git a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs index a6a032be38f8..5ff0fa76fc82 100644 --- a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs +++ b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs @@ -1445,6 +1445,166 @@ public async Task New_Capture_Can_Start_After_Recreated_Accepted_Result_Is_Recov Assert.Equal(secondCapture.Id, GetActiveOperation()?.Id); } + [Fact] + public async Task BeginOperationWithRecoveryAsync_Recovers_Accepted_Result_And_Starts_New_Operation() + { + var firstCapturePath = CreateNonEmptyMediaFile(FileExtensions.Mp4); + var secondCapturePath = CreateMissingMediaFilePath(FileExtensions.Jpg); + + var firstCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CaptureVideo, + [firstCapturePath], + PersistedPhotoProcessingOptions.Default); + + MediaPickerRecoveryManager.RecordCaptureCallbackResult(RecoveredMediaPickerResultKind.CaptureVideo, true); + SimulateProcessRecreation(); + + var secondCapture = await MediaPickerRecoveryManager.BeginOperationWithRecoveryAsync( + RecoveredMediaPickerResultKind.CapturePhoto, + [secondCapturePath], + PersistedPhotoProcessingOptions.Default); + + var recoveredResults = await MediaPicker.GetRecoveredMediaPickerResultsAsync(); + Assert.Equal(firstCapture.Id, Assert.Single(recoveredResults).Id); + Assert.NotEqual(firstCapture.Id, secondCapture.Id); + Assert.Equal(secondCapture.Id, GetActiveOperation()?.Id); + } + + [Fact] + public async Task BeginOperationWithRecoveryAsync_Completes_Waiters_When_Recovering_Accepted_Result() + { + var firstCapturePath = CreateNonEmptyMediaFile(FileExtensions.Mp4); + var secondCapturePath = CreateMissingMediaFilePath(FileExtensions.Jpg); + using var cancellationTokenSource = new CancellationTokenSource(); + + var firstCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CaptureVideo, + [firstCapturePath], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + Assert.Equal(1, GetPendingWaiterCount()); + + MediaPickerRecoveryManager.RecordCaptureCallbackResult(RecoveredMediaPickerResultKind.CaptureVideo, true); + + var secondCapture = await MediaPickerRecoveryManager.BeginOperationWithRecoveryAsync( + RecoveredMediaPickerResultKind.CapturePhoto, + [secondCapturePath], + PersistedPhotoProcessingOptions.Default); + + var waiterResults = await WaitForCompletion(waitTask); + Assert.Equal(firstCapture.Id, Assert.Single(waiterResults).Id); + Assert.Equal(0, GetPendingWaiterCount()); + Assert.NotEqual(firstCapture.Id, secondCapture.Id); + Assert.Equal(secondCapture.Id, GetActiveOperation()?.Id); + } + + [Fact] + public async Task BeginOperationWithRecoveryAsync_Recreated_Pending_Operation_Still_Blocks_New_Operation() + { + var firstCapturePath = CreateMissingMediaFilePath(FileExtensions.Jpg); + var secondCapturePath = CreateMissingMediaFilePath(FileExtensions.Mp4); + + var firstCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [firstCapturePath], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + + var exception = await Assert.ThrowsAsync(() => + MediaPickerRecoveryManager.BeginOperationWithRecoveryAsync( + RecoveredMediaPickerResultKind.CaptureVideo, + [secondCapturePath], + PersistedPhotoProcessingOptions.Default)); + + Assert.Equal("A MediaPicker operation is pending AndroidX result replay.", exception.Message); + + var activeCapture = Assert.IsType(GetActiveOperation()); + Assert.Equal(firstCapture.Id, activeCapture.Id); + Assert.Equal(PendingMediaPickerState.Pending, activeCapture.State); + Assert.Equal(firstCapturePath, GetSingleActiveOperationFilePath(activeCapture)); + } + + [Fact] + public async Task BeginOperationWithRecoveryAsync_InProcess_Operation_Still_Blocks_New_Operation() + { + var firstCapturePath = CreateMissingMediaFilePath(FileExtensions.Jpg); + var secondCapturePath = CreateMissingMediaFilePath(FileExtensions.Mp4); + + var firstCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [firstCapturePath], + PersistedPhotoProcessingOptions.Default); + + var exception = await Assert.ThrowsAsync(() => + MediaPickerRecoveryManager.BeginOperationWithRecoveryAsync( + RecoveredMediaPickerResultKind.CaptureVideo, + [secondCapturePath], + PersistedPhotoProcessingOptions.Default)); + + Assert.Equal("A MediaPicker operation is already in progress.", exception.Message); + + var activeCapture = Assert.IsType(GetActiveOperation()); + Assert.Equal(firstCapture.Id, activeCapture.Id); + Assert.Equal(PendingMediaPickerState.Pending, activeCapture.State); + Assert.Equal(firstCapturePath, GetSingleActiveOperationFilePath(activeCapture)); + } + + [Fact] + public async Task BeginOperationWithRecoveryAsync_Promotes_Callback_Race_And_Starts_New_Operation() + { + var firstCapturePath = CreateNonEmptyMediaFile(FileExtensions.Mp4); + var secondCapturePath = CreateMissingMediaFilePath(FileExtensions.Jpg); + using var cancellationTokenSource = new CancellationTokenSource(); + + var firstCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CaptureVideo, + [firstCapturePath], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + Assert.Equal(1, GetPendingWaiterCount()); + + var checkpointHit = false; + MediaPickerRecoveryManager.SetBeginOperationWithRecoveryCheckpointForTests(() => + { + if (checkpointHit) + { + return; + } + + checkpointHit = true; + Assert.True(MediaPickerRecoveryManager.RecordCaptureCallbackResult(RecoveredMediaPickerResultKind.CaptureVideo, true)); + }); + + try + { + var secondCapture = await MediaPickerRecoveryManager.BeginOperationWithRecoveryAsync( + RecoveredMediaPickerResultKind.CapturePhoto, + [secondCapturePath], + PersistedPhotoProcessingOptions.Default); + + var waiterResults = await WaitForCompletion(waitTask); + var recoveredResults = await MediaPicker.GetRecoveredMediaPickerResultsAsync(); + + Assert.True(checkpointHit); + Assert.Equal(firstCapture.Id, Assert.Single(waiterResults).Id); + Assert.Equal(firstCapture.Id, Assert.Single(recoveredResults).Id); + Assert.NotEqual(firstCapture.Id, secondCapture.Id); + Assert.Equal(secondCapture.Id, GetActiveOperation()?.Id); + Assert.Equal(0, GetPendingWaiterCount()); + } + finally + { + MediaPickerRecoveryManager.SetBeginOperationWithRecoveryCheckpointForTests(null); + } + } + [Fact] public async Task Concurrent_Accepted_Result_Promotion_Queues_Single_Result() { @@ -1527,6 +1687,7 @@ static void ResetRecoveryState() ClearInProcessOperationIds(); CompleteAndClearRecoveryWaiters(); MediaPickerRecoveryManager.SetPickerUriPermissionHandlersForTests(null, null); + MediaPickerRecoveryManager.SetBeginOperationWithRecoveryCheckpointForTests(null); SetRecoveryReconciliationGeneration(0); Preferences.Remove(ActiveOperationPreferenceKey, RecoveryPreferencesSharedName); Preferences.Remove(RecoveredResultsPreferenceKey, RecoveryPreferencesSharedName); From 78b1c1e94d29d7a0b09bfc9c0ca59c2852e75281 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Fri, 22 May 2026 17:29:26 -0400 Subject: [PATCH 13/17] Bound recovered MediaPicker records --- .../src/MediaPicker/MediaPicker.android.cs | 4 +- .../MediaPickerRecoveryManager.android.cs | 67 ++++++++++++- .../Android/MediaPickerRecovery_Tests.cs | 99 ++++++++++++++++++- 3 files changed, 162 insertions(+), 8 deletions(-) diff --git a/src/Essentials/src/MediaPicker/MediaPicker.android.cs b/src/Essentials/src/MediaPicker/MediaPicker.android.cs index ce6ae7c93146..8579092e1ae1 100644 --- a/src/Essentials/src/MediaPicker/MediaPicker.android.cs +++ b/src/Essentials/src/MediaPicker/MediaPicker.android.cs @@ -135,8 +135,8 @@ public async Task CaptureAsync(MediaPickerOptions options, bool phot if (!PlatformUtils.IsIntentSupported(captureIntent)) throw new FeatureNotSupportedException($"Either there was no camera on the device or '{captureIntent.Action}' was not added to the element in the app's manifest file. See more: https://developer.android.com/about/versions/11/privacy/package-visibility"); - captureIntent.AddFlags(ActivityFlags.GrantReadUriPermission); - captureIntent.AddFlags(ActivityFlags.GrantWriteUriPermission); + captureIntent.AddFlags(global::Android.Content.ActivityFlags.GrantReadUriPermission); + captureIntent.AddFlags(global::Android.Content.ActivityFlags.GrantWriteUriPermission); try { diff --git a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs index cbba6d039728..0a553f470fd6 100644 --- a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs +++ b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs @@ -27,6 +27,8 @@ enum PendingMediaPickerState /// internal static class MediaPickerRecoveryManager { + internal const int MaxRecoveredResultCount = 32; + static readonly Lock Locker = new(); static readonly HashSet InProcessOperationIds = new(StringComparer.Ordinal); static readonly List RecoveryWaiters = []; @@ -692,7 +694,7 @@ static async Task PublishRecoveredOperationAsync(PendingMediaPickerOperation ope recoveredResults.RemoveAll(result => string.Equals(result.Id, recoveredResult.Id, StringComparison.Ordinal)); recoveredResults.Add(recoveredResult); - MediaPickerRecoveryStore.WriteRecoveredResults(recoveredResults); + MediaPickerRecoveryStore.WriteRecoveredResults(NormalizeRecoveredResults(recoveredResults)); _ = ClearActiveOperationUnderLock(operation); } } @@ -717,10 +719,71 @@ static IReadOnlyList ReadPublicRecoveredResults() } static IReadOnlyList ReadPublicRecoveredResultsUnderLock() - => MediaPickerRecoveryStore.ReadRecoveredResults() + => ReadNormalizedRecoveredResultsUnderLock() .Select(result => result.ToPublicResult()) .ToArray(); + static List ReadNormalizedRecoveredResultsUnderLock() + { + var results = MediaPickerRecoveryStore.ReadRecoveredResults(); + var normalizedResults = NormalizeRecoveredResults(results); + + if (!AreRecoveredResultsEqual(results, normalizedResults)) + { + MediaPickerRecoveryStore.WriteRecoveredResults(normalizedResults); + } + + return normalizedResults; + } + + static List NormalizeRecoveredResults(IReadOnlyList results) + { + var normalizedResults = new List(); + + foreach (var result in results) + { + var availableFilePaths = result.FilePaths + .Where(IsFileAvailable) + .ToArray(); + + if (availableFilePaths.Length > 0) + { + normalizedResults.Add(new RecoveredMediaPickerRecord(result.Id, result.Kind, availableFilePaths)); + } + } + + var excessCount = normalizedResults.Count - MaxRecoveredResultCount; + if (excessCount > 0) + { + normalizedResults.RemoveRange(0, excessCount); + } + + return normalizedResults; + } + + static bool AreRecoveredResultsEqual(IReadOnlyList first, IReadOnlyList second) + { + if (first.Count != second.Count) + { + return false; + } + + for (var i = 0; i < first.Count; i++) + { + var firstResult = first[i]; + var secondResult = second[i]; + + if (!string.Equals(firstResult.Id, secondResult.Id, StringComparison.Ordinal) || + firstResult.Kind != secondResult.Kind || + !firstResult.FilePaths.SequenceEqual(secondResult.FilePaths)) + { + return false; + } + } + + return true; + } + static long GetRecoveryReconciliationGeneration() { lock (Locker) diff --git a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs index 5ff0fa76fc82..068ddf08068c 100644 --- a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs +++ b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs @@ -86,6 +86,97 @@ public async Task Recovered_Results_Are_NonConsuming_And_Clear_By_Id() Assert.Empty(await MediaPicker.GetRecoveredMediaPickerResultsAsync()); } + [Fact] + public async Task Recovered_Results_Public_Read_Prunes_Record_With_Missing_File() + { + var missingPath = CreateMissingMediaFilePath(FileExtensions.Jpg); + MediaPickerRecoveryStore.WriteRecoveredResults([ + new RecoveredMediaPickerRecord("missing", RecoveredMediaPickerResultKind.CapturePhoto, [missingPath]) + ]); + + var results = await MediaPicker.GetRecoveredMediaPickerResultsAsync(); + + Assert.Empty(results); + Assert.Empty(MediaPickerRecoveryStore.ReadRecoveredResults()); + Assert.Null(Preferences.Get(RecoveredResultsPreferenceKey, null, RecoveryPreferencesSharedName)); + } + + [Fact] + public async Task Recovered_Results_Public_Read_Prunes_Missing_File_Paths_From_Record() + { + var availablePath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + var missingPath = CreateMissingMediaFilePath(FileExtensions.Jpg); + MediaPickerRecoveryStore.WriteRecoveredResults([ + new RecoveredMediaPickerRecord( + "partial", + RecoveredMediaPickerResultKind.PickPhotos, + [missingPath, availablePath]) + ]); + + var results = await MediaPicker.GetRecoveredMediaPickerResultsAsync(); + + var result = Assert.Single(results); + Assert.Equal("partial", result.Id); + Assert.Equal(availablePath, GetSingleRecoveredFile(result).FullPath); + + var storedResult = Assert.Single(MediaPickerRecoveryStore.ReadRecoveredResults()); + Assert.Equal("partial", storedResult.Id); + Assert.Equal(availablePath, Assert.Single(storedResult.FilePaths)); + } + + [Fact] + public async Task Recovered_Results_Public_Read_Caps_Stored_Records_To_Newest() + { + var records = Enumerable.Range(0, MediaPickerRecoveryManager.MaxRecoveredResultCount + 3) + .Select(index => new RecoveredMediaPickerRecord( + $"result-{index}", + RecoveredMediaPickerResultKind.CaptureVideo, + [CreateNonEmptyMediaFile(FileExtensions.Mp4)])) + .ToList(); + MediaPickerRecoveryStore.WriteRecoveredResults(records); + + var results = await MediaPicker.GetRecoveredMediaPickerResultsAsync(); + + var expectedIds = records + .Skip(3) + .Select(record => record.Id) + .ToArray(); + Assert.Equal(expectedIds, results.Select(result => result.Id).ToArray()); + Assert.Equal(expectedIds, MediaPickerRecoveryStore.ReadRecoveredResults().Select(result => result.Id).ToArray()); + } + + [Fact] + public async Task Recovered_Result_Publish_Caps_Stored_Records_To_Newest() + { + var existingRecords = Enumerable.Range(0, MediaPickerRecoveryManager.MaxRecoveredResultCount) + .Select(index => new RecoveredMediaPickerRecord( + $"existing-{index}", + RecoveredMediaPickerResultKind.CaptureVideo, + [CreateNonEmptyMediaFile(FileExtensions.Mp4)])) + .ToList(); + MediaPickerRecoveryStore.WriteRecoveredResults(existingRecords); + + var capturePath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + var pendingCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + MediaPickerRecoveryManager.RecordCaptureCallbackResult(RecoveredMediaPickerResultKind.CapturePhoto, true); + SimulateProcessRecreation(); + + var results = await MediaPicker.GetRecoveredMediaPickerResultsAsync(); + + Assert.Equal(MediaPickerRecoveryManager.MaxRecoveredResultCount, results.Count); + Assert.DoesNotContain(results, result => result.Id == existingRecords[0].Id); + Assert.Equal(pendingCapture.Id, results[results.Count - 1].Id); + + var storedResults = MediaPickerRecoveryStore.ReadRecoveredResults(); + Assert.Equal(MediaPickerRecoveryManager.MaxRecoveredResultCount, storedResults.Count); + Assert.DoesNotContain(storedResults, result => result.Id == existingRecords[0].Id); + Assert.Equal(pendingCapture.Id, storedResults[storedResults.Count - 1].Id); + } + [Fact] public async Task Canceled_Capture_Clears_Active_State_Without_Recovered_Result() { @@ -667,7 +758,7 @@ await Assert.ThrowsAnyAsync(async () => public void Pick_Callback_Lost_Active_Operation_Race_Releases_Persisted_Uri_Access() { var pickUri = AndroidUri.Parse("content://maui-test/picked-media") ?? throw new InvalidOperationException("Unable to create picker URI."); - PendingMediaPickerOperation? pendingPick = null; + PendingMediaPickerOperation pendingPick = null; using var permissions = TrackPickerUriPermissions(_ => { if (pendingPick is not null) @@ -1726,7 +1817,7 @@ static void CompleteAndClearRecoveryWaiters() static System.Collections.IList GetRecoveryWaiters() => GetPrivateStaticField("RecoveryWaiters"); - static PickerUriPermissionTracker TrackPickerUriPermissions(Action? onPersist = null) + static PickerUriPermissionTracker TrackPickerUriPermissions(Action onPersist = null) { var tracker = new PickerUriPermissionTracker(onPersist); MediaPickerRecoveryManager.SetPickerUriPermissionHandlersForTests(tracker.Persist, tracker.Release); @@ -1753,11 +1844,11 @@ static T GetPrivateStaticField(string name) sealed class PickerUriPermissionTracker : IDisposable { - readonly Action? onPersist; + readonly Action onPersist; readonly List persisted = []; readonly List released = []; - public PickerUriPermissionTracker(Action? onPersist) + public PickerUriPermissionTracker(Action onPersist) { this.onPersist = onPersist; } From 39a8529205a60c14e14308aa2660144ae2ff8624 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Fri, 22 May 2026 17:43:25 -0400 Subject: [PATCH 14/17] Require cancellable MediaPicker recovery waits --- .../src/MediaPicker/MediaPickerRecovery.android.cs | 3 ++- .../MediaPicker/MediaPickerRecoveryManager.android.cs | 7 +++++++ .../Tests/Android/MediaPickerRecovery_Tests.cs | 10 ++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/Essentials/src/MediaPicker/MediaPickerRecovery.android.cs b/src/Essentials/src/MediaPicker/MediaPickerRecovery.android.cs index c67f68c4f713..730dd83d6be9 100644 --- a/src/Essentials/src/MediaPicker/MediaPickerRecovery.android.cs +++ b/src/Essentials/src/MediaPicker/MediaPickerRecovery.android.cs @@ -98,8 +98,9 @@ public static Task> GetRecoveredMediaP /// /// Waits for Android MediaPicker recovery to reconcile a pending result. /// - /// A token that cancels the wait and removes the recovery listener. + /// A token that cancels the wait and removes the recovery listener. If no result is immediately available, this token must be cancellable. /// A non-consuming list of recovered MediaPicker results. + /// Thrown when no result is immediately available and cannot be canceled. /// /// If recovered results are already available, this method returns them immediately. Otherwise, it waits until AndroidX result replay publishes or terminally clears a pending MediaPicker result. This method is one-shot; apps that need continuous observation should call it again with a lifecycle-scoped cancellation token. /// If AndroidX does not replay or reconcile a pending result, this method may wait until is canceled. diff --git a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs index 0a553f470fd6..fc3aa4094584 100644 --- a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs +++ b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs @@ -159,6 +159,13 @@ internal static async Task> WaitForRec cancellationToken.ThrowIfCancellationRequested(); } + if (!cancellationToken.CanBeCanceled) + { + throw new ArgumentException( + "A cancellable token is required when waiting for MediaPicker recovery.", + nameof(cancellationToken)); + } + RecoveryWaiters.Add(waiter); } diff --git a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs index 068ddf08068c..9484657c354d 100644 --- a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs +++ b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs @@ -313,6 +313,16 @@ public async Task Wait_For_Recovered_Results_Can_Be_Canceled() Assert.Equal(0, GetPendingWaiterCount()); } + [Fact] + public async Task Wait_For_Recovered_Results_Requires_Cancellable_Token_When_Waiting() + { + var exception = await Assert.ThrowsAsync(() => + MediaPicker.WaitForRecoveredMediaPickerResultsAsync(CancellationToken.None)); + + Assert.Equal("cancellationToken", exception.ParamName); + Assert.Equal(0, GetPendingWaiterCount()); + } + [Fact] public async Task Recovery_Waiter_Result_Before_Registration_Disposes_Late_Registration() { From 6acd98fcea55f5bc1688230c9de97e09f55ead7c Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Fri, 22 May 2026 18:00:33 -0400 Subject: [PATCH 15/17] Delete rotated MediaPicker recovery temp file --- .../src/MediaPicker/MediaPicker.android.cs | 31 +++++++++++++++++-- .../Android/MediaPickerRecovery_Tests.cs | 31 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/Essentials/src/MediaPicker/MediaPicker.android.cs b/src/Essentials/src/MediaPicker/MediaPicker.android.cs index 8579092e1ae1..f4e93cc7d662 100644 --- a/src/Essentials/src/MediaPicker/MediaPicker.android.cs +++ b/src/Essentials/src/MediaPicker/MediaPicker.android.cs @@ -62,16 +62,31 @@ internal static async Task ProcessPhotoPreservingSourceAsync(string imag return null; } + var originalImagePath = imagePath; + string rotatedImagePath = null; + // Recovery-sensitive MediaPicker paths must leave the original file intact until the // active recovery record has been cleared or promoted. if (options.RotateImage) { - imagePath = await RotateImageToNewFileAsync(imagePath); + var rotatedPath = await RotateImageToNewFileAsync(imagePath); + if (!string.Equals(rotatedPath, imagePath, StringComparison.Ordinal)) + { + rotatedImagePath = rotatedPath; + } + + imagePath = rotatedPath; } if (ImageProcessor.IsProcessingNeeded(options.MaximumWidth, options.MaximumHeight, options.CompressionQuality)) { - imagePath = await CompressImageIfNeeded(imagePath, options, preserveSource: true); + var compressedImagePath = await CompressImageIfNeeded(imagePath, options, preserveSource: true); + if (ShouldDeleteIntermediateFile(rotatedImagePath, originalImagePath, compressedImagePath)) + { + TryDeleteFile(rotatedImagePath); + } + + imagePath = compressedImagePath; } return imagePath; @@ -436,6 +451,18 @@ static async Task RotateImageToNewFileAsync(string imagePath) return outputFile.AbsolutePath; } + static bool ShouldDeleteIntermediateFile(string intermediatePath, string originalPath, string finalPath) + => !string.IsNullOrEmpty(intermediatePath) && + !string.Equals(intermediatePath, originalPath, StringComparison.Ordinal) && + !string.Equals(intermediatePath, finalPath, StringComparison.Ordinal); + + static void TryDeleteFile(string filePath) + { + try + { File.Delete(filePath); } + catch { } + } + static Task CompressImageIfNeeded(string imagePath, MediaPickerOptions options, bool preserveSource = false) => CompressImageIfNeeded(imagePath, GetPhotoProcessingOptions(options), preserveSource); diff --git a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs index 9484657c354d..750e24e3289a 100644 --- a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs +++ b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs @@ -1376,6 +1376,28 @@ public async Task ProcessPhotoPreservingSource_Writes_Separate_File_And_Leaves_S Assert.True(new FileInfo(processedPath).Length > 0); } + [Fact] + public async Task ProcessPhotoPreservingSource_RotationAndCompression_Deletes_Rotated_Intermediate() + { + var capturePath = CreateValidJpegMediaFile(); + var originalBytes = await File.ReadAllBytesAsync(capturePath); + var temporaryFilesBefore = GetEssentialsTemporaryFiles(); + + var processedPath = await MediaPickerImplementation.ProcessPhotoPreservingSourceAsync( + capturePath, + new PersistedPhotoProcessingOptions(16, null, 70, true, false)); + + var createdTemporaryFiles = GetEssentialsTemporaryFiles() + .Except(temporaryFilesBefore, StringComparer.Ordinal) + .ToArray(); + + Assert.NotEqual(capturePath, processedPath); + Assert.True(File.Exists(capturePath)); + Assert.Equal(originalBytes, await File.ReadAllBytesAsync(capturePath)); + Assert.True(new FileInfo(processedPath).Length > 0); + Assert.Equal(processedPath, Assert.Single(createdTemporaryFiles)); + } + [Fact] public async Task ProcessPhotoPreservingSource_InvalidRotationInput_Leaves_Source_Intact() { @@ -1887,6 +1909,15 @@ static string CreateCacheFilePath(string extension) return Path.Combine(cacheDirectory, $"{Guid.NewGuid():N}{extension}"); } + static string[] GetEssentialsTemporaryFiles() + { + var cacheDirectory = FileSystem.CacheDirectory ?? throw new InvalidOperationException("FileSystem.CacheDirectory is not available."); + var temporaryRoot = Path.Combine(cacheDirectory, FileSystemUtils.EssentialsFolderHash); + return Directory.Exists(temporaryRoot) + ? Directory.GetFiles(temporaryRoot, "*", SearchOption.AllDirectories) + : Array.Empty(); + } + static AndroidUri CreateFileUri(string path) => AndroidUri.FromFile(new Java.IO.File(path)) ?? throw new InvalidOperationException("Unable to create a file URI."); From cb5c4bbfd65237f2e2f86236f6bd34b6d9aedf27 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Fri, 22 May 2026 18:42:52 -0400 Subject: [PATCH 16/17] Add content URI MediaPicker recovery test --- .../Android/MediaPickerRecovery_Tests.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs index 750e24e3289a..393f6e4fd762 100644 --- a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs +++ b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs @@ -622,6 +622,37 @@ public async Task AndroidX_Orphaned_Single_Photo_Pick_Publishes_Recovered_Result Assert.Null(GetActiveOperation()); } + [Fact] + public async Task AndroidX_Orphaned_Single_Photo_Pick_Content_Uri_Materializes_And_Publishes_Recovered_Result() + { + var pickPath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + var originalBytes = await File.ReadAllBytesAsync(pickPath); + var pickUri = CreateContentUri(pickPath); + var pickForResult = new TestPickVisualMediaForResult(); + using var permissions = TrackPickerUriPermissions(); + using var cancellationTokenSource = new CancellationTokenSource(); + + var waitTask = MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + var pendingPick = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.PickPhoto, + [], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + pickForResult.DispatchResultForTests(pickUri); + + var recoveredResult = Assert.Single(await WaitForCompletion(waitTask)); + var recoveredFile = GetSingleRecoveredFile(recoveredResult); + Assert.Equal(pendingPick.Id, recoveredResult.Id); + Assert.Equal(RecoveredMediaPickerResultKind.PickPhoto, recoveredResult.Kind); + Assert.True(File.Exists(recoveredFile.FullPath)); + Assert.Equal(originalBytes, await File.ReadAllBytesAsync(recoveredFile.FullPath)); + Assert.Null(GetActiveOperation()); + Assert.Equal(0, GetPendingWaiterCount()); + Assert.Equal(new[] { pickUri.ToString() }, permissions.Persisted); + Assert.Equal(new[] { pickUri.ToString() }, permissions.Released); + } + [Theory] [InlineData(RecoveredMediaPickerResultKind.PickPhotos, FileExtensions.Jpg)] [InlineData(RecoveredMediaPickerResultKind.PickVideos, FileExtensions.Mp4)] From a38f91985c9d49cb1647e074b0cc19abee28524e Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Sun, 24 May 2026 18:13:26 -0400 Subject: [PATCH 17/17] Address MediaPicker recovery review feedback --- .../MediaPickerRecovery.android.cs | 6 +-- .../MediaPickerRecoveryManager.android.cs | 26 ++++++----- .../ActivityForResultRequest.android.cs | 6 +-- .../Platform/CapturePhotoForResult.android.cs | 2 +- .../Platform/CaptureVideoForResult.android.cs | 2 +- ...ickMultipleVisualMediaForResult.android.cs | 2 +- .../PickVisualMediaForResult.android.cs | 2 +- .../Android/MediaPickerRecovery_Tests.cs | 44 +++++++++++++++++-- 8 files changed, 64 insertions(+), 26 deletions(-) diff --git a/src/Essentials/src/MediaPicker/MediaPickerRecovery.android.cs b/src/Essentials/src/MediaPicker/MediaPickerRecovery.android.cs index 730dd83d6be9..a83002dee3be 100644 --- a/src/Essentials/src/MediaPicker/MediaPickerRecovery.android.cs +++ b/src/Essentials/src/MediaPicker/MediaPickerRecovery.android.cs @@ -98,11 +98,11 @@ public static Task> GetRecoveredMediaP /// /// Waits for Android MediaPicker recovery to reconcile a pending result. /// - /// A token that cancels the wait and removes the recovery listener. If no result is immediately available, this token must be cancellable. + /// A cancellable token that cancels the wait and removes the recovery listener. /// A non-consuming list of recovered MediaPicker results. - /// Thrown when no result is immediately available and cannot be canceled. + /// Thrown when cannot be canceled. /// - /// If recovered results are already available, this method returns them immediately. Otherwise, it waits until AndroidX result replay publishes or terminally clears a pending MediaPicker result. This method is one-shot; apps that need continuous observation should call it again with a lifecycle-scoped cancellation token. + /// With a cancellable token, if recovered results are already available, this method returns them immediately. Otherwise, it waits until AndroidX result replay publishes or terminally clears a pending MediaPicker result. This method is one-shot; apps that need continuous observation should call it again with a lifecycle-scoped cancellation token. /// If AndroidX does not replay or reconcile a pending result, this method may wait until is canceled. /// public static Task> WaitForRecoveredMediaPickerResultsAsync(CancellationToken cancellationToken) diff --git a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs index fc3aa4094584..a04e99bc79f3 100644 --- a/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs +++ b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs @@ -70,7 +70,8 @@ internal static async Task BeginOperationWithRecove try { - while (true) + // Retry once for a callback that records an accepted recreated result after the first recovery pass. + for (var attempt = 0; attempt < 2; attempt++) { var reconciliation = await RecoverOperationIfAvailableUnderSemaphoreAsync().ConfigureAwait(false); if (reconciliation.WasReconciled) @@ -88,12 +89,14 @@ internal static async Task BeginOperationWithRecove return BeginOperationUnderLock(kind, filePaths, photoProcessingOptions); } - if (!ShouldPromoteRecreatedOperation(activeOperation)) + if (!ShouldPromoteRecreatedOperation(activeOperation) || attempt == 1) { ThrowIfActiveOperationBlocksNewOperation(activeOperation); } } } + + throw new InvalidOperationException("A MediaPicker result is pending recovery."); } finally { @@ -131,9 +134,15 @@ internal static async Task> GetRecover internal static async Task> WaitForRecoveredResultsAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); + if (!cancellationToken.CanBeCanceled) + { + throw new ArgumentException( + "A cancellable token is required when waiting for MediaPicker recovery.", + nameof(cancellationToken)); + } var observedReconciliationGeneration = GetRecoveryReconciliationGeneration(); - var reconciliation = await RecoverOperationIfAvailableCoreAsync().ConfigureAwait(false); + var reconciliation = await RecoverOperationIfAvailableCoreAsync(cancellationToken).ConfigureAwait(false); if (reconciliation.WasReconciled || reconciliation.Results.Count > 0) { return reconciliation.Results; @@ -159,13 +168,6 @@ internal static async Task> WaitForRec cancellationToken.ThrowIfCancellationRequested(); } - if (!cancellationToken.CanBeCanceled) - { - throw new ArgumentException( - "A cancellable token is required when waiting for MediaPicker recovery.", - nameof(cancellationToken)); - } - RecoveryWaiters.Add(waiter); } @@ -338,9 +340,9 @@ internal static async Task> RecoverOpe // Promotes a recreated-process operation only after AndroidX has accepted the result. // A Pending record means the result callback has not been replayed yet. - static async Task RecoverOperationIfAvailableCoreAsync() + static async Task RecoverOperationIfAvailableCoreAsync(CancellationToken cancellationToken = default) { - await RecoveryPromotionSemaphore.WaitAsync().ConfigureAwait(false); + await RecoveryPromotionSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { diff --git a/src/Essentials/src/Platform/ActivityForResultRequest.android.cs b/src/Essentials/src/Platform/ActivityForResultRequest.android.cs index 17baa933dc74..f2c67bf7e559 100644 --- a/src/Essentials/src/Platform/ActivityForResultRequest.android.cs +++ b/src/Essentials/src/Platform/ActivityForResultRequest.android.cs @@ -154,7 +154,7 @@ public Task Launch(T input) Ensure your Activity inherits from ComponentActivity and call Microsoft.Maui.ApplicationModel.Platform.Init(Activity, Bundle) in OnCreate. """); ClearActiveLaunchCompletionSource(completionSource); - completionSource.SetCanceled(); + completionSource.TrySetCanceled(); return completionSource.Task; } @@ -165,7 +165,7 @@ Ensure your Activity inherits from ComponentActivity and call Microsoft.Maui.App catch (Exception ex) { ClearActiveLaunchCompletionSource(completionSource); - completionSource.SetException(ex); + completionSource.TrySetException(ex); } return completionSource.Task; @@ -179,4 +179,4 @@ void ClearActiveLaunchCompletionSource(TaskCompletionSource completionS activeLaunchCompletionSource = null; } } -} \ No newline at end of file +} diff --git a/src/Essentials/src/Platform/CapturePhotoForResult.android.cs b/src/Essentials/src/Platform/CapturePhotoForResult.android.cs index 7c928fe81495..0286a913af0a 100644 --- a/src/Essentials/src/Platform/CapturePhotoForResult.android.cs +++ b/src/Essentials/src/Platform/CapturePhotoForResult.android.cs @@ -8,7 +8,7 @@ namespace Microsoft.Maui.ApplicationModel; // registered launcher, while shared recovery behavior lives in MediaCaptureForResult. internal class CapturePhotoForResult : MediaCaptureForResult { - static readonly Lazy LazyInstance = new(new CapturePhotoForResult()); + static readonly Lazy LazyInstance = new(() => new CapturePhotoForResult()); public static CapturePhotoForResult Instance => LazyInstance.Value; diff --git a/src/Essentials/src/Platform/CaptureVideoForResult.android.cs b/src/Essentials/src/Platform/CaptureVideoForResult.android.cs index 6fcf0269e095..0a2a8a6eaa9e 100644 --- a/src/Essentials/src/Platform/CaptureVideoForResult.android.cs +++ b/src/Essentials/src/Platform/CaptureVideoForResult.android.cs @@ -8,7 +8,7 @@ namespace Microsoft.Maui.ApplicationModel; // registered launcher, while shared recovery behavior lives in MediaCaptureForResult. internal class CaptureVideoForResult : MediaCaptureForResult { - static readonly Lazy LazyInstance = new(new CaptureVideoForResult()); + static readonly Lazy LazyInstance = new(() => new CaptureVideoForResult()); public static CaptureVideoForResult Instance => LazyInstance.Value; diff --git a/src/Essentials/src/Platform/PickMultipleVisualMediaForResult.android.cs b/src/Essentials/src/Platform/PickMultipleVisualMediaForResult.android.cs index caaa149bb0e3..c4e68727fd6d 100644 --- a/src/Essentials/src/Platform/PickMultipleVisualMediaForResult.android.cs +++ b/src/Essentials/src/Platform/PickMultipleVisualMediaForResult.android.cs @@ -9,7 +9,7 @@ namespace Microsoft.Maui.ApplicationModel; internal class PickMultipleVisualMediaForResult : ActivityForResultRequest { - static readonly Lazy LazyInstance = new(new PickMultipleVisualMediaForResult()); + static readonly Lazy LazyInstance = new(() => new PickMultipleVisualMediaForResult()); public static PickMultipleVisualMediaForResult Instance => LazyInstance.Value; diff --git a/src/Essentials/src/Platform/PickVisualMediaForResult.android.cs b/src/Essentials/src/Platform/PickVisualMediaForResult.android.cs index c18c631a9e35..1cabe488f561 100644 --- a/src/Essentials/src/Platform/PickVisualMediaForResult.android.cs +++ b/src/Essentials/src/Platform/PickVisualMediaForResult.android.cs @@ -7,7 +7,7 @@ namespace Microsoft.Maui.ApplicationModel; internal class PickVisualMediaForResult : ActivityForResultRequest { - static readonly Lazy LazyInstance = new(new PickVisualMediaForResult()); + static readonly Lazy LazyInstance = new(() => new PickVisualMediaForResult()); public static PickVisualMediaForResult Instance => LazyInstance.Value; diff --git a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs index 393f6e4fd762..ed27061d776a 100644 --- a/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs +++ b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs @@ -215,6 +215,7 @@ public async Task Missing_Output_File_Clears_Active_State_Without_Recovered_Resu public async Task Wait_For_Recovered_Results_Returns_Existing_Result() { var capturePath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + using var cancellationTokenSource = new CancellationTokenSource(); var pendingCapture = MediaPickerRecoveryManager.BeginOperation( RecoveredMediaPickerResultKind.CapturePhoto, @@ -224,7 +225,7 @@ public async Task Wait_For_Recovered_Results_Returns_Existing_Result() SimulateProcessRecreation(); await MediaPickerRecoveryManager.RecoverOrphanedCaptureResultAsync(RecoveredMediaPickerResultKind.CapturePhoto, true); - var results = await MediaPicker.WaitForRecoveredMediaPickerResultsAsync(CancellationToken.None); + var results = await MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); var result = Assert.Single(results); Assert.Equal(pendingCapture.Id, result.Id); @@ -314,13 +315,37 @@ public async Task Wait_For_Recovered_Results_Can_Be_Canceled() } [Fact] - public async Task Wait_For_Recovered_Results_Requires_Cancellable_Token_When_Waiting() + public async Task Wait_For_Recovered_Results_Throws_When_Token_Is_Already_Canceled() + { + using var cancellationTokenSource = new CancellationTokenSource(); + await cancellationTokenSource.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => + MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token)); + + Assert.Equal(0, GetPendingWaiterCount()); + } + + [Fact] + public async Task Wait_For_Recovered_Results_Requires_Cancellable_Token() { var exception = await Assert.ThrowsAsync(() => MediaPicker.WaitForRecoveredMediaPickerResultsAsync(CancellationToken.None)); Assert.Equal("cancellationToken", exception.ParamName); Assert.Equal(0, GetPendingWaiterCount()); + + var capturePath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + MediaPickerRecoveryStore.WriteRecoveredResults([ + new RecoveredMediaPickerRecord("queued", RecoveredMediaPickerResultKind.CapturePhoto, [capturePath]) + ]); + + exception = await Assert.ThrowsAsync(() => + MediaPicker.WaitForRecoveredMediaPickerResultsAsync(CancellationToken.None)); + + Assert.Equal("cancellationToken", exception.ParamName); + Assert.Equal(0, GetPendingWaiterCount()); + Assert.Equal("queued", Assert.Single(MediaPickerRecoveryStore.ReadRecoveredResults()).Id); } [Fact] @@ -823,6 +848,7 @@ public void Pick_Callback_Lost_Active_Operation_Race_Releases_Persisted_Uri_Acce public async Task Accepted_Pick_Materialization_Failure_Clears_Active_State_And_Completes_Waiter_Empty() { var invalidPickerUri = AndroidUri.Parse("content://maui-test/missing-picked-media") ?? throw new InvalidOperationException("Unable to create invalid picker URI."); + using var cancellationTokenSource = new CancellationTokenSource(); MediaPickerRecoveryManager.BeginOperation( RecoveredMediaPickerResultKind.PickPhoto, [], @@ -831,7 +857,7 @@ public async Task Accepted_Pick_Materialization_Failure_Clears_Active_State_And_ Assert.True(MediaPickerRecoveryManager.RecordSinglePickCallbackResult(invalidPickerUri)); SimulateProcessRecreation(); - var results = await MediaPicker.WaitForRecoveredMediaPickerResultsAsync(CancellationToken.None); + var results = await MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); Assert.Empty(results); Assert.Null(GetActiveOperation()); @@ -945,6 +971,15 @@ public async Task ActivityForResultRequest_Rejects_Concurrent_Launch() await Assert.ThrowsAsync(() => captureForResult.Launch(AndroidUri.Empty)); } + [Fact] + public async Task ActivityForResultRequest_Unregistered_Launch_Returns_Canceled_Task() + { + var captureForResult = new TestMediaCaptureForResult(RecoveredMediaPickerResultKind.CapturePhoto); + + await Assert.ThrowsAnyAsync(() => captureForResult.Launch(AndroidUri.Empty)); + await Assert.ThrowsAnyAsync(() => captureForResult.Launch(AndroidUri.Empty)); + } + [Fact] public async Task AndroidX_Orphaned_Mismatched_Media_Type_Does_Not_Complete_Waiter() { @@ -1496,6 +1531,7 @@ public async Task Accepted_Result_From_Recreated_Process_Is_Promoted_By_Get() public async Task Accepted_Result_From_Recreated_Process_Completes_Wait() { var capturePath = CreateNonEmptyMediaFile(FileExtensions.Mp4); + using var cancellationTokenSource = new CancellationTokenSource(); var pendingCapture = MediaPickerRecoveryManager.BeginOperation( RecoveredMediaPickerResultKind.CaptureVideo, @@ -1505,7 +1541,7 @@ public async Task Accepted_Result_From_Recreated_Process_Completes_Wait() MediaPickerRecoveryManager.RecordCaptureCallbackResult(RecoveredMediaPickerResultKind.CaptureVideo, true); SimulateProcessRecreation(); - var results = await MediaPicker.WaitForRecoveredMediaPickerResultsAsync(CancellationToken.None); + var results = await MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); var result = Assert.Single(results); Assert.Equal(pendingCapture.Id, result.Id);