Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
82 changes: 82 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue30010.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using Microsoft.Maui.Media;

namespace Maui.Controls.Sample.Issues;

[Issue(IssueTracker.Github, 30010, "Loading the captured screenshot from webview content to Image control does not visible", PlatformAffected.Android)]
public class Issue30010 : ContentPage
{
readonly Image _resultImage;
readonly Label _statusLabel;
readonly Button _screenshotButton;

public Issue30010()
{
_statusLabel = new Label
{
Text = "Waiting for WebView to load...",
AutomationId = "StatusLabel",
Margin = new Thickness(12)
};

var webView = new WebView
{
HeightRequest = 200,
Source = new HtmlWebViewSource
{
Html = @"<HTML><BODY><H1>.NET MAUI</H1><P>Welcome to WebView.</P></BODY></HTML>"
}
};
webView.Navigated += (s, e) =>
{
_statusLabel.Text = "WebView loaded";
_screenshotButton.IsEnabled = true;
};

_screenshotButton = new Button
{
Text = "Take Screenshot",
AutomationId = "TakeScreenshotButton",
IsEnabled = false
};
_screenshotButton.Clicked += OnTakeScreenshotClicked;

_resultImage = new Image
{
AutomationId = "ResultImage",
HeightRequest = 300
};

Content = new VerticalStackLayout
{
Children =
{
new Label { Text = "Issue 30010", FontAttributes = FontAttributes.Bold, Margin = new Thickness(12) },
_statusLabel,
webView,
_screenshotButton,
_resultImage
}
};
}

async void OnTakeScreenshotClicked(object? sender, EventArgs e)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[major] Build & MSBuild — This file does not enable nullable annotations, but the new handler uses object?. With repository-wide warnings-as-errors this produces CS8632 and blocks the Android test host build. Use object sender here or add #nullable enable to the file.

{
try
{
var screenshot = await Screenshot.CaptureAsync();
var stream = await screenshot.OpenReadAsync(ScreenshotFormat.Png);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[moderate] Screenshot stream is not disposed — The stream returned from OpenReadAsync is copied and then abandoned without disposal. Wrap it in using before copying to the byte array so repeated screenshot test runs do not leak the native/managed stream resource.


// Read into a byte array so the stream can be consumed multiple times
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
var bytes = ms.ToArray();
Comment on lines +70 to +76

_resultImage.Source = ImageSource.FromStream(() => new MemoryStream(bytes));
_statusLabel.Text = "Screenshot captured";
}
catch (Exception ex)
{
_statusLabel.Text = $"Error: {ex.Message}";
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#if ANDROID
using NUnit.Framework;
using UITest.Appium;
using UITest.Core;

namespace Microsoft.Maui.TestCases.Tests.Issues;

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

public override string Issue =>
"Loading the captured screenshot from webview content to Image control does not visible";

[Test]
[Category(UITestCategories.WebView)]
public void Issue30010_TakeScreenshotFunctionality()
{
// Wait for WebView to finish loading
App.WaitForElement("StatusLabel");
App.WaitForElement("TakeScreenshotButton");

// The button is enabled only after WebView.Navigated fires
App.WaitForElement("TakeScreenshotButton");
App.Tap("TakeScreenshotButton");

// Wait for the screenshot to be captured and displayed
var statusLabel = App.WaitForElement("StatusLabel");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[major] Test waits for pre-existing UI instead of capture completion — This waits for StatusLabel/ResultImage, but both elements already exist before the screenshot is captured and before the Image source finishes loading. The test can snapshot the pre-capture UI and fail to validate the WebView screenshot fix. Wait for a deterministic post-capture signal such as status text Screenshot captured before calling VerifyScreenshot.

App.WaitForElement("ResultImage");

VerifyScreenshot("Issue30010TakeScreenshotFunctionality");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[moderate] Regression Prevention and Test Coverage — The test waits for the status label, but the app sets that label immediately after assigning _resultImage.Source; the Image may not have decoded/rendered yet when the screenshot comparison runs. This can make the regression test flaky or capture the pre-render state. Use VerifyScreenshot(..., retryTimeout: ...) or signal success only after the image has rendered.

}
}
#endif
22 changes: 22 additions & 0 deletions src/Essentials/samples/Samples/View/ScreenshotPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,28 @@
<ScrollView Grid.Row="1">
<StackLayout Padding="12,0,12,12" Spacing="6">

<StackLayout Padding="12" Spacing="6">
<Label Text="Some Controls" />
<Switch OnColor="Orange"
ThumbColor="Green" />
<WebView>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[moderate] Sample WebView needs an explicit height — This sample adds a WebView inside a StackLayout nested in a ScrollView without an explicit height. On MAUI layouts, WebView commonly measures to no visible height in this configuration, so the sample may not actually exercise WebView screenshot capture. Set a HeightRequest like the regression page does.

<WebView.Source>
<HtmlWebViewSource>
<HtmlWebViewSource.Html>
<![CDATA[
<HTML>
<BODY>
<H1>.NET MAUI</H1>
<P>Welcome to WebView.</P>
</BODY>
</HTML>
]]>
</HtmlWebViewSource.Html>
</HtmlWebViewSource>
</WebView.Source>
</WebView>
</StackLayout>

<Button Text="Take Screenshot" Command="{Binding ScreenshotCommand}" />

<Button Text="Email Screenshot" Command="{Binding EmailCommand}" />
Expand Down
111 changes: 95 additions & 16 deletions src/Essentials/src/Screenshot/Screenshot.android.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
#nullable enable

using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Android.App;
using Android.Content;
using Android.Graphics;
using Android.OS;
using Android.Views;
using Java.Nio;
using Microsoft.Maui.ApplicationModel;
Expand All @@ -13,7 +16,7 @@ namespace Microsoft.Maui.Media
{
partial class ScreenshotImplementation : IPlatformScreenshot, IScreenshot
{
static IWindowManager WindowManager =>
static IWindowManager? WindowManager =>
Application.Context.GetSystemService(Context.WindowService) as IWindowManager;

public bool IsCaptureSupported => true;
Expand All @@ -23,41 +26,117 @@ public Task<IScreenshotResult> CaptureAsync()
if (WindowManager?.DefaultDisplay?.Flags.HasFlag(DisplayFlags.Secure) == true)
throw new UnauthorizedAccessException("Unable to take a screenshot of a secure window.");

var activity = ActivityStateManager.Default.GetCurrentActivity(true);
var activity = ActivityStateManager.Default.GetCurrentActivity(true)
?? throw new InvalidOperationException("Unable to find the current activity.");

return CaptureAsync(activity);
}

public Task<IScreenshotResult> CaptureAsync(Activity activity)
public async Task<IScreenshotResult> CaptureAsync(Activity activity)
{
var view = activity?.Window?.DecorView?.RootView;
if (view == null)
throw new InvalidOperationException("Unable to find the main window.");

return CaptureAsync(view);
var result = await CaptureAsync(view).ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Unable to capture screenshot.");
}

public Task<IScreenshotResult> CaptureAsync(View view)
public async Task<IScreenshotResult?> CaptureAsync(View view)
{
_ = view ?? throw new ArgumentNullException(nameof(view));

var bitmap = Render(view);
var result = bitmap is null ? null : new ScreenshotResult(bitmap);
var bitmap = await RenderAsync(view).ConfigureAwait(false);
return bitmap is null ? null : new ScreenshotResult(bitmap);
}

static async Task<Bitmap?> RenderAsync(View view)
{
if (OperatingSystem.IsAndroidVersionAtLeast(26))
{
var bitmap = await RenderUsingPixelCopyAsync(view).ConfigureAwait(false);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[major] Async and threading safety — If PixelCopy fails, this continuation resumes after ConfigureAwait(false) and then falls back to RenderUsingCanvasDrawing(view) / RenderUsingDrawingCache(view) on a threadpool thread. Those fallback paths touch Android View APIs and must run on the UI thread. Keep this continuation on the main thread or explicitly dispatch fallback rendering back to the UI thread.

if (bitmap is not null)
return bitmap;
}

return RenderUsingCanvasDrawing(view) ?? RenderUsingDrawingCache(view);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[major] Async and Threading Safety - If CaptureAsync(View) is called from a background thread and PixelCopy returns null, this fallback path runs View.Draw / drawing-cache work on that background thread. Android view rendering APIs must run on the UI thread; dispatch the fallback render to the main looper or fail before falling back.

}

static Task<Bitmap?> RenderUsingPixelCopyAsync(View view)
{
if (view.Width <= 0 || view.Height <= 0 || !view.IsAttachedToWindow)
return Task.FromResult<Bitmap?>(null);

var window = GetActivity(view.Context)?.Window;
if (window is null)
return Task.FromResult<Bitmap?>(null);

var bitmap = Bitmap.CreateBitmap(view.Width, view.Height, Bitmap.Config.Argb8888!);
if (bitmap is null)
return Task.FromResult<Bitmap?>(null);

var location = new int[2];
view.GetLocationInWindow(location);
var rect = new Rect(
location[0],
location[1],
location[0] + view.Width,
location[1] + view.Height);

var tcs = new TaskCompletionSource<Bitmap?>(TaskCreationOptions.RunContinuationsAsynchronously);

try
{
PixelCopy.Request(window, rect, bitmap,
new PixelCopyFinishedListener(tcs, bitmap),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[major] Android callback lifetime — The PixelCopyFinishedListener is allocated inline while the async request is pending. This diverges from the repository's existing PixelCopy pattern, which keeps the listener in managed state until the callback is reached. Store the listener in a local/state object that remains alive until OnPixelCopyFinished completes.

new Handler(Looper.MainLooper!));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[moderate] Android Platform Specifics — This allocates a new Android Handler/Java peer for every screenshot capture. MAUI's existing PixelCopy usage posts through the view's native handler; prefer view.Handler ?? new Handler(Looper.MainLooper!) so the callback is tied to the view looper and avoids unnecessary per-capture allocation.

}
catch (Exception)
{
bitmap.Dispose();
return Task.FromResult<Bitmap?>(null);
}

return Task.FromResult<IScreenshotResult>(result);
return tcs.Task;
}

static Bitmap Render(View view)
static Activity? GetActivity(Context? context)
{
var bitmap = RenderUsingCanvasDrawing(view);
while (context is ContextWrapper wrapper)
{
if (context is Activity activity)
return activity;
context = wrapper.BaseContext;
}
return context as Activity;
}

if (bitmap == null)
bitmap = RenderUsingDrawingCache(view);
sealed class PixelCopyFinishedListener : Java.Lang.Object, PixelCopy.IOnPixelCopyFinishedListener
{
readonly TaskCompletionSource<Bitmap?> _tcs;
readonly Bitmap _bitmap;

return bitmap;
public PixelCopyFinishedListener(TaskCompletionSource<Bitmap?> tcs, Bitmap bitmap)
{
_tcs = tcs;
_bitmap = bitmap;
}

public void OnPixelCopyFinished(int copyResult)
{
if (copyResult == (int)PixelCopyResult.Success)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[major] PixelCopy success checkPixelCopyResult.Success is not used elsewhere in this repo and does not match the existing Android PixelCopy pattern, which checks copyResult == 0. Use the established success check for consistency with the target Android binding surface.

{
_tcs.TrySetResult(_bitmap);
}
else
{
_bitmap.Dispose();
_tcs.TrySetResult(null);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[major] Logic and Correctness - Returning null for any PixelCopy failure silently falls through to the old canvas/drawing-cache screenshot path, which is the path that cannot capture hardware-accelerated WebView content. On an API 26+ device where PixelCopy returns ERROR_SOURCE_NO_DATA/timeout, callers can still receive a successful but blank-prone screenshot instead of a retry or clear failure signal.

}
}
}

static Bitmap RenderUsingCanvasDrawing(View view)
static Bitmap? RenderUsingCanvasDrawing(View view)
{
try
{
Expand All @@ -67,7 +146,7 @@ static Bitmap RenderUsingCanvasDrawing(View view)
var height = view.Height;

var bitmap = Bitmap.CreateBitmap(width, height, Bitmap.Config.Argb8888);
if (bitmap == null)
if (bitmap is null)
return null;

using (var canvas = new Canvas(bitmap))
Expand All @@ -81,7 +160,7 @@ static Bitmap RenderUsingCanvasDrawing(View view)
}
}

static Bitmap RenderUsingDrawingCache(View view)
static Bitmap? RenderUsingDrawingCache(View view)
{
#pragma warning disable CS0618 // Type or member is obsolete
#pragma warning disable CA1416 // Validate platform compatibility
Expand Down
Loading