Skip to content

refactor(android): Make Skia-Android multi-window-ready - #24042

Draft
MartinZikmund wants to merge 9 commits into
feature/breakingchangesfrom
dev/mazi/androidunsingleton
Draft

refactor(android): Make Skia-Android multi-window-ready#24042
MartinZikmund wants to merge 9 commits into
feature/breakingchangesfrom
dev/mazi/androidunsingleton

Conversation

@MartinZikmund

@MartinZikmund MartinZikmund commented Aug 11, 2026

Copy link
Copy Markdown
Member

GitHub Issue: #8341

PR Type:

🔄 Refactoring (no functional changes, no api changes)

Review status: an eight-lens review panel found two blockers and eight high findings on the
first pass; those are fixed in the commits following the initial series. Remaining medium/low
findings are listed at the bottom as follow-ups. Still draft pending the on-device pass.

What changed? 🚀

Skia-on-Android baked "one window" into three process-wide static anchors. This PR
de-singletons them so Android converges onto the same per-window ownership pattern the
Skia desktop runtimes (Win32/X11/macOS) and the Apple UIKit runtime already use — making
the architecture multi-window-ready.

SupportsMultipleWindows deliberately stays false. The definition of done here is
per-window instances everywhere, and zero single-window regressions — not a live second window.

The three anchors that went away

  1. NativeWindowWrapper.Instance — was a process-wide Lazy<> singleton returned by
    AndroidSkiaWindowFactory.CreateWindow for every window. It is now an instance bound to
    its window, tracking the activity currently driving it (CurrentActivity), since the managed
    Window outlives individual activities across re-creation.
  2. ApplicationActivity.Instance + the static render stack (render view, native-layer host,
    root layout) — now per-activity instance state. A new activity rebuilds its surface and
    re-attaches on re-creation.
  3. ContextHelper.Current — split into an explicit app-global ApplicationContext and an
    activity-scoped Current that tracks the foreground activity via the existing BaseActivity
    registry, instead of today's sticky "last-ever-set" behaviour.

Supporting changes

  • AndroidSkiaXamlRootHost is per-window: it resolves its own RootElement and driving
    activity, and registers in XamlRootMap so consumers (native element hosting, TextBox
    notifications, IME) resolve the owning activity from a XamlRoot rather than a global.
  • Per-window input sources: pointer and keyboard sources are owned by the window's wrapper and
    registered via ApiExtensibility.Register<IXamlRootHost>(…), matching the Win32 runtime. Each
    window's InputManager resolves its own sources through its host.
  • Lifecycle correctness: the managed window's Closing is raised only when the activity is
    actually finishing, so a configuration-change re-creation no longer spuriously closes the
    surviving window.
  • Render-frame null guards: UnoSKCanvasView now skips a frame when the window's
    root/composition target isn't ready (e.g. mid-teardown during activity re-creation), matching
    the Vulkan backend, instead of dereferencing with !.

API surface

Not additive — this PR contains two deliberate public-surface breaks, both recorded in
doc/articles/migrating-to-uno-7.md and build/PackageDiffIgnore.xml:

  • Uno.UI.ContextHelper.Current is re-typed Android.Content.Context?. It could always be
    null before any activity exists, but was annotated non-null and returned _current!, so the
    compiler never surfaced it. Typing it honestly changed no runtime behaviour and immediately
    exposed 13 unguarded dereferences in Uno.UWP. Its semantics also change: it now tracks the
    foreground activity rather than the last one ever assigned.
  • Uno.UI.OnSystemUiVisibilityChangeListener is narrowed to internal. It is constructed by
    the host with the activity owning the window; app code had no way to supply one.

ContextHelper.ApplicationContext is new and additive. Everything else in this PR is internal.

Design notes

specs/053-android-multiwindow-ready/plan.md documents the architecture, the phase breakdown,
and the deliberate follow-ups — chiefly flipping SupportsMultipleWindows to true (needs
Activity↔Window lifecycle orchestration and on-device validation, mirroring how iOS staged its
own multi-window behind scene adoption) and threading an explicit owning-window Context through
the remaining ambient ContextHelper.Current consumers.

Validation

  • Compile: Uno.UI.Runtime.Skia.Android builds clean for net10.0-android
    (0 errors; the 2 XA0101 warnings are pre-existing and unrelated). This is the only assembly
    that compiles the __ANDROID__ Skia code.
  • Runtime: not executed — no emulator/device was reachable in the environment used for
    this change. Reviewers/CI should smoke-test single-window behaviour:
    cd src/SamplesApp/SamplesApp.Skia.netcoremobile/Android && dotnet run -f net10.0-android,
    exercising launch/render, touch + text input (soft keyboard, IME composition), rotation,
    background→foreground, and a config-change re-creation (system font-size/locale change).

Kept as a draft until that on-device pass is done.

PR Checklist ✅

  • 🧪 Added Runtime tests, UI tests, or a manual test sample (for bug fixes / features, if applicable) — Given_AndroidSkiaXamlRootHost runs on the Android Skia CI lane and asserts host registration, activity resolution and per-window input-source identity. The foreground-repoint logic itself lives on BaseActivity : AppCompatActivity, which the net-based Uno.UI.UnitTests cannot instantiate.
  • 📚 Docs have been added/updated following the documentation template (for bug fixes / features) — the two public-surface breaks are recorded in migrating-to-uno-7.md; the in-repo spec covers the architecture.
  • 🖼️ Validated PR Screenshots Compare Test Run results — pending CI.
  • ❗ Contains NO breaking changes — it does; see the API surface section above. Both are deliberate and recorded.
  • 👀 Reviewed 2 other open pull requests (optional but appreciated!)

Known follow-ups

Carried from the review panel, deliberately not in this PR:

  • The wrapper is per-activity, not strictly per-Window — the activity still adopts it through
    the ambient current window. An explicit activity⇄window binding needs the lifecycle
    orchestration that lands with the live second activity. Marked TODO #13827.
  • ContextHelper.ApplicationContext still has no consumers; migrating the app-scoped
    Uno.UWP callers off Current is the documented follow-up (several read Resources,
    WindowManager or assembly identity, where the two contexts are not interchangeable).
  • Diagnosability: the zero-size bail-outs in NativeWindowWrapper and the new lifecycle
    branches emit no trace; OnDrawFrame lacks the try/catch its Vulkan sibling has.
  • GetWindowSize dereferences WindowManager?.CurrentWindowMetrics unconditionally.
  • _contentViewAttachedToWindow survives re-creation, so a new activity's first pre-draw
    short-circuits; the fix must move the ContentViewAttachedToWindow subscription out of the
    WasShown-gated ShowCore rather than just resetting the flag.
  • BaseActivity prunes _instances in Dispose rather than OnDestroy, so the foreground
    repoint can select an already-destroyed activity.
  • Pre-existing: TextInputPlugin.NotifyValueChanged logs full text at Debug, and
    PasswordBox : TextBox, so passwords can reach logcat.

@github-actions github-actions Bot added platform/android 🤖 Categorizes an issue or PR as relevant to the Android platform area/skia ✏️ Categorizes an issue or PR as relevant to Skia kind/documentation labels Aug 11, 2026
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Claude finished @MartinZikmund's task in 9m 24s —— View job


Review complete

  • Gather context and understand the PR
  • Read changed files (18 files, 530 lines of context)
  • Analyze for correctness, lifecycle, async discipline, and cross-platform concerns
  • Post inline comments for specific findings
  • Post summary

Overall verdict: fix-first (3 inline issues, 1 optional cleanup)

The architecture is sound. The three-anchor de-singletonization (NativeWindowWrapper.Instance, ApplicationActivity.Instance + static render stack, ContextHelper.Current) correctly mirrors the Win32/iOS pattern, the per-window AndroidSkiaXamlRootHost and XamlRootMap integration follow existing precedent, and the IsFinishing-gated OnNativeClosed is a correct lifecycle fix.

The draft status is appropriate — no on-device runtime validation has been done. The issues below are findable at code-review time and should be resolved before promote-to-ready.


Issues (3 inline, 1 severity each)

HIGH — Vulkan render thread null-dereference (UnoSKVulkanView.cs:220)
_activity.NativeLayerHost!.Path = nativeClipPath retains the old null-forgiving ! that was just fixed in UnoSKCanvasView (which now uses if (_activity.NativeLayerHost is { } nativeLayerHost)). The Vulkan render thread is a background thread; apply the same null guard.

MEDIUM — BaseActivity constructors set ContextHelper.Current before the activity lifecycle (BaseActivity.cs:330)
The PR changes ContextHelper.Current semantics from "sticky last-ever-set" to "foreground-activity scoped", but the constructors (lines 132/138) still call ContextHelper.Current = this before OnCreate. On a configuration-change recreation, Android constructs the new activity before calling OnPause on the old one, so the still-visible old activity loses ContextHelper.Current the moment the new one is constructed. The assignment is redundant since SetAsCurrent() (called from InnerCreate, InnerStart, InnerResume) is the right lifecycle-driven writer.

MEDIUM — ContextHelper.Current getter returns a non-nullable type but the value can be null (ContextHelper.cs:39)
The PR makes _current explicitly Android.Content.Context? and documents that Current may be null, but the getter returns via _current! with a non-nullable return type — a silent NullReferenceException trap for any caller who trusts the C# type system. Consider Android.Content.Context? as the return type (breaking, but honest) or throwing InvalidOperationException explicitly instead of using !.

LOW — Incomplete null chain in GetWindowSize (NativeWindowWrapper.Android.cs:233)
activity.WindowManager?.CurrentWindowMetrics can return null, but windowMetrics.Bounds is called unconditionally on the next line. The PR touched line 232 and is a good opportunity to add a if (windowMetrics is null) return default; guard.


Cross-cutting notes

  • The (this as ApplicationActivity)?.Wrapper pattern in BaseActivity's lifecycle partials is an existing architectural constraint (base class referencing a derived type). The null-conditional ?. is correct hardening for non-ApplicationActivity subclasses.
  • InnerDestroy's ContextHelper.SetForeground repoint logic is correct: ResignCurrent() only affects BaseActivity._current; the separate ContextHelper._current is checked after. The lock on _instances is correctly scoped.
  • No event Action/event Action<T> violations introduced. No new async void without guards. No XAML changes requiring XamlStyler.
  • Conventional Commits titles on all 6 commits are correct.


// Update the native layer host clip path
ApplicationActivity.NativeLayerHost!.Path = nativeClipPath;
_activity.NativeLayerHost!.Path = nativeClipPath;

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.

The canvas view (UnoSKCanvasView) was just fixed in this same PR to null-guard NativeLayerHost (replacing ApplicationActivity.NativeLayerHost!.Path with if (_activity.NativeLayerHost is { } nativeLayerHost)), but this Vulkan view still retains the null-forgiving !. The Vulkan render thread starts when SurfaceCreated fires, which in theory can happen before _activity.NativeLayerHost is initialized in OnStart. Apply the same guard to be consistent and safe:

if (_activity.NativeLayerHost is { } nativeLayerHost)
{
    nativeLayerHost.Path = nativeClipPath;
}

@@ -328,7 +328,23 @@ protected override void OnDestroy()

partial void InnerDestroy();

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.

The PR updates ContextHelper.Current semantics to "foreground-activity scoped", but the BaseActivity constructors (lines 132 and 138) still call ContextHelper.Current = this before the activity enters its lifecycle. An activity under construction is not yet foreground — it only becomes foreground in OnResume.

On a configuration-change recreation, Android constructs the new activity before calling OnPause on the old one. The constructor's early assignment means ContextHelper.Current re-points to the uninitialized new activity while the old activity is still the visible foreground. Code running in the old activity's OnPause teardown path (e.g. any callers of ContextHelper.Current) will silently get the wrong activity.

The assignment in both constructors is also redundant: SetAsCurrent() (called from InnerCreate, InnerStart, InnerResume) already drives ContextHelper.Current = this from the correct lifecycle points. Consider removing the constructor assignments and letting SetAsCurrent() be the sole writer.

Comment thread src/Uno.UWP/ContextHelper.cs Outdated
}

typeof(ContextHelper)
.Log()

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.

The PR makes _current explicitly Android.Content.Context? and the docs say the value "may be null before any activity is created", but the public Current property still returns Android.Content.Context (non-nullable) via the ! null-forgiveness operator. Any external caller who trusts the type signature (or uses #nullable enable) will get a silent null-dereference rather than an explicit failure.

Now that TryGetCurrent is the safe internal path, consider either:

  1. Making the public return type Android.Content.Context? (breaking change, but honest about the contract), or
  2. Throwing InvalidOperationException explicitly here instead of returning _current!, so callers get a clear message rather than a NullReferenceException at the call site.

At a minimum, the XML doc <returns> on the property should be updated to reflect that the getter can return null (or that callers should use TryGetCurrent/ApplicationContext instead).

{
var windowMetrics = (ContextHelper.Current as Activity)?.WindowManager?.CurrentWindowMetrics;
var windowMetrics = activity.WindowManager?.CurrentWindowMetrics;
displaySize = new Size(windowMetrics.Bounds.Width(), windowMetrics.Bounds.Height());

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.

The nullable access chain activity.WindowManager?.CurrentWindowMetrics can yield null (e.g. when WindowManager is null), yet windowMetrics.Bounds is called on the next line without any null check — a latent NullReferenceException. The PR touched line 232 (switching from (ContextHelper.Current as Activity)? to activity) and is a good opportunity to complete the null guard:

var windowMetrics = activity.WindowManager?.CurrentWindowMetrics;
if (windowMetrics is null) return default;
displaySize = new Size(windowMetrics.Bounds.Width(), windowMetrics.Bounds.Height());

@unodevops

Copy link
Copy Markdown
Contributor

🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-24042/docs/index.html

@unodevops

Copy link
Copy Markdown
Contributor

🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-24042/wasm-skia-net9/index.html

@nventive-devops

Copy link
Copy Markdown
Contributor

The build 226956 found UI Test snapshots differences: skia-linux-screenshots: 74, skia-windows-screenshots: 114

Details
  • skia-linux-screenshots: 74 changed over 2366

    🚨🚨 Comparison Details (first 20) 🚨🚨
    • Buttons.png-dark
    • Buttons.png
    • DynamicFont.png-dark
    • Focus_FocusVisual_Properties.png-dark
    • Focus_FocusVisual_Properties.png
    • CalendarView_Theming.png-dark
    • DoubleImageBrushInList.png-dark
    • DisplayInformation.png-dark
    • DisplayInformation.png
    • ClipboardTests.png-dark
    • ClipboardTests.png
    • ImageBrushInList.png-dark
    • ImageBrushInList.png
    • DamageRegion_ShadowChildPaintingCaster.png-dark
    • Examples.png
    • Gamepad_CurrentReading.png-dark
    • Gamepad_CurrentReading.png
    • Gamepad_Enumeration.png-dark
    • Gamepad_Enumeration.png
    • ElementLevelTheme.png-dark
  • skia-windows-screenshots: 114 changed over 2366

    🚨🚨 Comparison Details (first 20) 🚨🚨
    • Basics Pivot Test.png-dark
    • DropDownButtonPage.png-dark
    • DropDownButtonPage.png
    • Gamepad_CurrentReading.png-dark
    • Gamepad_Enumeration.png-dark
    • ClipboardTests.png-dark
    • ClipboardTests.png
    • Examples.png
    • ContentPresenter_NativeEmbedding_Android_FillType.png
    • DataTransferManager.png-dark
    • DataTransferManager.png
    • ElementLevelTheme.png-dark
    • ElementLevelTheme.png
    • ButtonClippingTestsControl.png-dark
    • DisplayInformation.png-dark
    • EmailManagerTests.png-dark
    • EmailManagerTests.png
    • ImageBrush_Formats.png-dark
    • Attributed_text_FontSize_Changing.png
    • Buttons.png-dark

@github-actions github-actions Bot added the area/build Categorizes an issue or PR as relevant to build infrastructure label Aug 11, 2026
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Claude finished @MartinZikmund's task in 8m 3s —— View job


Re-review: 4 new commits since first pass

  • Check what changed since the last review
  • Read current state of files flagged in prior review
  • Check if prior HIGH/MEDIUM issues were addressed
  • Look for new issues in new commits
  • Post inline comments for remaining unfixed findings
  • Update summary

What the 4 new commits addressed

Commit Topic Status
ba048c68 ContextHelper: stay activity-scoped (no app-context fallback), fix InnerDestroy repoint ✅ Addressed
a2e944b2 Null-guard canvas render frame (UnoSKCanvasView) ✅ Fixed
e4e7ffcd Re-arm skipped frames, TeardownRenderer, unsubscribe singletons, correct IsChangingConfigurations gate ✅ Addressed (major)
7a71a164 Type ContextHelper.Current as Android.Content.Context?; update 13 call sites with explicit ! ✅ Fixed (MEDIUM from prior review)

The e4e7ffcd changes to ApplicationActivity.OnDestroy are sound: unsubscribing _inputPane and orientationSensor from process-wide singletons was a real activity-leak on re-creation, and the !IsChangingConfigurations && ReferenceEquals(wrapper.CurrentActivity, this) gate is the correct criterion for raising OnNativeClosed.

The new Given_AndroidSkiaXamlRootHost runtime-test class is well-structured: [PlatformCondition(… SkiaAndroid)], reflection-based access (no compile-time dep on the Android assembly), and the three scenarios (host registration, activity identity, input-source stability) cover the core de-singletoning invariants.


Remaining issues (2 inline comments posted)

HIGH — Vulkan NativeLayerHost! null-forgiving (still unfixed)UnoSKVulkanView.cs:226
a2e944b2 applied the null-guard to the canvas view but not to the Vulkan view. Line 226 still reads _activity.NativeLayerHost!.Path = nativeClipPath. Lifecycle analysis confirms the ordering is safe today (surface destroyed → render thread joined → then _nativeLayerHost = null), but the ! is inconsistent with the canvas peer and silently crashes if that ordering shifts. One-line fix: same if (_activity.NativeLayerHost is { } nativeLayerHost) pattern.

LOW — GetWindowSize null chain (still unfixed)NativeWindowWrapper.Android.cs:233
windowMetrics.Bounds is called unconditionally when windowMetrics can be null. Suggestion in the inline comment.


Note on BaseActivity constructors

The constructor-level ContextHelper.Current = this (lines 132/138) is still present and was called out in the first pass. After tracing the ba048c68 intent — keep the last-activity behavior for single-window callers that hard-cast Current — the constructor assignment serves as a belt-and-suspenders to advance ContextHelper._current from the destroyed old activity to the new one as early as possible. The gap before InnerCreateSetAsCurrent is tiny, and nothing in Initialize() reads ContextHelper.Current, so this is low-risk. It is still cosmetically redundant, but fix-first priority is lower than the two inline findings above.


Verdict: fix-first (2 inline findings remain open)

The new commits are a meaningful improvement. Address the Vulkan NativeLayerHost! guard and the GetWindowSize null chain before promoting to ready.


// Update the native layer host clip path
ApplicationActivity.NativeLayerHost!.Path = nativeClipPath;
_activity.NativeLayerHost!.Path = nativeClipPath;

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.

The null-forgiving ! here was flagged in the first review pass and is still not fixed. The canvas view (UnoSKCanvasView) received the defensive guard in commit a2e944b2:

if (_activity.NativeLayerHost is { } nativeLayerHost)
{
    nativeLayerHost.Path = nativeClipPath;
}

Please apply the same pattern here. Although a lifecycle analysis shows _nativeLayerHost is non-null while the Vulkan render thread is running (it is initialized in OnStart before the surface is added to the window, and TeardownRenderer joins the thread before OnDestroy nulls it), the ! is a silent crash if that ordering ever shifts and is inconsistent with the canvas view. Both backends should use the same null-safe pattern.

Suggested change
_activity.NativeLayerHost!.Path = nativeClipPath;
if (_activity.NativeLayerHost is { } nativeLayerHost)
{
nativeLayerHost.Path = nativeClipPath;
}

{
var windowMetrics = (ContextHelper.Current as Activity)?.WindowManager?.CurrentWindowMetrics;
var windowMetrics = activity.WindowManager?.CurrentWindowMetrics;
displaySize = new Size(windowMetrics.Bounds.Width(), windowMetrics.Bounds.Height());

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.

Still unfixed from the first review pass. activity.WindowManager?.CurrentWindowMetrics can return null when WindowManager is null, but windowMetrics.Bounds is called unconditionally on the next line:

Suggested change
displaySize = new Size(windowMetrics.Bounds.Width(), windowMetrics.Bounds.Height());
var windowMetrics = activity.WindowManager?.CurrentWindowMetrics;
if (windowMetrics is null) return default;
displaySize = new Size(windowMetrics.Bounds.Width(), windowMetrics.Bounds.Height());

@unodevops

Copy link
Copy Markdown
Contributor

🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-24042/wasm-skia-net9/index.html

@unodevops

Copy link
Copy Markdown
Contributor

🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-24042/docs/index.html

@nventive-devops

Copy link
Copy Markdown
Contributor

The build 227003 found UI Test snapshots differences: skia-linux-screenshots: 74, skia-windows-screenshots: 117

Details
  • skia-linux-screenshots: 74 changed over 2366

    🚨🚨 Comparison Details (first 20) 🚨🚨
    • Focus_FocusVisual_Properties.png-dark
    • Focus_FocusVisual_Properties.png
    • DoubleImageBrushInList.png-dark
    • ClipboardTests.png-dark
    • ClipboardTests.png
    • CalendarView_Theming.png-dark
    • DynamicFont.png-dark
    • Buttons.png-dark
    • Buttons.png
    • DamageRegion_ShadowChildPaintingCaster.png-dark
    • Gamepad_CurrentReading.png-dark
    • Gamepad_CurrentReading.png
    • Gamepad_Enumeration.png-dark
    • Gamepad_Enumeration.png
    • ImageBrushInList.png-dark
    • ImageBrushInList.png
    • Examples.png
    • ImageIconPage.png-dark
    • ButtonClippingTestsControl.png-dark
    • ButtonClippingTestsControl.png
  • skia-windows-screenshots: 117 changed over 2366

    🚨🚨 Comparison Details (first 20) 🚨🚨
    • Battery.png
    • ButtonClippingTestsControl.png-dark
    • ButtonClippingTestsControl.png
    • CommandBar_With_Long_Sentences.png-dark
    • EllipsemaskingEllipseGrid.png-dark
    • EllipsemaskingEllipseGrid.png
    • ImageIconPage.png-dark
    • ImageIconPage.png
    • DownloadFileSavePickerTests.png-dark
    • Examples.png
    • DynamicFont.png-dark
    • DynamicFont.png
    • Gamepad_CurrentReading.png-dark
    • Gamepad_CurrentReading.png
    • Gamepad_Enumeration.png-dark
    • Haptics.VibrationDevice.png-dark
    • Gamepad_Enumeration.png
    • Haptics.VibrationDevice.png
    • ImageBrushInList.png-dark
    • ImageBrushInList.png

MartinZikmund and others added 9 commits August 14, 2026 20:18
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SBqEtwCdGHuvjapkaNdAP
Add ContextHelper.ApplicationContext for app-scoped callers, make Current
fall back to the application context when no activity is in the foreground,
and repoint the foreground context on activity destroy so a torn-down
activity is never left as "current". Groundwork for multi-window (#13827).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SBqEtwCdGHuvjapkaNdAP
Converge Skia-on-Android onto the per-window ownership pattern the desktop
and iOS runtimes already use, so the architecture is multi-window-ready
(#13827). SupportsMultipleWindows stays false.

- NativeWindowWrapper is no longer a process-wide Lazy singleton; it is bound
  to its window and tracks the activity currently driving it (CurrentActivity),
  which the managed Window outlives across activity re-creation.
- ApplicationActivity's render stack (render view, native-layer host, root
  layout) and the Instance singleton become per-activity instance state; a new
  activity rebuilds its surface and re-attaches on re-creation.
- AndroidSkiaXamlRootHost is per-window: it resolves its own RootElement and the
  driving activity via the wrapper, and registers in XamlRootMap so consumers
  (native element hosting, TextBox notifications, IME) resolve the owning
  activity from a XamlRoot instead of a global.
- The window factory creates the host per window and binds the activity's wrapper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SBqEtwCdGHuvjapkaNdAP
Pointer and keyboard input sources are no longer process singletons. Each
window's wrapper owns its own sources, resolved by that window's InputManager
via its IXamlRootHost (matching the Win32 runtime), and fed by the driving
activity's native event dispatch. Completes the multi-window-ready de-singleton
(#13827).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SBqEtwCdGHuvjapkaNdAP
- ContextHelper.Current no longer falls back to the application context: it stays
  activity-scoped (foreground activity or null) so existing (Activity)Current
  hard-casts and Current == null guards keep their semantics; the app-context
  split is the separate ApplicationContext accessor.
- Only repoint the foreground context on destroy when another live activity can
  take over, preserving last-activity behavior for single-window.
- Raise the managed window Closing only when the activity is finishing, so a
  configuration-change re-creation doesn't spuriously close the surviving window.
- Make OnSystemUiVisibilityChangeListener internal (no external consumer).
- Tag the remaining foreground-activity fallbacks with the #13827 follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SBqEtwCdGHuvjapkaNdAP
Align UnoSKCanvasView with the Vulkan backend: skip the frame when the window's
root/composition target isn't ready (e.g. mid teardown during activity
re-creation) instead of dereferencing with a null-forgiving bang.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SBqEtwCdGHuvjapkaNdAP
Review panel findings on the per-activity render stack.

- Skipping a frame when the window isn't ready stranded rendering: only
  OnNativePlatformFrameRequested clears CompositionTarget.RenderRequested, so a
  bare return made every later RequestNewFrame a no-op and the window never
  repainted. Both backends now re-arm on the skip path.
- Build the render stack before base.OnStart(): that call synchronously reaches
  OnLaunched -> CreateWindow, after which InvalidateRender() can run against
  RelativeLayout. Harmless while the stack was static (null only on first
  launch), but it is per-activity now, so it was null on every re-creation.
- Release the GL/Vulkan context in OnDestroy via IUnoSkiaRenderView.TeardownRenderer;
  the peer finalizer never runs the managed dispose path, so each re-creation
  stranded a GRContext (a VkDevice on Vulkan).
- Unsubscribe the InputPane and orientation-sensor handlers, which are on
  process-wide singletons and were rooting every destroyed activity.
- Gate the managed window Closing on IsChangingConfigurations and wrapper
  ownership: IsFinishing is not the complement of "being re-created", so the
  StartActivity/Finish restart idiom raised Closing on the successor's window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yi8oUA7TT2ArHyBqzwJz6
Current is activity-scoped and can be null, but the file's new #nullable enable
left it annotated non-null while returning _current!, which told every consumer
null was unreachable. Typing it Context? restores the signal; runtime behaviour
is unchanged, since the null-forgiving getter already handed back the same null.

Making it honest surfaced 13 call sites that dereference it unguarded. They are
left explicit with `!` rather than repointed at ApplicationContext: several read
Resources, WindowManager or assembly identity, where the activity and
application contexts are not interchangeable. Threading an owning-window context
through these callers is the documented follow-up.

The native WebView provider does resolve its owning activity from the
ContentPresenter's XamlRoot, since one is available there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yi8oUA7TT2ArHyBqzwJz6
- Record the two public-surface changes that were not additive: the
  OnSystemUiVisibilityChangeListener narrowing (PackageDiffIgnore + migration
  notes) and the ContextHelper.Current nullability and foreground-tracking
  changes.
- Correct the plan: Current has no application-context fallback, and the wrapper
  lands per-activity rather than strictly per-Window, since the activity still
  adopts it through the ambient current window.
- Add Given_AndroidSkiaXamlRootHost. The plan claimed this logic was only
  exercisable on-device, but CI runs an Android Skia runtime-test lane, so host
  registration, activity resolution and per-window input-source identity are
  assertable there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yi8oUA7TT2ArHyBqzwJz6
@MartinZikmund
MartinZikmund force-pushed the dev/mazi/androidunsingleton branch from 9dc62c3 to d12374d Compare August 14, 2026 18:21
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Claude finished @MartinZikmund's task in 3m 8s —— View job


Re-review pass 3 (commits 2542a0c9, 080f5262, d12374d1)

  • Check new commits since last review
  • Read files for remaining open findings
  • Check for any new issues in new commits
  • Post inline comments
  • Update summary

What the 3 new commits addressed

Commit Topic Status
2542a0c9 Re-arm skipped frames, TeardownRenderer, unsubscribe singletons, IsChangingConfigurations gate ✅ Sound — no new issues
080f5262 Type ContextHelper.Current as Android.Content.Context?; 13 call sites updated with explicit ! ✅ Fixed (MEDIUM from prior passes)
d12374d1 Docs: migration guide additions, PackageDiffIgnore.xml, Given_AndroidSkiaXamlRootHost runtime test, spec corrections ✅ Sound

The new Given_AndroidSkiaXamlRootHost runtime test is well-formed: [PlatformCondition(ConditionMode.Include, RuntimeTestPlatforms.SkiaAndroid)] correctly gates it to the Android Skia CI lane; the three synchronous void methods don't await anything so the void return type is correct; reflection-based access avoids a compile-time dependency on the Android assembly.


Verdict: fix-first — 2 inline findings still open

The same two issues from the first two review passes remain unaddressed:

HIGHUnoSKVulkanView.cs:225_activity.NativeLayerHost!.Path = nativeClipPath still uses the null-forgiving !. The canvas view received this fix in commit a2e944b2; the Vulkan view did not. See inline comment.

LOWNativeWindowWrapper.Android.cs:233windowMetrics.Bounds is called unconditionally when windowMetrics can be null. See inline comment.

Neither the re-arm commit (2542a0c9) nor either of the two subsequent commits (080f5262, d12374d1) touched these lines. Both carry a one-line suggestion in their inline comments.


// Update the native layer host clip path
ApplicationActivity.NativeLayerHost!.Path = nativeClipPath;
_activity.NativeLayerHost!.Path = nativeClipPath;

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.

HIGH — still unfixed after three review passes. This null-forgiving ! was called out in the first pass, re-flagged in the second pass, and the three new commits (2542a0c9, 080f5262, d12374d1) did not address it.

The canvas view received the fix in commit a2e944b2 (line 237–240 of UnoSKCanvasView.cs):

if (_activity.NativeLayerHost is { } nativeLayerHost)
{
    nativeLayerHost.Path = nativeClipPath;
}

Please apply the identical pattern here so both backends are consistent.

Suggested change
_activity.NativeLayerHost!.Path = nativeClipPath;
if (_activity.NativeLayerHost is { } nativeLayerHost)
{
nativeLayerHost.Path = nativeClipPath;
}

{
var windowMetrics = (ContextHelper.Current as Activity)?.WindowManager?.CurrentWindowMetrics;
var windowMetrics = activity.WindowManager?.CurrentWindowMetrics;
displaySize = new Size(windowMetrics.Bounds.Width(), windowMetrics.Bounds.Height());

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.

LOW — still unfixed after three review passes. activity.WindowManager?.CurrentWindowMetrics can return null when WindowManager is null (e.g. before the activity window is attached), but windowMetrics.Bounds is called unconditionally on the next line.

Suggested change
displaySize = new Size(windowMetrics.Bounds.Width(), windowMetrics.Bounds.Height());
var windowMetrics = activity.WindowManager?.CurrentWindowMetrics;
if (windowMetrics is null) return default;
displaySize = new Size(windowMetrics.Bounds.Width(), windowMetrics.Bounds.Height());

@unodevops

Copy link
Copy Markdown
Contributor

🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-24042/wasm-skia-net9/index.html

@unodevops

Copy link
Copy Markdown
Contributor

🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-24042/docs/index.html

@nventive-devops

Copy link
Copy Markdown
Contributor

The build 227800 found UI Test snapshots differences: skia-linux-screenshots: 73, skia-windows-screenshots: 131

Details
  • skia-linux-screenshots: 73 changed over 2366

    🚨🚨 Comparison Details (first 20) 🚨🚨
    • ButtonClippingTestsControl.png-dark
    • DropDownButtonPage.png-dark
    • Examples.png
    • CalendarView_Theming.png-dark
    • Gamepad_CurrentReading.png-dark
    • Gamepad_CurrentReading.png
    • Gamepad_Enumeration.png-dark
    • ImageBrushInList.png-dark
    • ImageBrushInList.png
    • ImageIconPage.png-dark
    • ImageIconPage.png
    • ExpanderColorValidationPage.png-dark
    • ExpanderColorValidationPage.png
    • Attributed_text_FontSize_Changing.png
    • DisplayInformation.png-dark
    • DisplayInformation.png
    • ElementLevelTheme.png-dark
    • ElementLevelTheme.png
    • ButtonClippingTestsControl.png
    • Buttons.png
  • skia-windows-screenshots: 131 changed over 2366

    🚨🚨 Comparison Details (first 20) 🚨🚨
    • ExpanderColorValidationPage.png-dark
    • ExpanderColorValidationPage.png
    • Focus_FocusVisual_Properties.png-dark
    • Focus_FocusVisual_Properties.png
    • ContentPresenter_Template.png-dark
    • ContentPresenter_Template.png
    • ContentPresenter_TextProperties.png-dark
    • ContentPresenter_TextProperties.png
    • DisplayInformation.png-dark
    • Dopes.png-dark
    • CalendarView_Theming.png-dark
    • CalendarView_Theming.png
    • Examples.png
    • Buttons.png-dark
    • Buttons.png
    • EllipsemaskingEllipseGrid.png-dark
    • Button_Enabled_Control_Disabled.png
    • ClipboardTests.png-dark
    • ContentPresenter_NativeEmbedding_Android_FillType.png
    • ImageSourceUrlMsAppDataScheme.png-dark

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

Labels

area/build Categorizes an issue or PR as relevant to build infrastructure area/skia ✏️ Categorizes an issue or PR as relevant to Skia kind/documentation platform/android 🤖 Categorizes an issue or PR as relevant to the Android platform

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants