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..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,22 +1,45 @@ package com.microsoft.maui; -import android.content.Context; -import android.content.res.Resources; +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 { + 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"; + + // 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) { - savedInstanceState.remove("android:support:fragments"); - savedInstanceState.remove("androidx.lifecycle.BundlableSavedStateRegistry.key"); + warnIfActivityResultRegistryKeyChanged(activity); + removeFragmentRestoreState(savedInstanceState); } boolean mauiSplashAttrValue = false; @@ -33,4 +56,54 @@ public static void onCreate(AppCompatActivity activity, Bundle savedInstanceStat activity.setTheme(mauiTheme); } } + + private static void removeFragmentRestoreState(Bundle savedInstanceState) + { + // 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); + 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); + } + } + } + + 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); + } + } +} 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 08a3de2da51b..f4e93cc7d662 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,80 @@ 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; + } + + 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) + { + 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)) + { + var compressedImagePath = await CompressImageIfNeeded(imagePath, options, preserveSource: true); + if (ShouldDeleteIntermediateFile(rotatedImagePath, originalImagePath, compressedImagePath)) + { + TryDeleteFile(rotatedImagePath); + } + + imagePath = compressedImagePath; + } + + 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); @@ -87,35 +150,32 @@ 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 { 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 +183,65 @@ 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); + + var processingOptions = GetPhotoProcessingOptions(options); + var pendingOperation = await MediaPickerRecoveryManager.BeginOperationWithRecoveryAsync( + 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); + + var pendingOperation = await MediaPickerRecoveryManager.BeginOperationWithRecoveryAsync( + 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 +294,45 @@ 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); + var processingOptions = GetPhotoProcessingOptions(options); + var pendingOperation = await MediaPickerRecoveryManager.BeginOperationWithRecoveryAsync( + operationKind ?? (photo ? RecoveredMediaPickerResultKind.PickPhoto : RecoveredMediaPickerResultKind.PickVideo), + [], + processingOptions); - if (androidUri?.Equals(AndroidUri.Empty) ?? true) + 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 = await MediaPickerRecoveryManager.MaterializeAcceptedFilePathsAsync(pendingOperation.Id, throwOnMaterializationFailure: true); + var path = acceptedPaths.FirstOrDefault() ?? await FileSystemUtils.EnsurePhysicalPathAsync(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 +343,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 +360,48 @@ async Task> PickMultipleUsingPhotoPicker(MediaPickerOptions opt pickVisualMediaRequestBuilder.SetMaxItems(selectionLimit); } - var pickVisualMediaRequest = pickVisualMediaRequestBuilder.Build(); - - var androidUris = await PickMultipleVisualMediaForResult.Instance.Launch(pickVisualMediaRequest); + var processingOptions = GetPhotoProcessingOptions(options); + var pendingOperation = await MediaPickerRecoveryManager.BeginOperationWithRecoveryAsync( + 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 = await MediaPickerRecoveryManager.MaterializeAcceptedFilePathsAsync(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 +411,64 @@ 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 RotateImageToNewFileAsync(string 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 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 async Task CompressImageIfNeeded(string imagePath, MediaPickerOptions options) + 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 ?? 100) || string.IsNullOrEmpty(imagePath)) + if (!ImageProcessor.IsProcessingNeeded(options.MaximumWidth, options.MaximumHeight, options.CompressionQuality) || string.IsNullOrEmpty(imagePath)) return imagePath; try @@ -308,18 +484,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 +505,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 +645,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..01713653f7f3 --- /dev/null +++ b/src/Essentials/src/MediaPicker/MediaPickerRecovery.android.cs @@ -0,0 +1,137 @@ +#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. + internal 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 cancellable token that cancels the wait and removes the recovery listener. + /// A non-consuming list of recovered MediaPicker results. + /// Thrown when cannot be canceled. + /// + /// 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) + => 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. + /// + /// 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..a04e99bc79f3 --- /dev/null +++ b/src/Essentials/src/MediaPicker/MediaPickerRecoveryManager.android.cs @@ -0,0 +1,1351 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +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 +{ + internal const int MaxRecoveredResultCount = 32; + + static readonly Lock Locker = new(); + 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; + static Action? BeginOperationWithRecoveryCheckpointHandler; + // 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) + { + 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. + lock (Locker) + { + if (MediaPickerRecoveryStore.ReadActiveOperation() is { } activeOperation) + { + ThrowIfActiveOperationBlocksNewOperation(activeOperation); + } + + return BeginOperationUnderLock(kind, filePaths, photoProcessingOptions); + } + } + + internal static async Task BeginOperationWithRecoveryAsync( + RecoveredMediaPickerResultKind kind, + IReadOnlyList filePaths, + PersistedPhotoProcessingOptions photoProcessingOptions) + { + ValidateBeginOperationArguments(kind, filePaths); + + await RecoveryPromotionSemaphore.WaitAsync().ConfigureAwait(false); + + try + { + // 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) + { + CompleteRecoveryWaitersForReconciliation(reconciliation.Results); + } + + BeginOperationWithRecoveryCheckpointHandler?.Invoke(); + + lock (Locker) + { + var activeOperation = MediaPickerRecoveryStore.ReadActiveOperation(); + if (activeOperation is null) + { + return BeginOperationUnderLock(kind, filePaths, photoProcessingOptions); + } + + if (!ShouldPromoteRecreatedOperation(activeOperation) || attempt == 1) + { + ThrowIfActiveOperationBlocksNewOperation(activeOperation); + } + } + } + + throw new InvalidOperationException("A MediaPicker result is pending recovery."); + } + finally + { + RecoveryPromotionSemaphore.Release(); + } + } + + internal static void ClearActiveOperation(string id) + { + if (id is null) + { + return; + } + + IReadOnlyList pickerUriStringsToRelease = []; + + lock (Locker) + { + var operation = MediaPickerRecoveryStore.ReadActiveOperation(); + if (operation?.Id == id) + { + pickerUriStringsToRelease = ClearActiveOperationUnderLock(operation); + } + } + + ReleasePickerUriReadAccess(pickerUriStringsToRelease); + } + + internal static async Task> GetRecoveredResultsAsync() + { + var reconciliation = await RecoverOperationIfAvailableCoreAsync().ConfigureAwait(false); + return reconciliation.Results; + } + + 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(cancellationToken).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 SetPickerUriPermissionHandlersForTests( + Func? persistHandler, + Action? releaseHandler) + { + PersistPickerUriReadAccessHandler = persistHandler ?? PersistPickerUriReadAccessCore; + ReleasePickerUriReadAccessHandler = releaseHandler ?? ReleasePickerUriReadAccessCore; + } + + internal static void SetBeginOperationWithRecoveryCheckpointForTests(Action? checkpointHandler) + => BeginOperationWithRecoveryCheckpointHandler = checkpointHandler; + + internal static async Task DiscardPendingOperationAsync() + { + IReadOnlyList? waiterResults = null; + IReadOnlyList pickerUriStringsToRelease = []; + + 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."); + } + + pickerUriStringsToRelease = ClearActiveOperationUnderLock(operation); + waiterResults = ReadPublicRecoveredResultsUnderLock(); + } + + ReleasePickerUriReadAccess(pickerUriStringsToRelease); + + if (waiterResults is not null) + { + CompleteRecoveryWaitersForReconciliation(waiterResults); + } + } + finally + { + RecoveryPromotionSemaphore.Release(); + } + } + + internal static void RecoverOrphanedCaptureResult(RecoveredMediaPickerResultKind kind, bool success) + => ObserveOrphanedRecoveryTask( + RecoverOrphanedCaptureResultAsync(kind, success), + "Unhandled media capture recovery task failure"); + + 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) + => RecordPickCallbackResult( + operation => IsSinglePickCallbackKind(operation.Kind), + uri is null || uri.Equals(AndroidUri.Empty) ? Array.Empty() : new[] { uri }); + + internal static bool RecordMultiplePickCallbackResult(IReadOnlyList? uris) + => RecordPickCallbackResult( + operation => IsMultiplePickCallbackKind(operation.Kind), + uris ?? []); + + internal static void RecoverOrphanedSinglePickResult(AndroidUri? uri) + => ObserveOrphanedRecoveryTask( + RecoverOrphanedSinglePickResultAsync(uri), + "Unhandled picked media recovery task failure"); + + internal static async Task RecoverOrphanedSinglePickResultAsync(AndroidUri? uri) + => await RecoverOrphanedOperationResultAsync( + () => RecordSinglePickCallbackResult(uri), + "Unable to recover picked media result").ConfigureAwait(false); + + internal static void RecoverOrphanedMultiplePickResult(IReadOnlyList? 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); + 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(CancellationToken cancellationToken = default) + { + await RecoveryPromotionSemaphore.WaitAsync(cancellationToken).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) + { + 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; + } + + var persistedUriStrings = new List(); + foreach (var uri in uris) + { + if (TryPersistPickerUriReadAccess(uri) && uri is not null) + { + var uriString = uri.ToString(); + if (!string.IsNullOrWhiteSpace(uriString)) + { + persistedUriStrings.Add(uriString); + } + } + } + + var callbackRecorded = false; + try + { + lock (Locker) + { + 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; + } + + if (!callbackRecorded) + { + ReleasePickerUriReadAccess(persistedUriStrings); + return false; + } + + return true; + } + + internal static async Task> MaterializeAcceptedFilePathsAsync(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 = await MaterializePickerUrisAsync(operation.PickerUriStrings).ConfigureAwait(false); + } + 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 []; + } + + IReadOnlyList pickerUriStringsToRelease; + lock (Locker) + { + var current = MediaPickerRecoveryStore.ReadActiveOperation(); + if (current?.Id != operation.Id || current.State != PendingMediaPickerState.ResultAccepted) + { + return []; + } + + pickerUriStringsToRelease = current.PickerUriStrings.ToArray(); + MediaPickerRecoveryStore.WriteActiveOperation(current.WithAcceptedFiles(filePaths)); + } + + ReleasePickerUriReadAccess(pickerUriStringsToRelease); + 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 async Task> MaterializePickerUrisAsync(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 = await FileSystemUtils.EnsurePhysicalPathAsync(uri).ConfigureAwait(false); + if (!string.IsNullOrEmpty(filePath)) + { + filePaths.Add(filePath); + } + } + + return filePaths; + } + + static bool TryPersistPickerUriReadAccess(AndroidUri? uri) + { + if (!IsPersistablePickerUri(uri)) + { + return false; + } + + try + { + 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; + + 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 = await MaterializeAcceptedFilePathsAsync(operation.Id, throwOnMaterializationFailure: false).ConfigureAwait(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(NormalizeRecoveredResults(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() + => 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) + { + 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 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)) + { + 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 IReadOnlyList ClearActiveOperationUnderLock(PendingMediaPickerOperation operation) + { + InProcessOperationIds.Remove(operation.Id); + MediaPickerRecoveryStore.RemoveActiveOperation(); + return operation.PickerUriStrings.ToArray(); + } + + 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; + + // 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 || + 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 int SerializedRecordVersion = 1; + + 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 []; + } + + 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) + { + if (results.Count == 0) + { + Preferences.Remove(RecoveredResultsKey, PreferencesSharedName); + return; + } + + var records = results.Select(ToPreferenceRecord).ToArray(); + Preferences.Set(RecoveredResultsKey, JsonSerializer.Serialize(records, MediaPickerRecoveryJsonContext.Default.RecoveredResults), PreferencesSharedName); + } + + static string SerializePendingOperation(PendingMediaPickerOperation operation) + => JsonSerializer.Serialize(ToPreferenceRecord(operation), MediaPickerRecoveryJsonContext.Default.PendingOperation); + + static PendingMediaPickerOperation? DeserializePendingOperation(string? value) + { + try + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + 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 null; + } + + 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 + { + return null; + } + } + + static MediaPickerPendingOperationPreferenceRecord ToPreferenceRecord(PendingMediaPickerOperation operation) + => new() + { + 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 RecoveredMediaPickerRecord? DeserializeRecoveredResult(MediaPickerRecoveredResultPreferenceRecord? record) + { + if (record is null || + record.Version != SerializedRecordVersion || + string.IsNullOrWhiteSpace(record.Id) || + !TryDeserializeResultKind(record.Kind, out var kind)) + { + return null; + } + + var filePaths = GetValidStrings(record.FilePaths); + return filePaths.Count > 0 ? new RecoveredMediaPickerRecord(record.Id, kind, filePaths) : null; + } + + static MediaPickerRecoveredResultPreferenceRecord ToPreferenceRecord(RecoveredMediaPickerRecord result) + => new() + { + Version = SerializedRecordVersion, + Id = result.Id, + Kind = (int)result.Kind, + FilePaths = result.FilePaths.ToArray() + }; + + static IReadOnlyList GetValidStrings(string[]? values) + => values is null + ? [] + : values + .Where(value => !string.IsNullOrEmpty(value)) + .Select(value => value!) + .ToArray(); + + static bool TryDeserializePendingState(int value, out PendingMediaPickerState state) + { + if (Enum.IsDefined(typeof(PendingMediaPickerState), value)) + { + state = (PendingMediaPickerState)value; + return true; + } + + state = PendingMediaPickerState.Pending; + return false; + } + + static bool TryDeserializeResultKind(int value, out RecoveredMediaPickerResultKind kind) + { + if (Enum.IsDefined(typeof(RecoveredMediaPickerResultKind), value)) + { + kind = (RecoveredMediaPickerResultKind)value; + return true; + } + + kind = RecoveredMediaPickerResultKind.CapturePhoto; + return false; + } +} + +internal sealed class MediaPickerPendingOperationPreferenceRecord +{ + public int Version { get; set; } + + public string? Id { get; set; } + + public int Kind { get; set; } + + public int State { get; set; } + + public string[]? FilePaths { get; set; } + + public string[]? PickerUriStrings { get; set; } + + public MediaPickerPhotoProcessingOptionsPreferenceRecord? PhotoProcessingOptions { get; set; } +} + +internal sealed class MediaPickerRecoveredResultPreferenceRecord +{ + public int Version { get; set; } + + public string? Id { get; set; } + + public int Kind { get; set; } + + public string[]? FilePaths { get; set; } +} + +internal sealed class MediaPickerPhotoProcessingOptionsPreferenceRecord +{ + public int? MaximumWidth { get; set; } + + public int? MaximumHeight { get; set; } + + public int CompressionQuality { get; set; } + + public bool RotateImage { get; set; } + + public bool PreserveMetaData { get; set; } +} + +[JsonSerializable(typeof(MediaPickerPendingOperationPreferenceRecord), TypeInfoPropertyName = nameof(PendingOperation))] +[JsonSerializable(typeof(MediaPickerRecoveredResultPreferenceRecord[]), TypeInfoPropertyName = nameof(RecoveredResults))] +internal sealed partial class MediaPickerRecoveryJsonContext : JsonSerializerContext +{ +} + +/// +/// 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); + readonly Lock completionLock = new(); + CancellationTokenRegistration cancellationRegistration; + bool completed; + + public MediaPickerRecoveryWaiter(CancellationToken cancellationToken) + { + this.cancellationToken = cancellationToken; + } + + public Task> Task => completionSource.Task; + + public void SetCancellationRegistration(CancellationTokenRegistration registration) + { + var disposeRegistration = false; + + lock (completionLock) + { + if (completed) + { + disposeRegistration = true; + } + else + { + cancellationRegistration = registration; + } + } + + if (disposeRegistration) + { + registration.Dispose(); + } + } + + public void TrySetResult(IReadOnlyList results) + { + if (!TryComplete(out var registration)) + { + return; + } + + registration.Dispose(); + completionSource.TrySetResult(results); + } + + public void TrySetCanceled() + { + if (!TryComplete(out var registration)) + { + return; + } + + 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; + } + } +} + +/// +/// 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..f2c67bf7e559 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.TrySetCanceled(); + 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.TrySetException(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..0286a913af0a --- /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..0a2a8a6eaa9e --- /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..c4e68727fd6d 100644 --- a/src/Essentials/src/Platform/PickMultipleVisualMediaForResult.android.cs +++ b/src/Essentials/src/Platform/PickMultipleVisualMediaForResult.android.cs @@ -1,12 +1,40 @@ 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; internal class PickMultipleVisualMediaForResult : ActivityForResultRequest { - static readonly Lazy LazyInstance = new(new PickMultipleVisualMediaForResult()); + static readonly Lazy 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)); + + 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..1cabe488f561 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; @@ -7,7 +7,13 @@ 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; -} \ No newline at end of file + + protected override void OnActivityResultForActiveLaunch(AndroidUri result) + => MediaPickerRecoveryManager.RecordSinglePickCallbackResult(result); + + 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..de1873a0bba8 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.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.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!>! 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..ed27061d776a --- /dev/null +++ b/src/Essentials/test/DeviceTests/Tests/Android/MediaPickerRecovery_Tests.cs @@ -0,0 +1,2035 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.Json; +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 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() + { + 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); + using var cancellationTokenSource = new CancellationTokenSource(); + + var pendingCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CapturePhoto, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + SimulateProcessRecreation(); + await MediaPickerRecoveryManager.RecoverOrphanedCaptureResultAsync(RecoveredMediaPickerResultKind.CapturePhoto, true); + + var results = await MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + + 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_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] + 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() + { + 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()); + } + + [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)] + 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 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, + [], + PersistedPhotoProcessingOptions.Default); + + 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); + Assert.Empty(activePick.FilePaths); + Assert.Equal(pickUri.ToString(), GetSingleActiveOperationPickerUri(activePick)); + Assert.Equal(new[] { pickUri.ToString() }, permissions.Persisted); + Assert.Empty(permissions.Released); + } + + [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_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, + [], + PersistedPhotoProcessingOptions.Default); + + Assert.True(MediaPickerRecoveryManager.RecordSinglePickCallbackResult(invalidPickerUri)); + + 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] + 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, + [], + PersistedPhotoProcessingOptions.Default); + + Assert.True(MediaPickerRecoveryManager.RecordSinglePickCallbackResult(invalidPickerUri)); + SimulateProcessRecreation(); + + var results = await MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + + 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))); + 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 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() + { + 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 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() + { + 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); + } + + [Fact] + public void Pending_Operation_Writes_Json_Baseline_And_RoundTrips() + { + var id = Guid.NewGuid().ToString("N"); + var capturePath = CreateMissingMediaFilePath(FileExtensions.Jpg); + var pickerUriString = "content://maui-test/picked-media"; + var operation = new PendingMediaPickerOperation( + id, + RecoveredMediaPickerResultKind.PickPhotos, + PendingMediaPickerState.ResultAccepted, + [capturePath], + [pickerUriString], + new PersistedPhotoProcessingOptions(640, 480, 70, true, false)); + + MediaPickerRecoveryStore.WriteActiveOperation(operation); + + var serializedCapture = Assert.IsType(GetSerializedActiveOperation()); + + 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] + 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_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() + { + var invalidJpegPath = CreateNonEmptyMediaFile(FileExtensions.Jpg); + var originalBytes = await File.ReadAllBytesAsync(invalidJpegPath); + + 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] + 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); + using var cancellationTokenSource = new CancellationTokenSource(); + + var pendingCapture = MediaPickerRecoveryManager.BeginOperation( + RecoveredMediaPickerResultKind.CaptureVideo, + [capturePath], + PersistedPhotoProcessingOptions.Default); + + MediaPickerRecoveryManager.RecordCaptureCallbackResult(RecoveredMediaPickerResultKind.CaptureVideo, true); + SimulateProcessRecreation(); + + var results = await MediaPicker.WaitForRecoveredMediaPickerResultsAsync(cancellationTokenSource.Token); + + 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 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() + { + 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 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 + // recovery scan behaves like a recreated app process receiving AndroidX result replay. + ClearInProcessOperationIds(); + } + + static void ResetRecoveryState() + { + ClearInProcessOperationIds(); + CompleteAndClearRecoveryWaiters(); + MediaPickerRecoveryManager.SetPickerUriPermissionHandlersForTests(null, null); + MediaPickerRecoveryManager.SetBeginOperationWithRecoveryCheckpointForTests(null); + 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 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 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) + .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; + } + + 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."); + 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."); + + 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); + + 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); + } + } +}