Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
40e7b53
Secure regions: bridge message, registry and RenderObject, with tests
krassx Oct 20, 2026
001cde5
Android: real secure-rectangle store, placement tracking, pull throttle
krassx Oct 20, 2026
1bfa74e
Android: tests for per-display versions use different sets per display
krassx Oct 20, 2026
717ef89
iOS: real secure-rectangle store, placement tracker, XCTests
krassx Oct 21, 2026
e7de670
Secure rectangles: drift guards, channel test, docs; guards follow th…
krassx Oct 21, 2026
a8f745d
Dart secure regions: hide only on positive evidence, cover paint over…
krassx Oct 21, 2026
7378c26
Secure rectangles: grace window, ratio check, view scale, retired epo…
krassx Oct 21, 2026
0c951c1
Tests: do not depend on APIs that differ between the floor and stable…
krassx Oct 21, 2026
d8e4b4e
Review round 1: TickerMode is not evidence of invisibility; viewport …
krassx Oct 21, 2026
34b1ef4
Review round 2: ancestor evidence of invisibility is checked before a…
krassx Oct 21, 2026
f45d690
Review round 3: stop resending a state native keeps failing on; clear…
krassx Oct 21, 2026
a2db41d
Review round 4: drop a test that could not fail; comments follow the …
krassx Oct 21, 2026
43eafe7
Secure rectangles: a failed publish is retried with a capped backoff …
krassx Oct 21, 2026
151a6a8
Secure rectangles: a changed state after the retries are used up wait…
krassx Oct 21, 2026
703a859
Merge origin/feat/phase-3a-bridge-lifecycle into the secure rectangle…
krassx Oct 21, 2026
a52371a
Secure rectangles (Android): a placement refresh the main thread neve…
krassx Oct 21, 2026
977f0d4
Secure rectangles (Android): only a request actually posted to the ma…
krassx Oct 21, 2026
a38ef60
Merge origin/feat/phase-3a-bridge-lifecycle into the 3b branch
krassx Oct 21, 2026
7115800
Merge origin/feat/phase-3a-bridge-lifecycle into the 3b branch
krassx Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions .github/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand Down
23 changes: 18 additions & 5 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
114 changes: 113 additions & 1 deletion android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,22 +24,103 @@
*
* <p>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<SecurePlacement> 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: P3 Low (multi-engine; fails closed)

Problem: The comment says "One environment for the process", but every engine attach creates a new SecureEnvironment and installs it into the process-wide store (store.setEnvironment(environment)). Each engine's SecurePlacement registers its view's Resources only in its own instance (environment.setResources(...)). The store reads whichever environment was installed last. iOS does this correctly with a static let environment.

Impact: With two engines attached (add-to-app with several FlutterEngines), engine A's resources are never visible to the store once B attaches. nativePixelRatio() falls back to Display.getRealMetrics().density. If the app overrides the density in its own Resources (a per-activity densityDpi override, font or display-size handling), that value differs from the Flutter view's ratio. The ratio check then fails and the whole display is covered permanently for A's regions. That is over-covering, not a leak.

Fix: Make the environment a process-wide singleton (static final, created from the application context on first attach), as in BugseeFlutterPlugin.swift. Only install it once.

placement = new SecurePlacement(owner, binding.getFlutterEngine(), store, environment);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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);
Expand Down Expand Up @@ -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<Integer, SecureRectangleStore.ViewRects> views = new HashMap<>();
for (final Messages.SecureViewRectanglesMessage view : state.getViews()) {
final List<Double> 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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading