Remove all usage of .NET MAUI internal APIs - #3273
Conversation
.NET MAUI is removing the `InternalsVisibleTo` grants it currently gives the .NET MAUI Community Toolkit (dotnet/maui#34070). Once that lands, every place where the Toolkit reaches into a `internal` MAUI API stops compiling, and any already-shipped binary that binds to one throws `MethodAccessException` / `TypeAccessException` at runtime. This removes the last six of them so the Toolkit no longer depends on the grant at all. Android - `Microsoft.Maui.ApplicationModel.IntermediateActivity` (internal type), used by `FileSaver` and `FolderPicker`, is replaced by a Toolkit-owned `ActivityResultManager` built on AndroidX `ActivityResultRegistry`. The three-argument `Register` overload takes no `ILifecycleOwner`, so it is safe to call on demand, and it needs no manifest entry. Result semantics match the previous behaviour exactly: `Result.Canceled` cancels the task, anything else invokes `onResult` and completes it. - `Microsoft.Maui.Platform.ElementExtensions.ToPlatform(IElement)` (the one-argument internal overload) in `Snackbar` is replaced by reading `Handler.PlatformView` directly. If the modal page has no platform view yet, `Snackbar` now falls back to the content view instead of throwing. - `Microsoft.Maui.Platform.ViewExtensions.GetParentOfType<T>` and `Microsoft.Maui.JavaObjectExtensions.IsAlive` / `.IsDisposed` are replaced by Toolkit-owned equivalents in `PlatformViewExtensions.android.cs`. All platforms - `UserStoppedTypingBehavior` called `DispatchIfRequiredAsync` as an extension method. The Toolkit already ships its own `DispatcherExtensions.DispatchIfRequiredAsync(IDispatcher, Action, CancellationToken = default)`, but MAUI's internal `(IDispatcher, Action)` overload is a better match for overload resolution, so the call silently bound to MAUI's internal one. It is now called explicitly through the Toolkit's own class. This one affected every target, including Windows and iOS. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5806fd1d-038a-4d53-8ef1-ad41f456e474
There was a problem hiding this comment.
Pull request overview
This PR removes remaining dependencies on .NET MAUI internal APIs in the CommunityToolkit, ensuring the Toolkit continues to function once MAUI removes its InternalsVisibleTo grants.
Changes:
- Introduces a Toolkit-owned Android
ActivityResultManagerbased on AndroidXActivityResultRegistry, replacing MAUI’s internalIntermediateActivityfor FolderPicker/FileSaver. - Replaces internal MAUI Android helpers (
GetParentOfType,IsAlive,IsDisposed) with Toolkit-owned equivalents inPlatformViewExtensions.android.cs, and updates Android call sites. - Prevents accidental binding to MAUI’s internal
DispatcherExtensions.DispatchIfRequiredAsyncby calling the Toolkit dispatcher extension explicitly fromUserStoppedTypingBehavior.
Show a summary per file
| File | Description |
|---|---|
| src/CommunityToolkit.Maui/Extensions/PlatformViewExtensions.android.cs | Adds Toolkit-owned Android helper extensions replacing prior MAUI internal helpers. |
| src/CommunityToolkit.Maui/Behaviors/UserStoppedTypingBehavior.shared.cs | Forces binding to Toolkit dispatcher extensions to avoid MAUI internal overload resolution. |
| src/CommunityToolkit.Maui/Behaviors/PlatformBehaviors/Touch/TouchBehavior.android.cs | Switches TouchBehavior to use Toolkit-owned Android helper extensions. |
| src/CommunityToolkit.Maui/Alerts/Snackbar/Snackbar.android.cs | Replaces internal ToPlatform(IElement) path with Handler.PlatformView for modal-page retrieval. |
| src/CommunityToolkit.Maui.Core/Essentials/FolderPicker/FolderPickerImplementation.android.cs | Routes folder picking through ActivityResultManager instead of MAUI IntermediateActivity. |
| src/CommunityToolkit.Maui.Core/Essentials/FileSaver/FileSaverImplementation.android.cs | Routes file saving through ActivityResultManager instead of MAUI IntermediateActivity. |
| src/CommunityToolkit.Maui.Core/Essentials/ActivityResultManager.android.cs | Adds new AndroidX-based activity-result implementation to replace MAUI internal infrastructure. |
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Low
…nd honour CancellationToken Two follow-ups from CI and review. 1. CI builds against a shipped .NET 10 MAUI, which still grants us `InternalsVisibleTo`. That makes MAUI's internal `GetParentOfType`, `IsAlive` and `IsDisposed` visible at the same time as the Toolkit-owned replacements added in the previous commit, so every call site became ambiguous (CS0121). The Toolkit helpers are renamed to `FindParentOfType`, `IsPeerAlive` and `IsPeerDisposed` so they cannot collide with MAUI's, whether or not the grant is present. Verified both ways: against MAUI with the grant intact and against the dotnet/maui#34070 build with it removed. 2. `ActivityResultManager.StartAsync` now takes a `CancellationToken`. Previously callers wrapped it in `WaitAsync(cancellationToken)`, so a cancelled token left the launcher registered and the callback rooted until the user finally dismissed the picker, and `onResult` could still run after the caller had given up. Cancelling now unregisters and disposes the launcher and completes the task as cancelled. `FileSaver` and `FolderPicker` pass their token straight through instead of using `WaitAsync`. Release is idempotent via `Interlocked.Exchange` and marshals to the UI thread, since the result callback and a cancellation can race. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5806fd1d-038a-4d53-8ef1-ad41f456e474
CI is green (except one pre-existing failure) + full re-verification after
|
| MAUI internals visible? | Consequence | |
|---|---|---|
| .NET 10 MAUI (shipped — what CI builds against) | Yes, the InternalsVisibleTo grants are still there |
MAUI's internal extension methods are bindable, so anything I name identically is ambiguous |
| .NET 11 MAUI (dotnet/maui#34070) | No, grants removed | Only the toolkit's own helpers are visible |
I'd verified against the IVT-removed build only, so the collision was invisible to me. Fixed by giving the toolkit-owned helpers names that can't collide in either world:
| Was | Now |
|---|---|
GetParentOfType<T> |
FindParentOfType<T> |
IsAlive |
IsPeerAlive |
IsDisposed |
IsPeerDisposed |
The reason for the deliberate naming is documented in the <remarks> on PlatformViewExtensions.android.cs so it doesn't get "tidied up" later. I now build both worlds as a gate on every change.
Review feedback
Copilot's comment about StartAsync leaking the launcher on caller cancellation was valid and is fixed — details in the thread. StartAsync now takes a CancellationToken, releases the launcher exactly once (Interlocked.Exchange, marshalled via RunOnUiThread), and both call sites pass their token through instead of .WaitAsync(...).
CI
| Job | Result |
|---|---|
| Build Library (macos-26) | ✅ pass |
| Build Library (windows-latest) | ✅ pass |
| Build Sample App (macos-26) | ✅ pass |
| Build Sample App (windows-latest) | ❌ pre-existing |
The Windows sample-app failure is PRI175 / PRI277 (Conflicting values for resource 'Files/Microsoft.Maui/Platform/Windows/Styles/Resources.xbf') — a WinAppSDK duplicate-resource issue in the sample, not the library. It reproduces byte-identically on main (run 30027765216), where Build Library (windows-latest) also passes. Not caused by this PR.
Re-verification after the fix
Everything below was re-run from scratch on commit 55cc80c5.
Static — IL scan (Mono.Cecil, resolves every memberref by full signature) against MAUI with the IVT grants actually removed:
| Target | Findings |
|---|---|
Android (android-arm64) |
0 |
| Mac Catalyst | 0 |
| iOS | 0 |
| Windows | 0 |
| net11.0 shared | 0 |
| Android built with IVT present (the shipping config) | 0 |
Runtime — automated walk over all 99 sample pages:
| Platform | Result |
|---|---|
| Android (API 36 emulator) | 97 OK, 0 MethodAccess/FieldAccess/TypeAccess |
| Mac Catalyst | 99 visited, 94 OK, 0 MethodAccess/FieldAccess/TypeAccess |
Non-OK pages are identical to the pre-change baseline and unrelated to this PR: StatusBarBehaviorPage (NotSupportedException, by design on Catalyst), IsInRangeConverterPage (Invalid getter), StateContainerPage (InvalidCastException), and two MediaElement pages timing out on media load.
Runtime — targeted probes (the page walk only navigates, so these exercise the actual changed code paths):
| Scenario | Result |
|---|---|
FolderPicker — user cancels |
TaskCanceledException |
FolderPicker — user picks |
success=True path=/storage/emulated/0/Documents |
FolderPicker — token cancelled while picker on screen |
completed as TaskCanceledException at 6.0s, i.e. immediately on cancel rather than waiting for dismissal |
FileSaver — save |
success=True, file on disk with the expected contents |
Snackbar + snackbar over a modal page |
shown & dismissed (Android + Catalyst) |
UserStoppedTypingBehavior |
command fired (Android + Catalyst) |
TouchBehavior attach |
no exception |
bijington
left a comment
There was a problem hiding this comment.
Looks good to me! I think we need to chase up this pipeline build issue but that isn't caused by this PR.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52915014-21c8-4344-aba2-a4d5f660ce69
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52915014-21c8-4344-aba2-a4d5f660ce69
Final internal-API audit — commit
|
Description of Change
.NET MAUI is removing the
InternalsVisibleTogrants it currently gives this repo — dotnet/maui#34070. This PR removes every place where the Toolkit reaches into a MAUIinternalAPI, so we no longer depend on that grant.I verified dotnet/maui#34070 against Toolkit
15.0.0end to end (99-page sample walk on Android + Mac Catalyst, plus a signature-accurate IL scan of the shipped 15.0.0 binaries against the MAUI assemblies produced by that PR). That produced a definitive list of 6 internal APIs we still use. This PR removes all 6.What was still using MAUI internals
Microsoft.Maui.ApplicationModel.IntermediateActivity(internal type)FileSaverImplementation.android.cs,FolderPickerImplementation.android.csMicrosoft.Maui.Platform.ElementExtensions.ToPlatform(IElement)(1-arg internal overload)Snackbar.android.cs→TryGetPageActiveModalPageMicrosoft.Maui.Platform.ViewExtensions.GetParentOfType<T>TouchBehavior.android.cs→OnAttachedToMicrosoft.Maui.JavaObjectExtensions.IsAliveTouchBehavior.android.cs→UpdateClickHandlerMicrosoft.Maui.JavaObjectExtensions.IsDisposedSnackbar.android.cs→DismissPlatformMicrosoft.Maui.Controls.DispatcherExtensions.DispatchIfRequiredAsyncUserStoppedTypingBehavior.shared.csGood news: iOS, Mac Catalyst and Windows had no platform-specific internal usage at all. The only thing hitting them was #6.
How each one is fixed
#1 —
IntermediateActivity→ Toolkit-ownedActivityResultManagerNew
src/CommunityToolkit.Maui.Core/Essentials/ActivityResultManager.android.cs, built on AndroidXActivityResultRegistry:The three-argument
Registeroverload deliberately takes noILifecycleOwner, so unlikeRegisterForActivityResultit has no "must be called beforeSTARTED" restriction and can be used on demand — which is exactly what an on-demand picker needs. It also needs no manifest entry and noonSaveInstanceStateplumbing, so it is a lot less machinery than the activity it replaces.Result semantics are a deliberate 1:1 match with MAUI's
IntermediateActivity:Result.Canceled→TrySetCanceled()(so callers still seeTaskCanceledException)data ??= new Intent(), invokeonResult,TrySetResultThe launcher is unregistered and disposed as soon as the result arrives, and also if
Launchthrows.#2 —
ToPlatform→Handler.PlatformViewSlight behaviour improvement: if the modal page has no platform view yet, we now return
falseandGetParentView()falls back to the activity content view, instead ofToPlatform()throwing.#3/#4/#5 — Toolkit-owned Android helpers
New
src/CommunityToolkit.Maui/Extensions/PlatformViewExtensions.android.cswithFindParentOfType<T>,IsPeerAliveandIsPeerDisposed.FindParentOfType<T>keeps MAUI's semantics (returns the view itself when it already matches, then walks up the parent chain).Important
The names deliberately differ from MAUI's. The toolkit has to compile against two different MAUI worlds: shipped .NET 10 MAUI, where the
InternalsVisibleTogrants are still present and MAUI's internal extension methods are therefore bindable, and .NET 11 MAUI, where they're gone. Naming ours identically makes every call siteCS0121ambiguous in the first world — which is exactly how CI failed on the first push here. The reasoning is captured in the<remarks>on the new file so it doesn't get "tidied up" later.#6 —
DispatchIfRequiredAsync(this is the sneaky one)We already ship
CommunityToolkit.Maui.Core.Extensions.DispatcherExtensions.DispatchIfRequiredAsync(IDispatcher, Action, CancellationToken = default), andUserStoppedTypingBehavioralready hadusing CommunityToolkit.Maui.Core.Extensions;. But MAUI's internal overload is(IDispatcher?, Action)— exactly two parameters — so C# overload resolution prefers MAUI's over ours, because ours needs an optional argument filled in.The result is that this one is invisible in a source build: recompiling just silently rebinds to our own method and everything looks fine. But shipped
15.0.0binaries carry a memberref to MAUI's internal method, so they throwMethodAccessExceptionat JIT time once the IVT grant is gone. It is now called explicitly through our own class so the binding can't drift again:Verification
Everything below was run against a private MAUI build produced by dotnet/maui#34070 — i.e. with the
InternalsVisibleTogrants actually removed.Compile — clean on every target that can be built on macOS:
net11.0-androidnet11.0-iosnet11.0-maccatalystnet11.0Also re-run with this repo's real
.editorconfigandWarningsAsErrorsrestored (my harness had them relaxed): zero diagnostics from any of the changed files.IL scan — I wrote a Mono.Cecil-based analyzer that resolves every memberref/typeref/fieldref by full signature (name-only matching is exactly why the 1-arg
ToPlatformoverload is easy to miss) and reports unresolved references separately so a missing reference assembly can't be mistaken for a clean result. Scanned the rebuilt assemblies against MAUI reference assemblies for all five platforms:Windows can't be compiled on macOS, so it is covered by the IL scan only — but its single finding was #6, which lives in shared code, and the shared assembly now scans clean. The scan also covers
CommunityToolkit.Maui.Camera,.Mapsand.MediaElement: all clean.Runtime — sample app, automated walk over all 99 pages:
Both match the pre-PR baseline exactly. The 5 Mac Catalyst failures are pre-existing and unrelated (
StatusBarBehaviorPageNotSupportedExceptionby design,IsInRangeConverterPageinvalid getter,StateContainerPageinvalid cast, and twoMediaElementmedia-load timeouts).Because a page walk only navigates, I also drove the affected features directly — this matters most for
ActivityResultManager, which the walk would never have exercised:FolderPicker— cancel (Back out of SAF)TaskCanceledException, matchingIntermediateActivityFolderPicker— pick a foldersuccess=True path=/storage/emulated/0/DocumentsFileSaver— save a filesuccess=True, file present on disk with correct contentsSnackbarshow + dismissSnackbarover a pushed modal page (theToPlatformpath)TouchBehaviorattach (FindParentOfType+IsPeerAlive)TouchBehaviorPage,TouchBehaviorCollectionViewMultipleSelectionPageUserStoppedTypingBehavior(DispatchIfRequiredAsync)FolderPicker— caller'sCancellationTokenfires while the picker is on screenTaskCanceledExceptionat the cancel instant, launcher unregisteredCI
Build Librarypasses on both macOS and Windows, as doesBuild Sample App (macos-26).Build Sample App (windows-latest)fails withPRI175/PRI277(Conflicting values for resource 'Files/Microsoft.Maui/Platform/Windows/Styles/Resources.xbf') — a WinAppSDK duplicate-resource problem in the sample app, not the library. It reproduces byte-identically onmain(run 30027765216). Pre-existing, not caused by this PR.Unrelated heads-up
Building against the .NET 11 SDK surfaces pre-existing
CA2016errors inFadeAnimation.shared.csandGestureManager.shared.cs(CancellationTokennot forwarded). Untouched by this PR and nothing to do with internal APIs, but they'll need fixing before this repo moves to .NET 11.CommunityToolkit.Maui.MarkupWorth flagging separately:
Markup7.0.1 has 45 references to MAUI internals and hard-crashes on startup once the grant is removed. 8.0.0 is already clean — no action needed here, but anyone on 7.0.1 must upgrade.Linked Issues
PR Checklist
mainat time of PRcc @TheCodeTraveler @PureWeen @kubaflo