diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4539aa1..056993e 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -23,8 +23,8 @@ progress; `PLAN.md` is the source of intent. The authoritative wrapper spec is - Lifecycle events: native strips the `com.bugsee.lifecycle.` prefix once, forwards unknown names unchanged, hops to the platform thread before touching Flutter, fans out to every attached engine, buffers (cap 64, drops oldest, drop log rate-limited) only while none is attached, and replays the latest status event to each new receiver. Events in flight to a detached receiver go back to the FRONT of the buffer. Status derives from Launching/Launched/Stopping/Stopped only; never take over the app's delegate/listener. The report-id event set, prefix and cap are repeated in Dart/Java/ObjC and compared by `test/lifecycle_drift_test.dart`. - The wrapper object is replaced when Dart refines the identity, so state that must survive (secure rectangles, lifecycle hub) lives outside it (`SecureRectangleStore`, `LifecycleHub`, ObjC equivalents). Do not move it into the wrapper. - iOS Objective-C wrapper tests: `ios/wrapper_tests` (symlinked sources), `xcodebuild test`. Android: JUnit under `android/src/test`. -- Secure-rect units differ per platform (Android pixels, iOS points); convert explicitly. -- Never treat lifecycle events as proof that a report exists or was sent. +- Secure rectangles (workbook Part 5; Dart `lib/src/secure`, `SecureRectangleStore` / `BGSFlutterSecureRectangleStore`): Dart publishes Flutter LOGICAL pixels per view WITH the device pixel ratio it measured them at, whole state, ordered by `(epoch, sequence)` where the epoch is a RANDOM id (never a clock): a new epoch is a new isolate and wins, a retired one is a dead isolate's straggler and is ignored. The SDKs' units differ and are read from SDK source: Android 7.4.2 takes PHYSICAL PIXELS in `View.getLocationOnScreen` space (factor = the message's ratio, which must equal the density of the Flutter view's `Resources` read at every pull), iOS 7.0.0-beta7 takes POINTS in the screen's coordinate space (factor = ratio / `UIScreen.scale` = 1; NEVER multiply by `UIScreen.scale`). A ratio that does not match the display now (density changed natively before Dart laid out again) covers the whole display until Dart republishes. Scale (including the view's own on-screen scale from its and its ancestors' transforms), then add the view's screen position, THEN floor l/t and ceil r/b. Versions are derived from what is served (per display, never 0, no-op never moves them); a display that never had a rectangle serves `[0, 0]`. Anything unplaceable, non-finite, mis-scaled, rotated/skewed or without an activity/view controller serves the WHOLE display (fail closed); only a view known to be off-screen (iOS: in no window) serves nothing. A grace window (`GRACE_MS` = 0.12 s, in both stores, compared by the drift guard) serves each past set for that long AFTER IT stopped being current (not from the last change, so a long movement does not widen the cover to its path), so a moving region leaves no strip; the union shrinking back is itself a version change. Dart reports regions unclipped, including what their descendants paint outside the box, and drops one ONLY on positive evidence that it is not on screen (an ancestor that does not paint it, an IndexedStack showing another child, a clipping viewport it and what it paints lie wholly outside). A disabled TickerMode is NOT evidence (it only stops animations). A detached layer is NOT such evidence (SnapshotWidget, the Zoom page transition). Android: a pull asks the main thread to re-read the placements (throttled to 100 ms) and answers from the last completed read, so a view that moved with no relayout is served at its old place for up to about one pull; a refresh asked for and not completed for `REFRESH_STALE_MS` (1 s: a stuck main thread) covers every placed view whole until one completes (tracked from the OLDEST unanswered request, so throttle suppression cannot hide it, and independent of the pull rate; marking every pull as pending would blank the display on every pull). iOS refreshes synchronously on the main thread, where the SDK pulls today. A failed publish is retried by a timer with deterministic backoff (1 s doubling to a 30 s cap) and NEVER abandoned (native keeps the last accepted state, so giving up leaves a stale mask and a new region in the clear); frames never resend the state that just failed, and once `maxRetries` consecutive failures passed a CHANGED state (the only kind that can add a region) goes out once `retryDelay` has passed since the last attempt, never per frame (no flood; a missing plugin costs one message per 30 s while nothing moves, at most one per second while regions move). The registry's clock is injectable for tests. Every one of these rules has a test that fails when it is broken; keep it that way. +- Secure rectangle limits (known, documented, not bugs): a region that has just APPEARED is recorded in the clear for its first frame or so (it has no previous place to union with, and Dart cannot publish before layout has placed it; publishing from the build phase would only publish a guess); a rotated region is its bounding box; a region partly clipped is reported whole, so it can cover a few neighbouring pixels the clip hid; pixels drawn outside the region's subtree (an Overlay magnifier or tooltip) are not known to Flutter and not covered; FP noise can add one pixel to an edge; only the implicit view (id 0) is placed on mobile; a hot-restarted isolate that has no regions publishes nothing, so the old isolate's rectangles stay served until the engine detaches (debug only, over-covers); a region with more than 2000 descendants is not dropped by the viewport test (over-covers); iOS is verified in portrait only. ## Checks diff --git a/.github/CI.md b/.github/CI.md index 2549ff6..9f3d5b7 100644 --- a/.github/CI.md +++ b/.github/CI.md @@ -19,7 +19,7 @@ and checkout with `persist-credentials: false`. |---|---|---|---| | `format` | `format` | ubuntu | `dart format --set-exit-if-changed` over `lib test example/lib scripts pigeons`, run on the floor Flutter because formatter output changes between Dart releases | | `bridge-codegen` | `bridge codegen is current` | ubuntu | `dart scripts/generate_pigeon.dart --check`: regenerates the Dart/Java/Swift bridge from `pigeons/bridge.dart` with the pinned pigeon and fails if a committed generated file differs (the tree is left untouched). Runs on the floor Flutter because the generated Dart is `dart format`ted and formatter output varies by release. It also runs `dart analyze` in `pigeons/` (excluded from the root analysis, which has no `pub get` for it). Fix: run `dart scripts/generate_pigeon.dart` and commit | -| `test` | `analyze + test + ios build (floor)`, `analyze + test + ios build (stable)` | macOS | `flutter analyze`, `flutter test` (includes the drift guards), debug iOS build with SwiftPM, and (stable leg only) the XCTests of the Objective-C wrapper target via `xcodebuild test` on a simulator in `ios/wrapper_tests`. macOS is required: SwiftPM and the simulator only exist there | +| `test` | `analyze + test + ios build (floor)`, `analyze + test + ios build (stable)` | macOS | `flutter analyze`, `flutter test` (includes the drift guards, among them `test/secure_rects_drift_test.dart` for the constants the secure-rectangle store repeats in Java and Objective-C), debug iOS build with SwiftPM, and (stable leg only) the XCTests of the Objective-C wrapper target via `xcodebuild test` on a simulator in `ios/wrapper_tests`. macOS is required: SwiftPM and the simulator only exist there | | `build-android` | `build apk (floor)`, `build apk (stable)` | ubuntu | Debug APK of the example app, then the plugin's JUnit tests (`:bugsee_flutter:testDebugUnitTest`). Frees runner disk first (otherwise "No space left on device") | | `publish-check` | `pub publish dry-run` | ubuntu | Runs on the floor Flutter. Package validity only. Fails on warnings (e.g. CHANGELOG missing the pubspec version, dirty git tree). Never uploads, needs no credentials | | `ci-ok` | `ci-ok` | ubuntu | `if: always()`; fails unless every job above succeeded (failed, cancelled and skipped needed jobs all fail it). Deliberately not `!cancelled()`: that would skip `ci-ok` on a cancelled run, and a skipped required check counts as passing | @@ -63,7 +63,7 @@ The plugin's own Swift package cannot host a test target (its Swift target impor `Flutter`, which only the Flutter tool supplies). `ios/wrapper_tests` is a test-only package whose `Sources/BugseeFlutterWrapper` is a symlink to the shipped Objective-C target, so XCTest runs against exactly the shipped code. Locally: -`cd ios/wrapper_tests && xcodebuild test -scheme bugsee_flutter_wrapper_tests -destination 'platform=iOS Simulator,name=iPhone 17'`. +`cd ios/wrapper_tests && xcodebuild test -collect-test-diagnostics never -scheme bugsee_flutter_wrapper_tests -destination 'platform=iOS Simulator,name=iPhone 17'` (without `-collect-test-diagnostics never` a failing run can sit for 10 minutes collecting diagnostics). The secure-rectangle tests need a simulator whose screen scale is not 1 (every current iPhone): two of them assert that and fail rather than pass vacuously on a 1x screen (workbook 5.3.3). The tracker tests create real `UIWindow`/`UIView`s. ## JDK diff --git a/PLAN.md b/PLAN.md index 97d9b83..3226aa9 100644 --- a/PLAN.md +++ b/PLAN.md @@ -48,11 +48,24 @@ Sources: `specs/sdk/wrapper-workbook` (authoritative order of work), `wrapper-da Launching/Launched/Stopping/Stopped, never treat an event as proof of a report (4.7). - Lifecycle delivery limit (3a): guarantees end at the hop into the channel sink. A message sent while the Dart side has no handler is held only by Flutter's channel buffer, which by default keeps ONE message per channel (a restarted isolate may see that one after it sets a handler; any older ones are dropped, and the engine reports an error that native only logs). The hub replays the latest status to each new receiver, flushes report events still in its own buffer and, when the final receiver detaches, puts its in-flight events back in front of it; only an event already SENT over the channel while Dart has no handler is not retried. Phase 4/6 consumers must not rely on delivery surviving a restart. - `requestData`: always answer; unknown type → `null`; never throw. -- Secure rectangles: pull model, `[version,count,l,t,r,b…]` int32 LE; outward rounding; - version moves on any change and never on a no-op, per display; **scale to platform unit - (iOS points, Android px) before rounding**; immutable snapshots. Dart side: `Bugsee.secure` - widget/`RenderObject` publishing global rects (including clipped/scrolling cases). - Tests: mutation (hold version constant), non-1 scale on iOS. +- Secure rectangles (3b, done): pull model, `[version,count,l,t,r,b…]` int32 LE; outward rounding; + version moves on any change and never on a no-op, per display; immutable snapshots. + What the pinned SDKs take today (from source, not the workbook's in-flight note): **Android 7.4.2: + physical pixels in the `View.getLocationOnScreen` space, display = `Display.getDisplayId()`; + iOS 7.0.0-beta7: points in the screen's coordinate space, display 0 only.** Dart publishes Flutter + logical pixels per view; native converts (Android: density of the Flutter view's `Resources`, read at every + pull and matched against the ratio Dart measured at; iOS: ratio / UIScreen.scale = 1, never the screen scale), applies the view's on-screen scale, adds its on-screen position, then floors l/t and + ceils r/b; a 0.12 s grace window serves each past set for 0.12 s after it stopped being current, together with + the current one. Versions are derived from what is served (so a density + or position change moves them with no write), per display, never 0; a display that never had a rectangle + serves `[0, 0]` (unknown). Fail closed: an unplaceable view, a non-finite rectangle or a bad scale serves + the whole display; a view known to be off-screen serves nothing. Dart (`lib/src/secure`, internal until + Phase 4): `BugseeSecureRegion`/`RenderSecureRegion` + `SecureRegionRegistry`, measured every frame in a + post-frame callback, published when the set changed (and again, with a capped backoff that never gives up, after a failed publish), ordered by `(epoch, sequence)`. Regions are + reported UNCLIPPED through every ancestor transform (bounding box, including paint outside their box); a region is dropped only on positive evidence + (an ancestor that does not paint it, an IndexedStack showing another child, or the region and all its paint lying wholly outside a clipping viewport); neither a disabled TickerMode nor a detached layer counts as such evidence. An unmeasurable region covers the whole view. + First-frame exposure of a newly appearing region remains. + Known limits: see "Secure rectangle limits" in `.claude/CLAUDE.md`. - Report handlers: always call completion; `isTerminating=true` → synchronous native-only work, no round-trip to Dart. diff --git a/android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java b/android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java index 6455940..965e449 100644 --- a/android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java +++ b/android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java @@ -1,13 +1,21 @@ package com.bugsee.flutter; +import android.os.Handler; +import android.os.Looper; import android.util.Log; import androidx.annotation.NonNull; import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.embedding.engine.plugins.activity.ActivityAware; +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; import io.flutter.plugin.common.BinaryMessenger; +import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicLong; /** * The Dart <-> native bridge. Registration of the wrapper is not done here: it @@ -16,22 +24,103 @@ * *

Internal: no public API yet (Phase 4). */ -public class BugseeFlutterPlugin implements FlutterPlugin, Messages.BugseeBridgeHostApi { +public class BugseeFlutterPlugin + implements FlutterPlugin, ActivityAware, Messages.BugseeBridgeHostApi { private static final String TAG = FlutterWrapperInitProvider.TAG; + private static final AtomicLong NEXT_OWNER = new AtomicLong(1); + private static final Handler MAIN = new Handler(Looper.getMainLooper()); + + /** Every attached engine's placement tracker, for the SDK pull's refresh. */ + private static final List PLACEMENTS = new CopyOnWriteArrayList<>(); + private static final PullThrottle PULLS = new PullThrottle( + () -> System.nanoTime() / 1_000_000L, + task -> { + // Only a request that is really posted counts as one in flight (see the store). + SecureRectangleStore.shared().refreshRequested(); + MAIN.post(task); + }, + BugseeFlutterPlugin::refreshPlacements); + + private static SecureEnvironment sharedEnvironment; + + /** One per process: every engine's placement records its view's resources in it. */ + private static synchronized SecureEnvironment environment( + @NonNull final android.content.Context context) { + if (sharedEnvironment == null) { + sharedEnvironment = new SecureEnvironment(context); + } + return sharedEnvironment; + } + + private static void refreshPlacements() { + try { + for (final SecurePlacement placement : PLACEMENTS) { + placement.refresh(); + } + } finally { + SecureRectangleStore.shared().refreshCompleted(); + } + } + + /** Identifies this engine's Dart isolate in {@link SecureRectangleStore}. */ + private final long owner = NEXT_OWNER.getAndIncrement(); private BinaryMessenger messenger; private LifecycleHub.Sink sink; + private SecurePlacement placement; @Override public void onAttachedToEngine(@NonNull final FlutterPluginBinding binding) { messenger = binding.getBinaryMessenger(); Messages.BugseeBridgeHostApi.setUp(messenger, this); + + final SecureRectangleStore store = SecureRectangleStore.shared(); + final SecureEnvironment environment = environment(binding.getApplicationContext()); + // One environment for the process; it keeps what each engine's view reports. + store.setEnvironment(environment); + placement = new SecurePlacement(owner, binding.getFlutterEngine(), store, environment); + PLACEMENTS.add(placement); + store.setPullHook(PULLS::request); + // No activity yet: where the view is is not known, so cover rather than guess. + store.setPlacement(owner, SecurePlacement.IMPLICIT_VIEW_ID, + SecureRectangleStore.Placement.UNKNOWN); + } + + @Override + public void onAttachedToActivity(@NonNull final ActivityPluginBinding binding) { + if (placement != null) { + placement.setActivity(binding.getActivity()); + } + } + + @Override + public void onDetachedFromActivityForConfigChanges() { + onDetachedFromActivity(); + } + + @Override + public void onReattachedToActivityForConfigChanges(@NonNull final ActivityPluginBinding binding) { + onAttachedToActivity(binding); + } + + @Override + public void onDetachedFromActivity() { + if (placement != null) { + placement.setActivity(null); + } } @Override public void onDetachedFromEngine(@NonNull final FlutterPluginBinding binding) { Messages.BugseeBridgeHostApi.setUp(binding.getBinaryMessenger(), null); + if (placement != null) { + PLACEMENTS.remove(placement); + placement.dispose(); + placement = null; + } + // The engine's UI is gone; there is nothing left of it to hide. + SecureRectangleStore.shared().dropOwner(owner); final LifecycleHub.Sink stale = sink; if (stale != null) { LifecycleHub.shared().detach(stale); @@ -68,6 +157,29 @@ public void setWrapperIdentity(@NonNull final Messages.WrapperIdentityMessage id (Map) identity.getContext()); } + @NonNull + @Override + public Boolean setSecureRectangles(@NonNull final Messages.SecureRectanglesMessage state) { + final Map views = new HashMap<>(); + for (final Messages.SecureViewRectanglesMessage view : state.getViews()) { + final List flat = view.getRectangles(); + final double[] rects = new double[flat.size()]; + for (int i = 0; i < rects.length; i++) { + rects[i] = flat.get(i); + } + views.put(view.getViewId().intValue(), + new SecureRectangleStore.ViewRects(view.getDevicePixelRatio(), rects)); + } + final boolean applied = SecureRectangleStore.shared().publish( + owner, state.getEpoch(), state.getSequence(), views); + if (applied && placement != null) { + // Dart just measured, so look at where the view is now rather than + // trusting what the layout listener last saw. + placement.refresh(); + } + return applied; + } + @NonNull static Messages.LifecycleEventMessage toMessage(@NonNull final LifecycleHub.Event event) { return new Messages.LifecycleEventMessage.Builder() diff --git a/android/src/main/java/com/bugsee/flutter/FlutterWrapper.java b/android/src/main/java/com/bugsee/flutter/FlutterWrapper.java index cc935f7..24de3f6 100644 --- a/android/src/main/java/com/bugsee/flutter/FlutterWrapper.java +++ b/android/src/main/java/com/bugsee/flutter/FlutterWrapper.java @@ -162,7 +162,7 @@ public void onLifecycleEvent(@NonNull final String eventType, @Nullable final Ob @Override public int[] getSecureRectangles(final int display) { // From the store, not from this object: this wrapper is replaced. - return secureRectangles.snapshot(display); + return secureRectangles.pull(display); } @Override diff --git a/android/src/main/java/com/bugsee/flutter/Messages.java b/android/src/main/java/com/bugsee/flutter/Messages.java index 0e133ba..685d535 100644 --- a/android/src/main/java/com/bugsee/flutter/Messages.java +++ b/android/src/main/java/com/bugsee/flutter/Messages.java @@ -487,6 +487,272 @@ ArrayList toList() { } } + /** + * The rectangles of one Flutter view, flat `[l, t, r, b, l, t, r, b, ...]`, in + * the view's own LOGICAL pixels (Flutter logical pixels: iOS points, Android + * dp), origin at the view's top-left, right/bottom exclusive. Native converts + * them to the SDK's unit and places the view on the screen. + * + * Generated class from Pigeon that represents data sent in messages. + */ + public static final class SecureViewRectanglesMessage { + /** `FlutterView.viewId`. Mobile engines have only the implicit view (0). */ + private @NonNull Long viewId; + + public @NonNull Long getViewId() { + return viewId; + } + + public void setViewId(@NonNull Long setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"viewId\" is null."); + } + this.viewId = setterArg; + } + + /** + * `FlutterView.devicePixelRatio` the rectangles were measured with. Native + * scales by THIS, not by what the display reports now: a density change is + * applied natively before Dart has laid out again, so rectangles measured at + * the old ratio must not be scaled by the new one. When the two differ, + * native covers the whole display until Dart republishes. + */ + private @NonNull Double devicePixelRatio; + + public @NonNull Double getDevicePixelRatio() { + return devicePixelRatio; + } + + public void setDevicePixelRatio(@NonNull Double setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"devicePixelRatio\" is null."); + } + this.devicePixelRatio = setterArg; + } + + private @NonNull List rectangles; + + public @NonNull List getRectangles() { + return rectangles; + } + + public void setRectangles(@NonNull List setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"rectangles\" is null."); + } + this.rectangles = setterArg; + } + + /** Constructor is non-public to enforce null safety; use Builder. */ + SecureViewRectanglesMessage() {} + + @Override + public boolean equals(Object o) { + if (this == o) { return true; } + if (o == null || getClass() != o.getClass()) { return false; } + SecureViewRectanglesMessage that = (SecureViewRectanglesMessage) o; + return pigeonDeepEquals(viewId, that.viewId) && pigeonDeepEquals(devicePixelRatio, that.devicePixelRatio) && pigeonDeepEquals(rectangles, that.rectangles); + } + + @Override + public int hashCode() { + Object[] fields = new Object[] {getClass(), viewId, devicePixelRatio, rectangles}; + return pigeonDeepHashCode(fields); + } + + @Override + public String toString() { + return "SecureViewRectanglesMessage{" + "viewId=" + viewId + ", " + "devicePixelRatio=" + devicePixelRatio + ", " + "rectangles=" + rectangles + "}"; + } + + public static final class Builder { + + private @Nullable Long viewId; + + @CanIgnoreReturnValue + public @NonNull Builder setViewId(@NonNull Long setterArg) { + this.viewId = setterArg; + return this; + } + + private @Nullable Double devicePixelRatio; + + @CanIgnoreReturnValue + public @NonNull Builder setDevicePixelRatio(@NonNull Double setterArg) { + this.devicePixelRatio = setterArg; + return this; + } + + private @Nullable List rectangles; + + @CanIgnoreReturnValue + public @NonNull Builder setRectangles(@NonNull List setterArg) { + this.rectangles = setterArg; + return this; + } + + public @NonNull SecureViewRectanglesMessage build() { + SecureViewRectanglesMessage pigeonReturn = new SecureViewRectanglesMessage(); + pigeonReturn.setViewId(viewId); + pigeonReturn.setDevicePixelRatio(devicePixelRatio); + pigeonReturn.setRectangles(rectangles); + return pigeonReturn; + } + } + + @NonNull + ArrayList toList() { + ArrayList toListResult = new ArrayList<>(3); + toListResult.add(viewId); + toListResult.add(devicePixelRatio); + toListResult.add(rectangles); + return toListResult; + } + + static @NonNull SecureViewRectanglesMessage fromList(@NonNull ArrayList pigeonVar_list) { + SecureViewRectanglesMessage pigeonResult = new SecureViewRectanglesMessage(); + Object viewId = pigeonVar_list.get(0); + pigeonResult.setViewId((Long) viewId); + Object devicePixelRatio = pigeonVar_list.get(1); + pigeonResult.setDevicePixelRatio((Double) devicePixelRatio); + Object rectangles = pigeonVar_list.get(2); + pigeonResult.setRectangles((List) rectangles); + return pigeonResult; + } + } + + /** + * The COMPLETE set of secure rectangles of this Dart isolate: a view that is + * absent from [views] has none. Whole-state, not a delta, so a lost or late + * message can never leave a rectangle behind. + * + * Ordering: within one `epoch` native ignores a message whose `sequence` is + * not newer than the newest it accepted. `epoch` is a random id of the Dart + * isolate (never a clock: a clock can go backwards). A message with an epoch + * native has not seen starts a new isolate (a hot restart; its counter restarts + * at 1) and replaces the old one's state; a message of an epoch native has + * already retired is a late straggler of a dead isolate and is ignored. + * + * Generated class from Pigeon that represents data sent in messages. + */ + public static final class SecureRectanglesMessage { + private @NonNull Long epoch; + + public @NonNull Long getEpoch() { + return epoch; + } + + public void setEpoch(@NonNull Long setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"epoch\" is null."); + } + this.epoch = setterArg; + } + + private @NonNull Long sequence; + + public @NonNull Long getSequence() { + return sequence; + } + + public void setSequence(@NonNull Long setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"sequence\" is null."); + } + this.sequence = setterArg; + } + + private @NonNull List views; + + public @NonNull List getViews() { + return views; + } + + public void setViews(@NonNull List setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"views\" is null."); + } + this.views = setterArg; + } + + /** Constructor is non-public to enforce null safety; use Builder. */ + SecureRectanglesMessage() {} + + @Override + public boolean equals(Object o) { + if (this == o) { return true; } + if (o == null || getClass() != o.getClass()) { return false; } + SecureRectanglesMessage that = (SecureRectanglesMessage) o; + return pigeonDeepEquals(epoch, that.epoch) && pigeonDeepEquals(sequence, that.sequence) && pigeonDeepEquals(views, that.views); + } + + @Override + public int hashCode() { + Object[] fields = new Object[] {getClass(), epoch, sequence, views}; + return pigeonDeepHashCode(fields); + } + + @Override + public String toString() { + return "SecureRectanglesMessage{" + "epoch=" + epoch + ", " + "sequence=" + sequence + ", " + "views=" + views + "}"; + } + + public static final class Builder { + + private @Nullable Long epoch; + + @CanIgnoreReturnValue + public @NonNull Builder setEpoch(@NonNull Long setterArg) { + this.epoch = setterArg; + return this; + } + + private @Nullable Long sequence; + + @CanIgnoreReturnValue + public @NonNull Builder setSequence(@NonNull Long setterArg) { + this.sequence = setterArg; + return this; + } + + private @Nullable List views; + + @CanIgnoreReturnValue + public @NonNull Builder setViews(@NonNull List setterArg) { + this.views = setterArg; + return this; + } + + public @NonNull SecureRectanglesMessage build() { + SecureRectanglesMessage pigeonReturn = new SecureRectanglesMessage(); + pigeonReturn.setEpoch(epoch); + pigeonReturn.setSequence(sequence); + pigeonReturn.setViews(views); + return pigeonReturn; + } + } + + @NonNull + ArrayList toList() { + ArrayList toListResult = new ArrayList<>(3); + toListResult.add(epoch); + toListResult.add(sequence); + toListResult.add(views); + return toListResult; + } + + static @NonNull SecureRectanglesMessage fromList(@NonNull ArrayList pigeonVar_list) { + SecureRectanglesMessage pigeonResult = new SecureRectanglesMessage(); + Object epoch = pigeonVar_list.get(0); + pigeonResult.setEpoch((Long) epoch); + Object sequence = pigeonVar_list.get(1); + pigeonResult.setSequence((Long) sequence); + Object views = pigeonVar_list.get(2); + pigeonResult.setViews((List) views); + return pigeonResult; + } + } + private static class PigeonCodec extends StandardMessageCodec { public static final PigeonCodec INSTANCE = new PigeonCodec(); @@ -499,6 +765,10 @@ protected Object readValueOfType(byte type, @NonNull ByteBuffer buffer) { return LifecycleEventMessage.fromList((ArrayList) readValue(buffer)); case (byte) 130: return WrapperIdentityMessage.fromList((ArrayList) readValue(buffer)); + case (byte) 131: + return SecureViewRectanglesMessage.fromList((ArrayList) readValue(buffer)); + case (byte) 132: + return SecureRectanglesMessage.fromList((ArrayList) readValue(buffer)); default: return super.readValueOfType(type, buffer); } @@ -512,6 +782,12 @@ protected void writeValue(@NonNull ByteArrayOutputStream stream, Object value) { } else if (value instanceof WrapperIdentityMessage) { stream.write(130); writeValue(stream, ((WrapperIdentityMessage) value).toList()); + } else if (value instanceof SecureViewRectanglesMessage) { + stream.write(131); + writeValue(stream, ((SecureViewRectanglesMessage) value).toList()); + } else if (value instanceof SecureRectanglesMessage) { + stream.write(132); + writeValue(stream, ((SecureRectanglesMessage) value).toList()); } else { super.writeValue(stream, value); } @@ -559,6 +835,14 @@ public interface BugseeBridgeHostApi { * identity. Never throws on malformed content: facts become "unknown". */ void setWrapperIdentity(@NonNull WrapperIdentityMessage identity); + /** + * Publishes the secure rectangles (see [SecureRectanglesMessage]). Never + * throws on content: an unusable value fails closed natively. Returns + * whether the message was applied (`false`: it was older than one already + * applied, which is not an error). + */ + @NonNull + Boolean setSecureRectangles(@NonNull SecureRectanglesMessage state); /** The codec used by BugseeBridgeHostApi. */ static @NonNull MessageCodec getCodec() { @@ -605,6 +889,29 @@ static void setUp(@NonNull BinaryMessenger binaryMessenger, @NonNull String mess api.setWrapperIdentity(identityArg); wrapped.add(0, null); } + catch (Throwable exception) { + wrapped = wrapError(exception); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.setSecureRectangles" + messageChannelSuffix, getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + ArrayList wrapped = new ArrayList<>(); + ArrayList args = (ArrayList) message; + SecureRectanglesMessage stateArg = (SecureRectanglesMessage) args.get(0); + try { + Boolean output = api.setSecureRectangles(stateArg); + wrapped.add(0, output); + } catch (Throwable exception) { wrapped = wrapError(exception); } diff --git a/android/src/main/java/com/bugsee/flutter/PullThrottle.java b/android/src/main/java/com/bugsee/flutter/PullThrottle.java new file mode 100644 index 0000000..4554a2b --- /dev/null +++ b/android/src/main/java/com/bugsee/flutter/PullThrottle.java @@ -0,0 +1,60 @@ +package com.bugsee.flutter; + +import androidx.annotation.NonNull; + +import java.util.concurrent.atomic.AtomicLong; + +/** + * Lets the SDK's pull (its capture thread, 2-3 times a second) ask for a + * placement refresh without ever blocking or flooding the main thread: at most + * one request per {@link #MIN_INTERVAL_MS}, and a request only hands a task to + * the executor (which posts to the main thread). + * + *

Needed because the Flutter view can move on the screen with no relayout + * (freeform or desktop windowing, split-screen resize, picture-in-picture), and + * nothing then tells Dart or the layout listener; without the pull-driven + * refresh the old position would keep being served. + */ +final class PullThrottle { + + static final long MIN_INTERVAL_MS = 100; + + interface Clock { + long nowMs(); + } + + private static final long NEVER = Long.MIN_VALUE; + + private final Clock clock; + private final java.util.concurrent.Executor executor; + private final Runnable task; + private final AtomicLong last = new AtomicLong(NEVER); + + PullThrottle( + @NonNull final Clock clock, + @NonNull final java.util.concurrent.Executor executor, + @NonNull final Runnable task + ) { + this.clock = clock; + this.executor = executor; + this.task = task; + } + + /** Called on every pull. Never throws. */ + void request() { + try { + final long now = clock.nowMs(); + final long before = last.get(); + if (before != NEVER && now - before < MIN_INTERVAL_MS) { + return; + } + // One winner per window when several displays are pulled together. + if (!last.compareAndSet(before, now)) { + return; + } + executor.execute(task); + } catch (Throwable ignored) { + // The pull is answered regardless. + } + } +} diff --git a/android/src/main/java/com/bugsee/flutter/SecureEnvironment.java b/android/src/main/java/com/bugsee/flutter/SecureEnvironment.java new file mode 100644 index 0000000..3951872 --- /dev/null +++ b/android/src/main/java/com/bugsee/flutter/SecureEnvironment.java @@ -0,0 +1,85 @@ +package com.bugsee.flutter; + +import android.content.Context; +import android.content.res.Resources; +import android.hardware.display.DisplayManager; +import android.util.DisplayMetrics; +import android.view.Display; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.lang.ref.WeakReference; +import java.util.concurrent.ConcurrentHashMap; + +/** + * What {@link SecureRectangleStore} reads off the device, at every pull. + * + *

The density is the one Flutter itself divides by: {@code + * getResources().getDisplayMetrics().density} of the Flutter view's context + * (that is what {@code FlutterView} sends as the device pixel ratio). It is read + * from the live {@link Resources} object on every call, so a density change is + * seen at the next pull and no value is cached here. + */ +final class SecureEnvironment implements SecureRectangleStore.Environment { + + private final Context appContext; + private final ConcurrentHashMap> resources = + new ConcurrentHashMap<>(); + + SecureEnvironment(@NonNull final Context appContext) { + this.appContext = appContext.getApplicationContext() == null + ? appContext : appContext.getApplicationContext(); + } + + /** The Flutter view on {@code display} draws with these resources. */ + void setResources(final int display, @NonNull final Resources r) { + resources.put(display, new WeakReference<>(r)); + } + + @Override + public double unitsPerPhysicalPixel(final int display) { + return 1; // the SDK takes physical pixels + } + + @Override + public double nativePixelRatio(final int display) { + try { + final WeakReference ref = resources.get(display); + final Resources r = ref == null ? null : ref.get(); + if (r != null) { + return r.getDisplayMetrics().density; + } + final DisplayMetrics metrics = metricsOf(display); + return metrics == null ? Double.NaN : metrics.density; + } catch (Throwable t) { + return Double.NaN; + } + } + + @Nullable + @Override + public int[] displayBounds(final int display) { + try { + final DisplayMetrics metrics = metricsOf(display); + return metrics == null + ? null : new int[] {metrics.widthPixels, metrics.heightPixels}; + } catch (Throwable t) { + return null; + } + } + + @Nullable + @SuppressWarnings("deprecation") // getRealMetrics: still the real size on every API + private DisplayMetrics metricsOf(final int display) { + final DisplayManager dm = + (DisplayManager) appContext.getSystemService(Context.DISPLAY_SERVICE); + final Display d = dm == null ? null : dm.getDisplay(display); + if (d == null) { + return null; + } + final DisplayMetrics out = new DisplayMetrics(); + d.getRealMetrics(out); + return out; + } +} diff --git a/android/src/main/java/com/bugsee/flutter/SecurePlacement.java b/android/src/main/java/com/bugsee/flutter/SecurePlacement.java new file mode 100644 index 0000000..08a2f61 --- /dev/null +++ b/android/src/main/java/com/bugsee/flutter/SecurePlacement.java @@ -0,0 +1,199 @@ +package com.bugsee.flutter; + +import android.app.Activity; +import android.util.Log; +import android.view.Display; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; + +import java.util.ArrayList; +import java.util.List; + +import io.flutter.embedding.android.FlutterView; +import io.flutter.embedding.engine.FlutterEngine; + +/** + * Keeps {@link SecureRectangleStore}'s idea of where this engine's Flutter view + * sits on the display current. + * + *

Dart measures in the view's own logical pixels; the SDK wants the display's + * physical pixels, the space {@code View.getLocationOnScreen} reports. The view + * is not always at the display's origin: a status bar above it (edge-to-edge + * off), split-screen, freeform windows, a display cutout, a keyboard that pans + * the window. So the view's on-screen position is added to every rectangle. + * + *

The state it reports, and why: + *

    + *
  • no activity attached, or an activity but no (or an ambiguous, or detached) + * Flutter view, or a view that is rotated or skewed: + * {@link SecureRectangleStore.Placement#UNKNOWN}, which the store serves + * as the whole display, because the regions might be anywhere;
  • + *
  • otherwise the view's display id and on-screen position.
  • + *
+ * Only the engine's implicit view (id 0) exists on Android; any other view id + * has no placement and so is served as the whole display. + * + *

Re-read on a global layout, when an activity attaches, on every publish + * from Dart and, throttled, on every SDK pull ({@link PullThrottle}). All view + * access is on the main thread. + */ +final class SecurePlacement implements ViewTreeObserver.OnGlobalLayoutListener { + + private static final String TAG = FlutterWrapperInitProvider.TAG; + + /** The implicit view: the only one a mobile engine has. */ + static final int IMPLICIT_VIEW_ID = 0; + + private final long owner; + private final FlutterEngine engine; + private final SecureRectangleStore store; + private final SecureEnvironment environment; + + @Nullable + private Activity activity; + @Nullable + private FlutterView observed; + private final int[] location = new int[2]; + + SecurePlacement( + final long owner, + @NonNull final FlutterEngine engine, + @NonNull final SecureRectangleStore store, + @NonNull final SecureEnvironment environment + ) { + this.owner = owner; + this.engine = engine; + this.store = store; + this.environment = environment; + } + + @UiThread + void setActivity(@Nullable final Activity activity) { + this.activity = activity; + refresh(); + } + + /** Stops observing. The caller drops the owner's rectangles. */ + @UiThread + void dispose() { + activity = null; + unobserve(); + } + + @Override + public void onGlobalLayout() { + refresh(); + } + + /** Never throws: it runs from the host app's layout and from the pull's refresh. */ + @UiThread + void refresh() { + try { + if (activity == null) { + // Not attached to an activity does not mean off screen: an add-to-app + // FlutterView may be hosted without one. Rectangles are present, so cover. + unobserve(); + store.setPlacement(owner, IMPLICIT_VIEW_ID, SecureRectangleStore.Placement.UNKNOWN); + return; + } + final FlutterView view = find(activity.getWindow().getDecorView(), engine); + if (view == null || !view.isAttachedToWindow()) { + unobserve(); + store.setPlacement(owner, IMPLICIT_VIEW_ID, SecureRectangleStore.Placement.UNKNOWN); + return; + } + observe(view); + final Display display = view.getDisplay(); + final int displayId = display == null ? 0 : display.getDisplayId(); + view.getLocationOnScreen(location); + environment.setResources(displayId, view.getResources()); + final double[] scale = screenScale(view); + store.setPlacement(owner, IMPLICIT_VIEW_ID, scale == null + ? SecureRectangleStore.Placement.UNKNOWN + : SecureRectangleStore.Placement.known( + displayId, location[0], location[1], scale[0], scale[1])); + } catch (Throwable t) { + Log.w(TAG, "secure rectangles: could not read the Flutter view's position: " + + t.getClass().getName()); + try { + store.setPlacement(owner, IMPLICIT_VIEW_ID, SecureRectangleStore.Placement.UNKNOWN); + } catch (Throwable ignored) { + // nothing more to do + } + } + } + + /** + * The scale the view appears at on screen: the product of its own and its + * ancestors' view matrices, which {@code getLocationOnScreen} accounts for in + * the position but not in the size. {@code null} when any of them rotates or + * skews (not a pure scale and translation): the caller then covers the + * whole display. + */ + @Nullable + private static double[] screenScale(@NonNull final View view) { + final List matrices = new ArrayList<>(); + View v = view; + while (v != null) { + final float[] values = new float[9]; + v.getMatrix().getValues(values); + matrices.add(values); + final Object parent = v.getParent(); + v = parent instanceof View ? (View) parent : null; + } + return ViewScale.accumulate(matrices); + } + + private void observe(@NonNull final FlutterView view) { + if (view == observed) { + return; + } + unobserve(); + view.getViewTreeObserver().addOnGlobalLayoutListener(this); + observed = view; + } + + private void unobserve() { + final FlutterView view = observed; + observed = null; + if (view != null && view.getViewTreeObserver().isAlive()) { + view.getViewTreeObserver().removeOnGlobalLayoutListener(this); + } + } + + /** + * The Flutter view attached to {@code engine} in {@code root}'s tree, or + * null when there is none or when it is ambiguous (two attached to the same + * engine: which one the rectangles belong to cannot be told). + */ + @Nullable + static FlutterView find(@NonNull final View root, @NonNull final FlutterEngine engine) { + final FlutterView[] found = new FlutterView[1]; + final int[] count = new int[1]; + walk(root, engine, found, count); + return count[0] == 1 ? found[0] : null; + } + + private static void walk( + @NonNull final View view, + @NonNull final FlutterEngine engine, + @NonNull final FlutterView[] found, + @NonNull final int[] count + ) { + if (view instanceof FlutterView && ((FlutterView) view).getAttachedFlutterEngine() == engine) { + found[0] = (FlutterView) view; + count[0]++; + } + if (view instanceof ViewGroup) { + final ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) { + walk(group.getChildAt(i), engine, found, count); + } + } + } +} diff --git a/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java b/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java index d2e7a3e..189f084 100644 --- a/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java +++ b/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java @@ -1,10 +1,17 @@ package com.bugsee.flutter; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.bugsee.library.contracts.internal.BugseeWrapper; -import java.util.concurrent.ConcurrentHashMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicReference; /** * Where the secure-rectangle sets live (workbook 5.4.2): outside the wrapper @@ -12,44 +19,721 @@ * identity, and the regions must survive that or the app is recorded * unredacted until it next republishes. * - *

Empty for now; Phase 3b fills it. A set is the SDK's packed buffer - * {@code [version, count, l, t, r, b, ...]}, kept per display (5.2.3). Held as - * published, never mutated afterwards, and handed out as a copy, so the SDK - * thread that pulls it cannot see a half-written set (5.4.1). + *

What the SDK takes (Android 7.4.2)

+ * + *

{@link BugseeWrapper#getSecureRectangles(int)} returns + * {@code [version, count, l, t, r, b, ...]}; the rectangles are PHYSICAL PIXELS + * in the display's coordinate space, the one {@code View.getLocationOnScreen} + * reports (the SDK measures its own secure views with it and applies the + * screen-to-frame transform itself), {@code r}/{@code b} exclusive. The + * {@code display} argument is the Android display id + * ({@code Display.getDisplayId()}); the SDK pulls once per capture on its + * capture thread. A non-zero version equal to the last one seen for that + * display makes the SDK skip the rectangles entirely; 0 + * ({@link BugseeWrapper#SECURE_RECTANGLES_VERSION_UNKNOWN}) makes it compare + * them; a count of 0 clears the display's set whatever the version. + * + *

Dart publishes Flutter LOGICAL pixels (Android dp) relative to its view, + * with the device pixel ratio it measured them at. This class multiplies by + * that ratio, scales by the view's own on-screen scale, adds the view's + * position on the screen, then rounds OUTWARD (floor {@code l}/{@code t}, ceil + * {@code r}/{@code b}), scaling BEFORE rounding so no sub-pixel strip is left + * uncovered (5.1.2, 5.3.1). The display's density is read at every pull and + * must equal the ratio the rectangles were measured at: a density change is + * applied natively before Dart lays out again, and old logical rectangles times + * a new density cover neither the old nor the new content, so until Dart + * republishes at the new ratio the whole display is covered. + * + *

Versions

+ * + *

A display's version is derived from what is served, not from what was + * written: it moves exactly when the served rectangles differ from the ones + * served before (so also when only the density, the view's position or a past + * set's grace window ending changed, which no write announces), and never otherwise (5.2.1, + * 5.2.2). It is kept per display (5.2.3) and only ever steps forward, skipping + * 0. A display that has never had a rectangle serves {@code [0, 0]}: unknown, + * and empty. Once it has had one it serves real versions, an emptied set + * included ({@code [v, 0]}). + * + *

Grace window

+ * + *

Dart measures after a frame and the message crosses a channel, while the + * raster of that frame (or the one before) may be captured first. So every set + * that stops being current is served as well for {@link #GRACE_MS} from the + * moment IT stopped (not from the last change, so a long movement does not widen + * the cover to its whole path): a moving region leaves no uncovered strip as wide + * as one frame's movement, and a region that has just gone is covered a little + * longer. + * It only ever over-covers. A region that has just APPEARED has no previous + * position, so its first frame can still be recorded before the message lands: + * the one exposure this class cannot close. The union shrinks back on a later + * pull, which is a change of the served set and so moves the version. + * + *

Fail closed

+ * + *

A view whose placement is not known (the Flutter view has not been found, + * or is ambiguous, or is transformed by more than a scale) is served as one + * rectangle covering the whole display. A rectangle that is not finite does the + * same, and so does a scale or ratio that is not a positive finite number or + * does not match the display's density. A view known to be off screen serves + * nothing. + * + *

Threading

+ * + *

Writes come from the platform thread and the placement tracker, the pull + * from the SDK's capture thread. The written state is immutable and swapped + * whole; the served buffer is an immutable array that is cloned for every + * caller (5.4.1). */ final class SecureRectangleStore { /** - * {@link BugseeWrapper#SECURE_RECTANGLES_VERSION_UNKNOWN}: the SDK then - * compares the rectangles themselves. A constant real version would let a - * later set that also starts at that version, with real rectangles, be - * ignored (the SDK caches by version per display: unredacted capture). + * {@link BugseeWrapper#SECURE_RECTANGLES_VERSION_UNKNOWN}: what a display + * that never had a rectangle serves, and what the SDK reads as "compare the + * rectangles yourself". Real versions are never this value. + */ + static final int UNKNOWN_VERSION = BugseeWrapper.SECURE_RECTANGLES_VERSION_UNKNOWN; + + private static final int HEADER_INTS = 2; + + /** + * The size a whole-display rectangle gets when the display's real size is + * not known (or reads as zero): more than any panel, small enough not to + * overflow an int when the SDK scales it. */ - private static final int UNKNOWN_VERSION = BugseeWrapper.SECURE_RECTANGLES_VERSION_UNKNOWN; + static final int FALLBACK_DISPLAY_SIZE = 16384; + + /** Largest magnitude served: keeps the SDK's own int arithmetic away from overflow. */ + private static final double LIMIT = 1 << 28; + + /** + * A placement refresh that was asked for by a pull and has not completed for this long means + * the main thread is not answering: the placements may be stale by an unknown amount, so every + * placed view is covered whole until a refresh completes. A refresh normally completes within + * milliseconds of the pull that asked, so this never fires in ordinary running (and is + * independent of how often the SDK pulls). Repeated nowhere else. + */ + static final long REFRESH_STALE_MS = 1000; + + /** How long each past set is served after it stopped being current. Repeated in ObjC. */ + static final long GRACE_MS = 120; + + /** More rectangles than this and the past sets still being served are collapsed to their bounding box. */ + private static final int MAX_RECTANGLES = 64; + + /** The densities a message may differ from the display's by (floating-point noise). */ + private static final double RATIO_TOLERANCE = 1e-3; + + /** Epochs remembered as retired, so a straggler of a dead isolate is ignored. */ + private static final int RETIRED_EPOCHS = 32; + + /** What the store needs to know about the device, read at every pull. */ + interface Environment { + /** + * The display's physical pixels per Flutter logical pixel right now + * (Android: {@code DisplayMetrics.density} of the Flutter view). Read + * per call, never cached. Not finite or not positive means "unknown" + * and fails closed. + */ + double nativePixelRatio(int display); + + /** + * SDK units per physical pixel: 1 on Android (the SDK takes physical + * pixels). + */ + double unitsPerPhysicalPixel(int display); + + /** The display's current size in pixels, {@code {width, height}}, or {@code null}. */ + @Nullable + int[] displayBounds(int display); + } + + /** Knows nothing: every view covers the whole display. Until a plugin installs the real one. */ + static final Environment UNKNOWN_ENVIRONMENT = new Environment() { + @Override + public double nativePixelRatio(final int display) { + return Double.NaN; + } + + @Override + public double unitsPerPhysicalPixel(final int display) { + return 1; + } + + @Nullable + @Override + public int[] displayBounds(final int display) { + return null; + } + }; + + /** Milliseconds from a monotonic source. */ + interface Clock { + long nowMs(); + } + + /** What one view published: the ratio it was measured at and its flat logical ltrb. */ + static final class ViewRects { + final double ratio; + final double[] rects; + + ViewRects(final double ratio, @NonNull final double[] rects) { + this.ratio = ratio; + this.rects = rects; + } + } + + /** Where one Flutter view sits, in the unit and space the SDK wants. */ + static final class Placement { + static final int KNOWN_KIND = 0; + static final int UNKNOWN_KIND = 1; + static final int ABSENT_KIND = 2; + + /** The view's position is not known: serve the whole display. */ + static final Placement UNKNOWN = new Placement(UNKNOWN_KIND, 0, 0, 0, 1, 1); + /** The view is known not to be on screen: serve nothing. */ + static final Placement ABSENT = new Placement(ABSENT_KIND, 0, 0, 0, 1, 1); + + final int kind; + final int display; + /** The view's top-left on the display, in physical pixels. */ + final double x; + final double y; + /** The view's own scale on the screen (it, or an ancestor, is scaled); 1 normally. */ + final double scaleX; + final double scaleY; + + private Placement( + final int kind, + final int display, + final double x, + final double y, + final double scaleX, + final double scaleY + ) { + this.kind = kind; + this.display = display; + this.x = x; + this.y = y; + this.scaleX = scaleX; + this.scaleY = scaleY; + } + + static Placement known(final int display, final double x, final double y) { + return known(display, x, y, 1, 1); + } + + static Placement known( + final int display, + final double x, + final double y, + final double scaleX, + final double scaleY + ) { + return new Placement(KNOWN_KIND, display, x, y, scaleX, scaleY); + } + + @Override + public boolean equals(final Object o) { + if (!(o instanceof Placement)) { + return false; + } + final Placement p = (Placement) o; + return kind == p.kind && display == p.display && x == p.x && y == p.y + && scaleX == p.scaleX && scaleY == p.scaleY; + } + + @Override + public int hashCode() { + return Arrays.hashCode(new double[] {kind, display, x, y, scaleX, scaleY}); + } + } + + /** One Flutter view of one Dart isolate. */ + private static final class Lane implements Comparable { + final long owner; + final int viewId; + + Lane(final long owner, final int viewId) { + this.owner = owner; + this.viewId = viewId; + } + + @Override + public boolean equals(final Object o) { + return o instanceof Lane && ((Lane) o).owner == owner && ((Lane) o).viewId == viewId; + } + + @Override + public int hashCode() { + return Long.hashCode(owner) * 31 + viewId; + } + + @Override + public int compareTo(final Lane other) { + final int c = Long.compare(owner, other.owner); + return c != 0 ? c : Integer.compare(viewId, other.viewId); + } + } + + /** The ordering state of one owner. Immutable. */ + private static final class Order { + final long epoch; + final long sequence; + final List retired; + + Order(final long epoch, final long sequence, @NonNull final List retired) { + this.epoch = epoch; + this.sequence = sequence; + this.retired = retired; + } + } + + /** Immutable. Replaced wholesale on every write. */ + private static final class State { + final TreeMap rects; + final Map placements; + final Map order; + + State( + final TreeMap rects, + final Map placements, + final Map order + ) { + this.rects = rects; + this.placements = placements; + this.order = order; + } + } - private static final SecureRectangleStore SHARED = new SecureRectangleStore(); + /** A set that stopped being current, and until when it is still served. Immutable. */ + private static final class Past { + final int[] quads; + final long untilMs; + + Past(@NonNull final int[] quads, final long untilMs) { + this.quads = quads; + this.untilMs = untilMs; + } + } + + /** What a display last served. Immutable. */ + private static final class Served { + final int version; + /** What is served: the current set plus every past set still inside its window. */ + final int[] quads; + final int[] current; + /** Newest first. Each is served until {@code GRACE_MS} after it stopped being current. */ + final List past; + final int[] buffer; + + Served( + final int version, + @NonNull final int[] quads, + @NonNull final int[] current, + @NonNull final List past + ) { + this.version = version; + this.quads = quads; + this.current = current; + this.past = past; + this.buffer = new int[HEADER_INTS + quads.length]; + buffer[0] = version; + buffer[1] = quads.length / 4; + System.arraycopy(quads, 0, buffer, HEADER_INTS, quads.length); + } + } + + private static final int[] NONE = new int[0]; + + private static final SecureRectangleStore SHARED = new SecureRectangleStore( + UNKNOWN_ENVIRONMENT, () -> System.nanoTime() / 1_000_000L, GRACE_MS); @NonNull static SecureRectangleStore shared() { return SHARED; } - private final ConcurrentHashMap sets = new ConcurrentHashMap<>(); + private volatile Environment environment; + private final Clock clock; + private final long graceMs; + private volatile State state = new State(new TreeMap<>(), new HashMap<>(), new HashMap<>()); + private final Object writeLock = new Object(); + private final Object serveLock = new Object(); + private final Map served = new HashMap<>(); + private final AtomicReference pullHook = new AtomicReference<>(); + private static final long NO_REFRESH_PENDING = Long.MIN_VALUE; + /** When the oldest refresh that a pull asked for was asked; none while all completed. */ + private final java.util.concurrent.atomic.AtomicLong refreshPendingSince = + new java.util.concurrent.atomic.AtomicLong(NO_REFRESH_PENDING); + + /** No grace window: what is written is what is served at once. For tests. */ + SecureRectangleStore(@NonNull final Environment environment) { + this(environment, () -> System.nanoTime() / 1_000_000L, 0); + } + + SecureRectangleStore( + @NonNull final Environment environment, + @NonNull final Clock clock, + final long graceMs + ) { + this.environment = environment; + this.clock = clock; + this.graceMs = graceMs; + } + + /** For a plugin to install what it can read off the device. */ + void setEnvironment(@NonNull final Environment environment) { + this.environment = environment; + } + + /** + * Installs what every pull calls first (the placement tracker's throttled + * refresh request). It must only post to another thread, never block. + */ + void setPullHook(@Nullable final Runnable hook) { + pullHook.set(hook); + } + + /** + * Applies Dart's whole state for {@code owner}: {@code views} maps a view + * id to what it published; a view not in the map has no rectangles any + * more. Ignored, and {@code false}, when {@code epoch} is the current one + * and {@code sequence} is not newer, or when {@code epoch} was retired (a + * straggler of a dead isolate). An epoch not seen before is a new isolate + * (a hot restart): it replaces the old one's state and retires its epoch. + * Epochs are random ids, never clocks. + */ + boolean publish( + final long owner, + final long epoch, + final long sequence, + @NonNull final Map views + ) { + synchronized (writeLock) { + final State previous = state; + final Order last = previous.order.get(owner); + List retired = new ArrayList<>(); + if (last != null) { + if (last.retired.contains(epoch)) { + return false; + } + if (epoch == last.epoch) { + if (sequence <= last.sequence) { + return false; + } + retired = last.retired; + } else { + retired = new ArrayList<>(last.retired); + retired.add(last.epoch); + while (retired.size() > RETIRED_EPOCHS) { + retired.remove(0); + } + } + } + final TreeMap rects = new TreeMap<>(); + for (final Map.Entry e : previous.rects.entrySet()) { + if (e.getKey().owner != owner) { + rects.put(e.getKey(), e.getValue()); + } + } + for (final Map.Entry e : views.entrySet()) { + if (e.getValue().rects.length > 0) { + rects.put(new Lane(owner, e.getKey()), + new ViewRects(e.getValue().ratio, e.getValue().rects.clone())); + } + } + final Map order = new HashMap<>(previous.order); + order.put(owner, new Order(epoch, sequence, retired)); + state = new State(rects, previous.placements, order); + return true; + } + } - /** The set last published for {@code display}, or the empty set. */ + /** Where {@code owner}'s view {@code viewId} sits now. Not a change when it is the same. */ + void setPlacement(final long owner, final int viewId, @NonNull final Placement placement) { + synchronized (writeLock) { + final State previous = state; + final Lane lane = new Lane(owner, viewId); + if (placement.equals(previous.placements.get(lane))) { + return; + } + final Map placements = new HashMap<>(previous.placements); + placements.put(lane, placement); + state = new State(previous.rects, placements, previous.order); + } + } + + /** Dart's engine is gone: forget its rectangles, placements and ordering. */ + void dropOwner(final long owner) { + synchronized (writeLock) { + final State previous = state; + final TreeMap rects = new TreeMap<>(); + for (final Map.Entry e : previous.rects.entrySet()) { + if (e.getKey().owner != owner) { + rects.put(e.getKey(), e.getValue()); + } + } + final Map placements = new HashMap<>(); + for (final Map.Entry e : previous.placements.entrySet()) { + if (e.getKey().owner != owner) { + placements.put(e.getKey(), e.getValue()); + } + } + final Map order = new HashMap<>(previous.order); + order.remove(owner); + state = new State(rects, placements, order); + } + } + + /** The SDK's pull: asks the tracker for a refresh, then serves. */ + @NonNull + int[] pull(final int display) { + final Runnable hook = pullHook.get(); + if (hook != null) { + try { + hook.run(); + } catch (Throwable ignored) { + // The pull must always be answered. + } + } + return snapshot(display); + } + + /** + * A placement refresh was actually handed to the main thread (call it only for a request that + * was posted, never for one the throttle dropped: with nothing in flight there is nothing to + * wait for). The OLDEST unanswered request counts. + */ + void refreshRequested() { + refreshPendingSince.compareAndSet(NO_REFRESH_PENDING, clock.nowMs()); + } + + /** + * The placement refresh the pull asked for has finished (the tracker calls this after it + * re-read every view): the placements are fresh again. + */ + void refreshCompleted() { + refreshPendingSince.set(NO_REFRESH_PENDING); + } + + /** + * The buffer for {@code display}: {@code [version, count, l, t, r, b, ...]}. + * Computed from the current state, density and size at this call; a fresh + * array every time. + */ @NonNull int[] snapshot(final int display) { - final int[] set = sets.get(display); - return set == null ? new int[] {UNKNOWN_VERSION, 0} : set.clone(); + synchronized (serveLock) { + final long now = clock.nowMs(); + final int[] target = serve(state, display); + final Served last = served.get(display); + if (last == null) { + if (target.length == 0) { + return new int[] {UNKNOWN_VERSION, 0}; + } + final Served first = new Served(1, target, target, new ArrayList<>()); + served.put(display, first); + return first.buffer.clone(); + } + // Sets that stopped being current are served for a window each, counted from + // the moment THEY stopped (not from the last change), so a long movement keeps + // only the last window's worth of places instead of its whole path. + final List past = new ArrayList<>(); + if (graceMs > 0 && !Arrays.equals(target, last.current) && last.current.length > 0) { + past.add(new Past(last.current, now + graceMs)); + } + for (final Past p : last.past) { + if (p.untilMs > now) { + past.add(p); + } + } + int[] out = target; + for (final Past p : past) { + out = union(out, p.quads); + } + final int version = Arrays.equals(out, last.quads) + ? last.version : advance(last.version); + final Served next = new Served(version, out, target, past); + served.put(display, next); + return next.buffer.clone(); + } + } + + /** The rectangles of {@code a} then those of {@code b} that are not in {@code a}. */ + @NonNull + private static int[] union(@NonNull final int[] a, @NonNull final int[] b) { + if (b.length == 0) { + return a; + } + int[] extra = new int[b.length]; + int n = 0; + for (int i = 0; i < b.length; i += 4) { + boolean present = false; + for (int j = 0; j < a.length && !present; j += 4) { + present = a[j] == b[i] && a[j + 1] == b[i + 1] + && a[j + 2] == b[i + 2] && a[j + 3] == b[i + 3]; + } + if (!present) { + System.arraycopy(b, i, extra, n, 4); + n += 4; + } + } + if (n == 0) { + return a; + } + if ((a.length + n) / 4 > MAX_RECTANGLES) { + // Too many to carry: cover the previous ones by their bounding box (more, never less). + int l = Integer.MAX_VALUE, t = Integer.MAX_VALUE, r = Integer.MIN_VALUE, bt = Integer.MIN_VALUE; + for (int i = 0; i < n; i += 4) { + l = Math.min(l, extra[i]); + t = Math.min(t, extra[i + 1]); + r = Math.max(r, extra[i + 2]); + bt = Math.max(bt, extra[i + 3]); + } + extra = new int[] {l, t, r, bt}; + n = 4; + } + final int[] out = Arrays.copyOf(a, a.length + n); + System.arraycopy(extra, 0, out, a.length, n); + return out; + } + + /** The version after {@code version}: wraps, and never lands on 0 ("unknown"). */ + static int advance(final int version) { + final int next = version + 1; + return next == UNKNOWN_VERSION ? next + 1 : next; + } + + /** The physical-pixel rectangles {@code display} should serve for {@code s}, flat ltrb. */ + @NonNull + private int[] serve(@NonNull final State s, final int display) { + final Environment env = environment; + double nativeRatio = Double.NaN; + double unit = 1; + boolean envRead = false; + int[] bounds = null; + + int[] out = new int[16]; + int n = 0; + boolean whole = false; + final long pendingSince = refreshPendingSince.get(); + final boolean refreshStuck = pullHook.get() != null && pendingSince != NO_REFRESH_PENDING + && clock.nowMs() - pendingSince > REFRESH_STALE_MS; + + for (final Map.Entry entry : s.rects.entrySet()) { + final ViewRects view = entry.getValue(); + final double[] logical = view.rects; + final Placement placement = s.placements.get(entry.getKey()); + final int kind = placement == null ? Placement.UNKNOWN_KIND : placement.kind; + if (kind == Placement.ABSENT_KIND) { + continue; + } + if (kind == Placement.UNKNOWN_KIND) { + whole = true; + continue; + } + if (placement.display != display) { + continue; + } + if (refreshStuck) { + // The placement may be stale by an unknown amount: cover rather than trust it. + whole = true; + continue; + } + if (!envRead) { + nativeRatio = env.nativePixelRatio(display); + unit = env.unitsPerPhysicalPixel(display); + bounds = env.displayBounds(display); + envRead = true; + } + final double ratio = view.ratio; + final double factor = ratio * unit; + final double sx = placement.scaleX; + final double sy = placement.scaleY; + if (!isUsable(ratio) || !isUsable(nativeRatio) || !isUsable(unit) + || !isUsable(sx) || !isUsable(sy) + || Math.abs(nativeRatio - ratio) > RATIO_TOLERANCE * Math.max(1, ratio) + || logical.length % 4 != 0) { + // Measured at another density than the display has now, or not measurable. + whole = true; + continue; + } + for (int i = 0; i < logical.length; i += 4) { + final double l = logical[i]; + final double t = logical[i + 1]; + final double r = logical[i + 2]; + final double b = logical[i + 3]; + if (!isFinite(l) || !isFinite(t) || !isFinite(r) || !isFinite(b)) { + whole = true; + continue; + } + if (r <= l || b <= t) { + continue; // degenerate: nothing to hide + } + // Scale and place first, round outward last (5.3.1). + int il = toInt(Math.floor(l * factor * sx + placement.x)); + int it = toInt(Math.floor(t * factor * sy + placement.y)); + int ir = toInt(Math.ceil(r * factor * sx + placement.x)); + int ib = toInt(Math.ceil(b * factor * sy + placement.y)); + if (bounds != null && bounds[0] > 0 && bounds[1] > 0) { + il = clamp(il, bounds[0]); + ir = clamp(ir, bounds[0]); + it = clamp(it, bounds[1]); + ib = clamp(ib, bounds[1]); + } + if (ir <= il || ib <= it) { + continue; // nothing of it is on the display + } + if (n + 4 > out.length) { + out = Arrays.copyOf(out, out.length * 2); + } + out[n++] = il; + out[n++] = it; + out[n++] = ir; + out[n++] = ib; + } + } + + if (whole) { + // Something could not be placed or measured: cover everything. The + // other rectangles are inside it, so they add nothing. + if (!envRead) { + bounds = env.displayBounds(display); + } + final boolean known = bounds != null && bounds[0] > 0 && bounds[1] > 0; + final int w = known ? bounds[0] : FALLBACK_DISPLAY_SIZE; + final int h = known ? bounds[1] : FALLBACK_DISPLAY_SIZE; + return new int[] {0, 0, w, h}; + } + return n == out.length ? out : Arrays.copyOf(out, n); + } + + private static boolean isUsable(final double v) { + return v > 0 && !Double.isInfinite(v); + } + + private static boolean isFinite(final double v) { + return !Double.isNaN(v) && !Double.isInfinite(v); + } + + private static int toInt(final double v) { + return (int) Math.max(-LIMIT, Math.min(LIMIT, v)); } - /** Replaces the set for {@code display}. The buffer is copied. */ - void publish(final int display, @NonNull final int[] buffer) { - sets.put(display, buffer.clone()); + private static int clamp(final int v, final int max) { + return Math.max(0, Math.min(v, max)); } - /** For tests only: forgets every set. */ + /** For tests only: forgets everything. */ void clearForTests() { - sets.clear(); + synchronized (writeLock) { + state = new State(new TreeMap<>(), new HashMap<>(), new HashMap<>()); + } + synchronized (serveLock) { + served.clear(); + } + pullHook.set(null); + environment = UNKNOWN_ENVIRONMENT; } } diff --git a/android/src/main/java/com/bugsee/flutter/ViewScale.java b/android/src/main/java/com/bugsee/flutter/ViewScale.java new file mode 100644 index 0000000..aa2cd39 --- /dev/null +++ b/android/src/main/java/com/bugsee/flutter/ViewScale.java @@ -0,0 +1,38 @@ +package com.bugsee.flutter; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.List; + +/** The scale a view appears at on screen, from the view matrices up its ancestor chain. */ +final class ViewScale { + + private static final double TOLERANCE = 1e-4; + + private ViewScale() {} + + /** + * The product of the x and y scales of {@code matrices} (9 values each, as + * {@code Matrix.getValues}), or {@code null} when one has a skew or rotation + * term, a perspective term, or a non-positive scale: not a pure scale and + * translation, so the caller covers the whole display. + */ + @Nullable + static double[] accumulate(@NonNull final List matrices) { + double sx = 1; + double sy = 1; + for (final float[] m : matrices) { + // MSCALE_X 0, MSKEW_X 1, MSKEW_Y 3, MSCALE_Y 4, MPERSP_0..2 6..8 + final boolean pure = Math.abs(m[1]) < TOLERANCE && Math.abs(m[3]) < TOLERANCE + && Math.abs(m[6]) < TOLERANCE && Math.abs(m[7]) < TOLERANCE + && Math.abs(m[8] - 1) < TOLERANCE; + if (!pure || !(m[0] > 0) || !(m[4] > 0)) { + return null; + } + sx *= m[0]; + sy *= m[4]; + } + return new double[] {sx, sy}; + } +} diff --git a/android/src/test/java/com/bugsee/flutter/FlutterWrapperTest.java b/android/src/test/java/com/bugsee/flutter/FlutterWrapperTest.java index c51ad37..4c227cb 100644 --- a/android/src/test/java/com/bugsee/flutter/FlutterWrapperTest.java +++ b/android/src/test/java/com/bugsee/flutter/FlutterWrapperTest.java @@ -91,7 +91,8 @@ public void lifecycleEventsGoToTheHubAndNeverThrow() { final LifecycleHub hub = new LifecycleHub(Runnable::run); hub.attach(seen::add); final FlutterWrapper w = FlutterWrapper.create( - "flutter", "1", null, null, hub, new SecureRectangleStore()); + "flutter", "1", null, null, hub, + new SecureRectangleStore(SecureRectangleStore.UNKNOWN_ENVIRONMENT)); w.onLifecycleEvent("com.bugsee.lifecycle.Launched", null); w.onLifecycleEvent("unknown-future-event", new Object()); assertEquals(2, seen.size()); @@ -101,6 +102,30 @@ public void lifecycleEventsGoToTheHubAndNeverThrow() { FlutterWrapper.atProcessStart().onLifecycleEvent("x", null); } + private static final class FixedEnvironment implements SecureRectangleStore.Environment { + @Override + public double nativePixelRatio(final int display) { + return 3; + } + + @Override + public double unitsPerPhysicalPixel(final int display) { + return 1; + } + + @Override + public int[] displayBounds(final int display) { + return new int[] {1080, 2400}; + } + } + + private static void publishOnDisplay(final SecureRectangleStore store, final int display) { + store.setEnvironment(new FixedEnvironment()); + store.setPlacement(1, 0, SecureRectangleStore.Placement.known(display, 0, 0)); + store.publish(1, 1, 1, Collections.singletonMap(0, + new SecureRectangleStore.ViewRects(3, new double[] {10, 20, 30, 40}))); + } + /** * Workbook 5.4.2: the wrapper is replaced when Dart refines the identity; * what it serves must not live in it. @@ -110,12 +135,13 @@ public void replacingTheWrapperKeepsTheSecureRectangleSet() { final SecureRectangleStore store = SecureRectangleStore.shared(); store.clearForTests(); try { - final int[] set = {7, 1, 10, 20, 30, 40}; - store.publish(0, set); + publishOnDisplay(store, 0); + final int[] set = {1, 1, 30, 60, 90, 120}; final FlutterWrapper placeholder = FlutterWrapper.atProcessStart(); assertArrayEquals(set, placeholder.getSecureRectangles(0)); final FlutterWrapper refined = FlutterWrapper.create( "flutter", "1.0.0", null, Collections.singletonMap("flutter", "3.41.0")); + assertNotSame(placeholder, refined); assertArrayEquals(set, refined.getSecureRectangles(0)); // Other displays are unaffected. assertEquals(0, refined.getSecureRectangles(1)[1]); @@ -130,7 +156,7 @@ public void refiningTheIdentityReplacesTheWrapperAndKeepsTheSet() { final SecureRectangleStore store = SecureRectangleStore.shared(); store.clearForTests(); try { - store.publish(2, new int[] {9, 1, 1, 2, 3, 4}); + publishOnDisplay(store, 2); final List registered = new ArrayList<>(); FlutterWrapperInitProvider.registerEarly(registered::add); final Map ctx = new HashMap<>(); @@ -145,8 +171,16 @@ public void refiningTheIdentityReplacesTheWrapperAndKeepsTheSet() { assertEquals("1.2.3", second.getWrapperVersion()); assertEquals("dbg", second.getWrapperBuild()); assertEquals("3.41.0", second.getContext().get("flutter")); - assertArrayEquals(new int[] {9, 1, 1, 2, 3, 4}, first.getSecureRectangles(2)); - assertArrayEquals(new int[] {9, 1, 1, 2, 3, 4}, second.getSecureRectangles(2)); + final int[] set = {1, 1, 30, 60, 90, 120}; + assertArrayEquals(set, first.getSecureRectangles(2)); + assertArrayEquals(set, second.getSecureRectangles(2)); + // A change after the replacement reaches the SDK through the new wrapper, and + // moves the version it sees. + store.publish(1, 1, 2, Collections.singletonMap(0, + new SecureRectangleStore.ViewRects(3, new double[] {10, 20, 31, 40}))); + final int[] changed = second.getSecureRectangles(2); + assertEquals(2, changed[0]); + assertEquals(93, changed[4]); } finally { store.clearForTests(); } diff --git a/android/src/test/java/com/bugsee/flutter/PullThrottleTest.java b/android/src/test/java/com/bugsee/flutter/PullThrottleTest.java new file mode 100644 index 0000000..e7f9921 --- /dev/null +++ b/android/src/test/java/com/bugsee/flutter/PullThrottleTest.java @@ -0,0 +1,38 @@ +package com.bugsee.flutter; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class PullThrottleTest { + + private long now; + private int ran; + + private PullThrottle throttle() { + return new PullThrottle(() -> now, Runnable::run, () -> ran++); + } + + @Test + public void theFirstPullAsksAndLaterOnesWaitForTheInterval() { + final PullThrottle t = throttle(); + now = 1000; + t.request(); + assertEquals(1, ran); + now = 1000 + PullThrottle.MIN_INTERVAL_MS - 1; + t.request(); + assertEquals("inside the window", 1, ran); + now = 1000 + PullThrottle.MIN_INTERVAL_MS; + t.request(); + assertEquals("window passed", 2, ran); + } + + @Test + public void aFailingExecutorNeverEscapesTheSdksThread() { + final PullThrottle t = new PullThrottle(() -> now, r -> { + throw new IllegalStateException("looper gone"); + }, () -> ran++); + t.request(); + assertEquals(0, ran); + } +} diff --git a/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java b/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java index de16a0b..ea6c9bd 100644 --- a/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java +++ b/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java @@ -2,40 +2,807 @@ import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertTrue; + +import androidx.annotation.Nullable; import com.bugsee.library.contracts.internal.BugseeWrapper; +import org.junit.Before; import org.junit.Test; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import com.bugsee.flutter.SecureRectangleStore.Placement; + +/** + * Workbook Part 5 on Android: units, rounding, versions, ownership. The SDK's + * unit here is physical pixels in the space of View.getLocationOnScreen. + */ public class SecureRectangleStoreTest { + /** A device whose density and display size a test can change between pulls. */ + static final class FakeEnvironment implements SecureRectangleStore.Environment { + volatile double density = 3; + volatile int[] bounds = {1080, 2400}; + volatile int scaleReads; + + @Override + public double nativePixelRatio(final int display) { + scaleReads++; + return density; + } + + @Override + public double unitsPerPhysicalPixel(final int display) { + return 1; + } + + @Nullable + @Override + public int[] displayBounds(final int display) { + final int[] b = bounds; + return b == null ? null : b.clone(); + } + } + + private FakeEnvironment env; + private SecureRectangleStore store; + private long sequence; + + @Before + public void setUp() { + env = new FakeEnvironment(); + store = new SecureRectangleStore(env); + sequence = 0; + } + + /** What Dart publishes for the implicit view, measured at the display's current density. */ + private Map view(final double... ltrb) { + return viewAt(env.density, ltrb); + } + + private static Map viewAt( + final double ratio, final double... ltrb) { + return Collections.singletonMap(0, new SecureRectangleStore.ViewRects(ratio, ltrb)); + } + + /** Publishes {@code ltrb} for owner 1's implicit view, placed at the display origin. */ + private void publish(final double... ltrb) { + store.setPlacement(1, 0, Placement.known(0, 0, 0)); + assertTrue(store.publish(1, 1, ++sequence, view(ltrb))); + } + + // ---- empty ------------------------------------------------------------------- + + @Test + public void aDisplayThatNeverHadARectangleIsUnknownAndEmpty() { + assertArrayEquals(new int[] {BugseeWrapper.SECURE_RECTANGLES_VERSION_UNKNOWN, 0}, + store.snapshot(0)); + assertEquals(0, BugseeWrapper.SECURE_RECTANGLES_VERSION_UNKNOWN); + } + + @Test + public void afterARectangleVersionsAreRealAndAnEmptiedSetIsStillCounted() { + publish(1, 1, 2, 2); + final int[] full = store.snapshot(0); + assertEquals(1, full[1]); + assertNotEquals(0, full[0]); + assertTrue(store.publish(1, 1, ++sequence, Collections.emptyMap())); + final int[] empty = store.snapshot(0); + assertEquals(0, empty[1]); + assertNotEquals("emptying is a change", full[0], empty[0]); + assertNotEquals("a real version, not unknown", 0, empty[0]); + } + + // ---- unit and rounding (5.1.2, 5.3) -------------------------------------------- + + /** Scale 3, fractional edges: floor l/t and ceil r/b of the SCALED values. */ + @Test + public void scalesToPixelsThenRoundsOutward() { + publish(1.25, 2.25, 1.5, 2.5); + // 3.75 -> 3, 6.75 -> 6, 4.5 -> 5, 7.5 -> 8 + assertArrayEquals(new int[] {1, 3, 6, 5, 8}, withoutVersion(store.snapshot(0))); + } + + /** Rounding before scaling would give floor(1.25)*3=3 and ceil(1.5)*3=6. */ + @Test + public void roundsAfterScalingNotBefore() { + publish(1.25, 0, 1.5, 1); + final int[] s = store.snapshot(0); + assertEquals(3, s[2]); + assertEquals(5, s[4]); + } + + /** Round-to-nearest would give 3 and 3 (2.7, 3.3); outward is 2 and 4. */ + @Test + public void leftAndTopFloorRightAndBottomCeil() { + publish(0.9, 0.9, 1.1, 1.1); + // 2.7 -> 2, 3.3 -> 4 + assertArrayEquals(new int[] {1, 2, 2, 4, 4}, withoutVersion(store.snapshot(0))); + } + + @Test + public void rightAndBottomAreExclusiveSoExactEdgesAreNotWidened() { + publish(0, 0, 10, 20); // scale 3 -> 30 x 60 exactly + assertArrayEquals(new int[] {1, 0, 0, 30, 60}, withoutVersion(store.snapshot(0))); + } + + @Test + public void degenerateRectanglesAreDropped() { + publish(5, 5, 5, 10, // zero width + 5, 5, 10, 5, // zero height + 10, 10, 5, 20, // inverted + 1.25, 1, 1.25, 2, // zero width at a fractional edge: rounding outward must not make it 1px + 1, 1, 2, 2); + assertArrayEquals(new int[] {1, 3, 3, 6, 6}, withoutVersion(store.snapshot(0))); + } + + /** + * The density is read at every pull (5.3.2). A density change is applied natively before + * Dart lays out again, so what was measured at the old ratio is not scaled by the new one: + * the whole display is covered until Dart republishes at the new ratio. + */ + @Test + public void theDensityIsReadPerCallAndAMismatchCoversTheWholeDisplayUntilRepublished() { + publish(10, 10, 20, 20); + final int[] at3 = store.snapshot(0); + assertArrayEquals(new int[] {1, 30, 30, 60, 60}, withoutVersion(at3)); + env.density = 2; // the display changed; Dart has not laid out again + final int[] mismatch = store.snapshot(0); + assertArrayEquals("old logical rects x new density cover neither old nor new content", + new int[] {1, 0, 0, 1080, 2400}, withoutVersion(mismatch)); + assertNotEquals(at3[0], mismatch[0]); + // Dart republishes at the new ratio: placed by that ratio. + assertTrue(store.publish(1, 1, ++sequence, viewAt(2, 10, 10, 20, 20))); + final int[] at2 = store.snapshot(0); + assertArrayEquals(new int[] {1, 20, 20, 40, 40}, withoutVersion(at2)); + assertNotEquals(mismatch[0], at2[0]); + env.density = 2.625; + assertArrayEquals(new int[] {1, 0, 0, 1080, 2400}, withoutVersion(store.snapshot(0))); + assertTrue(store.publish(1, 1, ++sequence, viewAt(2.625, 10, 10, 20, 20))); + assertArrayEquals(new int[] {1, 26, 26, 53, 53}, withoutVersion(store.snapshot(0))); + assertTrue(env.scaleReads >= 4); + } + + @Test + public void theMessagesOwnRatioScalesItNotTheDensityAtPullTime() { + env.density = 2; + assertTrue(store.publish(1, 1, 1, viewAt(2, 10, 10, 20, 20))); + store.setPlacement(1, 0, Placement.known(0, 0, 0)); + assertArrayEquals(new int[] {1, 20, 20, 40, 40}, withoutVersion(store.snapshot(0))); + } + + @Test + public void aViewScaledOnScreenScalesTheRectanglesToo() { + store.setPlacement(1, 0, Placement.known(0, 100, 200, 0.9, 0.9)); + store.publish(1, 1, 1, view(10, 10, 20, 20)); + // 30*0.9 = 27 -> 27+100 = 127, 60*0.9 = 54 -> 154; y: 27+200, 54+200 + assertArrayEquals(new int[] {1, 127, 227, 154, 254}, withoutVersion(store.snapshot(0))); + store.setPlacement(1, 0, Placement.known(0, 100, 200, 1, 1)); + assertArrayEquals(new int[] {1, 130, 230, 160, 260}, withoutVersion(store.snapshot(0))); + } + + @Test + public void aViewScaleThatIsNotUsableCoversTheWholeDisplay() { + store.publish(1, 1, 1, view(10, 10, 20, 20)); + for (final double bad : new double[] {0, -1, Double.NaN}) { + store.setPlacement(1, 0, Placement.known(0, 0, 0, bad, 1)); + assertArrayEquals("scaleX " + bad, new int[] {1, 0, 0, 1080, 2400}, + withoutVersion(store.snapshot(0))); + store.setPlacement(1, 0, Placement.known(0, 0, 0, 1, bad)); + assertArrayEquals("scaleY " + bad, new int[] {1, 0, 0, 1080, 2400}, + withoutVersion(store.snapshot(0))); + } + } + + @Test + public void aSetWithNoScaleChangeKeepsItsVersionAcrossPulls() { + publish(10, 10, 20, 20); + final int v = store.snapshot(0)[0]; + for (int i = 0; i < 5; i++) { + assertEquals(v, store.snapshot(0)[0]); + } + } + + // ---- coordinate space --------------------------------------------------------- + + @Test + public void theViewsPositionOnTheScreenIsAdded() { + store.setPlacement(1, 0, Placement.known(0, 0, 66)); // below a status bar + store.publish(1, 1, 1, view(10, 10, 20, 20)); + assertArrayEquals(new int[] {1, 30, 96, 60, 126}, withoutVersion(store.snapshot(0))); + } + + @Test + public void movingTheViewMovesTheRectanglesAndTheVersion() { + store.setPlacement(1, 0, Placement.known(0, 0, 0)); + store.publish(1, 1, 1, view(10, 10, 20, 20)); + final int[] before = store.snapshot(0); + store.setPlacement(1, 0, Placement.known(0, 100, 0)); + final int[] after = store.snapshot(0); + assertArrayEquals(new int[] {1, 130, 30, 160, 60}, withoutVersion(after)); + assertNotEquals(before[0], after[0]); + } + + @Test + public void rectanglesAreClampedToTheDisplayAndOnesOffItAreDropped() { + publish(-10, -10, 5, 5, // overhangs the top-left + 300, 700, 400, 900, // 900*3 = 2700 > 2400, overhangs the bottom + 1000, 1000, 1100, 1100); // entirely off the display + assertArrayEquals(new int[] {2, 0, 0, 15, 15, 900, 2100, 1080, 2400}, + withoutVersion(store.snapshot(0))); + } + + // ---- fail closed ------------------------------------------------------------------ + + @Test + public void aViewWhosePlacementIsUnknownCoversTheWholeDisplay() { + store.publish(1, 1, 1, view(10, 10, 20, 20)); // no placement ever set + assertArrayEquals(new int[] {1, 0, 0, 1080, 2400}, withoutVersion(store.snapshot(0))); + store.setPlacement(1, 0, Placement.UNKNOWN); + assertArrayEquals(new int[] {1, 0, 0, 1080, 2400}, withoutVersion(store.snapshot(0))); + } + + @Test + public void aWholeDisplayCoverUsesTheFallbackSizeWhenTheDisplayIsUnknown() { + env.bounds = null; + store.publish(1, 1, 1, view(10, 10, 20, 20)); + assertArrayEquals( + new int[] {1, 0, 0, SecureRectangleStore.FALLBACK_DISPLAY_SIZE, + SecureRectangleStore.FALLBACK_DISPLAY_SIZE}, + withoutVersion(store.snapshot(0))); + } + + @Test + public void aDisplaySizeThatReadsAsZeroCoversWithTheFallbackSizeToo() { + env.bounds = new int[] {0, 0}; + store.publish(1, 1, 1, view(10, 10, 20, 20)); // no placement + assertArrayEquals( + new int[] {1, 0, 0, SecureRectangleStore.FALLBACK_DISPLAY_SIZE, + SecureRectangleStore.FALLBACK_DISPLAY_SIZE}, + withoutVersion(store.snapshot(0))); + } + + @Test + public void aViewKnownToBeOffScreenServesNothing() { + store.setPlacement(1, 0, Placement.ABSENT); + store.publish(1, 1, 1, view(10, 10, 20, 20)); + assertEquals(0, store.snapshot(0)[1]); + // and is covered again as soon as it is back and not placed yet + store.setPlacement(1, 0, Placement.UNKNOWN); + assertEquals(1, store.snapshot(0)[1]); + } + + @Test + public void aRectangleThatIsNotFiniteCoversTheWholeDisplay() { + publish(1, 1, 2, 2, Double.NaN, 0, 5, 5); + assertArrayEquals(new int[] {1, 0, 0, 1080, 2400}, withoutVersion(store.snapshot(0))); + publish(1, 1, 2, 2, 0, 0, Double.POSITIVE_INFINITY, 5); + assertArrayEquals(new int[] {1, 0, 0, 1080, 2400}, withoutVersion(store.snapshot(0))); + } + + @Test + public void aScaleThatIsNotUsableCoversTheWholeDisplay() { + publish(1, 1, 2, 2); + for (final double bad : new double[] {Double.NaN, 0, -1, Double.POSITIVE_INFINITY}) { + env.density = bad; + assertArrayEquals("scale " + bad, new int[] {1, 0, 0, 1080, 2400}, + withoutVersion(store.snapshot(0))); + } + } + + @Test + public void aMalformedViewListCoversTheWholeDisplay() { + store.setPlacement(1, 0, Placement.known(0, 0, 0)); + store.publish(1, 1, 1, view(1, 2, 3)); // not a multiple of four + assertArrayEquals(new int[] {1, 0, 0, 1080, 2400}, withoutVersion(store.snapshot(0))); + } + + @Test + public void aViewOtherThanTheImplicitOneIsNotPlacedSoItCoversTheDisplay() { + store.setPlacement(1, 0, Placement.known(0, 0, 0)); + store.publish(1, 1, 1, Collections.singletonMap(7, + new SecureRectangleStore.ViewRects(3, new double[] {1, 1, 2, 2}))); + assertArrayEquals(new int[] {1, 0, 0, 1080, 2400}, withoutVersion(store.snapshot(0))); + } + + // ---- versions (5.2) --------------------------------------------------------------- + + @Test + public void aNoOpWriteDoesNotMoveTheVersion() { + publish(1, 1, 2, 2); + final int v = store.snapshot(0)[0]; + for (int i = 0; i < 4; i++) { + publish(1, 1, 2, 2); // same set, newer sequence + assertEquals(v, store.snapshot(0)[0]); + } + // Setting the same placement again is no change either. + store.setPlacement(1, 0, Placement.known(0, 0, 0)); + assertEquals(v, store.snapshot(0)[0]); + } + @Test - public void emptyUntilSomethingIsPublished() { - final int[] s = new SecureRectangleStore().snapshot(0); - assertArrayEquals(new int[] {BugseeWrapper.SECURE_RECTANGLES_VERSION_UNKNOWN, 0}, s); + public void everyKindOfRealChangeMovesTheVersion() { + publish(1, 1, 2, 2); + int last = store.snapshot(0)[0]; + + publish(1, 1, 3, 2); // resize + int v = store.snapshot(0)[0]; + assertNotEquals("resize", last, v); + last = v; + + publish(2, 1, 4, 2); // move + v = store.snapshot(0)[0]; + assertNotEquals("move", last, v); + last = v; + + publish(2, 1, 4, 2, 10, 10, 11, 11); // add + v = store.snapshot(0)[0]; + assertNotEquals("add", last, v); + last = v; + + publish(2, 1, 4, 2); // remove one + v = store.snapshot(0)[0]; + assertNotEquals("remove", last, v); + last = v; + + store.setPlacement(1, 0, Placement.known(0, 1, 0)); // the view moved + v = store.snapshot(0)[0]; + assertNotEquals("view moved", last, v); + last = v; + + env.density = 2; // the density changed (mismatch: the whole display) + v = store.snapshot(0)[0]; + assertNotEquals("scale", last, v); + last = v; + + env.bounds = new int[] {6, 6}; // the display shrank under the rectangle + v = store.snapshot(0)[0]; + assertNotEquals("display size", last, v); } @Test - public void setsAreKeptPerDisplay() { - final SecureRectangleStore store = new SecureRectangleStore(); - store.publish(0, new int[] {1, 0}); - store.publish(1, new int[] {5, 1, 1, 1, 2, 2}); - assertEquals(1, store.snapshot(0)[0]); - assertEquals(5, store.snapshot(1)[0]); + public void aSetThatComesBackStillGetsANewVersion() { + publish(1, 1, 2, 2); + final int a = store.snapshot(0)[0]; + publish(5, 5, 6, 6); + final int b = store.snapshot(0)[0]; + publish(1, 1, 2, 2); + final int c = store.snapshot(0)[0]; + assertNotEquals(a, b); + assertNotEquals(b, c); + assertNotEquals("the SDK cached a by version; c must not look like a", a, c); } - /** 5.4.1: what the SDK reads is not what a caller can still mutate. */ @Test - public void snapshotsAreImmutableCopies() { - final SecureRectangleStore store = new SecureRectangleStore(); - final int[] mine = {3, 0}; - store.publish(0, mine); - mine[0] = 99; - assertEquals(3, store.snapshot(0)[0]); - final int[] read = store.snapshot(0); - read[0] = 77; - assertEquals(3, store.snapshot(0)[0]); + public void versionsNeverLandOnZero() { + assertEquals(1, SecureRectangleStore.advance(-1)); + assertEquals(Integer.MIN_VALUE, SecureRectangleStore.advance(Integer.MAX_VALUE)); + assertEquals(5, SecureRectangleStore.advance(4)); + } + + @Test + public void versionsArePerDisplay() { + store.setPlacement(1, 0, Placement.known(0, 0, 0)); + store.setPlacement(2, 0, Placement.known(1, 0, 0)); + store.publish(1, 1, 1, view(1, 1, 2, 2)); + store.publish(2, 1, 1, view(4, 4, 5, 5)); + final int d0 = store.snapshot(0)[0]; + final int d1 = store.snapshot(1)[0]; + // Reading one display again after the other changes nothing. + assertEquals(d0, store.snapshot(0)[0]); + assertEquals(d1, store.snapshot(1)[0]); + // A change on display 1 leaves display 0's version alone. + store.publish(2, 1, 2, view(1, 1, 5, 5)); + assertEquals(d0, store.snapshot(0)[0]); + assertNotEquals(d1, store.snapshot(1)[0]); + // And the other way round. + final int d1b = store.snapshot(1)[0]; + store.publish(1, 1, 2, view(1, 1, 9, 9)); + assertEquals(d1b, store.snapshot(1)[0]); + // A rectangle shows on its own display only. + assertEquals(1, store.snapshot(0)[1]); + assertEquals(1, store.snapshot(1)[1]); + assertEquals(0, store.snapshot(2)[1]); + } + + // ---- ordering ---------------------------------------------------------------------- + + @Test + public void aDelayedOlderMessageNeverOverwritesANewerOne() { + store.setPlacement(1, 0, Placement.known(0, 0, 0)); + assertTrue(store.publish(1, 5, 10, view(1, 1, 2, 2))); + assertFalse("older sequence", store.publish(1, 5, 9, view(7, 7, 8, 8))); + assertFalse("same sequence", store.publish(1, 5, 10, view(7, 7, 8, 8))); + assertArrayEquals(new int[] {1, 3, 3, 6, 6}, withoutVersion(store.snapshot(0))); + assertTrue("newer sequence", store.publish(1, 5, 11, view(2, 2, 3, 3))); + assertArrayEquals(new int[] {1, 6, 6, 9, 9}, withoutVersion(store.snapshot(0))); + } + + /** + * Epochs are random ids, not clocks: a new isolate's id may be numerically LOWER than the + * old one's (a wall clock that went backwards before a hot restart) and must still win, + * while a late message of the dead isolate must not. + */ + @Test + public void aNewIsolateWinsWhateverItsEpochIsAndTheOldOnesStragglersAreIgnored() { + store.setPlacement(1, 0, Placement.known(0, 0, 0)); + assertTrue(store.publish(1, 900, 500, view(1, 1, 2, 2))); + assertTrue("a hot restart: a LOWER epoch id, counter back at 1", + store.publish(1, 100, 1, view(2, 2, 3, 3))); + assertArrayEquals(new int[] {1, 6, 6, 9, 9}, withoutVersion(store.snapshot(0))); + assertFalse("a straggler of the dead isolate", store.publish(1, 900, 501, view(7, 7, 8, 8))); + assertArrayEquals(new int[] {1, 6, 6, 9, 9}, withoutVersion(store.snapshot(0))); + assertTrue(store.publish(1, 100, 2, view(3, 3, 4, 4))); + // and a third isolate retires the second + assertTrue(store.publish(1, 5, 1, view(1, 1, 2, 2))); + assertFalse(store.publish(1, 100, 3, view(7, 7, 8, 8))); + assertFalse(store.publish(1, 900, 502, view(7, 7, 8, 8))); + } + + @Test + public void theOrderingIsPerOwner() { + store.setPlacement(1, 0, Placement.known(0, 0, 0)); + store.setPlacement(2, 0, Placement.known(0, 0, 0)); + assertTrue(store.publish(1, 1, 50, view(1, 1, 2, 2))); + assertTrue("another engine has its own counter", store.publish(2, 1, 1, view(4, 4, 5, 5))); + assertEquals("both engines' rectangles are served", 2, store.snapshot(0)[1]); + } + + @Test + public void aPublishReplacesOnlyItsOwnersViews() { + store.setPlacement(1, 0, Placement.known(0, 0, 0)); + store.setPlacement(2, 0, Placement.known(0, 0, 0)); + store.publish(1, 1, 1, view(1, 1, 2, 2)); + store.publish(2, 1, 1, view(4, 4, 5, 5)); + store.publish(1, 1, 2, Collections.emptyMap()); + assertArrayEquals(new int[] {1, 12, 12, 15, 15}, withoutVersion(store.snapshot(0))); + } + + @Test + public void droppingAnOwnerClearsItsRectanglesAndOrdering() { + store.setPlacement(1, 0, Placement.known(0, 0, 0)); + store.publish(1, 9, 9, view(1, 1, 2, 2)); + store.dropOwner(1); + assertEquals(0, store.snapshot(0)[1]); + // A new engine given the same owner id starts fresh. + store.setPlacement(1, 0, Placement.known(0, 0, 0)); + assertTrue(store.publish(1, 1, 1, view(1, 1, 2, 2))); + assertEquals(1, store.snapshot(0)[1]); + } + + // ---- ownership (5.4.1) ---------------------------------------------------------------- + + @Test + public void snapshotsAreCopiesNobodyCanWriteThrough() { + final double[] mine = {1, 1, 2, 2}; + store.setPlacement(1, 0, Placement.known(0, 0, 0)); + store.publish(1, 1, 1, Collections.singletonMap(0, new SecureRectangleStore.ViewRects(3, mine))); + mine[0] = 50; // a later write by the caller must not reach what is served + final int[] first = store.snapshot(0); + assertEquals(3, first[2]); + first[2] = 999; // nor a write by the reader + assertEquals(3, store.snapshot(0)[2]); assertNotSame(store.snapshot(0), store.snapshot(0)); } + + /** + * The SDK pulls from its own thread while Dart keeps publishing: every buffer a + * reader gets must be one coherent published set, and one version must always mean + * one content. + */ + @Test + public void readersOnAnotherThreadSeeOnlyWholeSetsAndConsistentVersions() throws Exception { + env.density = 1; + store.setPlacement(1, 0, Placement.known(0, 0, 0)); + final AtomicBoolean stop = new AtomicBoolean(); + final AtomicReference failure = new AtomicReference<>(); + final Map contentByVersion = new java.util.concurrent.ConcurrentHashMap<>(); + final CountDownLatch started = new CountDownLatch(2); + + final Runnable reader = () -> { + started.countDown(); + try { + while (!stop.get()) { + final int[] s = store.snapshot(0); + final int n = s[1]; + if (s.length != 2 + 4 * n) { + throw new AssertionError("header and length disagree: " + s.length + " vs " + n); + } + // Every published set k is k rectangles (i, i, i + 100, i + 100), i < k. + for (int r = 0; r < n; r++) { + final int l = s[2 + 4 * r]; + if (s[3 + 4 * r] != l || s[4 + 4 * r] != l + 100 || s[5 + 4 * r] != l + 100) { + throw new AssertionError("torn rectangle " + r + " of " + n); + } + if (l != r) { + throw new AssertionError("rectangle " + r + " has l=" + l); + } + } + final String content = + java.util.Arrays.toString(java.util.Arrays.copyOfRange(s, 1, s.length)); + final String seen = contentByVersion.putIfAbsent(s[0], content); + if (seen != null && !seen.equals(content)) { + throw new AssertionError("version " + s[0] + " meant two sets"); + } + } + } catch (Throwable t) { + failure.compareAndSet(null, t); + } + }; + final Thread a = new Thread(reader); + final Thread b = new Thread(reader); + a.start(); + b.start(); + started.await(); + for (int i = 1; i <= 3000; i++) { + final int k = 1 + (i % 12); + final double[] set = new double[4 * k]; + for (int r = 0; r < k; r++) { + set[4 * r] = r; + set[4 * r + 1] = r; + set[4 * r + 2] = r + 100; + set[4 * r + 3] = r + 100; + } + store.publish(1, 1, i, viewAt(1, set)); + } + stop.set(true); + a.join(); + b.join(); + if (failure.get() != null) { + throw new AssertionError(failure.get()); + } + } + + // ---- the SDK's pull ------------------------------------------------------------------------ + + @Test + public void aPullCallsTheHookFirstAndSurvivesItThrowing() { + publish(1, 1, 2, 2); + final int[] calls = {0}; + store.setPullHook(() -> { + calls[0]++; + throw new IllegalStateException("hook failed"); + }); + assertEquals(1, store.pull(0)[1]); + assertEquals(1, calls[0]); + } + + // ---- a refresh the main thread never answers --------------------------------------------- + + private SecureRectangleStore pulledStore() { + env.density = 1; + final SecureRectangleStore g = new SecureRectangleStore(env, () -> now[0], 0); + g.setPlacement(1, 0, Placement.known(0, 0, 0)); + assertTrue(g.publish(1, 1, 1, viewAt(1, 10, 10, 20, 20))); + return g; + } + + @Test + public void aPlacementRefreshThatIsNeverAnsweredCoversTheWholeDisplayUntilItIs() { + final SecureRectangleStore g = pulledStore(); + g.setPullHook(g::refreshRequested); // posts to a main thread that is not answering + now[0] = 1000; + assertArrayEquals(new int[] {10, 10, 20, 20}, tail(g.pull(0))); + now[0] = 1000 + SecureRectangleStore.REFRESH_STALE_MS; + assertArrayEquals(new int[] {10, 10, 20, 20}, tail(g.pull(0))); + now[0] = 1000 + SecureRectangleStore.REFRESH_STALE_MS + 1; + assertArrayEquals("the placement may be stale: cover the display", + new int[] {0, 0, 1080, 2400}, tail(g.pull(0))); + g.refreshCompleted(); + assertArrayEquals(new int[] {10, 10, 20, 20}, tail(g.pull(0))); + } + + @Test + public void aPullTheThrottleDroppedLeavesNothingPending() { + final SecureRectangleStore g = pulledStore(); + final java.util.ArrayList posted = new java.util.ArrayList<>(); + final PullThrottle throttle = new PullThrottle(() -> now[0], task -> { + g.refreshRequested(); // as the plugin's executor does, only for a posted request + posted.add(task); + }, g::refreshCompleted); + g.setPullHook(throttle::request); + now[0] = 1000; + g.pull(0); + assertEquals(1, posted.size()); + posted.remove(0).run(); // the main thread answers + now[0] = 1050; + g.pull(0); // dropped by the throttle: nothing in flight + assertTrue(posted.isEmpty()); + // The SDK goes quiet, then pulls again: the main thread has not answered THIS one yet. + now[0] = 1050 + 2 * SecureRectangleStore.REFRESH_STALE_MS; + assertArrayEquals("healthy main thread: the rectangle, not the whole display", + new int[] {10, 10, 20, 20}, tail(g.pull(0))); + } + + @Test + public void aRefreshThatCompletesEachTimeNeverCovers_whateverTheRateOfThePulls() { + final SecureRectangleStore g = pulledStore(); + g.setPullHook(() -> { + g.refreshRequested(); + g.refreshCompleted(); + }); + for (final long interval : new long[] {100, 400, 5000}) { + for (int i = 0; i < 10; i++) { + now[0] += interval; + assertArrayEquals("interval " + interval, new int[] {10, 10, 20, 20}, + tail(g.pull(0))); + } + } + } + + @Test + public void withoutAPullHookThereIsNoRefreshToWaitFor() { + final SecureRectangleStore g = pulledStore(); + now[0] = 1000; + g.snapshot(0); + now[0] = 1000 + 10 * SecureRectangleStore.REFRESH_STALE_MS; + assertArrayEquals(new int[] {10, 10, 20, 20}, tail(g.snapshot(0))); + } + + private static int[] tail(final int[] buffer) { + return Arrays.copyOfRange(buffer, 2, buffer.length); + } + + + // ---- grace window --------------------------------------------------------------------------- + + private final long[] now = {1000}; + + private SecureRectangleStore graced() { + env.density = 1; + final SecureRectangleStore g = new SecureRectangleStore(env, () -> now[0], + SecureRectangleStore.GRACE_MS); + g.setPlacement(1, 0, Placement.known(0, 0, 0)); + return g; + } + + private long seq; + + private boolean put(final SecureRectangleStore g, final double... ltrb) { + return g.publish(1, 1, ++seq, viewAt(1, ltrb)); + } + + @Test + public void aMovedRegionIsCoveredWhereItWasToo_thenTheUnionShrinksAndMovesTheVersion() { + final SecureRectangleStore g = graced(); + put(g, 0, 0, 10, 10); + final int[] first = g.snapshot(0); + assertArrayEquals("a new region has no previous place: only itself", + new int[] {1, 0, 0, 10, 10}, withoutVersion(first)); + now[0] += 10; + put(g, 5, 0, 15, 10); + final int[] moved = g.snapshot(0); + assertArrayEquals("the new place AND the old one", + new int[] {2, 5, 0, 15, 10, 0, 0, 10, 10}, withoutVersion(moved)); + assertNotEquals(first[0], moved[0]); + now[0] += SecureRectangleStore.GRACE_MS - 1; + assertEquals("inside the window: same union, same version", moved[0], g.snapshot(0)[0]); + now[0] += 1; + final int[] shrunk = g.snapshot(0); + assertArrayEquals(new int[] {1, 5, 0, 15, 10}, withoutVersion(shrunk)); + assertNotEquals("shrinking is a change the SDK must re-read", moved[0], shrunk[0]); + assertEquals("and then it is stable", shrunk[0], g.snapshot(0)[0]); + } + + @Test + public void aRegionThatWentIsStillCoveredForTheWindow() { + final SecureRectangleStore g = graced(); + put(g, 0, 0, 10, 10); + g.snapshot(0); + now[0] += 10; + assertTrue(g.publish(1, 1, ++seq, Collections.emptyMap())); + final int[] gone = g.snapshot(0); + assertArrayEquals(new int[] {1, 0, 0, 10, 10}, withoutVersion(gone)); + now[0] += SecureRectangleStore.GRACE_MS; + final int[] empty = g.snapshot(0); + assertEquals(0, empty[1]); + assertNotEquals(gone[0], empty[0]); + } + + @Test + public void everyPastPlaceIsServedForAWindowFromWhenItStoppedBeingCurrent() { + final SecureRectangleStore g = graced(); + put(g, 0, 0, 10, 10); // A + g.snapshot(0); + now[0] += 10; + put(g, 5, 0, 15, 10); // B: A stops at +10, served until +130 + g.snapshot(0); + now[0] += 40; + put(g, 10, 0, 20, 10); // C: B stops at +50, served until +170 + assertEquals("current + both earlier places", 3, g.snapshot(0)[1]); + now[0] = 1000 + 129; + assertEquals(3, g.snapshot(0)[1]); + now[0] = 1000 + 130; + assertEquals("A has had its window", 2, g.snapshot(0)[1]); + now[0] = 1000 + 169; + assertEquals(2, g.snapshot(0)[1]); + now[0] = 1000 + 170; + assertEquals("B has had its window", 1, g.snapshot(0)[1]); + } + + /** A long movement must not widen the cover to its whole path. */ + @Test + public void aLongMovementKeepsOnlyTheLastWindowsWorthOfPlaces() { + final SecureRectangleStore g = graced(); + int max = 0; + for (int i = 0; i < 60; i++) { + put(g, i * 10, 0, i * 10 + 10, 10); + max = Math.max(max, g.snapshot(0)[1]); + now[0] += 80; // slower than a frame, like a few pulls a second + } + assertTrue("at most the current and the previous place, got " + max, max <= 3); + } + + @Test + public void tooManyPreviousRectanglesCollapseToTheirBoundingBox() { + final SecureRectangleStore g = graced(); + final double[] many = new double[4 * 70]; + for (int i = 0; i < 70; i++) { + many[4 * i] = i; + many[4 * i + 1] = 0; + many[4 * i + 2] = i + 1; + many[4 * i + 3] = 5; + } + put(g, many); + g.snapshot(0); + now[0] += 10; + put(g, 500, 500, 510, 510); + final int[] s = g.snapshot(0); + assertArrayEquals("the new one, then ONE box over the 70 old ones", + new int[] {2, 500, 500, 510, 510, 0, 0, 70, 5}, withoutVersion(s)); + } + + @Test + public void afterALongWalkTooManyPastRectanglesStillCoverTheLastStepToo() { + final SecureRectangleStore g = graced(); + for (int i = 0; i < 60; i++) { + put(g, i * 10, 0, i * 10 + 10, 10); + g.snapshot(0); + now[0] += 80; + } + final double[] many = new double[4 * 70]; + for (int i = 0; i < 70; i++) { + many[4 * i] = i; + many[4 * i + 2] = i + 1; + many[4 * i + 3] = 5; + } + now[0] += 1000; + put(g, many); + g.snapshot(0); + now[0] += 10; + put(g, 300, 300, 310, 310); + // the walk's last step stopped being current only 10 ms ago, so it is covered too + assertArrayEquals(new int[] {3, 300, 300, 310, 310, 0, 0, 70, 5, 590, 0, 600, 10}, + withoutVersion(g.snapshot(0))); + } + + @Test + public void aStaticSetNeverGrowsAWindow() { + final SecureRectangleStore g = graced(); + put(g, 0, 0, 10, 10); + final int v = g.snapshot(0)[0]; + for (int i = 0; i < 5; i++) { + now[0] += 500; + put(g, 0, 0, 10, 10); + assertEquals(v, g.snapshot(0)[0]); + } + } + + private static int[] withoutVersion(final int[] buffer) { + // [count, l, t, r, b, ...]: drops the version, which has its own tests. + final int[] out = new int[buffer.length - 1]; + System.arraycopy(buffer, 1, out, 0, out.length); + return out; + } } diff --git a/android/src/test/java/com/bugsee/flutter/ViewScaleTest.java b/android/src/test/java/com/bugsee/flutter/ViewScaleTest.java new file mode 100644 index 0000000..2beff4b --- /dev/null +++ b/android/src/test/java/com/bugsee/flutter/ViewScaleTest.java @@ -0,0 +1,41 @@ +package com.bugsee.flutter; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +import java.util.Arrays; + +public class ViewScaleTest { + + private static float[] m(final float sx, final float sy, final float skewX, final float skewY) { + return new float[] {sx, skewX, 0, skewY, sy, 0, 0, 0, 1}; + } + + @Test + public void identityIsScaleOne() { + assertArrayEquals(new double[] {1, 1}, + ViewScale.accumulate(Arrays.asList(m(1, 1, 0, 0), m(1, 1, 0, 0))), 1e-9); + } + + @Test + public void scalesMultiplyUpTheAncestorChain() { + assertArrayEquals(new double[] {0.45, 0.9}, + ViewScale.accumulate(Arrays.asList(m(0.9f, 1, 0, 0), m(0.5f, 0.9f, 0, 0))), 1e-6); + } + + @Test + public void aRotationOrSkewIsNotAScaleAndCoversTheWholeDisplay() { + assertNull(ViewScale.accumulate(Arrays.asList(m(1, 1, 0.1f, -0.1f)))); + assertNull(ViewScale.accumulate(Arrays.asList(m(1, 1, 0, 0), m(1, 1, 0, 0.05f)))); + } + + @Test + public void aPerspectiveTermOrANonPositiveScaleIsNotUsable() { + final float[] perspective = {1, 0, 0, 0, 1, 0, 0.001f, 0, 1}; + assertNull(ViewScale.accumulate(Arrays.asList(perspective))); + assertNull(ViewScale.accumulate(Arrays.asList(m(0, 1, 0, 0)))); + assertNull(ViewScale.accumulate(Arrays.asList(m(-1, 1, 0, 0)))); + } +} diff --git a/ios/bugsee_flutter/Sources/bugsee_flutter/BugseeFlutterPlugin.swift b/ios/bugsee_flutter/Sources/bugsee_flutter/BugseeFlutterPlugin.swift index 27bfb2e..7358162 100644 --- a/ios/bugsee_flutter/Sources/bugsee_flutter/BugseeFlutterPlugin.swift +++ b/ios/bugsee_flutter/Sources/bugsee_flutter/BugseeFlutterPlugin.swift @@ -18,9 +18,31 @@ public class BugseeFlutterPlugin: NSObject, FlutterPlugin, BugseeBridgeHostApi { private var pumpTask: Task? private var wake: AsyncStream.Continuation? - init(messenger: FlutterBinaryMessenger) { + // Secure rectangles. One owner id per engine identifies its Dart isolate in the + // process-wide store. + private static let ownerLock = NSLock() + private static var lastOwner: Int64 = 0 + private static func newOwner() -> Int64 { + ownerLock.lock() + defer { ownerLock.unlock() } + lastOwner += 1 + return lastOwner + } + private static let environment = BGSFlutterUIKitEnvironment() + private let owner: Int64 + private var tracker: BGSFlutterSecurePlacementTracker? + + init(messenger: FlutterBinaryMessenger, registrar: FlutterPluginRegistrar) { self.messenger = messenger + self.owner = BugseeFlutterPlugin.newOwner() super.init() + let store = BGSFlutterSecureRectangleStore.shared() + store.setEnvironment(BugseeFlutterPlugin.environment) + // The registrar is weak: it retains this instance (published below). + tracker = BGSFlutterSecurePlacementTracker( + owner: owner, store: store, environment: BugseeFlutterPlugin.environment, + viewProvider: { [weak registrar] in registrar?.viewController?.view }) + tracker?.refresh() } public static func register(with registrar: FlutterPluginRegistrar) { @@ -28,7 +50,7 @@ public class BugseeFlutterPlugin: NSObject, FlutterPlugin, BugseeBridgeHostApi { // `+load` out of the static archive. BGSFlutterWrapperRegistrar.ensureRegistered() - let instance = BugseeFlutterPlugin(messenger: registrar.messenger()) + let instance = BugseeFlutterPlugin(messenger: registrar.messenger(), registrar: registrar) BugseeBridgeHostApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance) // Keeps the instance alive and gets `detachFromEngine(for:)`. registrar.publish(instance) @@ -37,6 +59,10 @@ public class BugseeFlutterPlugin: NSObject, FlutterPlugin, BugseeBridgeHostApi { public func detachFromEngine(for registrar: FlutterPluginRegistrar) { BugseeBridgeHostApiSetup.setUp(binaryMessenger: registrar.messenger(), api: nil) stopForwarding() + tracker?.detach() + tracker = nil + // The engine's UI is gone; there is nothing left of it to hide. + BGSFlutterSecureRectangleStore.shared().dropOwner(owner) } func attachLifecycle() throws { @@ -96,4 +122,21 @@ public class BugseeFlutterPlugin: NSObject, FlutterPlugin, BugseeBridgeHostApi { context: identity.context ) } + + func setSecureRectangles(state: SecureRectanglesMessage) throws -> Bool { + var views: [NSNumber: BGSFlutterSecureViewRects] = [:] + for view in state.views { + views[NSNumber(value: view.viewId)] = BGSFlutterSecureViewRects( + ratio: view.devicePixelRatio, + rects: view.rectangles.withUnsafeBufferPointer { Data(buffer: $0) }) + } + let applied = BGSFlutterSecureRectangleStore.shared().publish( + forOwner: owner, epoch: state.epoch, sequence: state.sequence, views: views) + if applied { + // Dart just measured, so look at where the view is now rather than trusting what an + // earlier read saw. + tracker?.refresh() + } + return applied + } } diff --git a/ios/bugsee_flutter/Sources/bugsee_flutter/Messages.g.swift b/ios/bugsee_flutter/Sources/bugsee_flutter/Messages.g.swift index 51510ce..6af4973 100644 --- a/ios/bugsee_flutter/Sources/bugsee_flutter/Messages.g.swift +++ b/ios/bugsee_flutter/Sources/bugsee_flutter/Messages.g.swift @@ -292,6 +292,118 @@ struct WrapperIdentityMessage: Hashable, CustomStringConvertible { } } +/// The rectangles of one Flutter view, flat `[l, t, r, b, l, t, r, b, ...]`, in +/// the view's own LOGICAL pixels (Flutter logical pixels: iOS points, Android +/// dp), origin at the view's top-left, right/bottom exclusive. Native converts +/// them to the SDK's unit and places the view on the screen. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct SecureViewRectanglesMessage: Hashable, CustomStringConvertible { + /// `FlutterView.viewId`. Mobile engines have only the implicit view (0). + var viewId: Int64 + /// `FlutterView.devicePixelRatio` the rectangles were measured with. Native + /// scales by THIS, not by what the display reports now: a density change is + /// applied natively before Dart has laid out again, so rectangles measured at + /// the old ratio must not be scaled by the new one. When the two differ, + /// native covers the whole display until Dart republishes. + var devicePixelRatio: Double + var rectangles: [Double] + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> SecureViewRectanglesMessage? { + let viewId = pigeonVar_list[0] as! Int64 + let devicePixelRatio = pigeonVar_list[1] as! Double + let rectangles = pigeonVar_list[2] as! [Double] + + return SecureViewRectanglesMessage( + viewId: viewId, + devicePixelRatio: devicePixelRatio, + rectangles: rectangles + ) + } + func toList() -> [Any?] { + return [ + viewId, + devicePixelRatio, + rectangles, + ] + } + static func == (lhs: SecureViewRectanglesMessage, rhs: SecureViewRectanglesMessage) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return MessagesPigeonInternal.deepEquals(lhs.viewId, rhs.viewId) && MessagesPigeonInternal.deepEquals(lhs.devicePixelRatio, rhs.devicePixelRatio) && MessagesPigeonInternal.deepEquals(lhs.rectangles, rhs.rectangles) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("SecureViewRectanglesMessage") + MessagesPigeonInternal.deepHash(value: viewId, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: devicePixelRatio, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: rectangles, hasher: &hasher) + } + + public var description: String { + return "SecureViewRectanglesMessage(viewId: \(String(describing: viewId)), devicePixelRatio: \(String(describing: devicePixelRatio)), rectangles: \(String(describing: rectangles)))" + } +} + +/// The COMPLETE set of secure rectangles of this Dart isolate: a view that is +/// absent from [views] has none. Whole-state, not a delta, so a lost or late +/// message can never leave a rectangle behind. +/// +/// Ordering: within one `epoch` native ignores a message whose `sequence` is +/// not newer than the newest it accepted. `epoch` is a random id of the Dart +/// isolate (never a clock: a clock can go backwards). A message with an epoch +/// native has not seen starts a new isolate (a hot restart; its counter restarts +/// at 1) and replaces the old one's state; a message of an epoch native has +/// already retired is a late straggler of a dead isolate and is ignored. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct SecureRectanglesMessage: Hashable, CustomStringConvertible { + var epoch: Int64 + var sequence: Int64 + var views: [SecureViewRectanglesMessage] + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> SecureRectanglesMessage? { + let epoch = pigeonVar_list[0] as! Int64 + let sequence = pigeonVar_list[1] as! Int64 + let views = pigeonVar_list[2] as! [SecureViewRectanglesMessage] + + return SecureRectanglesMessage( + epoch: epoch, + sequence: sequence, + views: views + ) + } + func toList() -> [Any?] { + return [ + epoch, + sequence, + views, + ] + } + static func == (lhs: SecureRectanglesMessage, rhs: SecureRectanglesMessage) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return MessagesPigeonInternal.deepEquals(lhs.epoch, rhs.epoch) && MessagesPigeonInternal.deepEquals(lhs.sequence, rhs.sequence) && MessagesPigeonInternal.deepEquals(lhs.views, rhs.views) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("SecureRectanglesMessage") + MessagesPigeonInternal.deepHash(value: epoch, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: sequence, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: views, hasher: &hasher) + } + + public var description: String { + return "SecureRectanglesMessage(epoch: \(String(describing: epoch)), sequence: \(String(describing: sequence)), views: \(String(describing: views)))" + } +} + private class MessagesPigeonCodecReader: FlutterStandardReader { override func readValue(ofType type: UInt8) -> Any? { switch type { @@ -299,6 +411,10 @@ private class MessagesPigeonCodecReader: FlutterStandardReader { return LifecycleEventMessage.fromList(self.readValue() as! [Any?]) case 130: return WrapperIdentityMessage.fromList(self.readValue() as! [Any?]) + case 131: + return SecureViewRectanglesMessage.fromList(self.readValue() as! [Any?]) + case 132: + return SecureRectanglesMessage.fromList(self.readValue() as! [Any?]) default: return super.readValue(ofType: type) } @@ -313,6 +429,12 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter { } else if let value = value as? WrapperIdentityMessage { super.writeByte(130) super.writeValue(value.toList()) + } else if let value = value as? SecureViewRectanglesMessage { + super.writeByte(131) + super.writeValue(value.toList()) + } else if let value = value as? SecureRectanglesMessage { + super.writeByte(132) + super.writeValue(value.toList()) } else { super.writeValue(value) } @@ -343,6 +465,11 @@ protocol BugseeBridgeHostApi { /// Replaces the natively registered placeholder wrapper with the full /// identity. Never throws on malformed content: facts become "unknown". func setWrapperIdentity(identity: WrapperIdentityMessage) throws + /// Publishes the secure rectangles (see [SecureRectanglesMessage]). Never + /// throws on content: an unusable value fails closed natively. Returns + /// whether the message was applied (`false`: it was older than one already + /// applied, which is not an error). + func setSecureRectangles(state: SecureRectanglesMessage) throws -> Bool } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -383,6 +510,25 @@ class BugseeBridgeHostApiSetup { } else { setWrapperIdentityChannel.setMessageHandler(nil) } + /// Publishes the secure rectangles (see [SecureRectanglesMessage]). Never + /// throws on content: an unusable value fails closed natively. Returns + /// whether the message was applied (`false`: it was older than one already + /// applied, which is not an error). + let setSecureRectanglesChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.setSecureRectangles\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + setSecureRectanglesChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let stateArg = args[0] as! SecureRectanglesMessage + do { + let result = try api.setSecureRectangles(state: stateArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + setSecureRectanglesChannel.setMessageHandler(nil) + } } } diff --git a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecurePlacementTracker.m b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecurePlacementTracker.m new file mode 100644 index 0000000..56d2f47 --- /dev/null +++ b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecurePlacementTracker.m @@ -0,0 +1,199 @@ +#import "BGSFlutterSecurePlacementTracker.h" + +#import + +#include + +/// The only display the SDK polls on iOS (`BGSDefaultDisplayId`). +static const NSInteger kDefaultDisplay = 0; +/// The implicit view: the only one a mobile engine has. +static const int64_t kImplicitViewId = 0; +/// The fastest an off-main pull asks the main thread for a refresh. +static const CFTimeInterval kMinPullRefreshInterval = 0.1; + +#pragma mark - Environment + +@implementation BGSFlutterUIKitEnvironment { + NSObject *_lock; + __weak UIScreen *_screen; +} + +- (instancetype)init { + if ((self = [super init])) { + _lock = [[NSObject alloc] init]; + } + return self; +} + +- (void)updateWithScreen:(UIScreen *)screen { + @synchronized(_lock) { + _screen = screen; + } +} + +- (UIScreen *)currentScreen { + UIScreen *screen; + @synchronized(_lock) { + screen = _screen; + } +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + return screen ?: [UIScreen mainScreen]; +#pragma clang diagnostic pop +} + +- (double)nativePixelRatioForDisplay:(NSInteger)display { + // Read per call: the screen's scale can change (display zoom, an external display). + return [self currentScreen].scale; +} + +- (double)unitsPerPhysicalPixelForDisplay:(NSInteger)display { + // The SDK takes points. A Flutter logical pixel is physical / ratio, a point is + // physical / scale: with ratio == scale (the store checks) a logical pixel is one point, and + // the screen's scale is divided out, never multiplied in. + double scale = [self currentScreen].scale; + return scale > 0 ? 1.0 / scale : NAN; +} + +- (CGSize)displayBoundsInPointsForDisplay:(NSInteger)display { + return [self currentScreen].bounds.size; +} + +@end + +#pragma mark - Tracker + +/// Whether the view's corners map to the screen by a positive scale and a translation only (no +/// rotation, skew or flip): `o` is its origin on screen, `px` its top-right and `py` its +/// bottom-left, `size` its bounds. On success the scale is returned in `sx`/`sy`: a Flutter view +/// shrunk by a transform of its own or an ancestor's (a page-sheet presentation) must scale its +/// rectangles too, or they land on the wrong pixels. +static BOOL BGSPureScale(CGPoint o, CGPoint px, CGPoint py, CGSize size, double *sx, double *sy) { + if (!(size.width > 0) || !(size.height > 0)) return NO; + double ax = (px.x - o.x) / size.width, ay = (px.y - o.y) / size.width; + double bx = (py.x - o.x) / size.height, by = (py.y - o.y) / size.height; + const double tolerance = 1e-4; + if (!isfinite(ax) || !isfinite(ay) || !isfinite(bx) || !isfinite(by)) return NO; + if (fabs(ay) > tolerance || fabs(bx) > tolerance) return NO; + if (!(ax > 0) || !(by > 0)) return NO; + *sx = ax; + *sy = by; + return YES; +} + + +static NSHashTable *BGSTrackers(void) { + static NSHashTable *trackers; + static dispatch_once_t once; + dispatch_once(&once, ^{ + trackers = [NSHashTable weakObjectsHashTable]; + }); + return trackers; +} + +@implementation BGSFlutterSecurePlacementTracker { + int64_t _owner; + BGSFlutterSecureRectangleStore *_store; + BGSFlutterUIKitEnvironment *_environment; + UIView *_Nullable (^_viewProvider)(void); + BOOL _detached; +} + +- (instancetype)initWithOwner:(int64_t)owner + store:(BGSFlutterSecureRectangleStore *)store + environment:(BGSFlutterUIKitEnvironment *)environment + viewProvider:(UIView *_Nullable (^)(void))viewProvider { + if ((self = [super init])) { + _owner = owner; + _store = store; + _environment = environment; + _viewProvider = [viewProvider copy]; + @synchronized(BGSTrackers()) { + [BGSTrackers() addObject:self]; + } + // The pull asks every live tracker for a refresh. + [store setPullHook:^{ + [BGSFlutterSecurePlacementTracker refreshAllFromPull]; + }]; + } + return self; +} + +- (void)refresh { + if (_detached) return; + @try { + UIView *view = _viewProvider(); + if (view == nil) { + [self place:[BGSFlutterSecurePlacement unknown]]; + return; + } + UIWindow *window = view.window; + if (window == nil) { + [self place:[BGSFlutterSecurePlacement absent]]; + return; + } + UIScreen *screen = window.screen; + id space = screen.coordinateSpace; + CGRect bounds = view.bounds; + CGPoint o = [view convertPoint:CGPointZero toCoordinateSpace:space]; + CGPoint px = [view convertPoint:CGPointMake(CGRectGetMaxX(bounds), 0) toCoordinateSpace:space]; + CGPoint py = [view convertPoint:CGPointMake(0, CGRectGetMaxY(bounds)) toCoordinateSpace:space]; + [_environment updateWithScreen:screen]; + double sx = 0, sy = 0; + if (!BGSPureScale(o, px, py, bounds.size, &sx, &sy)) { + // Rotated, skewed, flipped or degenerate: not a scale and a translation. + [self place:[BGSFlutterSecurePlacement unknown]]; + return; + } + [self place:[BGSFlutterSecurePlacement knownOnDisplay:kDefaultDisplay + x:o.x + y:o.y + scaleX:sx + scaleY:sy]]; + } @catch (id exception) { + [self place:[BGSFlutterSecurePlacement unknown]]; + } +} + +- (void)place:(BGSFlutterSecurePlacement *)placement { + [_store setPlacement:placement forOwner:_owner viewId:kImplicitViewId]; +} + +- (void)detach { + _detached = YES; + @synchronized(BGSTrackers()) { + [BGSTrackers() removeObject:self]; + } +} + ++ (void)refreshAllFromPull { + @try { + static _Atomic(CFTimeInterval) lastAsk = 0; + if ([NSThread isMainThread]) { + // The SDK pulls on the main thread today: read the view now, so the answer is exact. + [self refreshAllNow]; + return; + } + CFTimeInterval now = CACurrentMediaTime(); + CFTimeInterval last = lastAsk; + if (last != 0 && now - last < kMinPullRefreshInterval) return; + lastAsk = now; + dispatch_async(dispatch_get_main_queue(), ^{ + [self refreshAllNow]; + }); + } @catch (id exception) { + // The pull is answered regardless. + } +} + ++ (void)refreshAllNow { + NSArray *all; + @synchronized(BGSTrackers()) { + all = BGSTrackers().allObjects; + } + for (BGSFlutterSecurePlacementTracker *tracker in all) { + [tracker refresh]; + } +} + +@end diff --git a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecureRectangleStore.m b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecureRectangleStore.m index e00e87c..23aea6c 100644 --- a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecureRectangleStore.m +++ b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecureRectangleStore.m @@ -1,47 +1,543 @@ #import "BGSFlutterSecureRectangleStore.h" +#import + +#include + +/// Version meaning "unknown" (`BGSSecureRectanglesVersionUnknown` in the SDK's BGSContracts.h, +/// not imported here so the store needs no SDK). +static const int32_t kUnknownVersion = 0; +static const NSInteger kHeaderInts = 2; +/// A whole-display rectangle's size when the display's is not known. +static const int32_t kFallbackDisplaySize = 16384; +/// Largest magnitude served: keeps the SDK's own arithmetic away from overflow. +static const double kLimit = 268435456.0; // 2^28 +/// How long each past set is served after it stopped being current. Repeated in Java (GRACE_MS). +static const NSTimeInterval kGraceSeconds = 0.12; +/// More rectangles than this and the past sets still being served are collapsed to their bounding box. +static const NSUInteger kMaxRectangles = 64; +/// How far a message's ratio may differ from the screen's (floating-point noise). +static const double kRatioTolerance = 1e-3; +/// Epochs remembered as retired, so a straggler of a dead isolate is ignored. +static const NSUInteger kRetiredEpochs = 32; + +#pragma mark - Placement + +@implementation BGSFlutterSecureViewRects +- (instancetype)initWithRatio:(double)ratio rects:(NSData *)rects { + if ((self = [super init])) { + _ratio = ratio; + _rects = [rects copy]; + } + return self; +} +@end + +@implementation BGSFlutterSecurePlacement +- (instancetype)initWithKind:(BGSFlutterPlacementKind)kind + display:(NSInteger)display + x:(double)x + y:(double)y + scaleX:(double)scaleX + scaleY:(double)scaleY { + if ((self = [super init])) { + _kind = kind; + _display = display; + _x = x; + _y = y; + _scaleX = scaleX; + _scaleY = scaleY; + } + return self; +} ++ (instancetype)knownOnDisplay:(NSInteger)display x:(double)x y:(double)y { + return [self knownOnDisplay:display x:x y:y scaleX:1 scaleY:1]; +} ++ (instancetype)knownOnDisplay:(NSInteger)display + x:(double)x + y:(double)y + scaleX:(double)scaleX + scaleY:(double)scaleY { + return [[self alloc] initWithKind:BGSFlutterPlacementKnown + display:display + x:x + y:y + scaleX:scaleX + scaleY:scaleY]; +} ++ (instancetype)unknown { + return [[self alloc] initWithKind:BGSFlutterPlacementUnknown display:0 x:0 y:0 scaleX:1 scaleY:1]; +} ++ (instancetype)absent { + return [[self alloc] initWithKind:BGSFlutterPlacementAbsent display:0 x:0 y:0 scaleX:1 scaleY:1]; +} +- (BOOL)isEqual:(id)object { + if (![object isKindOfClass:[BGSFlutterSecurePlacement class]]) return NO; + BGSFlutterSecurePlacement *other = object; + return _kind == other.kind && _display == other.display && _x == other.x && _y == other.y && + _scaleX == other.scaleX && _scaleY == other.scaleY; +} +- (NSUInteger)hash { + return (NSUInteger)_kind ^ (NSUInteger)_display ^ (NSUInteger) @(_x).hash ^ (NSUInteger) @(_y).hash; +} +@end + +#pragma mark - Unknown environment + +@interface BGSFlutterUnknownEnvironment : NSObject +@end +@implementation BGSFlutterUnknownEnvironment +- (double)nativePixelRatioForDisplay:(NSInteger)display { + return NAN; +} +- (double)unitsPerPhysicalPixelForDisplay:(NSInteger)display { + return 1; +} +- (CGSize)displayBoundsInPointsForDisplay:(NSInteger)display { + return CGSizeZero; +} +@end + +#pragma mark - Immutable state + +/// One Flutter view of one Dart isolate. +@interface BGSFlutterLane : NSObject +@property(nonatomic, readonly) int64_t owner; +@property(nonatomic, readonly) int64_t viewId; +/// The ratio Dart measured the rectangles at. +@property(nonatomic, readonly) double ratio; +/// Packed native-endian doubles, flat ltrb, logical pixels, as Dart sent them. +@property(nonatomic, readonly, copy) NSData *rects; +@end +@implementation BGSFlutterLane +- (instancetype)initWithOwner:(int64_t)owner viewId:(int64_t)viewId ratio:(double)ratio rects:(NSData *)rects { + if ((self = [super init])) { + _owner = owner; + _viewId = viewId; + _ratio = ratio; + _rects = [rects copy]; + } + return self; +} +- (NSString *)key { + return [BGSFlutterLane keyForOwner:_owner viewId:_viewId]; +} ++ (NSString *)keyForOwner:(int64_t)owner viewId:(int64_t)viewId { + return [NSString stringWithFormat:@"%lld:%lld", owner, viewId]; +} +@end + +@interface BGSFlutterSecureState : NSObject +/// Sorted by (owner, viewId). +@property(nonatomic, readonly) NSArray *lanes; +@property(nonatomic, readonly) NSDictionary *placements; +/// owner -> @[epoch, sequence, retired epochs] of the newest applied publish. +@property(nonatomic, readonly) NSDictionary *order; +@end +@implementation BGSFlutterSecureState +- (instancetype)initWithLanes:(NSArray *)lanes placements:(NSDictionary *)placements order:(NSDictionary *)order { + if ((self = [super init])) { + _lanes = [lanes copy]; + _placements = [placements copy]; + _order = [order copy]; + } + return self; +} +@end + +/// A set that stopped being current, and until when it is still served. +@interface BGSFlutterPast : NSObject +@property(nonatomic, readonly, copy) NSData *quads; +@property(nonatomic, readonly) NSTimeInterval until; +@end +@implementation BGSFlutterPast +- (instancetype)initWithQuads:(NSData *)quads until:(NSTimeInterval)until { + if ((self = [super init])) { + _quads = [quads copy]; + _until = until; + } + return self; +} +@end + +/// What a display last served. +@interface BGSFlutterServed : NSObject +@property(nonatomic, readonly) int32_t version; +/// What is served: the current set plus every past set still inside its window. +@property(nonatomic, readonly, copy) NSData *quads; +@property(nonatomic, readonly, copy) NSData *current; +/// Newest first. Each is served until the grace period after it stopped being current. +@property(nonatomic, readonly, copy) NSArray *past; +@property(nonatomic, readonly, copy) NSData *buffer; +@end +@implementation BGSFlutterServed +- (instancetype)initWithVersion:(int32_t)version + quads:(NSData *)quads + current:(NSData *)current + past:(NSArray *)past { + if ((self = [super init])) { + _version = version; + _quads = [quads copy]; + _current = [current copy]; + _past = [past copy]; + NSMutableData *buffer = [NSMutableData dataWithCapacity:(kHeaderInts * 4) + quads.length]; + int32_t header[2] = {version, (int32_t)(quads.length / (4 * sizeof(int32_t)))}; + [buffer appendBytes:header length:sizeof(header)]; + [buffer appendData:quads]; + _buffer = [buffer copy]; // immutable: safe to hand to any thread + } + return self; +} +@end + +#pragma mark - Store + @implementation BGSFlutterSecureRectangleStore { - NSMutableDictionary *_sets; + id _environment; + NSTimeInterval (^_clock)(void); + NSTimeInterval _grace; + BGSFlutterSecureState *_state; + NSMutableDictionary *_served; + void (^_pullHook)(void); + NSObject *_writeLock; + NSObject *_serveLock; +} + ++ (NSTimeInterval)graceSeconds { + return kGraceSeconds; } + (instancetype)shared { static BGSFlutterSecureRectangleStore *store; static dispatch_once_t once; dispatch_once(&once, ^{ - store = [[BGSFlutterSecureRectangleStore alloc] init]; + store = [[BGSFlutterSecureRectangleStore alloc] + initWithEnvironment:[[BGSFlutterUnknownEnvironment alloc] init] + clock:^NSTimeInterval { + return CACurrentMediaTime(); + } + grace:kGraceSeconds]; }); return store; } - (instancetype)init { + return [self initWithEnvironment:[[BGSFlutterUnknownEnvironment alloc] init]]; +} + +- (instancetype)initWithEnvironment:(id)environment { + return [self initWithEnvironment:environment + clock:^NSTimeInterval { + return CACurrentMediaTime(); + } + grace:0]; +} + +- (instancetype)initWithEnvironment:(id)environment + clock:(NSTimeInterval (^)(void))clock + grace:(NSTimeInterval)grace { if ((self = [super init])) { - _sets = [NSMutableDictionary dictionary]; + _environment = environment; + _clock = [clock copy]; + _grace = grace; + _state = [[BGSFlutterSecureState alloc] initWithLanes:@[] placements:@{} order:@{}]; + _served = [NSMutableDictionary dictionary]; + _writeLock = [[NSObject alloc] init]; + _serveLock = [[NSObject alloc] init]; } return self; } -- (NSData *)snapshotForDisplay:(NSInteger)display { - NSData *set; - @synchronized(self) { - set = _sets[@(display)]; +- (void)setEnvironment:(id)environment { + @synchronized(_serveLock) { + _environment = environment; } - if (set != nil) { - return set; +} + +- (void)setPullHook:(void (^)(void))hook { + @synchronized(_writeLock) { + _pullHook = [hook copy]; } - int32_t ints[2] = {0, 0}; - return [NSData dataWithBytes:ints length:sizeof(ints)]; } -- (void)publish:(NSData *)buffer forDisplay:(NSInteger)display { - NSData *copy = [buffer copy]; - @synchronized(self) { - _sets[@(display)] = copy; +- (BGSFlutterSecureState *)currentState { + @synchronized(_writeLock) { + return _state; + } +} + +- (BOOL)publishForOwner:(int64_t)owner + epoch:(int64_t)epoch + sequence:(int64_t)sequence + views:(NSDictionary *)views { + @synchronized(_writeLock) { + BGSFlutterSecureState *previous = _state; + NSArray *last = previous.order[@(owner)]; + NSArray *retired = @[]; + if (last != nil) { + int64_t lastEpoch = [last[0] longLongValue]; + int64_t lastSequence = [last[1] longLongValue]; + NSArray *lastRetired = last[2]; + if ([lastRetired containsObject:@(epoch)]) { + return NO; // a straggler of a dead isolate + } + if (epoch == lastEpoch) { + if (sequence <= lastSequence) return NO; + retired = lastRetired; + } else { + // A new isolate (a hot restart): its epoch is any id not seen before, never a clock. + NSMutableArray *grown = [lastRetired mutableCopy]; + [grown addObject:@(lastEpoch)]; + while (grown.count > kRetiredEpochs) [grown removeObjectAtIndex:0]; + retired = grown; + } + } + NSMutableArray *lanes = [NSMutableArray array]; + for (BGSFlutterLane *lane in previous.lanes) { + if (lane.owner != owner) [lanes addObject:lane]; + } + [views enumerateKeysAndObjectsUsingBlock:^(NSNumber *viewId, BGSFlutterSecureViewRects *view, BOOL *stop) { + if (view.rects.length > 0) { + [lanes addObject:[[BGSFlutterLane alloc] initWithOwner:owner + viewId:viewId.longLongValue + ratio:view.ratio + rects:view.rects]]; + } + }]; + [lanes sortUsingComparator:^NSComparisonResult(BGSFlutterLane *a, BGSFlutterLane *b) { + if (a.owner != b.owner) return a.owner < b.owner ? NSOrderedAscending : NSOrderedDescending; + if (a.viewId != b.viewId) return a.viewId < b.viewId ? NSOrderedAscending : NSOrderedDescending; + return NSOrderedSame; + }]; + NSMutableDictionary *order = [previous.order mutableCopy]; + order[@(owner)] = @[ @(epoch), @(sequence), retired ]; + _state = [[BGSFlutterSecureState alloc] initWithLanes:lanes + placements:previous.placements + order:order]; + return YES; + } +} + +- (void)setPlacement:(BGSFlutterSecurePlacement *)placement forOwner:(int64_t)owner viewId:(int64_t)viewId { + @synchronized(_writeLock) { + BGSFlutterSecureState *previous = _state; + NSString *key = [BGSFlutterLane keyForOwner:owner viewId:viewId]; + if ([placement isEqual:previous.placements[key]]) { + return; + } + NSMutableDictionary *placements = [previous.placements mutableCopy]; + placements[key] = placement; + _state = [[BGSFlutterSecureState alloc] initWithLanes:previous.lanes + placements:placements + order:previous.order]; + } +} + +- (void)dropOwner:(int64_t)owner { + @synchronized(_writeLock) { + BGSFlutterSecureState *previous = _state; + NSMutableArray *lanes = [NSMutableArray array]; + for (BGSFlutterLane *lane in previous.lanes) { + if (lane.owner != owner) [lanes addObject:lane]; + } + NSString *prefix = [NSString stringWithFormat:@"%lld:", owner]; + NSMutableDictionary *placements = [NSMutableDictionary dictionary]; + [previous.placements enumerateKeysAndObjectsUsingBlock:^(NSString *key, id value, BOOL *stop) { + if (![key hasPrefix:prefix]) placements[key] = value; + }]; + NSMutableDictionary *order = [previous.order mutableCopy]; + [order removeObjectForKey:@(owner)]; + _state = [[BGSFlutterSecureState alloc] initWithLanes:lanes placements:placements order:order]; + } +} + +- (NSData *)pullForDisplay:(NSInteger)display { + void (^hook)(void); + @synchronized(_writeLock) { + hook = _pullHook; + } + if (hook != nil) { + @try { + hook(); + } @catch (id exception) { + // The pull is always answered. + } + } + return [self snapshotForDisplay:display]; +} + ++ (int32_t)versionAfter:(int32_t)version { + int32_t next = (int32_t)((uint32_t)version + 1u); // wraps + return next == kUnknownVersion ? next + 1 : next; +} + +static int32_t BGSClampInt(int32_t v, int32_t max) { + return v < 0 ? 0 : (v > max ? max : v); +} + +static int32_t BGSToInt(double v) { + return (int32_t)fmax(-kLimit, fmin(kLimit, v)); +} + +static BOOL BGSUsable(double v) { + return v > 0 && !isinf(v); +} + +/// The point rectangles `display` should serve for `state`, as packed int32 quads. +static NSData *BGSServe(BGSFlutterSecureState *state, NSInteger display, + id env) { + double nativeRatio = NAN; + double unit = 1; + CGSize bounds = CGSizeZero; + BOOL envRead = NO; + NSMutableData *out = [NSMutableData data]; + BOOL whole = NO; + + for (BGSFlutterLane *lane in state.lanes) { + BGSFlutterSecurePlacement *placement = state.placements[[lane key]]; + BGSFlutterPlacementKind kind = placement == nil ? BGSFlutterPlacementUnknown : placement.kind; + if (kind == BGSFlutterPlacementAbsent) continue; + if (kind == BGSFlutterPlacementUnknown) { + whole = YES; + continue; + } + if (placement.display != display) continue; + if (!envRead) { + nativeRatio = [env nativePixelRatioForDisplay:display]; + unit = [env unitsPerPhysicalPixelForDisplay:display]; + bounds = [env displayBoundsInPointsForDisplay:display]; + envRead = YES; + } + double ratio = lane.ratio; + double factor = ratio * unit; + double sx = placement.scaleX, sy = placement.scaleY; + NSUInteger count = lane.rects.length / sizeof(double); + if (!BGSUsable(ratio) || !BGSUsable(nativeRatio) || !BGSUsable(unit) || !BGSUsable(sx) || + !BGSUsable(sy) || fabs(nativeRatio - ratio) > kRatioTolerance * fmax(1, ratio) || + lane.rects.length % (4 * sizeof(double)) != 0) { + // Measured at another ratio than the screen has now, or not measurable. + whole = YES; + continue; + } + const double *r = lane.rects.bytes; + for (NSUInteger i = 0; i + 3 < count; i += 4) { + double l = r[i], t = r[i + 1], rt = r[i + 2], b = r[i + 3]; + if (!isfinite(l) || !isfinite(t) || !isfinite(rt) || !isfinite(b)) { + whole = YES; + continue; + } + if (rt <= l || b <= t) continue; // degenerate: nothing to hide + // Scale and place first, round outward last (5.3.1). + int32_t il = BGSToInt(floor(l * factor * sx + placement.x)); + int32_t it = BGSToInt(floor(t * factor * sy + placement.y)); + int32_t ir = BGSToInt(ceil(rt * factor * sx + placement.x)); + int32_t ib = BGSToInt(ceil(b * factor * sy + placement.y)); + if (bounds.width > 0 && bounds.height > 0) { + int32_t w = (int32_t)ceil(bounds.width), h = (int32_t)ceil(bounds.height); + il = BGSClampInt(il, w); + ir = BGSClampInt(ir, w); + it = BGSClampInt(it, h); + ib = BGSClampInt(ib, h); + } + if (ir <= il || ib <= it) continue; // nothing of it is on the display + int32_t quad[4] = {il, it, ir, ib}; + [out appendBytes:quad length:sizeof(quad)]; + } + } + + if (whole) { + // Something could not be placed or measured: cover everything. + if (!envRead) bounds = [env displayBoundsInPointsForDisplay:display]; + int32_t w = bounds.width > 0 ? (int32_t)ceil(bounds.width) : kFallbackDisplaySize; + int32_t h = bounds.height > 0 ? (int32_t)ceil(bounds.height) : kFallbackDisplaySize; + int32_t quad[4] = {0, 0, w, h}; + return [NSData dataWithBytes:quad length:sizeof(quad)]; + } + return out; +} + +/// The rectangles of `a` then those of `b` that are not in `a`; `b` collapsed to its bounding box +/// when together they would be too many. +static NSData *BGSUnion(NSData *a, NSData *b) { + if (b.length == 0) return a; + const int32_t *pa = a.bytes, *pb = b.bytes; + NSUInteger na = a.length / (4 * sizeof(int32_t)), nb = b.length / (4 * sizeof(int32_t)); + NSMutableData *extra = [NSMutableData data]; + for (NSUInteger i = 0; i < nb; i++) { + BOOL present = NO; + for (NSUInteger j = 0; j < na && !present; j++) { + present = memcmp(pa + 4 * j, pb + 4 * i, 4 * sizeof(int32_t)) == 0; + } + if (!present) [extra appendBytes:pb + 4 * i length:4 * sizeof(int32_t)]; + } + if (extra.length == 0) return a; + NSUInteger ne = extra.length / (4 * sizeof(int32_t)); + if (na + ne > kMaxRectangles) { + const int32_t *pe = extra.bytes; + int32_t l = INT32_MAX, t = INT32_MAX, r = INT32_MIN, bt = INT32_MIN; + for (NSUInteger i = 0; i < ne; i++) { + l = MIN(l, pe[4 * i]); + t = MIN(t, pe[4 * i + 1]); + r = MAX(r, pe[4 * i + 2]); + bt = MAX(bt, pe[4 * i + 3]); + } + int32_t box[4] = {l, t, r, bt}; + extra = [NSMutableData dataWithBytes:box length:sizeof(box)]; + } + NSMutableData *out = [a mutableCopy]; + [out appendData:extra]; + return out; +} + +- (NSData *)snapshotForDisplay:(NSInteger)display { + @synchronized(_serveLock) { + NSTimeInterval now = _clock(); + NSData *target = BGSServe([self currentState], display, _environment); + BGSFlutterServed *last = _served[@(display)]; + if (last == nil) { + if (target.length == 0) { + int32_t empty[2] = {kUnknownVersion, 0}; + return [NSData dataWithBytes:empty length:sizeof(empty)]; + } + BGSFlutterServed *first = [[BGSFlutterServed alloc] initWithVersion:1 + quads:target + current:target + past:@[]]; + _served[@(display)] = first; + return first.buffer; + } + // Sets that stopped being current are served for a window each, counted from the moment THEY + // stopped (not from the last change), so a long movement keeps only the last window's worth of + // places instead of its whole path. + NSMutableArray *past = [NSMutableArray array]; + if (_grace > 0 && ![target isEqualToData:last.current] && last.current.length > 0) { + [past addObject:[[BGSFlutterPast alloc] initWithQuads:last.current until:now + _grace]]; + } + for (BGSFlutterPast *p in last.past) { + if (p.until > now) [past addObject:p]; + } + NSData *out = target; + for (BGSFlutterPast *p in past) out = BGSUnion(out, p.quads); + int32_t version = [out isEqualToData:last.quads] + ? last.version + : [BGSFlutterSecureRectangleStore versionAfter:last.version]; + BGSFlutterServed *next = [[BGSFlutterServed alloc] initWithVersion:version + quads:out + current:target + past:past]; + _served[@(display)] = next; + return next.buffer; } } - (void)clearForTests { - @synchronized(self) { - [_sets removeAllObjects]; + @synchronized(_writeLock) { + _state = [[BGSFlutterSecureState alloc] initWithLanes:@[] placements:@{} order:@{}]; + _pullHook = nil; + } + @synchronized(_serveLock) { + [_served removeAllObjects]; + _environment = [[BGSFlutterUnknownEnvironment alloc] init]; } } diff --git a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterWrapper.m b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterWrapper.m index 669a06c..cdbaa86 100644 --- a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterWrapper.m +++ b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterWrapper.m @@ -85,7 +85,7 @@ - (void)onLifecycleEvent:(NSString *)eventType data:(id)data { - (NSData *)secureRectanglesForDisplay:(NSInteger)display { // From the store, not from this object: this wrapper is replaced. - return [_secureRectangles snapshotForDisplay:display]; + return [_secureRectangles pullForDisplay:display]; } - (void)onBeforeReportCreated:(id)report diff --git a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/include/BGSFlutterSecurePlacementTracker.h b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/include/BGSFlutterSecurePlacementTracker.h new file mode 100644 index 0000000..7ffb2b6 --- /dev/null +++ b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/include/BGSFlutterSecurePlacementTracker.h @@ -0,0 +1,54 @@ +#import + +#import "BGSFlutterSecureRectangleStore.h" + +NS_ASSUME_NONNULL_BEGIN + +/// What `BGSFlutterSecureRectangleStore` reads off an iPhone or iPad, at every pull. +/// +/// The factor from Flutter logical pixels to the SDK's unit is exactly 1: Flutter's iOS +/// embedder defines a logical pixel as `physical / UIScreen.scale`, and a UIKit point is +/// `physical / UIScreen.scale` too, on every screen and in every display mode. The SDK takes +/// points, so nothing is multiplied by the screen's scale (that would be the Android rule, +/// physical pixels, and would redact 3x too much on a 3x phone). It is still a method that is +/// asked at every pull, and the screen's size is read from the live `UIScreen` each time. +@interface BGSFlutterUIKitEnvironment : NSObject +/// Called by the tracker (main thread) with the screen the Flutter view is on. +- (void)updateWithScreen:(nullable UIScreen *)screen; +@end + +/// Keeps `BGSFlutterSecureRectangleStore`'s idea of where this engine's Flutter view sits +/// on the screen current. Dart measures in the view's own logical pixels; the SDK wants +/// points in the screen's coordinate space, so the view's position in +/// `UIScreen.coordinateSpace` is added (a window need not sit at the screen's origin on +/// iPad: Split View, Stage Manager). +/// +/// Reports, and why: +/// - no view controller yet (a headless engine, or a transition): Unknown, served as the +/// whole display, because the regions might be anywhere; +/// - a view that is in no window: Absent, nothing of Flutter can be drawn; +/// - otherwise the view's display (0, the only one the SDK polls) and position. +/// +/// Re-read on every publish from Dart and on every SDK pull: synchronously when that is on +/// the main thread (where the SDK pulls today), else by a throttled hop to it. All view +/// access is on the main thread. +@interface BGSFlutterSecurePlacementTracker : NSObject + +/// `viewProvider` returns the engine's Flutter view, or nil. Main thread only. +- (instancetype)initWithOwner:(int64_t)owner + store:(BGSFlutterSecureRectangleStore *)store + environment:(BGSFlutterUIKitEnvironment *)environment + viewProvider:(UIView *_Nullable (^)(void))viewProvider; + +/// Reads the view now. Main thread only. Never throws. +- (void)refresh; + +/// Stops tracking (engine detached). +- (void)detach; + +/// The SDK's pull asks every tracker to refresh. Any thread; never blocks, never throws. ++ (void)refreshAllFromPull; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/include/BGSFlutterSecureRectangleStore.h b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/include/BGSFlutterSecureRectangleStore.h index 001af6d..67ceb69 100644 --- a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/include/BGSFlutterSecureRectangleStore.h +++ b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/include/BGSFlutterSecureRectangleStore.h @@ -1,30 +1,147 @@ +#import #import NS_ASSUME_NONNULL_BEGIN +/// What the store reads off the device, at every pull. +@protocol BGSFlutterSecureEnvironment +/// The display's physical pixels per Flutter logical pixel right now (iOS: `UIScreen.scale`, +/// which is Flutter's device pixel ratio). Read at every pull, never cached. The ratio a message +/// was measured at must equal it, or the whole display is covered until Dart republishes. Not +/// finite or not positive means "unknown" and fails closed. +- (double)nativePixelRatioForDisplay:(NSInteger)display; +/// SDK units per physical pixel: `1 / UIScreen.scale` on iOS (the SDK takes points). With a +/// matching ratio a Flutter logical pixel is therefore exactly one point, and the screen's scale +/// is never multiplied in. +- (double)unitsPerPhysicalPixelForDisplay:(NSInteger)display; +/// The display's size in points in the current orientation; `CGSizeZero` when unknown. +- (CGSize)displayBoundsInPointsForDisplay:(NSInteger)display; +@end + +typedef NS_ENUM(NSInteger, BGSFlutterPlacementKind) { + /// The view's position on the screen is known. + BGSFlutterPlacementKnown, + /// Not known: serve the whole display (fail closed). + BGSFlutterPlacementUnknown, + /// Known not to be on screen: serve nothing. + BGSFlutterPlacementAbsent, +}; + +/// What one view published: the ratio it was measured at and its flat logical ltrb (packed +/// native-endian doubles). +@interface BGSFlutterSecureViewRects : NSObject +@property(nonatomic, readonly) double ratio; +@property(nonatomic, readonly, copy) NSData *rects; +- (instancetype)initWithRatio:(double)ratio rects:(NSData *)rects; +@end + +/// Where one Flutter view sits on the screen, in points. +@interface BGSFlutterSecurePlacement : NSObject +@property(nonatomic, readonly) BGSFlutterPlacementKind kind; +@property(nonatomic, readonly) NSInteger display; +@property(nonatomic, readonly) double x; +@property(nonatomic, readonly) double y; +/// The view's own scale on screen (it, or an ancestor, is scaled); 1 normally. +@property(nonatomic, readonly) double scaleX; +@property(nonatomic, readonly) double scaleY; ++ (instancetype)knownOnDisplay:(NSInteger)display x:(double)x y:(double)y; ++ (instancetype)knownOnDisplay:(NSInteger)display + x:(double)x + y:(double)y + scaleX:(double)scaleX + scaleY:(double)scaleY; ++ (instancetype)unknown; ++ (instancetype)absent; +@end + /// Where the secure-rectangle sets live (workbook 5.4.2): outside the wrapper /// object, because the wrapper is replaced mid-session when Dart refines the /// identity, and the regions must survive that or the app is recorded /// unredacted until it next republishes. /// -/// Empty for now; Phase 3b fills it. A set is the SDK's packed buffer -/// `[version, count, l, t, r, b, ...]` (little-endian int32), kept per display -/// (5.2.3). Stored as published (an immutable NSData copy) and handed out as is, -/// so the SDK thread that pulls it never sees a half-written set (5.4.1). +/// What the SDK takes (iOS 7.0.0-beta7, BGSContracts.h `secureRectanglesForDisplay:`): +/// a packed little-endian int32 `[version, count, l, t, r, b, ...]`; the rectangles +/// are in POINTS in the screen's coordinate space, origin top-left, `r`/`b` exclusive +/// (the SDK draws them as they are over the recorded frame, which is the screen), and +/// the SDK edge-rounds nothing itself, so fractional edges must be rounded OUTWARD +/// here. Only display 0 is polled today; it is called once per captured frame and per +/// report screenshot on the thread that snapshots the frame (currently the main +/// thread). A non-zero version equal to the last one seen makes the SDK skip the +/// rectangles; 0 makes it compare them; a count of 0 clears the set. +/// +/// Dart publishes Flutter LOGICAL pixels relative to its view, with the pixel ratio it measured +/// them at. This class multiplies by that ratio and by `unitsPerPhysicalPixel` (so by 1 on iOS), +/// by the view's own on-screen scale, adds the view's position on the screen, then rounds outward +/// (scale BEFORE rounding, 5.3.1). The screen's scale is read at every pull and must equal the +/// ratio: a mismatch (Flutter has not caught up with a display change) covers the whole display +/// until Dart republishes. +/// +/// Grace window: every set that stops being current is served as well for `+graceSeconds` from the +/// moment IT stopped (not from the last change, so a long movement does not widen the cover to its +/// path), because Dart measures after a frame and the message crosses a channel while +/// the frame may be captured first. It only ever over-covers; the union shrinking back is itself a +/// change and moves the version. A region that has just APPEARED has no previous place, so its +/// first frame can still be recorded before the message lands: the one exposure left. +/// +/// Versions are derived from what is served, per display: they move exactly when the +/// served rectangles differ from the ones served before, never otherwise, and skip 0. +/// A display that never had a rectangle serves `[0, 0]`. +/// +/// Fail closed: a view whose placement is unknown, a rectangle that is not finite, a +/// malformed list and a scale that is not a positive finite number are each served as +/// ONE rectangle covering the whole display; a view known to be off screen serves nothing. +/// +/// Thread safety: written state is immutable and replaced whole; the served buffer is an +/// immutable `NSData` (5.4.1). @interface BGSFlutterSecureRectangleStore : NSObject + (instancetype)shared; +/// Knows nothing about the device until an environment is installed: every view covers +/// the whole display. - (instancetype)init; +/// No grace window: what is written is what is served at once. For tests. +- (instancetype)initWithEnvironment:(id)environment; +/// `clock` returns seconds from a monotonic source; `grace` is how long each past set is served after +/// it stopped being current (0 for none). +- (instancetype)initWithEnvironment:(id)environment + clock:(NSTimeInterval (^)(void))clock + grace:(NSTimeInterval)grace NS_DESIGNATED_INITIALIZER; + +/// How long each past set is served after it stopped being current. Repeated in Java (`GRACE_MS`). ++ (NSTimeInterval)graceSeconds; + +- (void)setEnvironment:(id)environment; + +/// Called first by every `-pullForDisplay:` (the placement tracker's refresh request). +/// It must not block. +- (void)setPullHook:(nullable void (^)(void))hook; + +/// Applies Dart's whole state for `owner`: `views` maps a view id to what it published; a view +/// not in it has no rectangles any more. Ignored (returns NO) when `epoch` is the current one and +/// `sequence` is not newer, or when `epoch` was retired (a straggler of a dead isolate). An epoch +/// not seen before is a new isolate (a hot restart): it replaces the old one's state and retires +/// its epoch. Epochs are random ids, never clocks. +- (BOOL)publishForOwner:(int64_t)owner + epoch:(int64_t)epoch + sequence:(int64_t)sequence + views:(NSDictionary *)views; + +/// Where `owner`'s view `viewId` sits now. Not a change when it is the same. +- (void)setPlacement:(BGSFlutterSecurePlacement *)placement + forOwner:(int64_t)owner + viewId:(int64_t)viewId; + +/// Dart's engine is gone: forget its rectangles, placements and ordering. +- (void)dropOwner:(int64_t)owner; + +/// The SDK's pull: asks for a placement refresh, then serves. +- (NSData *)pullForDisplay:(NSInteger)display; -/// The set last published for `display`, or the empty set: `[0, 0]`. Version 0 is -/// the contract's "unknown", so the SDK compares the rectangles themselves; a -/// constant real version would let a later set that also starts at that version, -/// with real rectangles, be ignored (the SDK caches by version per display: -/// unredacted capture). +/// The buffer for `display`, computed from the current state, scale and size. - (NSData *)snapshotForDisplay:(NSInteger)display; -/// Replaces the set for `display`. The data is copied. -- (void)publish:(NSData *)buffer forDisplay:(NSInteger)display; +/// The version after `version`: wraps, never lands on 0 (unknown). ++ (int32_t)versionAfter:(int32_t)version; /// For tests only. - (void)clearForTests; diff --git a/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterSecurePlacementTrackerTests.m b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterSecurePlacementTrackerTests.m new file mode 100644 index 0000000..f30d1dd --- /dev/null +++ b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterSecurePlacementTrackerTests.m @@ -0,0 +1,136 @@ +#import + +#import "BGSFlutterSecurePlacementTracker.h" +#import "BGSFlutterSecureRectangleStore.h" + +/// Where the Flutter view sits on the screen, read off real UIKit views. +@interface BGSFlutterSecurePlacementTrackerTests : XCTestCase +@end + +@implementation BGSFlutterSecurePlacementTrackerTests { + BGSFlutterSecureRectangleStore *_store; + BGSFlutterUIKitEnvironment *_environment; + UIWindow *_window; + UIView *_flutterView; + BGSFlutterSecurePlacementTracker *_tracker; + BOOL _viewAvailable; +} + +- (void)setUp { + _environment = [[BGSFlutterUIKitEnvironment alloc] init]; + _store = [[BGSFlutterSecureRectangleStore alloc] initWithEnvironment:_environment]; + CGRect screen = [UIScreen mainScreen].bounds; + _window = [[UIWindow alloc] initWithFrame:screen]; + _flutterView = [[UIView alloc] initWithFrame:CGRectMake(0, 66, screen.size.width, screen.size.height - 66)]; + [_window addSubview:_flutterView]; + _viewAvailable = YES; + __weak typeof(self) weakSelf = self; + _tracker = [[BGSFlutterSecurePlacementTracker alloc] + initWithOwner:1 + store:_store + environment:_environment + viewProvider:^UIView *_Nullable { + typeof(self) strongSelf = weakSelf; + return strongSelf != nil && strongSelf->_viewAvailable ? strongSelf->_flutterView : nil; + }]; +} + +- (void)tearDown { + [_tracker detach]; +} + +static NSArray *Ints(NSData *buffer) { + NSMutableArray *out = [NSMutableArray array]; + const int32_t *p = buffer.bytes; + for (NSUInteger i = 0; i < buffer.length / 4; i++) [out addObject:@(p[i])]; + return out; +} + +- (void)publishRect { + double v[] = {10, 10, 20, 20}; + [_store publishForOwner:1 epoch:1 sequence:1 views:@{@0 : [[BGSFlutterSecureViewRects alloc] initWithRatio:[UIScreen mainScreen].scale rects:[NSData dataWithBytes:v length:sizeof(v)]]}]; +} + +- (void)testTheViewsOffsetFromTheScreenOriginIsAdded { + [self publishRect]; + [_tracker refresh]; + XCTAssertNotEqual([UIScreen mainScreen].scale, 1.0, @"a 1x screen would make this check vacuous"); + NSArray *s = Ints([_store snapshotForDisplay:0]); + XCTAssertEqualObjects([s subarrayWithRange:NSMakeRange(1, s.count - 1)], (@[ @1, @10, @76, @20, @86 ])); +} + +- (void)testAWindowThatIsNotAtTheScreenOriginIsAccountedFor { + _window.frame = CGRectOffset(_window.frame, 20, 30); + [self publishRect]; + [_tracker refresh]; + NSArray *s = Ints([_store snapshotForDisplay:0]); + XCTAssertEqualObjects([s subarrayWithRange:NSMakeRange(1, s.count - 1)], (@[ @1, @30, @106, @40, @116 ])); +} + +- (void)testMovingTheViewMovesTheRectanglesAtTheNextPull { + [self publishRect]; + [_tracker refresh]; + int32_t before = [Ints([_store snapshotForDisplay:0]) .firstObject intValue]; + _flutterView.frame = CGRectOffset(_flutterView.frame, 0, 100); + // The SDK's pull (on the main thread, as today) re-reads the position first. + NSArray *s = Ints([_store pullForDisplay:0]); + XCTAssertEqualObjects([s subarrayWithRange:NSMakeRange(1, s.count - 1)], (@[ @1, @10, @176, @20, @186 ])); + XCTAssertNotEqual(before, [s.firstObject intValue]); +} + +/// A Flutter view shrunk by a transform of its own or an ancestor's (a page-sheet presentation, a +/// picker's automatic style) must scale its rectangles, or they land on the wrong pixels. +- (void)testAScaledViewScalesTheRectanglesAndARotatedOneCoversTheWholeScreen { + _flutterView.transform = CGAffineTransformMakeScale(0.9, 0.9); + [self publishRect]; + [_tracker refresh]; + CGPoint origin = [_flutterView convertPoint:CGPointZero toCoordinateSpace:[UIScreen mainScreen].coordinateSpace]; + NSArray *s = Ints([_store snapshotForDisplay:0]); + XCTAssertEqual(s.count, 6u); + // x: 10 * 0.9 = 9 -> floor(origin + 9), 20 * 0.9 = 18 -> ceil(origin + 18) + XCTAssertEqual([s[2] intValue], (int)floor(origin.x + 9)); + XCTAssertEqual([s[4] intValue], (int)ceil(origin.x + 18)); + XCTAssertEqual([s[3] intValue], (int)floor(origin.y + 9)); + XCTAssertEqual([s[5] intValue], (int)ceil(origin.y + 18)); + // The same through an ANCESTOR's transform. + _flutterView.transform = CGAffineTransformIdentity; + _window.transform = CGAffineTransformMakeScale(0.5, 0.5); + [_tracker refresh]; + CGPoint o2 = [_flutterView convertPoint:CGPointZero toCoordinateSpace:[UIScreen mainScreen].coordinateSpace]; + NSArray *t = Ints([_store snapshotForDisplay:0]); + XCTAssertEqual([t[4] intValue], (int)ceil(o2.x + 10), @"20 * 0.5"); + // Rotated: not a scale and a translation. + _window.transform = CGAffineTransformIdentity; + _flutterView.transform = CGAffineTransformMakeRotation(0.3); + [_tracker refresh]; + CGSize bounds = [UIScreen mainScreen].bounds.size; + NSArray *r = Ints([_store snapshotForDisplay:0]); + XCTAssertEqualObjects([r subarrayWithRange:NSMakeRange(1, r.count - 1)], + (@[ @1, @0, @0, @(ceil(bounds.width)), @(ceil(bounds.height)) ])); +} + +- (void)testNoViewControllerYetCoversTheWholeScreen { + _viewAvailable = NO; + [self publishRect]; + [_tracker refresh]; + CGSize bounds = [UIScreen mainScreen].bounds.size; + NSArray *s = Ints([_store snapshotForDisplay:0]); + XCTAssertEqualObjects([s subarrayWithRange:NSMakeRange(1, s.count - 1)], (@[ @1, @0, @0, @(ceil(bounds.width)), @(ceil(bounds.height)) ])); +} + +- (void)testAViewInNoWindowServesNothing { + [_flutterView removeFromSuperview]; + [self publishRect]; + [_tracker refresh]; + XCTAssertEqual([Ints([_store snapshotForDisplay:0])[1] intValue], 0); +} + +- (void)testBeforeTheFirstReadTheRectangleCoversTheWholeScreen { + [self publishRect]; // the tracker has not read the view yet + XCTAssertEqual([Ints([_store snapshotForDisplay:0])[1] intValue], 1); + CGSize bounds = [UIScreen mainScreen].bounds.size; + XCTAssertEqualObjects([Ints([_store snapshotForDisplay:0]) subarrayWithRange:NSMakeRange(2, 4)], + (@[ @0, @0, @(ceil(bounds.width)), @(ceil(bounds.height)) ])); +} + +@end diff --git a/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterSecureRectangleStoreTests.m b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterSecureRectangleStoreTests.m new file mode 100644 index 0000000..c41a195 --- /dev/null +++ b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterSecureRectangleStoreTests.m @@ -0,0 +1,667 @@ +#import + +#import "BGSFlutterSecurePlacementTracker.h" +#import "BGSFlutterSecureRectangleStore.h" + +/// Workbook Part 5 on iOS: units, rounding, versions, ownership. The SDK's unit here is POINTS in +/// the screen's coordinate space (BGSContracts.h, `secureRectanglesForDisplay:`). + +/// A device a test can change between pulls. +@interface BGSMockScreen : NSObject +/// The screen's scale (Flutter's pixel ratio, what messages are measured at). +@property(nonatomic) double nativeRatio; +@property(nonatomic) double units; +/// ratio * units: logical pixels to points. 1 on a real phone. +@property(nonatomic) double factor; +@property(nonatomic) CGSize bounds; +@property(nonatomic) NSInteger scaleReads; +@end + +@implementation BGSMockScreen +- (instancetype)init { + if ((self = [super init])) { + _nativeRatio = 3; // a 3x phone + _units = 1.0 / 3; // points per physical pixel + _bounds = CGSizeMake(402, 874); // iPhone 17 Pro, points + } + return self; +} +- (double)factor { + return _nativeRatio * _units; +} +- (void)setFactor:(double)factor { + _units = factor / _nativeRatio; +} +- (double)nativePixelRatioForDisplay:(NSInteger)display { + _scaleReads++; + return _nativeRatio; +} +- (double)unitsPerPhysicalPixelForDisplay:(NSInteger)display { + return _units; +} +- (CGSize)displayBoundsInPointsForDisplay:(NSInteger)display { + return _bounds; +} +@end + +@interface BGSFlutterSecureRectangleStoreTests : XCTestCase +@end + +@implementation BGSFlutterSecureRectangleStoreTests { + BGSMockScreen *_screen; + BGSFlutterSecureRectangleStore *_store; + int64_t _sequence; +} + +- (void)setUp { + _screen = [[BGSMockScreen alloc] init]; + _store = [[BGSFlutterSecureRectangleStore alloc] initWithEnvironment:_screen]; + _sequence = 0; +} + +static NSData *Doubles(const double *values, NSUInteger count) { + return [NSData dataWithBytes:values length:count * sizeof(double)]; +} +#define VIEW(...) \ + ({ \ + double v_[] = {__VA_ARGS__}; \ + @{@0 : [self viewOf:Doubles(v_, sizeof(v_) / sizeof(double))]}; \ + }) + +/// What Dart publishes: rectangles measured at the screen's current ratio. +- (BGSFlutterSecureViewRects *)viewOf:(NSData *)rects { + return [self viewOf:rects ratio:_screen.nativeRatio]; +} +- (BGSFlutterSecureViewRects *)viewOf:(NSData *)rects ratio:(double)ratio { + return [[BGSFlutterSecureViewRects alloc] initWithRatio:ratio rects:rects]; +} + +static NSArray *Ints(NSData *buffer) { + NSMutableArray *out = [NSMutableArray array]; + const int32_t *p = buffer.bytes; + for (NSUInteger i = 0; i < buffer.length / 4; i++) [out addObject:@(p[i])]; + return out; +} + +/// `[count, l, t, r, b, ...]`: the buffer without its version. +static NSArray *WithoutVersion(NSData *buffer) { + NSArray *all = Ints(buffer); + return [all subarrayWithRange:NSMakeRange(1, all.count - 1)]; +} + +static int32_t VersionOf(NSData *buffer) { + return ((const int32_t *)buffer.bytes)[0]; +} + +- (BOOL)publishViews:(NSDictionary *)views { + [_store setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:0 x:0 y:0] forOwner:1 viewId:0]; + return [_store publishForOwner:1 epoch:1 sequence:++_sequence views:views]; +} +#define PUBLISH(...) XCTAssertTrue([self publishViews:VIEW(__VA_ARGS__)]) + +#pragma mark - empty + +- (void)testADisplayThatNeverHadARectangleIsUnknownAndEmpty { + XCTAssertEqualObjects(Ints([_store snapshotForDisplay:0]), (@[ @0, @0 ])); +} + +- (void)testAfterARectangleVersionsAreRealAndAnEmptiedSetIsStillCounted { + PUBLISH(1, 1, 2, 2); + NSData *full = [_store snapshotForDisplay:0]; + XCTAssertEqual(((const int32_t *)full.bytes)[1], 1); + XCTAssertNotEqual(VersionOf(full), 0); + XCTAssertTrue([_store publishForOwner:1 epoch:1 sequence:++_sequence views:@{}]); + NSData *empty = [_store snapshotForDisplay:0]; + XCTAssertEqual(((const int32_t *)empty.bytes)[1], 0); + XCTAssertNotEqual(VersionOf(empty), VersionOf(full), @"emptying is a change"); + XCTAssertNotEqual(VersionOf(empty), 0); +} + +#pragma mark - unit (5.3) and rounding (5.1.2) + +/// Workbook 5.3.3: the check must not pass vacuously, so this runs against the real UIScreen and +/// refuses to run where its scale is 1. On a 3x screen ratio * units is 1 (a logical pixel is a +/// point), NOT the screen's scale. +- (void)testTheUIKitFactorIsOneNotTheScreensScale { + BGSFlutterUIKitEnvironment *env = [[BGSFlutterUIKitEnvironment alloc] init]; + UIScreen *screen = [UIScreen mainScreen]; + [env updateWithScreen:screen]; + XCTAssertNotEqual(screen.scale, 1.0, @"run on a 2x/3x simulator: at scale 1 this test proves nothing"); + XCTAssertEqual([env nativePixelRatioForDisplay:0], screen.scale); + XCTAssertEqualWithAccuracy([env nativePixelRatioForDisplay:0] * [env unitsPerPhysicalPixelForDisplay:0], 1.0, 1e-12); + XCTAssertTrue(CGSizeEqualToSize([env displayBoundsInPointsForDisplay:0], screen.bounds.size)); +} + +/// On a 3x screen Flutter's logical pixels are points: nothing is multiplied by 3, which is what +/// treating them as physical pixels (the Android rule) would do. +- (void)testOnA3xScreenLogicalPixelsAreServedAsPointsNotMultipliedByTheScale { + _screen.factor = 1; + PUBLISH(10, 20, 100, 50); + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @10, @20, @100, @50 ])); +} + +/// A factor that is not 1 and not whole: scale first, round outward after. +- (void)testScalesToPointsThenRoundsOutward { + _screen.factor = 1.5; + PUBLISH(1.25, 2.25, 1.5, 2.5); + // 1.875 -> 1, 3.375 -> 3, 2.25 -> 3, 3.75 -> 4 + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @1, @3, @3, @4 ])); +} + +- (void)testRoundsAfterScalingNotBefore { + _screen.factor = 1.5; + PUBLISH(1.25, 0, 1.5, 1); + NSArray *s = WithoutVersion([_store snapshotForDisplay:0]); + // Rounding first would give floor(1.25)*1.5 = 1.5 -> 1 and ceil(1.5)*1.5 = 3. + XCTAssertEqualObjects(s[1], @1); + XCTAssertEqualObjects(s[3], @3); + // ceil(1.5*1.5 = 2.25) = 3, so no sub-point strip is left; round-to-nearest would give 2. +} + +- (void)testLeftAndTopFloorRightAndBottomCeil { + _screen.factor = 1.5; + PUBLISH(0.9, 0.9, 1.1, 1.1); + // 1.35 -> 1, 1.65 -> 2 + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @1, @1, @2, @2 ])); +} + +- (void)testRightAndBottomAreExclusiveSoExactEdgesAreNotWidened { + PUBLISH(0, 0, 10, 20); + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @0, @0, @10, @20 ])); +} + +- (void)testDegenerateRectanglesAreDropped { + PUBLISH(5, 5, 5, 10, 5, 5, 10, 5, 10, 10, 5, 20, 1.25, 1, 1.25, 2, 1, 1, 2, 2); + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @1, @1, @2, @2 ])); +} + +/// The factor is read at every pull (5.3.2). +- (void)testTheScaleIsReadPerCall { + _screen.factor = 1; + PUBLISH(10, 10, 20, 20); + NSData *at1 = [_store snapshotForDisplay:0]; + XCTAssertEqualObjects(WithoutVersion(at1), (@[ @1, @10, @10, @20, @20 ])); + _screen.factor = 2.0 / 3.0; + NSData *at2 = [_store snapshotForDisplay:0]; + XCTAssertEqualObjects(WithoutVersion(at2), (@[ @1, @6, @6, @14, @14 ])); // 6.67 -> 6, 13.33 -> 14 + XCTAssertNotEqual(VersionOf(at1), VersionOf(at2), @"a scale change moves the version"); + XCTAssertGreaterThanOrEqual(_screen.scaleReads, 2); +} + +- (void)testASetWithNoScaleChangeKeepsItsVersionAcrossPulls { + PUBLISH(10, 10, 20, 20); + int32_t v = VersionOf([_store snapshotForDisplay:0]); + for (int i = 0; i < 5; i++) XCTAssertEqual(VersionOf([_store snapshotForDisplay:0]), v); +} + +#pragma mark - coordinate space + +- (void)testTheViewsPositionOnTheScreenIsAdded { + [_store setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:0 x:0 y:20.5] forOwner:1 viewId:0]; + double v[] = {10, 10, 20, 20}; + [_store publishForOwner:1 epoch:1 sequence:1 views:@{@0 : [self viewOf:Doubles(v, 4)]}]; + // y: floor(30.5) = 30, ceil(40.5) = 41 + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @10, @30, @20, @41 ])); +} + +- (void)testMovingTheViewMovesTheRectanglesAndTheVersion { + PUBLISH(10, 10, 20, 20); + NSData *before = [_store snapshotForDisplay:0]; + [_store setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:0 x:100 y:0] forOwner:1 viewId:0]; + NSData *after = [_store snapshotForDisplay:0]; + XCTAssertEqualObjects(WithoutVersion(after), (@[ @1, @110, @10, @120, @20 ])); + XCTAssertNotEqual(VersionOf(before), VersionOf(after)); +} + +- (void)testRectanglesAreClampedToTheScreenAndOnesOffItAreDropped { + PUBLISH(-10, -10, 5, 5, 300, 800, 500, 900, 1000, 1000, 1100, 1100); + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), + (@[ @2, @0, @0, @5, @5, @300, @800, @402, @874 ])); +} + +#pragma mark - fail closed + +- (void)testAViewWhosePlacementIsUnknownCoversTheWholeScreen { + double v[] = {10, 10, 20, 20}; + [_store publishForOwner:1 epoch:1 sequence:1 views:@{@0 : [self viewOf:Doubles(v, 4)]}]; // no placement + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @0, @0, @402, @874 ])); + [_store setPlacement:[BGSFlutterSecurePlacement unknown] forOwner:1 viewId:0]; + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @0, @0, @402, @874 ])); +} + +- (void)testAWholeScreenCoverUsesTheFallbackSizeWhenTheScreenIsUnknown { + _screen.bounds = CGSizeZero; + double v[] = {10, 10, 20, 20}; + [_store publishForOwner:1 epoch:1 sequence:1 views:@{@0 : [self viewOf:Doubles(v, 4)]}]; + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @0, @0, @16384, @16384 ])); +} + +- (void)testAViewKnownToBeOffScreenServesNothing { + [_store setPlacement:[BGSFlutterSecurePlacement absent] forOwner:1 viewId:0]; + double v[] = {10, 10, 20, 20}; + [_store publishForOwner:1 epoch:1 sequence:1 views:@{@0 : [self viewOf:Doubles(v, 4)]}]; + XCTAssertEqual(((const int32_t *)[_store snapshotForDisplay:0].bytes)[1], 0); + [_store setPlacement:[BGSFlutterSecurePlacement unknown] forOwner:1 viewId:0]; + XCTAssertEqual(((const int32_t *)[_store snapshotForDisplay:0].bytes)[1], 1); +} + +- (void)testARectangleThatIsNotFiniteCoversTheWholeScreen { + PUBLISH(1, 1, 2, 2, NAN, 0, 5, 5); + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @0, @0, @402, @874 ])); + PUBLISH(1, 1, 2, 2, 0, 0, INFINITY, 5); + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @0, @0, @402, @874 ])); +} + +- (void)testAScaleThatIsNotUsableCoversTheWholeScreen { + PUBLISH(1, 1, 2, 2); + for (NSNumber *bad in @[ @0, @-1, @(NAN), @(INFINITY) ]) { + _screen.factor = bad.doubleValue; + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @0, @0, @402, @874 ]), + @"factor %@", bad); + } +} + +- (void)testAMalformedViewListCoversTheWholeScreen { + PUBLISH(1, 2, 3); // not a multiple of four + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @0, @0, @402, @874 ])); +} + +- (void)testAViewOtherThanTheImplicitOneIsNotPlacedSoItCoversTheScreen { + [_store setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:0 x:0 y:0] forOwner:1 viewId:0]; + double v[] = {1, 1, 2, 2}; + [_store publishForOwner:1 epoch:1 sequence:1 views:@{@7 : [self viewOf:Doubles(v, 4)]}]; + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @0, @0, @402, @874 ])); +} + +#pragma mark - versions (5.2) + +- (void)testANoOpWriteDoesNotMoveTheVersion { + PUBLISH(1, 1, 2, 2); + int32_t v = VersionOf([_store snapshotForDisplay:0]); + for (int i = 0; i < 4; i++) { + PUBLISH(1, 1, 2, 2); + XCTAssertEqual(VersionOf([_store snapshotForDisplay:0]), v); + } + [_store setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:0 x:0 y:0] forOwner:1 viewId:0]; + XCTAssertEqual(VersionOf([_store snapshotForDisplay:0]), v); +} + +/// Workbook 5.2.4: a store that held the version constant across a change would fail here. +- (void)testEveryKindOfRealChangeMovesTheVersion { + PUBLISH(1, 1, 2, 2); + __block int32_t last = VersionOf([_store snapshotForDisplay:0]); + void (^expectMoved)(NSString *) = ^(NSString *what) { + int32_t v = VersionOf([self->_store snapshotForDisplay:0]); + XCTAssertNotEqual(v, last, @"%@", what); + last = v; + }; + PUBLISH(1, 1, 3, 2); + expectMoved(@"resize"); + PUBLISH(2, 1, 4, 2); + expectMoved(@"move"); + PUBLISH(2, 1, 4, 2, 10, 10, 11, 11); + expectMoved(@"add"); + PUBLISH(2, 1, 4, 2); + expectMoved(@"remove one"); + [_store setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:0 x:1 y:0] forOwner:1 viewId:0]; + expectMoved(@"view moved"); + _screen.factor = 1.5; + expectMoved(@"scale"); + _screen.bounds = CGSizeMake(3, 3); + expectMoved(@"screen size"); +} + +- (void)testASetThatComesBackStillGetsANewVersion { + PUBLISH(1, 1, 2, 2); + int32_t a = VersionOf([_store snapshotForDisplay:0]); + PUBLISH(5, 5, 6, 6); + int32_t b = VersionOf([_store snapshotForDisplay:0]); + PUBLISH(1, 1, 2, 2); + int32_t c = VersionOf([_store snapshotForDisplay:0]); + XCTAssertNotEqual(a, b); + XCTAssertNotEqual(b, c); + XCTAssertNotEqual(a, c, @"the SDK cached a by version; c must not look like a"); +} + +- (void)testVersionsNeverLandOnZero { + XCTAssertEqual([BGSFlutterSecureRectangleStore versionAfter:-1], 1); + XCTAssertEqual([BGSFlutterSecureRectangleStore versionAfter:INT32_MAX], INT32_MIN); + XCTAssertEqual([BGSFlutterSecureRectangleStore versionAfter:4], 5); +} + +- (void)testVersionsArePerDisplay { + [_store setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:0 x:0 y:0] forOwner:1 viewId:0]; + [_store setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:1 x:0 y:0] forOwner:2 viewId:0]; + double a[] = {1, 1, 2, 2}, b[] = {4, 4, 5, 5}; + [_store publishForOwner:1 epoch:1 sequence:1 views:@{@0 : [self viewOf:Doubles(a, 4)]}]; + [_store publishForOwner:2 epoch:1 sequence:1 views:@{@0 : [self viewOf:Doubles(b, 4)]}]; + int32_t d0 = VersionOf([_store snapshotForDisplay:0]); + int32_t d1 = VersionOf([_store snapshotForDisplay:1]); + XCTAssertEqual(d0, VersionOf([_store snapshotForDisplay:0])); + XCTAssertEqual(d1, VersionOf([_store snapshotForDisplay:1])); + double b2[] = {1, 1, 9, 9}; + [_store publishForOwner:2 epoch:1 sequence:2 views:@{@0 : [self viewOf:Doubles(b2, 4)]}]; + XCTAssertEqual(d0, VersionOf([_store snapshotForDisplay:0])); + XCTAssertNotEqual(d1, VersionOf([_store snapshotForDisplay:1])); + XCTAssertEqual(((const int32_t *)[_store snapshotForDisplay:2].bytes)[1], 0); +} + +#pragma mark - ordering + +- (void)testADelayedOlderMessageNeverOverwritesANewerOne { + [_store setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:0 x:0 y:0] forOwner:1 viewId:0]; + double a[] = {1, 1, 2, 2}, old[] = {7, 7, 8, 8}, b[] = {2, 2, 3, 3}; + XCTAssertTrue([_store publishForOwner:1 epoch:5 sequence:10 views:@{@0 : [self viewOf:Doubles(a, 4)]}]); + XCTAssertFalse([_store publishForOwner:1 epoch:5 sequence:9 views:@{@0 : [self viewOf:Doubles(old, 4)]}], @"older sequence"); + XCTAssertFalse([_store publishForOwner:1 epoch:5 sequence:10 views:@{@0 : [self viewOf:Doubles(old, 4)]}], @"same sequence"); + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @1, @1, @2, @2 ])); + XCTAssertTrue([_store publishForOwner:1 epoch:5 sequence:11 views:@{@0 : [self viewOf:Doubles(b, 4)]}]); + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @2, @2, @3, @3 ])); +} + +/// Epochs are random ids, not clocks: a new isolate's id may be numerically LOWER than the old +/// one's (a wall clock that went backwards before a hot restart) and must still win, while a late +/// message of the dead isolate must not. +- (void)testANewIsolateWinsWhateverItsEpochIsAndTheOldOnesStragglersAreIgnored { + [_store setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:0 x:0 y:0] forOwner:1 viewId:0]; + double a[] = {1, 1, 2, 2}, b[] = {2, 2, 3, 3}, late[] = {7, 7, 8, 8}, c[] = {3, 3, 4, 4}, d[] = {1, 1, 2, 2}; + XCTAssertTrue([_store publishForOwner:1 epoch:900 sequence:500 views:@{@0 : [self viewOf:Doubles(a, 4)]}]); + XCTAssertTrue([_store publishForOwner:1 epoch:100 sequence:1 views:@{@0 : [self viewOf:Doubles(b, 4)]}], + @"a hot restart: a LOWER epoch id, counter back at 1"); + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @2, @2, @3, @3 ])); + XCTAssertFalse([_store publishForOwner:1 epoch:900 sequence:501 views:@{@0 : [self viewOf:Doubles(late, 4)]}], + @"a straggler of the dead isolate"); + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @2, @2, @3, @3 ])); + XCTAssertTrue([_store publishForOwner:1 epoch:100 sequence:2 views:@{@0 : [self viewOf:Doubles(c, 4)]}]); + XCTAssertTrue([_store publishForOwner:1 epoch:5 sequence:1 views:@{@0 : [self viewOf:Doubles(d, 4)]}]); + XCTAssertFalse([_store publishForOwner:1 epoch:100 sequence:3 views:@{@0 : [self viewOf:Doubles(late, 4)]}]); + XCTAssertFalse([_store publishForOwner:1 epoch:900 sequence:502 views:@{@0 : [self viewOf:Doubles(late, 4)]}]); +} + +#pragma mark - ratio, view scale + +/// A density change reaches native before Dart lays out again: what was measured at the old ratio is +/// not scaled by the new one, the whole screen is covered until Dart republishes. +- (void)testARatioThatNoLongerMatchesTheScreenCoversTheWholeScreenUntilRepublished { + PUBLISH(10, 10, 20, 20); + NSData *before = [_store snapshotForDisplay:0]; + XCTAssertEqualObjects(WithoutVersion(before), (@[ @1, @10, @10, @20, @20 ])); + _screen.nativeRatio = 2; // the screen changed scale; Flutter has not caught up + _screen.units = 1.0 / 2; + NSData *mismatch = [_store snapshotForDisplay:0]; + XCTAssertEqualObjects(WithoutVersion(mismatch), (@[ @1, @0, @0, @402, @874 ])); + XCTAssertNotEqual(VersionOf(before), VersionOf(mismatch)); + double v[] = {10, 10, 20, 20}; + XCTAssertTrue([_store publishForOwner:1 epoch:1 sequence:++_sequence views:@{@0 : [self viewOf:Doubles(v, 4) ratio:2]}]); + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @10, @10, @20, @20 ])); +} + +- (void)testAViewScaledOnScreenScalesTheRectanglesToo { + [_store setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:0 x:100 y:200 scaleX:0.9 scaleY:0.9] forOwner:1 viewId:0]; + double v[] = {100, 100, 180, 140}; + [_store publishForOwner:1 epoch:1 sequence:1 views:@{@0 : [self viewOf:Doubles(v, 4)]}]; + // 100*0.9 = 90 -> 190, 180*0.9 = 162 -> 262; y: 90 -> 290, 126 -> 326 + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @190, @290, @262, @326 ])); + [_store setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:0 x:100 y:200] forOwner:1 viewId:0]; + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @200, @300, @280, @340 ])); +} + +- (void)testAViewScaleThatIsNotUsableCoversTheWholeScreen { + double v[] = {10, 10, 20, 20}; + [_store publishForOwner:1 epoch:1 sequence:1 views:@{@0 : [self viewOf:Doubles(v, 4)]}]; + for (NSNumber *bad in @[ @0, @-1, @(NAN) ]) { + [_store setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:0 x:0 y:0 scaleX:bad.doubleValue scaleY:1] + forOwner:1 + viewId:0]; + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @0, @0, @402, @874 ])); + [_store setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:0 x:0 y:0 scaleX:1 scaleY:bad.doubleValue] + forOwner:1 + viewId:0]; + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @0, @0, @402, @874 ])); + } +} + +#pragma mark - grace window + +- (void)testAMovedRegionIsCoveredWhereItWasTooThenTheUnionShrinksAndMovesTheVersion { + __block NSTimeInterval now = 100; + BGSFlutterSecureRectangleStore *g = [[BGSFlutterSecureRectangleStore alloc] + initWithEnvironment:_screen + clock:^NSTimeInterval { + return now; + } + grace:[BGSFlutterSecureRectangleStore graceSeconds]]; + [g setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:0 x:0 y:0] forOwner:1 viewId:0]; + double a[] = {0, 0, 10, 10}, b[] = {5, 0, 15, 10}; + [g publishForOwner:1 epoch:1 sequence:1 views:@{@0 : [self viewOf:Doubles(a, 4)]}]; + NSData *first = [g snapshotForDisplay:0]; + XCTAssertEqualObjects(WithoutVersion(first), (@[ @1, @0, @0, @10, @10 ]), @"a new region has no previous place"); + now += 0.01; + [g publishForOwner:1 epoch:1 sequence:2 views:@{@0 : [self viewOf:Doubles(b, 4)]}]; + NSData *moved = [g snapshotForDisplay:0]; + XCTAssertEqualObjects(WithoutVersion(moved), (@[ @2, @5, @0, @15, @10, @0, @0, @10, @10 ])); + XCTAssertNotEqual(VersionOf(first), VersionOf(moved)); + now += [BGSFlutterSecureRectangleStore graceSeconds] - 0.001; + XCTAssertEqual(VersionOf([g snapshotForDisplay:0]), VersionOf(moved), @"inside the window"); + now += 0.001; + NSData *shrunk = [g snapshotForDisplay:0]; + XCTAssertEqualObjects(WithoutVersion(shrunk), (@[ @1, @5, @0, @15, @10 ])); + XCTAssertNotEqual(VersionOf(moved), VersionOf(shrunk), @"shrinking is a change the SDK must re-read"); + XCTAssertEqual(VersionOf([g snapshotForDisplay:0]), VersionOf(shrunk)); +} + +- (void)testAfterALongWalkTooManyPastRectanglesStillCoverTheLastStepToo { + __block NSTimeInterval now = 100; + BGSFlutterSecureRectangleStore *g = [[BGSFlutterSecureRectangleStore alloc] + initWithEnvironment:_screen + clock:^NSTimeInterval { + return now; + } + grace:[BGSFlutterSecureRectangleStore graceSeconds]]; + [g setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:0 x:0 y:0] forOwner:1 viewId:0]; + for (int i = 0; i < 60; i++) { + double step[] = {i * 5.0, 0, i * 5.0 + 5, 10}; + [g publishForOwner:1 epoch:1 sequence:1 + i views:@{@0 : [self viewOf:Doubles(step, 4)]}]; + [g snapshotForDisplay:0]; + now += 0.08; + } + double many[4 * 70]; + for (int i = 0; i < 70; i++) { + many[4 * i] = i; + many[4 * i + 1] = 0; + many[4 * i + 2] = i + 1; + many[4 * i + 3] = 5; + } + now += 1; + [g publishForOwner:1 epoch:1 sequence:100 views:@{@0 : [self viewOf:Doubles(many, 280)]}]; + [g snapshotForDisplay:0]; + now += 0.01; + double far[] = {300, 300, 310, 310}; + [g publishForOwner:1 epoch:1 sequence:101 views:@{@0 : [self viewOf:Doubles(far, 4)]}]; + // the walk's last step stopped being current only 10 ms ago, so it is covered too + XCTAssertEqualObjects(WithoutVersion([g snapshotForDisplay:0]), + (@[ @3, @300, @300, @310, @310, @0, @0, @70, @5, @295, @0, @300, @10 ])); +} + +- (void)testARegionThatWentIsStillCoveredForTheWindow { + __block NSTimeInterval now = 100; + BGSFlutterSecureRectangleStore *g = [[BGSFlutterSecureRectangleStore alloc] + initWithEnvironment:_screen + clock:^NSTimeInterval { + return now; + } + grace:[BGSFlutterSecureRectangleStore graceSeconds]]; + [g setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:0 x:0 y:0] forOwner:1 viewId:0]; + double a[] = {0, 0, 10, 10}; + [g publishForOwner:1 epoch:1 sequence:1 views:@{@0 : [self viewOf:Doubles(a, 4)]}]; + [g snapshotForDisplay:0]; + now += 0.01; + [g publishForOwner:1 epoch:1 sequence:2 views:@{}]; + NSData *gone = [g snapshotForDisplay:0]; + XCTAssertEqualObjects(WithoutVersion(gone), (@[ @1, @0, @0, @10, @10 ])); + now += [BGSFlutterSecureRectangleStore graceSeconds]; + NSData *empty = [g snapshotForDisplay:0]; + XCTAssertEqual(((const int32_t *)empty.bytes)[1], 0); + XCTAssertNotEqual(VersionOf(gone), VersionOf(empty)); +} + +- (void)testContinuousMovementCoversEveryRecentPlaceAndTooManyCollapse { + __block NSTimeInterval now = 100; + BGSFlutterSecureRectangleStore *g = [[BGSFlutterSecureRectangleStore alloc] + initWithEnvironment:_screen + clock:^NSTimeInterval { + return now; + } + grace:[BGSFlutterSecureRectangleStore graceSeconds]]; + [g setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:0 x:0 y:0] forOwner:1 viewId:0]; + double a[] = {0, 0, 10, 10}, b[] = {5, 0, 15, 10}, c[] = {10, 0, 20, 10}; + [g publishForOwner:1 epoch:1 sequence:1 views:@{@0 : [self viewOf:Doubles(a, 4)]}]; + [g snapshotForDisplay:0]; + now += 0.01; + [g publishForOwner:1 epoch:1 sequence:2 views:@{@0 : [self viewOf:Doubles(b, 4)]}]; + [g snapshotForDisplay:0]; + now += 0.04; + [g publishForOwner:1 epoch:1 sequence:3 views:@{@0 : [self viewOf:Doubles(c, 4)]}]; + XCTAssertEqual(((const int32_t *)[g snapshotForDisplay:0].bytes)[1], 3, @"current + both earlier places"); + // Each past place is served for a window from when IT stopped being current: A at +0.01, B at +0.05. + now = 100 + 0.129; + XCTAssertEqual(((const int32_t *)[g snapshotForDisplay:0].bytes)[1], 3); + now = 100 + 0.131; + XCTAssertEqual(((const int32_t *)[g snapshotForDisplay:0].bytes)[1], 2, @"A has had its window"); + now = 100 + 0.169; + XCTAssertEqual(((const int32_t *)[g snapshotForDisplay:0].bytes)[1], 2); + now = 100 + 0.171; + XCTAssertEqual(((const int32_t *)[g snapshotForDisplay:0].bytes)[1], 1, @"B has had its window"); + + // A long movement must not widen the cover to its whole path. + NSInteger most = 0; + for (int i = 0; i < 60; i++) { + double step[] = {i * 10.0, 0, i * 10.0 + 10, 10}; + [g publishForOwner:1 epoch:1 sequence:100 + i views:@{@0 : [self viewOf:Doubles(step, 4)]}]; + most = MAX(most, (NSInteger)((const int32_t *)[g snapshotForDisplay:0].bytes)[1]); + now += 0.08; + } + XCTAssertLessThanOrEqual(most, 3, @"the current and at most the previous place"); + + double many[4 * 70]; + for (int i = 0; i < 70; i++) { + many[4 * i] = i; + many[4 * i + 1] = 0; + many[4 * i + 2] = i + 1; + many[4 * i + 3] = 5; + } + // Too many past rectangles collapse to a box: a fresh store, so only the 70 are past. + g = [[BGSFlutterSecureRectangleStore alloc] initWithEnvironment:_screen + clock:^NSTimeInterval { + return now; + } + grace:[BGSFlutterSecureRectangleStore graceSeconds]]; + [g setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:0 x:0 y:0] forOwner:1 viewId:0]; + now += 1; + [g publishForOwner:1 epoch:1 sequence:1000 views:@{@0 : [self viewOf:Doubles(many, 280)]}]; + [g snapshotForDisplay:0]; + now += 0.01; + double far[] = {300, 300, 310, 310}; + [g publishForOwner:1 epoch:1 sequence:1001 views:@{@0 : [self viewOf:Doubles(far, 4)]}]; + XCTAssertEqualObjects(WithoutVersion([g snapshotForDisplay:0]), + (@[ @2, @300, @300, @310, @310, @0, @0, @70, @5 ]), @"one box over the 70 old ones"); +} + +- (void)testTheOrderingIsPerOwnerAndAPublishReplacesOnlyItsOwnersViews { + [_store setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:0 x:0 y:0] forOwner:1 viewId:0]; + [_store setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:0 x:0 y:0] forOwner:2 viewId:0]; + double a[] = {1, 1, 2, 2}, b[] = {4, 4, 5, 5}; + XCTAssertTrue([_store publishForOwner:1 epoch:1 sequence:50 views:@{@0 : [self viewOf:Doubles(a, 4)]}]); + XCTAssertTrue([_store publishForOwner:2 epoch:1 sequence:1 views:@{@0 : [self viewOf:Doubles(b, 4)]}]); + XCTAssertEqual(((const int32_t *)[_store snapshotForDisplay:0].bytes)[1], 2); + XCTAssertTrue([_store publishForOwner:1 epoch:1 sequence:51 views:@{}]); + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @4, @4, @5, @5 ])); +} + +- (void)testDroppingAnOwnerClearsItsRectanglesAndOrdering { + [_store setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:0 x:0 y:0] forOwner:1 viewId:0]; + double a[] = {1, 1, 2, 2}; + [_store publishForOwner:1 epoch:9 sequence:9 views:@{@0 : [self viewOf:Doubles(a, 4)]}]; + [_store dropOwner:1]; + XCTAssertEqual(((const int32_t *)[_store snapshotForDisplay:0].bytes)[1], 0); + [_store setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:0 x:0 y:0] forOwner:1 viewId:0]; + XCTAssertTrue([_store publishForOwner:1 epoch:1 sequence:1 views:@{@0 : [self viewOf:Doubles(a, 4)]}]); + XCTAssertEqual(((const int32_t *)[_store snapshotForDisplay:0].bytes)[1], 1); +} + +#pragma mark - ownership (5.4.1) + +- (void)testWhatTheSdkReceivesIsAnImmutableSnapshotSharedSafely { + PUBLISH(1, 1, 2, 2); + NSData *first = [_store snapshotForDisplay:0]; + XCTAssertFalse([first isKindOfClass:[NSMutableData class]]); + PUBLISH(5, 5, 6, 6); + // What an earlier pull returned is untouched by a later publish. + XCTAssertEqualObjects(WithoutVersion(first), (@[ @1, @1, @1, @2, @2 ])); +} + +- (void)testReadersOnAnotherQueueSeeOnlyWholeSetsAndConsistentVersions { + [_store setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:0 x:0 y:0] forOwner:1 viewId:0]; + __block BOOL stop = NO; + NSLock *lock = [[NSLock alloc] init]; + NSMutableDictionary *contentByVersion = [NSMutableDictionary dictionary]; + NSMutableArray *failures = [NSMutableArray array]; + dispatch_group_t group = dispatch_group_create(); + for (int t = 0; t < 2; t++) { + dispatch_group_async(group, dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + while (!stop) { + NSData *s = [self->_store snapshotForDisplay:0]; + NSArray *ints = Ints(s); + NSInteger n = [ints[1] integerValue]; + NSString *problem = nil; + if (ints.count != 2 + 4 * (NSUInteger)n) problem = @"header and length disagree"; + for (NSInteger r = 0; !problem && r < n; r++) { + NSInteger l = [ints[2 + 4 * r] integerValue]; + if ([ints[3 + 4 * r] integerValue] != l || [ints[4 + 4 * r] integerValue] != l + 100 || + [ints[5 + 4 * r] integerValue] != l + 100 || l != r) { + problem = @"torn rectangle"; + } + } + NSArray *content = [ints subarrayWithRange:NSMakeRange(1, ints.count - 1)]; + [lock lock]; + NSArray *seen = contentByVersion[ints[0]]; + if (seen == nil) { + contentByVersion[ints[0]] = content; + } else if (![seen isEqual:content]) { + problem = @"one version meant two sets"; + } + if (problem) [failures addObject:problem]; + [lock unlock]; + } + }); + } + for (int i = 1; i <= 3000; i++) { + int k = 1 + (i % 12); + double set[4 * 12]; + for (int r = 0; r < k; r++) { + set[4 * r] = r; + set[4 * r + 1] = r; + set[4 * r + 2] = r + 100; + set[4 * r + 3] = r + 100; + } + [_store publishForOwner:1 epoch:1 sequence:i views:@{@0 : [self viewOf:Doubles(set, 4 * k)]}]; + } + stop = YES; + dispatch_group_wait(group, DISPATCH_TIME_FOREVER); + XCTAssertEqual(failures.count, 0u, @"%@", failures.firstObject); +} + +#pragma mark - the SDK's pull + +- (void)testAPullCallsTheHookFirstAndSurvivesItThrowing { + PUBLISH(1, 1, 2, 2); + __block int calls = 0; + [_store setPullHook:^{ + calls++; + @throw [NSException exceptionWithName:@"hook" reason:@"failed" userInfo:nil]; + }]; + XCTAssertEqual(((const int32_t *)[_store pullForDisplay:0].bytes)[1], 1); + XCTAssertEqual(calls, 1); +} + +@end diff --git a/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterWrapperTests.m b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterWrapperTests.m index e0bcd46..8fd7328 100644 --- a/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterWrapperTests.m +++ b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterWrapperTests.m @@ -6,16 +6,38 @@ @import Bugsee; -@interface BGSFlutterWrapperTests : XCTestCase +/// A 3x phone: a Flutter logical pixel is a point, so the factor is 1. +@interface BGSWrapperTestScreen : NSObject +@end +@implementation BGSWrapperTestScreen +- (double)nativePixelRatioForDisplay:(NSInteger)display { + return 3.0; +} +- (double)unitsPerPhysicalPixelForDisplay:(NSInteger)display { + return 1.0 / 3.0; +} +- (CGSize)displayBoundsInPointsForDisplay:(NSInteger)display { + return CGSizeMake(402, 874); +} @end -@implementation BGSFlutterWrapperTests +static void PublishOnDisplay(BGSFlutterSecureRectangleStore *store, NSInteger display) { + [store setEnvironment:[[BGSWrapperTestScreen alloc] init]]; + [store setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:display x:0 y:0] forOwner:1 viewId:0]; + double rect[] = {10, 20, 30, 40}; + [store publishForOwner:1 epoch:1 sequence:1 views:@{@0 : [[BGSFlutterSecureViewRects alloc] initWithRatio:3 rects:[NSData dataWithBytes:rect length:sizeof(rect)]]}]; +} -static NSData *Ints(int32_t a, int32_t b, int32_t c, int32_t d, int32_t e, int32_t f) { - int32_t ints[6] = {a, b, c, d, e, f}; +static NSData *Buffer(int32_t version, int32_t l, int32_t t, int32_t r, int32_t b) { + int32_t ints[6] = {version, 1, l, t, r, b}; return [NSData dataWithBytes:ints length:sizeof(ints)]; } +@interface BGSFlutterWrapperTests : XCTestCase +@end + +@implementation BGSFlutterWrapperTests + - (void)tearDown { [[BGSFlutterSecureRectangleStore shared] clearForTests]; [BGSFlutterWrapperRegistrar setRegistrationHandlerForTests:nil]; @@ -83,8 +105,8 @@ - (void)testLifecycleEventsGoToTheHub { /// Workbook 5.4.2: the wrapper is replaced when Dart refines the identity; what /// it serves must not live in it. - (void)testReplacingTheWrapperKeepsTheSecureRectangleSet { - NSData *set = Ints(7, 1, 10, 20, 30, 40); - [[BGSFlutterSecureRectangleStore shared] publish:set forDisplay:0]; + PublishOnDisplay([BGSFlutterSecureRectangleStore shared], 0); + NSData *set = Buffer(1, 10, 20, 30, 40); id placeholder = (id)[BGSFlutterWrapper wrapperAtProcessStart]; XCTAssertEqualObjects([placeholder secureRectanglesForDisplay:0], set); @@ -106,21 +128,17 @@ - (void)testReplacingTheWrapperKeepsTheSecureRectangleSet { // Other displays are unaffected: the empty set, version unknown (0). XCTAssertEqualObjects([(id)refined secureRectanglesForDisplay:1], ([NSData dataWithBytes:(int32_t[]){0, 0} length:8])); -} - -- (void)testSecureRectangleSnapshotsAreImmutableCopiesPerDisplay { - BGSFlutterSecureRectangleStore *store = [BGSFlutterSecureRectangleStore new]; - NSMutableData *mine = [Ints(3, 0, 0, 0, 0, 0) mutableCopy]; - [store publish:mine forDisplay:0]; - [store publish:Ints(5, 1, 1, 1, 2, 2) forDisplay:1]; - int32_t changed = 99; - [mine replaceBytesInRange:NSMakeRange(0, 4) withBytes:&changed]; - int32_t first = 0; - [[store snapshotForDisplay:0] getBytes:&first length:4]; - XCTAssertEqual(first, 3, @"a later write by the caller must not reach the SDK's snapshot"); - int32_t other = 0; - [[store snapshotForDisplay:1] getBytes:&other length:4]; - XCTAssertEqual(other, 5); + // A change after the replacement reaches the SDK through the new wrapper, with a new version. + double moved[] = {10, 20, 31, 40}; + [[BGSFlutterSecureRectangleStore shared] publishForOwner:1 + epoch:1 + sequence:2 + views:@{@0 : [[BGSFlutterSecureViewRects alloc] initWithRatio:3 rects:[NSData dataWithBytes:moved length:sizeof(moved)]]}]; + // (The shared store has the grace window, so the old place is served after the new one.) + NSData *changed = [(id)refined secureRectanglesForDisplay:0]; + int32_t head[6] = {2, 2, 10, 20, 31, 40}; + XCTAssertEqualObjects([changed subdataWithRange:NSMakeRange(0, sizeof(head))], + [NSData dataWithBytes:head length:sizeof(head)]); } @end diff --git a/lib/src/bridge/messages.g.dart b/lib/src/bridge/messages.g.dart index ec144f6..1053c33 100644 --- a/lib/src/bridge/messages.g.dart +++ b/lib/src/bridge/messages.g.dart @@ -227,6 +227,135 @@ class WrapperIdentityMessage { } } +/// The rectangles of one Flutter view, flat `[l, t, r, b, l, t, r, b, ...]`, in +/// the view's own LOGICAL pixels (Flutter logical pixels: iOS points, Android +/// dp), origin at the view's top-left, right/bottom exclusive. Native converts +/// them to the SDK's unit and places the view on the screen. +class SecureViewRectanglesMessage { + SecureViewRectanglesMessage({ + required this.viewId, + required this.devicePixelRatio, + required this.rectangles, + }); + + /// `FlutterView.viewId`. Mobile engines have only the implicit view (0). + int viewId; + + /// `FlutterView.devicePixelRatio` the rectangles were measured with. Native + /// scales by THIS, not by what the display reports now: a density change is + /// applied natively before Dart has laid out again, so rectangles measured at + /// the old ratio must not be scaled by the new one. When the two differ, + /// native covers the whole display until Dart republishes. + double devicePixelRatio; + + List rectangles; + + List _toList() { + return [viewId, devicePixelRatio, rectangles]; + } + + Object encode() { + return _toList(); + } + + static SecureViewRectanglesMessage decode(Object result) { + result as List; + return SecureViewRectanglesMessage( + viewId: result[0]! as int, + devicePixelRatio: result[1]! as double, + rectangles: (result[2]! as List).cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! SecureViewRectanglesMessage || + other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(viewId, other.viewId) && + _deepEquals(devicePixelRatio, other.devicePixelRatio) && + _deepEquals(rectangles, other.rectangles); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'SecureViewRectanglesMessage(viewId: $viewId, devicePixelRatio: $devicePixelRatio, rectangles: $rectangles)'; + } +} + +/// The COMPLETE set of secure rectangles of this Dart isolate: a view that is +/// absent from [views] has none. Whole-state, not a delta, so a lost or late +/// message can never leave a rectangle behind. +/// +/// Ordering: within one `epoch` native ignores a message whose `sequence` is +/// not newer than the newest it accepted. `epoch` is a random id of the Dart +/// isolate (never a clock: a clock can go backwards). A message with an epoch +/// native has not seen starts a new isolate (a hot restart; its counter restarts +/// at 1) and replaces the old one's state; a message of an epoch native has +/// already retired is a late straggler of a dead isolate and is ignored. +class SecureRectanglesMessage { + SecureRectanglesMessage({ + required this.epoch, + required this.sequence, + required this.views, + }); + + int epoch; + + int sequence; + + List views; + + List _toList() { + return [epoch, sequence, views]; + } + + Object encode() { + return _toList(); + } + + static SecureRectanglesMessage decode(Object result) { + result as List; + return SecureRectanglesMessage( + epoch: result[0]! as int, + sequence: result[1]! as int, + views: (result[2]! as List).cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! SecureRectanglesMessage || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(epoch, other.epoch) && + _deepEquals(sequence, other.sequence) && + _deepEquals(views, other.views); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'SecureRectanglesMessage(epoch: $epoch, sequence: $sequence, views: $views)'; + } +} + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -240,6 +369,12 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is WrapperIdentityMessage) { buffer.putUint8(130); writeValue(buffer, value.encode()); + } else if (value is SecureViewRectanglesMessage) { + buffer.putUint8(131); + writeValue(buffer, value.encode()); + } else if (value is SecureRectanglesMessage) { + buffer.putUint8(132); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -252,6 +387,10 @@ class _PigeonCodec extends StandardMessageCodec { return LifecycleEventMessage.decode(readValue(buffer)!); case 130: return WrapperIdentityMessage.decode(readValue(buffer)!); + case 131: + return SecureViewRectanglesMessage.decode(readValue(buffer)!); + case 132: + return SecureRectanglesMessage.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -317,6 +456,31 @@ class BugseeBridgeHostApi { isNullValid: true, ); } + + /// Publishes the secure rectangles (see [SecureRectanglesMessage]). Never + /// throws on content: an unusable value fails closed natively. Returns + /// whether the message was applied (`false`: it was older than one already + /// applied, which is not an error). + Future setSecureRectangles(SecureRectanglesMessage state) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.setSecureRectangles$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [state], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as bool; + } } /// Native calls Dart. Always from the platform (main) thread. diff --git a/lib/src/secure/secure_region.dart b/lib/src/secure/secure_region.dart new file mode 100644 index 0000000..6e7e609 --- /dev/null +++ b/lib/src/secure/secure_region.dart @@ -0,0 +1,617 @@ +// Secure regions: the Dart half of workbook Part 5. Internal: not exported from +// package:bugsee_flutter; Phase 4 decides the public shape. +// +// A Flutter app is ONE host view, so the SDK's only way to learn what must not +// be recorded is a set of rectangles that this code measures and publishes. A +// wrong rectangle leaks pixels and also tap coordinates, so every ambiguous +// case here resolves to "cover MORE". +// +// What is reported, and why each case fails closed: +// +// * Position: the region's box, run through every ancestor's paint transform +// (Transform, RotatedBox, scrolling offsets, ...), as the bounding box of the +// result. A rotated region is reported as its (larger) axis-aligned box. +// * Clipping: NOT applied. A region that a ClipRect/viewport clips partly is +// reported with its full, unclipped box, so every visible part of it is +// covered (at the cost of covering some neighbouring pixels the clip hid). +// Intersecting with ancestor clips would need every clip shape modelled +// exactly (paths, rounded rects, a clip that animates); one error leaks. +// * Hidden: a region is dropped ONLY on positive evidence that none of its +// pixels are on screen: an ancestor that does not paint it (`paintsChild`: +// Offstage, Visibility, zero Opacity), an IndexedStack showing another child, +// or a clipping scroll viewport that its region (with what its descendants +// paint) lies wholly outside. Nothing else drops it. TickerMode is NOT +// evidence: it only stops animations (reduce-motion, a paused tab), the +// content is still painted. In particular a detached layer is +// NOT evidence: SnapshotWidget (the Zoom page transition) paints a region into +// a temporary layer and draws the cached image, so its pixels are on screen +// while its own layer is not attached. A region that has never been laid out +// is not reported (it has no geometry and has drawn nothing). +// * Overflow: what the region's descendants paint outside its box (a shifted +// Transform, an OverflowBox) is included. What is drawn outside its subtree +// (an Overlay magnifier, a tooltip) is not known to Flutter and not covered. +// * Not measurable (a transform that yields NaN/infinity, an exception while +// measuring): the WHOLE view is reported. +// * Degenerate (zero or negative area): nothing to hide; not reported. +// +// Changes are coalesced: the registry measures once per frame, after the frame +// (a post-frame callback), and publishes only when the set differs from what it +// last sent. Position changes that need no layout or paint of the region itself +// (scrolling inside a RepaintBoundary, a transform animation) are caught +// because the registry measures every frame while any region exists; a frame +// that never happens cannot have moved anything. +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; + +import '../bridge/messages.g.dart'; + +/// Sends one whole-state message to native. Returns whether it was applied. +typedef SecureRectanglesPublisher = + Future Function(SecureRectanglesMessage state); + +/// One region's measured box: logical pixels, relative to its view. +@immutable +class MeasuredRegion { + const MeasuredRegion(this.viewId, this.ratio, this.rect); + final int viewId; + + /// The view's device pixel ratio when it was measured. + final double ratio; + final Rect rect; + + @override + bool operator ==(Object other) => + other is MeasuredRegion && + other.viewId == viewId && + other.ratio == ratio && + other.rect == rect; + + @override + int get hashCode => Object.hash(viewId, ratio, rect); + + @override + String toString() => 'MeasuredRegion(view $viewId @$ratio, $rect)'; +} + +/// What one view publishes: the ratio it was measured at and its canonical +/// rectangles. +@immutable +class ViewRects { + const ViewRects(this.ratio, this.rects); + final double ratio; + final List rects; +} + +/// The rectangles of every view: view id to its ratio and rectangles. +typedef SecureState = Map; + +bool _rectFinite(Rect r) => + r.left.isFinite && r.top.isFinite && r.right.isFinite && r.bottom.isFinite; + +/// Sorted, de-duplicated, without empty rectangles: the same set in any order +/// is the same state, so reordering regions never counts as a change. +List canonicalRects(Iterable rects) { + final list = + rects.where((r) => r.right > r.left && r.bottom > r.top).toSet().toList() + ..sort((a, b) { + var c = a.left.compareTo(b.left); + if (c == 0) c = a.top.compareTo(b.top); + if (c == 0) c = a.right.compareTo(b.right); + if (c == 0) c = a.bottom.compareTo(b.bottom); + return c; + }); + return list; +} + +/// Whether two states are the same set of rectangles per view, measured at the +/// same ratio (a ratio change must be republished even if no rectangle moved). +bool sameState(SecureState a, SecureState b) { + if (a.length != b.length) return false; + for (final entry in a.entries) { + final other = b[entry.key]; + if (other == null || + other.ratio != entry.value.ratio || + other.rects.length != entry.value.rects.length) { + return false; + } + for (var i = 0; i < other.rects.length; i++) { + if (other.rects[i] != entry.value.rects[i]) return false; + } + } + return true; +} + +/// Descendants visited when looking for paint outside a region's box; more than +/// this and the region is reported as the whole view (fail closed). +const int _maxDescendants = 2000; + +/// Whether [box] is PROVABLY not on screen. Only positive evidence counts; see +/// the library comment. [painted] is the box together with what its +/// descendants paint, in the view's logical pixels; without it only the +/// evidence that needs no geometry (ancestors that do not paint it) is used. Never throws (an exception +/// means "not provable"). +bool provablyHidden(RenderSecureRegion box, Rect? painted) { + try { + RenderObject child = box; + RenderObject? parent = box.parent; + while (parent != null) { + if (!parent.paintsChild(child)) return true; + if (parent is RenderIndexedStack) { + final visible = parent.index; + if (visible == null) return true; + RenderBox? shown = parent.firstChild; + for (var i = 0; i < visible && shown != null; i++) { + shown = (shown.parentData! as StackParentData).nextSibling; + } + if (!identical(shown, child)) return true; + } + if (painted != null && + parent is RenderViewportBase && + parent.clipBehavior != Clip.none) { + // A viewport clips what lies outside it: a region wholly outside (with + // everything it paints) is invisible (a child kept in the cache extent, + // scrolled out of view). + final Matrix4 toView = parent.getTransformTo(null); + final Matrix4 fromView = Matrix4.tryInvert(toView) ?? Matrix4.zero(); + final Rect inViewport = MatrixUtils.transformRect(fromView, painted); + final Rect port = Offset.zero & parent.size; + if (Matrix4.tryInvert(toView) != null && + _rectFinite(inViewport) && + !inViewport.overlaps(port)) { + return true; + } + } + child = parent; + parent = parent.parent; + } + } catch (_) { + return false; + } + return false; +} + +/// The box of [box] and of everything its descendants paint outside it, in the +/// view's logical pixels, or `null` when that is too much to follow. One +/// top-down pass: each child's transform is its parent's plus one +/// `applyPaintTransform`, not a walk to the root per node. +Rect? _paintedRect(RenderSecureRegion box) { + final Matrix4 toView = box.getTransformTo(null); + var rect = MatrixUtils.transformRect(toView, Offset.zero & box.size); + var visited = 0; + var tooMany = false; + void visit(RenderObject node, Matrix4 nodeToView) { + node.visitChildren((RenderObject child) { + if (tooMany) return; + // A child its parent does not paint has no meaningful geometry (a + // zeroed transform would drag the rect to the view's origin). + if (!node.paintsChild(child)) return; + if (++visited > _maxDescendants) { + tooMany = true; + return; + } + final Matrix4 childToView = nodeToView.clone(); + node.applyPaintTransform(child, childToView); + rect = rect.expandToInclude( + MatrixUtils.transformRect(childToView, child.paintBounds), + ); + visit(child, childToView); + }); + } + + visit(box, toView); + return tooMany ? null : rect; +} + +/// Measures [box] in its view's logical pixels, or `null` when it contributes +/// nothing to what is on screen (see the library comment). Never throws. +MeasuredRegion? measureRegion(RenderSecureRegion box) { + RenderView? view; + try { + if (!box.attached || !box.hasSize) return null; + // Evidence that needs no geometry first: a hidden region is never measured, however + // big its subtree or odd its transform. + if (provablyHidden(box, null)) return null; + // The root of this box's tree is its view. + RenderObject root = box; + while (root.parent != null) { + root = root.parent!; + } + if (root is! RenderView) return null; + view = root; + final size = box.size; + if (size.width.isNaN || size.height.isNaN) return _wholeView(view); + if (!(size.width > 0 && size.height > 0)) { + return null; // zero or negative size: nothing to hide + } + final Rect? rect = _paintedRect(box); + if (rect != null && _rectFinite(rect) && provablyHidden(box, rect)) { + return null; + } + return rect != null && _rectFinite(rect) + ? MeasuredRegion( + view.flutterView.viewId, + view.flutterView.devicePixelRatio, + rect, + ) + : _wholeView(view); + } catch (_) { + return view == null ? null : _wholeView(view); + } +} + +MeasuredRegion? _wholeView(RenderView view) { + final int id = view.flutterView.viewId; + final double ratio = view.flutterView.devicePixelRatio; + try { + final Size physical = view.flutterView.physicalSize; + final Size logical = ratio > 0 && ratio.isFinite + ? Size(physical.width / ratio, physical.height / ratio) + : physical; + final w = logical.width.isFinite ? logical.width : 1048576.0; + final h = logical.height.isFinite ? logical.height : 1048576.0; + return MeasuredRegion( + id, + ratio, + Rect.fromLTWH(0, 0, math.max(w, 1), math.max(h, 1)), + ); + } catch (_) { + return MeasuredRegion( + id, + ratio, + const Rect.fromLTWH(0, 0, 1048576, 1048576), + ); + } +} + +/// Tracks every [RenderSecureRegion], measures them once per frame and +/// publishes the set when it changed. +class SecureRegionRegistry { + SecureRegionRegistry({ + required this.publish, + int? epoch, + this.retryDelay = const Duration(seconds: 1), + this.maxRetryDelay = const Duration(seconds: 30), + this.maxRetries = 5, + Duration Function()? clock, + }) : epoch = epoch ?? _randomEpoch(), + _now = clock ?? _stopwatchClock(); + + /// Monotonic time since the registry was made; replaced in tests. + static Duration Function() _stopwatchClock() { + final watch = Stopwatch()..start(); + return () => watch.elapsed; + } + + final Duration Function() _now; + + /// A random id for this isolate, never a clock (a clock can go backwards + /// before a hot restart, and native would then reject the new isolate). + static int _randomEpoch() { + final random = math.Random.secure(); + final int id = (random.nextInt(1 << 30) << 22) | random.nextInt(1 << 22); + return id == 0 ? 1 : id; + } + + /// The process-wide registry that widgets use unless given another. + static SecureRegionRegistry get instance => + _instance ??= SecureRegionRegistry(publish: _publishToNative); + static SecureRegionRegistry? _instance; + + static Future _publishToNative(SecureRectanglesMessage state) => + BugseeBridgeHostApi().setSecureRectangles(state); + + final SecureRectanglesPublisher publish; + + /// Identifies this isolate to native (see [SecureRectanglesMessage]). + final int epoch; + + /// How long to wait before trying again after a failed publish; `null` + /// leaves the retry to the next frame that happens (no timer, for tests). + /// + /// The delay doubles with every consecutive failure (1 s, 2 s, 4 s, ...) up + /// to [maxRetryDelay], and the registry NEVER gives up: native keeps the last + /// state it accepted, so a state that was never delivered leaves a stale mask + /// (a new region recorded in the clear) until something resends it. A + /// transport that is down for good (no plugin on this platform) therefore + /// costs one message per [maxRetryDelay], and one that recovers after a long + /// outage is republished within that time. Deterministic steps, no jitter. + final Duration? retryDelay; + + /// The longest wait between two retries of a failing publish. + final Duration maxRetryDelay; + + /// After this many consecutive failures the transport counts as known bad: + /// with a [retryDelay], at most one message per [retryDelay] is sent (the + /// same failed state waits for the backoff timer; a CHANGED state, the only + /// kind that can add a region, waits only until [retryDelay] has passed since + /// the last attempt, so a transport that came back is not waited on for the + /// whole backoff, and one that did not is never hit per frame); without a + /// [retryDelay], only the resend of the very same failed state is suppressed. + final int maxRetries; + + final Set _regions = {}; + bool _scheduled = false; + int _sequence = 0; + int _failures = 0; + Timer? _retry; + bool _retryDue = false; + bool _expedited = false; + Duration _lastSend = Duration.zero; + bool _disposed = false; + SecureState? _failedState; + + /// What native is known to hold, or `null` when that is unknown (a publish + /// failed): then the next measurement is sent even if it is empty. + SecureState? _published = {}; + + /// How many publishes were attempted. For tests. + @visibleForTesting + int get publishCount => _sequence; + + /// The regions currently registered. For tests. + @visibleForTesting + int get regionCount => _regions.length; + + void add(RenderSecureRegion region) { + if (_regions.add(region)) _schedule(); + } + + void remove(RenderSecureRegion region) { + if (_regions.remove(region)) _schedule(); + } + + void _schedule() { + final binding = SchedulerBinding.instance; + if (!_scheduled) { + _scheduled = true; + binding.addPostFrameCallback(_afterFrame); + } + // Registration happens in a frame (mount) almost always; this covers the + // rest (and a retry), and is a no-op when a frame is already scheduled. + binding.ensureVisualUpdate(); + } + + void _afterFrame(Duration _) { + _scheduled = false; + flush(); + // Scroll offsets and transforms can move a region with no layout or paint + // of its own, so keep measuring on every frame while any region exists. + // This schedules no frame; it only runs when one happens. + if (_regions.isNotEmpty && !_scheduled) { + _scheduled = true; + SchedulerBinding.instance.addPostFrameCallback(_afterFrame); + } + } + + /// Measures now and publishes if the set changed. Normally called once per + /// frame by the registry itself; public for tests. + void flush() { + final SecureState state = measure(); + final SecureState? published = _published; + if (published != null && sameState(published, state)) return; + if (published == null && !_retryDue) { + final failed = _failedState; + final bool sameAsFailed = failed != null && sameState(failed, state); + if (_retry != null) { + // A retry timer is pending: it resends, frames do not. A changed state + // is still tried at once, unless the transport is known bad: then it + // goes out once `retryDelay` has passed since the last attempt (an + // animation cannot flood a dead transport, a recovered one is not + // waited on for the whole backoff). + if (sameAsFailed) return; + if (_failures > maxRetries) { + final Duration wait = retryDelay! - (_now() - _lastSend); + if (wait > Duration.zero) { + _expedite(wait); + return; + } + } + } else if (_failures > maxRetries && sameAsFailed) { + // No timer (frame-driven mode): stop resending this very state every + // frame. A changed state is still sent, and a success resets the count. + return; + } + } + _send(state); + } + + /// The current state of every view, as it would be published. + SecureState measure() { + final byView = >{}; + final ratios = {}; + for (final region in _regions) { + final m = measureRegion(region); + if (m == null) continue; + (byView[m.viewId] ??= []).add(m.rect); + ratios[m.viewId] = m.ratio; + } + final result = {}; + final ids = byView.keys.toList()..sort(); + for (final id in ids) { + final rects = canonicalRects(byView[id]!); + if (rects.isNotEmpty) result[id] = ViewRects(ratios[id]!, rects); + } + return result; + } + + void _send(SecureState state) { + final sequence = ++_sequence; + _retryDue = false; + _expedited = false; + _lastSend = _now(); + _retry?.cancel(); + _retry = null; + final message = SecureRectanglesMessage( + epoch: epoch, + sequence: sequence, + views: [ + for (final entry in state.entries) + SecureViewRectanglesMessage( + viewId: entry.key, + devicePixelRatio: entry.value.ratio, + rectangles: [ + for (final r in entry.value.rects) ...[ + r.left, + r.top, + r.right, + r.bottom, + ], + ], + ), + ], + ); + // Assume it lands: the next frame must not resend the same thing. A failure + // below marks native's state unknown, which forces a resend. + _published = state; + Future sent; + try { + sent = publish(message); + } catch (e) { + sent = Future.error(e); + } + sent.then( + (_) { + if (sequence != _sequence) return; + _failures = 0; + _failedState = null; + }, + onError: (Object error, StackTrace stack) { + // Only the newest attempt decides: an older one failing after a newer + // one was sent says nothing about native's current state. + if (sequence != _sequence) return; + // Not reported to the app's error handlers: a missing native side + // (desktop, a test) would fire this on every retry. The retry below + // and `lastError` are the record. + _published = null; + _failedState = state; + _failures++; + lastError = error; + final delay = retryDelay; + if (delay != null && !_disposed) _armRetry(_backoff(delay)); + }, + ); + } + + void _armRetry(Duration wait) { + _retry?.cancel(); + _retry = Timer(wait, () { + _retry = null; + _retryDue = true; + _schedule(); + }); + } + + /// A changed state is waiting out the throttle: bring the retry forward to the end of it + /// (once; the frames in between would only move it to the same instant). + void _expedite(Duration wait) { + if (_expedited || _disposed) return; + _expedited = true; + _armRetry(wait); + } + + /// The wait after the current run of failures: [base] doubled per failure + /// after the first, capped at [maxRetryDelay]. + Duration _backoff(Duration base) { + final int doublings = math.min(_failures - 1, 20); + final Duration wait = base * (1 << (doublings < 0 ? 0 : doublings)); + return wait > maxRetryDelay ? maxRetryDelay : wait; + } + + /// The error of the most recent failed publish, if any. For diagnostics. + Object? lastError; + + /// Cancels any pending retry and arms no more. For tests. + @visibleForTesting + void dispose() { + _disposed = true; + _retry?.cancel(); + _retry = null; + } +} + +/// The render object behind [BugseeSecureRegion]. +class RenderSecureRegion extends RenderProxyBox { + RenderSecureRegion({ + required bool enabled, + required SecureRegionRegistry registry, + }) : _enabled = enabled, + _registry = registry; + + bool _enabled; + SecureRegionRegistry _registry; + + bool get enabled => _enabled; + set enabled(bool value) { + if (value == _enabled) return; + _enabled = value; + if (!attached) return; + value ? _registry.add(this) : _registry.remove(this); + } + + SecureRegionRegistry get registry => _registry; + set registry(SecureRegionRegistry value) { + if (identical(value, _registry)) return; + if (attached && _enabled) _registry.remove(this); + _registry = value; + if (attached && _enabled) _registry.add(this); + } + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + if (_enabled) _registry.add(this); + } + + @override + void detach() { + _registry.remove(this); + super.detach(); + } + + @override + void dispose() { + _registry.remove(this); + super.dispose(); + } +} + +/// Marks its child as a region the SDK must not record: redacted in the video +/// and the report screenshot, and taps on it are dropped from the report. +/// +/// Internal for now (Phase 4 exposes it). See the library comment for how +/// transformed, clipped, scrolled and hidden regions are reported. +class BugseeSecureRegion extends SingleChildRenderObjectWidget { + const BugseeSecureRegion({ + super.key, + this.enabled = true, + this.registry, + super.child, + }); + + /// `false` removes the redaction (the child is shown as usual). + final bool enabled; + + /// For tests; the process-wide registry by default. + final SecureRegionRegistry? registry; + + @override + RenderSecureRegion createRenderObject(BuildContext context) => + RenderSecureRegion( + enabled: enabled, + registry: registry ?? SecureRegionRegistry.instance, + ); + + @override + void updateRenderObject( + BuildContext context, + RenderSecureRegion renderObject, + ) { + renderObject + ..registry = registry ?? SecureRegionRegistry.instance + ..enabled = enabled; + } +} diff --git a/pigeons/bridge.dart b/pigeons/bridge.dart index 04a576b..3c26990 100644 --- a/pigeons/bridge.dart +++ b/pigeons/bridge.dart @@ -52,6 +52,51 @@ class WrapperIdentityMessage { Map context; } +/// The rectangles of one Flutter view, flat `[l, t, r, b, l, t, r, b, ...]`, in +/// the view's own LOGICAL pixels (Flutter logical pixels: iOS points, Android +/// dp), origin at the view's top-left, right/bottom exclusive. Native converts +/// them to the SDK's unit and places the view on the screen. +class SecureViewRectanglesMessage { + SecureViewRectanglesMessage({ + required this.viewId, + required this.devicePixelRatio, + required this.rectangles, + }); + + /// `FlutterView.viewId`. Mobile engines have only the implicit view (0). + int viewId; + + /// `FlutterView.devicePixelRatio` the rectangles were measured with. Native + /// scales by THIS, not by what the display reports now: a density change is + /// applied natively before Dart has laid out again, so rectangles measured at + /// the old ratio must not be scaled by the new one. When the two differ, + /// native covers the whole display until Dart republishes. + double devicePixelRatio; + List rectangles; +} + +/// The COMPLETE set of secure rectangles of this Dart isolate: a view that is +/// absent from [views] has none. Whole-state, not a delta, so a lost or late +/// message can never leave a rectangle behind. +/// +/// Ordering: within one `epoch` native ignores a message whose `sequence` is +/// not newer than the newest it accepted. `epoch` is a random id of the Dart +/// isolate (never a clock: a clock can go backwards). A message with an epoch +/// native has not seen starts a new isolate (a hot restart; its counter restarts +/// at 1) and replaces the old one's state; a message of an epoch native has +/// already retired is a late straggler of a dead isolate and is ignored. +class SecureRectanglesMessage { + SecureRectanglesMessage({ + required this.epoch, + required this.sequence, + required this.views, + }); + + int epoch; + int sequence; + List views; +} + /// Dart calls native. @HostApi() abstract class BugseeBridgeHostApi { @@ -62,6 +107,12 @@ abstract class BugseeBridgeHostApi { /// Replaces the natively registered placeholder wrapper with the full /// identity. Never throws on malformed content: facts become "unknown". void setWrapperIdentity(WrapperIdentityMessage identity); + + /// Publishes the secure rectangles (see [SecureRectanglesMessage]). Never + /// throws on content: an unusable value fails closed natively. Returns + /// whether the message was applied (`false`: it was older than one already + /// applied, which is not an error). + bool setSecureRectangles(SecureRectanglesMessage state); } /// Native calls Dart. Always from the platform (main) thread. diff --git a/test/lifecycle_drift_test.dart b/test/lifecycle_drift_test.dart index c7bc3d3..e1516df 100644 --- a/test/lifecycle_drift_test.dart +++ b/test/lifecycle_drift_test.dart @@ -210,7 +210,7 @@ void main() { test('secure rectangles come from the process-wide store (5.4.2)', () { expect( code('$java/FlutterWrapper.java'), - contains('return secureRectangles.snapshot(display);'), + contains('return secureRectangles.pull(display);'), ); final store = code('$java/SecureRectangleStore.java'); expect(store, contains('SECURE_RECTANGLES_VERSION_UNKNOWN')); diff --git a/test/secure_rects_drift_test.dart b/test/secure_rects_drift_test.dart new file mode 100644 index 0000000..bcb6d73 --- /dev/null +++ b/test/secure_rects_drift_test.dart @@ -0,0 +1,120 @@ +// Drift guards for what the secure-rectangle store repeats across Android (Java) +// and iOS (Objective-C), and what the SDKs' contracts fix (workbook Part 5). The +// behaviour itself is tested by test/secure_region_test.dart, the JUnit tests and +// the XCTests. +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +String code(String path) => File(path) + .readAsStringSync() + .replaceAll(RegExp(r'/\*.*?\*/', dotAll: true), '') + .replaceAll(RegExp(r'//[^\n]*'), ''); + +const java = 'android/src/main/java/com/bugsee/flutter'; +const objc = 'ios/bugsee_flutter/Sources/bugsee_flutter_wrapper'; + +num number(String source, RegExp pattern) { + final m = pattern.firstMatch(source); + expect(m, isNotNull, reason: 'no match for ${pattern.pattern}'); + return num.parse(m!.group(1)!); +} + +void main() { + final store = code('$java/SecureRectangleStore.java'); + final tracker = code('$java/SecurePlacement.java'); + final throttle = code('$java/PullThrottle.java'); + final mStore = code('$objc/BGSFlutterSecureRectangleStore.m'); + final mTracker = code('$objc/BGSFlutterSecurePlacementTracker.m'); + + test('the whole-display fallback size is the same on both platforms', () { + final j = number(store, RegExp(r'FALLBACK_DISPLAY_SIZE\s*=\s*(\d+);')); + final o = number(mStore, RegExp(r'kFallbackDisplaySize\s*=\s*(\d+);')); + expect(j, o); + expect(j, 16384); + }); + + test('the magnitude limit is the same on both platforms', () { + // Java: `1 << 28`; Objective-C: 268435456.0 (2^28). + expect( + RegExp(r'LIMIT\s*=\s*1 << 28;').hasMatch(store), + isTrue, + reason: 'Java LIMIT', + ); + expect(number(mStore, RegExp(r'kLimit\s*=\s*(\d+)\.0;')), 1 << 28); + }); + + test('"unknown version" is 0 on both, and is the SDKs value', () { + expect( + store, + contains( + 'UNKNOWN_VERSION = BugseeWrapper.SECURE_RECTANGLES_VERSION_UNKNOWN', + ), + ); + expect(number(mStore, RegExp(r'kUnknownVersion\s*=\s*(\d+);')), 0); + }); + + test('the header is [version, count] on both', () { + expect(number(store, RegExp(r'HEADER_INTS\s*=\s*(\d+);')), 2); + expect(number(mStore, RegExp(r'kHeaderInts\s*=\s*(\d+);')), 2); + }); + + test('the implicit view is 0 and iOS polls display 0 only', () { + expect(number(tracker, RegExp(r'IMPLICIT_VIEW_ID\s*=\s*(\d+);')), 0); + expect(number(mTracker, RegExp(r'kImplicitViewId\s*=\s*(\d+);')), 0); + expect(number(mTracker, RegExp(r'kDefaultDisplay\s*=\s*(\d+);')), 0); + }); + + test('the pull refresh interval is 100 ms on both', () { + expect(number(throttle, RegExp(r'MIN_INTERVAL_MS\s*=\s*(\d+);')), 100); + expect( + number(mTracker, RegExp(r'kMinPullRefreshInterval\s*=\s*([\d.]+);')), + 0.1, + ); + }); + + test('the version never lands on 0 on either platform', () { + expect(store, contains('next == UNKNOWN_VERSION ? next + 1 : next')); + expect(mStore, contains('next == kUnknownVersion ? next + 1 : next')); + }); + + test('both stores order messages by epoch and sequence the same way', () { + // Same epoch: sequence must grow. A retired epoch (a dead isolate) is + // ignored; an unseen one is a new isolate and wins, whatever its number. + expect(store, contains('last.retired.contains(epoch)')); + expect(store, contains('if (sequence <= last.sequence)')); + expect(mStore, contains('[lastRetired containsObject:@(epoch)]')); + expect(mStore, contains('if (sequence <= lastSequence) return NO;')); + expect( + number(store, RegExp(r'RETIRED_EPOCHS\s*=\s*(\d+);')), + number(mStore, RegExp(r'kRetiredEpochs\s*=\s*(\d+);')), + ); + }); + + test('the grace window and its limits are the same on both platforms', () { + expect(number(store, RegExp(r'GRACE_MS\s*=\s*(\d+);')), 120); + expect(number(mStore, RegExp(r'kGraceSeconds\s*=\s*([\d.]+);')), 0.12); + expect( + number(store, RegExp(r'MAX_RECTANGLES\s*=\s*(\d+);')), + number(mStore, RegExp(r'kMaxRectangles\s*=\s*(\d+);')), + ); + }); + + test('the ratio a message must match the display at is the same', () { + expect( + number(store, RegExp(r'RATIO_TOLERANCE\s*=\s*([\d.e-]+);')), + number(mStore, RegExp(r'kRatioTolerance\s*=\s*([\d.e-]+);')), + ); + }); + + test('units: Android takes physical pixels (1), iOS points (1 / screen ' + 'scale, never multiplied in)', () { + final env = code('$java/SecureEnvironment.java'); + expect(env, contains('getDisplayMetrics().density')); + expect(env, contains('return 1;')); + expect(mTracker, contains('return scale > 0 ? 1.0 / scale : NAN;')); + // The screen's scale is read for the ratio and divided out; it is never + // returned as the unit factor. + expect(mTracker, isNot(contains('return scale;'))); + }); +} diff --git a/test/secure_region_test.dart b/test/secure_region_test.dart new file mode 100644 index 0000000..79d8578 --- /dev/null +++ b/test/secure_region_test.dart @@ -0,0 +1,1259 @@ +// Secure regions (workbook Part 5, Dart side): what is measured, when it is +// published, and that every ambiguous case covers more, never less. + +import 'dart:async'; +import 'dart:math' as math; +import 'dart:typed_data' show ByteData; + +import 'package:bugsee_flutter/src/bridge/messages.g.dart'; +import 'package:bugsee_flutter/src/secure/secure_region.dart'; +// `cacheExtent` is deprecated on stable, but the floor (3.41.0) has no +// `scrollCacheExtent` yet. +// ignore_for_file: deprecated_member_use +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show PlatformException; +import 'package:flutter_test/flutter_test.dart'; + +/// Stands in for native: records every message, optionally failing. +class Recorder { + final List sent = []; + final List> pending = []; + int failNext = 0; + bool hold = false; + + Future call(SecureRectanglesMessage m) { + sent.add(m); + if (failNext > 0) { + failNext--; + return Future.error(PlatformException(code: 'channel-error')); + } + if (hold) { + final c = Completer(); + pending.add(c); + return c.future; + } + return Future.value(true); + } + + /// The latest message as `{viewId: [l, t, r, b, ...]}`. + Map> get latest => { + for (final v in sent.last.views) v.viewId: v.rectangles, + }; +} + +SecureRegionRegistry registryFor(Recorder r, {Duration? retry}) => + SecureRegionRegistry(publish: r.call, epoch: 7, retryDelay: retry); + +Widget app(SecureRegionRegistry registry, Widget child) => + Directionality(textDirection: TextDirection.ltr, child: child); + +Widget region( + SecureRegionRegistry registry, { + double w = 100, + double h = 50, + bool enabled = true, +}) => BugseeSecureRegion( + registry: registry, + enabled: enabled, + child: SizedBox(width: w, height: h), +); + +/// Places a [w] x [h] region at ([x], [y]) in the view. +Widget at( + SecureRegionRegistry registry, + double x, + double y, { + double w = 100, + double h = 50, +}) => Stack( + children: [ + Positioned( + left: x, + top: y, + child: region(registry, w: w, h: h), + ), + ], +); + +void main() { + late Recorder native; + late SecureRegionRegistry registry; + + setUp(() { + native = Recorder(); + registry = registryFor(native); + }); + tearDown(() => registry.dispose()); + + group('what is published', () { + testWidgets('a region is published in logical pixels, once', ( + tester, + ) async { + await tester.pumpWidget(app(registry, at(registry, 10, 20))); + expect(native.sent, hasLength(1)); + expect(native.sent.single.epoch, 7); + expect(native.sent.single.sequence, 1); + expect(native.latest, { + 0: [10.0, 20.0, 110.0, 70.0], + }); + }); + + testWidgets('the device pixel ratio does not change the logical rect', ( + tester, + ) async { + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(app(registry, at(registry, 10.5, 20.25))); + expect(native.latest, { + 0: [10.5, 20.25, 110.5, 70.25], + }); + }); + + testWidgets('several regions are sent together, in a canonical order', ( + tester, + ) async { + await tester.pumpWidget( + app( + registry, + Stack( + children: [ + Positioned(left: 200, top: 0, child: region(registry)), + Positioned(left: 0, top: 100, child: region(registry)), + Positioned(left: 0, top: 0, child: region(registry)), + ], + ), + ), + ); + expect(native.sent, hasLength(1)); + expect(native.latest[0], [ + 0, 0, 100, 50, // + 0, 100, 100, 150, // + 200, 0, 300, 50, + ]); + }); + + testWidgets('a zero-area region is not published', (tester) async { + await tester.pumpWidget( + app(registry, Stack(children: [region(registry, w: 0, h: 50)])), + ); + expect(native.sent, isEmpty); + }); + }); + + group('coalescing and no-ops', () { + testWidgets('many changes inside one frame are one publish', ( + tester, + ) async { + final x = ValueNotifier(0); + await tester.pumpWidget( + app( + registry, + ValueListenableBuilder( + valueListenable: x, + builder: (_, v, _) => Stack( + children: [ + Positioned(left: v, top: 0, child: region(registry)), + Positioned(left: 0, top: v, child: region(registry)), + ], + ), + ), + ), + ); + expect(native.sent, hasLength(1)); + x.value = 5; + x.value = 6; + x.value = 7; + await tester.pump(); + expect(native.sent, hasLength(2)); + expect(native.latest[0], [0, 7, 100, 57, 7, 0, 107, 50]); + }); + + testWidgets('frames that change nothing publish nothing', (tester) async { + await tester.pumpWidget(app(registry, at(registry, 10, 20))); + expect(native.sent, hasLength(1)); + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + // A rebuild with an equal tree, and a relayout, are not changes either. + await tester.pumpWidget(app(registry, at(registry, 10, 20))); + await tester.pumpWidget(app(registry, at(registry, 10, 20))); + expect(native.sent, hasLength(1)); + }); + + testWidgets('reordering regions is not a change', (tester) async { + Widget two(bool flip) => Stack( + children: [ + Positioned( + key: ValueKey(flip ? 'b' : 'a'), + left: 0, + top: 0, + child: region(registry), + ), + Positioned( + key: ValueKey(flip ? 'a' : 'b'), + left: 200, + top: 0, + child: region(registry), + ), + ], + ); + await tester.pumpWidget(app(registry, two(false))); + await tester.pumpWidget(app(registry, two(true))); + expect(native.sent, hasLength(1)); + }); + + testWidgets('a real change is published with a larger sequence', ( + tester, + ) async { + await tester.pumpWidget(app(registry, at(registry, 10, 20))); + await tester.pumpWidget(app(registry, at(registry, 11, 20))); + expect(native.sent.map((m) => m.sequence), [1, 2]); + expect(native.latest[0], [11, 20, 111, 70]); + }); + + testWidgets('a changed size is a change', (tester) async { + await tester.pumpWidget(app(registry, at(registry, 10, 20))); + await tester.pumpWidget(app(registry, at(registry, 10, 20, w: 101))); + expect(native.sent, hasLength(2)); + expect(native.latest[0], [10, 20, 111, 70]); + }); + }); + + group('transforms', () { + testWidgets('Transform.translate moves the rect', (tester) async { + await tester.pumpWidget( + app( + registry, + Align( + alignment: Alignment.topLeft, + child: Transform.translate( + offset: const Offset(30, 40), + child: region(registry), + ), + ), + ), + ); + expect(native.latest[0], [30, 40, 130, 90]); + }); + + testWidgets('Transform.scale reports the scaled box', (tester) async { + await tester.pumpWidget( + app( + registry, + Align( + alignment: Alignment.topLeft, + child: Transform.scale( + scale: 2, + alignment: Alignment.topLeft, + child: region(registry), + ), + ), + ), + ); + expect(native.latest[0], [0, 0, 200, 100]); + }); + + testWidgets('a rotated region is reported as its bounding box (larger)', ( + tester, + ) async { + await tester.pumpWidget( + app( + registry, + Align( + alignment: Alignment.topLeft, + child: Transform.rotate( + angle: math.pi / 4, + child: region(registry, w: 100, h: 100), + ), + ), + ), + ); + final r = native.latest[0]!; + // The 100x100 box rotated 45 degrees about its centre (50, 50): its + // bounding box is 141.42 wide, centred on the same point. + final half = 50 * math.sqrt2; + expect(r[0], closeTo(50 - half, 1e-9)); + expect(r[1], closeTo(50 - half, 1e-9)); + expect(r[2], closeTo(50 + half, 1e-9)); + expect(r[3], closeTo(50 + half, 1e-9)); + // Never less than the region itself. + expect(r[0] <= 0 && r[1] <= 0 && r[2] >= 100 && r[3] >= 100, isTrue); + }); + + testWidgets('RotatedBox swaps the extent', (tester) async { + await tester.pumpWidget( + app( + registry, + Align( + alignment: Alignment.topLeft, + child: RotatedBox( + quarterTurns: 1, + child: region(registry, w: 100, h: 30), + ), + ), + ), + ); + final r = native.latest[0]!; + expect(r[0], closeTo(0, 1e-9)); + expect(r[1], closeTo(0, 1e-9)); + expect(r[2], closeTo(30, 1e-9)); + expect(r[3], closeTo(100, 1e-9)); + }); + + for (final broken in ['nan', 'throws']) { + testWidgets('an unmeasurable region ($broken) covers the whole view', ( + tester, + ) async { + await tester.pumpWidget( + app( + registry, + Align( + alignment: Alignment.topLeft, + child: BrokenRegion(registry: registry, mode: broken), + ), + ), + ); + final view = tester.view; + expect(native.latest[0], [ + 0, + 0, + view.physicalSize.width / view.devicePixelRatio, + view.physicalSize.height / view.devicePixelRatio, + ]); + }); + } + }); + + group('clipping and scrolling', () { + testWidgets('a clipped region is reported whole, never less', ( + tester, + ) async { + await tester.pumpWidget( + app( + registry, + Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 40, + height: 20, + child: ClipRect( + child: OverflowBox( + alignment: Alignment.topLeft, + maxWidth: 100, + maxHeight: 50, + child: region(registry), + ), + ), + ), + ), + ), + ); + expect(native.latest[0], [0, 0, 100, 50]); + }); + + testWidgets('a scrolling region follows, leaves, and comes back', ( + tester, + ) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + app( + registry, + SizedBox( + width: 400, + height: 300, + child: ListView( + controller: controller, + cacheExtent: 0, + children: [ + const SizedBox(height: 100), + Align( + alignment: Alignment.topLeft, + child: region(registry, h: 100), + ), + const SizedBox(height: 1000), + ], + ), + ), + ), + ); + expect(native.latest[0], [0, 100, 100, 200]); + final before = native.sent.length; + + controller.jumpTo(30); + await tester.pump(); + expect(native.sent.length, before + 1); + expect(native.latest[0], [0, 70, 100, 170]); + + // Scrolled out of the viewport: not painted, so not reported, and no + // stale position stays behind. + controller.jumpTo(500); + await tester.pump(); + expect(native.sent.last.views, isEmpty); + + controller.jumpTo(0); + await tester.pump(); + expect(native.latest[0], [0, 100, 100, 200]); + }); + + testWidgets('scrolling inside a repaint boundary is still tracked', ( + tester, + ) async { + // ListView wraps every child in a RepaintBoundary: scrolling re-composites + // cached layers without painting the region again. + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + app( + registry, + SizedBox( + width: 400, + height: 300, + child: ListView.builder( + controller: controller, + itemCount: 20, + itemExtent: 100, + itemBuilder: (_, i) => i == 1 + ? Align( + alignment: Alignment.topLeft, + child: region(registry, w: 50, h: 100), + ) + : const SizedBox(), + ), + ), + ), + ); + expect(native.latest[0], [0, 100, 50, 200]); + for (var i = 1; i <= 4; i++) { + controller.jumpTo(i * 10.0); + await tester.pump(); + expect(native.latest[0], [0, 100.0 - i * 10, 50, 200.0 - i * 10]); + } + }); + }); + + group('page transitions keep the region covered (pixels are on screen)', () { + Future run( + WidgetTester tester, + PageTransitionsBuilder? builder, { + bool hero = false, + }) async { + final navigator = GlobalKey(); + Widget page(String label) => Scaffold( + body: Stack( + children: [ + Positioned( + left: 10, + top: 20, + child: hero + ? Hero(tag: 'h', child: region(registry)) + : region(registry), + ), + Text(label), + ], + ), + ); + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigator, + theme: builder == null + ? ThemeData(platform: TargetPlatform.iOS) // the platform default + : ThemeData( + pageTransitionsTheme: PageTransitionsTheme( + builders: { + for (final p in TargetPlatform.values) p: builder, + }, + ), + ), + home: page('one'), + ), + ); + void expectCovered(String when) { + final state = registry.measure(); + expect(state[0], isNotNull, reason: 'nothing covered $when'); + expect(state[0]!.rects, isNotEmpty, reason: 'nothing covered $when'); + } + + expectCovered('before the push'); + navigator.currentState!.push( + MaterialPageRoute(builder: (_) => page('two')), + ); + for (var i = 0; i < 20; i++) { + await tester.pump(const Duration(milliseconds: 25)); + expectCovered('during the push, frame $i'); + } + await tester.pumpAndSettle(); + navigator.currentState!.pop(); + for (var i = 0; i < 20; i++) { + await tester.pump(const Duration(milliseconds: 25)); + expectCovered('during the pop, frame $i'); + } + await tester.pumpAndSettle(); + expectCovered('after the pop'); + } + + testWidgets('Zoom (a SnapshotWidget: the region paints into a temporary ' + 'layer)', (tester) async { + await run(tester, const ZoomPageTransitionsBuilder()); + }); + testWidgets('FadeForwards', (tester) async { + await run(tester, const FadeForwardsPageTransitionsBuilder()); + }); + testWidgets('the iOS default (Cupertino)', (tester) async { + await run(tester, null); + }); + testWidgets('Hero in flight', (tester) async { + await run(tester, const FadeUpwardsPageTransitionsBuilder(), hero: true); + }); + }); + + group('what is hidden is hidden on positive evidence only', () { + testWidgets('a dialog over the page leaves the page covered', ( + tester, + ) async { + final navigator = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigator, + home: Scaffold(body: at(registry, 10, 20)), + ), + ); + showDialog( + context: navigator.currentContext!, + builder: (_) => const AlertDialog(title: Text('modal')), + ); + await tester.pumpAndSettle(); + expect(registry.measure()[0]!.rects, hasLength(1)); + }); + + testWidgets('a disabled TickerMode only stops animations: the content is ' + 'still painted, so it stays covered', (tester) async { + Widget tree(bool on) => + TickerMode(enabled: on, child: at(registry, 5, 5)); + await tester.pumpWidget(app(registry, tree(true))); + await tester.pumpWidget(app(registry, tree(false))); + expect(native.latest[0], [5, 5, 105, 55]); + }); + + testWidgets('paint that overflows back into a viewport keeps a scrolled-' + 'out box covered', (tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + app( + registry, + Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only(top: 200), + child: SizedBox( + width: 400, + height: 300, + child: ListView( + controller: controller, + cacheExtent: 1000, + children: [ + Align( + alignment: Alignment.topLeft, + child: BugseeSecureRegion( + registry: registry, + child: SizedBox( + width: 100, + height: 100, + // paints 150 px BELOW its own box + child: Transform.translate( + offset: const Offset(0, 150), + child: const SizedBox(width: 100, height: 100), + ), + ), + ), + ), + const SizedBox(height: 1000), + ], + ), + ), + ), + ), + ), + ); + controller.jumpTo(120); // the box (0..100) is wholly above the viewport + await tester.pump(); + // its overflow (150..250 -> 30..130 in the viewport) is visible + expect(native.latest[0], [0, 80, 100, 330]); + }); + + testWidgets('IndexedStack: only the shown child is covered', ( + tester, + ) async { + Widget stack(int index) => IndexedStack( + index: index, + alignment: Alignment.topLeft, + children: [ + Padding( + padding: const EdgeInsets.only(left: 10), + child: region(registry, w: 11, h: 11), + ), + Padding( + padding: const EdgeInsets.only(left: 100), + child: region(registry, w: 22, h: 22), + ), + ], + ); + await tester.pumpWidget(app(registry, stack(0))); + expect(native.latest[0], [10, 0, 21, 11]); + await tester.pumpWidget(app(registry, stack(1))); + expect(native.latest[0], [100, 0, 122, 22]); + }); + + testWidgets('Visibility without maintaining state hides it, with ' + 'maintainState it too, shown it is covered', (tester) async { + Widget vis(bool visible) => Visibility( + visible: visible, + maintainState: true, + child: at(registry, 5, 5), + ); + await tester.pumpWidget(app(registry, vis(true))); + expect(native.latest[0], [5, 5, 105, 55]); + await tester.pumpWidget(app(registry, vis(false))); + expect(native.sent.last.views, isEmpty); + await tester.pumpWidget(app(registry, vis(true))); + expect(native.latest[0], [5, 5, 105, 55]); + }); + + testWidgets('a child kept in the cache extent but scrolled out of a ' + 'clipping viewport is hidden; a partly visible one is covered whole', ( + tester, + ) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + app( + registry, + Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only(top: 200), + child: SizedBox( + width: 400, + height: 300, + child: ListView( + controller: controller, + cacheExtent: 1000, + children: [ + Align( + alignment: Alignment.topLeft, + child: region(registry, h: 100), + ), + const SizedBox(height: 1000), + ], + ), + ), + ), + ), + ), + ); + expect(native.latest[0], [0, 200, 100, 300]); + controller.jumpTo(50); // half of it is above the viewport top (200) + await tester.pump(); + expect(native.latest[0], [0, 150, 100, 250]); + controller.jumpTo(150); // wholly above the viewport, still built + await tester.pump(); + expect(native.sent.last.views, isEmpty); + }); + + testWidgets('a subtree too big to follow is reported as the whole view', ( + tester, + ) async { + await tester.pumpWidget( + app( + registry, + Align( + alignment: Alignment.topLeft, + child: BugseeSecureRegion( + registry: registry, + child: SizedBox( + width: 10, + height: 10, + child: OverflowBox( + maxWidth: 10, + maxHeight: 100000, + child: Column( + children: [ + for (var i = 0; i < 2100; i++) const SizedBox(height: 1), + ], + ), + ), + ), + ), + ), + ), + ); + final view = tester.view; + expect(native.latest[0], [ + 0, + 0, + view.physicalSize.width / view.devicePixelRatio, + view.physicalSize.height / view.devicePixelRatio, + ]); + }); + + testWidgets( + 'a hidden region with a huge subtree is dropped, not covered as ' + 'the whole view', + (tester) async { + Widget tree(bool hidden) => Offstage( + offstage: hidden, + child: Align( + alignment: Alignment.topLeft, + child: BugseeSecureRegion( + registry: registry, + child: SizedBox( + width: 10, + height: 10, + child: OverflowBox( + maxWidth: 10, + maxHeight: 100000, + child: Column( + children: [ + for (var i = 0; i < 2100; i++) const SizedBox(height: 1), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpWidget(app(registry, tree(true))); + expect(native.sent, isEmpty); + }, + ); + + testWidgets('paint outside the region box is covered too', (tester) async { + await tester.pumpWidget( + app( + registry, + Align( + alignment: Alignment.topLeft, + child: BugseeSecureRegion( + registry: registry, + child: SizedBox( + width: 100, + height: 50, + child: Transform.translate( + offset: const Offset(200, 0), + child: const SizedBox(width: 100, height: 50), + ), + ), + ), + ), + ), + ); + expect(native.latest[0], [0, 0, 300, 50]); + }); + }); + + group('ratio and epoch', () { + testWidgets('the ratio travels with the rectangles and a ratio change is ' + 'republished even though no rectangle moved', (tester) async { + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + Widget tree() => Align( + alignment: Alignment.topLeft, + child: region(registry, w: 10, h: 10), + ); + await tester.pumpWidget(app(registry, tree())); + expect(native.sent.last.views.single.devicePixelRatio, 3); + expect(native.latest[0], [0, 0, 10, 10]); + tester.view.devicePixelRatio = 2; + await tester.pumpWidget(app(registry, tree())); + expect(native.sent, hasLength(2)); + expect(native.sent.last.views.single.devicePixelRatio, 2); + expect(native.latest[0], [0, 0, 10, 10]); + }); + + test('the epoch is random, never zero and not a clock', () { + final a = SecureRegionRegistry(publish: (m) async => true); + final b = SecureRegionRegistry(publish: (m) async => true); + expect(a.epoch, isNot(b.epoch)); + expect(a.epoch, greaterThan(0)); + final now = DateTime.now().microsecondsSinceEpoch; + expect( + (a.epoch - now).abs() > 1000000 || (b.epoch - now).abs() > 1000000, + isTrue, + ); + }); + }); + + group('lifecycle', () { + testWidgets('disposing the region clears it, once, then goes quiet', ( + tester, + ) async { + await tester.pumpWidget(app(registry, at(registry, 10, 20))); + expect(registry.regionCount, 1); + await tester.pumpWidget(app(registry, const SizedBox())); + expect(registry.regionCount, 0); + expect(native.sent, hasLength(2)); + expect(native.sent.last.views, isEmpty); + for (var i = 0; i < 3; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + expect(native.sent, hasLength(2)); + }); + + testWidgets('enabled: false removes the region, true brings it back', ( + tester, + ) async { + Widget build(bool on) => Stack( + children: [ + Positioned(left: 5, top: 5, child: region(registry, enabled: on)), + ], + ); + await tester.pumpWidget(app(registry, build(true))); + expect(native.latest[0], [5, 5, 105, 55]); + await tester.pumpWidget(app(registry, build(false))); + expect(native.sent.last.views, isEmpty); + await tester.pumpWidget(app(registry, build(true))); + expect(native.latest[0], [5, 5, 105, 55]); + }); + + testWidgets('an offstage region is not reported, and returns when shown', ( + tester, + ) async { + Widget build(bool hidden) => + Offstage(offstage: hidden, child: at(registry, 10, 20)); + await tester.pumpWidget(app(registry, build(false))); + expect(native.latest[0], [10, 20, 110, 70]); + await tester.pumpWidget(app(registry, build(true))); + expect(native.sent.last.views, isEmpty); + await tester.pumpWidget(app(registry, build(false))); + expect(native.latest[0], [10, 20, 110, 70]); + }); + + testWidgets('a hidden route does not leave a ghost over the next page', ( + tester, + ) async { + final navigator = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigator, + home: Scaffold(body: at(registry, 10, 20)), + ), + ); + expect(native.latest[0], isNotNull); + navigator.currentState!.push( + MaterialPageRoute(builder: (_) => const Scaffold()), + ); + await tester.pumpAndSettle(); + expect(native.sent.last.views, isEmpty); + navigator.currentState!.pop(); + await tester.pumpAndSettle(); + expect(native.latest[0], [10, 20, 110, 70]); + }); + + testWidgets('moving the same region between parents is one region', ( + tester, + ) async { + final key = GlobalKey(); + Widget build(bool left) => Stack( + children: [ + Positioned( + left: left ? 0 : 200, + top: 0, + child: BugseeSecureRegion( + key: key, + registry: registry, + child: const SizedBox(width: 10, height: 10), + ), + ), + ], + ); + await tester.pumpWidget(app(registry, build(true))); + await tester.pumpWidget(app(registry, build(false))); + expect(registry.regionCount, 1); + expect(native.latest[0], [200, 0, 210, 10]); + }); + }); + + group('view changes', () { + testWidgets('a resize that re-lays-out the region is published', ( + tester, + ) async { + Widget fill() => Align( + alignment: Alignment.bottomRight, + child: region(registry, w: 100, h: 50), + ); + tester.view.physicalSize = const Size(900, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(app(registry, fill())); + expect(native.latest[0], [800, 550, 900, 600]); + // Orientation change. + tester.view.physicalSize = const Size(600, 900); + await tester.pump(); + expect(native.latest[0], [500, 850, 600, 900]); + }); + + testWidgets('regions in two views are published per view', (tester) async { + final second = OtherView(tester); + await tester.pumpWidget( + ViewCollection( + views: [ + View(view: tester.view, child: app(registry, at(registry, 1, 2))), + View( + view: second, + child: app(registry, at(registry, 30, 40, w: 10, h: 10)), + ), + ], + ), + wrapWithView: false, + ); + expect(native.latest[tester.view.viewId], [1, 2, 101, 52]); + expect(native.latest[second.viewId], [30, 40, 40, 50]); + // Removing the second view removes only its rectangles. + await tester.pumpWidget( + ViewCollection( + views: [ + View(view: tester.view, child: app(registry, at(registry, 1, 2))), + ], + ), + wrapWithView: false, + ); + expect(native.latest.keys, [tester.view.viewId]); + }); + }); + + group('over the real channel', () { + testWidgets('the process-wide registry sends the pigeon message natively ' + 'decodes, and handles a refusal of an old message', (tester) async { + const channel = + 'dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.setSecureRectangles'; + final codec = BugseeBridgeHostApi.pigeonChannelCodec; + final received = []; + var answer = true; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMessageHandler(channel, (ByteData? data) async { + final args = codec.decodeMessage(data) as List; + received.add(args[0]! as SecureRectanglesMessage); + return codec.encodeMessage([answer]); + }); + addTearDown(() => messenger.setMockMessageHandler(channel, null)); + + final real = SecureRegionRegistry.instance; + await tester.pumpWidget( + app( + real, + Stack( + children: [ + Positioned( + left: 4, + top: 8, + child: BugseeSecureRegion( + child: const SizedBox(width: 10, height: 20), + ), + ), + ], + ), + ), + ); + await tester.pump(); + expect(received, hasLength(1)); + expect(received.single.epoch, real.epoch); + expect(received.single.sequence, greaterThan(0)); + expect(received.single.views.single.viewId, tester.view.viewId); + expect(received.single.views.single.rectangles, [4, 8, 14, 28]); + + // A "not applied" answer (an older message) is not an error: no retry storm. + answer = false; + await tester.pumpWidget(app(real, const SizedBox())); + await tester.pump(); + expect(received.last.views, isEmpty); + final before = received.length; + await tester.pump(const Duration(seconds: 2)); + expect(received, hasLength(before)); + expect(real.lastError, isNull); + }); + }); + + group('publishing', () { + testWidgets('a failed publish is retried and then goes quiet', ( + tester, + ) async { + registry.dispose(); + registry = registryFor(native, retry: const Duration(milliseconds: 100)); + native.failNext = 1; + await tester.pumpWidget(app(registry, at(registry, 10, 20))); + await tester.pump(); // deliver the failure + expect(native.sent, hasLength(1)); + await tester.pump(const Duration(milliseconds: 150)); // retry timer + await tester.pump(); + expect(native.sent, hasLength(2)); + expect(native.sent.last.sequence, 2); + expect(native.latest[0], [10, 20, 110, 70]); + await tester.pump(const Duration(seconds: 1)); + expect(native.sent, hasLength(2)); + }); + + testWidgets('a native side that keeps failing is not hit every frame, but ' + 'a changed state is still sent', (tester) async { + registry.dispose(); + registry = SecureRegionRegistry( + publish: native.call, + epoch: 7, + retryDelay: null, + maxRetries: 3, + ); + native.failNext = 1000; + await tester.pumpWidget(app(registry, at(registry, 10, 20))); + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 16)); + await tester.pumpWidget(app(registry, at(registry, 10, 20))); // a frame + } + expect(native.sent.length, lessThanOrEqualTo(5)); + final before = native.sent.length; + await tester.pumpWidget(app(registry, at(registry, 11, 20))); + expect(native.sent.length, before + 1, reason: 'a new state is tried'); + }); + + // The registry's clock, advanced together with the fake time of the pumps. + var fakeNow = Duration.zero; + setUp(() => fakeNow = Duration.zero); + + SecureRegionRegistry slowRetry( + Recorder native, { + int maxRetries = 5, + Duration retry = const Duration(milliseconds: 100), + Duration cap = const Duration(seconds: 1), + }) => SecureRegionRegistry( + publish: native.call, + epoch: 7, + retryDelay: retry, + maxRetryDelay: cap, + maxRetries: maxRetries, + clock: () => fakeNow, + ); + + /// Pumps [total] of fake time in 16 ms frames. + Future runFrames(WidgetTester tester, Duration total) async { + for ( + var t = Duration.zero; + t < total; + t += const Duration(milliseconds: 16) + ) { + fakeNow += const Duration(milliseconds: 16); + await tester.pump(const Duration(milliseconds: 16)); + } + } + + testWidgets('retries back off 1x, 2x, 4x ... and stop growing at the cap', ( + tester, + ) async { + registry.dispose(); + registry = slowRetry(native); + native.failNext = 1000; + await tester.pumpWidget(app(registry, at(registry, 10, 20))); + await tester.pump(); // failure 1 delivered, next try in 100 ms + expect(native.sent, hasLength(1)); + for (var i = 0; i < 3; i++) { + // frames in between do not resend the state that just failed + await tester.pump(const Duration(milliseconds: 10)); + await tester.pumpWidget(app(registry, at(registry, 10, 20))); + } + expect(native.sent, hasLength(1)); + await tester.pump(const Duration(milliseconds: 60)); + expect(native.sent, hasLength(1)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pump(); + expect(native.sent, hasLength(2)); // t = 110 ms, next in 200 ms + await tester.pump(const Duration(milliseconds: 190)); + expect(native.sent, hasLength(2)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pump(); + expect(native.sent, hasLength(3)); // next in 400 ms + await tester.pump(const Duration(milliseconds: 390)); + expect(native.sent, hasLength(3)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pump(); + expect(native.sent, hasLength(4)); // next in 800 ms + await tester.pump(const Duration(milliseconds: 820)); + await tester.pump(); + expect(native.sent, hasLength(5)); // next in 1000 ms (the cap) + await tester.pump(const Duration(milliseconds: 990)); + expect(native.sent, hasLength(5)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pump(); + expect(native.sent, hasLength(6)); + await tester.pump(const Duration(milliseconds: 1010)); + await tester.pump(); + expect(native.sent, hasLength(7)); // still 1000 ms, not growing + registry.dispose(); // the retry timer would still be pending + }); + + testWidgets('a long outage does not make the registry give up: the state ' + 'is republished once native recovers', (tester) async { + registry.dispose(); + registry = slowRetry(native); + native.failNext = 1000; + await tester.pumpWidget(app(registry, at(registry, 10, 20))); + await runFrames( + tester, + const Duration(seconds: 60), + ); // far past the budget + native.failNext = 0; + native.sent.clear(); + await runFrames(tester, const Duration(seconds: 2)); + expect(native.sent, isNotEmpty, reason: 'the stale mask is repaired'); + expect(native.latest[0], [10, 20, 110, 70]); + // And it is quiet again afterwards. + native.sent.clear(); + await runFrames(tester, const Duration(seconds: 5)); + expect(native.sent, isEmpty); + }); + + testWidgets('a transport that keeps failing is not flooded, frames or ' + 'not, even while the region moves', (tester) async { + registry.dispose(); + registry = slowRetry( + native, + retry: const Duration(seconds: 1), + cap: const Duration(seconds: 30), + ); + native.failNext = 1000000; + await tester.pumpWidget(app(registry, at(registry, 10, 20))); + for (var i = 0; i < 1250; i++) { + // ~20 s of frames, a region that moves in every one + await tester.pumpWidget(app(registry, at(registry, 10.0 + i, 20))); + fakeNow += const Duration(milliseconds: 16); + await tester.pump(const Duration(milliseconds: 16)); + } + // 5 quick tries plus at most about one per second, not one per frame (1250). + expect(native.sent.length, lessThan(40)); + registry.dispose(); + }); + + testWidgets('the boundary: the retry after exactly maxRetries failures ' + 'is still made', (tester) async { + registry.dispose(); + registry = slowRetry(native, maxRetries: 2); + native.failNext = 3; // the first send and two retries fail + await tester.pumpWidget(app(registry, at(registry, 10, 20))); + await runFrames(tester, const Duration(seconds: 3)); + expect(native.sent.length, greaterThanOrEqualTo(4)); + expect(native.latest[0], [10, 20, 110, 70]); + final settled = native.sent.length; + await runFrames(tester, const Duration(seconds: 3)); + expect(native.sent, hasLength(settled), reason: 'delivered: quiet'); + }); + + testWidgets('a state that changes after the retries are used up does not ' + 'wait for the whole backoff: it goes out within retryDelay of the ' + 'last attempt', (tester) async { + registry.dispose(); + registry = slowRetry(native, cap: const Duration(seconds: 30)); + native.failNext = 1000000; + await tester.pumpWidget(app(registry, at(registry, 10, 20))); + // Far past maxRetries: the retry timer is now several seconds long. + await runFrames(tester, const Duration(seconds: 20)); + native.failNext = 0; // the transport is back + native.sent.clear(); + await tester.pumpWidget(app(registry, at(registry, 40, 20))); + await runFrames(tester, const Duration(milliseconds: 300)); + expect(native.sent, isNotEmpty, reason: 'sent within retryDelay'); + expect(native.latest[0], [40, 20, 140, 70]); + await runFrames(tester, const Duration(seconds: 2)); + final settled = native.sent.length; + await runFrames(tester, const Duration(seconds: 3)); + expect(native.sent, hasLength(settled), reason: 'delivered: quiet'); + }); + + testWidgets('changed states on a transport that is known bad go out at ' + 'most once per retryDelay, and the latest one wins', (tester) async { + registry.dispose(); + registry = slowRetry( + native, + maxRetries: 1, + cap: const Duration(seconds: 30), + ); + native.failNext = 1000000; + await tester.pumpWidget(app(registry, at(registry, 10, 20))); + await runFrames(tester, const Duration(seconds: 2)); + // Every frame brings a new state for the next second: at most ~10 sends. + native.sent.clear(); + for (var i = 0; i < 62; i++) { + await tester.pumpWidget(app(registry, at(registry, 100.0 + i, 20))); + fakeNow += const Duration(milliseconds: 16); + await tester.pump(const Duration(milliseconds: 16)); + } + expect(native.sent.length, lessThanOrEqualTo(11)); + native.failNext = 0; + await runFrames(tester, const Duration(seconds: 2)); + expect(native.latest[0], [161, 20, 261, 70]); + }); + + testWidgets('a failed clear is sent again, even though it is empty', ( + tester, + ) async { + registry.dispose(); + registry = registryFor(native, retry: const Duration(milliseconds: 100)); + await tester.pumpWidget(app(registry, at(registry, 10, 20))); + native.failNext = 1; + await tester.pumpWidget(app(registry, const SizedBox())); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + await tester.pump(); + expect(native.sent, hasLength(3)); + expect(native.sent.last.views, isEmpty); + }); + + testWidgets('sequences only ever grow, one per publish', (tester) async { + native.hold = true; // nothing is acknowledged + for (var i = 0; i < 5; i++) { + await tester.pumpWidget(app(registry, at(registry, i * 1.0, 0))); + } + final seq = native.sent.map((m) => m.sequence).toList(); + expect(seq, [1, 2, 3, 4, 5]); + expect(native.sent.map((m) => m.epoch).toSet(), {7}); + }); + }); +} + +/// A second view, with an id of its own, sharing the test view's geometry. +class OtherView extends TestFlutterView { + OtherView(WidgetTester tester) + : super( + view: tester.view, + platformDispatcher: tester.platformDispatcher, + display: tester.view.display, + ); + + @override + int get viewId => 99; +} + +/// A region whose measurement goes wrong, to prove the failure covers more. +class BrokenRegion extends SingleChildRenderObjectWidget { + const BrokenRegion({super.key, required this.registry, required this.mode}); + final SecureRegionRegistry registry; + final String mode; + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderBroken(mode, registry); +} + +class _RenderBroken extends RenderSecureRegion { + _RenderBroken(this.mode, SecureRegionRegistry registry) + : super(enabled: true, registry: registry); + final String mode; + + @override + void performLayout() { + size = const Size(100, 50); + } + + @override + Matrix4 getTransformTo(RenderObject? target) { + if (mode == 'throws') throw StateError('measurement failed'); + return Matrix4.diagonal3Values(double.nan, 1, 1); + } +} diff --git a/test/wrapper_registration_test.dart b/test/wrapper_registration_test.dart index 72056d3..dacee19 100644 --- a/test/wrapper_registration_test.dart +++ b/test/wrapper_registration_test.dart @@ -148,18 +148,18 @@ void main() { }); test( - 'secure rectangles come from the process-wide store, empty with version 0', + 'secure rectangles come from the process-wide store, never-published display is [0, 0]', () { final m = code('$base/bugsee_flutter_wrapper/BGSFlutterWrapper.m'); expect( m, - contains('[_secureRectangles snapshotForDisplay:display]'), + contains('[_secureRectangles pullForDisplay:display]'), reason: 'the set must not live in the replaceable wrapper (5.4.2)', ); final store = code( '$base/bugsee_flutter_wrapper/BGSFlutterSecureRectangleStore.m', ); - expect(store, contains('int32_t ints[2] = {0, 0};')); + expect(store, contains('int32_t empty[2] = {kUnknownVersion, 0};')); }, );