Skip to content

[net11.0] Add public IImageSourcePaint contract for external platform backends - #37864

Open
Redth wants to merge 2 commits into
net11.0from
redth-image-source-paint-public-contract
Open

[net11.0] Add public IImageSourcePaint contract for external platform backends#37864
Redth wants to merge 2 commits into
net11.0from
redth-image-source-paint-public-contract

Conversation

@Redth

@Redth Redth commented Aug 26, 2026

Copy link
Copy Markdown
Member

Note

Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!

Description of Change

.NET MAUI represents an image background as the internal Microsoft.Maui.ImageSourcePaint. An out-of-tree platform backend receives backgrounds as a Paint through IView.Background, so it has no supported way to recognize an image background or read its IImageSource.

This is a hard blocker for external backends. Reproduced against the released Microsoft.Maui.Core package — an external assembly writing the exact code the in-tree handlers use fails to compile:

error CS0122: 'ImageSourcePaint' is inaccessible due to its protection level
error CS0122: 'ImageSourcePaint.ImageSource' is inaccessible due to its protection level

The only workarounds today are reflection or InternalsVisibleTo, neither of which is viable for a third-party backend.

Solution

Add the smallest possible additive read contract:

namespace Microsoft.Maui;

public interface IImageSourcePaint
{
    IImageSource? ImageSource { get; }
}

The internal ImageSourcePaint implements it and stays internal, so no implementation detail (its settable property, its constructors, the concrete type) is exposed. An external backend can now do:

if (view.Background is IImageSourcePaint imagePaint)
{
    var provider = handler.GetRequiredService<IImageSourceServiceProvider>();
    ApplyImageBackgroundAsync(handler.PlatformView, imagePaint.ImageSource, provider);
}

Consumption-only contract

This interface is for consuming paints that .NET MAUI produces. Implementing it outside of .NET MAUI is explicitly not supported, and this is stated in the XML docs:

  • .NET MAUI reserves the right to add members to this interface in future releases, which would break external implementers.
  • A custom Paint implementing this interface is not guaranteed to be honored. Specialized handlers bypass the image-source path — for example LayoutHandler.MapBackground on iOS/Android calls platformView.UpdateBackground(layout) directly and never checks for an image paint.
  • Brush's paint→brush conversion only round-trips an IImageSource that is a Controls ImageSource, so arbitrary image sources would not survive conversion.

Because of this, in-tree pattern matches deliberately continue to use the concrete internal ImageSourcePaint. The diff is therefore purely additive — a new interface, an interface list entry on an internal type, and the PublicAPI entries. No in-tree behavior changes at all.

Also documented: values are obtained by pattern matching an existing Paint (typically IView.Background); a null ImageSource means an image background with nothing to draw and should clear any previously applied image; and this is distinct from Microsoft.Maui.Graphics.ImagePaint, which carries an already-loaded IImage rather than an unresolved IImageSource.

Alternatives considered and rejected:

Option Why not
Make ImageSourcePaint public Exposes a settable ImageSource, two constructors, and a concrete type we'd be locked into. Far larger surface than needed.
InternalsVisibleTo for known backends Not a general solution; requires a MAUI change per backend.
Reflection in the backend Not trim/AOT safe, silently breaks on rename.

Compatibility

Purely additive — nothing is removed, renamed, or rerouted.

  • Public API: one new interface + one getter, added to PublicAPI.Unshipped.txt for all 8 TFM folders.
  • Behavior: unchanged. Every in-tree consumer still matches the same concrete type it did before.
  • Brush/paint API: unchanged. ImageBrushImageSourcePaint round-trips exactly as before.
  • XAML / serialization: unchanged. ImageSourcePaint was never XAML-exposed; ImageBrush is untouched.
  • Trimming / AOT: adding an interface introduces no reflection; consumers use trim-safe isinst.
  • Equality: ImageSourcePaint did not and does not override equality.

Tests

ImageSourcePaintContractTests (Core, 10 tests) uses a fake external backend that only consumes the contract — it never implements it — and touches only public API, with no reflection and no internals access. It proves that backend can distinguish solid / gradient / image / no paint and read the IImageSource off MAUI's built-in internal paint. It also asserts:

  • the contract is public and exposes only the getter (no other member leaks through),
  • ImageSourcePaint remains internal,
  • a null ImageSource still identifies an image background,
  • Graphics.ImagePaint is not an IImageSourcePaint.

ImageBrushTests (Controls, 3 tests) covers ImageBrush → paint → ImageBrush round-tripping and that solid/gradient brushes are not image paints.

Verified locally:

  • Core.UnitTests (net11.0): 958 passed, 0 failed
  • Controls.Core.UnitTests (net11.0): 6210 passed, 0 failed
  • Core.csproj builds clean (0 warnings) for netstandard2.0, netstandard2.1, net11.0, net11.0-ios, net11.0-maccatalyst, net11.0-android — confirming the PublicAPI entries are correct for every TFM.
  • The external-assembly repro that previously failed with CS0122 now compiles successfully against the reference assembly produced by this branch.
  • dotnet format --verify-no-changes clean on all changed files.

The net11.0-tizen7.0 TFM could not be built locally (NETSDK1139: The target platform identifier tizen was not recognized in the current SDK band). No Tizen source file is modified by this PR, so this is not a risk; CI covers that TFM.

Issues Fixed

Unblocks external platform backends (e.g. an out-of-tree Tizen backend) from implementing image-source background rendering.

MAUI represents an image background as the internal `ImageSourcePaint`
type. Out-of-tree platform backends receive backgrounds as a `Paint` via
`IView.Background`, so they cannot detect an image background or read its
`IImageSource` without reflection or `InternalsVisibleTo`.

Adds a minimal, additive public contract, `Microsoft.Maui.IImageSourcePaint`,
exposing a single read-only `ImageSource` property. The internal
`ImageSourcePaint` now implements it, and every in-tree consumer pattern
matches on the interface instead of the concrete type.

This is purely additive:
- `ImageSourcePaint` stays internal, so no implementation detail is exposed.
- No brush/paint API, XAML, or serialization surface changes; `ImageBrush`
  round-trips exactly as before.
- Interface pattern matching is trim/AOT safe and adds no reflection.
- Equality and built-in platform behavior are unchanged.

Because in-tree handlers now match the interface, a backend (or app) can
also supply its own `Paint` implementing `IImageSourcePaint` and have the
built-in handlers render it as an image background.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5ec0bf62-da2c-49fb-af27-a0a79cbbd6bb
Copilot AI lite review requested due to automatic review settings August 26, 2026 23:46
@Redth
Redth temporarily deployed to copilot-pat-pool August 26, 2026 23:46 — with GitHub Actions Inactive
@github-actions

Copy link
Copy Markdown
Contributor

🚀 Dogfood this PR with:

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

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

Or

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

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).
There may be pipelines that require an authorized user to comment /azp run to run.

@Redth
Redth temporarily deployed to copilot-pat-pool August 26, 2026 23:49 — with GitHub Actions Inactive
Redth pushed a commit to Redth/Maui.Tizen that referenced this pull request Aug 26, 2026
Architecture review found the backend was not actually substitutable for MAUI's
own handlers: it owned a parallel ITizen*Handler hierarchy and chained only the
Tizen base mapper, so MAUI Controls' RemapForControls dispatch never reached it.

Every claim the redesign rests on was verified against the pinned package
(11.0.0-preview.7.26426.4) before any code changed, because the previous design
was justified by an objection that turned out to be obsolete:

  * Microsoft.Maui.Core ships NO Tizen asset - only net11.0, -android, -ios,
    -maccatalyst and -windows. A net11.0-tizen11.0 project therefore resolves the
    neutral assembly, where every handler interface types PlatformView as object.
    The CS9333 alias mismatch that motivated the backend-only interfaces cannot
    occur. Confirmed by implementing IButtonHandler explicitly: it compiled.
  * ImageSourcePartLoader is public, which is what unblocked IButtonHandler.
  * Chaining MAUI's static mapper is LIVE, not a snapshot: a mapper built before
    Controls' static constructor runs still picks up the remap. Measured.
  * All 39 chained Button mappings dispatch against a backend handler with zero
    InvalidCastException. The hard-cast concern is real but satisfied.

So: all fourteen handlers now implement MAUI's real IXHandler, the backend-only
interfaces are gone, and each mapper is composed in three layers - MAUI's static
mapper (carrying Controls' remaps), then the Tizen view mappings over its no-op
bodies, then the handler's own keys, which must win because Entry.Background has
to re-evaluate the container before painting.

Parity is now measured against Controls rather than Core. The test project
references Microsoft.Maui.Controls.Core and forces the static remaps before
reading any mapper; measuring beforehand reported a parity the backend did not
have. The generated matrix gained a third state as a direct consequence:
`inherited` (resolves through the chain, but the body is MAUI's off-platform
no-op, so nothing happens) as distinct from `tizen`. Chaining makes every key
resolve, so a table reporting presence alone would have claimed total parity
while most properties did nothing.

CheckBox.Color added - it exists only after Controls remaps. Label's Controls
keys (FormattedText, TextType, LineBreakMode, MaxLines, TextTransform) are NOT
addressed here: TizenLabelHandler belongs to the core slice and still chains the
Tizen base mapper. Reported rather than edited, to avoid conflicting with an
in-flight file; the parity test deliberately excludes it with that reason stated.

Two new guards, both for a bug class that has now bitten three times. A mapping
declared as Map(TizenXHandler, ...) cannot satisfy Action<IXHandler, ...>, and a
mapping declared inside #if TIZEN does not exist off-platform - in both cases the
name silently binds to MAUI's INHERITED no-op instead. It compiles, and behaviour
differs by target framework. Found this time in TizenEntryHandler.MapBackground
and TizenEditorHandler.MapBackground by the new tests, not by reading.

Also adds an expiry test for the internal ImageSourcePaint gap. The workaround
degrades honestly rather than reflecting over MAUI internals; dotnet/maui#37864
adds a public IImageSourcePaint, and the test asserts the gap still exists so it
FAILS when the contract ships. A workaround that keeps working after its
justification disappears is otherwise invisible.

Not included: the rebase onto core removing its ITizen* shims. Core head ac3dba6
still ships ITizenHandlers.cs, so that remains outstanding. Items 1-3 touch only
Wave A files and do not conflict with it.

Validation: ref-pack and unit lanes clean under CI semantics, 694 tests pass
(full suite run five times to rule out order dependence from the global mapper
mutation), eng/build-workload-free.sh green.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@Redth
Redth temporarily deployed to copilot-pat-pool August 26, 2026 23:50 — with GitHub Actions Inactive
@github-actions github-actions Bot added area-image Image loading, sources, caching platform/ios platform/macos macOS / Mac Catalyst labels Aug 26, 2026
@Redth
Redth temporarily deployed to copilot-pat-pool August 26, 2026 23:51 — with GitHub Actions Inactive

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds a minimal, additive public contract (Microsoft.Maui.IImageSourcePaint) so out-of-tree platform backends can reliably detect image-based backgrounds and access the underlying IImageSource, without reflection or InternalsVisibleTo, while keeping the built-in ImageSourcePaint implementation internal.

Changes:

  • Introduces the new public IImageSourcePaint interface in Core and has internal ImageSourcePaint implement it.
  • Updates in-tree background handling (handlers + platform extensions) and Controls Brush/Paint conversions to pattern-match the interface instead of the internal concrete type.
  • Adds Core + Controls unit tests and updates PublicAPI baselines across all TFMs.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/Core/src/ImageSources/IImageSourcePaint.cs Adds the new public interface contract and docs.
src/Core/src/ImageSources/ImageSourcePaint.cs Implements IImageSourcePaint on the internal paint type.
src/Core/src/Handlers/View/ViewHandler.cs Updates background mapping to detect image paints via the interface.
src/Core/src/Handlers/Entry/EntryHandler.iOS.cs Uses IImageSourcePaint for iOS background mapping.
src/Core/src/Handlers/Editor/EditorHandler.iOS.cs Uses IImageSourcePaint for iOS background mapping.
src/Core/src/Platform/iOS/PageExtensions.cs Updates iOS page background logic to match on the interface.
src/Core/src/Platform/Tizen/ViewExtensions.cs Updates Tizen background logic to match on the interface.
src/Controls/src/Core/Brush/Brush.cs Enables Brush conversion from any IImageSourcePaint (including external implementations).
src/Core/tests/UnitTests/ImageSource/ImageSourcePaintContractTests.cs Adds tests proving contract visibility + external-backend usage shape.
src/Controls/tests/Core.UnitTests/ImageBrushTests.cs Adds tests validating ImageBrush/paint round-tripping and external paint conversion.
src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt Adds new public API entries for IImageSourcePaint.
src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt Adds new public API entries for IImageSourcePaint.
src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt Adds new public API entries for IImageSourcePaint.
src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt Adds new public API entries for IImageSourcePaint.
src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt Adds new public API entries for IImageSourcePaint.
src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt Adds new public API entries for IImageSourcePaint.
src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt Adds new public API entries for IImageSourcePaint.
src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt Adds new public API entries for IImageSourcePaint.

Comment on lines +138 to +151
public static (PaintKind Kind, IImageSource ImageSource) Describe(IView view)
{
switch (view.Background)
{
case IImageSourcePaint imagePaint:
return (PaintKind.Image, imagePaint.ImageSource);
case GradientPaint:
return (PaintKind.Gradient, null);
case SolidPaint:
return (PaintKind.Solid, null);
default:
return (PaintKind.None, null);
}
}
Redth pushed a commit to Redth/Maui.Tizen that referenced this pull request Aug 27, 2026
Architecture review found the backend was not actually substitutable for MAUI's
own handlers: it owned a parallel ITizen*Handler hierarchy and chained only the
Tizen base mapper, so MAUI Controls' RemapForControls dispatch never reached it.

Every claim the redesign rests on was verified against the pinned package
(11.0.0-preview.7.26426.4) before any code changed, because the previous design
was justified by an objection that turned out to be obsolete:

  * Microsoft.Maui.Core ships NO Tizen asset - only net11.0, -android, -ios,
    -maccatalyst and -windows. A net11.0-tizen11.0 project therefore resolves the
    neutral assembly, where every handler interface types PlatformView as object.
    The CS9333 alias mismatch that motivated the backend-only interfaces cannot
    occur. Confirmed by implementing IButtonHandler explicitly: it compiled.
  * ImageSourcePartLoader is public, which is what unblocked IButtonHandler.
  * Chaining MAUI's static mapper is LIVE, not a snapshot: a mapper built before
    Controls' static constructor runs still picks up the remap. Measured.
  * All 39 chained Button mappings dispatch against a backend handler with zero
    InvalidCastException. The hard-cast concern is real but satisfied.

So: all fourteen handlers now implement MAUI's real IXHandler, the backend-only
interfaces are gone, and each mapper is composed in three layers - MAUI's static
mapper (carrying Controls' remaps), then the Tizen view mappings over its no-op
bodies, then the handler's own keys, which must win because Entry.Background has
to re-evaluate the container before painting.

Parity is now measured against Controls rather than Core. The test project
references Microsoft.Maui.Controls.Core and forces the static remaps before
reading any mapper; measuring beforehand reported a parity the backend did not
have. The generated matrix gained a third state as a direct consequence:
`inherited` (resolves through the chain, but the body is MAUI's off-platform
no-op, so nothing happens) as distinct from `tizen`. Chaining makes every key
resolve, so a table reporting presence alone would have claimed total parity
while most properties did nothing.

CheckBox.Color added - it exists only after Controls remaps. Label's Controls
keys (FormattedText, TextType, LineBreakMode, MaxLines, TextTransform) are NOT
addressed here: TizenLabelHandler belongs to the core slice and still chains the
Tizen base mapper. Reported rather than edited, to avoid conflicting with an
in-flight file; the parity test deliberately excludes it with that reason stated.

Two new guards, both for a bug class that has now bitten three times. A mapping
declared as Map(TizenXHandler, ...) cannot satisfy Action<IXHandler, ...>, and a
mapping declared inside #if TIZEN does not exist off-platform - in both cases the
name silently binds to MAUI's INHERITED no-op instead. It compiles, and behaviour
differs by target framework. Found this time in TizenEntryHandler.MapBackground
and TizenEditorHandler.MapBackground by the new tests, not by reading.

Also adds an expiry test for the internal ImageSourcePaint gap. The workaround
degrades honestly rather than reflecting over MAUI internals; dotnet/maui#37864
adds a public IImageSourcePaint, and the test asserts the gap still exists so it
FAILS when the contract ships. A workaround that keeps working after its
justification disappears is otherwise invisible.

Not included: the rebase onto core removing its ITizen* shims. Core head ac3dba6
still ships ITizenHandlers.cs, so that remains outstanding. Items 1-3 touch only
Wave A files and do not conflict with it.

Validation: ref-pack and unit lanes clean under CI semantics, 694 tests pass
(full suite run five times to rule out order dependence from the global mapper
mutation), eng/build-workload-free.sh green.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Addresses review feedback that the original change over-promised support
for third-party implementations of the new interface.

The public read contract is unchanged and still solves the blocker: an
external backend can pattern match `IView.Background` and read the image
source with no reflection and no internals access.

What changed:

- Document the interface as consumption-only. Implementing it outside of
  .NET MAUI is explicitly unsupported, and .NET MAUI reserves the right to
  add members in future releases.
- Revert the in-tree pattern matches back to the internal `ImageSourcePaint`.
  Custom paints were never reliably honored: specialized handlers such as
  `LayoutHandler.MapBackground` call `UpdateBackground` directly and bypass
  the image-source path, and `Brush`'s paint-to-brush conversion only
  round-trips an `IImageSource` that is a Controls `ImageSource`. Matching
  the concrete type keeps behavior provably identical to before, making this
  change purely additive.
- Drop the tests asserting that externally-authored paints implementing the
  interface are supported. The remaining tests prove a fake external backend
  can pattern match and read MAUI's built-in internal image paint through
  public API only, without implementing the interface itself.
- Document that values arise as a `Paint` from `IView.Background`, that a
  null `ImageSource` means an image background with nothing to draw, and how
  this differs from `Microsoft.Maui.Graphics.ImagePaint`, which carries an
  already-loaded `IImage`. Both are covered by new tests.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5ec0bf62-da2c-49fb-af27-a0a79cbbd6bb
Copilot AI review requested due to automatic review settings August 27, 2026 00:13
@Redth

Redth commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

Updated — requesting fresh code + API review

Pushed 329bb7de addressing review feedback that the original change over-promised third-party implementation support. Verified both concerns before acting:

  • Specialized handlers bypass the image path — confirmed. LayoutHandler.MapBackground on iOS (LayoutHandler.iOS.cs#L154) and Android (LayoutHandler.Android.cs#L138) call platformView.UpdateBackground(layout) directly and never check for an image paint.
  • Arbitrary IImageSource brush conversion is not preserved — confirmed. Brush's paint→brush conversion requires imageSourcePaint.ImageSource is ImageSource (the Controls type), so an arbitrary IImageSource yields null.

What changed

  1. Contract is now explicitly consumption-only. XML docs state that implementing it outside .NET MAUI is unsupported, that .NET MAUI reserves the right to add members, and that custom paints are not guaranteed to be honored because not every handler routes through the image-source path.
  2. Reverted in-tree pattern matches to the concrete internal ImageSourcePaint. This makes the diff purely additive with provably zero behavior change, rather than creating partial support for a scenario we document as unsupported.
  3. Removed the tests and PR language claiming custom paints are supported. The remaining tests prove a fake external backend can pattern match and read MAUI's built-in internal paint through public API only — it never implements the interface.
  4. Documented the semantics that were missing, each with a test:
    • usable values arise as a Paint via IView.Background;
    • null ImageSource = image background with nothing to draw → clear any prior image;
    • distinct from Microsoft.Maui.Graphics.ImagePaint, which carries an already-loaded IImage rather than an unresolved IImageSource.

The public surface is unchanged from the first push — still one interface with one read-only getter.

Re-validation

Check Result
Core.UnitTests (net11.0) 958 passed, 0 failed
Controls.Core.UnitTests (net11.0) 6210 passed, 0 failed
Core.csproj all TFMs (netstandard2.0/2.1, net11.0, ios, maccatalyst, android) 0 warnings, 0 errors — PublicAPI validated
External read-only consumer vs. this branch's ref assembly compiles clean (previously CS0122)
dotnet format --verify-no-changes clean

Fresh code and API review appreciated — particularly on the consumption-only framing and whether the PublicAPI.Unshipped.txt entries should carry any additional annotation.

Redth added a commit to Redth/Maui.Tizen that referenced this pull request Aug 27, 2026
Key presence is cheap to satisfy and proves little, so dispatch the
Controls-remapped properties and the focus commands through the real
composed mappers on real handlers bound to real Controls views. Each
dispatch first asserts the key actually resolves, otherwise UpdateValue on
an unknown key is a silent no-op and the test would pass vacuously -
verified by removing TizenLabelHandler's chain and watching it fail.

TizenLabelHandler is no longer excluded from the reachability theory. Core
fixed it in f90ba12: FormattedText, TextType, LineBreakMode, MaxLines and
TextTransform all resolve now, with no cast failures, so the exclusion note
was stale.

Upstream dotnet/maui#37864 is still open, so the image-background
workaround stays. Mark the exact adoption point in UpdateBackground instead,
with the image-first ordering the fix requires spelled out, and have the
expiry test report the shipped shape of IImageSourcePaint.ImageSource so the
adopter learns in one run whether the planned pattern still applies.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8b0524d9-c874-4468-bff6-d21f31c772de

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/Core/tests/UnitTests/ImageSource/ImageSourcePaintContractTests.cs:136

  • The tuple return type declares ImageSource as non-nullable IImageSource, but this method returns null for non-image paints and IImageSourcePaint.ImageSource is nullable. This creates a nullability mismatch (and can introduce warnings or incorrect assumptions by callers).
			public static (PaintKind Kind, IImageSource ImageSource) Describe(IView view)

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

Labels

area-image Image loading, sources, caching platform/ios platform/macos macOS / Mac Catalyst

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants