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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,28 @@ class NativeUIEngineTest {
.put("imageSurface", "web")
)
val webToken = webCapture.getString("token")
val root = activity.window.decorView as android.view.ViewGroup
var before = 0
main { before = root.childCount }
fun assertHandoffPixels(message: String, visible: Boolean) = main {
assertEquals(View.INVISIBLE, native.visibility)
val root = activity.window.decorView
val pixels =
android.graphics.Bitmap.createBitmap(
root.width,
root.height,
android.graphics.Bitmap.Config.ARGB_8888,
)
root.draw(android.graphics.Canvas(pixels))
val origin = IntArray(2)
val rootOrigin = IntArray(2)
activity.web.getLocationInWindow(origin)
root.getLocationInWindow(rootOrigin)
val color =
pixels.getPixel(
origin[0] - rootOrigin[0] + 100,
origin[1] - rootOrigin[1] + 150,
)
pixels.recycle()
assertEquals(message, visible, color == Color.RED)
}
assertTrue(
send(
JSONObject()
Expand All @@ -231,14 +250,9 @@ class NativeUIEngineTest {
)
.getBoolean("shown")
)
main {
assertEquals(View.INVISIBLE, native.visibility)
assertEquals(
"Web surface must not install a fixed native entry image",
before,
root.childCount,
)
}
assertHandoffPixels("Entry image bridges the cover acknowledgement", true)
send(JSONObject().put("action", "painted").put("token", webToken))
assertHandoffPixels("Painted acknowledgement retires the entry image", false)
assertTrue(
send(
JSONObject()
Expand All @@ -249,12 +263,10 @@ class NativeUIEngineTest {
)
.getBoolean("shown")
)
assertHandoffPixels("Return image bridges the restore acknowledgement", true)
send(JSONObject().put("action", "restored").put("token", webToken))
assertHandoffPixels("Restored acknowledgement retires the return image", false)
main {
assertEquals(
"Web surface must not install a fixed native return image",
before,
root.childCount,
)
snapshots.restoreViews()
assertEquals(View.VISIBLE, native.visibility)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ class NativeUISnapshots(private val directory: File) : AutoCloseable {
val referenceWidth: Int,
val referenceHeight: Int,
val parts: List<Part>,
var webSurface: Boolean = false,
)

private data class Copy(
Expand Down Expand Up @@ -134,11 +133,7 @@ class NativeUISnapshots(private val directory: File) : AutoCloseable {
initial = null
measured.takeIf { it.keys == expected } ?: emptyMap()
},
complete = { result ->
captures[result.optString("token")]?.webSurface =
request.optString("imageSurface") == "web"
respond(result)
},
complete = respond,
)
}
"cover" -> {
Expand Down Expand Up @@ -546,11 +541,7 @@ class NativeUISnapshots(private val directory: File) : AutoCloseable {
// Composite pixels cover both the live translucent view and its Web replacement.
// Install the entry copy before changing native visibility in the same UI turn.
expiredEntries.remove(token)
if (captures[token]?.webSurface == true) {
val capture = valid(token, window, reference) ?: return false
if (frames != null && capture.parts.any { frames[it.id] == null }) return false
clearCopy()
} else if (!restore(token, window, reference, frames = frames, entry = true, clips = clips))
if (!restore(token, window, reference, frames = frames, entry = true, clips = clips))
return false
if (!transient) coveringToken = token
hide(token, views)
Expand Down Expand Up @@ -612,7 +603,6 @@ class NativeUISnapshots(private val directory: File) : AutoCloseable {
)
return false
clearCopy()
if (capture.webSurface) return true
val root = window.decorView as? ViewGroup ?: return false
val rootLocation = origin(root)
val sourceLocation = origin(reference)
Expand Down
6 changes: 3 additions & 3 deletions docs/advanced/native-handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ This uses WKWebView's internal overflow hierarchy without private class names. M

A `prepare` request may specify `captureMode: 'view'` to draw only the renderer views returned by the view selector, preserving transparent pixels outside their material. The default remains composited window capture. Renderer-only capture can change or omit backdrop-dependent blur and refraction; it does not guarantee transparency inside a glass surface. Missing, hidden or detached renderer views reject preparation. Android and older native binaries keep their existing capture route.

Moving DOM surfaces can provide a fixed viewport transfer layer through `WebProjectionOptions.imageSurface` (`HTMLElement`). The Web projection marks its prepare requests with `imageSurface: 'web'`; these tokens do not install fixed Native image copies. During motion, the decoded DOM images live on their source surface so its transform carries them. Translation does not invalidate their reusable capture; dimensions are compared at display-pixel resolution, while actual size and source changes still invalidate it.
Moving DOM surfaces can provide a fixed viewport transfer layer through `WebProjectionOptions.imageSurface` (`HTMLElement`). The Web projection marks its prepare requests with `imageSurface: 'web'` so Native concealment preserves renderer layout. This changes placement, not the handoff protocol: every surface uses the same temporary Native entry and return copies. During motion, the decoded DOM images live on their source surface so its transform carries them. Translation does not invalidate their reusable capture; dimensions are compared at display-pixel resolution, while actual size and source changes still invalidate it. On iOS, each image part includes optional `anchorBounds` relative to the image crop, in viewport units. Renderers can supply `anchorFrames` for composite controls, such as the first actual toolbar item in a group. Moving targets use these bounds to retain capture padding without mixing capture-time Native coordinates with later DOM placement.

Keep the Native renderer and source ownership alive while DOM images own the pixels. For entry, the Engine temporarily places those same images in the transfer layer at the captured Native coordinates, waits for Web painting, covers Native pixels, and returns the images to their source surface. For return, first lay out Native controls while keeping their pixels concealed. Read their actual frames and call `prepareNativeReturn(frames, revision)`. When the frames are usable, the Engine parks the same DOM images there; otherwise, pass `undefined` and it keeps their current placement. Both paths set `nativeReady` after Web painting. Missing alignment is not a failure of an accepted Native renderer. Reveal Native controls in the following update, then call `updated(token, ids, revision)` on its accepted acknowledgement to retire the DOM images. Only a revision newer than the staged layout can retire them; an acknowledgement for the layout stage must not. `prepareNativeReturn` ignores obsolete measurements. The displayed image remains leased until the reveal acknowledgement or explicit retirement, independently of whether its cached capture is still reusable. No snapshot is captured during either transfer.
Keep the Native renderer and source ownership alive while DOM images own the pixels. For entry, the common handoff installs prepared Native pixels and conceals the live renderer in the same Native turn, then displays the DOM images on their source surface. During that transfer the DOM image shares the temporary Native copy’s viewport coordinates. After Web painting, the Engine retires the Native copy and returns the DOM image to its source surface. Live translucent controls never sample their own Web image during entry. The optional `layoutFrame(target, part, parts)` adapter supplies current Native anchor placement from the captured cohort; it does not own visibility or handoff timing. Return uses that placement to freeze both prepared copies at the same destination until the accepted Native update. The renderer applies its ordinary Native layout and calls `updated(token, ids, revision)` on the accepted acknowledgement. Only a revision newer than the return request can retire the DOM images. The displayed image remains leased until the reveal acknowledgement or explicit retirement, independently of whether its cached capture is still reusable. No snapshot is captured during either transfer.

On iOS, use `NativeUICoveredSnapshots.concealPixels(in:)` on the main thread when layout must remain measurable. It masks pixels and disables interaction/accessibility without changing `isHidden` or geometry. Its returned restoration closure is idempotent; overlapping leases restore the original mask and flags only after the last lease ends. The default composited handoff retains its existing Native copy and drawing-completion contract.
On iOS, use `NativeUICoveredSnapshots.concealPixels(in:)` on the main thread when layout must remain measurable. It masks pixels and disables interaction/accessibility without changing `isHidden` or geometry. Its returned restoration closure is idempotent; overlapping leases restore the original mask and flags only after the last lease ends. Both composited and renderer-only capture use the same Native copy and drawing-completion contract.
Loading
Loading