Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0ffa921
Report handlers, Dart end: pigeon report proxy operations, ReportProx…
krassx Oct 21, 2026
7e3084e
Report handlers, Android: pipeline that completes exactly once, never…
krassx Oct 21, 2026
e60190f
Report handlers, iOS: the same pipeline in Objective-C, with XCTests …
krassx Oct 21, 2026
0510545
Report enums by value: extracted fixture from both SDKs, parity test …
krassx Oct 21, 2026
1e090ef
Drift guards for the report pipeline: deadlines inside the SDKs, term…
krassx Oct 21, 2026
2e2b0da
Time the dispatch (a timed wait still returns), fewer race rounds; do…
krassx Oct 21, 2026
142196a
Format the bridge definition; the lifecycle Task guard names the life…
krassx Oct 21, 2026
4c70780
Review round 1: adaptive window, check-and-apply under a lock, hot-re…
krassx Oct 21, 2026
61345b8
Review round 2: the terminating-order guards slice dispatchOnce befor…
krassx Oct 21, 2026
bcf0077
Report handlers: sweep the previous isolate once before any phase reg…
krassx Oct 21, 2026
b6f8732
Merge origin/feat/phase-3b-secure-rects into the report handlers branch
krassx Oct 21, 2026
e0d99f1
Fix the bridge test fake (override on a field)
krassx Oct 21, 2026
d59d003
ReportHandlerPipelineTest: the SDK-thread non-blocking check uses the…
krassx Oct 21, 2026
f2d4486
Merge origin/feat/phase-3b-secure-rects into the report handlers branch
krassx Oct 21, 2026
bbf0289
CLAUDE.md: the report-enum check line was not a runnable command (Cod…
krassx Oct 21, 2026
0cd1ce9
Merge origin/feat/phase-3b-secure-rects into the 3c branch
krassx Oct 21, 2026
32d2b0b
Merge origin/feat/phase-3b-secure-rects into the 3c branch
krassx Oct 21, 2026
9ea7362
Merge origin/feat/phase-3b-secure-rects into the 3c 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: 4 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,14 @@ progress; `PLAN.md` is the source of intent. The authoritative wrapper spec is
- 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.

- Report handlers (workbook Part 7; `ReportHandlerPipeline.java`, `BGSFlutterReportHandlerPipeline.m`, Dart `lib/src/report` + `BugseeBridge.setReportHandlers`): the SDK completion runs exactly once on every path and the pipeline NEVER blocks (iOS calls the handler on the main thread and Dart's reply needs it: a wait is a deadlock; `test/report_handlers_drift_test.dart` greps for blocking primitives and for `isTerminating` being decided before anything touches Dart, time or the report). Terminating, no registered phase, no attached Dart, or a path the SDK does not wait on: complete at once. The window is the SDK's budget less a margin of a third (at most 10 s): 20 s live of the SDK's 30 s, iOS recovery 2 s of 3 s; the SDK's numbers are EXTRACTED from SDK source into `test/fixtures/report_enums.json` (`scripts/extract_report_enums.dart`, with the iOS source ref) and compared with the wrapper's by the drift guard, never wrapper against wrapper. The SDK starts its timer when it QUEUES the handler and ours starts at entry: that wait is not observable, only the margin absorbs it (an edit after the SDK moved on can still be accepted: documented limit); the observable hop to the platform thread is deducted (`deadlineMs` sent to Dart is what is left; an over window is completed, not offered). An edit holds the per-handle lock across check-and-apply and `done` flips under it, the SDK completion/Dart/scheduler are called outside it. `resetReportHandlers` (called first by `start()`) completes a previous isolate's outstanding deliveries. The drift guard bans blocking primitives in every file of the report path and allow-lists each `synchronized`. The path is told apart by thread for the time budget only; never pass it to Dart, never infer a recovered crash (7.2.3). The proxy is live until the completion runs; native answers `false` to a late edit and Dart throws `ReportClosedError`. `onBefore` at-most-once, `onAfter` at-least-once: Dart dedupes on the stable `deliveryId`. Enums cross by value; the Pigeon bridge declares NO enum (Pigeon sends ordinals); `test/fixtures/report_enums.json` is extracted from both SDKs by `scripts/extract_report_enums.dart` (re-run when a native pin moves: the parity test fails on stale pins) and compared with Dart/Java/ObjC, and the JUnit/XCTest compare it to the real SDK enums. Attachments are Phase 6 (path-first).
- Report handler limits (known, not bugs): an edit not awaited before the handler returns is lost and throws; a re-delivery of the same report and phase in the same isolate is not re-run (the id is recorded BEFORE the handler runs, so a handler that threw or overran counts as delivered and is not retried in-process: the SDK's own redelivery after a crash is the retry, in a later process; decided, not an oversight); several engines: the most recently attached one with a handler gets the delivery; a Dart handler cannot run for an iOS recovery that happens before the engine exists (completed at once); Android early-crash recovery never reaches Dart. Handles (`rh-N`) are looked up by handle alone, with no owner check: the wrapper trusts every caller in the app's own process (same trust model as the SDK itself); write this down before the API is public.

## Checks

```bash
flutter analyze && flutter test
# report enums: scripts/extract_report_enums.dart [--check] needs the SDK source files named in its header (not a CI step: the private SDKs are not vendored)
(cd example && flutter build apk --debug) # JDK 17 or 21
(cd example && flutter build ios --debug --no-codesign)
dart scripts/generate_pigeon.dart --check # bridge outputs current
Expand Down
2 changes: 1 addition & 1 deletion .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, 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 |
| `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, and `test/report_handlers_drift_test.dart` / `test/report_enum_parity_test.dart` for the report pipeline and the report enums, which read `test/fixtures/report_enums.json`), 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
15 changes: 13 additions & 2 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,19 @@ Sources: `specs/sdk/wrapper-workbook` (authoritative order of work), `wrapper-da
(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.
- Report handlers (3c, done): native pipeline (`ReportHandlerPipeline` / `BGSFlutterReportHandlerPipeline`) + a Dart
`ReportProxy` (internal, `lib/src/report`; Phase 4 exposes it). The SDK's completion runs EXACTLY ONCE on every path; the
pipeline NEVER WAITS (the SDK already waits on its own timer; a native wait for Dart's reply deadlocks when the handler runs
on the platform thread, which iOS's live path does). `isTerminating` never reaches Dart and the report is not even read.
No Dart handler registered, or Dart not attached: complete at once. The window is the SDK's budget LESS A MARGIN (a third, at most 10 s), from SDK source (numbers extracted into `test/fixtures/report_enums.json`): Android 7.4.2 waits `ReportHandlerCallbackTimeout` (30 s, option) per handler on thread `BugseeReportHandlerThread`, ours 20 s; its recovery/terminating paths run on other threads, wait 3 s for the handler to RETURN and never for the completion, so Android completes at once there. iOS (nextgen = beta7 headers): live = MAIN thread, 30 s per handler, ours 20 s; recovery = worker thread, 3 s cap, a late completion re-persists the report, ours 2 s. Honest limit: the SDK's per-handler timer starts when it QUEUES the handler, ours at handler entry, and the wait in between (another report's slow handler holding the shared Android dispatch thread, a busy iOS main thread) is NOT observable; the margin absorbs it, and a handler queued behind more than the margin can still overrun, with an edit accepted although the SDK has moved on. What is observable (the hop to the platform thread after entry) is deducted: Dart is told what is LEFT, and a window already over is completed, not offered. An accepted edit is guaranteed to have landed before the completion ran (per-handle lock across check-and-apply). A new isolate (hot restart) completes the previous one's outstanding deliveries. A Dart handler that overruns its budget is reported even if it makes no further edit. The path is told apart by thread
(Android: name, iOS: main or not) for the time budget ONLY, never passed to Dart, never "this is a recovered crash" (7.2.3).
The workbook's table says Android recovery runs on main and is waited for; 7.4.2 source says a spawned thread, not awaited
(inline on the launching thread only when no thread can be started): source wins, spec gap to report.
Dart: edits are operations keyed by a handle; a late edit throws `ReportClosedError` (a `StateError`) and native ignores it.
`onAfter` is at-least-once: each delivery carries a stable `deliveryId` (`before|after:<reportId>`) and Dart skips a repeat
within the isolate (128 remembered). Enums cross by value (the bridge has none; severity is the SDK integer; Dart
`ReportSeverity.veryLow` = Android name, `low` = iOS alias, value 1). Attachments/screenshots: Phase 6 (TODO seam in `ReportProxy`).
- `requestData` (3c): the stub answers null/nil for every type incl. `vh`, also on the replaced wrapper; tested on both platforms.

## 4. Phase 4 — Public API and options (Part 8)

Expand Down
Loading