Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue36523.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
using Microsoft.Maui.Media;

namespace Maui.Controls.Sample.Issues;

[Issue(IssueTracker.Github, 36523, "MediaPicker.PickPhotosAsync hangs after device rotation on API 33+", PlatformAffected.Android)]
public class Issue36523 : ContentPage
{
public Issue36523()
{
var openButton = new Button { AutomationId = "OpenRotationActivityButton", Text = "Open Rotation Activity" };
openButton.Clicked += (_, _) =>
{
#if ANDROID
Issue36523State.Reset();
var activity = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity!;
activity.StartActivity(new Android.Content.Intent(activity, typeof(Issue36523RotationActivity)));
#endif
};
Content = new VerticalStackLayout { Padding = 30, Children = { openButton } };
}
}

#if ANDROID
static class Issue36523State
{
static readonly object s_gate = new();
static Task s_pickerTask;
static int s_launchActivityId;
static string s_outcome = "READY";

public static void Reset() { lock (s_gate) { s_pickerTask = null; s_launchActivityId = 0; s_outcome = "READY"; } }
public static string GetOutcome() { lock (s_gate) return s_outcome; }
public static void Complete(string outcome) { lock (s_gate) s_outcome = outcome; }

public static void BeginPick(int activityId, Task task)
{
lock (s_gate)
{ s_launchActivityId = activityId; s_pickerTask = task; s_outcome = "WAITING"; }
}

public static bool CheckForHang(int currentActivityId)
{
lock (s_gate)
{
if (s_pickerTask is { IsCompleted: false } && s_launchActivityId != currentActivityId)
{
s_outcome = "FAIL: picker task hung after activity recreation";
return true;
}
return false;
}
}
}

[Android.App.Activity(Label = "Issue36523", Theme = "@style/Maui.SplashTheme")]
public class Issue36523RotationActivity : AndroidX.AppCompat.App.AppCompatActivity
{
static int s_nextId;
readonly int _id = Interlocked.Increment(ref s_nextId);
Android.Widget.TextView _status = null!;
CancellationTokenSource _cts;

protected override void OnCreate(Android.OS.Bundle savedInstanceState)
{
base.OnCreate(savedInstanceState);
Microsoft.Maui.ApplicationModel.Platform.Init(this, savedInstanceState);

var layout = new Android.Widget.LinearLayout(this) { Orientation = Android.Widget.Orientation.Vertical };
var statusBarHeight = 0;
var resourceId = Resources.GetIdentifier("status_bar_height", "dimen", "android");
if (resourceId > 0)
{
statusBarHeight = Resources.GetDimensionPixelSize(resourceId);
}
layout.SetPadding(50, statusBarHeight + 50, 50, 50);

_status = new Android.Widget.TextView(this) { Text = $"Status: {Issue36523State.GetOutcome()}" };
SetAutomationId(_status, "RotationActivityStatusLabel");

var btn = new Android.Widget.Button(this) { Text = "Pick Photos" };
SetAutomationId(btn, "RotationActivityPickButton");
btn.Click += OnPick;

layout.AddView(_status);
layout.AddView(btn);
SetContentView(layout);
}

public override void OnWindowFocusChanged(bool hasFocus)
{
base.OnWindowFocusChanged(hasFocus);
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
if (!hasFocus)
{
return;
}
_cts = new CancellationTokenSource();
_ = DelayedHangCheck(_cts.Token);
}

async Task DelayedHangCheck(CancellationToken ct)
{
try
{
await Task.Delay(3000, ct);
if (!IsDestroyed)
{
RunOnUiThread(() => { Issue36523State.CheckForHang(_id); _status.Text = $"Status: {Issue36523State.GetOutcome()}"; });
}
}
catch (OperationCanceledException) { }
}

async void OnPick(object s, EventArgs e)
{
_status.Text = "Status: WAITING";
try
{
var task = MediaPicker.PickPhotosAsync();
Issue36523State.BeginPick(_id, task);
var r = await task;
Issue36523State.Complete(r?.Count > 0 ? $"PASS: got {r.Count} photo(s)" : "PASS: cancelled");
}
catch (OperationCanceledException) { Issue36523State.Complete("PASS: cancelled"); }
catch (Exception ex) { Issue36523State.Complete($"ERROR: {ex.Message}"); }
if (!IsDestroyed)
{
_status.Text = $"Status: {Issue36523State.GetOutcome()}";
}
}

protected override void OnDestroy() { _cts?.Cancel(); _cts?.Dispose(); _cts = null; base.OnDestroy(); }

void SetAutomationId(Android.Views.View view, string id)
{
AndroidX.Core.View.ViewCompat.SetAccessibilityDelegate(view,
new IdDelegate($"{PackageName}:id/{id}"));
}

class IdDelegate(string name) : AndroidX.Core.View.AccessibilityDelegateCompat
{
public override void OnInitializeAccessibilityNodeInfo(Android.Views.View host,
AndroidX.Core.View.Accessibility.AccessibilityNodeInfoCompat info)
{ base.OnInitializeAccessibilityNodeInfo(host, info); info.ViewIdResourceName = name; }
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#if ANDROID
using NUnit.Framework;
using UITest.Appium;
using UITest.Core;

namespace Microsoft.Maui.TestCases.Tests.Issues;

public class Issue36523 : _IssuesUITest
{
public Issue36523(TestDevice device) : base(device) { }

public override string Issue => "MediaPicker.PickPhotosAsync hangs after device rotation on API 33+";

[TearDown]
public void TearDown()
{
App.SetOrientationPortrait();
}

[Test]
[Category(UITestCategories.Essentials)]
public void PickPhotosAsyncShouldReturnAfterRotation()
{
// Bug only manifests on API 33+ where the native Photo Picker is used.
if (App is AppiumApp appiumApp)
{
var apiLevel = (long?)appiumApp.Driver.Capabilities.GetCapability("deviceApiLevel") ?? 0;
if (apiLevel < 33)
{
Assert.Ignore($"Issue #36523 only manifests on Android API 33+. Current device API: {apiLevel}.");
}
}

App.WaitForElement("OpenRotationActivityButton");
App.Tap("OpenRotationActivityButton");

App.WaitForElement("RotationActivityPickButton");
App.WaitForElement("RotationActivityStatusLabel");

App.Tap("RotationActivityPickButton");
Task.Delay(4000).Wait();

// Rotate while picker is open — triggers activity destroy/recreate
App.SetOrientationLandscape();
Task.Delay(4000).Wait();

// Cancel the picker
App.Back();

// With fix: task completes → "PASS". Without fix: task hangs → "FAIL".
Assert.That(
App.WaitForTextToBePresentInElement("RotationActivityStatusLabel", "PASS",
timeout: TimeSpan.FromSeconds(30)),
Is.True, "Timed out waiting for PASS — picker task likely hung after rotation.");

var resultText = App.FindElement("RotationActivityStatusLabel").GetText();

Assert.That(resultText, Does.Contain("PASS"),
$"PickPhotosAsync must complete after device rotation. Actual: '{resultText}'.");

Assert.That(resultText, Does.Not.Contain("FAIL"),
$"Picker task hung after activity recreation: '{resultText}'.");

App.Back();
App.WaitForElement("OpenRotationActivityButton");
}
}
#endif
39 changes: 39 additions & 0 deletions src/Essentials/src/Platform/ActivityForResultRequest.android.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ internal abstract class ActivityForResultRequest<TContract, TResult>
// This prevents Activity B from overwriting Activity A's pending request.
readonly ConditionalWeakTable<ComponentActivity, TaskCompletionSource<TResult>> _pendingRequests = new();

// Strong reference to the launching activity — prevents GC from collecting the CWT entry
// before Register() can migrate the TCS to the new activity after a config change.
ComponentActivity _inFlightActivity;

/// <summary>
/// Gets a value indicating whether the request has a launcher registered for the
/// currently focused activity.
Expand All @@ -71,6 +75,9 @@ public void Register(ComponentActivity componentActivity)
if (_activityLaunchers.TryGetValue(componentActivity, out _))
return;

// Migrate pending TCS from the old activity to the new one on config change (e.g. rotation).
MigratePendingRequests(componentActivity);

var contract = new TContract();

// CRITICAL: capture the same `componentActivity` instance the launcher is being
Expand All @@ -83,6 +90,7 @@ public void Register(ComponentActivity componentActivity)
var registeredActivity = componentActivity;
var callback = new ActivityResultCallback<TResult>(result =>
{
_inFlightActivity = null;
if (_pendingRequests.TryGetValue(registeredActivity, out var tcs))
{
_pendingRequests.Remove(registeredActivity);
Expand Down Expand Up @@ -147,6 +155,7 @@ public Task<TResult> Launch<T>(ComponentActivity launchingActivity, T input)

var tcs = new TaskCompletionSource<TResult>();
_pendingRequests.Add(launchingActivity, tcs);
_inFlightActivity = launchingActivity;

// Get the launcher for this specific activity
if (!_activityLaunchers.TryGetValue(launchingActivity, out var launcher))
Expand All @@ -156,6 +165,7 @@ public Task<TResult> Launch<T>(ComponentActivity launchingActivity, T input)
Ensure your Activity inherits from ComponentActivity and call Microsoft.Maui.ApplicationModel.Platform.Init(Activity, Bundle) in OnCreate.
""");
_pendingRequests.Remove(launchingActivity);
_inFlightActivity = null;
tcs.SetCanceled();
return tcs.Task;
}
Expand All @@ -167,6 +177,7 @@ Ensure your Activity inherits from ComponentActivity and call Microsoft.Maui.App
catch (Exception ex)
{
_pendingRequests.Remove(launchingActivity);
_inFlightActivity = null;
tcs.TrySetException(ex);
}

Expand All @@ -184,6 +195,7 @@ internal void CancelPendingRequest(ComponentActivity componentActivity)
if (_pendingRequests.TryGetValue(componentActivity, out var tcs))
{
_pendingRequests.Remove(componentActivity);
_inFlightActivity = null;
tcs?.TrySetCanceled();
}
}
Expand All @@ -198,4 +210,31 @@ ActivityResultLauncher GetLauncherForCurrentActivity()

return null;
}

/// <summary>
/// Migrates any pending TCS from the old (destroyed) activity to the new activity
/// during a configuration change, so the result callback can find the TCS under the
/// new activity's key.
/// </summary>
void MigratePendingRequests(ComponentActivity newActivity)
{
var oldActivity = _inFlightActivity;
if (oldActivity is null || ReferenceEquals(oldActivity, newActivity))
{
return;
}

// Only migrate on config change — not when the activity was finished normally.
if (!oldActivity.IsChangingConfigurations)
{
return;
}

if (_pendingRequests.TryGetValue(oldActivity, out var tcs))
{
_pendingRequests.Remove(oldActivity);
_pendingRequests.Add(newActivity, tcs);
_inFlightActivity = newActivity;
}
}
}
Loading