diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java index 98037c79ba3..7b53f4effe7 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java @@ -727,6 +727,15 @@ private int getClientY(MouseEvent evt) { } private boolean hitTest(int x, int y) { + // With no native peer behind the canvas there is nothing a pointer could + // fall through to, so the canvas owns every point. Probing anyway cost a + // blocking round trip and a pixel read-back of the display canvas on every + // touchstart and wheel event, and Chrome answers repeated read-backs by + // taking the canvas off the GPU for good: on a low-end Android phone every + // later frame was then rasterized in software, ~80ms each (#6123). + if (!(Accessor.getActivePeerCount() > 0 && paintNativePeersBehind())) { + return true; + } if (outputCanvas != null) { // Single-pixel alpha probe of the display surface. A fully // transparent pixel means the pointer falls through (no component diff --git a/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/net/GuardTest.java b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/net/GuardTest.java index 75b65854c4a..cbe66c67b37 100644 --- a/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/net/GuardTest.java +++ b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/net/GuardTest.java @@ -19,7 +19,10 @@ package com.codenameone.examples.bastion.net; import com.codenameone.examples.bastion.AppConfig; +import com.codename1.impl.javase.JavaSEPort; import com.codename1.io.ConnectionRequest; +import com.codename1.io.Util; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import java.io.IOException; @@ -32,6 +35,14 @@ /// What the network guard lets through. class GuardTest { + /// `new ConnectionRequest()` asks the platform whether it supports access + /// points. Without this the test passed only when an earlier test class in + /// the same JVM had installed a port, and failed whenever it ran first. + @BeforeAll + static void port() { + Util.setImplementation(new JavaSEPort()); + } + @Test void cleartextAndUnlistedHostsAreRefused() { withDevelopment(() -> { diff --git a/scripts/test-javascript-composited-rendering.mjs b/scripts/test-javascript-composited-rendering.mjs index a2a53538e11..e47a27eca37 100644 --- a/scripts/test-javascript-composited-rendering.mjs +++ b/scripts/test-javascript-composited-rendering.mjs @@ -47,6 +47,8 @@ * cancelled those keys on the accessibility tree's listbox element); * phone (Chromium with touch emulation): * - a swipe scrolls the content (#5912: it did nothing at all); + * - a swipe, a wheel and a mouse drag read no pixels back from the display: this app + * has no native peers, and a read-back takes Chrome's canvas off the GPU (#6123); * - a tap on the hamburger opens the side menu, and a tap on a button fires it (#5912); * - with the side menu open, none of the form's DOM text floats over the menu's shade. * iOS user agent (Chromium presenting itself as an iPhone): @@ -139,6 +141,39 @@ function canvasPixels(page, points) { }, points); } +// Pixel read-backs of the display while a pointer is used. An application without native +// peers has nothing a pointer could fall through to, so it must read nothing back: each +// touchstart and wheel event used to cost a worker round trip and a getImageData on the +// display canvas, and Chrome answers repeated read-backs by taking that canvas off the GPU +// for good -- a nine-row form then scrolled at ~12fps on a budget Android phone (#6123). +// Both are counted: the worker's __cn1_surface_read__ requests catch the port asking, and a +// direct getImageData on the display canvas catches any bridge path that reads it in place. +// Installed after boot and read before the test itself samples pixels (canvasPixels). +function watchReadbacks(page) { + return page.evaluate(() => { + const state = { surfaceReads: 0, displayReads: 0 }; + const worker = window.__parparWorker; + if (!worker) throw new Error('no __parparWorker to observe'); + worker.addEventListener('message', (e) => { + const d = e.data || {}; + if (d.symbol === '__cn1_surface_read__') state.surfaceReads++; + }); + const original = CanvasRenderingContext2D.prototype.getImageData; + CanvasRenderingContext2D.prototype.getImageData = function(...args) { + if (this.canvas && this.canvas.id === 'codenameone-canvas') state.displayReads++; + return original.apply(this, args); + }; + window.__cn1ReadbackWatch = state; + }); +} + +async function checkNoReadbacks(page, label) { + const r = await page.evaluate(() => window.__cn1ReadbackWatch); + check(r && r.surfaceReads === 0 && r.displayReads === 0, + `${label} reads no pixels back from the display (no native peers)`, + r ? `${r.surfaceReads} surface read request(s), ${r.displayReads} display getImageData call(s)` : 'watch missing'); +} + function luminance(px) { return 0.2126 * px[0] + 0.7152 * px[1] + 0.0722 * px[2]; } @@ -340,9 +375,11 @@ async function desktop(name, browserType) { const title = await span(page, 'Hi World'); const toolbarBottom = Math.max(20, Math.floor(title.bottom)); const a = await page.screenshot({ path: path.join(outDir, `${name}-scroll-0.png`) }); + await watchReadbacks(page); await page.mouse.move(500, 300); for (let i = 0; i < 8; i++) { await page.mouse.wheel(0, 120); await page.waitForTimeout(120); } await page.waitForTimeout(1500); + await checkNoReadbacks(page, `${name}: the wheel`); const b = await page.screenshot({ path: path.join(outDir, `${name}-scroll-1.png`) }); const toolbarDiff = await diffFraction(page, a, b, [0, toolbarBottom]); const contentDiff = await diffFraction(page, a, b, [toolbarBottom + 20, 440]); @@ -367,6 +404,7 @@ async function desktop(name, browserType) { await boot(page, logs); const title = await span(page, 'Hi World'); const a = await page.screenshot(); + await watchReadbacks(page); await page.mouse.move(600, 400); await page.mouse.down(); for (let i = 1; i <= 15; i++) { await page.mouse.move(600, 400 - i * 15); await page.waitForTimeout(20); } @@ -375,6 +413,16 @@ async function desktop(name, browserType) { const b = await page.screenshot({ path: path.join(outDir, `${name}-drag.png`) }); const moved = await diffFraction(page, a, b, [Math.floor(title.bottom) + 20, 440]); check(moved > 0.05, `${name}: a mouse drag scrolls the content`, `${(moved * 100).toFixed(2)}% changed`); + // More presses from the same point: the first one of a page never probed (see the + // phone swipe). + for (let k = 0; k < 2; k++) { + await page.mouse.move(600, 400); + await page.mouse.down(); + for (let i = 1; i <= 15; i++) { await page.mouse.move(600, 400 - i * 15); await page.waitForTimeout(20); } + await page.mouse.up(); + await page.waitForTimeout(1000); + } + await checkNoReadbacks(page, `${name}: three mouse drags`); fs.writeFileSync(path.join(outDir, `${name}-drag-console.txt`), logs.join('\n')); await page.close(); } @@ -445,6 +493,7 @@ async function phone() { const band = [Math.floor(title.bottom) + 30, vp.height - 10]; const a = await page.screenshot({ path: path.join(outDir, 'phone-swipe-0.png') }); const cdp = await context.newCDPSession(page); + await watchReadbacks(page); const x = Math.round(vp.width / 2); const y0 = Math.round(vp.height * 0.8); const y1 = Math.round(vp.height * 0.3); @@ -458,6 +507,19 @@ async function phone() { const b = await page.screenshot({ path: path.join(outDir, 'phone-swipe-1.png') }); const moved = await diffFraction(page, a, b, band); check(moved > 0.05, 'phone: a swipe scrolls the content', `${(moved * 100).toFixed(2)}% changed`); + // More swipes from the same point: the probe never ran on the first press of a page, + // and a single gesture passed with the read-back still in place (measured on #6123's + // build: one read per press from the second on). + for (let k = 0; k < 2; k++) { + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y: y0 }] }); + for (let i = 1; i <= 15; i++) { + await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y: Math.round(y0 + (y1 - y0) * i / 15) }] }); + await page.waitForTimeout(16); + } + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + await page.waitForTimeout(1000); + } + await checkNoReadbacks(page, 'phone: three swipes'); fs.writeFileSync(path.join(outDir, 'phone-swipe-console.txt'), logs.join('\n')); await page.close(); } diff --git a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js index a10ea8e05e3..6036e185a70 100644 --- a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js +++ b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js @@ -2835,6 +2835,65 @@ // with JavaSEPort.applyLensBuffer(): the Simulator is the browser renderer's // pixel reference for the glass-tab animation. var LENS_MAG_FLAT = 0.75; + + // Reads RGBA bytes of a canvas region WITHOUT calling getImageData on that + // canvas. Chrome counts read-backs per context and, after a couple, moves the + // canvas off the GPU for the rest of the page's life ("Multiple readback + // operations using getImageData..."). For the full-screen display canvas that + // meant every later frame was rasterized in software -- ~80ms a frame on a + // low-end Android phone (#6123). Copying the region into a scratch canvas made + // for reading keeps the read-back off the source's count. Out-of-bounds pixels + // read as transparent, as getImageData's do. + var READBACK_SCRATCH_KEEP_PIXELS = 1 << 20; + var readbackScratch = null; + var readbackScratchCtx = null; + function readCanvasPixels(canvas, ctx, x, y, w, h) { + if (w <= 0 || h <= 0) { + return null; + } + var doc = global.document || (global.window && global.window.document); + if (!canvas || !doc || !doc.createElement) { + try { + return ctx.getImageData(x, y, w, h).data; + } catch (_ed) { + return null; + } + } + try { + if (!readbackScratch) { + readbackScratch = doc.createElement('canvas'); + } + if (readbackScratch.width < w || readbackScratch.height < h) { + readbackScratch.width = Math.max(readbackScratch.width, w); + readbackScratch.height = Math.max(readbackScratch.height, h); + readbackScratchCtx = null; + } + if (!readbackScratchCtx) { + readbackScratchCtx = readbackScratch.getContext('2d', { willReadFrequently: true }); + } + var sc = readbackScratchCtx; + sc.setTransform(1, 0, 0, 1, 0, 0); + sc.globalAlpha = 1; + sc.globalCompositeOperation = 'source-over'; + sc.clearRect(0, 0, w, h); + // The source rect is clipped to the canvas; draw only the part that is + // inside it, at its own offset, so the rest stays transparent. + var sx = Math.max(0, x), sy = Math.max(0, y); + var ex = Math.min(canvas.width, x + w), ey = Math.min(canvas.height, y + h); + if (ex > sx && ey > sy) { + sc.drawImage(canvas, sx, sy, ex - sx, ey - sy, sx - x, sy - y, ex - sx, ey - sy); + } + var data = sc.getImageData(0, 0, w, h).data; + if (w * h > READBACK_SCRATCH_KEEP_PIXELS) { + // One large getRGB must not pin a big CPU-backed canvas for the page's life. + readbackScratch = null; + readbackScratchCtx = null; + } + return data; + } catch (_er) { + return null; + } + } var LENS_TINT_HI = 150; var LENS_TINT_LO = 55; var LENS_LIFT_COEF = 0.40; @@ -2917,8 +2976,11 @@ return; } - var source = ctx.getImageData(rx, ry, rw, rh); - var src = source.data; + // Read through a scratch canvas: see readCanvasPixels (#6123). + var src = readCanvasPixels(ctx.canvas, ctx, rx, ry, rw, rh); + if (!src) { + return; + } var result = ctx.createImageData(rw, rh); var out = result.data; var hw = rw / 2.0, hh = rh / 2.0; @@ -3250,7 +3312,10 @@ if (availableWidth <= 0 || availableHeight <= 0) { return; } - var available = ctx.getImageData(ax0, ay0, availableWidth, availableHeight).data; + var available = readCanvasPixels(ctx.canvas, ctx, ax0, ay0, availableWidth, availableHeight); + if (!available) { + return; + } var padded = new Uint8ClampedArray(bufferWidth * bufferHeight * 4); for (var by = 0; by < bufferHeight; by++) { var sourceY = ry - pad + by - ay0; @@ -3440,7 +3505,10 @@ if (rw <= 0 || rh <= 0) { return; } - var part = ctx.getImageData(rx, ry, rw, rh).data; + var part = readCanvasPixels(ctx.canvas, ctx, rx, ry, rw, rh); + if (!part) { + return; + } var full = new Uint8ClampedArray(fullW * fullH * 4); var row; for (row = 0; row < rh; row++) { @@ -3867,13 +3935,10 @@ return null; } var x = r.x | 0, y = r.y | 0, w = r.w | 0, h = r.h | 0; - var img; - try { - img = s.ctx.getImageData(x, y, w, h); - } catch (_er) { + var data = readCanvasPixels(s.canvas, s.ctx, x, y, w, h); + if (!data) { return null; } - var data = img.data; var n = w * h; var out = new Array(n); for (var i = 0; i < n; i++) { @@ -7617,7 +7682,6 @@ // TRANSPARENT (an actual punched hole), so we additionally require the canvas // pixel under the cursor to be (near-)transparent before yielding to the peer. function installPeerPointerToggle() { - var alphaCtx = null; function canvasAlphaAt(canvas, x, y) { // Returns the canvas' painted alpha (0-255) at client point (x,y), or -1 if // it cannot be read. Maps CSS coords -> backing-store pixels (devicePixelRatio). @@ -7627,12 +7691,9 @@ var px = Math.round((x - rect.left) * (canvas.width / rect.width)); var py = Math.round((y - rect.top) * (canvas.height / rect.height)); if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height) { return -1; } - if (!alphaCtx) { - alphaCtx = canvas.getContext('2d', { willReadFrequently: true }) - || canvas.getContext('2d'); - } - if (!alphaCtx || typeof alphaCtx.getImageData !== 'function') { return -1; } - return alphaCtx.getImageData(px, py, 1, 1).data[3]; + // Never getImageData on the display canvas itself: see readCanvasPixels. + var px1 = readCanvasPixels(canvas, null, px, py, 1, 1); + return px1 ? px1[3] : -1; } catch (e) { return -1; } } function pointerOverPeer(x, y) { diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptTargetIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptTargetIntegrationTest.java index cccf29476a5..b05b73d9430 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptTargetIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptTargetIntegrationTest.java @@ -176,7 +176,7 @@ void generatesBrowserBundleForJavascriptTarget(CompilerHelper.CompilerConfig con assertTrue(browserBridge.contains("cn1HostBridge") && browserBridge.contains("host-callback") && browserBridge.contains("host-call"), "Browser bridge should expose host-call plumbing for the JavaScript port shell"); assertTrue(browserBridge.contains("function applyLensSelfRegion") - && browserBridge.contains("ctx.getImageData(rx, ry, rw, rh)") + && browserBridge.contains("readCanvasPixels(ctx.canvas, ctx, rx, ry, rw, rh)") && browserBridge.contains("LENS_GLASS_TINT_STR") && browserBridge.contains("_lab = nums[ni++]") && browserBridge.contains("_ltintStrength = nums[ni++]"),