diff --git a/examples/bare/App.tsx b/examples/bare/App.tsx index 5c5aac36..819664af 100644 --- a/examples/bare/App.tsx +++ b/examples/bare/App.tsx @@ -15,7 +15,8 @@ * is scenarios/secure.tsx, `attributes`/`attributes-persist` are * scenarios/attributes.ts, `e2e-native-smoke` / `native-crash-*` are * scenarios/native.ts, `blackout`, `blackout-prelaunch`, - * `secure-component` and `view-tree` are scenarios/privacy.tsx, and + * `secure-component`, `secure-modal`, `secure-modal-translucent`, + * `secure-modal-sheet` and `view-tree` are scenarios/privacy.tsx, and * `exc-*` are scenarios/exceptions.tsx, `rp-*` are * scenarios/reporting.ts, `log-filter` is scenarios/log-filter.ts, * `breadcrumb-filter` is scenarios/breadcrumb-filter.ts, diff --git a/examples/bare/e2e/media.ts b/examples/bare/e2e/media.ts index cac6a0da..05db0025 100644 --- a/examples/bare/e2e/media.ts +++ b/examples/bare/e2e/media.ts @@ -415,3 +415,74 @@ export function heldBlackout( `${BLACKOUT_MIN_DARK_S}s until a bright frame`, }; } + +/** An RGB colour, each channel 0-255. */ +export interface Colour { + readonly r: number; + readonly g: number; + readonly b: number; +} + +/** How far each channel may stray from a test colour: lossy screenshots shift it. */ +export const COLOUR_TOLERANCE = 48; + +/** `#RRGGBB` as a `Colour`. */ +export function colourOf(hex: string): Colour { + const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex); + if (match === null) { + throw new Error(`colourOf: not #RRGGBB: ${hex}`); + } + return { r: parseInt(match[1]!, 16), g: parseInt(match[2]!, 16), b: parseInt(match[3]!, 16) }; +} + +/** + * Every pixel of `file`'s first frame within `tolerance` of `colour` on all + * three channels: how many, and their bounding box in image pixels (`right` + * and `bottom` exclusive), or `null` for none. Decodes the whole frame, so it + * finds a solid test colour wherever it really is on screen. + */ +export async function colourPixels( + file: string, + colour: Colour, + tolerance = COLOUR_TOLERANCE, +): Promise<{ + count: number; + box: { left: number; top: number; right: number; bottom: number } | null; + width: number; + height: number; +}> { + const { width, height } = await imageSize(file); + const { stdout } = await runBinary('ffmpeg', [ + '-v', 'error', + '-i', file, + '-frames:v', '1', + '-pix_fmt', 'rgb24', + '-f', 'rawvideo', + '-', + ]); + if (stdout.length !== width * height * 3) { + throw new Error(`colourPixels: ${file}: expected ${width * height * 3} raw byte(s), got ${stdout.length}`); + } + let count = 0; + let left = width; + let top = height; + let right = 0; + let bottom = 0; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const at = (y * width + x) * 3; + if ( + Math.abs(stdout[at]! - colour.r) <= tolerance && + Math.abs(stdout[at + 1]! - colour.g) <= tolerance && + Math.abs(stdout[at + 2]! - colour.b) <= tolerance + ) { + count++; + left = Math.min(left, x); + top = Math.min(top, y); + right = Math.max(right, x + 1); + bottom = Math.max(bottom, y + 1); + } + } + } + return { count, box: count === 0 ? null : { left, top, right, bottom }, width, height }; +} diff --git a/examples/bare/e2e/secure-modal.test.ts b/examples/bare/e2e/secure-modal.test.ts new file mode 100644 index 00000000..7f5c2cbb --- /dev/null +++ b/examples/bare/e2e/secure-modal.test.ts @@ -0,0 +1,381 @@ +/** + * `` on the app's root and inside a ``: both must be + * masked in the report. Fabric measureInWindow is surface-relative, and each + * rectangle is translated by its own surface's origin at pull time. + * + * Scenarios (scenarios/privacy.tsx), one block each: + * - `secure-modal`: a transparent Modal (both platforms). + * - `secure-modal-translucent` (Android): also `statusBarTranslucent`. On a + * build with edge-to-edge off this is the case where the dialog's origin + * (0) differs from the activity root's (the status bar), so it is the one + * that tells a per-surface origin from a shared one. + * - `secure-modal-sheet` (iOS): an opaque `pageSheet`, whose content is + * inset inside the window. + * + * Ground truth is independent of what the app measured: each secure view is + * a colour nothing else on screen has, and a screenshot taken by the device + * itself (`adb exec-out screencap`, `xcrun simctl io screenshot`) finds where + * each really is. The report's screenshot must be dark over each one the + * device shows, and must not contain either colour anywhere. An opaque + * `pageSheet` covers the main view entirely on an iPhone, so there only the + * sheet's colour is on screen. The iPhone has no command-line screenshot, so + * there only the colour check runs. + * + * The video is checked too (the SDK masks it on a separate, native path): a + * witness inside the Modal, red and not secure, marks every frame that shows + * the Modal, and in each of those the Modal's secure view must be dark. Both + * crops come from the same frames, so the video's timestamp lag does not + * matter. + * + * Android: WOD_LX1 with `E2E_EDGE_TO_EDGE` naming the installed build. + * iOS: simulator or XS (`E2E_IOS_TARGET`). + */ +import { execFile } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { type PulledBundle, airplane, captureEvents, removePulledBundles } from './bundles'; +import { ADB, ANDROID_SERIAL, IOS_SIMULATOR_ID, iosTarget } from './device'; +import { + ON_IOS, + type Run, + TARGET_NAME, + awaitBundles, + clearBundles, + describeDevice, + escape, + must, + report, + startDeviceLog, + startRun, + stopApp, + stopDeviceLog, +} from './harness'; +import { + LUMA_BRIGHT_MIN, + LUMA_DARK_MAX, + colourOf, + colourPixels, + frameLumas, + imageSize, + letterbox, + regionFrameLumas, + videoRegion, + widthFitRegion, +} from './media'; +import { type DeviceLog, type LogLine, adb, resetScenario } from './scenario'; +import { + type Media, + type Rect, + assertMedia, + boundsIn, + bundleBySummary, + displaySize, + interiorFrames, + markerScreen, + moved, + screenshotLumas, + servedOf, + uiDump, +} from './screen'; + +const execFileAsync = promisify(execFile); + +jest.setTimeout(4 * 60_000); + +const SHEET_LABEL = 'bugsee-secure-modal-sheet'; +const MAIN_COLOUR = colourOf('#FF00FF'); +const SHEET_COLOUR = colourOf('#00FFFF'); +const WITNESS_COLOUR = colourOf('#FF0000'); +/** Video pixels trimmed off every edge of a crop, as in secure-component.test.ts. */ +const VIDEO_INSET = 3; +/** The fewest witnessed frames a video check may rest on. */ +const MIN_FRAMES = 5; +/** Pixels of a secure colour a report may show before it counts as a leak (antialiased edges). */ +const LEAK_MAX_PIXELS = 64; +/** A secure view the device screenshot shows must be at least this big to count as found. */ +const FOUND_MIN_PIXELS = 1_000; +const EDGE_TO_EDGE = process.env.E2E_EDGE_TO_EDGE; +const SERVED = /BugseeRN\s*:\s*secure (published|origin) .*served=/; +const ACTIVITY_ORIGIN = /BugseeRN\s*:\s*secure origin .*kind=activity\b/; + +const SCENARIOS = ON_IOS ? ['secure-modal', 'secure-modal-sheet'] : ['secure-modal', 'secure-modal-translucent']; + +/** The device's own screenshot, or `null` where there is no command-line one (the iPhone). */ +async function deviceScreenshot(dir: string): Promise { + const file = join(dir, 'device.png'); + if (!ON_IOS) { + const { stdout } = await execFileAsync(ADB, ['-s', ANDROID_SERIAL, 'exec-out', 'screencap', '-p'], { + encoding: 'buffer', + maxBuffer: 64 * 1024 * 1024, + }); + writeFileSync(file, stdout as unknown as Buffer); + return file; + } + if (iosTarget() !== 'simulator') { + return null; + } + await execFileAsync('xcrun', ['simctl', 'io', IOS_SIMULATOR_ID, 'screenshot', file]); + return file; +} + +/** `box` (image pixels) as a `Rect` in display units, on an image `imageWidth` wide. */ +function toDisplay(box: { left: number; top: number; right: number; bottom: number }, imageWidth: number, displayWidth: number): Rect { + const scale = displayWidth / imageWidth; + return { left: box.left * scale, top: box.top * scale, right: box.right * scale, bottom: box.bottom * scale }; +} + +for (const scenario of SCENARIOS) { + describeDevice(` in ${scenario} on ${TARGET_NAME}`, () => { + let log: DeviceLog; + let run: Run; + let nonce: string; + let display: { width: number; height: number }; + let shotDir: string; + let deviceShot: string | null; + /** Where the device shows each secure view (display units); `main` is absent behind an opaque sheet. */ + let found: { main: Rect | null; sheet: Rect } | null = null; + /** Where the device shows the Modal's red witness (display units). */ + let witness: Rect | null = null; + let sheetFromDump: Rect | undefined; + let mainFromJs: Rect | undefined; + let servedLine: LogLine | undefined; + let bundle: PulledBundle; + let media: Media; + + const marker = async (what: string, from: number, timeoutMs = 45_000): Promise => + must( + await log.waitFor(new RegExp(`BUGSEE_E2E ${escape(scenario)} ${escape(what)}.* nonce=${nonce}`), timeoutMs, from), + `the ${scenario} scenario's "${what}" marker`, + run.start, + ); + + beforeAll(async () => { + if (!ON_IOS && EDGE_TO_EDGE !== 'true' && EDGE_TO_EDGE !== 'false') { + throw new Error( + `E2E_EDGE_TO_EDGE must state how the installed build was made, "true" or "false"; got ${JSON.stringify(EDGE_TO_EDGE)}`, + ); + } + shotDir = mkdtempSync(join(tmpdir(), 'bugsee-secure-modal-')); + log = await startDeviceLog('6.modal', '6.modal'); + if (!ON_IOS) { + await adb('shell', 'setprop', 'log.tag.BugseeRN', 'DEBUG'); + await airplane(true); + display = await displaySize(); + } + + await clearBundles(); + run = await startRun(scenario); + nonce = run.scenario.nonce; + report('banner', run.banner.text.trim()); + + const rect = await marker('rect phase=both', run.launched.index); + await marker('uploaded', rect.index); + const still = await marker('still', rect.index); + if (ON_IOS) { + display = markerScreen(rect); + } + report('JS rectangles', rect.text.replace(/^.*rect phase=both /, '')); + + // While the scenario holds still: the device's own picture of where + // the two secure views are. + deviceShot = await deviceScreenshot(shotDir); + if (deviceShot !== null) { + const main = await colourPixels(deviceShot, MAIN_COLOUR); + const sheet = await colourPixels(deviceShot, SHEET_COLOUR); + const red = await colourPixels(deviceShot, WITNESS_COLOUR); + report('device screenshot', { main, sheet, witness: red }); + if (red.box !== null) { + witness = toDisplay(red.box, red.width, display.width); + } + if (sheet.box !== null) { + found = { + main: main.box === null ? null : toDisplay(main.box, main.width, display.width), + sheet: toDisplay(sheet.box, sheet.width, display.width), + }; + } + expect(sheet.count).toBeGreaterThanOrEqual(FOUND_MIN_PIXELS); + if (scenario === 'secure-modal-sheet') { + // The opaque sheet hides the main view: the device must not show it. + expect(main.count).toBe(0); + } else { + expect(main.count).toBeGreaterThanOrEqual(FOUND_MIN_PIXELS); + } + } + + if (!ON_IOS) { + // Before the dump: a focused Dialog's accessibility dump leaves out + // the activity-root control behind it. + const upTo = log.mark(); + servedLine = must(log.all(SERVED, run.start, upTo).pop(), 'a served line before the dump', run.start); + const activity = must( + log.all(ACTIVITY_ORIGIN, run.start, upTo).pop(), + "the activity root's origin line", + run.start, + ); + const origin = /origin=(-?\d+),(-?\d+)/.exec(activity.text); + const ratio = Number(/\bratio=([\d.]+)/.exec(rect.text)?.[1] ?? 'NaN'); + const main = /main=(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/.exec(rect.text); + if (origin === null || main === null || !Number.isFinite(ratio) || ratio <= 0) { + throw new Error(`could not parse the activity origin, ratio or main rect: ${activity.text} / ${rect.text}`); + } + const [ox, oy] = [Number(origin[1]), Number(origin[2])]; + mainFromJs = { + left: Math.round(Number(main[1]) * ratio) + ox, + top: Math.round(Number(main[2]) * ratio) + oy, + right: Math.round((Number(main[1]) + Number(main[3])) * ratio) + ox, + bottom: Math.round((Number(main[2]) + Number(main[4])) * ratio) + oy, + }; + const { xml } = await uiDump(); + sheetFromDump = boundsIn(xml, SHEET_LABEL); + report('bounds', { mainFromJs, sheetFromDump, activityOrigin: { ox, oy }, ratio, stillBeforeDump: still.deviceMs }); + report('origins', log.all(/BugseeRN\s*:\s*secure origin /, run.start, upTo).map(line => line.text.trim())); + report('served', servedLine.text.trim()); + } + + report('display', display); + const bundles = await awaitBundles(1); + bundle = bundleBySummary(bundles, `${scenario}-${nonce}`); + media = await assertMedia(bundle); + report('media', { screenshots: media.screenshots.length, codecs: media.screenshotCodecs, video: media.videoCodec }); + }); + + afterAll(async () => { + try { + await stopApp(); + await clearBundles().catch((error: unknown) => report('cleanup clear failed', String(error))); + } finally { + try { + if (!ON_IOS) { + await airplane(false); + } + } finally { + if (!ON_IOS) { + await adb('shell', 'setprop', 'log.tag.BugseeRN', "''").catch(() => {}); + } + rmSync(shotDir, { recursive: true, force: true }); + const { removed, kept } = removePulledBundles(); + report('pulled bundle roots', { removed: removed.length, kept }); + stopDeviceLog(log); + resetScenario(); + } + } + }); + + it('the report shows neither secure colour anywhere', async () => { + const leaks = []; + for (const file of media.screenshots) { + leaks.push({ + main: (await colourPixels(file, MAIN_COLOUR)).count, + sheet: (await colourPixels(file, SHEET_COLOUR)).count, + }); + } + report('secure colour pixels in the report', leaks); + for (const leak of leaks) { + expect(leak.main).toBeLessThanOrEqual(LEAK_MAX_PIXELS); + expect(leak.sheet).toBeLessThanOrEqual(LEAK_MAX_PIXELS); + } + }); + + it('the report is dark where the device shows each secure view', async () => { + if (deviceShot === null) { + report('device screenshot', 'none on this target: only the colour check above applies'); + return; + } + if (found === null) { + throw new Error("the device screenshot does not show the Modal's secure colour"); + } + const where = found; + const mainInner = where.main === null ? [] : await screenshotLumas(media.screenshots, where.main, display.width, 0.6); + const sheetInner = await screenshotLumas(media.screenshots, where.sheet, display.width, 0.6); + // Inside the Modal's white backdrop, below its secure view. + const control: Rect = moved(where.sheet, (where.sheet.bottom - where.sheet.top) + 40); + const controlLumas = await screenshotLumas(media.screenshots, control, display.width); + report('lumas', { found: where, mainInner, sheetInner, control, controlLumas }); + + for (const luma of [...mainInner, ...sheetInner]) { + expect(luma).toBeLessThanOrEqual(LUMA_DARK_MAX); + } + for (const luma of controlLumas) { + expect(luma).toBeGreaterThanOrEqual(LUMA_BRIGHT_MIN); + } + }); + + if (!ON_IOS) { + it('the served set covers the main rectangle and the sheet', () => { + const served = servedOf(servedLine as LogLine); + report('served vs on screen', { + edgeToEdge: EDGE_TO_EDGE, + served: served.rects, + main: mainFromJs, + sheet: sheetFromDump, + found, + }); + const covers = (rect: Rect, onScreen: Rect): boolean => + rect.left <= onScreen.left && + rect.top <= onScreen.top && + rect.right >= onScreen.right && + rect.bottom >= onScreen.bottom && + onScreen.left - rect.left <= 2 && + onScreen.top - rect.top <= 2 && + rect.right - onScreen.right <= 2 && + rect.bottom - onScreen.bottom <= 2; + + expect(served.count).toBeGreaterThanOrEqual(2); + expect(served.rects.some(r => covers(r, mainFromJs as Rect))).toBe(true); + expect(served.rects.some(r => covers(r, sheetFromDump as Rect))).toBe(true); + }); + } + it('every video frame that shows the Modal is masked over its secure view', async () => { + if (deviceShot === null) { + report('video', 'no device screenshot on this target to place the witness: skipped'); + return; + } + if (found === null || witness === null) { + throw new Error("the device screenshot does not show the Modal's secure colour and its witness"); + } + const video = media.video; + const size = await imageSize(video); + let region: (rect: Rect) => { x: number; y: number; w: number; h: number }; + if (ON_IOS) { + // The iOS SDK scales by width from the top-left, as its screenshot. + expect(captureEvents(bundle, 'video.aux')).toEqual([]); + region = rect => widthFitRegion(rect, display, size, VIDEO_INSET); + } else { + const box = letterbox(display, size); + const aux = captureEvents(bundle, 'video.aux')[0] as + | { paddingH?: number; paddingV?: number; screenW?: number; screenH?: number } + | undefined; + report('video letterbox', { size, derived: box, aux }); + expect(aux).toBeDefined(); + expect({ screenW: aux!.screenW, screenH: aux!.screenH }).toEqual({ screenW: display.width, screenH: display.height }); + expect(Math.abs(box.padH - aux!.paddingH!)).toBeLessThanOrEqual(1); + expect(Math.abs(box.padV - aux!.paddingV!)).toBeLessThanOrEqual(1); + region = rect => videoRegion(rect, display, size, VIDEO_INSET); + } + const centre = await frameLumas(video); + const { from, to } = interiorFrames(centre); + const series = async (rect: Rect): Promise => { + const frames = await regionFrameLumas(video, region(rect)); + // The same decode, frame for frame: the pairing below is by index. + expect(frames.map(f => f.t)).toEqual(centre.map(f => f.t)); + return frames.slice(from, to).map(f => Math.round(f.luma)); + }; + const t = centre.slice(from, to).map(f => f.t); + const witnessLuma = await series(witness); + const sheetLuma = await series(found.sheet); + report('video frames (t, witness, sheet)', t.map((time, i) => [time, witnessLuma[i], sheetLuma[i]])); + + // The witness is white stage before the Modal shows, and red (or black, + // under a whole-display mask while an origin is unknown) once it does. + // Every frame that shows it must hide the secure view, and the Modal is + // still up when the report is taken. + const shown = witnessLuma.map((l, i) => i).filter(i => witnessLuma[i]! < LUMA_BRIGHT_MIN); + expect(shown.length).toBeGreaterThanOrEqual(MIN_FRAMES); + expect(shown[shown.length - 1]).toBe(witnessLuma.length - 1); + const leaked = shown.map(i => ({ t: t[i], l: sheetLuma[i]! })).filter(frame => frame.l > LUMA_DARK_MAX); + expect(leaked).toEqual([]); + }); + }); +} diff --git a/examples/bare/scenarios/privacy.tsx b/examples/bare/scenarios/privacy.tsx index d7e40f8f..1e280a75 100644 --- a/examples/bare/scenarios/privacy.tsx +++ b/examples/bare/scenarios/privacy.tsx @@ -39,6 +39,7 @@ import { Animated, Dimensions, Easing, + Modal, PixelRatio, ScrollView, StyleSheet, @@ -52,6 +53,9 @@ export const PRIVACY_SCENARIOS = [ 'blackout', 'blackout-prelaunch', 'secure-component', + 'secure-modal', + 'secure-modal-translucent', + 'secure-modal-sheet', 'view-tree', ] as const; @@ -69,6 +73,34 @@ export const HOLD_MS = 8_000; /** The accessibility labels the tests find the probes by (uiautomator). */ export const SECURE_COMPONENT_LABEL = 'bugsee-secure-component'; export const SECURE_WITNESS_LABEL = 'bugsee-secure-witness'; +export const SECURE_MODAL_MAIN_LABEL = 'bugsee-secure-modal-main'; +export const SECURE_MODAL_SHEET_LABEL = 'bugsee-secure-modal-sheet'; +/** + * The two secure views' colours: nothing else on screen is either, so a + * device screenshot finds where each one really is, and a recording that + * shows either colour has leaked it. + */ +export const SECURE_MODAL_MAIN_COLOUR = '#FF00FF'; +export const SECURE_MODAL_SHEET_COLOUR = '#00FFFF'; +/** + * A witness inside the Modal, not secure, in a colour of its own: a video + * frame that shows it shows the Modal, so the frames the test checks the + * Modal's secure view in are found from the video itself. + */ +export const SECURE_MODAL_WITNESS_COLOUR = '#FF0000'; +const MODAL_WITNESS_RECT = { position: 'absolute', top: 340, left: 80, width: 80, height: 80 } as const; + +/** + * The Modal each `secure-modal*` scenario shows: + * - `secure-modal`: `transparent` (overFullScreen on iOS). + * - `secure-modal-translucent`: also `statusBarTranslucent`. With Android + * edge-to-edge off, the dialog's content then starts under the status bar + * while the activity root starts below it, so the two surfaces' origins + * differ. + * - `secure-modal-sheet`: an opaque `pageSheet` (iOS), whose content is + * inset inside the window. + */ +export type SecureModalVariant = 'secure-modal' | 'secure-modal-translucent' | 'secure-modal-sheet'; export const VH_OPEN_LABEL = 'vh-open-probe'; function mark(message: string): void { @@ -309,6 +341,75 @@ function SecureComponentProbe({ nonce, setMoving }: ProbeProps) { ); } +const MODAL_MAIN_RECT = { position: 'absolute', top: 120, left: 40, width: 160, height: 80 } as const; +/** Same absolute box for the secure view and its measure twin (must overlap). */ +const MODAL_SHEET_RECT = { position: 'absolute', top: 80, left: 80, width: 200, height: 100 } as const; + +/** + * secure-modal*: a coloured `` on the activity root and another + * inside a `` (Android Dialog / iOS presented VC). Both must land in + * display space for their own surface — the sheet must not be translated by + * the activity root's origin alone. Markers are tagged with the scenario. + */ +function SecureModalProbe({ nonce, setMoving, variant }: ProbeProps & { variant: SecureModalVariant }) { + const mainTwin = useRef(null); + const sheetTwin = useRef(null); + + useLayoutEffect(() => { + mark(`${variant} mounted t=${Date.now()} nonce=${nonce}`); + }, [nonce, variant]); + + useEffect(() => { + (async () => { + await sleep(2000); + const main = await measure(mainTwin.current); + const sheet = await measure(sheetTwin.current); + mark( + `${variant} rect phase=both main=${main.x},${main.y},${main.w},${main.h} ` + + `sheet=${sheet.x},${sheet.y},${sheet.w},${sheet.h} ${screenLine()} nonce=${nonce}`, + ); + await sleep(1500); + Bugsee.upload(`${variant}-${nonce}`, ''); + mark(`${variant} uploaded t=${Date.now()} nonce=${nonce}`); + await sleep(SETTLE_MS); + setMoving(false); + mark(`${variant} still t=${Date.now()} nonce=${nonce}`); + await sleep(HOLD_MS); + setMoving(true); + })().catch((error: unknown) => { + mark(`${variant} threw ${String(error)} nonce=${nonce}`); + }); + }, [nonce, setMoving, variant]); + + return ( + <> + + + + + + + + + + + ); +} + /** * view-tree: what the managed tree must describe, and what it must never * carry (the text, the typed value, and every id under the secure boundary). @@ -387,6 +488,12 @@ export function PrivacyStage({ {launched && scenario === 'secure-component' && ( )} + {launched && + (scenario === 'secure-modal' || + scenario === 'secure-modal-translucent' || + scenario === 'secure-modal-sheet') && ( + + )} {launched && scenario === 'view-tree' && ( )} @@ -399,4 +506,12 @@ const styles = StyleSheet.create({ stage: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: '#FFFFFF' }, square: { position: 'absolute', left: 0, width: SQUARE, height: SQUARE, backgroundColor: '#000000' }, witness: { position: 'absolute', left: 20, width: 80, height: 80, backgroundColor: '#000000' }, + modalSheet: { + flex: 1, + marginTop: 200, + marginHorizontal: 24, + backgroundColor: '#FFFFFF', + borderTopLeftRadius: 12, + borderTopRightRadius: 12, + }, }); diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java index 93a125d8..1a789e04 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java @@ -3,8 +3,12 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import android.content.Context; +import android.graphics.Point; +import android.hardware.display.DisplayManager; import android.util.Base64; import android.util.Log; +import android.view.Display; import com.bugsee.library.Bugsee; import com.bugsee.library.contracts.exchange.Breadcrumb; @@ -35,7 +39,10 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import org.json.JSONException; import org.json.JSONObject; @@ -102,12 +109,24 @@ public void logUnhandledException( */ private final DataRequestBridge.Sink dataRequestSink = this::emitRequest; + /** This module's claim on the secure store (SecureRectangleStore#claimRuntime). */ + private final int secureRuntime; + /** Spans this module is holding. {@link #invalidate()} drops them. */ private final SpanHandles spanHandles = new SpanHandles(); public BugseeModule(final ReactApplicationContext context) { super(context); - originTracker = new ReactRootOriginTracker(context, SecureRectangleStore.shared()); + // A new JS runtime: Modal surfaces the previous one left behind (a + // reload with a secure Modal open) are dropped before this one's JS + // can publish. + final int leftBehind = SecureRectangleStore.shared().nonMainSurfacesWithRectangles(); + secureRuntime = SecureRectangleStore.shared().claimRuntime(); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "secure runtime claimed: dropped " + leftBehind + + " Modal surface(s) the previous runtime left"); + } + originTracker = new ReactRootOriginTracker(context, SecureRectangleStore.shared(), secureRuntime); // refreshSoon only posts to the UI thread, which is all the SDK's pull // thread may do. pullRefresher = originTracker::refreshSoon; @@ -162,6 +181,14 @@ public void invalidate() { spanHandles.releaseAll(); SecureRectanglePulls.shared().clearRefresher(pullRefresher); originTracker.dispose(); + // This runtime's Modal surfaces cannot be cleared by its JS any more. + // A no-op when the next module has already claimed the store. + final int open = SecureRectangleStore.shared().nonMainSurfacesWithRectangles(); + SecureRectangleStore.shared().releaseRuntime(secureRuntime); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "secure runtime released: " + open + " Modal surface(s) held, " + + SecureRectangleStore.shared().nonMainSurfacesWithRectangles() + " left"); + } super.invalidate(); } @@ -246,6 +273,16 @@ public void setWrapperInfo(final ReadableMap identity) { @Override public void setSecureRectangles(final double display, final ReadableArray coordinates) { + publishSecure(display, SecureRectangleStore.MAIN_SURFACE, coordinates); + } + + @Override + public void setSecureRectanglesOnSurface( + final double display, final double surface, final ReadableArray coordinates) { + publishSecure(display, (int) Math.round(surface), coordinates); + } + + private void publishSecure(final double display, final int surface, final ReadableArray coordinates) { // A void TurboModule method: anything thrown here has no promise to // reject and crashes the host app, over a call JS already validated. try { @@ -258,16 +295,30 @@ public void setSecureRectangles(final double display, final ReadableArray coordi for (int i = 0; i < flat.length; i++) { flat[i] = (int) Math.round(coordinates.getDouble(i)); } - // Stored as measured (relative to React Native's viewport - // offset); the store serves them moved to the React root's - // display origin, which the tracker keeps current. Re-read now - // too, in case the window moved without a layout pass. - if (!SecureRectangleStore.shared().publishOrLog((int) display, flat)) { + // The display's real size first: until an origin is read the + // store serves the whole display, and it must know how big that + // is. Display reads are safe off the UI thread. + recordDisplayBounds((int) display); + // Stored as measured (relative to that surface's own origin); + // the store serves them moved by that surface's display origin, + // which the tracker keeps current. Re-read now too, in case the + // window moved without a layout pass. + // Ignored once a newer runtime has claimed the store: this + // module's late writes (a reload's teardown) must not touch + // the next runtime's lanes, whose keys may be the same tags. + if (!SecureRectangleStore.shared().publishForRuntime(secureRuntime, (int) display, surface, flat)) { return; } originTracker.refreshSoon(); + // A 's first rectangles: find and watch its dialog root. + // Until then they fail closed in the store. An empty publish + // (the Modal clearing on unmount) watches nothing. + if (flat.length > 0) { + originTracker.watchSurface(surface); + } if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "secure published display=" + (int) display + + " surface=" + surface + " raw=" + Arrays.toString(flat) + " served=" + Arrays.toString(SecureRectangleStore.shared().snapshot((int) display))); } @@ -277,6 +328,73 @@ public void setSecureRectangles(final double display, final ReadableArray coordi } } + /** Records {@code display}'s real size in the store, once. */ + private void recordDisplayBounds(final int display) { + final SecureRectangleStore store = SecureRectangleStore.shared(); + if (store.hasDisplayBounds(display)) { + return; + } + final DisplayManager displays = + (DisplayManager) getReactApplicationContext().getSystemService(Context.DISPLAY_SERVICE); + final Display found = displays == null ? null : displays.getDisplay(display); + if (found == null) { + return; + } + final Point size = new Point(); + found.getRealSize(size); + store.setDisplayBounds(display, size.x, size.y); + } + + /** + * The display origin {@code [x, y]} of the {@code } whose host has + * React tag {@code surface}, for the {@code vh} walk, which asks once per + * {@code } per walk. Answered from the tracker's cache when the + * surface is watched; otherwise read on the UI thread, waiting at most + * {@link #SURFACE_ORIGIN_WAIT_MS}. Empty when unknown: the walk then + * keeps the request's origin for that subtree. + */ + @Override + public WritableArray secureSurfaceOrigin(final double surface) { + final int key = (int) Math.round(surface); + int[] origin = originTracker.cachedSurfaceOrigin(key); + if (origin == null) { + origin = readSurfaceOriginOnUiThread(key); + } + final WritableArray out = Arguments.createArray(); + if (origin != null) { + out.pushDouble(origin[0]); + out.pushDouble(origin[1]); + } + return out; + } + + /** Well inside the walk's own 250 ms budget. */ + private static final long SURFACE_ORIGIN_WAIT_MS = 100; + + @Nullable + private int[] readSurfaceOriginOnUiThread(final int key) { + if (UiThreadUtil.isOnUiThread()) { + return originTracker.surfaceOriginNow(key); + } + final AtomicReference result = new AtomicReference<>(); + final CountDownLatch done = new CountDownLatch(1); + UiThreadUtil.runOnUiThread(() -> { + try { + result.set(originTracker.surfaceOriginNow(key)); + } catch (Throwable t) { + Log.w(TAG, "secure surface origin failed (" + t.getClass().getSimpleName() + ")"); + } finally { + done.countDown(); + } + }); + try { + done.await(SURFACE_ORIGIN_WAIT_MS, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return result.get(); + } + // --- Blackout and view-hierarchy capture (design doc §4.1) ----------- // Android ignores startBlackout/endBlackout before launch() (a logged // no-op inside the SDK itself); iOS honours them regardless. Recorded in diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactRootOriginTracker.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactRootOriginTracker.java index b7a3d44d..94750636 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactRootOriginTracker.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactRootOriginTracker.java @@ -15,30 +15,56 @@ import com.facebook.react.bridge.LifecycleEventListener; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.UiThreadUtil; +import com.facebook.react.bridge.UIManager; +import com.facebook.react.uimanager.ReactRoot; import com.facebook.react.uimanager.RootView; import com.facebook.react.uimanager.RootViewUtil; +import com.facebook.react.uimanager.UIManagerHelper; import java.lang.ref.WeakReference; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; /** - * Keeps {@link SecureRectangleStore}'s display origin in step with the React - * root view, so rectangles JS measured with {@code measureInWindow} are served - * to the SDK in display pixels. + * Keeps {@link SecureRectangleStore}'s per-surface display origins in step + * with each React root view, so rectangles JS measured with {@code + * measureInWindow} are served to the SDK in display pixels. * - *

The origin is the root's {@code getLocationOnScreen} less React Native's - * own viewport offset for it ({@link RootViewUtil#getViewportOffset}, the - * formula {@code ReactSurfaceView} feeds Fabric's layout constraints, which is - * what {@code measureInWindow} adds to a view's position in its root). With - * edge-to-edge on and a full-screen window that is (0, 0); with it off, it is - * the status-bar/cutout inset; in split-screen or freeform it is also the - * window's own position on the display. + *

The origin of the activity root (a {@link ReactRoot}) is its {@code + * getLocationOnScreen} less React Native's own viewport offset for it + * ({@link RootViewUtil#getViewportOffset}, the formula {@code + * ReactSurfaceView} feeds Fabric's layout constraints, which the root shadow + * node's {@code Translate(viewportOffset)} adds to {@code measureInWindow}). + * With edge-to-edge on and a full-screen window that is (0, 0); with it off, + * it is the status-bar/cutout inset; in split-screen or freeform it is also + * the window's own position on the display. * - *

Re-read on every global layout of the root (rotation, insets arriving, - * a window resize), on host resume, on every JS publish, and at most every - * {@link SecureRectanglePulls#ORIGIN_REFRESH_MIN_INTERVAL_MS} while the SDK - * pulls (a window can move without a relayout). All view access is on the UI - * thread; the store is thread-safe. + *

Fabric {@code measureInWindow} stops at the nearest {@code RootNodeKind} + * ancestor ({@code ModalHostViewShadowNode} sets that trait), so a rectangle + * inside a {@code } is relative to the modal's content, which is + * mounted in the dialog's {@code DialogRootViewGroup}. That root is a {@link + * RootView} but not a {@link ReactRoot}: nothing feeds its viewport offset to + * Fabric, and the modal host node's transform is the identity. Its origin is + * therefore its {@code getLocationOnScreen} alone ({@link #surfaceOrigin}). + * Each surface has its own origin; a single {@code setOrigin} for the display + * cannot serve both. + * + *

A surface's key is the React tag of its {@code } host, which JS + * reads off the fiber tree; {@link #watchSurface} resolves the dialog root + * from it when JS first publishes there, and retries on every refresh until + * the dialog root exists. + * + *

Re-read on every global layout of a watched root (rotation, insets + * arriving, a window resize), on host resume, on every JS publish, and at + * most every {@link SecureRectanglePulls#ORIGIN_REFRESH_MIN_INTERVAL_MS} + * while the SDK pulls (a window can move without a relayout). All view access + * is on the UI thread; the store is thread-safe. */ final class ReactRootOriginTracker implements LifecycleEventListener { @@ -48,10 +74,23 @@ final class ReactRootOriginTracker implements LifecycleEventListener { private final RootFinder rootFinder; private final SecureRectangleStore store; - /** UI thread only. */ + /** + * The owning module's claim on {@link #store}: every lane write carries + * it, so a tracker whose runtime has been replaced writes nothing. + */ + private final int claim; + + /** UI thread only: the activity React root, when found. */ @Nullable private RootHandle root; + /** + * Extra surfaces (a {@code }'s {@code DialogRootViewGroup}) + * registered via {@link #watch}, each paired with the layout-listener + * token from {@link RootHandle#addOnGlobalLayoutListener}. UI thread only. + */ + private final List watched = new ArrayList<>(); + /** * The layout-listener registration for {@link #root}, if any, released * through this same token later. Which observer it is removed from is @@ -63,6 +102,34 @@ final class ReactRootOriginTracker implements LifecycleEventListener { @Nullable private LayoutListenerToken layoutToken; + /** A watched surface and the layout token that must be released with it. */ + private static final class WatchedRoot { + final RootHandle handle; + final LayoutListenerToken token; + + WatchedRoot(@NonNull final RootHandle handle, + @NonNull final LayoutListenerToken token) { + this.handle = handle; + this.token = token; + } + } + + /** Finds a {@code }'s dialog root from its host's React tag. */ + private final SurfaceResolver surfaceResolver; + + /** + * Surface keys JS has published on whose dialog root could not be found + * yet. Retried on every {@link #refresh}. UI thread only. + */ + private final Set pendingSurfaces = new LinkedHashSet<>(); + + /** + * Each watched surface's last origin, for {@link #cachedSurfaceOrigin} + * (the {@code vh} walk asks from the JS thread). Written on the UI + * thread. + */ + private final Map surfaceOrigins = new ConcurrentHashMap<>(); + /** * A copy of the {@code {x, y}} last passed to {@link * SecureRectangleStore#setOrigin}, for {@link #currentOrigin()} (Task @@ -83,8 +150,9 @@ final class ReactRootOriginTracker implements LifecycleEventListener { private volatile boolean disposed; ReactRootOriginTracker(@NonNull final ReactApplicationContext context, - @NonNull final SecureRectangleStore store) { - this(new ContextLifecycleSource(context), new ActivityRootFinder(context), store); + @NonNull final SecureRectangleStore store, final int claim) { + this(new ContextLifecycleSource(context), new ActivityRootFinder(context), + new ModalSurfaceResolver(context), store, claim); } /** @@ -98,9 +166,28 @@ final class ReactRootOriginTracker implements LifecycleEventListener { */ ReactRootOriginTracker(@NonNull final LifecycleSource lifecycle, @NonNull final RootFinder rootFinder, @NonNull final SecureRectangleStore store) { + this(lifecycle, rootFinder, surfaceKey -> null, store); + } + + /** + * Test seam, as above, with a fake {@link SurfaceResolver} too. Writes + * with whatever claim {@code store} holds now. + */ + ReactRootOriginTracker(@NonNull final LifecycleSource lifecycle, + @NonNull final RootFinder rootFinder, @NonNull final SurfaceResolver surfaceResolver, + @NonNull final SecureRectangleStore store) { + this(lifecycle, rootFinder, surfaceResolver, store, store.currentClaim()); + } + + /** Test seam, as above, writing with {@code claim}. */ + ReactRootOriginTracker(@NonNull final LifecycleSource lifecycle, + @NonNull final RootFinder rootFinder, @NonNull final SurfaceResolver surfaceResolver, + @NonNull final SecureRectangleStore store, final int claim) { this.lifecycle = lifecycle; this.rootFinder = rootFinder; + this.surfaceResolver = surfaceResolver; this.store = store; + this.claim = claim; lifecycle.addLifecycleEventListener(this); refreshSoon(); } @@ -146,44 +233,225 @@ void refresh() { try { RootHandle current = root; if (current == null || !current.isAttachedToWindow()) { - detach(); + detachPrimary(); current = rootFinder.findCurrentRoot(); - if (current == null) { - return; + if (current != null) { + layoutToken = current.addOnGlobalLayoutListener(this::refresh); + root = current; } - layoutToken = current.addOnGlobalLayoutListener(this::refresh); - root = current; } - // Resolved once, atomically: current holds the root's view only - // weakly, and reading location/viewport/display as separate - // calls could see the view collected partway through, mixing a - // real value read before that with a made-up default (0,0) / - // DEFAULT_DISPLAY read after it -- publishing a wrong origin is - // a privacy defect, not a cosmetic one, so a gone-mid-refresh - // view aborts the whole read instead: nothing new is published - // and the previous origin stands. - final OriginSnapshot snapshot = current.resolveOrigin(); - if (snapshot == null) { - return; - } - final int[] origin = - SecureRectangleStore.displayOrigin(snapshot.onScreen, snapshot.viewport.x, snapshot.viewport.y); - store.setOrigin(snapshot.displayId, origin[0], origin[1]); - lastOrigin = origin; - if (Log.isLoggable(TAG, Log.DEBUG)) { - Log.d(TAG, "secure origin display=" + snapshot.displayId - + " onScreen=" + snapshot.onScreen[0] + "," + snapshot.onScreen[1] - + " viewport=" + snapshot.viewport.x + "," + snapshot.viewport.y - + " origin=" + origin[0] + "," + origin[1] - + " served=" + Arrays.toString(store.snapshot(snapshot.displayId))); + if (current != null) { + publishOrigin(current, true); } + refreshWatched(); + resolvePending(); } catch (Throwable t) { Log.w(TAG, "secure rectangles: could not read the React root's display origin: " + t.getClass().getName()); } } + /** + * JS published on {@code surfaceKey} (a {@code } host's tag): + * watch its dialog root. Safe from any thread: posts to the UI thread. + * Until the root is found its rectangles fail closed in the store. + */ + void watchSurface(final int surfaceKey) { + if (surfaceKey <= SecureRectangleStore.MAIN_SURFACE) { + return; + } + UiThreadUtil.runOnUiThread(() -> watchSurfaceNow(surfaceKey)); + } + + /** Package-visible for the same reason as {@link #refresh()}. */ + @UiThread + void watchSurfaceNow(final int surfaceKey) { + if (disposed || surfaceKey <= SecureRectangleStore.MAIN_SURFACE || isWatched(surfaceKey)) { + return; + } + final RootHandle handle = resolveQuietly(surfaceKey); + if (handle == null) { + pendingSurfaces.add(surfaceKey); + return; + } + pendingSurfaces.remove(surfaceKey); + watchNow(handle); + } + + /** + * The display origin of {@code surfaceKey}'s dialog root, watching it + * first if needed; {@code null} when it cannot be found. For the {@code + * vh} walk, which asks once per {@code } per walk. + */ + @UiThread + @Nullable + int[] surfaceOriginNow(final int surfaceKey) { + watchSurfaceNow(surfaceKey); + for (final WatchedRoot entry : watched) { + if (entry.handle.surfaceKey() == surfaceKey) { + publishOrigin(entry.handle, false); + } + } + return cachedSurfaceOrigin(surfaceKey); + } + + /** The last origin read for {@code surfaceKey}, or {@code null}. Any thread. */ + @Nullable + int[] cachedSurfaceOrigin(final int surfaceKey) { + final int[] origin = surfaceOrigins.get(surfaceKey); + return origin == null ? null : origin.clone(); + } + + /** Package-visible for the same reason as {@link #refresh()}. */ + @UiThread + void watchNow(@NonNull final RootHandle handle) { + if (disposed || isWatched(handle.surfaceKey())) { + return; + } + final LayoutListenerToken token = handle.addOnGlobalLayoutListener(this::refresh); + watched.add(new WatchedRoot(handle, token)); + publishOrigin(handle, false); + } + + @UiThread + private boolean isWatched(final int surfaceKey) { + for (final WatchedRoot existing : watched) { + if (existing.handle.surfaceKey() == surfaceKey) { + return true; + } + } + return false; + } + + @UiThread + @Nullable + private RootHandle resolveQuietly(final int surfaceKey) { + try { + return surfaceResolver.resolve(surfaceKey); + } catch (Throwable t) { + Log.w(TAG, "secure surface: could not resolve a dialog root (" + + t.getClass().getSimpleName() + ")"); + return null; + } + } + + /** + * Retries every pending surface. One whose rectangles JS has cleared + * meanwhile (its Modal unmounted before its root was found) is not + * retried: it is dropped with its empty lane. + */ + @UiThread + private void resolvePending() { + if (pendingSurfaces.isEmpty()) { + return; + } + for (final Integer surfaceKey : new ArrayList<>(pendingSurfaces)) { + if (!store.hasRectangles(surfaceKey)) { + pendingSurfaces.remove(surfaceKey); + store.dropSurfaceIfEmptyForRuntime(claim, surfaceKey); + continue; + } + watchSurfaceNow(surfaceKey); + } + } + + @UiThread + private void refreshWatched() { + final Iterator it = watched.iterator(); + while (it.hasNext()) { + final WatchedRoot entry = it.next(); + if (!entry.handle.isAttachedToWindow()) { + it.remove(); + forget(entry); + continue; + } + publishOrigin(entry.handle, false); + } + } + + /** + * Stops watching a dialog root. If its surface still holds rectangles, + * its origin no longer holds: the store serves the surface as the whole + * display again and the key goes back to pending, so a later refresh + * watches whatever root then hosts it. Otherwise its empty lane goes. + */ + @UiThread + private void forget(@NonNull final WatchedRoot entry) { + entry.token.release(); + final int surfaceKey = entry.handle.surfaceKey(); + surfaceOrigins.remove(surfaceKey); + if (disposed) { + // The runtime is gone: its Modal surfaces are the module's to + // drop (SecureRectangleStore.releaseRuntime), not to retry. + return; + } + if (store.hasRectangles(surfaceKey)) { + store.forgetOriginForRuntime(claim, surfaceKey); + pendingSurfaces.add(surfaceKey); + } else { + store.dropSurfaceIfEmptyForRuntime(claim, surfaceKey); + } + } + + /** + * Where {@code measureInWindow}'s (0, 0) sits on the display for the + * root in {@code snapshot}: {@code locationOnScreen - viewportOffset} for + * a {@link ReactRoot} (the activity root), {@code locationOnScreen} alone + * for any other {@link RootView} (a {@code }'s dialog root). Pure, + * so a JVM test checks the choice. + */ + @NonNull + static int[] surfaceOrigin(@NonNull final OriginSnapshot snapshot) { + return snapshot.reactRoot + ? SecureRectangleStore.displayOrigin(snapshot.onScreen, snapshot.viewport.x, snapshot.viewport.y) + : new int[] { snapshot.onScreen[0], snapshot.onScreen[1] }; + } + + @UiThread + private void publishOrigin(@NonNull final RootHandle handle, final boolean primary) { + // Resolved once, atomically: the handle holds the root's view only + // weakly, and reading location/viewport/display as separate calls + // could see the view collected partway through, mixing a real value + // read before that with a made-up default (0,0) / DEFAULT_DISPLAY + // read after it -- publishing a wrong origin is a privacy defect, + // not a cosmetic one, so a gone-mid-refresh view aborts the whole + // read instead: nothing new is published and the previous origin + // stands. + // A root that is not attached has no place on screen yet: its + // location reads (0, 0). Nothing is recorded; the surface keeps + // failing closed until its first layout after attaching. + if (!handle.isAttachedToWindow()) { + return; + } + final OriginSnapshot snapshot = handle.resolveOrigin(); + if (snapshot == null) { + return; + } + if (snapshot.displaySize != null) { + store.setDisplayBounds(snapshot.displayId, snapshot.displaySize.x, snapshot.displaySize.y); + } + final int[] origin = surfaceOrigin(snapshot); + // The activity root is the main surface whatever its own view id: + // JS publishes everything outside a there. + final int surface = primary ? SecureRectangleStore.MAIN_SURFACE : handle.surfaceKey(); + store.setOriginForRuntime(claim, snapshot.displayId, surface, origin[0], origin[1]); + if (primary) { + lastOrigin = origin; + } else { + surfaceOrigins.put(surface, origin.clone()); + } + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "secure origin display=" + snapshot.displayId + + " surface=" + surface + + " kind=" + (primary ? "activity" : "dialog") + + " onScreen=" + snapshot.onScreen[0] + "," + snapshot.onScreen[1] + + " viewport=" + snapshot.viewport.x + "," + snapshot.viewport.y + + " origin=" + origin[0] + "," + origin[1] + + " served=" + Arrays.toString(store.snapshot(snapshot.displayId))); + } + } + /** * The React root's current display origin, for {@link DataRequestBridge} * (Task 6.5): the {@code vh} data request is answered on the same thread @@ -205,6 +473,28 @@ int[] currentOrigin() { /** Package-visible for the same reason as {@link #refresh()}. */ @UiThread void detach() { + detachPrimary(); + // Surfaces that still hold rectangles stay pending (failing closed) + // for a root a recreated activity brings back; refresh re-watches + // them. After dispose() nothing refreshes again. + for (final WatchedRoot entry : new ArrayList<>(watched)) { + forget(entry); + } + watched.clear(); + surfaceOrigins.clear(); + if (disposed) { + pendingSurfaces.clear(); + } + } + + /** How many surfaces wait for their dialog root. Tests read it. */ + @UiThread + int pendingSurfaceCount() { + return pendingSurfaces.size(); + } + + @UiThread + private void detachPrimary() { root = null; final LayoutListenerToken token = layoutToken; layoutToken = null; @@ -248,6 +538,12 @@ interface RootFinder { interface RootHandle { boolean isAttachedToWindow(); + /** + * Opaque key for {@link SecureRectangleStore}'s per-surface origin. + * The activity React root and a dialog root must not share one. + */ + int surfaceKey(); + /** * The root's current on-screen location, viewport offset and display * id, resolved as one atomic snapshot -- or {@code null} if the @@ -266,16 +562,43 @@ interface RootHandle { LayoutListenerToken addOnGlobalLayoutListener(Runnable onLayout); } - /** A root's location, viewport offset and display id, read together. */ + /** Finds a {@code }'s dialog root from its host's React tag. */ + interface SurfaceResolver { + /** UI thread. {@code null} while the dialog root does not exist. */ + @Nullable + RootHandle resolve(int surfaceKey); + } + + /** + * A root's location, viewport offset, display id and kind, read + * together. {@code reactRoot} is whether the root is a {@link ReactRoot} + * (whose viewport offset Fabric applies) rather than a dialog root. + */ static final class OriginSnapshot { final int[] onScreen; final Point viewport; final int displayId; + final boolean reactRoot; + /** The display's real size in pixels ({@code Display.getRealSize}), or {@code null}. */ + @Nullable + final Point displaySize; OriginSnapshot(final int[] onScreen, final Point viewport, final int displayId) { + this(onScreen, viewport, displayId, true); + } + + OriginSnapshot(final int[] onScreen, final Point viewport, final int displayId, + final boolean reactRoot) { + this(onScreen, viewport, displayId, reactRoot, null); + } + + OriginSnapshot(final int[] onScreen, final Point viewport, final int displayId, + final boolean reactRoot, @Nullable final Point displaySize) { this.onScreen = onScreen; this.viewport = viewport; this.displayId = displayId; + this.reactRoot = reactRoot; + this.displaySize = displaySize; } } @@ -321,7 +644,43 @@ public RootHandle findCurrentRoot() { return null; } final View view = firstRootView(activity.getWindow().getDecorView()); - return view == null ? null : new ViewRootHandle(view); + return view == null ? null : new ViewRootHandle(view, ViewRootHandle.surfaceKeyOf(view)); + } + } + + /** + * Production {@link SurfaceResolver}: the {@code } host view for + * the tag forwards {@code getChildAt} to its {@code DialogRootViewGroup} + * ({@code ReactModalHostView}), so the dialog root is the React root of + * the host's first child. {@code null} until the modal has content. + */ + private static final class ModalSurfaceResolver implements SurfaceResolver { + private final ReactApplicationContext context; + + ModalSurfaceResolver(final ReactApplicationContext context) { + this.context = context; + } + + @Nullable + @Override + public RootHandle resolve(final int surfaceKey) { + if (!context.hasActiveReactInstance()) { + return null; + } + final UIManager uiManager = UIManagerHelper.getUIManagerForReactTag(context, surfaceKey); + if (uiManager == null) { + return null; + } + final View host = uiManager.resolveView(surfaceKey); + if (!(host instanceof ViewGroup) || ((ViewGroup) host).getChildCount() == 0) { + return null; + } + final View child = ((ViewGroup) host).getChildAt(0); + final RootView root = child == null ? null : RootViewUtil.getRootView(child); + if (!(root instanceof View) || root instanceof ReactRoot) { + return null; + } + return new ViewRootHandle((View) root, surfaceKey); } } @@ -349,9 +708,20 @@ private static View firstRootView(@NonNull final View view) { */ private static final class ViewRootHandle implements RootHandle { private final WeakReference view; + private final int surfaceKey; - ViewRootHandle(final View view) { + ViewRootHandle(final View view, final int surfaceKey) { this.view = new WeakReference<>(view); + this.surfaceKey = surfaceKey; + } + + /** + * React tags for RootViews are positive; {@link View#NO_ID} falls + * back to identity so two untagged roots still stay apart. + */ + static int surfaceKeyOf(@NonNull final View view) { + final int id = view.getId(); + return id != View.NO_ID ? id : System.identityHashCode(view); } @Nullable @@ -365,6 +735,11 @@ public boolean isAttachedToWindow() { return v != null && v.isAttachedToWindow(); } + @Override + public int surfaceKey() { + return surfaceKey; + } + @Nullable @Override public OriginSnapshot resolveOrigin() { @@ -374,10 +749,18 @@ public OriginSnapshot resolveOrigin() { } final int[] onScreen = new int[2]; v.getLocationOnScreen(onScreen); + // Read for every root, so the log shows it; only a ReactRoot's is + // subtracted (surfaceOrigin). + final boolean reactRoot = v instanceof ReactRoot; final Point viewport = RootViewUtil.getViewportOffset(v); final Display display = v.getDisplay(); final int displayId = display == null ? Display.DEFAULT_DISPLAY : display.getDisplayId(); - return new OriginSnapshot(onScreen, viewport, displayId); + Point size = null; + if (display != null) { + size = new Point(); + display.getRealSize(size); + } + return new OriginSnapshot(onScreen, viewport, displayId, reactRoot, size); } @Override diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java index f9e06db0..d7678641 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java @@ -7,6 +7,7 @@ import java.util.Arrays; import java.util.Map; +import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; /** @@ -30,6 +31,55 @@ *

The version is kept per display because the SDK's freshness comparison is * per display: a change on one screen must not invalidate another's. * + *

Surfaces

+ * + *

JS measures with {@code measureInWindow}, which Fabric makes relative to + * the measured node's nearest {@code RootNodeKind} ancestor + * ({@code LayoutableShadowNode.cpp} stops the ancestor walk there; + * {@code ModalHostViewShadowNode} sets that trait). The activity React root + * and a {@code }'s {@code DialogRootViewGroup} are different surfaces: + * each rectangle is stored under the surface it was measured in, and served + * translated by that surface's display origin (see {@link + * ReactRootOriginTracker#surfaceOrigin}). A single origin for the whole + * display cannot serve both. + * + *

Fails closed. A surface whose origin has not been read yet, or whose + * root was forgotten ({@link #forgetOrigin}), serves its rectangles as one + * rectangle covering the whole display: an unknown origin must not place a + * secure region anywhere it could miss. That includes the main surface, so + * the very first publish masks the whole display until the tracker's first + * read, a fraction of a second. While no React root can be found at all, + * the main surface's rectangles stay one whole-display rectangle: there is + * no origin to read, and the store will not guess one (ruled, N8). + * + *

A {@code } surface belongs to one JS runtime: its key is a React + * tag that the next runtime does not know. A reload drops every surface but + * the main one ({@link #claimRuntime}, {@link #releaseRuntime}), so a Modal + * that was open when the old runtime went away does not mask the display + * for the rest of the process. A claim also empties the main surface's + * rectangles, keeping its origin: they were the old runtime's, and its + * clearing write is ignored once stale (below), so without this a new tree + * that never publishes on the main surface would leave them masking until + * the process dies. The new runtime's first publish is then the only main + * set. + * + *

The claim also gates every lane write a runtime makes: the module's + * publish ({@link #publishForRuntime}) and its tracker's origin writes + * ({@link #setOriginForRuntime}, {@link #forgetOriginForRuntime}, {@link + * #dropSurfaceIfEmptyForRuntime}). A write whose claim is no longer current + * is ignored, under the same lock as the claim. A reload can construct the + * new module before the old one is invalidated, and Fabric numbers React + * tags from 1 again, so the old runtime's late writes would otherwise put + * back a lane the claim dropped or clear the new runtime's lane on the same + * key, uncovering its Modal. The display size is not a runtime's state: it + * is recorded by whoever reads it. + * + *

Every served rectangle is clamped to the display's real size once it + * is known ({@link #setDisplayBounds}). The SDK's native video mask clamps a + * rectangle's rows to the frame but not its right edge to a row, so a + * rectangle wider than the frame would black out the start of the next row + * on every row: it must never be handed one. + * *

Threading

* *

Writes arrive on the JS thread; the pull happens on a Bugsee background @@ -58,38 +108,147 @@ final class SecureRectangleStore { private static final int[] NO_COORDINATES = new int[0]; - /** One immutable published set. Replaced wholesale, never edited. */ - private static final class Snapshot { - final int version; - /** As JS published them: relative to React Native's viewport offset. */ + /** + * The activity React root's surface key. Legacy {@link #set}/{@link + * #setOrigin} writers that do not name a surface land here; a {@code + * }'s {@code DialogRootViewGroup} uses a different key. + */ + static final int MAIN_SURFACE = 0; + + /** + * The bounds a display serves a fail-closed lane as when its real size + * has not been recorded ({@link #setDisplayBounds}): bigger than any + * phone or tablet panel, and small enough that a renderer that does not + * clip a rectangle's right edge per row still stays cheap. + */ + static final int FALLBACK_DISPLAY_SIZE = 16384; + + /** One surface's raw measurements and the origin that places them. */ + private static final class Lane { + /** As JS published them: relative to this surface's own origin. */ final int[] raw; final int originX; final int originY; - /** {@link #raw} moved to the display origin: what the SDK is served. */ - final int[] coordinates; - - Snapshot(final int version, @NonNull final int[] raw, - final int originX, final int originY) { - this(version, raw, originX, originY, translate(raw, originX, originY)); - } + /** Whether {@link #originX}/{@link #originY} were read (and still hold). */ + final boolean originKnown; - private Snapshot(final int version, @NonNull final int[] raw, - final int originX, final int originY, @NonNull final int[] coordinates) { - this.version = version; + Lane(@NonNull final int[] raw, final int originX, final int originY, final boolean originKnown) { this.raw = raw; this.originX = originX; this.originY = originY; - this.coordinates = coordinates; + this.originKnown = originKnown; + } + + @NonNull + Lane withRaw(@NonNull final int[] newRaw) { + return new Lane(newRaw, originX, originY, originKnown); + } + + @NonNull + Lane withOrigin(final int newOriginX, final int newOriginY) { + return new Lane(raw, newOriginX, newOriginY, true); + } + + @NonNull + Lane withOriginUnknown() { + return new Lane(raw, originX, originY, false); + } + } + + /** One immutable published set for a display. Replaced wholesale. */ + private static final class Snapshot { + final int version; + /** Surface key → lane. TreeMap so the served order is stable. */ + final TreeMap lanes; + /** The display's real size {@code {width, height}} in pixels, or {@code null}. */ + @Nullable + final int[] bounds; + /** What the SDK is served: every lane in surface-key order, clamped to {@link #bounds}. */ + final int[] coordinates; + + Snapshot(final int version, @NonNull final TreeMap lanes, @Nullable final int[] bounds) { + this.version = version; + // Defensive copies: callers must not retain a mutable reference. + this.lanes = new TreeMap<>(lanes); + this.bounds = bounds == null ? null : bounds.clone(); + this.coordinates = serve(this.lanes, this.bounds); } @NonNull Snapshot withVersion(final int newVersion) { - return new Snapshot(newVersion, raw, originX, originY, coordinates); + return new Snapshot(newVersion, lanes, bounds); + } + + @NonNull + Snapshot withLanes(@NonNull final TreeMap nextLanes) { + return new Snapshot(0, nextLanes, bounds); + } + + @NonNull + Snapshot withBounds(@NonNull final int[] nextBounds) { + return new Snapshot(0, lanes, nextBounds); + } + + /** + * A lane whose origin is unknown serves the whole display; any other + * serves its rectangles moved by its origin. Every rectangle is then + * clamped to the display, and one left with no area is dropped: a + * renderer is never handed a rectangle that runs off the panel. + */ + @NonNull + private static int[] serve(@NonNull final TreeMap lanes, @Nullable final int[] bounds) { + final int width = bounds == null ? FALLBACK_DISPLAY_SIZE : bounds[0]; + final int height = bounds == null ? FALLBACK_DISPLAY_SIZE : bounds[1]; + int total = 0; + for (final Lane lane : lanes.values()) { + total += lane.raw.length == 0 ? 0 : lane.originKnown ? lane.raw.length : COORDINATES_PER_RECTANGLE; + } + if (total == 0) { + return NO_COORDINATES; + } + final int[] served = new int[total]; + int at = 0; + for (final Lane lane : lanes.values()) { + if (lane.raw.length == 0) { + continue; + } + if (!lane.originKnown) { + served[at++] = 0; + served[at++] = 0; + served[at++] = width; + served[at++] = height; + continue; + } + final int[] moved = translate(lane.raw, lane.originX, lane.originY); + for (int i = 0; i < moved.length; i += COORDINATES_PER_RECTANGLE) { + if (bounds == null) { + System.arraycopy(moved, i, served, at, COORDINATES_PER_RECTANGLE); + at += COORDINATES_PER_RECTANGLE; + continue; + } + final int left = clamp(moved[i], width); + final int top = clamp(moved[i + 1], height); + final int right = clamp(moved[i + 2], width); + final int bottom = clamp(moved[i + 3], height); + if (right <= left || bottom <= top) { + continue; + } + served[at++] = left; + served[at++] = top; + served[at++] = right; + served[at++] = bottom; + } + } + return at == served.length ? served : Arrays.copyOf(served, at); + } + + private static int clamp(final int value, final int max) { + return Math.max(0, Math.min(value, max)); } } /** What a display serves before anything is secured or located. */ - private static final Snapshot EMPTY = new Snapshot(INITIAL_VERSION, NO_COORDINATES, 0, 0); + private static final Snapshot EMPTY = new Snapshot(INITIAL_VERSION, new TreeMap<>(), null); /** * The process-wide set of secured regions. @@ -111,13 +270,23 @@ static SecureRectangleStore shared() { private final Map byDisplay = new ConcurrentHashMap<>(); /** - * Publishes {@code coordinates} as the secure set for {@code display}, as a - * flat list of four-int rectangles. + * Publishes {@code coordinates} as the secure set for {@code display}'s + * main surface, as a flat list of four-int rectangles. Other surfaces on + * the same display are left alone. * - *

The version moves only when the set actually differs, so an app that - * re-publishes an unchanged layout on every render costs the SDK nothing. + *

The version moves only when the served set actually differs, so an + * app that re-publishes an unchanged layout on every render costs the SDK + * nothing. */ void set(final int display, @Nullable final int[] coordinates) { + set(display, MAIN_SURFACE, coordinates); + } + + /** + * Publishes {@code coordinates} for one surface on {@code display}. Other + * surfaces keep their rectangles and origins. + */ + void set(final int display, final int surface, @Nullable final int[] coordinates) { if (coordinates == null) { throw new IllegalArgumentException( "secure rectangles cannot be null; pass an empty array to clear them"); @@ -131,7 +300,7 @@ void set(final int display, @Nullable final int[] coordinates) { // Copy on the way in as well as out: the caller keeps its array and a // later write through it would edit a snapshot the SDK is reading. final int[] raw = coordinates.clone(); - update(display, previous -> new Snapshot(0, raw, previous.originX, previous.originY)); + update(display, previous -> withLane(previous, surface, lane -> lane.withRaw(raw))); } /** @@ -143,8 +312,27 @@ void set(final int display, @Nullable final int[] coordinates) { * @return whether {@code coordinates} was published */ boolean publishOrLog(final int display, @Nullable final int[] coordinates) { + return publishOrLog(display, MAIN_SURFACE, coordinates); + } + + boolean publishOrLog(final int display, final int surface, @Nullable final int[] coordinates) { + return publishForRuntime(currentClaim(), display, surface, coordinates); + } + + /** + * {@link #publishOrLog}, written by the module holding {@code claim}: ignored + * when a newer runtime has claimed the store since. What the TurboModule + * calls. + * + * @return whether {@code coordinates} was published + */ + synchronized boolean publishForRuntime(final int claim, final int display, final int surface, + @Nullable final int[] coordinates) { + if (!isCurrentLocked(claim)) { + return false; + } try { - set(display, coordinates); + set(display, surface, coordinates); return true; } catch (IllegalArgumentException e) { Log.e(TAG, "setSecureRectangles rejected; the previous set stays published: " @@ -154,8 +342,9 @@ boolean publishOrLog(final int display, @Nullable final int[] coordinates) { } /** - * Records where the React root's viewport origin sits on {@code display}, - * in display pixels, and serves every rectangle moved by it. + * Records where the main React root's viewport origin sits on {@code + * display}, in display pixels, and serves that surface's rectangles moved + * by it. Other surfaces keep their own origins. * *

JS measures with {@code measureInWindow}, which React Native makes * relative to the root's viewport offset @@ -171,13 +360,239 @@ boolean publishOrLog(final int display, @Nullable final int[] coordinates) { * for integer {@code k}. */ void setOrigin(final int display, final int originX, final int originY) { - update(display, previous -> new Snapshot(0, previous.raw, originX, originY)); + setOrigin(display, MAIN_SURFACE, originX, originY); + } + + /** + * Records where one surface's viewport origin sits on {@code display}. + * Only that surface's rectangles move; a {@code } and the activity + * root must not share one origin. + */ + void setOrigin(final int display, final int surface, final int originX, final int originY) { + update(display, previous -> withLane(previous, surface, + lane -> lane.withOrigin(originX, originY))); + } + + /** + * {@link #setOrigin(int, int, int, int)}, written by the module holding + * {@code claim}: ignored when a newer runtime has claimed the store since. + * An old runtime's dialog root must not place the new runtime's Modal. + */ + synchronized void setOriginForRuntime(final int claim, final int display, final int surface, + final int originX, final int originY) { + if (isCurrentLocked(claim)) { + setOrigin(display, surface, originX, originY); + } + } + + /** + * Records {@code display}'s real size in pixels: what a fail-closed lane + * serves, and what every rectangle is clamped to. + */ + void setDisplayBounds(final int display, final int width, final int height) { + if (width <= 0 || height <= 0) { + return; + } + update(display, previous -> previous.bounds != null + && previous.bounds[0] == width && previous.bounds[1] == height + ? previous + : previous.withBounds(new int[] { width, height })); + } + + /** The current JS runtime's claim; see {@link #claimRuntime}. Guarded by {@code this}. */ + private int runtime; + + /** + * The claim a runtime holds now. For writers that belong to no runtime + * of their own (tests, single-runtime callers); a module writes with the + * claim {@link #claimRuntime} gave it. + */ + synchronized int currentClaim() { + return runtime; + } + + /** + * Whether {@code claim} is still the current runtime's. A stale one is + * logged at debug, numbers only. Caller holds {@code this}. + */ + private boolean isCurrentLocked(final int claim) { + if (claim == runtime) { + return true; + } + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "secure write ignored: runtime claim " + claim + + " is stale, current " + runtime); + } + return false; + } + + /** + * A new JS runtime's module is starting: drops every surface but the main + * one, whose keys the new runtime cannot know, empties the main one's + * rectangles (keeping its origin), and returns its claim. Synchronised + * with {@link #releaseRuntime} so an old module released after the new + * one started cannot drop the new runtime's surfaces. + */ + synchronized int claimRuntime() { + runtime++; + dropNonMainSurfaces(); + emptyMainSurface(); + return runtime; + } + + /** + * Empties the main surface's rectangles on every display, keeping its + * origin: the old runtime's set must not outlive it, and a new runtime + * that never publishes on the main surface would otherwise inherit it. + */ + private void emptyMainSurface() { + for (final Integer display : byDisplay.keySet()) { + update(display, previous -> { + final Lane main = previous.lanes.get(MAIN_SURFACE); + if (main == null || main.raw.length == 0) { + return previous; + } + final TreeMap nextLanes = new TreeMap<>(previous.lanes); + nextLanes.put(MAIN_SURFACE, main.withRaw(NO_COORDINATES)); + return previous.withLanes(nextLanes); + }); + } + } + + /** + * The module holding {@code claim} is gone. Drops every surface but the + * main one, unless a newer runtime has already claimed the store. + */ + synchronized void releaseRuntime(final int claim) { + if (claim == runtime) { + dropNonMainSurfaces(); + } + } + + /** How many surfaces other than the main one hold rectangles, on every display. */ + int nonMainSurfacesWithRectangles() { + int count = 0; + for (final Snapshot snapshot : byDisplay.values()) { + for (final java.util.Map.Entry entry : snapshot.lanes.entrySet()) { + if (entry.getKey() != MAIN_SURFACE && entry.getValue().raw.length > 0) { + count++; + } + } + } + return count; + } + + /** Drops every surface but the main one, on every display. */ + private void dropNonMainSurfaces() { + for (final Integer display : byDisplay.keySet()) { + update(display, previous -> { + if (previous.lanes.isEmpty() + || (previous.lanes.size() == 1 && previous.lanes.containsKey(MAIN_SURFACE))) { + return previous; + } + final TreeMap nextLanes = new TreeMap<>(); + final Lane main = previous.lanes.get(MAIN_SURFACE); + if (main != null) { + nextLanes.put(MAIN_SURFACE, main); + } + return previous.withLanes(nextLanes); + }); + } } /** - * {@code locationOnScreen} of the React root less React Native's viewport + * {@link #forgetOrigin}, by the module holding {@code claim}: ignored when + * a newer runtime has claimed the store since. + */ + synchronized void forgetOriginForRuntime(final int claim, final int surface) { + if (isCurrentLocked(claim)) { + forgetOrigin(surface); + } + } + + /** + * {@link #dropSurfaceIfEmpty}, by the module holding {@code claim}: ignored + * when a newer runtime has claimed the store since. + */ + synchronized void dropSurfaceIfEmptyForRuntime(final int claim, final int surface) { + if (isCurrentLocked(claim)) { + dropSurfaceIfEmpty(surface); + } + } + + /** Whether {@code display}'s real size has been recorded. */ + boolean hasDisplayBounds(final int display) { + final Snapshot current = byDisplay.get(display); + return current != null && current.bounds != null; + } + + /** + * {@code surface}'s root is gone: its origin no longer holds, so every + * display serves its rectangles as the whole display again until a new + * origin is read. An empty lane is unaffected. + */ + void forgetOrigin(final int surface) { + for (final Integer display : byDisplay.keySet()) { + update(display, previous -> { + final Lane lane = previous.lanes.get(surface); + if (lane == null || !lane.originKnown) { + return previous; + } + final TreeMap nextLanes = new TreeMap<>(previous.lanes); + nextLanes.put(surface, lane.withOriginUnknown()); + return previous.withLanes(nextLanes); + }); + } + } + + /** Whether {@code surface} holds rectangles on any display. */ + boolean hasRectangles(final int surface) { + for (final Snapshot snapshot : byDisplay.values()) { + final Lane lane = snapshot.lanes.get(surface); + if (lane != null && lane.raw.length > 0) { + return true; + } + } + return false; + } + + /** + * Forgets {@code surface} on every display where it holds no rectangles: + * its root is gone, and a later root may reuse the key. A surface that + * still holds rectangles stays (fails closed): only its owner clearing + * them removes the redaction. + */ + void dropSurfaceIfEmpty(final int surface) { + if (surface == MAIN_SURFACE) { + return; + } + for (final Integer display : byDisplay.keySet()) { + update(display, previous -> { + final Lane lane = previous.lanes.get(surface); + if (lane == null || lane.raw.length > 0) { + return previous; + } + final TreeMap nextLanes = new TreeMap<>(previous.lanes); + nextLanes.remove(surface); + return previous.withLanes(nextLanes); + }); + } + } + + /** Whether {@code surface} has a lane on any display. Tests and the tracker read it. */ + boolean hasSurface(final int surface) { + for (final Snapshot snapshot : byDisplay.values()) { + if (snapshot.lanes.containsKey(surface)) { + return true; + } + } + return false; + } + + /** + * {@code locationOnScreen} of a React root less React Native's viewport * offset for it: the display-pixel position of the origin - * {@code measureInWindow} measures from. + * {@code measureInWindow} measures from on that surface. */ @NonNull static int[] displayOrigin(@NonNull final int[] locationOnScreen, @@ -185,12 +600,27 @@ static int[] displayOrigin(@NonNull final int[] locationOnScreen, return new int[] { locationOnScreen[0] - viewportX, locationOnScreen[1] - viewportY }; } + private interface LaneChange { + @NonNull + Lane apply(@NonNull Lane previous); + } + private interface Change { /** The next state, built from the current one; its version is ignored. */ @NonNull Snapshot apply(@NonNull Snapshot previous); } + @NonNull + private static Snapshot withLane(@NonNull final Snapshot previous, final int surface, + @NonNull final LaneChange change) { + final TreeMap nextLanes = new TreeMap<>(previous.lanes); + final Lane prior = nextLanes.get(surface); + final Lane base = prior == null ? new Lane(NO_COORDINATES, 0, 0, false) : prior; + nextLanes.put(surface, change.apply(base)); + return previous.withLanes(nextLanes); + } + /** * The version moves only when the SERVED set differs, so an app that * re-publishes an unchanged layout, or a layout pass that re-reports the diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/BugseeReactNativeWrapperTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/BugseeReactNativeWrapperTest.java index 9629f841..58edecc7 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/BugseeReactNativeWrapperTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/BugseeReactNativeWrapperTest.java @@ -90,6 +90,9 @@ public void answersAnUnknownDataTypeToo() { */ @Test public void securedRegionsSurviveTheWrapperBeingReplaced() { + // The main surface's origin read, as the tracker does at start; + // before that read the store serves the whole display (fail closed). + SecureRectangleStore.shared().setOrigin(0, 0, 0); SecureRectangleStore.shared().set(0, new int[] { 1, 2, 3, 4 }); final int[] packed = wrapper(context()).getSecureRectangles(0); diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactRootOriginTrackerTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactRootOriginTrackerTest.java index 87b9b610..72552c4f 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactRootOriginTrackerTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactRootOriginTrackerTest.java @@ -2,7 +2,10 @@ import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import android.graphics.Point; @@ -57,7 +60,12 @@ private static final class FakeRoot implements RootHandle { boolean attached = true; int[] location = { 0, 0 }; Point viewport = new Point(); + /** The display's real size this root reports, or null. */ + Point displaySize; int displayId; + int surfaceKey = SecureRectangleStore.MAIN_SURFACE; + /** A ReactRoot (the activity root) rather than a dialog root. */ + boolean reactRoot = true; /** Simulates the weakly-held view having been collected mid-refresh. */ boolean viewGone; int listenerRegistrations; @@ -68,12 +76,18 @@ public boolean isAttachedToWindow() { return attached; } + @Override + public int surfaceKey() { + return surfaceKey; + } + @Override public OriginSnapshot resolveOrigin() { if (viewGone) { return null; } - return new OriginSnapshot(new int[] { location[0], location[1] }, viewport, displayId); + return new OriginSnapshot(new int[] { location[0], location[1] }, viewport, displayId, reactRoot, + displaySize); } @Override @@ -329,6 +343,451 @@ public void currentOriginIsNullBeforeAnyRootIsFound() { assertNull(tracker.currentOrigin()); } + /** + * watchNow must keep the LayoutListenerToken it receives and release it + * when the handle leaves watched (detach, or a detached-root refresh). + * Dropping the token leaks the global-layout listener on the dialog root. + */ + @Test + public void detachingAWatchedSurfaceReleasesItsLayoutToken() { + final FakeRoot modal = new FakeRoot(); + modal.surfaceKey = 42; + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), new SecureRectangleStore()); + tracker.watchNow(modal); + final FakeToken token = modal.lastToken; + assertEquals(1, modal.listenerRegistrations); + assertNotNull(token); + + tracker.detach(); + + assertEquals("watchNow must retain the token so detach can release it", + 1, token.releaseCalls); + } + + @Test + public void aDetachedWatchedSurfaceIsReleasedOnRefresh() { + final FakeRoot modal = new FakeRoot(); + modal.surfaceKey = 42; + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), new SecureRectangleStore()); + tracker.watchNow(modal); + final FakeToken token = modal.lastToken; + + modal.attached = false; + tracker.refresh(); + + assertEquals(1, token.releaseCalls); + } + + /** + * A {@code }'s DialogRootViewGroup is a second surface. Its + * measureInWindow values are relative to that root, so the activity + * origin must not translate them, and its own origin must. + */ + @Test + public void aWatchedSurfacesOriginMovesOnlyItsRectangles() { + final FakeRoot main = new FakeRoot(); + main.location = new int[] { 0, 96 }; + main.viewport.x = 0; + main.viewport.y = 0; + main.displayId = 0; + main.surfaceKey = SecureRectangleStore.MAIN_SURFACE; + final FakeRoot modal = new FakeRoot(); + modal.location = new int[] { 40, 200 }; + // A dialog root is not a ReactRoot: Fabric never applies its viewport + // offset, so the offset RootViewUtil would report (here a translucent + // status bar's -51) must not move its rectangles. + modal.reactRoot = false; + modal.viewport.x = 0; + modal.viewport.y = -51; + modal.displayId = 0; + modal.surfaceKey = 42; + final QueueRootFinder finder = new QueueRootFinder(); + finder.queue.add(main); + final SecureRectangleStore store = new SecureRectangleStore(); + store.set(0, SecureRectangleStore.MAIN_SURFACE, new int[] { 10, 20, 30, 40 }); + store.set(0, 42, new int[] { 100, 50, 180, 90 }); + + final ReactRootOriginTracker tracker = + new ReactRootOriginTracker(new FakeLifecycleSource(), finder, store); + tracker.watchNow(modal); + tracker.refresh(); + + final int[] packed = store.snapshot(0); + assertEquals(2, packed[1]); + // Main origin (0, 96): (10,20)-(30,40) → (10,116)-(30,136). + // Modal origin (40, 200): (100,50)-(180,90) → (140,250)-(220,290). + assertArrayEquals( + new int[] { 10, 116, 30, 136, 140, 250, 220, 290 }, + java.util.Arrays.copyOfRange(packed, 2, packed.length)); + + // Moving only the activity root must not drag the modal rectangle. + main.location = new int[] { 0, 200 }; + tracker.refresh(); + final int[] afterMainMove = store.snapshot(0); + assertArrayEquals( + new int[] { 10, 220, 30, 240, 140, 250, 220, 290 }, + java.util.Arrays.copyOfRange(afterMainMove, 2, afterMainMove.length)); + } + + @Test + public void theActivityRootsOriginSubtractsItsViewportOffset() { + final OriginSnapshot snapshot = new OriginSnapshot(new int[] { 0, 51 }, new Point(), 0, true); + snapshot.viewport.y = 51; + assertArrayEquals(new int[] { 0, 0 }, ReactRootOriginTracker.surfaceOrigin(snapshot)); + } + + @Test + public void aDialogRootsOriginIsItsLocationOnScreenAlone() { + final OriginSnapshot snapshot = new OriginSnapshot(new int[] { 0, 0 }, new Point(), 0, false); + snapshot.viewport.y = -51; + assertArrayEquals(new int[] { 0, 0 }, ReactRootOriginTracker.surfaceOrigin(snapshot)); + } + + /** The activity root is the main surface whatever its own view id. */ + @Test + public void theActivityRootPublishesOnTheMainSurfaceOnly() { + final FakeRoot main = new FakeRoot(); + main.location = new int[] { 0, 51 }; + main.surfaceKey = 1; + final QueueRootFinder finder = new QueueRootFinder(); + finder.queue.add(main); + final SecureRectangleStore store = new SecureRectangleStore(); + store.set(0, new int[] { 10, 20, 30, 40 }); + + new ReactRootOriginTracker(new FakeLifecycleSource(), finder, store).refresh(); + + assertArrayEquals(new int[] { 10, 71, 30, 91 }, + java.util.Arrays.copyOfRange(store.snapshot(0), 2, 6)); + assertFalse(store.hasSurface(1)); + } + + /** A resolver whose answer a test sets. */ + private static final class FakeResolver implements ReactRootOriginTracker.SurfaceResolver { + RootHandle answer; + int calls; + + @Override + public RootHandle resolve(final int surfaceKey) { + calls++; + return answer; + } + } + + private static FakeRoot dialog(final int key, final int x, final int y) { + final FakeRoot dialog = new FakeRoot(); + dialog.surfaceKey = key; + dialog.reactRoot = false; + dialog.location = new int[] { x, y }; + return dialog; + } + + /** + * A 's dialog root that does not exist yet stays pending: its + * rectangles fail closed until a refresh finds it. + */ + @Test + public void aSurfaceWhoseDialogRootIsNotFoundYetFailsClosedUntilARefreshFindsIt() { + final FakeResolver resolver = new FakeResolver(); + final SecureRectangleStore store = new SecureRectangleStore(); + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), resolver, store); + store.set(0, 56, new int[] { 208, 560, 608, 760 }); + + tracker.watchSurfaceNow(56); + assertArrayEquals(WHOLE_FALLBACK, + java.util.Arrays.copyOfRange(store.snapshot(0), 2, 6)); + assertNull(tracker.cachedSurfaceOrigin(56)); + + resolver.answer = dialog(56, 0, 0); + tracker.refresh(); + + assertArrayEquals(new int[] { 208, 560, 608, 760 }, + java.util.Arrays.copyOfRange(store.snapshot(0), 2, 6)); + assertArrayEquals(new int[] { 0, 0 }, tracker.cachedSurfaceOrigin(56)); + } + + /** + * A tracker whose module has been replaced (a reload, before the old + * module is invalidated) must not place the new runtime's Modal with its + * own, old dialog root: the key may be the same React tag. + */ + @Test + public void aTrackerWithAStaleClaimWritesNothingToTheNewRuntimesLane() { + final FakeResolver resolver = new FakeResolver(); + resolver.answer = dialog(56, 0, 0); + final SecureRectangleStore store = new SecureRectangleStore(); + store.setDisplayBounds(0, 1080, 2340); + final int old = store.claimRuntime(); + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), resolver, store, old); + final int current = store.claimRuntime(); + store.publishForRuntime(current, 0, 56, new int[] { 208, 560, 608, 760 }); + store.setOriginForRuntime(current, 0, 56, 100, 200); + final int[] before = store.snapshot(0); + + tracker.watchSurfaceNow(56); + tracker.refresh(); + + assertArrayEquals(before, store.snapshot(0)); + assertArrayEquals(new int[] { 308, 760, 708, 960 }, + java.util.Arrays.copyOfRange(store.snapshot(0), 2, 6)); + } + + @Test + public void aWatchedSurfaceIsResolvedOnce() { + final FakeResolver resolver = new FakeResolver(); + resolver.answer = dialog(56, 0, 0); + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), resolver, new SecureRectangleStore()); + + tracker.watchSurfaceNow(56); + tracker.watchSurfaceNow(56); + tracker.refresh(); + + assertEquals(1, resolver.calls); + assertEquals(1, ((FakeRoot) resolver.answer).listenerRegistrations); + } + + @Test + public void theMainSurfaceAndUnknownKeysAreNeverResolved() { + final FakeResolver resolver = new FakeResolver(); + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), resolver, new SecureRectangleStore()); + + tracker.watchSurfaceNow(SecureRectangleStore.MAIN_SURFACE); + tracker.watchSurfaceNow(-1); + tracker.refresh(); + + assertEquals(0, resolver.calls); + } + + @Test + public void aThrowingResolverLeavesTheSurfacePending() { + final SecureRectangleStore store = new SecureRectangleStore(); + final int[] calls = { 0 }; + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), key -> { + calls[0]++; + throw new IllegalStateException("no view"); + }, store); + store.set(0, 56, new int[] { 1, 2, 3, 4 }); + + tracker.watchSurfaceNow(56); + tracker.refresh(); + + assertEquals(2, calls[0]); + assertArrayEquals(WHOLE_FALLBACK, + java.util.Arrays.copyOfRange(store.snapshot(0), 2, 6)); + } + + @Test + public void surfaceOriginNowReadsTheDialogRootsCurrentPlace() { + final FakeResolver resolver = new FakeResolver(); + final FakeRoot dialog = dialog(56, 0, 0); + resolver.answer = dialog; + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), resolver, new SecureRectangleStore()); + + assertArrayEquals(new int[] { 0, 0 }, tracker.surfaceOriginNow(56)); + dialog.location = new int[] { 0, 30 }; + assertArrayEquals(new int[] { 0, 30 }, tracker.surfaceOriginNow(56)); + assertNull(tracker.surfaceOriginNow(57)); + } + + /** A dialog root that leaves the window takes its empty lane with it. */ + @Test + public void aDetachedDialogRootDropsItsEmptyLaneAndItsOrigin() { + final FakeResolver resolver = new FakeResolver(); + final FakeRoot dialog = dialog(56, 0, 0); + resolver.answer = dialog; + final SecureRectangleStore store = new SecureRectangleStore(); + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), resolver, store); + store.set(0, 56, new int[] { 1, 2, 3, 4 }); + tracker.watchSurfaceNow(56); + store.set(0, 56, new int[0]); + assertTrue(store.hasSurface(56)); + + dialog.attached = false; + tracker.refresh(); + + assertFalse(store.hasSurface(56)); + assertNull(tracker.cachedSurfaceOrigin(56)); + } + + @Test + public void aPendingSurfaceWithNoRectanglesIsPrunedNotRetried() { + final FakeResolver resolver = new FakeResolver(); + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), resolver, new SecureRectangleStore()); + tracker.watchSurfaceNow(56); + tracker.detach(); + tracker.refresh(); + + assertEquals(1, resolver.calls); + } + + private static final int[] WHOLE_FALLBACK = { + 0, 0, SecureRectangleStore.FALLBACK_DISPLAY_SIZE, SecureRectangleStore.FALLBACK_DISPLAY_SIZE }; + + private static int[] served(final SecureRectangleStore store) { + final int[] packed = store.snapshot(0); + return java.util.Arrays.copyOfRange(packed, 2, packed.length); + } + + /** N3: a Modal cleared after its host is gone leaves nothing pending and no lane. */ + @Test + public void clearingAPendingSurfacesRectanglesDropsItAndItsLane() { + final FakeResolver resolver = new FakeResolver(); + final SecureRectangleStore store = new SecureRectangleStore(); + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), resolver, store); + store.set(0, 56, new int[] { 1, 2, 3, 4 }); + tracker.watchSurfaceNow(56); + tracker.refresh(); + assertEquals(2, resolver.calls); + + store.set(0, 56, new int[0]); + tracker.refresh(); + tracker.refresh(); + + assertEquals(2, resolver.calls); + assertFalse(store.hasSurface(56)); + } + + /** N2: a dialog root seen detached while its surface holds rectangles fails closed and is re-watched. */ + @Test + public void aDetachedDialogRootWithRectanglesFailsClosedAndIsWatchedAgain() { + final FakeResolver resolver = new FakeResolver(); + final FakeRoot first = dialog(56, 0, 0); + resolver.answer = first; + final SecureRectangleStore store = new SecureRectangleStore(); + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), resolver, store); + store.set(0, 56, new int[] { 208, 560, 608, 760 }); + tracker.watchSurfaceNow(56); + assertArrayEquals(new int[] { 208, 560, 608, 760 }, served(store)); + + // The dialog is recreated: the old root leaves its window, and the + // resolver cannot find the new one yet. + first.attached = false; + resolver.answer = null; + tracker.refresh(); + + assertArrayEquals(WHOLE_FALLBACK, served(store)); + assertEquals(1, first.lastToken.releaseCalls); + assertNull(tracker.cachedSurfaceOrigin(56)); + + // A later refresh finds it again, now at a different place. + resolver.answer = dialog(56, 0, 30); + tracker.refresh(); + + assertArrayEquals(new int[] { 208, 590, 608, 790 }, served(store)); + } + + /** N2: an activity destroyed with a Modal open keeps the surface failing closed, and pending. */ + @Test + public void detachKeepsASurfaceWithRectanglesPendingAndFailingClosed() { + final FakeResolver resolver = new FakeResolver(); + resolver.answer = dialog(56, 0, 0); + final SecureRectangleStore store = new SecureRectangleStore(); + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), resolver, store); + store.set(0, 56, new int[] { 208, 560, 608, 760 }); + tracker.watchSurfaceNow(56); + + tracker.detach(); + assertArrayEquals(WHOLE_FALLBACK, served(store)); + + resolver.answer = dialog(56, 0, 0); + tracker.refresh(); + assertArrayEquals(new int[] { 208, 560, 608, 760 }, served(store)); + assertEquals(2, resolver.calls); + } + + /** N2: a root that is not attached records no origin; its first layout after attaching does. */ + @Test + public void aRootThatIsNotAttachedRecordsNoOrigin() { + final FakeResolver resolver = new FakeResolver(); + final FakeRoot notYet = dialog(56, 0, 40); + notYet.attached = false; + resolver.answer = notYet; + final SecureRectangleStore store = new SecureRectangleStore(); + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), resolver, store); + store.set(0, 56, new int[] { 1, 2, 3, 4 }); + + tracker.watchSurfaceNow(56); + assertArrayEquals(WHOLE_FALLBACK, served(store)); + assertNull(tracker.cachedSurfaceOrigin(56)); + assertEquals(1, notYet.listenerRegistrations); + + notYet.attached = true; + tracker.refresh(); + assertArrayEquals(new int[] { 1, 42, 3, 44 }, served(store)); + } + + @Test + public void anActivityRootThatIsNotAttachedRecordsNoOrigin() { + final FakeRoot main = new FakeRoot(); + main.attached = false; + main.location = new int[] { 0, 51 }; + final QueueRootFinder finder = new QueueRootFinder(); + finder.queue.add(main); + final SecureRectangleStore store = new SecureRectangleStore(); + store.set(0, new int[] { 10, 20, 30, 40 }); + + new ReactRootOriginTracker(new FakeLifecycleSource(), finder, store).refresh(); + + assertArrayEquals(WHOLE_FALLBACK, served(store)); + } + + /** N1: the root's display size bounds what a fail-closed surface serves. */ + @Test + public void theRootsDisplaySizeBoundsAFailClosedSurface() { + final FakeRoot main = new FakeRoot(); + main.displaySize = new Point(); + main.displaySize.x = 720; + main.displaySize.y = 1612; + final QueueRootFinder finder = new QueueRootFinder(); + finder.queue.add(main); + final SecureRectangleStore store = new SecureRectangleStore(); + store.set(0, 56, new int[] { 1, 2, 3, 4 }); + + new ReactRootOriginTracker(new FakeLifecycleSource(), finder, store).refresh(); + + assertTrue(store.hasDisplayBounds(0)); + assertArrayEquals(new int[] { 0, 0, 720, 1612 }, served(store)); + } + + /** N7: a disposed tracker (the old runtime) keeps nothing pending for a Modal it watched. */ + @Test + public void disposingWithAnOpenModalLeavesNothingPending() { + final FakeResolver resolver = new FakeResolver(); + resolver.answer = dialog(56, 0, 0); + final SecureRectangleStore store = new SecureRectangleStore(); + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), resolver, store); + store.set(0, 56, new int[] { 208, 560, 608, 760 }); + tracker.watchSurfaceNow(56); + // A second Modal whose root was never found: pending. + resolver.answer = null; + store.set(0, 57, new int[] { 1, 2, 3, 4 }); + tracker.watchSurfaceNow(57); + assertEquals(1, tracker.pendingSurfaceCount()); + + tracker.dispose(); + // dispose() posts detach() to the UI thread; run it as that would. + tracker.detach(); + tracker.refresh(); + + assertEquals(0, tracker.pendingSurfaceCount()); + // Nothing is resolved after dispose. + assertEquals(2, resolver.calls); + } + @Test public void constructorRegistersAsALifecycleListener() { final FakeLifecycleSource lifecycle = new FakeLifecycleSource(); diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java index 67a3f0db..bf4b3c3a 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java @@ -26,17 +26,29 @@ public class SecureRectangleStoreTest { private static final int DISPLAY = 0; + /** + * A store whose main surface's origin has been read, at (0, 0), on + * displays 0 and 1: what every test about publishing, versions and + * translation starts from. The fail-closed tests below build a bare one. + */ + private static SecureRectangleStore located() { + final SecureRectangleStore store = new SecureRectangleStore(); + store.setOrigin(DISPLAY, 0, 0); + store.setOrigin(1, 0, 0); + return store; + } + /** `[version, count]` with no rectangles, not an empty buffer. */ @Test public void publishesAnEmptySetBeforeAnythingIsSecured() { - final int[] packed = new SecureRectangleStore().snapshot(DISPLAY); + final int[] packed = located().snapshot(DISPLAY); assertEquals(2, packed.length); assertEquals(0, packed[1]); } @Test public void packsVersionCountThenEachRectangle() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.set(DISPLAY, new int[] { 1, 2, 3, 4, 10, 20, 30, 40 }); final int[] packed = store.snapshot(DISPLAY); @@ -48,7 +60,7 @@ public void packsVersionCountThenEachRectangle() { @Test public void movesTheVersionWhenTheRectanglesChange() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); final int before = store.snapshot(DISPLAY)[0]; store.set(DISPLAY, new int[] { 1, 2, 3, 4 }); @@ -59,7 +71,7 @@ public void movesTheVersionWhenTheRectanglesChange() { /** Re-publishing the same set must not make the SDK re-read it. */ @Test public void holdsTheVersionWhenNothingChanges() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.set(DISPLAY, new int[] { 1, 2, 3, 4 }); final int settled = store.snapshot(DISPLAY)[0]; @@ -75,7 +87,7 @@ public void holdsTheVersionWhenNothingChanges() { */ @Test public void movesTheVersionWhenOnlyTheCoordinatesChange() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.set(DISPLAY, new int[] { 1, 2, 3, 4 }); final int before = store.snapshot(DISPLAY)[0]; @@ -87,7 +99,7 @@ public void movesTheVersionWhenOnlyTheCoordinatesChange() { /** Clearing is a change like any other: the last secret region must stop. */ @Test public void movesTheVersionWhenTheLastRectangleIsRemoved() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.set(DISPLAY, new int[] { 1, 2, 3, 4 }); final int before = store.snapshot(DISPLAY)[0]; @@ -100,7 +112,7 @@ public void movesTheVersionWhenTheLastRectangleIsRemoved() { /** The freshness clock is per display, so one screen cannot stale another. */ @Test public void versionsEachDisplayIndependently() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); final int otherBefore = store.snapshot(1)[0]; store.set(DISPLAY, new int[] { 1, 2, 3, 4 }); @@ -116,7 +128,7 @@ public void versionsEachDisplayIndependently() { */ @Test public void handsOutASnapshotThatCannotAliasTheStore() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.set(DISPLAY, new int[] { 1, 2, 3, 4 }); final int[] first = store.snapshot(DISPLAY); @@ -129,7 +141,7 @@ public void handsOutASnapshotThatCannotAliasTheStore() { /** Callers cannot keep writing through the array they handed us either. */ @Test public void copiesWhatItIsGiven() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); final int[] mutable = { 1, 2, 3, 4 }; store.set(DISPLAY, mutable); @@ -145,7 +157,7 @@ public void copiesWhatItIsGiven() { */ @Test public void rejectsACoordinateListThatIsNotWholeRectangles() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); final IllegalArgumentException thrown = assertThrows( IllegalArgumentException.class, () -> store.set(DISPLAY, new int[] { 1, 2, 3 })); @@ -155,7 +167,7 @@ public void rejectsACoordinateListThatIsNotWholeRectangles() { @Test public void rejectsNullCoordinates() { assertThrows(IllegalArgumentException.class, - () -> new SecureRectangleStore().set(DISPLAY, null)); + () -> located().set(DISPLAY, null)); } // ---- Display origin ------------------------------------------------- @@ -169,7 +181,7 @@ public void rejectsNullCoordinates() { @Test public void translatesEveryRectangleByTheDisplayOrigin() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.setOrigin(DISPLAY, 7, 96); store.set(DISPLAY, new int[] { 10, 20, 30, 40, 50, 60, 70, 80 }); @@ -187,7 +199,7 @@ public void translatesEveryRectangleByTheDisplayOrigin() { */ @Test public void anOriginChangeMovesPublishedRectanglesAndTheVersion() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.set(DISPLAY, new int[] { 10, 20, 30, 40 }); final int before = store.snapshot(DISPLAY)[0]; @@ -202,7 +214,7 @@ public void anOriginChangeMovesPublishedRectanglesAndTheVersion() { /** The origin is re-reported on every layout pass; an unchanged one is free. */ @Test public void anUnchangedOriginHoldsTheVersion() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.setOrigin(DISPLAY, 0, 96); store.set(DISPLAY, new int[] { 10, 20, 30, 40 }); final int settled = store.snapshot(DISPLAY)[0]; @@ -215,7 +227,7 @@ public void anUnchangedOriginHoldsTheVersion() { /** A republish after an origin change keeps the origin, not (0, 0). */ @Test public void keepsTheOriginAcrossRepublishes() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.setOrigin(DISPLAY, 0, 96); store.set(DISPLAY, new int[] { 1, 2, 3, 4 }); store.set(DISPLAY, new int[] { 10, 20, 30, 40 }); @@ -227,7 +239,7 @@ public void keepsTheOriginAcrossRepublishes() { @Test public void anOriginAppliesOnlyToItsOwnDisplay() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.setOrigin(1, 0, 96); store.set(DISPLAY, new int[] { 10, 20, 30, 40 }); @@ -239,7 +251,7 @@ public void anOriginAppliesOnlyToItsOwnDisplay() { /** An origin with nothing secured serves the empty set, at the empty version. */ @Test public void anOriginAloneSecuresNothing() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); final int[] empty = store.snapshot(DISPLAY); store.setOrigin(DISPLAY, 0, 96); @@ -250,7 +262,7 @@ public void anOriginAloneSecuresNothing() { /** Past int32 the sum saturates outwards rather than wrapping elsewhere. */ @Test public void saturatesOutwardsInsteadOfWrapping() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.setOrigin(DISPLAY, -10, 10); store.set(DISPLAY, new int[] { Integer.MIN_VALUE + 5, 0, 0, Integer.MAX_VALUE - 5 }); @@ -276,6 +288,393 @@ public void theDisplayOriginIsTheScreenLocationLessTheViewportOffset() { SecureRectangleStore.displayOrigin(new int[] { 80, 1296 }, 0, 96)); } + // ---- Per-surface origins (Modal / DialogRootViewGroup) -------------- + // + // Fabric measureInWindow stops at the nearest RootNodeKind ancestor + // (ModalHostViewShadowNode sets that trait), so a rectangle measured + // inside a is already relative to THAT surface's root -- not + // the activity React root. One setOrigin for the whole display cannot + // serve both: the activity origin must not move the modal's rectangle, + // and the modal surface's own origin must. + + private static final int MAIN_SURFACE = 0; + private static final int MODAL_SURFACE = 42; + + @Test + public void theMainOriginDoesNotMoveAnotherSurfacesRectangle() { + final SecureRectangleStore store = located(); + store.set(DISPLAY, MAIN_SURFACE, new int[] { 10, 20, 30, 40 }); + store.set(DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 }); + store.setOrigin(DISPLAY, MAIN_SURFACE, 0, 96); + store.setOrigin(DISPLAY, MODAL_SURFACE, 0, 0); + + final int[] packed = store.snapshot(DISPLAY); + assertEquals(2, packed[1]); + assertArrayEquals( + new int[] { 10, 116, 30, 136, 100, 200, 150, 250 }, + java.util.Arrays.copyOfRange(packed, 2, packed.length)); + } + + @Test + public void anotherSurfacesOwnOriginMovesOnlyItsRectangle() { + final SecureRectangleStore store = located(); + store.set(DISPLAY, MAIN_SURFACE, new int[] { 10, 20, 30, 40 }); + store.set(DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 }); + store.setOrigin(DISPLAY, MAIN_SURFACE, 0, 96); + store.setOrigin(DISPLAY, MODAL_SURFACE, 7, 40); + + final int[] packed = store.snapshot(DISPLAY); + assertEquals(2, packed[1]); + assertArrayEquals( + new int[] { 10, 116, 30, 136, 107, 240, 157, 290 }, + java.util.Arrays.copyOfRange(packed, 2, packed.length)); + } + + // ---- Failing closed, and the display's bounds ------------------------ + + private static final int WIDTH = 720; + private static final int HEIGHT = 1612; + private static final int[] WHOLE_DISPLAY = { 0, 0, WIDTH, HEIGHT }; + + private static int[] rectanglesOf(final int[] packed) { + return java.util.Arrays.copyOfRange(packed, 2, packed.length); + } + + /** A 's rectangles before its origin is read redact exactly the whole display. */ + @Test + public void aSurfaceWithNoOriginYetServesTheDisplayBounds() { + final SecureRectangleStore store = new SecureRectangleStore(); + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + store.set(DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250, 1, 2, 3, 4 }); + + final int[] packed = store.snapshot(DISPLAY); + assertEquals(1, packed[1]); + assertArrayEquals(WHOLE_DISPLAY, rectanglesOf(packed)); + } + + /** N4: the main surface fails closed too, until its first origin read. */ + @Test + public void theMainSurfaceWithNoOriginYetServesTheDisplayBounds() { + final SecureRectangleStore store = new SecureRectangleStore(); + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + store.set(DISPLAY, new int[] { 10, 20, 30, 40 }); + + assertArrayEquals(WHOLE_DISPLAY, rectanglesOf(store.snapshot(DISPLAY))); + + store.setOrigin(DISPLAY, 0, 51); + assertArrayEquals(new int[] { 10, 71, 30, 91 }, rectanglesOf(store.snapshot(DISPLAY))); + } + + @Test + public void withoutTheDisplaysSizeTheWholeDisplayIsTheFallbackSquare() { + final SecureRectangleStore store = new SecureRectangleStore(); + store.set(DISPLAY, MODAL_SURFACE, new int[] { 1, 2, 3, 4 }); + + final int size = SecureRectangleStore.FALLBACK_DISPLAY_SIZE; + assertArrayEquals(new int[] { 0, 0, size, size }, rectanglesOf(store.snapshot(DISPLAY))); + } + + @Test + public void recordingTheDisplaysSizeShrinksTheWholeDisplayRectangleAndMovesTheVersion() { + final SecureRectangleStore store = new SecureRectangleStore(); + store.set(DISPLAY, MODAL_SURFACE, new int[] { 1, 2, 3, 4 }); + final int before = store.snapshot(DISPLAY)[0]; + + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + + final int[] packed = store.snapshot(DISPLAY); + assertNotEquals(before, packed[0]); + assertArrayEquals(WHOLE_DISPLAY, rectanglesOf(packed)); + assertTrue(store.hasDisplayBounds(DISPLAY)); + assertFalse(store.hasDisplayBounds(1)); + } + + @Test + public void recordingTheSameSizeAgainHoldsTheVersion() { + final SecureRectangleStore store = located(); + store.set(DISPLAY, new int[] { 1, 2, 3, 4 }); + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + final int settled = store.snapshot(DISPLAY)[0]; + + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + store.setDisplayBounds(DISPLAY, 0, HEIGHT); + + assertEquals(settled, store.snapshot(DISPLAY)[0]); + } + + /** N1: a renderer is never handed a rectangle that runs off the panel. */ + @Test + public void everyServedRectangleIsClampedToTheDisplay() { + final SecureRectangleStore store = located(); + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + store.set(DISPLAY, new int[] { + -10, -20, 100, 200, // off the top left: clamped + 600, 1500, 900, 1800, // off the bottom right: clamped + 800, 100, 900, 200, // wholly off the right: dropped + 10, 10, 10, 50, // no width: dropped + }); + + final int[] packed = store.snapshot(DISPLAY); + assertEquals(2, packed[1]); + assertArrayEquals(new int[] { 0, 0, 100, 200, 600, 1500, WIDTH, HEIGHT }, rectanglesOf(packed)); + } + + @Test + public void readingTheSurfacesOriginReplacesTheWholeDisplayRectangle() { + final SecureRectangleStore store = new SecureRectangleStore(); + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + store.set(DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 }); + final int before = store.snapshot(DISPLAY)[0]; + + store.setOrigin(DISPLAY, MODAL_SURFACE, 0, 0); + + final int[] packed = store.snapshot(DISPLAY); + assertNotEquals(before, packed[0]); + assertArrayEquals(new int[] { 100, 200, 150, 250 }, rectanglesOf(packed)); + } + + /** N2: a forgotten root's surface fails closed again until a new origin is read. */ + @Test + public void forgettingASurfacesOriginServesTheWholeDisplayAgain() { + final SecureRectangleStore store = located(); + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + store.set(DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 }); + store.setOrigin(DISPLAY, MODAL_SURFACE, 0, 0); + + store.forgetOrigin(MODAL_SURFACE); + assertArrayEquals(WHOLE_DISPLAY, rectanglesOf(store.snapshot(DISPLAY))); + + store.setOrigin(DISPLAY, MODAL_SURFACE, 0, 30); + assertArrayEquals(new int[] { 100, 230, 150, 280 }, rectanglesOf(store.snapshot(DISPLAY))); + } + + @Test + public void forgettingAnEmptyOrUnknownSurfaceChangesNothing() { + final SecureRectangleStore store = located(); + store.set(DISPLAY, new int[] { 1, 2, 3, 4 }); + store.set(DISPLAY, MODAL_SURFACE, new int[0]); + final int[] before = store.snapshot(DISPLAY); + + store.forgetOrigin(MODAL_SURFACE); + store.forgetOrigin(99); + + assertArrayEquals(before, store.snapshot(DISPLAY)); + } + + @Test + public void hasRectanglesIsTrueOnlyForASurfaceThatHoldsSome() { + final SecureRectangleStore store = located(); + store.set(DISPLAY, MODAL_SURFACE, new int[] { 1, 2, 3, 4 }); + store.set(DISPLAY, 43, new int[0]); + + assertTrue(store.hasRectangles(MODAL_SURFACE)); + assertFalse(store.hasRectangles(43)); + assertFalse(store.hasRectangles(99)); + } + + // ---- A JS reload (N7) ------------------------------------------------ + + /** + * A new runtime drops the Modal surfaces the old one left; the main one + * stays, emptied, with its origin. + */ + @Test + public void claimingANewRuntimeDropsEveryModalSurfaceButKeepsTheMainOne() { + final SecureRectangleStore store = located(); + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + store.set(DISPLAY, new int[] { 10, 20, 30, 40 }); + store.set(DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 }); + store.set(1, 43, new int[] { 1, 2, 3, 4 }); + assertArrayEquals(new int[] { 10, 20, 30, 40, 0, 0, WIDTH, HEIGHT }, rectanglesOf(store.snapshot(DISPLAY))); + final int before = store.snapshot(DISPLAY)[0]; + assertEquals(2, store.nonMainSurfacesWithRectangles()); + + store.claimRuntime(); + + assertEquals(0, store.nonMainSurfacesWithRectangles()); + + assertFalse(store.hasSurface(MODAL_SURFACE)); + assertFalse(store.hasSurface(43)); + assertTrue(store.hasSurface(MAIN_SURFACE)); + final int[] packed = store.snapshot(DISPLAY); + assertNotEquals(before, packed[0]); + assertEquals(0, packed[1]); + assertEquals(0, store.snapshot(1)[1]); + } + + /** The main surface's origin survives the claim: the new runtime's first set is placed, not failed closed. */ + @Test + public void aClaimKeepsTheMainSurfacesOrigin() { + final SecureRectangleStore store = new SecureRectangleStore(); + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + store.setOrigin(DISPLAY, 5, 7); + store.set(DISPLAY, new int[] { 10, 20, 30, 40 }); + + final int current = store.claimRuntime(); + assertTrue(store.publishForRuntime(current, DISPLAY, MAIN_SURFACE, new int[] { 1, 2, 3, 4 })); + + assertArrayEquals(new int[] { 6, 9, 8, 11 }, rectanglesOf(store.snapshot(DISPLAY))); + } + + /** + * The old runtime's main set must not outlive it: its clearing write is + * stale, and a new tree that never publishes on the main surface would + * otherwise keep masking where the old secure view was. + */ + @Test + public void aClaimWithAMainSetThenAStaleEmptyWriteAndNoNewPublishServesNothing() { + final SecureRectangleStore store = located(); + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + final int old = store.claimRuntime(); + store.publishForRuntime(old, DISPLAY, MAIN_SURFACE, new int[] { 10, 20, 30, 40 }); + assertEquals(1, store.snapshot(DISPLAY)[1]); + + store.claimRuntime(); + assertFalse(store.publishForRuntime(old, DISPLAY, MAIN_SURFACE, new int[0])); + + assertEquals(0, store.snapshot(DISPLAY)[1]); + } + + @Test + public void releasingTheCurrentRuntimeDropsItsModalSurfaces() { + final SecureRectangleStore store = located(); + final int claim = store.claimRuntime(); + store.set(DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 }); + + store.releaseRuntime(claim); + + assertFalse(store.hasSurface(MODAL_SURFACE)); + } + + /** A reload can start the new module before the old one is released. */ + @Test + public void releasingAnOldRuntimeLeavesTheNewRuntimesSurfaces() { + final SecureRectangleStore store = located(); + final int old = store.claimRuntime(); + store.set(DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 }); + store.claimRuntime(); + store.set(DISPLAY, 58, new int[] { 1, 2, 3, 4 }); + + store.releaseRuntime(old); + + assertTrue(store.hasSurface(58)); + assertFalse(store.hasSurface(MODAL_SURFACE)); + } + + // ---- Writes from a runtime that is no longer current (PR 48) --------- + + /** + * The old module is not invalidated yet when the new one claims: its late + * publish must not put back the Modal lane the claim dropped, which would + * fail closed over the whole display for the rest of the process. + */ + @Test + public void aStaleNonEmptyWriteDoesNotRecreateADroppedLane() { + final SecureRectangleStore store = located(); + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + final int old = store.claimRuntime(); + assertTrue(store.publishForRuntime(old, DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 })); + store.claimRuntime(); + assertFalse(store.hasSurface(MODAL_SURFACE)); + final int[] before = store.snapshot(DISPLAY); + + assertFalse(store.publishForRuntime(old, DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 })); + store.setOriginForRuntime(old, DISPLAY, MODAL_SURFACE, 7, 9); + + assertFalse(store.hasSurface(MODAL_SURFACE)); + assertArrayEquals(before, store.snapshot(DISPLAY)); + } + + /** + * Fabric numbers React tags from 1 again after a reload, so the old + * runtime's unmount clears the same key the new runtime's Modal now + * publishes on. That empty write must not uncover the new Modal. + */ + @Test + public void aStaleEmptyWriteDoesNotClearTheNewRuntimesLaneOnTheSameTag() { + final SecureRectangleStore store = located(); + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + final int old = store.claimRuntime(); + store.publishForRuntime(old, DISPLAY, MODAL_SURFACE, new int[] { 1, 2, 3, 4 }); + final int current = store.claimRuntime(); + store.publishForRuntime(current, DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 }); + store.setOriginForRuntime(current, DISPLAY, MODAL_SURFACE, 10, 20); + final int[] before = store.snapshot(DISPLAY); + assertArrayEquals(new int[] { 110, 220, 160, 270 }, rectanglesOf(before)); + + assertFalse(store.publishForRuntime(old, DISPLAY, MODAL_SURFACE, new int[0])); + store.forgetOriginForRuntime(old, MODAL_SURFACE); + store.dropSurfaceIfEmptyForRuntime(old, MODAL_SURFACE); + store.setOriginForRuntime(old, DISPLAY, MODAL_SURFACE, 500, 500); + + assertArrayEquals(before, store.snapshot(DISPLAY)); + } + + @Test + public void theCurrentRuntimesWritesStillApply() { + final SecureRectangleStore store = located(); + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + store.claimRuntime(); + final int current = store.claimRuntime(); + + assertTrue(store.publishForRuntime(current, DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 })); + assertArrayEquals(new int[] { 0, 0, WIDTH, HEIGHT }, rectanglesOf(store.snapshot(DISPLAY))); + store.setOriginForRuntime(current, DISPLAY, MODAL_SURFACE, 10, 20); + assertArrayEquals(new int[] { 110, 220, 160, 270 }, rectanglesOf(store.snapshot(DISPLAY))); + store.forgetOriginForRuntime(current, MODAL_SURFACE); + assertArrayEquals(new int[] { 0, 0, WIDTH, HEIGHT }, rectanglesOf(store.snapshot(DISPLAY))); + assertTrue(store.publishForRuntime(current, DISPLAY, MODAL_SURFACE, new int[0])); + assertEquals(0, store.snapshot(DISPLAY)[1]); + store.dropSurfaceIfEmptyForRuntime(current, MODAL_SURFACE); + assertFalse(store.hasSurface(MODAL_SURFACE)); + } + + /** A malformed write from the current runtime is still rejected, not applied. */ + @Test + public void theCurrentRuntimesMalformedWriteIsStillRejected() { + final SecureRectangleStore store = located(); + final int current = store.claimRuntime(); + + assertFalse(store.publishForRuntime(current, DISPLAY, MODAL_SURFACE, new int[] { 1, 2, 3 })); + assertFalse(store.hasSurface(MODAL_SURFACE)); + } + + @Test + public void anEmptySurfaceWithNoOriginServesNothing() { + final SecureRectangleStore store = new SecureRectangleStore(); + store.set(DISPLAY, MODAL_SURFACE, new int[0]); + + assertEquals(0, store.snapshot(DISPLAY)[1]); + } + + @Test + public void dropSurfaceIfEmptyForgetsOnlyAnEmptyNonMainLane() { + final SecureRectangleStore store = located(); + store.set(DISPLAY, MODAL_SURFACE, new int[0]); + store.set(DISPLAY, 43, new int[] { 1, 2, 3, 4 }); + store.set(DISPLAY, MAIN_SURFACE, new int[0]); + + store.dropSurfaceIfEmpty(MODAL_SURFACE); + store.dropSurfaceIfEmpty(43); + store.dropSurfaceIfEmpty(MAIN_SURFACE); + + assertFalse(store.hasSurface(MODAL_SURFACE)); + assertTrue(store.hasSurface(43)); + assertTrue(store.hasSurface(MAIN_SURFACE)); + } + + /** Existing single-argument set/setOrigin keep meaning the main surface. */ + @Test + public void theLegacySetAndSetOriginTargetTheMainSurface() { + final SecureRectangleStore store = located(); + store.set(DISPLAY, new int[] { 10, 20, 30, 40 }); + store.setOrigin(DISPLAY, 0, 96); + + final int[] packed = store.snapshot(DISPLAY); + assertArrayEquals(new int[] { 10, 116, 30, 136 }, + java.util.Arrays.copyOfRange(packed, 2, packed.length)); + } + // ---- The TurboModule's entry point ---------------------------------- // // setSecureRectangles is a void TurboModule method: an exception thrown @@ -284,7 +683,7 @@ public void theDisplayOriginIsTheScreenLocationLessTheViewportOffset() { @Test public void publishOrLogRejectsABadListWithoutThrowingOrTouchingTheSet() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.set(DISPLAY, new int[] { 1, 2, 3, 4 }); final int[] before = store.snapshot(DISPLAY); @@ -296,7 +695,7 @@ public void publishOrLogRejectsABadListWithoutThrowingOrTouchingTheSet() { @Test public void publishOrLogPublishesAGoodList() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); assertTrue(store.publishOrLog(DISPLAY, new int[] { 1, 2, 3, 4 })); diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index 8768bc91..aad3e5d1 100644 --- a/packages/react-native/ios/BugseeModule.mm +++ b/packages/react-native/ios/BugseeModule.mm @@ -65,6 +65,12 @@ #import "BGSRNErrorMessage.h" #endif +/// Forward-declared: the wrapper's pull path refreshes the origins before the +/// functions' definitions later in this file. +static NSValue *_Nullable BGSRNReactOrigin(void); +static NSValue *_Nullable BGSRNModalSurfaceOrigin(NSInteger surface); +static Class _Nullable BGSRNModalHostClass(void); + /// The conformance lives here rather than in the Support package so that the /// package stays buildable and testable without the SDK's headers. BGSRNWrapper /// already declares every property the protocol requires; this states that it @@ -105,6 +111,29 @@ - (void)onLifecycleEvent:(NSString *)eventType data:(id)data { /// secret must survive that swap. See `BGSRNSecureRectangles` for the version /// contract, which is what makes the SDK notice a change at all. - (NSData *)secureRectanglesForDisplay:(NSInteger)display { + // Every surface's origin is re-read on the pull, so a window move or a + // sheet settling still updates without waiting for the next JS publish, + // the same reason Android refreshes at pull time. The main surface is the + // React root's window; each is its presented view controller's + // view. A Modal whose host is gone takes its empty lane with it; one that + // is not found yet keeps failing closed. + if (NSThread.isMainThread) { + BGSRNSecureRectangles *store = BGSRNSecureRectangles.shared; + // What a surface whose origin is unknown serves: the screen, not more. + [store setDisplaySize:UIScreen.mainScreen.bounds.size forDisplay:display]; + NSValue *origin = BGSRNReactOrigin(); + if (origin != nil) { + [store setOrigin:origin.CGPointValue forDisplay:display]; + } + for (NSNumber *surface in [store surfacesForDisplay:display]) { + NSValue *modalOrigin = BGSRNModalSurfaceOrigin(surface.integerValue); + if (modalOrigin != nil) { + [store setOrigin:modalOrigin.CGPointValue forDisplay:display surface:surface.integerValue]; + } else { + [store dropSurfaceIfEmpty:surface.integerValue]; + } + } + } return [BGSRNSecureRectangles.shared snapshotForDisplay:display]; } @@ -233,6 +262,41 @@ static void BGSRNSetWrapper(id _Nullable wrapper, BOOL onlyIfAbse }); } +/// `RCTModalHostViewComponentView`, resolved by name rather than imported; +/// Nil when this React Native has no such class. +static Class _Nullable BGSRNModalHostClass(void) { + static Class modalHostClass; + static dispatch_once_t once; + dispatch_once(&once, ^{ + modalHostClass = NSClassFromString(@"RCTModalHostViewComponentView"); + }); + return modalHostClass; +} + +/// Where `measureInWindow`'s (0, 0) sits on the screen, in points, for the +/// `` whose host has React tag `surface` (`BGSRNModalHostOrigin`). +/// Read from the current runtime's host, which its module names at publish +/// time (`nameModalHostForSurface:`), the same shape as Android's +/// `watchSurface`: a window can hold another runtime's host with the same +/// tag during a reload, and only the module's own view registry tells them +/// apart. While that host has not mounted, unknown. Only without a +/// registry, the one class+tag match in the windows the SDK walks. nil (none or several, or off main) leaves the lane's +/// origin as it was: unknown for a new lane, which is served as the whole +/// display. +static NSValue *_Nullable BGSRNModalSurfaceOrigin(NSInteger surface) { + if (!NSThread.isMainThread) { + return nil; + } + Class modalHostClass = BGSRNModalHostClass(); + if (modalHostClass == Nil || surface <= 0) { + return nil; + } + return BGSRNSecureSurfaceOrigin(BGSRNSecureRectangles.shared, surface, + BGSRNSdkWalkedWindows(BGSRNSdkKeyWindow()), ^BOOL(UIView *view) { + return [view isKindOfClass:modalHostClass]; + }); +} + static NSString *const kHandleDeadCode = @"E_REPORT_HANDLE_DEAD"; static NSString *const kCreateBusyCode = @"E_REPORT_CREATE_BUSY"; @@ -800,10 +864,16 @@ @interface BugseeModule () @implementation BugseeModule { os_unfair_lock _spanRegistryLock; + /// This module's claim on the secure store (`claimRuntime`). + NSInteger _secureRuntime; BOOL _spansRetired; } @synthesize spanHandles = _spanHandles; +/// This runtime's view registry (set by React Native through +/// `RCTBridgeModuleDecorator`): how a `` host is found by its React +/// tag without searching windows another runtime shares. +@synthesize viewRegistry_DEPRECATED = _viewRegistry_DEPRECATED; RCT_EXPORT_MODULE(Bugsee) @@ -815,6 +885,9 @@ @implementation BugseeModule { - (instancetype)init { if ((self = [super init])) { _spanRegistryLock = OS_UNFAIR_LOCK_INIT; + // A new JS runtime: Modal surfaces the previous one left behind (a reload + // with a secure Modal open) are dropped before this one's JS can publish. + _secureRuntime = [BGSRNSecureRectangles.shared claimRuntime]; BGSRNSetWrapper((id)[BGSRNWrapper wrapperWithoutJsRuntime], YES); BGSRNInstallConsoleCapture(); BGSRNInstallLogEventFilter(); @@ -908,6 +981,9 @@ - (void)invalidate { // No super call: `invalidate` comes from RCTInvalidating, and // NativeBugseeSpecBase inherits NSObject, which does not declare it. [BGSRNEventBus.shared detach:self]; + // This runtime's Modal surfaces cannot be cleared by its JS any more. A + // no-op when the next module has already claimed the store. + [BGSRNSecureRectangles.shared releaseRuntime:_secureRuntime]; // Also completes every report handle this module's JS was given: the next // runtime cannot know them, so the reports must not wait out their // deadlines. @@ -957,6 +1033,14 @@ + (BOOL)requiresMainQueueSetup { - (void)setSecureRectangles:(double)display coordinates:(NSArray *)coordinates { + [self setSecureRectanglesOnSurface:display + surface:BGSRNSecureMainSurface + coordinates:coordinates]; +} + +- (void)setSecureRectanglesOnSurface:(double)display + surface:(double)surface + coordinates:(NSArray *)coordinates { const NSUInteger count = coordinates.count; // Codegen hands numbers across as double, because that is what a JS number // is. Rounding rather than truncating: the JS side has already rounded each @@ -970,10 +1054,57 @@ - (void)setSecureRectangles:(double)display flat[i] = (int32_t)llround([coordinates[i] doubleValue]); } - [BGSRNSecureRectangles.shared setCoordinates:flat - count:count - forDisplay:(NSInteger)display]; + // Ignored once a newer runtime has claimed the store: this module's late + // writes (a reload's teardown) must not touch the next runtime's lanes, + // whose keys may be the same React tags. + const NSInteger key = (NSInteger)llround(surface); + const BOOL published = [BGSRNSecureRectangles.shared setCoordinates:flat + count:count + forDisplay:(NSInteger)display + surface:key + runtime:_secureRuntime]; free(flat); + // A 's rectangles: name this runtime's lookup of its host, so the + // pull places them by this runtime's Modal. An empty publish (the Modal + // clearing on unmount) names nothing. + if (published && count > 0 && key != BGSRNSecureMainSurface) { + [self nameModalHostForSurface:key]; + } +} + +/// Names, under this module's claim, how the store finds the `` host +/// with React tag `surface`: this runtime's view registry, which another +/// runtime's same-tag host is not in. Not looked up here: Fabric mounts the +/// host after JS has measured and published inside it, so the pull asks (on +/// main) until the host is there, then holds it weakly; until then the lane +/// fails closed. No registry: nothing is named, and the pull falls back to a +/// unique class+tag match. +- (void)nameModalHostForSurface:(NSInteger)surface { + __weak RCTViewRegistry *registry = self.viewRegistry_DEPRECATED; + if (registry == nil) { + return; + } + [BGSRNSecureRectangles.shared setHostResolver:^id _Nullable(NSInteger tag) { + return [registry viewForReactTag:@(tag)]; + } + forSurface:surface + runtime:_secureRuntime]; +} + +/// The screen origin `[x, y]` (points) of the `` whose host has React +/// tag `surface`, for the `vh` walk, which asks once per Modal per walk. +/// Empty when the Modal is not presented. +- (NSArray *)secureSurfaceOrigin:(double)surface { + __block NSArray *origin = @[]; + const NSInteger key = (NSInteger)llround(surface); + BGSRNRunOnMainSync(^{ + NSValue *value = BGSRNModalSurfaceOrigin(key); + if (value != nil) { + CGPoint p = value.CGPointValue; + origin = @[ @(p.x), @(p.y) ]; + } + }); + return origin; } #pragma mark - Blackout and view-hierarchy capture (design doc §4.1) diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m index db4b6d0a..61866813 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m @@ -2,6 +2,8 @@ const NSUInteger BGSRNReactRootSearchBudget = 2000; +const NSUInteger BGSRNModalHostSearchBudget = 10000; + UIWindow *BGSRNSdkKeyWindow(void) { UIApplication *application = UIApplication.sharedApplication; if (application == nil) { @@ -100,3 +102,73 @@ static BOOL HostsReactRoot(UIWindow *window, BOOL (^isReactRoot)(UIView *), NSUI // space: the SDK adds exactly this (BGSCaptureViewHierarchyEngine.m:335-336). return [NSValue valueWithCGPoint:window.frame.origin]; } + +/// A `` host's view controller. Declared here rather than imported: +/// `RCTModalHostViewComponentView` implements `viewController` without +/// declaring it in its header, and the call is guarded by +/// `respondsToSelector:`. +@interface UIView (BGSRNModalHostController) +- (UIViewController *)viewController; +@end + +UIView *BGSRNUniqueTaggedView(NSArray *windows, NSInteger tag, BOOL (^matches)(UIView *), NSUInteger budget) { + UIView *found = nil; + for (UIWindow *window in windows) { + NSMutableArray *queue = [NSMutableArray arrayWithObject:window]; + NSUInteger head = 0; + while (head < queue.count && head < budget) { + UIView *view = queue[head++]; + if (view.tag == tag && matches(view)) { + if (found != nil && found != view) { + return nil; + } + found = view; + } + [queue addObjectsFromArray:view.subviews]; + } + } + return found; +} + +NSValue *BGSRNModalHostOrigin(UIView *host) { + if (![host respondsToSelector:@selector(viewController)]) { + return nil; + } + UIViewController *controller = [host viewController]; + if (controller == nil || !controller.isViewLoaded) { + return nil; + } + UIView *content = controller.view; + UIWindow *window = content.window; + if (window == nil) { + return nil; + } + // For PR #30's rebase: the `window.frame.origin` term here must become the + // same window-placement value #30 gives the main lane, so both lanes share + // one convention. + CGPoint inWindow = [content convertPoint:CGPointZero toView:nil]; + return [NSValue valueWithCGPoint:CGPointMake(inWindow.x + window.frame.origin.x, inWindow.y + window.frame.origin.y)]; +} + +NSValue *BGSRNSecureSurfaceOrigin(BGSRNSecureRectangles *store, + NSInteger surface, + NSArray *windows, + BOOL (^isHost)(UIView *)) { + UIView *host = [store hostForSurface:surface accepting:^BOOL(id candidate) { + return [candidate isKindOfClass:UIView.class] && ((UIView *)candidate).tag == surface && isHost(candidate); + }]; + if (host != nil) { + // The current runtime's own host: not presented yet is no origin, not a + // search that could find another runtime's. + return BGSRNModalHostOrigin(host); + } + if ([store isHostNamedForSurface:surface]) { + // Named but not found: the current runtime's host has not mounted yet + // (Fabric mounts it after the publish), or has gone. Unknown, so the lane + // fails closed: during a reload the walk's one match can be the old + // runtime's still-presented host. + return nil; + } + UIView *found = BGSRNUniqueTaggedView(windows, surface, isHost, BGSRNModalHostSearchBudget); + return found == nil ? nil : BGSRNModalHostOrigin(found); +} diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m index 56381d7c..570385f5 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m @@ -1,5 +1,7 @@ #import "BGSRNSecureRectangles.h" +#import + /// Four coordinates per rectangle: left, top, right, bottom. static const NSUInteger kCoordinatesPerRectangle = 4; @@ -10,6 +12,10 @@ /// as it is stable; the SDK only ever compares it against what it saw last. static const int32_t kInitialVersion = 1; +const NSInteger BGSRNSecureMainSurface = 0; + +const int32_t BGSRNSecureFallbackDisplaySize = 16384; + /// Steps the version, skipping the value that means "nothing secured yet": /// landing back on it would make a real set look identical to the empty one to /// an SDK that had only ever seen the latter. Wrapping takes 2^31 changes, so @@ -19,15 +25,93 @@ static int32_t BGSRNNextVersion(int32_t current) { return next == kInitialVersion ? kInitialVersion + 1 : next; } +/// One coordinate moved by the origin, rounded outward: down for a left or top +/// edge, up for a right or bottom one, so the region can only grow. Saturates +/// at the int32 range. +static int32_t BGSRNMovedCoordinate(int32_t value, NSUInteger index, CGPoint origin) { + const BOOL isX = index % 2 == 0; + const BOOL isLeadingEdge = index % kCoordinatesPerRectangle < 2; + const double moved = (double)value + (isX ? origin.x : origin.y); + const double rounded = isLeadingEdge ? floor(moved) : ceil(moved); + if (rounded >= (double)INT32_MAX) { + return INT32_MAX; + } + if (rounded <= (double)INT32_MIN) { + return INT32_MIN; + } + return (int32_t)rounded; +} + +/// `raw` (int32 coordinates) with every coordinate moved by `origin`. +static NSData *BGSRNMovedCoordinates(NSData *raw, CGPoint origin) { + if (origin.x == 0 && origin.y == 0) { + return raw; + } + const NSUInteger count = raw.length / sizeof(int32_t); + NSMutableData *moved = [NSMutableData dataWithLength:raw.length]; + const int32_t *in = (const int32_t *)raw.bytes; + int32_t *out = (int32_t *)moved.mutableBytes; + for (NSUInteger i = 0; i < count; i++) { + out[i] = BGSRNMovedCoordinate(in[i], i, origin); + } + return [moved copy]; +} + +/// One surface's raw measurements and the origin that places them. Immutable: +/// a change replaces the lane. +@interface BGSRNSecureLane : NSObject +@property (nonatomic, copy, readonly) NSData *raw; +@property (nonatomic, assign, readonly) CGPoint origin; +@property (nonatomic, assign, readonly) BOOL originKnown; +/// What the SDK is served for this lane. +@property (nonatomic, copy, readonly) NSData *served; +@end + +@implementation BGSRNSecureLane + +- (instancetype)initWithRaw:(NSData *)raw origin:(CGPoint)origin originKnown:(BOOL)originKnown { + self = [super init]; + if (self) { + _raw = [raw copy] ?: [NSData data]; + _origin = origin; + _originKnown = originKnown; + // An unknown origin is served as the whole display, sized at serve time. + _served = _raw.length == 0 || !originKnown ? [NSData data] : BGSRNMovedCoordinates(_raw, origin); + } + return self; +} + +@end + +/// A Modal surface's host, as its owning module named it: the host itself, +/// held weakly once found (a host that unmounts must not be kept alive), and +/// the module's lookup that finds it, with that module's claim. +@interface BGSRNSurfaceHost : NSObject +@property (nonatomic, weak) id host; +@property (nonatomic, copy, nullable) BGSRNSecureHostResolver resolver; +@property (nonatomic, assign) NSInteger claim; +@end + +@implementation BGSRNSurfaceHost +@end + @implementation BGSRNSecureRectangles { - /// display -> the coordinates last published for it, as NSData of int32. - NSMutableDictionary *_coordinatesByDisplay; - /// display -> its current version. + /// display -> (surface -> lane). Served in surface-key order. + NSMutableDictionary *> *_lanesByDisplay; + /// display -> its screen's size in points, a CGSize, once recorded. + NSMutableDictionary *_boundsByDisplay; + /// display -> its current version. Absent until the served set first changes. NSMutableDictionary *_versionsByDisplay; /// Serialises the JS-thread write against the main-thread pull. A plain lock - /// rather than a queue: the pull happens on the SDK's own thread 2-3 times a - /// second and must not be made to hop. + /// rather than a queue: the pull happens on main once per captured frame and + /// must not be made to hop. NSLock *_lock; + /// The current JS runtime's claim; see `claimRuntime`. Under `_lock`. + NSInteger _runtime; + /// surface -> its host as the owning module named it (`setHost:...`, + /// `setHostResolver:...`). Under `_lock`. An entry goes with the surface's + /// last lane, and on a claim. + NSMutableDictionary *_hostsBySurface; } + (BGSRNSecureRectangles *)shared { @@ -42,9 +126,11 @@ + (BGSRNSecureRectangles *)shared { - (instancetype)init { self = [super init]; if (self) { - _coordinatesByDisplay = [NSMutableDictionary dictionary]; + _lanesByDisplay = [NSMutableDictionary dictionary]; _versionsByDisplay = [NSMutableDictionary dictionary]; + _boundsByDisplay = [NSMutableDictionary dictionary]; _lock = [[NSLock alloc] init]; + _hostsBySurface = [NSMutableDictionary dictionary]; } return self; } @@ -52,6 +138,43 @@ - (instancetype)init { - (BOOL)setCoordinates:(const int32_t *)coordinates count:(NSUInteger)count forDisplay:(NSInteger)display { + return [self setCoordinates:coordinates count:count forDisplay:display surface:BGSRNSecureMainSurface]; +} + +- (BOOL)setCoordinates:(const int32_t *)coordinates + count:(NSUInteger)count + forDisplay:(NSInteger)display + surface:(NSInteger)surface { + return [self setCoordinates:coordinates + count:count + forDisplay:display + surface:surface + runtime:[self currentClaim]]; +} + +- (NSInteger)currentClaim { + [_lock lock]; + const NSInteger claim = _runtime; + [_lock unlock]; + return claim; +} + +/// Whether `claim` is still the current runtime's. A stale one is logged at +/// debug, numbers only. Caller holds `_lock`. +- (BOOL)isCurrentLocked:(NSInteger)claim { + if (claim == _runtime) { + return YES; + } + os_log_debug(OS_LOG_DEFAULT, "BugseeRN secure write ignored: runtime claim %ld is stale, current %ld", + (long)claim, (long)_runtime); + return NO; +} + +- (BOOL)setCoordinates:(const int32_t *)coordinates + count:(NSUInteger)count + forDisplay:(NSInteger)display + surface:(NSInteger)surface + runtime:(NSInteger)claim { if (count % kCoordinatesPerRectangle != 0) { return NO; } @@ -65,31 +188,302 @@ - (BOOL)setCoordinates:(const int32_t *)coordinates ? [NSData data] : [NSData dataWithBytes:coordinates length:count * sizeof(int32_t)]; - NSNumber *key = @(display); [_lock lock]; - NSData *previous = _coordinatesByDisplay[key]; - if (previous == nil || ![previous isEqualToData:published]) { - const int32_t currentVersion = - previous == nil ? kInitialVersion : _versionsByDisplay[key].intValue; - _coordinatesByDisplay[key] = published; - _versionsByDisplay[key] = @(BGSRNNextVersion(currentVersion)); + // Under the same lock as claimRuntime and releaseRuntime: a write checked + // here cannot land after a newer claim has dropped the lanes. + if (![self isCurrentLocked:claim]) { + [_lock unlock]; + return NO; } + [self changeLaneLockedForDisplay:display + surface:surface + change:^BGSRNSecureLane *(BGSRNSecureLane *prior) { + return [[BGSRNSecureLane alloc] initWithRaw:published + origin:prior.origin + originKnown:prior.originKnown]; + }]; [_lock unlock]; return YES; } +- (void)setOrigin:(CGPoint)origin forDisplay:(NSInteger)display { + [self setOrigin:origin forDisplay:display surface:BGSRNSecureMainSurface]; +} + +- (void)setOrigin:(CGPoint)origin forDisplay:(NSInteger)display surface:(NSInteger)surface { + [_lock lock]; + BGSRNSecureLane *current = _lanesByDisplay[@(display)][@(surface)]; + // The usual case: re-read on every pull, the window has not moved. + if (current != nil && current.originKnown && CGPointEqualToPoint(current.origin, origin)) { + [_lock unlock]; + return; + } + [self changeLaneLockedForDisplay:display + surface:surface + change:^BGSRNSecureLane *(BGSRNSecureLane *prior) { + return [[BGSRNSecureLane alloc] initWithRaw:prior.raw + origin:origin + originKnown:YES]; + }]; + [_lock unlock]; +} + +- (void)setDisplaySize:(CGSize)size forDisplay:(NSInteger)display { + if (!(size.width > 0) || !(size.height > 0)) { + return; + } + NSNumber *key = @(display); + [_lock lock]; + CGSize previous = CGSizeZero; + NSValue *recorded = _boundsByDisplay[key]; + [recorded getValue:&previous size:sizeof(previous)]; + if (recorded == nil || !CGSizeEqualToSize(previous, size)) { + NSData *previousServed = [self servedLockedForDisplay:display]; + _boundsByDisplay[key] = [NSValue valueWithBytes:&size objCType:@encode(CGSize)]; + [self moveVersionLockedForDisplay:display ifServedDiffersFrom:previousServed]; + } + [_lock unlock]; +} + +- (NSInteger)claimRuntime { + [_lock lock]; + _runtime += 1; + const NSInteger claim = _runtime; + [self dropNonMainSurfacesLocked]; + [self emptyMainSurfaceLocked]; + [_lock unlock]; + return claim; +} + +/// Empties the main surface's rectangles on every display, keeping its +/// origin: the old runtime's set must not outlive it, and a new runtime that +/// never publishes on the main surface would otherwise inherit it. Caller +/// holds `_lock`. +- (void)emptyMainSurfaceLocked { + for (NSNumber *display in _lanesByDisplay.allKeys) { + BGSRNSecureLane *main = _lanesByDisplay[display][@(BGSRNSecureMainSurface)]; + if (main == nil || main.raw.length == 0) { + continue; + } + [self changeLaneLockedForDisplay:display.integerValue + surface:BGSRNSecureMainSurface + change:^BGSRNSecureLane *(BGSRNSecureLane *prior) { + return [[BGSRNSecureLane alloc] initWithRaw:[NSData data] + origin:prior.origin + originKnown:prior.originKnown]; + }]; + } +} + +- (void)releaseRuntime:(NSInteger)claim { + [_lock lock]; + if (claim == _runtime) { + [self dropNonMainSurfacesLocked]; + } + [_lock unlock]; +} + +/// Drops every surface but the main one, moving each display's version when +/// what it serves changes. Caller holds `_lock`. +- (void)dropNonMainSurfacesLocked { + for (NSNumber *display in _lanesByDisplay.allKeys) { + NSMutableDictionary *lanes = _lanesByDisplay[display]; + NSData *previousServed = [self servedLockedForDisplay:display.integerValue]; + for (NSNumber *surface in lanes.allKeys) { + if (surface.integerValue != BGSRNSecureMainSurface) { + [lanes removeObjectForKey:surface]; + } + } + [self moveVersionLockedForDisplay:display.integerValue ifServedDiffersFrom:previousServed]; + } + // Every host recorded so far belongs to a runtime whose lanes just went. + [_hostsBySurface removeAllObjects]; +} + +- (BOOL)setHost:(id)host forSurface:(NSInteger)surface runtime:(NSInteger)claim { + if (host == nil) { + return NO; + } + [_lock lock]; + BGSRNSurfaceHost *entry = [self hostEntryLockedForSurface:surface runtime:claim]; + entry.host = host; + [_lock unlock]; + return entry != nil; +} + +- (BOOL)setHostResolver:(BGSRNSecureHostResolver)resolver forSurface:(NSInteger)surface runtime:(NSInteger)claim { + if (resolver == nil) { + return NO; + } + [_lock lock]; + BGSRNSurfaceHost *entry = [self hostEntryLockedForSurface:surface runtime:claim]; + entry.resolver = resolver; + [_lock unlock]; + return entry != nil; +} + +- (BOOL)isHostNamedForSurface:(NSInteger)surface { + [_lock lock]; + const BOOL named = _hostsBySurface[@(surface)] != nil; + [_lock unlock]; + return named; +} + +/// `surface`'s host entry for the module holding `claim`, made if needed; +/// nil when the claim is stale, `surface` is the main one, or no display has +/// a lane for it. Caller holds `_lock`. +- (nullable BGSRNSurfaceHost *)hostEntryLockedForSurface:(NSInteger)surface runtime:(NSInteger)claim { + if (surface == BGSRNSecureMainSurface || ![self isCurrentLocked:claim] || ![self hasLaneLockedForSurface:surface]) { + return nil; + } + BGSRNSurfaceHost *entry = _hostsBySurface[@(surface)]; + if (entry == nil) { + entry = [BGSRNSurfaceHost new]; + entry.claim = claim; + _hostsBySurface[@(surface)] = entry; + } + return entry; +} + +- (id)hostForSurface:(NSInteger)surface accepting:(BOOL (^)(id candidate))accept { + [_lock lock]; + BGSRNSurfaceHost *entry = _hostsBySurface[@(surface)]; + id host = entry.host; + BGSRNSecureHostResolver resolver = entry.resolver; + const NSInteger claim = entry.claim; + [_lock unlock]; + if (host != nil || resolver == nil) { + return host; + } + // Outside the lock: the lookup is the module's, and may take its own. + id candidate = nil; + @try { + candidate = resolver(surface); + } @catch (NSException *exception) { + NSLog(@"BugseeRN secure host lookup threw: %@", NSStringFromClass(exception.class)); + return nil; + } + if (candidate == nil || !accept(candidate)) { + return nil; + } + // Pinned under the claim it was named with: a newer runtime's claim since + // the lookup started forgets it instead. + return [self setHost:candidate forSurface:surface runtime:claim] ? candidate : nil; +} + +/// Whether any display has a lane for `surface`. Caller holds `_lock`. +- (BOOL)hasLaneLockedForSurface:(NSInteger)surface { + for (NSNumber *display in _lanesByDisplay) { + if (_lanesByDisplay[display][@(surface)] != nil) { + return YES; + } + } + return NO; +} + +- (NSArray *)surfacesForDisplay:(NSInteger)display { + [_lock lock]; + NSMutableArray *surfaces = [NSMutableArray array]; + for (NSNumber *key in _lanesByDisplay[@(display)]) { + if (key.integerValue != BGSRNSecureMainSurface) { + [surfaces addObject:key]; + } + } + [_lock unlock]; + return [surfaces sortedArrayUsingSelector:@selector(compare:)]; +} + +- (void)dropSurfaceIfEmpty:(NSInteger)surface { + if (surface == BGSRNSecureMainSurface) { + return; + } + [_lock lock]; + for (NSNumber *display in _lanesByDisplay.allKeys) { + BGSRNSecureLane *lane = _lanesByDisplay[display][@(surface)]; + if (lane != nil && lane.raw.length == 0) { + // An empty lane serves nothing, so removing it leaves the version alone. + [_lanesByDisplay[display] removeObjectForKey:@(surface)]; + } + } + if (![self hasLaneLockedForSurface:surface]) { + [_hostsBySurface removeObjectForKey:@(surface)]; + } + [_lock unlock]; +} + +/// Replaces one lane and moves the display's version only when what is served +/// changes. Caller holds `_lock`. +- (void)changeLaneLockedForDisplay:(NSInteger)display + surface:(NSInteger)surface + change:(BGSRNSecureLane * (^)(BGSRNSecureLane *prior))change { + NSNumber *displayKey = @(display); + NSNumber *surfaceKey = @(surface); + NSData *previousServed = [self servedLockedForDisplay:display]; + NSMutableDictionary *lanes = _lanesByDisplay[displayKey]; + if (lanes == nil) { + lanes = [NSMutableDictionary dictionary]; + _lanesByDisplay[displayKey] = lanes; + } + BGSRNSecureLane *prior = lanes[surfaceKey] + ?: [[BGSRNSecureLane alloc] initWithRaw:[NSData data] origin:CGPointZero originKnown:NO]; + lanes[surfaceKey] = change(prior); + [self moveVersionLockedForDisplay:display ifServedDiffersFrom:previousServed]; +} + +/// Steps the display's version when what it serves now differs from +/// `previousServed`. Caller holds `_lock`. +- (void)moveVersionLockedForDisplay:(NSInteger)display ifServedDiffersFrom:(NSData *)previousServed { + NSNumber *displayKey = @(display); + NSData *nextServed = [self servedLockedForDisplay:display]; + if (![previousServed isEqualToData:nextServed]) { + const int32_t currentVersion = _versionsByDisplay[displayKey] + ? _versionsByDisplay[displayKey].intValue + : kInitialVersion; + _versionsByDisplay[displayKey] = @(BGSRNNextVersion(currentVersion)); + } +} + +/// Every lane's served coordinates, in surface-key order. A lane whose origin +/// is unknown serves one rectangle covering the display: its screen's size, +/// rounded up, or the fallback square before that is recorded. Caller holds +/// `_lock`. +- (NSData *)servedLockedForDisplay:(NSInteger)display { + NSMutableDictionary *lanes = _lanesByDisplay[@(display)]; + if (lanes.count == 0) { + return [NSData data]; + } + NSValue *recorded = _boundsByDisplay[@(display)]; + CGSize size = CGSizeMake(BGSRNSecureFallbackDisplaySize, BGSRNSecureFallbackDisplaySize); + [recorded getValue:&size size:sizeof(size)]; + const int32_t whole[4] = {0, 0, (int32_t)ceil(size.width), (int32_t)ceil(size.height)}; + NSMutableData *merged = [NSMutableData data]; + for (NSNumber *key in [lanes.allKeys sortedArrayUsingSelector:@selector(compare:)]) { + BGSRNSecureLane *lane = lanes[key]; + if (lane.raw.length == 0) { + continue; + } + if (lane.originKnown) { + [merged appendData:lane.served]; + } else { + [merged appendBytes:whole length:sizeof(whole)]; + } + } + return [merged copy]; +} + - (NSData *)snapshotForDisplay:(NSInteger)display { NSNumber *key = @(display); [_lock lock]; - NSData *coordinates = _coordinatesByDisplay[key]; + NSData *coordinates = [self servedLockedForDisplay:display]; + // Keyed off the version itself, not the lanes: an origin-only write creates + // a lane without moving the version, and must still report the initial one. const int32_t version = - coordinates == nil ? kInitialVersion : _versionsByDisplay[key].intValue; + _versionsByDisplay[key] ? _versionsByDisplay[key].intValue : kInitialVersion; [_lock unlock]; - const NSUInteger coordinateCount = - coordinates == nil ? 0 : coordinates.length / sizeof(int32_t); + const NSUInteger coordinateCount = coordinates.length / sizeof(int32_t); NSMutableData *packed = [NSMutableData dataWithLength:(kHeaderInts + coordinateCount) * sizeof(int32_t)]; diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h index 41ffda81..dd3f8237 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h @@ -1,5 +1,7 @@ #import +#import "BGSRNSecureRectangles.h" + NS_ASSUME_NONNULL_BEGIN /// Where the `vh` request's origin comes from: the one number that puts the @@ -50,4 +52,49 @@ FOUNDATION_EXPORT NSValue *_Nullable BGSRNReactRootOrigin(UIWindow *_Nullable ke NSArray *windows, BOOL (^isReactRoot)(UIView *view)); +/// How many views `BGSRNUniqueTaggedView` inspects per window before moving +/// on to the next. A `` host sits inside the React tree, deeper than a +/// root, so this is larger than `BGSRNReactRootSearchBudget`; it still keeps +/// a pathological native hierarchy from costing the main thread on a pull. +FOUNDATION_EXPORT const NSUInteger BGSRNModalHostSearchBudget; + +/// The one view whose `tag` is `tag` and that `matches` accepts, searching +/// each of `windows` breadth-first over at most `budget` views per window. +/// nil when there is none, AND when there is more than one: Fabric sets a +/// component view's `tag` to its React tag, but tags are not a window-wide +/// namespace. A native view can carry the same small integer (a non-matching +/// view does not end the search, unlike `-[UIView viewWithTag:]`), and during +/// a reload two runtimes' hosts can share one tag, so two matches cannot say +/// which is current. +FOUNDATION_EXPORT UIView *_Nullable BGSRNUniqueTaggedView(NSArray *windows, + NSInteger tag, + BOOL (^matches)(UIView *view), + NSUInteger budget); + +/// Where `measureInWindow`'s (0, 0) sits on the screen, in points, inside the +/// `` whose host component view is `host`: the origin of the host's +/// presented view controller's view in its window, plus the window's +/// `frame.origin`. Fabric inserts the Modal's children into that view +/// (`RCTModalHostViewComponentView`), and `measureInWindow` inside a Modal +/// stops at the `ModalHostView` node with an identity transform, so this is +/// the window's origin for a full-screen Modal and inset for a `pageSheet` or +/// `formSheet` one. nil when `host` has no `viewController`, or that view is +/// not loaded or has no window (the Modal is not presented). +FOUNDATION_EXPORT NSValue *_Nullable BGSRNModalHostOrigin(UIView *host); + +/// The screen origin of `surface`, a `` lane in `store`. Read from the +/// current runtime's host (`hostForSurface:accepting:`, which takes a view +/// `isHost` accepts with that tag) when there is one, even when that gives no +/// origin yet. nil while a host or lookup the current runtime named finds +/// nothing (`isHostNamedForSurface:`): the host mounts after the publish, and +/// until then the origin is unknown. Only when nothing was ever named (no +/// registry), the one view `isHost` accepts with that tag in `windows` +/// (`BGSRNUniqueTaggedView`); nil when there are none or several. A nil +/// leaves the lane's origin as it was: unknown for a new lane, which is +/// served as the whole display. Main thread only. +FOUNDATION_EXPORT NSValue *_Nullable BGSRNSecureSurfaceOrigin(BGSRNSecureRectangles *store, + NSInteger surface, + NSArray *windows, + BOOL (^isHost)(UIView *view)); + NS_ASSUME_NONNULL_END diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h index e3e9e751..292b790a 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h @@ -1,7 +1,16 @@ +#import #import NS_ASSUME_NONNULL_BEGIN +/// The app's own React root's surface key. Legacy writers that do not name a +/// surface land here. +FOUNDATION_EXPORT const NSInteger BGSRNSecureMainSurface; + +/// The side of the square a surface whose origin is unknown serves before its +/// display's size is recorded (`setDisplaySize:forDisplay:`). +FOUNDATION_EXPORT const int32_t BGSRNSecureFallbackDisplaySize; + /// The regions the app has asked Bugsee not to record, in the form the SDK /// pulls them. /// @@ -20,6 +29,46 @@ NS_ASSUME_NONNULL_BEGIN /// The version is kept per display, because the SDK's freshness comparison is /// per display: a change on one screen must not invalidate another's. /// +/// Fabric `measureInWindow` is relative to the measured node's nearest +/// `RootNodeKind` ancestor: the app's root, or a ``'s content (the +/// `ModalHostView` node, whose transform is the identity). Each rectangle is +/// stored under the surface it was measured in (the Modal host's React tag, +/// or `BGSRNSecureMainSurface`) and served moved by that surface's origin in +/// screen points. A Modal's origin is where its presented view controller's +/// view sits: the window's origin for a full-screen Modal, inset for a +/// `pageSheet` or `formSheet` one. A single origin for the display cannot +/// serve both. +/// +/// Origins can be fractional. Left and top edges are moved and rounded down, +/// right and bottom ones up: a rectangle may grow by under a point, never +/// shrink. Edges saturate at the int32 range rather than wrap. +/// +/// Fails closed: a surface whose origin has not been recorded yet, the main +/// one included, serves one rectangle covering its display (the screen's +/// bounds) instead of its rectangles. While no React root can be found at +/// all, the main surface stays that one rectangle: there is no origin to +/// record, and the store will not guess one (ruled, N8). +/// +/// A Modal surface belongs to one JS runtime: its key is a React tag the next +/// runtime does not know. A reload drops every surface but the main one +/// (`claimRuntime`, `releaseRuntime:`), so a Modal that was open when the old +/// runtime went away does not mask the screen for the rest of the process. A +/// claim also empties the main surface's rectangles, keeping its origin: they +/// were the old runtime's, and its clearing write is ignored once stale +/// (below), so a new tree that never publishes on the main surface would +/// otherwise leave them masking until the process dies. +/// +/// The claim also gates the module's rectangle writes +/// (`setCoordinates:count:forDisplay:surface:runtime:`): one whose claim is no +/// longer current is ignored, under the same lock as the claim. A reload can +/// start the new module before the old one is invalidated, and Fabric numbers +/// React tags from 1 again, so the old runtime's late writes would otherwise +/// put back a lane the claim dropped, or clear the new runtime's lane on the +/// same key and uncover its Modal. A Modal surface's host is named the same way +/// (`setHostResolver:forSurface:runtime:`), so the pull reads each origin +/// from the current runtime's host. Origins, display sizes and empty-lane drops are +/// written by the wrapper's pull, which belongs to no runtime. +/// /// ## Threading /// /// Writes arrive on the JS thread; the pull is on the main thread. Every @@ -27,6 +76,10 @@ NS_ASSUME_NONNULL_BEGIN /// is explicit that a buffer rewritten by another thread while it reads is a /// use-after-free, so nothing here ever republishes into a buffer it has /// already handed out. +/// A module's lookup of a surface's host by its React tag (see +/// `setHostResolver:forSurface:runtime:`). +typedef id _Nullable (^BGSRNSecureHostResolver)(NSInteger surface); + @interface BGSRNSecureRectangles : NSObject /// The process-wide set of secured regions. @@ -37,8 +90,7 @@ NS_ASSUME_NONNULL_BEGIN /// forgotten when that happens. @property (class, readonly) BGSRNSecureRectangles *shared; -/// Publishes `coordinates` as the secure set for `display`, as a flat list of -/// four-`int32` rectangles. +/// Publishes `coordinates` as the secure set for `display`'s main surface. /// /// @param coordinates may be NULL only when `count` is 0. /// @param count number of int32s, which must be a multiple of 4. @@ -47,6 +99,94 @@ NS_ASSUME_NONNULL_BEGIN count:(NSUInteger)count forDisplay:(NSInteger)display; +/// Publishes `coordinates` for one surface on `display`. Other surfaces keep +/// their rectangles and origins. Writes as whichever runtime holds the claim +/// now; a module uses the `runtime:` variant with its own claim. +- (BOOL)setCoordinates:(nullable const int32_t *)coordinates + count:(NSUInteger)count + forDisplay:(NSInteger)display + surface:(NSInteger)surface; + +/// `setCoordinates:count:forDisplay:surface:`, written by the module holding +/// `claim` (from `claimRuntime`). What the TurboModule calls. +/// +/// @return NO, publishing nothing, when `count` is not whole rectangles or a +/// newer runtime has claimed the store since `claim` was given. +- (BOOL)setCoordinates:(nullable const int32_t *)coordinates + count:(NSUInteger)count + forDisplay:(NSInteger)display + surface:(NSInteger)surface + runtime:(NSInteger)claim; + +/// The claim a runtime holds now. For writers that belong to no runtime of +/// their own (tests, single-runtime callers); a module writes with the claim +/// `claimRuntime` gave it. +- (NSInteger)currentClaim; + +/// Records where the main surface's window sits on `display`'s screen, in +/// points. Re-recording the same origin costs nothing. +- (void)setOrigin:(CGPoint)origin forDisplay:(NSInteger)display; + +/// Records where one surface's `measureInWindow` (0, 0) sits on `display`'s +/// screen, in points. Only that surface's rectangles move. +- (void)setOrigin:(CGPoint)origin forDisplay:(NSInteger)display surface:(NSInteger)surface; + +/// Records `display`'s screen size in points: what a surface whose origin is +/// unknown serves. Re-recording the same size costs nothing. +- (void)setDisplaySize:(CGSize)size forDisplay:(NSInteger)display; + +/// The surfaces other than the main one that `display` has lanes for, in key +/// order: the ones whose origin the pull refreshes. +- (NSArray *)surfacesForDisplay:(NSInteger)display; + +/// A new JS runtime's module is starting: drops every surface but the main +/// one, empties the main one's rectangles (keeping its origin), and returns +/// its claim. Serialised with `releaseRuntime:`, so an old +/// module released after the new one started cannot drop its surfaces. +- (NSInteger)claimRuntime; + +/// The module holding `claim` is gone: drops every surface but the main one, +/// unless a newer runtime has already claimed the store. +- (void)releaseRuntime:(NSInteger)claim; + +/// Records `host`, held weakly, as the view whose origin places `surface`, a +/// `` lane of the module holding `claim`. The pull then reads the +/// origin from this host rather than searching the windows for a view with +/// the tag, which another runtime's host can share during a reload. Ignored +/// (NO) when the claim is stale, `surface` is the main one, or no display has +/// a lane for it. Every recorded host is forgotten on a claim and on a +/// release that drops lanes, and a surface's host goes with its last lane. +- (BOOL)setHost:(id)host forSurface:(NSInteger)surface runtime:(NSInteger)claim; + +/// Records how the module holding `claim` finds `surface`'s host: a lookup +/// in its own runtime's view registry. A Modal's host is mounted after JS has +/// measured and published inside it, so the lookup is kept and asked (on +/// main, by `hostForSurface:accepting:`) until it finds the host, which is +/// then recorded as by `setHost:forSurface:runtime:`. Same rules: ignored +/// (NO) when the claim is stale, `surface` is the main one, or it has no +/// lane; forgotten with the surface's last lane and on a claim. +- (BOOL)setHostResolver:(BGSRNSecureHostResolver)resolver + forSurface:(NSInteger)surface + runtime:(NSInteger)claim; + +/// `surface`'s host: the recorded one while it lives; otherwise what the +/// recorded lookup finds now, recorded if `accept` takes it and the claim it +/// was named with is still current. nil when there is neither, or the lookup +/// finds nothing acceptable. Calls the lookup outside the store's lock, on +/// the caller's thread: main, for a view registry. +- (nullable id)hostForSurface:(NSInteger)surface accepting:(BOOL (^)(id candidate))accept; + +/// Whether the current runtime has named `surface`'s host or its lookup +/// (`setHost:...`, `setHostResolver:...`). Once it has, only that host places +/// the lane: a lookup that finds nothing yet means the origin is unknown, not +/// that the windows may be searched, where another runtime's host can be the +/// only one with the tag. +- (BOOL)isHostNamedForSurface:(NSInteger)surface; + +/// Forgets `surface` on every display where it holds no rectangles (its +/// Modal is gone). A surface that still holds rectangles stays. +- (void)dropSurfaceIfEmpty:(NSInteger)surface; + /// The buffer for `display`. A display nothing has secured reports an empty /// set rather than nil, so the SDK always has a version to compare against. - (NSData *)snapshotForDisplay:(NSInteger)display; diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m index cabd2e4f..07501a74 100644 --- a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m @@ -10,6 +10,16 @@ @interface BGSRNFakeReactRoot : UIView @implementation BGSRNFakeReactRoot @end +/// Stands in for `RCTModalHostViewComponentView`; the module's predicate +/// matches the real class by name. +@interface BGSRNFakeModalHost : UIView +/// What `RCTModalHostViewComponentView` presents its content in. +@property (nonatomic, strong, nullable) UIViewController *viewController; +@end + +@implementation BGSRNFakeModalHost +@end + /// The `vh` origin must be the offset the SDK itself adds to every native /// node -- the hosting window's `frame.origin` /// (`BGSCaptureViewHierarchyEngine.m:334-336`) -- so the React tree and the @@ -121,4 +131,334 @@ - (void)testTheSdkWalkedWindowsAreEmptyWithoutAKeyWindow { XCTAssertEqualObjects(BGSRNSdkWalkedWindows(nil), @[]); } +#pragma mark - The host by React tag (PR 48) + +- (BOOL (^)(UIView *))isModalHost { + return ^BOOL(UIView *view) { + return [view isKindOfClass:BGSRNFakeModalHost.class]; + }; +} + +/// A native view with the host's tag, earlier in the same window, is what +/// `viewWithTag:` returns; the walk must keep going and find the host. +- (void)testANativeViewWithTheSameTagBeforeTheHostStillFindsTheHost { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + UIView *dummy = [UIView new]; + dummy.tag = 42; + [window addSubview:dummy]; + UIView *container = [UIView new]; + [window addSubview:container]; + BGSRNFakeModalHost *host = [BGSRNFakeModalHost new]; + host.tag = 42; + [container addSubview:host]; + XCTAssertEqual([window viewWithTag:42], dummy, @"the fixture must put the dummy first"); + + UIView *found = BGSRNUniqueTaggedView(@[ window ], 42, [self isModalHost], BGSRNModalHostSearchBudget); + + XCTAssertEqual(found, host); +} + +/// Same when the dummy is the host's own ancestor. +- (void)testAnAncestorWithTheSameTagStillFindsTheHost { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + UIView *dummy = [UIView new]; + dummy.tag = 7; + [window addSubview:dummy]; + BGSRNFakeModalHost *host = [BGSRNFakeModalHost new]; + host.tag = 7; + [dummy addSubview:host]; + + XCTAssertEqual(BGSRNUniqueTaggedView(@[ window ], 7, [self isModalHost], BGSRNModalHostSearchBudget), host); +} + +/// A wrong-class match in one window does not stop the search of the next. +- (void)testAWrongClassMatchInOneWindowStillSearchesTheNext { + UIWindow *first = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + UIView *dummy = [UIView new]; + dummy.tag = 42; + [first addSubview:dummy]; + UIWindow *second = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + BGSRNFakeModalHost *host = [BGSRNFakeModalHost new]; + host.tag = 42; + [second addSubview:host]; + + XCTAssertEqual(BGSRNUniqueTaggedView(@[ first, second ], 42, [self isModalHost], BGSRNModalHostSearchBudget), host); +} + +/// The class alone is not enough: another Modal's host has another tag. +- (void)testAHostWithAnotherTagIsNotAMatch { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + BGSRNFakeModalHost *other = [BGSRNFakeModalHost new]; + other.tag = 43; + [window addSubview:other]; + UIView *dummy = [UIView new]; + dummy.tag = 42; + [window addSubview:dummy]; + + XCTAssertNil(BGSRNUniqueTaggedView(@[ window ], 42, [self isModalHost], BGSRNModalHostSearchBudget)); +} + +- (void)testTheTaggedSearchIsBoundedPerWindow { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + UIView *parent = window; + for (NSUInteger i = 0; i < 5; i++) { + UIView *child = [UIView new]; + [parent addSubview:child]; + parent = child; + } + BGSRNFakeModalHost *host = [BGSRNFakeModalHost new]; + host.tag = 42; + [parent addSubview:host]; + + // The window, five levels, then the host: the seventh view visited. + XCTAssertNil(BGSRNUniqueTaggedView(@[ window ], 42, [self isModalHost], 6)); + XCTAssertEqual(BGSRNUniqueTaggedView(@[ window ], 42, [self isModalHost], 7), host); +} + +#pragma mark - Which host places a lane (PR 48, round 2) + +/// A host with `tag`, presenting its content at `contentFrame` inside +/// `window` when `presentedIn` is non-nil (a stand-in for a presented view +/// controller: its view is in a window), not presented otherwise. +- (BGSRNFakeModalHost *)hostTagged:(NSInteger)tag + inWindow:(UIWindow *)window + presentedIn:(nullable UIWindow *)presentedIn + contentFrame:(CGRect)contentFrame { + BGSRNFakeModalHost *host = [BGSRNFakeModalHost new]; + host.tag = tag; + [window addSubview:host]; + UIViewController *controller = [UIViewController new]; + controller.view.frame = contentFrame; + if (presentedIn != nil) { + [presentedIn addSubview:controller.view]; + } + host.viewController = controller; + return host; +} + +- (BGSRNSecureRectangles *)storeWithLaneOn:(NSInteger)surface claim:(NSInteger *)claim { + BGSRNSecureRectangles *store = [[BGSRNSecureRectangles alloc] init]; + const NSInteger current = [store claimRuntime]; + const int32_t rects[] = {1, 2, 3, 4}; + XCTAssertTrue([store setCoordinates:rects count:4 forDisplay:0 surface:surface runtime:current]); + if (claim != NULL) { + *claim = current; + } + return store; +} + +- (void)testTheHostOriginIsThePresentedContentInItsWindowPlusTheWindowsOrigin { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(10, 20, 390, 844)]; + BGSRNFakeModalHost *host = [self hostTagged:42 inWindow:window presentedIn:window contentFrame:CGRectMake(0, 60, 390, 784)]; + + XCTAssertEqualObjects(BGSRNModalHostOrigin(host), [NSValue valueWithCGPoint:CGPointMake(10, 80)]); +} + +- (void)testAHostWhoseContentHasNoWindowHasNoOrigin { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + BGSRNFakeModalHost *host = [self hostTagged:42 inWindow:window presentedIn:nil contentFrame:CGRectMake(0, 60, 390, 784)]; + + XCTAssertNil(BGSRNModalHostOrigin(host)); + XCTAssertNil(BGSRNModalHostOrigin([UIView new])); +} + +/// What a module's view registry answers: `host` for `tag`, nothing else. +- (BGSRNSecureHostResolver)registryWith:(UIView *)host calls:(NSUInteger *)calls { + __weak UIView *weakHost = host; + return ^id _Nullable(NSInteger tag) { + if (calls != NULL) { + *calls += 1; + } + return tag == weakHost.tag ? weakHost : nil; + }; +} + +/// Two hosts with tag 42, only the second presented and the current +/// runtime's: the origin comes from the second, though the walk meets the +/// first one first. +- (void)testTheOriginComesFromTheCurrentRuntimesHost { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + [self hostTagged:42 inWindow:window presentedIn:nil contentFrame:CGRectMake(0, 300, 390, 544)]; + BGSRNFakeModalHost *current = [self hostTagged:42 inWindow:window presentedIn:window contentFrame:CGRectMake(0, 60, 390, 784)]; + NSInteger claim = 0; + BGSRNSecureRectangles *store = [self storeWithLaneOn:42 claim:&claim]; + XCTAssertTrue([store setHostResolver:[self registryWith:current calls:NULL] forSurface:42 runtime:claim]); + + NSValue *origin = BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost]); + + XCTAssertEqualObjects(origin, [NSValue valueWithCGPoint:CGPointMake(0, 60)]); +} + +/// The host is mounted after the publish that names it: the lookup is asked +/// on each pull until it finds the host, which is then held, not looked up +/// again. +- (void)testTheLookupIsAskedUntilTheHostIsMountedThenTheHostIsHeld { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + NSInteger claim = 0; + BGSRNSecureRectangles *store = [self storeWithLaneOn:42 claim:&claim]; + __block UIView *mounted = nil; + __block NSUInteger calls = 0; + [store setHostResolver:^id _Nullable(NSInteger tag) { + calls += 1; + return mounted; + } + forSurface:42 + runtime:claim]; + + XCTAssertNil(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost])); + XCTAssertEqual(calls, 1u); + + mounted = [self hostTagged:42 inWindow:window presentedIn:window contentFrame:CGRectMake(0, 60, 390, 784)]; + XCTAssertEqualObjects(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost]), + [NSValue valueWithCGPoint:CGPointMake(0, 60)]); + XCTAssertEqualObjects(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost]), + [NSValue valueWithCGPoint:CGPointMake(0, 60)]); + XCTAssertEqual(calls, 2u); +} + +/// What the registry answers must be a Modal host with the surface's tag. +- (void)testALookupAnswerThatIsNotAModalHostIsNotUsed { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + UIView *plain = [UIView new]; + plain.tag = 42; + [window addSubview:plain]; + NSInteger claim = 0; + BGSRNSecureRectangles *store = [self storeWithLaneOn:42 claim:&claim]; + [store setHostResolver:^id _Nullable(NSInteger tag) { + return plain; + } + forSurface:42 + runtime:claim]; + + XCTAssertNil(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost])); + XCTAssertNil([store hostForSurface:42 accepting:^BOOL(id candidate) { return NO; }]); +} + +/// A reload with a sheet up: after the new runtime claims, the old host's +/// origin is never used, though it is still presented with the same tag. +- (void)testAfterAClaimTheOldHostsOriginIsNeverUsed { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + BGSRNFakeModalHost *old = [self hostTagged:42 inWindow:window presentedIn:window contentFrame:CGRectMake(0, 300, 390, 544)]; + NSInteger oldClaim = 0; + BGSRNSecureRectangles *store = [self storeWithLaneOn:42 claim:&oldClaim]; + XCTAssertTrue([store setHostResolver:[self registryWith:old calls:NULL] forSurface:42 runtime:oldClaim]); + XCTAssertEqualObjects(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost]), + [NSValue valueWithCGPoint:CGPointMake(0, 300)]); + + const NSInteger current = [store claimRuntime]; + XCTAssertNil([store hostForSurface:42 accepting:^BOOL(id candidate) { return YES; }], + @"a claim forgets every host and lookup"); + const int32_t rects[] = {1, 2, 3, 4}; + [store setCoordinates:rects count:4 forDisplay:0 surface:42 runtime:current]; + BGSRNFakeModalHost *fresh = [self hostTagged:42 inWindow:window presentedIn:window contentFrame:CGRectMake(0, 0, 390, 844)]; + + // Before the new module names its host: two matches, so no origin. + XCTAssertNil(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost])); + // The old module's late naming is stale. + XCTAssertFalse([store setHostResolver:[self registryWith:old calls:NULL] forSurface:42 runtime:oldClaim]); + XCTAssertFalse([store setHost:old forSurface:42 runtime:oldClaim]); + XCTAssertNil(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost])); + // The new module's lookup places the lane by its own host. + XCTAssertTrue([store setHostResolver:[self registryWith:fresh calls:NULL] forSurface:42 runtime:current]); + XCTAssertEqualObjects(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost]), + [NSValue valueWithCGPoint:CGPointZero]); +} + +/// The fallback walk cannot tell two hosts with one tag apart: no origin, +/// even when only one is presented. +- (void)testTwoWalkMatchesGiveNoOrigin { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + [self hostTagged:42 inWindow:window presentedIn:window contentFrame:CGRectMake(0, 300, 390, 544)]; + UIWindow *other = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + [self hostTagged:42 inWindow:other presentedIn:nil contentFrame:CGRectMake(0, 60, 390, 784)]; + BGSRNSecureRectangles *store = [self storeWithLaneOn:42 claim:NULL]; + + XCTAssertNil(BGSRNUniqueTaggedView(@[ window, other ], 42, [self isModalHost], BGSRNModalHostSearchBudget)); + XCTAssertNil(BGSRNSecureSurfaceOrigin(store, 42, @[ window, other ], [self isModalHost])); + XCTAssertEqualObjects(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost]), + [NSValue valueWithCGPoint:CGPointMake(0, 300)]); +} + +/// The one walk match whose content has no window gives no origin. +- (void)testAWalkMatchThatIsNotPresentedGivesNoOrigin { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + [self hostTagged:42 inWindow:window presentedIn:nil contentFrame:CGRectMake(0, 60, 390, 784)]; + BGSRNSecureRectangles *store = [self storeWithLaneOn:42 claim:NULL]; + + XCTAssertNil(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost])); +} + +/// A held host that has gone is looked up again; the lookup not finding it +/// leaves the origin unknown, not a search that could find another host. +- (void)testAGoneHostTheLookupCannotFindGivesNoOrigin { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + [self hostTagged:42 inWindow:window presentedIn:window contentFrame:CGRectMake(0, 300, 390, 544)]; + NSInteger claim = 0; + BGSRNSecureRectangles *store = [self storeWithLaneOn:42 claim:&claim]; + __block NSUInteger calls = 0; + @autoreleasepool { + BGSRNFakeModalHost *gone = [BGSRNFakeModalHost new]; + gone.tag = 42; + XCTAssertTrue([store setHost:gone forSurface:42 runtime:claim]); + [store setHostResolver:^id _Nullable(NSInteger tag) { + calls += 1; + return nil; + } + forSurface:42 + runtime:claim]; + } + + XCTAssertNil(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost])); + XCTAssertEqual(calls, 1u); +} + +/// A reload with a sheet up, before the new runtime's Modal has mounted: the +/// old runtime's presented host is the only one with tag 42, and the new +/// runtime has named its lookup, which finds nothing yet. The old host's +/// origin must not place the new lane: it stays unknown and is served as the +/// whole display. +- (void)testAnOldHostAloneWhileTheNewHostIsNotMountedGivesNoOrigin { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + [self hostTagged:42 inWindow:window presentedIn:window contentFrame:CGRectMake(0, 300, 390, 544)]; + BGSRNSecureRectangles *store = [[BGSRNSecureRectangles alloc] init]; + [store setDisplaySize:CGSizeMake(390, 844) forDisplay:0]; + [store setOrigin:CGPointZero forDisplay:0]; + [store claimRuntime]; + const NSInteger current = [store claimRuntime]; + const int32_t rects[] = {10, 20, 110, 120}; + XCTAssertTrue([store setCoordinates:rects count:4 forDisplay:0 surface:42 runtime:current]); + XCTAssertTrue([store setHostResolver:^id _Nullable(NSInteger tag) { + return nil; + } + forSurface:42 + runtime:current]); + XCTAssertNotNil(BGSRNUniqueTaggedView(@[ window ], 42, [self isModalHost], BGSRNModalHostSearchBudget), + @"the fixture's old host is the walk's one match"); + + NSValue *origin = BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost]); + + XCTAssertNil(origin); + // What the pull then serves: the lane's origin is still unknown. + NSData *snapshot = [store snapshotForDisplay:0]; + int32_t served[6] = {0}; + [snapshot getBytes:served length:sizeof(served)]; + XCTAssertEqual(CFSwapInt32LittleToHost((uint32_t)served[1]), 1u); + XCTAssertEqual((int32_t)CFSwapInt32LittleToHost((uint32_t)served[2]), 0); + XCTAssertEqual((int32_t)CFSwapInt32LittleToHost((uint32_t)served[3]), 0); + XCTAssertEqual((int32_t)CFSwapInt32LittleToHost((uint32_t)served[4]), 390); + XCTAssertEqual((int32_t)CFSwapInt32LittleToHost((uint32_t)served[5]), 844); +} + +/// With no lookup ever named (no registry), the walk's one presented match +/// places the lane. +- (void)testWithNoLookupNamedTheUniqueWalkPlacesTheLane { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + [self hostTagged:42 inWindow:window presentedIn:window contentFrame:CGRectMake(0, 300, 390, 544)]; + BGSRNSecureRectangles *store = [self storeWithLaneOn:42 claim:NULL]; + + XCTAssertFalse([store isHostNamedForSurface:42]); + XCTAssertEqualObjects(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost]), + [NSValue valueWithCGPoint:CGPointMake(0, 300)]); +} + @end diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m index 714d2f9a..018e7778 100644 --- a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m @@ -19,6 +19,11 @@ @implementation BGSRNSecureRectanglesTests { - (void)setUp { [super setUp]; _store = [[BGSRNSecureRectangles alloc] init]; + // The main surface's origin read, as the first pull does: every test about + // publishing and versions starts here. The fail-closed tests use a bare + // store. + [_store setOrigin:CGPointZero forDisplay:0]; + [_store setOrigin:CGPointZero forDisplay:1]; } /// Reads the buffer as the SDK does: little-endian int32, `[version, count, …]`. @@ -144,6 +149,415 @@ - (void)testIgnoresACoordinateListThatIsNotWholeRectangles { XCTAssertEqualObjects(packed[1], @0, @"a rejected write must not publish anything"); } +/// An origin-only write that leaves the served buffer empty must keep the +/// snapshot at the empty-set version (1), not message-nil's 0. Android's +/// empty snapshot stays at version 1 the same way. +- (void)testAnOriginOnlyWriteThatLeavesServedEmptyKeepsVersionOne { + [_store setOrigin:CGPointMake(0, 96) forDisplay:0]; + + NSArray *packed = [self unpack:[_store snapshotForDisplay:0]]; + XCTAssertEqualObjects(packed[0], @1, + @"origin-only with no rectangles must not report version 0"); + XCTAssertEqualObjects(packed[1], @0); +} + +/// The main origin must not move another surface's rectangle, and that +/// surface's own origin must. +- (void)testTheMainOriginDoesNotMoveAnotherSurfacesRectangle { + const int32_t mainRects[] = {10, 20, 30, 40}; + const int32_t modalRects[] = {100, 200, 150, 250}; + [_store setCoordinates:mainRects count:4 forDisplay:0 surface:BGSRNSecureMainSurface]; + [_store setCoordinates:modalRects count:4 forDisplay:0 surface:42]; + [_store setOrigin:CGPointMake(0, 96) forDisplay:0 surface:BGSRNSecureMainSurface]; + [_store setOrigin:CGPointMake(0, 0) forDisplay:0 surface:42]; + + NSArray *packed = [self unpack:[_store snapshotForDisplay:0]]; + XCTAssertEqualObjects(packed[1], @2); + XCTAssertEqualObjects([packed subarrayWithRange:NSMakeRange(2, 8)], + (@[ @10, @116, @30, @136, @100, @200, @150, @250 ])); +} + +- (void)testAnotherSurfacesOwnOriginMovesOnlyItsRectangle { + const int32_t mainRects[] = {10, 20, 30, 40}; + const int32_t modalRects[] = {100, 200, 150, 250}; + [_store setCoordinates:mainRects count:4 forDisplay:0 surface:BGSRNSecureMainSurface]; + [_store setCoordinates:modalRects count:4 forDisplay:0 surface:42]; + [_store setOrigin:CGPointMake(0, 96) forDisplay:0 surface:BGSRNSecureMainSurface]; + [_store setOrigin:CGPointMake(7, 40) forDisplay:0 surface:42]; + + NSArray *packed = [self unpack:[_store snapshotForDisplay:0]]; + XCTAssertEqualObjects(packed[1], @2); + XCTAssertEqualObjects([packed subarrayWithRange:NSMakeRange(2, 8)], + (@[ @10, @116, @30, @136, @107, @240, @157, @290 ])); +} + +/// A Modal's rectangles before its origin is recorded redact exactly the screen. +- (void)testASurfaceWithNoOriginYetServesTheScreenBounds { + const int32_t modalRects[] = {100, 200, 150, 250, 1, 2, 3, 4}; + [_store setDisplaySize:CGSizeMake(402, 874) forDisplay:0]; + [_store setCoordinates:modalRects count:8 forDisplay:0 surface:42]; + + NSArray *packed = [self unpack:[_store snapshotForDisplay:0]]; + XCTAssertEqualObjects(packed[1], @1); + XCTAssertEqualObjects([packed subarrayWithRange:NSMakeRange(2, 4)], (@[ @0, @0, @402, @874 ])); +} + +- (void)testAFractionalScreenSizeIsRoundedUp { + const int32_t rects[] = {1, 2, 3, 4}; + [_store setDisplaySize:CGSizeMake(402.5, 873.2) forDisplay:0]; + [_store setCoordinates:rects count:4 forDisplay:0 surface:42]; + + XCTAssertEqualObjects([[self unpack:[_store snapshotForDisplay:0]] subarrayWithRange:NSMakeRange(2, 4)], + (@[ @0, @0, @403, @874 ])); +} + +- (void)testBeforeTheScreenSizeIsKnownTheWholeDisplayIsTheFallbackSquare { + const int32_t rects[] = {1, 2, 3, 4}; + [_store setCoordinates:rects count:4 forDisplay:0 surface:42]; + + XCTAssertEqualObjects([[self unpack:[_store snapshotForDisplay:0]] subarrayWithRange:NSMakeRange(2, 4)], + (@[ @0, @0, @(BGSRNSecureFallbackDisplaySize), @(BGSRNSecureFallbackDisplaySize) ])); +} + +- (void)testRecordingTheScreenSizeMovesTheVersionOnlyWhenWhatIsServedChanges { + const int32_t rects[] = {1, 2, 3, 4}; + [_store setCoordinates:rects count:4 forDisplay:0 surface:42]; + NSNumber *before = [self unpack:[_store snapshotForDisplay:0]][0]; + + [_store setDisplaySize:CGSizeMake(402, 874) forDisplay:0]; + NSNumber *sized = [self unpack:[_store snapshotForDisplay:0]][0]; + [_store setDisplaySize:CGSizeMake(402, 874) forDisplay:0]; + [_store setDisplaySize:CGSizeZero forDisplay:0]; + + XCTAssertNotEqualObjects(sized, before); + XCTAssertEqualObjects([self unpack:[_store snapshotForDisplay:0]][0], sized); +} + +- (void)testRecordingTheSurfacesOriginReplacesTheWholeDisplayRectangle { + const int32_t modalRects[] = {100, 200, 150, 250}; + [_store setCoordinates:modalRects count:4 forDisplay:0 surface:42]; + NSNumber *before = [self unpack:[_store snapshotForDisplay:0]][0]; + + [_store setOrigin:CGPointZero forDisplay:0 surface:42]; + + NSArray *packed = [self unpack:[_store snapshotForDisplay:0]]; + XCTAssertNotEqualObjects(packed[0], before); + XCTAssertEqualObjects([packed subarrayWithRange:NSMakeRange(2, 4)], (@[ @100, @200, @150, @250 ])); +} + +/// The main surface fails closed too, until its first origin is recorded. +- (void)testTheMainSurfaceWithNoOriginYetServesTheScreenBounds { + BGSRNSecureRectangles *bare = [[BGSRNSecureRectangles alloc] init]; + const int32_t rects[] = {10, 20, 30, 40}; + [bare setDisplaySize:CGSizeMake(402, 874) forDisplay:0]; + [bare setCoordinates:rects count:4 forDisplay:0]; + + XCTAssertEqualObjects([[self unpack:[bare snapshotForDisplay:0]] subarrayWithRange:NSMakeRange(2, 4)], + (@[ @0, @0, @402, @874 ])); + + [bare setOrigin:CGPointMake(0, 59) forDisplay:0]; + XCTAssertEqualObjects([[self unpack:[bare snapshotForDisplay:0]] subarrayWithRange:NSMakeRange(2, 4)], + (@[ @10, @79, @30, @99 ])); +} + +/// A fractional origin grows the rectangle by under a point, never shrinks it. +- (void)testAFractionalOriginRoundsEveryEdgeOutward { + const int32_t rects[] = {10, 20, 30, 40}; + [_store setCoordinates:rects count:4 forDisplay:0 surface:42]; + [_store setOrigin:CGPointMake(0.5, 59.25) forDisplay:0 surface:42]; + + XCTAssertEqualObjects([[self unpack:[_store snapshotForDisplay:0]] subarrayWithRange:NSMakeRange(2, 4)], + (@[ @10, @79, @31, @100 ])); +} + +- (void)testANegativeFractionalOriginAlsoRoundsOutward { + const int32_t rects[] = {10, 20, 30, 40}; + [_store setCoordinates:rects count:4 forDisplay:0]; + [_store setOrigin:CGPointMake(-0.5, -0.5) forDisplay:0]; + + XCTAssertEqualObjects([[self unpack:[_store snapshotForDisplay:0]] subarrayWithRange:NSMakeRange(2, 4)], + (@[ @9, @19, @30, @40 ])); +} + +- (void)testAnOriginSaturatesInsteadOfWrapping { + const int32_t rects[] = {INT32_MAX - 1, INT32_MIN + 1, INT32_MAX - 1, INT32_MIN + 1}; + [_store setCoordinates:rects count:4 forDisplay:0]; + [_store setOrigin:CGPointMake(10, -10) forDisplay:0]; + + XCTAssertEqualObjects([[self unpack:[_store snapshotForDisplay:0]] subarrayWithRange:NSMakeRange(2, 4)], + (@[ @(INT32_MAX), @((uint32_t)INT32_MIN), @(INT32_MAX), @((uint32_t)INT32_MIN) ]), + @"unpack reads each int32's bits as unsigned"); +} + +- (void)testRecordingTheSameOriginAgainHoldsTheVersion { + const int32_t rects[] = {10, 20, 30, 40}; + [_store setCoordinates:rects count:4 forDisplay:0 surface:42]; + [_store setOrigin:CGPointMake(0, 59) forDisplay:0 surface:42]; + NSNumber *settled = [self unpack:[_store snapshotForDisplay:0]][0]; + + [_store setOrigin:CGPointMake(0, 59) forDisplay:0 surface:42]; + + XCTAssertEqualObjects([self unpack:[_store snapshotForDisplay:0]][0], settled); +} + +- (void)testListsTheSurfacesOtherThanTheMainOne { + const int32_t rects[] = {1, 2, 3, 4}; + [_store setCoordinates:rects count:4 forDisplay:0]; + [_store setCoordinates:rects count:4 forDisplay:0 surface:78]; + [_store setCoordinates:rects count:4 forDisplay:0 surface:42]; + [_store setCoordinates:rects count:4 forDisplay:1 surface:99]; + + XCTAssertEqualObjects([_store surfacesForDisplay:0], (@[ @42, @78 ])); +} + +- (void)testDropsOnlyAnEmptyNonMainSurface { + const int32_t rects[] = {1, 2, 3, 4}; + [_store setCoordinates:NULL count:0 forDisplay:0 surface:42]; + [_store setCoordinates:rects count:4 forDisplay:0 surface:78]; + [_store setCoordinates:NULL count:0 forDisplay:0]; + NSNumber *before = [self unpack:[_store snapshotForDisplay:0]][0]; + + [_store dropSurfaceIfEmpty:42]; + [_store dropSurfaceIfEmpty:78]; + [_store dropSurfaceIfEmpty:BGSRNSecureMainSurface]; + + XCTAssertEqualObjects([_store surfacesForDisplay:0], (@[ @78 ])); + XCTAssertEqualObjects([self unpack:[_store snapshotForDisplay:0]][0], before); +} + +/// A new runtime drops the Modal surfaces the old one left; the main one +/// stays, emptied, with its origin. +- (void)testClaimingANewRuntimeDropsEveryModalSurfaceButKeepsTheMainOne { + const int32_t mainRects[] = {10, 20, 30, 40}; + const int32_t modalRects[] = {100, 200, 150, 250}; + [_store setDisplaySize:CGSizeMake(402, 874) forDisplay:0]; + [_store setCoordinates:mainRects count:4 forDisplay:0]; + [_store setCoordinates:modalRects count:4 forDisplay:0 surface:42]; + [_store setCoordinates:modalRects count:4 forDisplay:1 surface:43]; + NSNumber *before = [self unpack:[_store snapshotForDisplay:0]][0]; + + [_store claimRuntime]; + + XCTAssertEqualObjects([_store surfacesForDisplay:0], @[]); + XCTAssertEqualObjects([_store surfacesForDisplay:1], @[]); + NSArray *packed = [self unpack:[_store snapshotForDisplay:0]]; + XCTAssertNotEqualObjects(packed[0], before); + XCTAssertEqualObjects(packed[1], @0); +} + +/// The main surface's origin survives the claim: the new runtime's first set +/// is placed, not failed closed. +- (void)testAClaimKeepsTheMainSurfacesOrigin { + const int32_t oldRects[] = {10, 20, 30, 40}; + const int32_t newRects[] = {1, 2, 3, 4}; + [_store setDisplaySize:CGSizeMake(402, 874) forDisplay:0]; + [_store setOrigin:CGPointMake(5, 7) forDisplay:0]; + [_store setCoordinates:oldRects count:4 forDisplay:0]; + + const NSInteger current = [_store claimRuntime]; + XCTAssertTrue([_store setCoordinates:newRects count:4 forDisplay:0 surface:BGSRNSecureMainSurface runtime:current]); + + XCTAssertEqualObjects([[self unpack:[_store snapshotForDisplay:0]] subarrayWithRange:NSMakeRange(1, 5)], + (@[ @1, @6, @9, @8, @11 ])); +} + +/// The old runtime's main set must not outlive it: its clearing write is +/// stale, and a new tree that never publishes on the main surface would +/// otherwise keep masking where the old secure view was. +- (void)testAClaimWithAMainSetThenAStaleEmptyWriteAndNoNewPublishServesNothing { + const int32_t rects[] = {10, 20, 30, 40}; + [_store setDisplaySize:CGSizeMake(402, 874) forDisplay:0]; + const NSInteger old = [_store claimRuntime]; + [_store setCoordinates:rects count:4 forDisplay:0 surface:BGSRNSecureMainSurface runtime:old]; + XCTAssertEqualObjects([self unpack:[_store snapshotForDisplay:0]][1], @1); + + [_store claimRuntime]; + XCTAssertFalse([_store setCoordinates:NULL count:0 forDisplay:0 surface:BGSRNSecureMainSurface runtime:old]); + + XCTAssertEqualObjects([self unpack:[_store snapshotForDisplay:0]][1], @0); +} + +- (void)testReleasingTheCurrentRuntimeDropsItsModalSurfaces { + const int32_t rects[] = {1, 2, 3, 4}; + const NSInteger claim = [_store claimRuntime]; + [_store setCoordinates:rects count:4 forDisplay:0 surface:42]; + + [_store releaseRuntime:claim]; + + XCTAssertEqualObjects([_store surfacesForDisplay:0], @[]); +} + +/// A reload can start the new module before the old one is released. +- (void)testReleasingAnOldRuntimeLeavesTheNewRuntimesSurfaces { + const int32_t rects[] = {1, 2, 3, 4}; + const NSInteger old = [_store claimRuntime]; + [_store setCoordinates:rects count:4 forDisplay:0 surface:42]; + [_store claimRuntime]; + [_store setCoordinates:rects count:4 forDisplay:0 surface:58]; + + [_store releaseRuntime:old]; + + XCTAssertEqualObjects([_store surfacesForDisplay:0], @[ @58 ]); +} + +/// The old module is not invalidated yet when the new one claims: its late +/// publish must not put back the Modal lane the claim dropped, which would +/// fail closed over the whole screen for the rest of the process. +- (void)testAStaleNonEmptyWriteDoesNotRecreateADroppedLane { + const int32_t rects[] = {100, 200, 150, 250}; + [_store setDisplaySize:CGSizeMake(402, 874) forDisplay:0]; + const NSInteger old = [_store claimRuntime]; + XCTAssertTrue([_store setCoordinates:rects count:4 forDisplay:0 surface:42 runtime:old]); + [_store claimRuntime]; + XCTAssertEqualObjects([_store surfacesForDisplay:0], @[]); + NSData *before = [_store snapshotForDisplay:0]; + + XCTAssertFalse([_store setCoordinates:rects count:4 forDisplay:0 surface:42 runtime:old]); + + XCTAssertEqualObjects([_store surfacesForDisplay:0], @[]); + XCTAssertEqualObjects([_store snapshotForDisplay:0], before); +} + +/// Fabric numbers React tags from 1 again after a reload, so the old +/// runtime's unmount clears the same key the new runtime's Modal now +/// publishes on. That empty write must not uncover the new Modal. +- (void)testAStaleEmptyWriteDoesNotClearTheNewRuntimesLaneOnTheSameTag { + const int32_t oldRects[] = {1, 2, 3, 4}; + const int32_t newRects[] = {100, 200, 150, 250}; + [_store setDisplaySize:CGSizeMake(402, 874) forDisplay:0]; + const NSInteger old = [_store claimRuntime]; + [_store setCoordinates:oldRects count:4 forDisplay:0 surface:42 runtime:old]; + const NSInteger current = [_store claimRuntime]; + [_store setCoordinates:newRects count:4 forDisplay:0 surface:42 runtime:current]; + [_store setOrigin:CGPointMake(10, 20) forDisplay:0 surface:42]; + NSData *before = [_store snapshotForDisplay:0]; + XCTAssertEqualObjects([[self unpack:before] subarrayWithRange:NSMakeRange(1, 5)], + (@[ @1, @110, @220, @160, @270 ])); + + XCTAssertFalse([_store setCoordinates:NULL count:0 forDisplay:0 surface:42 runtime:old]); + + XCTAssertEqualObjects([_store snapshotForDisplay:0], before); +} + +- (void)testTheCurrentRuntimesWritesStillApply { + const int32_t rects[] = {100, 200, 150, 250}; + [_store setDisplaySize:CGSizeMake(402, 874) forDisplay:0]; + [_store claimRuntime]; + const NSInteger current = [_store claimRuntime]; + XCTAssertEqual([_store currentClaim], current); + + XCTAssertTrue([_store setCoordinates:rects count:4 forDisplay:0 surface:42 runtime:current]); + [_store setOrigin:CGPointMake(10, 20) forDisplay:0 surface:42]; + XCTAssertEqualObjects([[self unpack:[_store snapshotForDisplay:0]] subarrayWithRange:NSMakeRange(1, 5)], + (@[ @1, @110, @220, @160, @270 ])); + + XCTAssertTrue([_store setCoordinates:NULL count:0 forDisplay:0 surface:42 runtime:current]); + XCTAssertEqualObjects([self unpack:[_store snapshotForDisplay:0]][1], @0); +} + +/// A malformed write from the current runtime is still rejected, not applied. +- (void)testTheCurrentRuntimesMalformedWriteIsStillRejected { + const int32_t rects[] = {1, 2, 3}; + const NSInteger current = [_store claimRuntime]; + + XCTAssertFalse([_store setCoordinates:rects count:3 forDisplay:0 surface:42 runtime:current]); + XCTAssertEqualObjects([_store surfacesForDisplay:0], @[]); +} + +#pragma mark - A Modal surface's host (PR 48, round 2) + +static BOOL (^const BGSRNAcceptAny)(id) = ^BOOL(id candidate) { + return YES; +}; + +- (void)testAHostIsRecordedOnlyForTheCurrentRuntimesModalLane { + NSObject *host = [NSObject new]; + const int32_t rects[] = {1, 2, 3, 4}; + const NSInteger old = [_store claimRuntime]; + XCTAssertFalse([_store setHost:host forSurface:42 runtime:old], @"no lane for 42 yet"); + [_store setCoordinates:rects count:4 forDisplay:0 surface:42 runtime:old]; + XCTAssertFalse([_store setHost:host forSurface:BGSRNSecureMainSurface runtime:old]); + XCTAssertTrue([_store setHost:host forSurface:42 runtime:old]); + XCTAssertEqual([_store hostForSurface:42 accepting:BGSRNAcceptAny], host); + + const NSInteger current = [_store claimRuntime]; + XCTAssertNil([_store hostForSurface:42 accepting:BGSRNAcceptAny]); + [_store setCoordinates:rects count:4 forDisplay:0 surface:42 runtime:current]; + XCTAssertFalse([_store setHost:host forSurface:42 runtime:old]); + XCTAssertNil([_store hostForSurface:42 accepting:BGSRNAcceptAny]); + XCTAssertTrue([_store setHost:host forSurface:42 runtime:current]); + XCTAssertEqual([_store hostForSurface:42 accepting:BGSRNAcceptAny], host); +} + +- (void)testALookupIsNamedOnlyByTheCurrentRuntime { + NSObject *host = [NSObject new]; + const int32_t rects[] = {1, 2, 3, 4}; + BGSRNSecureHostResolver lookup = ^id _Nullable(NSInteger tag) { + return host; + }; + const NSInteger old = [_store claimRuntime]; + XCTAssertFalse([_store setHostResolver:lookup forSurface:42 runtime:old], @"no lane for 42 yet"); + [_store setCoordinates:rects count:4 forDisplay:0 surface:42 runtime:old]; + [_store claimRuntime]; + const NSInteger current = [_store claimRuntime]; + [_store setCoordinates:rects count:4 forDisplay:0 surface:42 runtime:current]; + + XCTAssertFalse([_store setHostResolver:lookup forSurface:42 runtime:old]); + XCTAssertNil([_store hostForSurface:42 accepting:BGSRNAcceptAny]); + XCTAssertTrue([_store setHostResolver:lookup forSurface:42 runtime:current]); + XCTAssertEqual([_store hostForSurface:42 accepting:BGSRNAcceptAny], host); +} + +/// A claim that lands while a lookup is running: what it found is not kept. +- (void)testAHostFoundAcrossANewClaimIsNotKept { + NSObject *host = [NSObject new]; + const int32_t rects[] = {1, 2, 3, 4}; + const NSInteger old = [_store claimRuntime]; + [_store setCoordinates:rects count:4 forDisplay:0 surface:42 runtime:old]; + BGSRNSecureRectangles *store = _store; + [_store setHostResolver:^id _Nullable(NSInteger tag) { + const NSInteger current = [store claimRuntime]; + const int32_t again[] = {1, 2, 3, 4}; + [store setCoordinates:again count:4 forDisplay:0 surface:42 runtime:current]; + return host; + } + forSurface:42 + runtime:old]; + + XCTAssertNil([_store hostForSurface:42 accepting:BGSRNAcceptAny]); + XCTAssertNil([_store hostForSurface:42 accepting:BGSRNAcceptAny]); +} + +- (void)testASurfacesHostGoesWithItsLastLane { + NSObject *host = [NSObject new]; + const int32_t rects[] = {1, 2, 3, 4}; + const NSInteger claim = [_store claimRuntime]; + [_store setCoordinates:rects count:4 forDisplay:0 surface:42 runtime:claim]; + [_store setHost:host forSurface:42 runtime:claim]; + [_store setHostResolver:^id _Nullable(NSInteger tag) { + return host; + } + forSurface:42 + runtime:claim]; + + [_store setCoordinates:NULL count:0 forDisplay:0 surface:42 runtime:claim]; + [_store dropSurfaceIfEmpty:42]; + + XCTAssertNil([_store hostForSurface:42 accepting:BGSRNAcceptAny]); +} + +- (void)testReleasingTheCurrentRuntimeForgetsItsHosts { + NSObject *host = [NSObject new]; + const int32_t rects[] = {1, 2, 3, 4}; + const NSInteger claim = [_store claimRuntime]; + [_store setCoordinates:rects count:4 forDisplay:0 surface:42 runtime:claim]; + [_store setHost:host forSurface:42 runtime:claim]; + + [_store releaseRuntime:claim]; + + XCTAssertNil([_store hostForSurface:42 accepting:BGSRNAcceptAny]); +} + /// The shared store outlives any one wrapper: the init provider registers one /// before launch and setWrapperInfo swaps in another, and the regions the app /// marked secret must survive that. diff --git a/packages/react-native/src/NativeBugsee.ts b/packages/react-native/src/NativeBugsee.ts index da4e8302..0481e6e4 100644 --- a/packages/react-native/src/NativeBugsee.ts +++ b/packages/react-native/src/NativeBugsee.ts @@ -56,9 +56,31 @@ export interface Spec extends TurboModule { * has to answer immediately, and a rejected one would leave the caller * believing a region is redacted when it is not. * - * An empty list clears the display's set. + * An empty list clears the display's main-surface set. Rectangles measured + * inside a React Native `` go through + * {@link setSecureRectanglesOnSurface} instead. */ setSecureRectangles(display: number, coordinates: number[]): void; + /** + * Like {@link setSecureRectangles}, for one React surface on the display: + * `surface` is the React tag of the `` host the rectangles were + * measured in (JS reads it off the fiber tree), or `-1` for a Modal whose + * tag is unknown. Fabric `measureInWindow` is relative to that Modal's + * content, so native finds and watches the Modal's root on the first + * publish and translates the rectangles by its origin at pull time. Until + * that origin is read, the surface redacts the whole display. + */ + setSecureRectanglesOnSurface( + display: number, + surface: number, + coordinates: number[], + ): void; + /** + * The display origin `[x, y]` of the `` whose host has React tag + * `surface`, in the units of the `vh` request's `originX`/`originY`. Empty + * when unknown. Synchronous: the `vh` walk asks once per Modal per walk. + */ + secureSurfaceOrigin(surface: number): number[]; /** * Suppresses everything that describes the screen: the video (black diff --git a/packages/react-native/src/__mocks__/native.ts b/packages/react-native/src/__mocks__/native.ts index 653332da..193c8c5a 100644 --- a/packages/react-native/src/__mocks__/native.ts +++ b/packages/react-native/src/__mocks__/native.ts @@ -20,6 +20,8 @@ const SYNC_RETURNS: Record = { noteConsoleEcho: true, setAppearanceColor: true, getAppearanceColor: '', + // An unknown surface: no origin of its own. + secureSurfaceOrigin: [], }; const DEFAULTS: Record = { @@ -108,6 +110,8 @@ let breadcrumbFilterRequestSubscribeCalls = 0; export const native = { setWrapperInfo: jest.fn]>(), setSecureRectangles: jest.fn(), + setSecureRectanglesOnSurface: jest.fn(), + secureSurfaceOrigin: jest.fn(), startBlackout: jest.fn(), endBlackout: jest.fn(), isBlackout: jest.fn, []>(), diff --git a/packages/react-native/src/secure/BugseeSecure.tsx b/packages/react-native/src/secure/BugseeSecure.tsx index 0aacf73d..12b91392 100644 --- a/packages/react-native/src/secure/BugseeSecure.tsx +++ b/packages/react-native/src/secure/BugseeSecure.tsx @@ -4,6 +4,7 @@ import { View } from 'react-native'; import type { LayoutChangeEvent, ViewProps } from 'react-native'; import { addMeasurer } from './measureLoop'; import { clearOwner, setOwnerRectangles } from './registry'; +import { surfaceOfInstance } from './surface'; import { errorName } from '../errorName'; export interface BugseeSecureProps extends ViewProps { @@ -25,10 +26,12 @@ export interface BugseeSecureProps extends ViewProps { * Fails closed: a measurement that throws or is rejected leaves the last * rectangle published. Only unmounting or `enabled={false}` removes it. * - * The rectangle is in the main React root's window. Android adds that root's - * display origin to `measureInWindow`. A React Native `` is its own - * window (an Android `Dialog`), so a secure view inside one is not placed on - * the sheet. Same limit for `managed` nodes measured the same way. + * Fabric `measureInWindow` is relative to the measured node's nearest + * `RootNodeKind` ancestor: the app's root, or the content of the `` + * it is in. The rectangle is published under that surface's key (the Modal + * host's tag, read off the fiber tree once per mount), so native translates + * it by that surface's origin at pull time. No native call is made to find + * the surface. * * A plain function component on purpose (no `memo`, no `forwardRef`): the view * tree walk recognises it by identity. @@ -44,6 +47,9 @@ export function BugseeSecure(props: BugseeSecureProps): ReactElement { // disable must not bring the region back. const active = useRef(false); const warned = useRef(false); + // The surface this view is measured in. Read once per mount: a mounted + // host never changes ancestors. + const surface = useRef(null); // Reads refs only, so every render's copy does the same thing; the loop // keeps the one from the render that enabled it. @@ -60,12 +66,14 @@ export function BugseeSecure(props: BugseeSecureProps): ReactElement { return; } try { + surface.current ??= surfaceOfInstance(view); + const measuredIn = surface.current; view.measureInWindow((x, y, width, height) => { if (!active.current) { return; } try { - setOwnerRectangles(token, 0, [{ x, y, width, height }]); + setOwnerRectangles(token, 0, [{ x, y, width, height }], measuredIn); } catch (error) { failed(error); } @@ -87,6 +95,7 @@ export function BugseeSecure(props: BugseeSecureProps): ReactElement { const removeMeasurer = addMeasurer(measure); return () => { active.current = false; + surface.current = null; removeMeasurer(); clearOwner(token); }; diff --git a/packages/react-native/src/secure/__tests__/BugseeSecure.test.tsx b/packages/react-native/src/secure/__tests__/BugseeSecure.test.tsx index 78d7a668..140b174a 100644 --- a/packages/react-native/src/secure/__tests__/BugseeSecure.test.tsx +++ b/packages/react-native/src/secure/__tests__/BugseeSecure.test.tsx @@ -281,3 +281,134 @@ describe('', () => { expect(inner?.props.testID).toBe('inner'); }); }); + +// Fabric measureInWindow is relative to the React root that holds the view, +// and a is its own root. The rectangle goes out under the surface of +// the nearest Modal host above the view, read off the fiber tree once per +// mount, with no native call. +describe(' surfaces', () => { + const MODAL_TAG = 56; + /** Host fibers handed to RendererProxy, so a test can count the reads. */ + let tagReads: unknown[]; + + beforeEach(() => { + tagReads = []; + jest.doMock('react-native/Libraries/ReactNative/RendererProxy', () => ({ + getPublicInstanceFromInternalInstanceHandle: (fiber: { tagForTest?: unknown }) => { + tagReads.push(fiber); + return { __nativeTag: fiber.tagForTest }; + }, + })); + }); + + afterEach(() => { + jest.dontMock('react-native/Libraries/ReactNative/RendererProxy'); + }); + + /** A host fiber for the view, under `ancestors` (nearest first). */ + function fiberUnder(...ancestors: Array>): Record { + const view: Record = { tag: 5, type: 'RCTView', return: null }; + let child = view; + for (const ancestor of ancestors) { + const parent = { ...ancestor, return: null }; + child.return = parent; + child = parent; + } + return view; + } + + const modalHost = (tag: unknown = MODAL_TAG) => ({ tag: 5, type: 'RCTModalHostView', tagForTest: tag }); + + function renderWithNode(element: ReactElement, node: Record): ReactTestRenderer { + let renderer: ReactTestRenderer | undefined; + act(() => { + renderer = create(element, { + createNodeMock: () => ({ measureInWindow, ...node }), + unstable_isConcurrent: true, + } as Parameters[1]); + }); + mounted.push(renderer as ReactTestRenderer); + return renderer as ReactTestRenderer; + } + + it('publishes on the surface of the Modal it is in, asking native nothing', () => { + renderWithNode(, { + __internalInstanceHandle: fiberUnder({ tag: 5, type: 'RCTView' }, modalHost(), { tag: 3, type: null }), + }); + + expect(native.setSecureRectanglesOnSurface).toHaveBeenLastCalledWith(0, MODAL_TAG, MEASURED); + expect(native.setSecureRectangles).not.toHaveBeenCalled(); + expect(native.secureSurfaceOrigin).not.toHaveBeenCalled(); + }); + + it('a view outside every Modal publishes on the main surface', () => { + renderWithNode(, { + __internalInstanceHandle: fiberUnder({ tag: 5, type: 'RCTView' }, { tag: 3, type: null }), + }); + + expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, MEASURED); + expect(native.setSecureRectanglesOnSurface).not.toHaveBeenCalled(); + }); + + it('a view with no fiber publishes on the main surface', () => { + renderWithNode(, {}); + + expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, MEASURED); + }); + + // Never the main surface: native serves a surface whose origin it cannot + // find as the whole display. + it('a Modal whose tag cannot be read publishes on the unknown-Modal surface', () => { + renderWithNode(, { __internalInstanceHandle: fiberUnder(modalHost(null)) }); + + expect(native.setSecureRectanglesOnSurface).toHaveBeenLastCalledWith(0, -1, MEASURED); + expect(native.setSecureRectangles).not.toHaveBeenCalled(); + }); + + it('reads its surface once per mount, however often it measures', () => { + renderWithNode(, { __internalInstanceHandle: fiberUnder(modalHost()) }); + + tick(5); + + expect(tagReads).toHaveLength(1); + expect(measureInWindow.mock.calls.length).toBeGreaterThan(5); + }); + + it('reads it again after being re-enabled', () => { + const node = { __internalInstanceHandle: fiberUnder(modalHost()) }; + const renderer = renderWithNode(, node); + + update(renderer, ); + update(renderer, ); + + expect(tagReads).toHaveLength(2); + }); + + it('removes its rectangle from that surface on unmount', () => { + const renderer = renderWithNode(, { __internalInstanceHandle: fiberUnder(modalHost()) }); + + act(() => renderer.unmount()); + + expect(native.setSecureRectanglesOnSurface).toHaveBeenLastCalledWith(0, MODAL_TAG, []); + }); + + // Same as any failed measurement: nothing is published, and the next + // measurement tries again. + it('a fiber tree that throws while it is read publishes nothing and warns once', () => { + const fiber = { tag: 5, type: 'RCTView' }; + const failure = new Error('torn fiber'); + Object.defineProperty(fiber, 'return', { + get(): never { + throw failure; + }, + }); + + renderWithNode(, { __internalInstanceHandle: fiber }); + tick(2); + + expect(native.setSecureRectangles).not.toHaveBeenCalled(); + expect(native.setSecureRectanglesOnSurface).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith('[Bugsee] BugseeSecure could not measure', failure.name); + }); +}); diff --git a/packages/react-native/src/secure/__tests__/registry.test.ts b/packages/react-native/src/secure/__tests__/registry.test.ts index 74b42b4a..31a5024c 100644 --- a/packages/react-native/src/secure/__tests__/registry.test.ts +++ b/packages/react-native/src/secure/__tests__/registry.test.ts @@ -205,3 +205,143 @@ describe('the secure-rectangle registry', () => { expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, [20, 40, 80, 120]); }); }); + +// Fabric measureInWindow is relative to the measured node's own React root, +// and a is its own root (an Android Dialog). Each surface's union is +// published on its own, so native can translate it by that surface's origin. +describe('the secure-rectangle registry, per surface', () => { + const MODAL = 42; + + // The Android `doMock` above outlives its own test; these are in points. + beforeEach(() => { + jest.resetModules(); + jest.doMock('react-native', () => ({ Platform: { OS: 'ios' } })); + native = require('../../__mocks__/native').native; + native.reset(); + registry = require('../registry'); + }); + + /** Every publish on display 0, in order, across both bridge methods. */ + function publishes(): unknown[][] { + const calls: { order: number; call: unknown[] }[] = []; + native.setSecureRectangles.mock.calls.forEach((call, i) => { + calls.push({ order: native.setSecureRectangles.mock.invocationCallOrder[i] as number, call: ['main', ...call] }); + }); + native.setSecureRectanglesOnSurface.mock.calls.forEach((call, i) => { + calls.push({ + order: native.setSecureRectanglesOnSurface.mock.invocationCallOrder[i] as number, + call: ['surface', ...call], + }); + }); + return calls.sort((a, b) => a.order - b.order).map(({ call }) => call); + } + + it('names the main surface 0, as native does', () => { + expect(registry.MAIN_SURFACE).toBe(0); + }); + + it('publishes another surface through setSecureRectanglesOnSurface, apart from the main one', () => { + registry.setOwnerRectangles('manual:0', 0, [A]); + registry.setOwnerRectangles({}, 0, [B], MODAL); + + expect(publishes()).toEqual([ + ['main', 0, A_FLAT], + ['surface', 0, MODAL, B_FLAT], + ]); + }); + + it('an explicit main surface is the same lane as the default', () => { + registry.setOwnerRectangles('manual:0', 0, [A]); + registry.setOwnerRectangles({}, 0, [B], registry.MAIN_SURFACE); + + expect(publishes()).toEqual([ + ['main', 0, A_FLAT], + ['main', 0, [...A_FLAT, ...B_FLAT]], + ]); + }); + + it('keeps each surface its own union', () => { + const first = {}; + const second = {}; + registry.setOwnerRectangles(first, 0, [A], MODAL); + registry.setOwnerRectangles(second, 0, [B], MODAL); + registry.setOwnerRectangles({}, 0, [A], 7); + + expect(publishes()).toEqual([ + ['surface', 0, MODAL, A_FLAT], + ['surface', 0, MODAL, [...A_FLAT, ...B_FLAT]], + ['surface', 0, 7, A_FLAT], + ]); + }); + + it('an unchanged surface union does not cross the bridge again', () => { + const component = {}; + registry.setOwnerRectangles(component, 0, [A], MODAL); + registry.setOwnerRectangles(component, 0, [{ ...A }], MODAL); + + expect(native.setSecureRectanglesOnSurface).toHaveBeenCalledTimes(1); + }); + + it('the first publish on a surface always crosses, even when empty', () => { + registry.setOwnerRectangles('manual:0', 0, []); + registry.setOwnerRectangles({}, 0, [], MODAL); + + expect(publishes()).toEqual([ + ['main', 0, []], + ['surface', 0, MODAL, []], + ]); + }); + + it('an owner that moves surface covers the new one before it uncovers the old', () => { + const component = {}; + registry.setOwnerRectangles(component, 0, [A]); + registry.setOwnerRectangles(component, 0, [A], MODAL); + + expect(publishes()).toEqual([ + ['main', 0, A_FLAT], + ['surface', 0, MODAL, A_FLAT], + ['main', 0, []], + ]); + }); + + it('an owner that moves display and surface leaves the old display\'s surface', () => { + const component = {}; + registry.setOwnerRectangles(component, 0, [A], MODAL); + registry.setOwnerRectangles(component, 1, [A]); + + expect(publishes()).toEqual([ + ['surface', 0, MODAL, A_FLAT], + ['main', 1, A_FLAT], + // Display 1 never had this surface: its first publish crosses, empty. + ['surface', 1, MODAL, []], + ['surface', 0, MODAL, []], + ]); + }); + + it('clearing an owner republishes only its own surface', () => { + const component = {}; + registry.setOwnerRectangles('manual:0', 0, [A]); + registry.setOwnerRectangles(component, 0, [B], MODAL); + + registry.clearOwner(component); + + expect(publishes()).toEqual([ + ['main', 0, A_FLAT], + ['surface', 0, MODAL, B_FLAT], + ['surface', 0, MODAL, []], + ]); + }); + + it('retries a surface publish the bridge threw on', () => { + native.setSecureRectanglesOnSurface.mockImplementationOnce(() => { + throw new Error('bridge down'); + }); + const component = {}; + expect(() => registry.setOwnerRectangles(component, 0, [A], MODAL)).toThrow('bridge down'); + + registry.setOwnerRectangles(component, 0, [A], MODAL); + + expect(native.setSecureRectanglesOnSurface).toHaveBeenCalledTimes(2); + expect(native.setSecureRectanglesOnSurface).toHaveBeenLastCalledWith(0, MODAL, A_FLAT); + }); +}); diff --git a/packages/react-native/src/secure/__tests__/surface.test.ts b/packages/react-native/src/secure/__tests__/surface.test.ts new file mode 100644 index 00000000..6c77eef1 --- /dev/null +++ b/packages/react-native/src/secure/__tests__/surface.test.ts @@ -0,0 +1,145 @@ +/** + * Which React surface a view is measured in, read off the fiber tree. A + * wrong answer puts a secure rectangle on the wrong origin, so every test + * here is about the surface a real Modal tree must resolve to. + */ +import { FiberTag } from '../../viewtree/fiber'; +import type { FiberLike } from '../../viewtree/fiber'; +import type * as SurfaceModule from '../surface'; +import { + MAIN_SURFACE, + MODAL_HOST_TYPE, + UNKNOWN_MODAL_SURFACE, + nativeTagOfFiber, + surfaceOfFiber, + surfaceOfInstance, +} from '../surface'; + +const RENDERER_PROXY = 'react-native/Libraries/ReactNative/RendererProxy'; + +function fiber(tag: number, type: unknown, parent: FiberLike | null = null, nativeTag?: number): FiberLike { + return { + tag, + type, + child: null, + sibling: null, + return: parent, + memoizedProps: { nativeTag }, + memoizedState: null, + stateNode: null, + } as FiberLike; +} + +/** Reads the test's tag off `memoizedProps`, in place of the renderer. */ +const tagOf = (f: FiberLike): number | null => + (f.memoizedProps as { nativeTag?: number }).nativeTag ?? null; + +const root = () => fiber(FiberTag.HostRoot, null); +const modal = (parent: FiberLike, nativeTag?: number) => + fiber(FiberTag.HostComponent, MODAL_HOST_TYPE, parent, nativeTag); +const view = (parent: FiberLike) => fiber(FiberTag.HostComponent, 'RCTView', parent); + +describe('surfaceOfFiber', () => { + it('is the main surface outside every Modal', () => { + expect(surfaceOfFiber(view(view(root())), tagOf)).toBe(MAIN_SURFACE); + }); + + it("is the Modal host's tag inside a Modal, through composites and views", () => { + const composite = fiber(FiberTag.FunctionComponent, () => null, modal(view(root()), 56)); + expect(surfaceOfFiber(view(view(composite)), tagOf)).toBe(56); + }); + + it('is the nearest Modal for a Modal inside a Modal', () => { + const inner = modal(view(modal(root(), 56)), 78); + expect(surfaceOfFiber(view(inner), tagOf)).toBe(78); + }); + + // The Modal host itself is laid out in its parent's surface. + it('does not count the fiber itself', () => { + expect(surfaceOfFiber(modal(view(root()), 56), tagOf)).toBe(MAIN_SURFACE); + }); + + it('ignores a non-host fiber whose type happens to be the Modal host name', () => { + const lookalike = fiber(FiberTag.FunctionComponent, MODAL_HOST_TYPE, root(), 56); + expect(surfaceOfFiber(view(lookalike), tagOf)).toBe(MAIN_SURFACE); + }); + + it('is the unknown-Modal surface when the Modal host has no tag, never the main one', () => { + expect(surfaceOfFiber(view(modal(root())), tagOf)).toBe(UNKNOWN_MODAL_SURFACE); + expect(UNKNOWN_MODAL_SURFACE).not.toBe(MAIN_SURFACE); + }); + + it('stops on a cycle instead of spinning', () => { + const looped = view(root()); + looped.return = looped; + expect(surfaceOfFiber(view(looped), tagOf)).toBe(MAIN_SURFACE); + }); + + it('reads the tag through the renderer by default', () => { + jest.resetModules(); + jest.doMock(RENDERER_PROXY, () => ({ + getPublicInstanceFromInternalInstanceHandle: () => ({ __nativeTag: 91 }), + })); + const fresh = require('../surface') as typeof SurfaceModule; + expect(fresh.surfaceOfFiber(view(modal(root())))).toBe(91); + jest.dontMock(RENDERER_PROXY); + }); +}); + +describe('nativeTagOfFiber', () => { + afterEach(() => { + jest.dontMock(RENDERER_PROXY); + }); + + function withInstance(instance: unknown | (() => never)): typeof SurfaceModule { + jest.resetModules(); + jest.doMock(RENDERER_PROXY, () => ({ + getPublicInstanceFromInternalInstanceHandle: () => + typeof instance === 'function' ? (instance as () => never)() : instance, + })); + return require('../surface') as typeof SurfaceModule; + } + + it("reads the public instance's tag", () => { + expect(withInstance({ __nativeTag: 56 }).nativeTagOfFiber(root())).toBe(56); + }); + + it.each([ + ['no instance', null], + ['no tag', {}], + ['a NaN tag', { __nativeTag: Number.NaN }], + ['a string tag', { __nativeTag: '56' }], + ])('is null for %s', (_name, instance) => { + expect(withInstance(instance).nativeTagOfFiber(root())).toBeNull(); + }); + + it('is null when the renderer throws', () => { + expect( + withInstance(() => { + throw new Error('unmounted'); + }).nativeTagOfFiber(root()), + ).toBeNull(); + }); + + it('is exported for the walk', () => { + expect(typeof nativeTagOfFiber).toBe('function'); + }); +}); + +describe('surfaceOfInstance', () => { + it('is the main surface for an instance with no fiber', () => { + expect(surfaceOfInstance({})).toBe(MAIN_SURFACE); + expect(surfaceOfInstance({ __internalInstanceHandle: null })).toBe(MAIN_SURFACE); + expect(surfaceOfInstance({ __internalInstanceHandle: 7 })).toBe(MAIN_SURFACE); + }); + + it("is the surface of the instance's fiber", () => { + jest.resetModules(); + jest.doMock(RENDERER_PROXY, () => ({ + getPublicInstanceFromInternalInstanceHandle: () => ({ __nativeTag: 56 }), + })); + const fresh = require('../surface') as typeof SurfaceModule; + expect(fresh.surfaceOfInstance({ __internalInstanceHandle: view(modal(root())) })).toBe(56); + jest.dontMock(RENDERER_PROXY); + }); +}); diff --git a/packages/react-native/src/secure/registry.ts b/packages/react-native/src/secure/registry.ts index 55a5fc06..7c9baf8e 100644 --- a/packages/react-native/src/secure/registry.ts +++ b/packages/react-native/src/secure/registry.ts @@ -1,6 +1,7 @@ import NativeBugsee from '../NativeBugsee'; import { flattenSecureRectangles } from './rectangles'; import type { SecureRectangle } from './rectangles'; +import { MAIN_SURFACE } from './surface'; import { secureRectangleScale } from './unit'; /** @@ -13,8 +14,11 @@ import { secureRectangleScale } from './unit'; */ export type SecureOwner = object | `manual:${number}`; +export { MAIN_SURFACE }; + interface OwnerEntry { readonly display: number; + readonly surface: number; readonly rectangles: readonly SecureRectangle[]; } @@ -22,20 +26,24 @@ interface OwnerEntry { const owners = new Map(); /** - * What last reached the bridge, per display. A display missing here has never - * been published to by this JS runtime, so its first publish always crosses, - * even an empty one: the native store outlives a JS reload and may still hold - * a set this runtime knows nothing about. + * What last reached the bridge, per display and surface. A missing entry has + * never been published by this JS runtime, so its first publish always + * crosses, even an empty one: the native store outlives a JS reload and may + * still hold a set this runtime knows nothing about. */ -const published = new Map(); +const published = new Map(); + +function publishKey(display: number, surface: number): string { + return `${display}:${surface}`; +} function sameList(a: readonly number[], b: readonly number[]): boolean { return a.length === b.length && a.every((value, index) => value === b[index]); } /** - * Publishes the union of every owner's rectangles on `display`, if it differs - * from what was last published there. + * Publishes the union of every owner's rectangles on `display` for each + * surface that has (or had) owners there. * * Re-flattened every time rather than cached per owner: the pixel ratio is * read per publish (see `secureRectangleScale`), so a ratio change reaches the @@ -45,29 +53,37 @@ function sameList(a: readonly number[], b: readonly number[]): boolean { * already keep their version still for a no-op write. It saves a bridge hop * on every 100 ms re-measure that found nothing moved. */ -function publish(display: number): void { - const union: SecureRectangle[] = []; - for (const entry of owners.values()) { - if (entry.display === display) { - union.push(...entry.rectangles); +function publish(display: number, surfaces: ReadonlySet): void { + const scale = secureRectangleScale(); + for (const surface of surfaces) { + const union: SecureRectangle[] = []; + for (const entry of owners.values()) { + if (entry.display === display && entry.surface === surface) { + union.push(...entry.rectangles); + } } - } - const flat = flattenSecureRectangles(union, secureRectangleScale()); - const last = published.get(display); - if (last !== undefined && sameList(last, flat)) { - return; - } + const flat = flattenSecureRectangles(union, scale); + const key = publishKey(display, surface); + const last = published.get(key); + if (last !== undefined && sameList(last, flat)) { + continue; + } - NativeBugsee.setSecureRectangles(display, flat); - // Only after the call returned: a publish that threw was not published, and - // recording it would skip the retry as "unchanged". - published.set(display, flat); + if (surface === MAIN_SURFACE) { + NativeBugsee.setSecureRectangles(display, flat); + } else { + NativeBugsee.setSecureRectanglesOnSurface(display, surface, flat); + } + // Only after the call returned: a publish that threw was not published, and + // recording it would skip the retry as "unchanged". + published.set(key, flat); + } } /** - * Replaces `owner`'s rectangles with `rectangles` on `display` and publishes - * that display's union. + * Replaces `owner`'s rectangles with `rectangles` on `display`/`surface` and + * publishes that lane's union. * * Throws, changing nothing, if any rectangle is malformed. The owner's * previous rectangles stay published: a bad call never uncovers a region. @@ -76,6 +92,7 @@ export function setOwnerRectangles( owner: SecureOwner, display: number, rectangles: readonly SecureRectangle[], + surface: number = MAIN_SURFACE, ): void { // Validation first, before the entry changes. flattenSecureRectangles(rectangles, secureRectangleScale()); @@ -85,13 +102,18 @@ export function setOwnerRectangles( // must not change what a later publish for some other owner sends. owners.set(owner, { display, + surface, rectangles: rectangles.map(({ x, y, width, height }) => ({ x, y, width, height })), }); + const surfaces = new Set([surface]); + if (previous !== undefined) { + surfaces.add(previous.surface); + } // The new display is covered before the old one is uncovered. - publish(display); + publish(display, surfaces); if (previous !== undefined && previous.display !== display) { - publish(previous.display); + publish(previous.display, new Set([previous.surface])); } } @@ -102,5 +124,5 @@ export function clearOwner(owner: SecureOwner): void { return; } owners.delete(owner); - publish(previous.display); + publish(previous.display, new Set([previous.surface])); } diff --git a/packages/react-native/src/secure/surface.ts b/packages/react-native/src/secure/surface.ts new file mode 100644 index 00000000..704d2d61 --- /dev/null +++ b/packages/react-native/src/secure/surface.ts @@ -0,0 +1,83 @@ +import { FiberTag } from '../viewtree/fiber'; +import type { FiberLike } from '../viewtree/fiber'; + +/** + * Which React surface a view is measured in, read off the fiber tree with + * no native call. + * + * Fabric `measureInWindow` stops at the nearest `RootNodeKind` ancestor. A + * `` is one (`ModalHostViewShadowNode`), and its host fiber is the + * `RCTModalHostView` component, so a view inside a Modal is measured from + * that Modal's content and everything else from the app's own root. A + * surface is named by the Modal host's React tag (Android also gives the + * Modal's dialog root that id); the app's root is {@link MAIN_SURFACE}. + * Native translates each surface's rectangles by that surface's own origin. + */ + +/** The app's own React root; matches native `MAIN_SURFACE`. */ +export const MAIN_SURFACE = 0; + +/** + * A view known to be inside a `` whose host's tag cannot be read. + * Never the main surface: native serves a surface whose origin it cannot + * find as the whole display. + */ +export const UNKNOWN_MODAL_SURFACE = -1; + +/** The host component type of a `` (`RCTModalHostViewNativeComponent`). */ +export const MODAL_HOST_TYPE = 'RCTModalHostView'; + +/** A cycle in a malformed tree must not spin forever. */ +const MAX_ANCESTORS = 10_000; + +/** + * The native tag of a host fiber, via its public instance (Fabric's carry + * `__nativeTag`), or `null` when it has none. + */ +export function nativeTagOfFiber(fiber: FiberLike): number | null { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy, like the view tree's measure: only a real renderer has it. + const rendererProxy = require('react-native/Libraries/ReactNative/RendererProxy') as { + getPublicInstanceFromInternalInstanceHandle: (fiber: unknown) => unknown; + }; + const publicInstance = rendererProxy.getPublicInstanceFromInternalInstanceHandle(fiber); + const tag = (publicInstance as { __nativeTag?: unknown } | null)?.__nativeTag; + // Number.isFinite never coerces: a string or a missing tag is not finite. + return Number.isFinite(tag) ? (tag as number) : null; + } catch { + return null; + } +} + +/** + * The surface `fiber` is measured in: the tag of the nearest `` host + * above it, {@link UNKNOWN_MODAL_SURFACE} when that host has no tag, or + * {@link MAIN_SURFACE} when there is no Modal above it. `fiber` itself is + * not considered: a Modal host is measured in its parent's surface. + */ +export function surfaceOfFiber( + fiber: FiberLike, + tagOf: (fiber: FiberLike) => number | null = nativeTagOfFiber, +): number { + let current = fiber.return; + for (let steps = 0; current != null && steps < MAX_ANCESTORS; steps += 1) { + if (current.tag === FiberTag.HostComponent && current.type === MODAL_HOST_TYPE) { + return tagOf(current) ?? UNKNOWN_MODAL_SURFACE; + } + current = current.return; + } + return MAIN_SURFACE; +} + +/** + * The surface of a mounted host's public instance (Fabric's carries its + * fiber as `__internalInstanceHandle`). {@link MAIN_SURFACE} when the + * instance has no fiber, which only a non-Fabric renderer produces. + */ +export function surfaceOfInstance(instance: object): number { + const fiber = (instance as { __internalInstanceHandle?: unknown }).__internalInstanceHandle; + if (fiber === null || typeof fiber !== 'object') { + return MAIN_SURFACE; + } + return surfaceOfFiber(fiber as FiberLike); +} diff --git a/packages/react-native/src/viewtree/__tests__/requests.test.ts b/packages/react-native/src/viewtree/__tests__/requests.test.ts index 3be9200f..481203f8 100644 --- a/packages/react-native/src/viewtree/__tests__/requests.test.ts +++ b/packages/react-native/src/viewtree/__tests__/requests.test.ts @@ -121,6 +121,91 @@ describe('a vh request', () => { }); }); +// A is its own React surface: the walk asks native for the origin of +// a Modal by its host's native tag, once per Modal per walk. +describe('the walk env, per surface', () => { + const RENDERER_PROXY = 'react-native/Libraries/ReactNative/RendererProxy'; + + function envOf(): { + originForSurface: (surface: number) => { x: number; y: number } | null; + nativeTagOf: (fiber: FiberLike) => number | null; + } { + const { instance } = anchorUnderNewRoot(); + requests.registerAnchor(instance); + emit(); + return buildViewTree.mock.calls[0][1]; + } + + afterEach(() => { + jest.dontMock(RENDERER_PROXY); + }); + + it("resolves a tag's origin through secureSurfaceOrigin", () => { + native.secureSurfaceOrigin.mockReturnValue([40, 200.5]); + + expect(envOf().originForSurface(77)).toEqual({ x: 40, y: 200.5 }); + expect(native.secureSurfaceOrigin).toHaveBeenCalledWith(77); + }); + + it.each([ + ['empty', []], + ['one number', [40]], + ['a NaN x', [Number.NaN, 1]], + ['an infinite y', [1, Number.POSITIVE_INFINITY]], + ['not an array', { 0: 1, 1: 2, length: 2 }], + ['null', null], + ])('knows no origin when native answers %s', (_name, answer) => { + native.secureSurfaceOrigin.mockReturnValue(answer as never); + + expect(envOf().originForSurface(77)).toBeNull(); + }); + + it('knows no origin when native throws', () => { + native.secureSurfaceOrigin.mockImplementation(() => { + throw new Error('no root'); + }); + + expect(envOf().originForSurface(77)).toBeNull(); + }); + + it("reads a host fiber's tag off its public instance", () => { + const fiber = makeFiber(); + const seen: unknown[] = []; + jest.doMock(RENDERER_PROXY, () => ({ + getPublicInstanceFromInternalInstanceHandle: (handle: unknown) => { + seen.push(handle); + return { __nativeTag: 77 }; + }, + })); + + expect(envOf().nativeTagOf(fiber)).toBe(77); + expect(seen).toEqual([fiber]); + }); + + it.each([ + ['no public instance', null], + ['no tag', {}], + ['a NaN tag', { __nativeTag: Number.NaN }], + ['a string tag', { __nativeTag: '77' }], + ])('has no tag for %s', (_name, instance) => { + jest.doMock(RENDERER_PROXY, () => ({ + getPublicInstanceFromInternalInstanceHandle: () => instance, + })); + + expect(envOf().nativeTagOf(makeFiber())).toBeNull(); + }); + + it('has no tag when the renderer throws', () => { + jest.doMock(RENDERER_PROXY, () => ({ + getPublicInstanceFromInternalInstanceHandle: () => { + throw new Error('unmounted'); + }, + })); + + expect(envOf().nativeTagOf(makeFiber())).toBeNull(); + }); +}); + describe('a walk that finds nothing', () => { // `buildViewTree` returns `null` whenever no surface emits a node: nothing // measurable yet, everything under a hidden Offscreen, or every root's @@ -494,6 +579,7 @@ describe('the real walk env', () => { function mockMeasuring(): void { jest.doMock(RENDERER_PROXY_PATH, () => ({ getPublicInstanceFromInternalInstanceHandle: (fiber: FiberLike) => ({ + __nativeTag: (fiber.stateNode as { tag?: number } | null)?.tag, measureInWindow: (callback: (x: number, y: number, width: number, height: number) => void) => { const rect = (fiber.stateNode as { rect?: { x: number; y: number; width: number; height: number } } | null)?.rect; if (rect) { @@ -521,11 +607,17 @@ describe('the real walk env', () => { } /** Builds `HostRoot -> WrapMarker -> [appHost, BugseeSecure -> secureHost, anchor]` and registers a real anchor instance for it, returning the parsed 'vh' reply payload. */ - function runRealWalk(platform: 'ios' | 'android', origin: { x: number; y: number } = { x: 0, y: 0 }): Node { + function runRealWalk( + platform: 'ios' | 'android', + origin: { x: number; y: number } = { x: 0, y: 0 }, + arrange: () => void = () => undefined, + inModal = false, + ): Node { jest.unmock('../walk'); jest.resetModules(); ({ native } = require('../../__mocks__/native')); native.reset(); + arrange(); jest.doMock('react-native', () => ({ Platform: { OS: platform }, PixelRatio: { get: () => 3 }, @@ -544,6 +636,9 @@ describe('the real walk env', () => { memoizedProps: { testID: 'app-root' }, stateNode: { rect: { x: 1, y: 2, width: 10, height: 20 } }, }); + // Unmeasurable, like a real Modal host on Android: its children are + // promoted, carrying the Modal's origin. + const modalHost = makeFiber({ type: 'RCTModalHostView', stateNode: { tag: 56 }, child: appHost }); const secureHost = makeFiber({ type: 'SecretView', memoizedProps: { testID: 'nope' }, @@ -588,8 +683,14 @@ describe('the real walk env', () => { secureBoundary.sibling = secureBoundaryViaElementType; secureBoundaryViaElementType.sibling = anchor; - const wrapFiber = makeFiber({ tag: FiberTag.FunctionComponent, type: WrapMarker, child: appHost }); - appHost.return = wrapFiber; + const firstChild = inModal ? modalHost : appHost; + if (inModal) { + modalHost.sibling = appHost.sibling; + appHost.sibling = null; + appHost.return = modalHost; + } + const wrapFiber = makeFiber({ tag: FiberTag.FunctionComponent, type: WrapMarker, child: firstChild }); + firstChild.return = wrapFiber; secureBoundary.return = wrapFiber; secureBoundaryViaElementType.return = wrapFiber; anchor.return = wrapFiber; @@ -655,4 +756,26 @@ describe('the real walk env', () => { // it is already in display px, not a point value that itself needs 3x. expect(appNode?.bounds).toEqual([103, 206, 30, 60]); }); + + it("places a node inside a Modal by the Modal's origin, asking once", () => { + const tree = runRealWalk( + 'android', + { x: 100, y: 200 }, + () => { + native.secureSurfaceOrigin.mockImplementation((tag: number) => (tag === 56 ? [40, 60] : [])); + }, + true, + ); + const appNode = flatten(tree).find((n) => n.class_name === 'RootView'); + + // Scaled by 3 as above, then moved by (40, 60), not the request's (100, 200). + expect(appNode?.bounds).toEqual([43, 66, 30, 60]); + expect(native.secureSurfaceOrigin.mock.calls).toEqual([[56]]); + }); + + it('asks native nothing for a tree without a Modal', () => { + runRealWalk('android', { x: 100, y: 200 }); + + expect(native.secureSurfaceOrigin).not.toHaveBeenCalled(); + }); }); diff --git a/packages/react-native/src/viewtree/__tests__/walk.test.ts b/packages/react-native/src/viewtree/__tests__/walk.test.ts index 509c004b..06c5cdf9 100644 --- a/packages/react-native/src/viewtree/__tests__/walk.test.ts +++ b/packages/react-native/src/viewtree/__tests__/walk.test.ts @@ -779,6 +779,139 @@ describe('buildViewTree', () => { expect(node?.bounds).toEqual([11.13, 22.67, 5.56, 6.45]); }); + // A is its own React surface: measureInWindow inside it is relative + // to the Modal's content, so its subtree takes that surface's origin. The + // origin is asked once per Modal per walk, never per node. + describe('a subtree inside a ', () => { + const MODAL_RECT = { x: 0, y: 0, width: 360, height: 800 }; + const tagged = (fiber: FiberLike): number | null => + (fiber.memoizedProps as { tag?: number } | null)?.tag ?? null; + const modal = (tag: number | undefined, children: FiberSpec[], rect: WindowRect | null = MODAL_RECT): FiberSpec => + host('RCTModalHostView', rect, children, tag === undefined ? {} : { tag }); + const box = (name: string, y = 20): FiberSpec => host(name, { x: 10, y, width: 5, height: 5 }); + + function nodes(tree: ManagedNode | null): Map { + return new Map(flatten(tree as ManagedNode).map((n) => [n.class_name, n])); + } + + it('Android: places the subtree by the Modal surface origin, the host by the enclosing one', () => { + const originForSurface = jest.fn((tag: number) => (tag === 56 ? { x: 40, y: 200 } : null)); + const tree = buildViewTree( + [fiberRoot(fragment([box('Main'), modal(56, [box('Sheet'), box('Sheet2', 30)])]))], + makeEnv({ platform: 'android', scale: 2, originX: 0, originY: 63, nativeTagOf: tagged, originForSurface }), + ); + + const byName = nodes(tree); + expect(byName.get('Main')?.bounds).toEqual([20, 103, 10, 10]); + expect(byName.get('RCTModalHostView')?.bounds).toEqual([0, 63, 720, 1600]); + expect(byName.get('Sheet')?.bounds).toEqual([60, 240, 10, 10]); + expect(byName.get('Sheet2')?.bounds).toEqual([60, 260, 10, 10]); + expect(originForSurface).toHaveBeenCalledTimes(1); + expect(originForSurface).toHaveBeenCalledWith(56); + }); + + it('iOS: places the subtree by the Modal surface origin', () => { + const tree = buildViewTree( + [fiberRoot(modal(56, [box('Sheet')]))], + makeEnv({ platform: 'ios', originX: 1, originY: 2, nativeTagOf: tagged, originForSurface: () => ({ x: 3.5, y: 4 }) }), + ); + + expect(nodes(tree).get('Sheet')?.bounds).toEqual([13.5, 24, 5, 5]); + }); + + it('asks native nothing for a tree without a Modal', () => { + const originForSurface = jest.fn(() => ({ x: 40, y: 200 })); + buildViewTree( + [fiberRoot(fragment([box('A'), host('B', RECT, [box('C')], { tag: 9 })]))], + makeEnv({ platform: 'android', scale: 1, nativeTagOf: tagged, originForSurface }), + ); + + expect(originForSurface).not.toHaveBeenCalled(); + }); + + it('asks once per Modal, and again for a different Modal', () => { + const originForSurface = jest.fn((tag: number) => ({ x: tag, y: 0 })); + const tree = buildViewTree( + [fiberRoot(fragment([modal(56, [box('First')]), modal(78, [box('Second')])]))], + makeEnv({ platform: 'android', scale: 1, nativeTagOf: tagged, originForSurface }), + ); + + expect(originForSurface.mock.calls).toEqual([[56], [78]]); + expect(nodes(tree).get('First')?.bounds).toEqual([66, 20, 5, 5]); + expect(nodes(tree).get('Second')?.bounds).toEqual([88, 20, 5, 5]); + }); + + it('asks once for one Modal reached from two roots', () => { + const originForSurface = jest.fn(() => ({ x: 40, y: 200 })); + buildViewTree( + [fiberRoot(modal(56, [box('A')])), fiberRoot(modal(56, [box('B')]))], + makeEnv({ platform: 'android', scale: 1, nativeTagOf: tagged, originForSurface }), + ); + + expect(originForSurface).toHaveBeenCalledTimes(1); + }); + + it('a Modal inside a Modal takes the inner origin', () => { + const tree = buildViewTree( + [fiberRoot(modal(56, [box('Outer'), modal(78, [box('Inner')])]))], + makeEnv({ + platform: 'android', + scale: 1, + nativeTagOf: tagged, + originForSurface: (tag) => (tag === 56 ? { x: 100, y: 0 } : { x: 0, y: 300 }), + }), + ); + + expect(nodes(tree).get('Outer')?.bounds).toEqual([110, 20, 5, 5]); + expect(nodes(tree).get('Inner')?.bounds).toEqual([10, 320, 5, 5]); + }); + + it('an unmeasurable Modal host still gives its children its origin', () => { + const tree = buildViewTree( + [fiberRoot(modal(56, [box('Sheet')], null))], + makeEnv({ platform: 'android', scale: 1, nativeTagOf: tagged, originForSurface: () => ({ x: 40, y: 200 }) }), + ); + + expect(nodes(tree).get('Sheet')?.bounds).toEqual([50, 220, 5, 5]); + }); + + it('keeps the enclosing origin when native knows no origin for the Modal', () => { + const tree = buildViewTree( + [fiberRoot(modal(56, [box('Sheet')]))], + makeEnv({ platform: 'android', scale: 1, originX: 7, originY: 9, nativeTagOf: tagged, originForSurface: () => null }), + ); + + expect(nodes(tree).get('Sheet')?.bounds).toEqual([17, 29, 5, 5]); + }); + + it('keeps the enclosing origin for a Modal host with no tag', () => { + const originForSurface = jest.fn(() => ({ x: 40, y: 200 })); + const tree = buildViewTree( + [fiberRoot(modal(undefined, [box('Sheet')]))], + makeEnv({ platform: 'android', scale: 1, originX: 7, originY: 9, nativeTagOf: tagged, originForSurface }), + ); + + expect(nodes(tree).get('Sheet')?.bounds).toEqual([17, 29, 5, 5]); + expect(originForSurface).not.toHaveBeenCalled(); + }); + + it('keeps the enclosing origin when the env cannot read tags or origins', () => { + const originForSurface = jest.fn(() => ({ x: 40, y: 200 })); + const noTags = buildViewTree( + [fiberRoot(modal(56, [box('Sheet')]))], + makeEnv({ platform: 'android', scale: 1, originX: 7, originY: 9, originForSurface }), + ); + const noOrigins = buildViewTree( + [fiberRoot(modal(56, [box('Sheet')]))], + makeEnv({ platform: 'android', scale: 1, originX: 7, originY: 9, nativeTagOf: tagged }), + ); + + expect(nodes(noTags).get('Sheet')?.bounds).toEqual([17, 29, 5, 5]); + expect(nodes(noOrigins).get('Sheet')?.bounds).toEqual([17, 29, 5, 5]); + expect(originForSurface).not.toHaveBeenCalled(); + }); + }); + it('stops at exactly VH_MAX_NODES nodes emitted in total — root and surface reserved up front, not overshot', () => { const children = Array.from({ length: VH_MAX_NODES + 5 }, (_, i) => host(`Child${i}`, RECT)); const tree = buildViewTree([fiberRoot(fragment(children))], makeEnv()); diff --git a/packages/react-native/src/viewtree/requests.ts b/packages/react-native/src/viewtree/requests.ts index f745053d..902b84a1 100644 --- a/packages/react-native/src/viewtree/requests.ts +++ b/packages/react-native/src/viewtree/requests.ts @@ -16,6 +16,7 @@ import { Platform } from 'react-native'; import NativeBugsee from '../NativeBugsee'; import { RootErrorReporter } from '../exceptions/RootErrorReporter'; import { BugseeSecure } from '../secure/BugseeSecure'; +import { nativeTagOfFiber } from '../secure/surface'; import { secureRectangleScale } from '../secure/unit'; import { VH_ANCHOR_NATIVE_ID } from './constants'; import { errorName } from '../errorName'; @@ -202,6 +203,19 @@ function buildEnv(event: DataRequestEvent): WalkEnv { scale: secureRectangleScale(), originX: event.originX, originY: event.originY, + nativeTagOf: nativeTagOfFiber, + originForSurface: (surface) => { + try { + const origin: unknown = NativeBugsee.secureSurfaceOrigin(surface); + // Number.isFinite never coerces, so a missing element fails it too. + if (Array.isArray(origin) && Number.isFinite(origin[0]) && Number.isFinite(origin[1])) { + return { x: origin[0] as number, y: origin[1] as number }; + } + } catch { + // Keep the enclosing origin for that Modal's subtree. + } + return null; + }, now: monotonicNow, }; } diff --git a/packages/react-native/src/viewtree/walk.ts b/packages/react-native/src/viewtree/walk.ts index 76f28ff2..f04741a8 100644 --- a/packages/react-native/src/viewtree/walk.ts +++ b/packages/react-native/src/viewtree/walk.ts @@ -20,6 +20,7 @@ * away, and `.child`/`.sibling`/`.return` being `undefined` or some other * non-object value is tolerated the same as `null` throughout. */ +import { MODAL_HOST_TYPE } from '../secure/surface'; import { FiberTag } from './fiber'; import type { FiberLike, WindowRect } from './fiber'; @@ -66,12 +67,28 @@ export interface WalkEnv { scale: number; originX: number; originY: number; + /** + * Display origin of the `` whose host has React tag `surface`, when + * known. `measureInWindow` inside a Modal is relative to the Modal's own + * content, so its subtree must not take the request's origin. Asked at + * most once per Modal per walk; a `null` answer keeps the enclosing + * origin for that subtree. + */ + originForSurface?: (surface: number) => Origin | null; + /** Native tag of a host fiber's public instance, when measurable. */ + nativeTagOf?: (fiber: FiberLike) => number | null; /** ms; the budget is measured from this function's first call. */ now(): number; } type Bounds = [number, number, number, number]; +/** Where `measureInWindow`'s (0, 0) sits, in the units `bounds` are in. */ +export interface Origin { + x: number; + y: number; +} + interface Ctx { env: WalkEnv; startedAt: number; @@ -83,6 +100,8 @@ interface Ctx { anyTruncated: boolean; /** Every fiber visited so far, across every root — a repeat means a cycle, not a legitimate second visit (a well-formed tree never revisits a fiber). */ visited: Set; + /** Each `` surface's origin for this walk, asked of `originForSurface` once. */ + surfaceOrigins: Map; } /** The children collected for one fiber (or one fiber root), plus whether that list is known to be incomplete. */ @@ -92,7 +111,7 @@ interface Frame { } type Task = - | { kind: 'visit'; fiber: FiberLike; secure: boolean; depthRemaining: number; frame: Frame } + | { kind: 'visit'; fiber: FiberLike; secure: boolean; depthRemaining: number; frame: Frame; origin: Origin } | { kind: 'finishHost'; frame: Frame; @@ -121,26 +140,58 @@ function round2(value: number): number { return Math.round(value * 100) / 100; } -function androidBounds(rect: WindowRect, env: WalkEnv): Bounds { +function androidBounds(rect: WindowRect, env: WalkEnv, originX: number, originY: number): Bounds { return [ - Math.round(rect.x * env.scale) + env.originX, - Math.round(rect.y * env.scale) + env.originY, + Math.round(rect.x * env.scale) + originX, + Math.round(rect.y * env.scale) + originY, Math.round(rect.width * env.scale), Math.round(rect.height * env.scale), ]; } -function iosBounds(rect: WindowRect, env: WalkEnv): Bounds { +function iosBounds(rect: WindowRect, env: WalkEnv, originX: number, originY: number): Bounds { return [ - round2(rect.x + env.originX), - round2(rect.y + env.originY), + round2(rect.x + originX), + round2(rect.y + originY), round2(rect.width), round2(rect.height), ]; } -function toBounds(rect: WindowRect, env: WalkEnv): Bounds { - return env.platform === 'android' ? androidBounds(rect, env) : iosBounds(rect, env); +/** + * The origin `fiber`'s children are measured from: a `` host starts a + * surface of its own (its subtree is relative to the Modal's content), every + * other fiber passes `origin` down unchanged. The Modal host itself is + * measured in its parent's surface, so its own bounds keep `origin`. + */ +function childOriginOf(fiber: FiberLike, origin: Origin, ctx: Ctx): Origin { + if (!isModalHost(fiber)) { + return origin; + } + const { env } = ctx; + const tag = env.nativeTagOf?.(fiber) ?? null; + if (tag === null || env.originForSurface === undefined) { + return origin; + } + if (!ctx.surfaceOrigins.has(tag)) { + ctx.surfaceOrigins.set(tag, env.originForSurface(tag)); + } + return ctx.surfaceOrigins.get(tag) ?? origin; +} + +/** Whether `fiber` is a `` host. A throwing `.type` getter is not one (`safeHostClassName` names it). */ +function isModalHost(fiber: FiberLike): boolean { + try { + return fiber.type === MODAL_HOST_TYPE; + } catch { + return false; + } +} + +function toBounds(rect: WindowRect, env: WalkEnv, origin: Origin): Bounds { + return env.platform === 'android' + ? androidBounds(rect, env, origin.x, origin.y) + : iosBounds(rect, env, origin.x, origin.y); } function unionBounds(boxes: readonly Bounds[], env: WalkEnv): Bounds { @@ -469,14 +520,21 @@ function makeNode( } /** Pushes the work to flatten `fiber`'s children directly into `parentFrame` — used for every "this fiber emits nothing of its own" case (transparent tags, the wrap component/anchor, a visible Offscreen/LegacyHidden, and an unmeasurable host promoting its children per the plan's "no measurable host" rule). */ -function pushFlatten(stack: Task[], fiber: FiberLike, secure: boolean, depthRemaining: number, parentFrame: Frame): void { +function pushFlatten( + stack: Task[], + fiber: FiberLike, + secure: boolean, + depthRemaining: number, + parentFrame: Frame, + origin: Origin, +): void { const child = linkOf(fiber.child); if (child === null) { return; } const frame: Frame = { nodes: [], truncated: false }; stack.push({ kind: 'finishFlatten', frame, parentFrame }); - stack.push({ kind: 'visit', fiber: child, secure, depthRemaining, frame }); + stack.push({ kind: 'visit', fiber: child, secure, depthRemaining, frame, origin }); } /** @@ -487,7 +545,10 @@ function pushFlatten(stack: Task[], fiber: FiberLike, secure: boolean, depthRema * JS call stack or loop forever. */ function runWalk(rootFiber: FiberLike, ctx: Ctx, outerFrame: Frame): void { - const stack: Task[] = [{ kind: 'visit', fiber: rootFiber, secure: false, depthRemaining: VH_MAX_DEPTH, frame: outerFrame }]; + const rootOrigin: Origin = { x: ctx.env.originX, y: ctx.env.originY }; + const stack: Task[] = [ + { kind: 'visit', fiber: rootFiber, secure: false, depthRemaining: VH_MAX_DEPTH, frame: outerFrame, origin: rootOrigin }, + ]; while (stack.length > 0) { const task = stack.pop() as Task; @@ -539,7 +600,7 @@ function runWalk(rootFiber: FiberLike, ctx: Ctx, outerFrame: Frame): void { if ((task as { kind: unknown }).kind !== 'visit') { throw new Error('unreachable view-tree task kind'); } - const { fiber, secure, depthRemaining, frame } = task; + const { fiber, secure, depthRemaining, frame, origin } = task; // N5 (fix round 2): everything from here on reads fields off `fiber` // itself — `.tag`, `.sibling`, `.child`, `.type`, `.elementType`, @@ -585,7 +646,7 @@ function runWalk(rootFiber: FiberLike, ctx: Ctx, outerFrame: Frame): void { // outcome for anything this walk produces. const sibling = linkOf(fiber.sibling); if (sibling !== null && !ctx.stopped) { - stack.push({ kind: 'visit', fiber: sibling, secure, depthRemaining, frame }); + stack.push({ kind: 'visit', fiber: sibling, secure, depthRemaining, frame, origin }); } ctx.fiberVisits += 1; @@ -601,7 +662,7 @@ function runWalk(rootFiber: FiberLike, ctx: Ctx, outerFrame: Frame): void { const secureHere = secure || safeIsSecureBoundary(ctx.env, fiber); if (safeIsWrapper(ctx.env, fiber)) { - pushFlatten(stack, fiber, secureHere, depthRemaining, frame); + pushFlatten(stack, fiber, secureHere, depthRemaining, frame, origin); continue; } @@ -609,24 +670,25 @@ function runWalk(rootFiber: FiberLike, ctx: Ctx, outerFrame: Frame): void { if (isHidden(fiber)) { continue; } - pushFlatten(stack, fiber, secureHere, depthRemaining, frame); + pushFlatten(stack, fiber, secureHere, depthRemaining, frame, origin); continue; } const kind = classify(fiber.tag); if (kind === 'transparent') { - pushFlatten(stack, fiber, secureHere, depthRemaining, frame); + pushFlatten(stack, fiber, secureHere, depthRemaining, frame, origin); continue; } if (kind === 'host') { + const childOrigin = childOriginOf(fiber, origin, ctx); const rect = safeMeasure(ctx.env, fiber); if (rect === null) { // No measurable host here — promote whatever children it has // (as if this fiber were transparent) rather than dropping a // subtree that might still have something to show underneath. - pushFlatten(stack, fiber, secureHere, depthRemaining, frame); + pushFlatten(stack, fiber, secureHere, depthRemaining, frame, childOrigin); continue; } @@ -635,14 +697,21 @@ function runWalk(rootFiber: FiberLike, ctx: Ctx, outerFrame: Frame): void { const child = linkOf(fiber.child); const atDepthLimit = child !== null && depthRemaining <= 1; const depthCut = atDepthLimit && hasVisibleContentChild(child as FiberLike); - const bounds = toBounds(rect, ctx.env); + const bounds = toBounds(rect, ctx.env, origin); const tagOpts = secureHere ? {} : tagOptions(fiber); const className = safeHostClassName(fiber); if (child !== null && !atDepthLimit) { const childFrame: Frame = { nodes: [], truncated: false }; stack.push({ kind: 'finishHost', frame: childFrame, parentFrame: frame, className, bounds, secureHere, tagOpts, depthCut: false }); - stack.push({ kind: 'visit', fiber: child, secure: secureHere, depthRemaining: depthRemaining - 1, frame: childFrame }); + stack.push({ + kind: 'visit', + fiber: child, + secure: secureHere, + depthRemaining: depthRemaining - 1, + frame: childFrame, + origin: childOrigin, + }); } else { stack.push({ kind: 'finishHost', @@ -669,7 +738,7 @@ function runWalk(rootFiber: FiberLike, ctx: Ctx, outerFrame: Frame): void { if (child !== null && !atDepthLimit) { const childFrame: Frame = { nodes: [], truncated: false }; stack.push({ kind: 'finishComposite', frame: childFrame, parentFrame: frame, className, secureHere }); - stack.push({ kind: 'visit', fiber: child, secure: secureHere, depthRemaining: depthRemaining - 1, frame: childFrame }); + stack.push({ kind: 'visit', fiber: child, secure: secureHere, depthRemaining: depthRemaining - 1, frame: childFrame, origin }); } else { stack.push({ kind: 'finishComposite', @@ -744,6 +813,7 @@ export function buildViewTree(roots: readonly { current: FiberLike }[], env: Wal stopped: false, anyTruncated: false, visited: new Set(), + surfaceOrigins: new Map(), }; const surfaces: ManagedNode[] = [];