From 40e7b530921cb1fc7ded9a82e35fd4cbc850226c Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 04:39:43 +0500 Subject: [PATCH 01/16] Secure regions: bridge message, registry and RenderObject, with tests Pigeon: setSecureRectangles(SecureRectanglesMessage), whole-state, ordered by (epoch, sequence). Dart: BugseeSecureRegion measures each region through every ancestor transform once per frame, reports it unclipped (fail closed), drops regions that are not painted, and publishes only when the set changed. Co-Authored-By: Claude Sonnet 5.5 Change-Id: I2e918022b06cb0afd383a2e292ade9756a69fc1a --- .../java/com/bugsee/flutter/Messages.java | 273 +++++++ .../Sources/bugsee_flutter/Messages.g.swift | 134 ++++ lib/src/bridge/messages.g.dart | 149 ++++ lib/src/secure/secure_region.dart | 411 +++++++++++ pigeons/bridge.dart | 38 + test/secure_region_test.dart | 669 ++++++++++++++++++ 6 files changed, 1674 insertions(+) create mode 100644 lib/src/secure/secure_region.dart create mode 100644 test/secure_region_test.dart diff --git a/android/src/main/java/com/bugsee/flutter/Messages.java b/android/src/main/java/com/bugsee/flutter/Messages.java index 0e133ba..d3560d2 100644 --- a/android/src/main/java/com/bugsee/flutter/Messages.java +++ b/android/src/main/java/com/bugsee/flutter/Messages.java @@ -487,6 +487,238 @@ 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; + } + + 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(rectangles, that.rectangles); + } + + @Override + public int hashCode() { + Object[] fields = new Object[] {getClass(), viewId, rectangles}; + return pigeonDeepHashCode(fields); + } + + @Override + public String toString() { + return "SecureViewRectanglesMessage{" + "viewId=" + viewId + ", " + "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 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.setRectangles(rectangles); + return pigeonReturn; + } + } + + @NonNull + ArrayList toList() { + ArrayList toListResult = new ArrayList<>(2); + toListResult.add(viewId); + 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 rectangles = pigeonVar_list.get(1); + 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: `(epoch, sequence)` is compared lexicographically and native + * ignores a message that is not newer than the newest it accepted. `epoch` + * identifies the Dart isolate (a hot restart starts a new one, whose counter + * restarts at 0, so it must still win); `sequence` counts publishes within it. + * + * 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 +731,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 +748,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 +801,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 +855,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/ios/bugsee_flutter/Sources/bugsee_flutter/Messages.g.swift b/ios/bugsee_flutter/Sources/bugsee_flutter/Messages.g.swift index 51510ce..6dfea1e 100644 --- a/ios/bugsee_flutter/Sources/bugsee_flutter/Messages.g.swift +++ b/ios/bugsee_flutter/Sources/bugsee_flutter/Messages.g.swift @@ -292,6 +292,106 @@ 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 + var rectangles: [Double] + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> SecureViewRectanglesMessage? { + let viewId = pigeonVar_list[0] as! Int64 + let rectangles = pigeonVar_list[1] as! [Double] + + return SecureViewRectanglesMessage( + viewId: viewId, + rectangles: rectangles + ) + } + func toList() -> [Any?] { + return [ + viewId, + 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.rectangles, rhs.rectangles) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("SecureViewRectanglesMessage") + MessagesPigeonInternal.deepHash(value: viewId, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: rectangles, hasher: &hasher) + } + + public var description: String { + return "SecureViewRectanglesMessage(viewId: \(String(describing: viewId)), 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: `(epoch, sequence)` is compared lexicographically and native +/// ignores a message that is not newer than the newest it accepted. `epoch` +/// identifies the Dart isolate (a hot restart starts a new one, whose counter +/// restarts at 0, so it must still win); `sequence` counts publishes within it. +/// +/// 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 +399,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 +417,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 +453,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 +498,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/lib/src/bridge/messages.g.dart b/lib/src/bridge/messages.g.dart index ec144f6..fd89869 100644 --- a/lib/src/bridge/messages.g.dart +++ b/lib/src/bridge/messages.g.dart @@ -227,6 +227,120 @@ 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.rectangles}); + + /// `FlutterView.viewId`. Mobile engines have only the implicit view (0). + int viewId; + + List rectangles; + + List _toList() { + return [viewId, rectangles]; + } + + Object encode() { + return _toList(); + } + + static SecureViewRectanglesMessage decode(Object result) { + result as List; + return SecureViewRectanglesMessage( + viewId: result[0]! as int, + rectangles: (result[1]! 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(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, 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: `(epoch, sequence)` is compared lexicographically and native +/// ignores a message that is not newer than the newest it accepted. `epoch` +/// identifies the Dart isolate (a hot restart starts a new one, whose counter +/// restarts at 0, so it must still win); `sequence` counts publishes within it. +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 +354,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 +372,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 +441,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..6dfcc1f --- /dev/null +++ b/lib/src/secure/secure_region.dart @@ -0,0 +1,411 @@ +// 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. +// * Scrolled out / offstage / not painted: a region that contributes nothing to +// the composited frame (its layer is not in the layer tree: the sliver did not +// paint it, an Offstage or an inactive route hides it) is NOT reported, and is +// removed the frame it stops painting. Reporting its stale geometry would +// black out whatever now sits at that spot. A region that has never been laid +// out or painted is in the same state. +// * 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.rect); + final int viewId; + final Rect rect; + + @override + bool operator ==(Object other) => + other is MeasuredRegion && other.viewId == viewId && other.rect == rect; + + @override + int get hashCode => Object.hash(viewId, rect); + + @override + String toString() => 'MeasuredRegion(view $viewId, $rect)'; +} + +/// The rectangles of every view: view id to its canonical rectangles. +typedef SecureState = Map>; + +bool _finite(double v) => v.isFinite; + +bool _rectFinite(Rect r) => + _finite(r.left) && _finite(r.top) && _finite(r.right) && _finite(r.bottom); + +/// 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. +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.length != entry.value.length) return false; + for (var i = 0; i < other.length; i++) { + if (other[i] != entry.value[i]) return false; + } + } + return true; +} + +/// 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 || !box.isOnScreen) 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 = MatrixUtils.transformRect( + box.getTransformTo(null), + Offset.zero & size, + ); + return _rectFinite(rect) + ? MeasuredRegion(view.flutterView.viewId, rect) + : _wholeView(view); + } catch (_) { + return view == null ? null : _wholeView(view); + } +} + +MeasuredRegion? _wholeView(RenderView view) { + try { + final Size physical = view.flutterView.physicalSize; + final double ratio = view.flutterView.devicePixelRatio; + 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( + view.flutterView.viewId, + Rect.fromLTWH(0, 0, math.max(w.toDouble(), 1), math.max(h.toDouble(), 1)), + ); + } catch (_) { + return MeasuredRegion( + view.flutterView.viewId, + 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.maxRetries = 5, + }) : epoch = epoch ?? DateTime.now().microsecondsSinceEpoch; + + /// 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. + final Duration? retryDelay; + final int maxRetries; + + final Set _regions = {}; + bool _scheduled = false; + int _sequence = 0; + int _failures = 0; + Timer? _retry; + + /// 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; + _send(state); + } + + /// The current state of every view, as it would be published. + SecureState measure() { + final byView = >{}; + for (final region in _regions) { + final m = measureRegion(region); + if (m != null) (byView[m.viewId] ??= []).add(m.rect); + } + final result = >{}; + final ids = byView.keys.toList()..sort(); + for (final id in ids) { + final rects = canonicalRects(byView[id]!); + if (rects.isNotEmpty) result[id] = rects; + } + return result; + } + + void _send(SecureState state) { + final sequence = ++_sequence; + final message = SecureRectanglesMessage( + epoch: epoch, + sequence: sequence, + views: [ + for (final entry in state.entries) + SecureViewRectanglesMessage( + viewId: entry.key, + rectangles: [ + for (final r in entry.value) ...[ + 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( + (_) { + _failures = 0; + }, + 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; + _failures++; + lastError = error; + final delay = retryDelay; + if (delay != null && _failures <= maxRetries) { + _retry?.cancel(); + _retry = Timer(delay, _schedule); + } + }, + ); + } + + /// The error of the most recent failed publish, if any. For diagnostics. + Object? lastError; + + /// Cancels any pending retry. For tests. + @visibleForTesting + void dispose() { + _retry?.cancel(); + _retry = null; + } +} + +/// The render object behind [BugseeSecureRegion]. +/// +/// A repaint boundary on purpose: its layer is in the composited tree exactly +/// when the region is being painted this frame, which is how [isOnScreen] tells +/// a visible region from one that is offstage or scrolled out of its sliver. +class RenderSecureRegion extends RenderProxyBox { + RenderSecureRegion({ + required bool enabled, + required SecureRegionRegistry registry, + }) : _enabled = enabled, + _registry = registry; + + bool _enabled; + SecureRegionRegistry _registry; + + @override + bool get isRepaintBoundary => true; + + /// Whether this region is part of the composited frame. + bool get isOnScreen { + final l = layer; + return l != null && l.attached; + } + + 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..9e1aca3 100644 --- a/pigeons/bridge.dart +++ b/pigeons/bridge.dart @@ -52,6 +52,38 @@ 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.rectangles}); + + /// `FlutterView.viewId`. Mobile engines have only the implicit view (0). + int viewId; + 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: `(epoch, sequence)` is compared lexicographically and native +/// ignores a message that is not newer than the newest it accepted. `epoch` +/// identifies the Dart isolate (a hot restart starts a new one, whose counter +/// restarts at 0, so it must still win); `sequence` counts publishes within it. +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 +94,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/secure_region_test.dart b/test/secure_region_test.dart new file mode 100644 index 0000000..cf80c49 --- /dev/null +++ b/test/secure_region_test.dart @@ -0,0 +1,669 @@ +// 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 'package:bugsee_flutter/src/bridge/messages.g.dart'; +import 'package:bugsee_flutter/src/secure/secure_region.dart'; +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('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('a pixel-ratio change with no relayout republishes nothing', ( + tester, + ) async { + tester.view.physicalSize = const Size(900, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(app(registry, at(registry, 10, 20))); + expect(native.sent, hasLength(1)); + // Logical rects are unchanged by the ratio; native rescales them with its + // own, per-call, scale. + tester.view.devicePixelRatio = 1; + await tester.pump(); + expect(native.sent, hasLength(1)); + }); + + 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('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 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); + } +} From 001cde549578aa63249c96363d45c5db103d0039 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 04:44:53 +0500 Subject: [PATCH 02/16] Android: real secure-rectangle store, placement tracking, pull throttle Per-display immutable snapshots; logical px to physical px with the density read per pull, then outward rounding; the Flutter view's on-screen position added; unknown placement fails closed to the whole display; versions derived from what is served, per display. Replaces the stub; JUnit covers units, rounding, versions, ordering and cross-thread reads. Co-Authored-By: Claude Sonnet 5.5 Change-Id: I98952b96e238a1aefd6a5aa4e4517997bf44bbf1 --- .../bugsee/flutter/BugseeFlutterPlugin.java | 94 +++- .../com/bugsee/flutter/FlutterWrapper.java | 2 +- .../java/com/bugsee/flutter/PullThrottle.java | 60 +++ .../com/bugsee/flutter/SecureEnvironment.java | 80 +++ .../com/bugsee/flutter/SecurePlacement.java | 173 ++++++ .../bugsee/flutter/SecureRectangleStore.java | 470 +++++++++++++++- .../bugsee/flutter/FlutterWrapperTest.java | 39 +- .../com/bugsee/flutter/PullThrottleTest.java | 38 ++ .../flutter/SecureRectangleStoreTest.java | 503 +++++++++++++++++- 9 files changed, 1412 insertions(+), 47 deletions(-) create mode 100644 android/src/main/java/com/bugsee/flutter/PullThrottle.java create mode 100644 android/src/main/java/com/bugsee/flutter/SecureEnvironment.java create mode 100644 android/src/main/java/com/bugsee/flutter/SecurePlacement.java create mode 100644 android/src/test/java/com/bugsee/flutter/PullThrottleTest.java diff --git a/android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java b/android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java index 6455940..5156857 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,84 @@ * *

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, + MAIN::post, + BugseeFlutterPlugin::refreshPlacements); + + private static void refreshPlacements() { + for (final SecurePlacement placement : PLACEMENTS) { + placement.refresh(); + } + } + + /** 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 = new SecureEnvironment(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: nothing of this engine is on screen. + store.setPlacement(owner, SecurePlacement.IMPLICIT_VIEW_ID, + SecureRectangleStore.Placement.ABSENT); + } + + @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 +138,28 @@ 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(), 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/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..bc03314 --- /dev/null +++ b/android/src/main/java/com/bugsee/flutter/SecureEnvironment.java @@ -0,0 +1,80 @@ +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 pixelsPerLogicalPixel(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..cd1ac68 --- /dev/null +++ b/android/src/main/java/com/bugsee/flutter/SecurePlacement.java @@ -0,0 +1,173 @@ +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 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: {@link SecureRectangleStore.Placement#ABSENT}, no + * Flutter pixels can be on screen (also across a configuration change, + * where the new activity re-attaches within the same frame of the main + * thread);
  • + *
  • an activity but no (or an ambiguous, or detached) Flutter view: + * {@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) { + unobserve(); + store.setPlacement(owner, IMPLICIT_VIEW_ID, SecureRectangleStore.Placement.ABSENT); + 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()); + store.setPlacement(owner, IMPLICIT_VIEW_ID, + SecureRectangleStore.Placement.known(displayId, location[0], location[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 + } + } + } + + 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..cc7f2c4 100644 --- a/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java +++ b/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java @@ -1,10 +1,15 @@ 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.Arrays; +import java.util.HashMap; +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 +17,469 @@ * 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. + * This class turns them into the SDK's buffer: multiply by the display's + * pixels-per-logical-pixel (read at every pull, never cached), add the view's + * position on the screen, then round 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). + * + *

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 scale or the view's position 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]}). + * + *

Fail closed

+ * + *

A view whose placement is not known (the Flutter view has not been found, + * or is ambiguous) is served as one rectangle covering the whole display. A + * rectangle that is not finite does the same, and so does a scale that is not + * a positive finite number. A view that is known to be not on screen at all + * (its activity is detached) 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. */ - private static final int UNKNOWN_VERSION = BugseeWrapper.SECURE_RECTANGLES_VERSION_UNKNOWN; + 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: more than any panel, small enough not to overflow an int when + * the SDK scales it. + */ + 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; + + /** What the store needs to know about the device, read at every pull. */ + interface Environment { + /** + * Physical pixels per Flutter logical pixel on {@code display}: the + * density Flutter itself uses (Android: {@code DisplayMetrics.density} + * of the Flutter view). Read per call, never cached. Not finite or not + * positive means "unknown" and fails closed. + */ + double pixelsPerLogicalPixel(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 pixelsPerLogicalPixel(final int display) { + return Double.NaN; + } + + @Nullable + @Override + public int[] displayBounds(final int display) { + return null; + } + }; + + /** 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; - private static final SecureRectangleStore SHARED = new SecureRectangleStore(); + /** The view's position is not known: serve the whole display. */ + static final Placement UNKNOWN = new Placement(UNKNOWN_KIND, 0, 0, 0); + /** The view is known not to be on screen: serve nothing. */ + static final Placement ABSENT = new Placement(ABSENT_KIND, 0, 0, 0); + + final int kind; + final int display; + /** The view's top-left on the display, in physical pixels. */ + final double x; + final double y; + + private Placement(final int kind, final int display, final double x, final double y) { + this.kind = kind; + this.display = display; + this.x = x; + this.y = y; + } + + static Placement known(final int display, final double x, final double y) { + return new Placement(KNOWN_KIND, display, x, y); + } + + @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; + } + + @Override + public int hashCode() { + return Arrays.hashCode(new double[] {kind, display, x, y}); + } + } + + /** 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); + } + } + + /** Immutable. Replaced wholesale on every write. */ + private static final class State { + /** Logical-pixel rectangles as Dart sent them, flat ltrb. */ + final TreeMap rects; + final Map placements; + /** owner to {epoch, sequence} of the newest applied publish. */ + final Map order; + + State( + final TreeMap rects, + final Map placements, + final Map order + ) { + this.rects = rects; + this.placements = placements; + this.order = order; + } + } + + /** What a display last served. Immutable. */ + private static final class Served { + final int version; + /** The rectangles only (no header), physical pixels. */ + final int[] quads; + final int[] buffer; + + Served(final int version, @NonNull final int[] quads) { + this.version = version; + this.quads = quads; + 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 SecureRectangleStore SHARED = new SecureRectangleStore(UNKNOWN_ENVIRONMENT); @NonNull static SecureRectangleStore shared() { return SHARED; } - private final ConcurrentHashMap sets = new ConcurrentHashMap<>(); + private volatile Environment environment; + 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<>(); + + SecureRectangleStore(@NonNull final Environment environment) { + this.environment = environment; + } + + /** 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 its flat logical {@code [l, t, r, b, ...]}; a view not in the map + * has no rectangles any more. Ignored, and {@code false}, unless + * {@code (epoch, sequence)} is newer than the newest applied for + * {@code owner}: a delayed older message must never overwrite a newer one, + * while a new isolate (a larger epoch, its counter back at 0) still wins. + */ + boolean publish( + final long owner, + final long epoch, + final long sequence, + @NonNull final Map views + ) { + synchronized (writeLock) { + final State previous = state; + final long[] last = previous.order.get(owner); + if (last != null && (epoch < last[0] || (epoch == last[0] && sequence <= last[1]))) { + return false; + } + 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()) { + final double[] copy = e.getValue().clone(); + if (copy.length > 0) { + rects.put(new Lane(owner, e.getKey()), copy); + } + } + final Map order = new HashMap<>(previous.order); + order.put(owner, new long[] {epoch, sequence}); + state = new State(rects, previous.placements, order); + return true; + } + } + + /** 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); + } - /** The set last published for {@code display}, or the empty set. */ + /** + * The buffer for {@code display}: {@code [version, count, l, t, r, b, ...]}. + * Computed from the current state, scale 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 int[] quads = serve(state, display); + final Served previous = served.get(display); + if (previous == null && quads.length == 0) { + return new int[] {UNKNOWN_VERSION, 0}; + } + if (previous != null && Arrays.equals(previous.quads, quads)) { + return previous.buffer.clone(); + } + final Served next = new Served(nextVersion(previous), quads); + served.put(display, next); + return next.buffer.clone(); + } + } + + private static int nextVersion(@Nullable final Served previous) { + return previous == null ? 1 : advance(previous.version); + } + + /** 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} serves for {@code s}, flat ltrb. */ + @NonNull + private int[] serve(@NonNull final State s, final int display) { + final Environment env = environment; + double scale = Double.NaN; + boolean scaleRead = false; + int[] bounds = null; + boolean boundsRead = false; + + int[] out = new int[16]; + int n = 0; + boolean whole = false; + + for (final Map.Entry entry : s.rects.entrySet()) { + final double[] logical = entry.getValue(); + 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 (!scaleRead) { + scale = env.pixelsPerLogicalPixel(display); + scaleRead = true; + } + if (!boundsRead) { + bounds = env.displayBounds(display); + boundsRead = true; + } + if (!(scale > 0) || Double.isInfinite(scale) || logical.length % 4 != 0) { + 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 * scale + placement.x)); + int it = toInt(Math.floor(t * scale + placement.y)); + int ir = toInt(Math.ceil(r * scale + placement.x)); + int ib = toInt(Math.ceil(b * scale + placement.y)); + if (bounds != null) { + 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 (!boundsRead) { + bounds = env.displayBounds(display); + } + final int w = bounds == null ? FALLBACK_DISPLAY_SIZE : bounds[0]; + final int h = bounds == null ? FALLBACK_DISPLAY_SIZE : bounds[1]; + return w > 0 && h > 0 ? new int[] {0, 0, w, h} : new int[0]; + } + return n == out.length ? out : Arrays.copyOf(out, n); + } + + 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/test/java/com/bugsee/flutter/FlutterWrapperTest.java b/android/src/test/java/com/bugsee/flutter/FlutterWrapperTest.java index c51ad37..94c4d9e 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,24 @@ public void lifecycleEventsGoToTheHubAndNeverThrow() { FlutterWrapper.atProcessStart().onLifecycleEvent("x", null); } + private static final class FixedEnvironment implements SecureRectangleStore.Environment { + @Override + public double pixelsPerLogicalPixel(final int display) { + return 3; + } + + @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 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 +129,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 +150,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 +165,15 @@ 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 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..a339d61 100644 --- a/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java +++ b/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java @@ -2,40 +2,505 @@ 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.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 scale = 3; + volatile int[] bounds = {1080, 2400}; + volatile int scaleReads; + + @Override + public double pixelsPerLogicalPixel(final int display) { + scaleReads++; + return scale; + } + + @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; + } + + private static Map view(final double... ltrb) { + return Collections.singletonMap(0, 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, 1, 2, 2); + assertArrayEquals(new int[] {1, 3, 3, 6, 6}, withoutVersion(store.snapshot(0))); + } + + /** The scale is read at every pull (5.3.2): a density change is seen by the next one. */ + @Test + public void theScaleIsReadPerCall() { + publish(10, 10, 20, 20); + final int[] at3 = store.snapshot(0); + assertArrayEquals(new int[] {1, 30, 30, 60, 60}, withoutVersion(at3)); + env.scale = 2; + final int[] at2 = store.snapshot(0); + assertArrayEquals(new int[] {1, 20, 20, 40, 40}, withoutVersion(at2)); + assertNotEquals("a density change moves the version", at3[0], at2[0]); + env.scale = 2.625; + assertArrayEquals(new int[] {1, 26, 26, 53, 53}, withoutVersion(store.snapshot(0))); + assertTrue(env.scaleReads >= 3); + } + + @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 emptyUntilSomethingIsPublished() { - final int[] s = new SecureRectangleStore().snapshot(0); - assertArrayEquals(new int[] {BugseeWrapper.SECURE_RECTANGLES_VERSION_UNKNOWN, 0}, s); + 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 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 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))); } - /** 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 aScaleThatIsNotUsableCoversTheWholeDisplay() { + publish(1, 1, 2, 2); + for (final double bad : new double[] {Double.NaN, 0, -1, Double.POSITIVE_INFINITY}) { + env.scale = 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 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 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.scale = 2; // the density changed + 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 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); + } + + @Test + 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(1, 1, 2, 2)); + final int d0 = store.snapshot(0)[0]; + final int 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))); + assertFalse("older epoch", store.publish(1, 4, 99, 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))); + } + + @Test + public void aNewIsolateWinsEvenThoughItsCounterRestarted() { + store.setPlacement(1, 0, Placement.known(0, 0, 0)); + assertTrue(store.publish(1, 5, 500, view(1, 1, 2, 2))); + assertTrue("a hot restart: larger epoch, sequence back at 1", + store.publish(1, 6, 1, view(2, 2, 3, 3))); + assertArrayEquals(new int[] {1, 6, 6, 9, 9}, withoutVersion(store.snapshot(0))); + } + + @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, 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.scale = 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, Collections.singletonMap(0, 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]); + } + + 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; + } } From 1bfa74ee33b3f16f8cbba97f7415f22c4cac1308 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 04:46:32 +0500 Subject: [PATCH 03/16] Android: tests for per-display versions use different sets per display Change-Id: Id39628b73217984eded6d10af92452186aa581dd --- .../java/com/bugsee/flutter/SecureRectangleStoreTest.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java b/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java index a339d61..4b0acfb 100644 --- a/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java +++ b/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java @@ -130,6 +130,7 @@ 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))); } @@ -329,9 +330,12 @@ 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(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]); From 717ef89ea481f3e792ea75e7053dd91e91ed0cf1 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 05:12:26 +0500 Subject: [PATCH 04/16] iOS: real secure-rectangle store, placement tracker, XCTests Same model as Android with the iOS unit: points, so the factor from Flutter logical pixels is 1 (not the screen scale); the view's position in UIScreen.coordinateSpace is added; per-display derived versions; unknown placement fails closed to the whole screen. XCTests cover the 3x screen, fractional factors, versions, ordering, cross-queue reads and real UIKit views. Co-Authored-By: Claude Sonnet 5.5 Change-Id: Id2d0ae92bfdf09fc10f155125e6b5ac907070d0a --- .../bugsee_flutter/BugseeFlutterPlugin.swift | 45 +- .../BGSFlutterSecurePlacementTracker.m | 158 +++++++ .../BGSFlutterSecureRectangleStore.m | 361 +++++++++++++- .../BGSFlutterWrapper.m | 2 +- .../BGSFlutterSecurePlacementTracker.h | 54 +++ .../include/BGSFlutterSecureRectangleStore.h | 100 +++- .../BGSFlutterSecurePlacementTrackerTests.m | 105 +++++ .../BGSFlutterSecureRectangleStoreTests.m | 444 ++++++++++++++++++ .../BGSFlutterWrapperTests.m | 53 ++- 9 files changed, 1271 insertions(+), 51 deletions(-) create mode 100644 ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecurePlacementTracker.m create mode 100644 ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/include/BGSFlutterSecurePlacementTracker.h create mode 100644 ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterSecurePlacementTrackerTests.m create mode 100644 ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterSecureRectangleStoreTests.m diff --git a/ios/bugsee_flutter/Sources/bugsee_flutter/BugseeFlutterPlugin.swift b/ios/bugsee_flutter/Sources/bugsee_flutter/BugseeFlutterPlugin.swift index 27bfb2e..d94091d 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,19 @@ public class BugseeFlutterPlugin: NSObject, FlutterPlugin, BugseeBridgeHostApi { context: identity.context ) } + + func setSecureRectangles(state: SecureRectanglesMessage) throws -> Bool { + var views: [NSNumber: Data] = [:] + for view in state.views { + views[NSNumber(value: view.viewId)] = 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_wrapper/BGSFlutterSecurePlacementTracker.m b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecurePlacementTracker.m new file mode 100644 index 0000000..96dfa75 --- /dev/null +++ b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecurePlacementTracker.m @@ -0,0 +1,158 @@ +#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)pointsPerLogicalPixelForDisplay:(NSInteger)display { + // A Flutter logical pixel is a UIKit point (see the header). Not `UIScreen.scale`. + return 1.0; +} + +- (CGSize)displayBoundsInPointsForDisplay:(NSInteger)display { + return [self currentScreen].bounds.size; +} + +@end + +#pragma mark - Tracker + +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; + CGPoint origin = [view convertPoint:CGPointZero toCoordinateSpace:screen.coordinateSpace]; + [_environment updateWithScreen:screen]; + [self place:[BGSFlutterSecurePlacement knownOnDisplay:kDefaultDisplay x:origin.x y:origin.y]]; + } @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..5aa19a5 100644 --- a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecureRectangleStore.m +++ b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecureRectangleStore.m @@ -1,7 +1,137 @@ #import "BGSFlutterSecureRectangleStore.h" +#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 + +#pragma mark - Placement + +@implementation BGSFlutterSecurePlacement +- (instancetype)initWithKind:(BGSFlutterPlacementKind)kind + display:(NSInteger)display + x:(double)x + y:(double)y { + if ((self = [super init])) { + _kind = kind; + _display = display; + _x = x; + _y = y; + } + return self; +} ++ (instancetype)knownOnDisplay:(NSInteger)display x:(double)x y:(double)y { + return [[self alloc] initWithKind:BGSFlutterPlacementKnown display:display x:x y:y]; +} ++ (instancetype)unknown { + return [[self alloc] initWithKind:BGSFlutterPlacementUnknown display:0 x:0 y:0]; +} ++ (instancetype)absent { + return [[self alloc] initWithKind:BGSFlutterPlacementAbsent display:0 x:0 y:0]; +} +- (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; +} +- (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)pointsPerLogicalPixelForDisplay:(NSInteger)display { + return NAN; +} +- (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; +/// 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 rects:(NSData *)rects { + if ((self = [super init])) { + _owner = owner; + _viewId = viewId; + _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] 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 + +/// What a display last served. +@interface BGSFlutterServed : NSObject +@property(nonatomic, readonly) int32_t version; +@property(nonatomic, readonly, copy) NSData *quads; +@property(nonatomic, readonly, copy) NSData *buffer; +@end +@implementation BGSFlutterServed +- (instancetype)initWithVersion:(int32_t)version quads:(NSData *)quads { + if ((self = [super init])) { + _version = version; + _quads = [quads 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; + BGSFlutterSecureState *_state; + NSMutableDictionary *_served; + void (^_pullHook)(void); + NSObject *_writeLock; + NSObject *_serveLock; } + (instancetype)shared { @@ -14,34 +144,233 @@ + (instancetype)shared { } - (instancetype)init { + return [self initWithEnvironment:[[BGSFlutterUnknownEnvironment alloc] init]]; +} + +- (instancetype)initWithEnvironment:(id)environment { if ((self = [super init])) { - _sets = [NSMutableDictionary dictionary]; + _environment = environment; + _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; + } +} + +- (void)setPullHook:(void (^)(void))hook { + @synchronized(_writeLock) { + _pullHook = [hook 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)]; + if (last != nil) { + int64_t lastEpoch = last[0].longLongValue; + int64_t lastSequence = last[1].longLongValue; + if (epoch < lastEpoch || (epoch == lastEpoch && sequence <= lastSequence)) { + return NO; + } + } + NSMutableArray *lanes = [NSMutableArray array]; + for (BGSFlutterLane *lane in previous.lanes) { + if (lane.owner != owner) [lanes addObject:lane]; + } + [views enumerateKeysAndObjectsUsingBlock:^(NSNumber *viewId, NSData *rects, BOOL *stop) { + if (rects.length > 0) { + [lanes addObject:[[BGSFlutterLane alloc] initWithOwner:owner + viewId:viewId.longLongValue + rects: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) ]; + _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]; } - if (set != nil) { - return set; +} + +- (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)); +} + +/// The point rectangles `display` serves for `state`, as packed int32 quads. +static NSData *BGSServe(BGSFlutterSecureState *state, NSInteger display, + id env) { + double scale = NAN; + BOOL scaleRead = NO; + CGSize bounds = CGSizeZero; + BOOL boundsRead = 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 (!scaleRead) { + scale = [env pointsPerLogicalPixelForDisplay:display]; + scaleRead = YES; + } + if (!boundsRead) { + bounds = [env displayBoundsInPointsForDisplay:display]; + boundsRead = YES; + } + NSUInteger count = lane.rects.length / sizeof(double); + if (!(scale > 0) || isinf(scale) || lane.rects.length % (4 * sizeof(double)) != 0) { + 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 * scale + placement.x)); + int32_t it = BGSToInt(floor(t * scale + placement.y)); + int32_t ir = BGSToInt(ceil(rt * scale + placement.x)); + int32_t ib = BGSToInt(ceil(b * scale + 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 (!boundsRead) 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)]; } - int32_t ints[2] = {0, 0}; - return [NSData dataWithBytes:ints length:sizeof(ints)]; + return out; } -- (void)publish:(NSData *)buffer forDisplay:(NSInteger)display { - NSData *copy = [buffer copy]; - @synchronized(self) { - _sets[@(display)] = copy; +- (NSData *)snapshotForDisplay:(NSInteger)display { + @synchronized(_serveLock) { + NSData *quads = BGSServe([self currentState], display, _environment); + BGSFlutterServed *previous = _served[@(display)]; + if (previous == nil && quads.length == 0) { + int32_t empty[2] = {kUnknownVersion, 0}; + return [NSData dataWithBytes:empty length:sizeof(empty)]; + } + if (previous != nil && [previous.quads isEqualToData:quads]) { + return previous.buffer; + } + int32_t version = previous == nil ? 1 : [BGSFlutterSecureRectangleStore versionAfter:previous.version]; + BGSFlutterServed *next = [[BGSFlutterServed alloc] initWithVersion:version quads:quads]; + _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..9b92631 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,108 @@ +#import #import NS_ASSUME_NONNULL_BEGIN +/// What the store reads off the device, at every pull. +@protocol BGSFlutterSecureEnvironment +/// UIKit points per Flutter logical pixel on `display`. 1 on iOS (a Flutter logical pixel IS a +/// point); the store applies whatever it is told, at every pull, never cached. Not finite or not +/// positive means "unknown" and fails closed. +- (double)pointsPerLogicalPixelForDisplay:(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, +}; + +/// 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; ++ (instancetype)knownOnDisplay:(NSInteger)display x:(double)x y:(double)y; ++ (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. This class multiplies +/// by `pointsPerLogicalPixel` (asked at every pull), adds the view's position on the +/// screen, then rounds outward (scale BEFORE rounding, 5.3.1). +/// +/// 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; +- (instancetype)initWithEnvironment:(id)environment + NS_DESIGNATED_INITIALIZER; + +- (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 its flat logical +/// `[l, t, r, b, ...]` as packed native-endian doubles; a view not in it has no +/// rectangles any more. Ignored (returns NO) unless `(epoch, sequence)` is newer than the +/// newest applied for `owner`, so a delayed older message never overwrites a newer one, +/// while a new isolate (larger epoch, counter back at 0) still wins. +- (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..e0ada25 --- /dev/null +++ b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterSecurePlacementTrackerTests.m @@ -0,0 +1,105 @@ +#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 : [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]); +} + +- (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..abde7c1 --- /dev/null +++ b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterSecureRectangleStoreTests.m @@ -0,0 +1,444 @@ +#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 +@property(nonatomic) double factor; +@property(nonatomic) CGSize bounds; +@property(nonatomic) NSInteger scaleReads; +@end + +@implementation BGSMockScreen +- (instancetype)init { + if ((self = [super init])) { + _factor = 1; // a logical pixel is a point + _bounds = CGSizeMake(402, 874); // iPhone 17 Pro, points + } + return self; +} +- (double)pointsPerLogicalPixelForDisplay:(NSInteger)display { + _scaleReads++; + return _factor; +} +- (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 : Doubles(v_, sizeof(v_) / sizeof(double))}; \ + }) + +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 the factor is 1, 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 pointsPerLogicalPixelForDisplay:0], 1.0); + 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 : 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 : 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 : 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 : 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 : 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 : Doubles(a, 4)}]; + [_store publishForOwner:2 epoch:1 sequence:1 views:@{@0 : 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 : 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 : Doubles(a, 4)}]); + XCTAssertFalse([_store publishForOwner:1 epoch:5 sequence:9 views:@{@0 : Doubles(old, 4)}], @"older sequence"); + XCTAssertFalse([_store publishForOwner:1 epoch:5 sequence:10 views:@{@0 : Doubles(old, 4)}], @"same sequence"); + XCTAssertFalse([_store publishForOwner:1 epoch:4 sequence:99 views:@{@0 : Doubles(old, 4)}], @"older epoch"); + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @1, @1, @2, @2 ])); + XCTAssertTrue([_store publishForOwner:1 epoch:5 sequence:11 views:@{@0 : Doubles(b, 4)}]); + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @2, @2, @3, @3 ])); +} + +- (void)testANewIsolateWinsEvenThoughItsCounterRestarted { + [_store setPlacement:[BGSFlutterSecurePlacement knownOnDisplay:0 x:0 y:0] forOwner:1 viewId:0]; + double a[] = {1, 1, 2, 2}, b[] = {2, 2, 3, 3}; + XCTAssertTrue([_store publishForOwner:1 epoch:5 sequence:500 views:@{@0 : Doubles(a, 4)}]); + XCTAssertTrue([_store publishForOwner:1 epoch:6 sequence:1 views:@{@0 : Doubles(b, 4)}]); + XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @2, @2, @3, @3 ])); +} + +- (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 : Doubles(a, 4)}]); + XCTAssertTrue([_store publishForOwner:2 epoch:1 sequence:1 views:@{@0 : 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 : 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 : 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 : 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..c31763d 100644 --- a/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterWrapperTests.m +++ b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterWrapperTests.m @@ -6,16 +6,35 @@ @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)pointsPerLogicalPixelForDisplay:(NSInteger)display { + return 1.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 : [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 +102,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 +125,13 @@ - (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 : [NSData dataWithBytes:moved length:sizeof(moved)]}]; + XCTAssertEqualObjects([(id)refined secureRectanglesForDisplay:0], Buffer(2, 10, 20, 31, 40)); } @end From e7de670e29b88eda7a1a5ecbc218c42d1e1eff78 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 05:43:41 +0500 Subject: [PATCH 05/16] Secure rectangles: drift guards, channel test, docs; guards follow the pull Co-Authored-By: Claude Sonnet 5.5 Change-Id: Ic949a219e2ce80c28639375e80e90d28170b9eeb --- .claude/CLAUDE.md | 3 +- .github/CI.md | 4 +- PLAN.md | 21 ++++-- test/lifecycle_drift_test.dart | 2 +- test/secure_rects_drift_test.dart | 101 ++++++++++++++++++++++++++++ test/secure_region_test.dart | 54 +++++++++++++++ test/wrapper_registration_test.dart | 6 +- 7 files changed, 179 insertions(+), 12 deletions(-) create mode 100644 test/secure_rects_drift_test.dart diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4539aa1..7e3e942 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -23,7 +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. +- Secure rectangles (workbook Part 5; Dart `lib/src/secure`, `SecureRectangleStore` / `BGSFlutterSecureRectangleStore`): Dart publishes Flutter LOGICAL pixels per view, whole state, ordered by `(epoch, sequence)`; native converts. The SDKs' units differ and are read from SDK source: Android 7.4.2 takes PHYSICAL PIXELS in `View.getLocationOnScreen` space (factor = 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 1; NEVER multiply by `UIScreen.scale`). Scale and add the view's screen position FIRST, 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 or mis-scaled serves the WHOLE display (fail closed); only a view known to be off screen serves nothing. Dart reports regions unclipped (never less than the region) and drops those that are not painted. The constants repeated in Java and Objective-C are compared by `test/secure_rects_drift_test.dart`. 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 moves is covered one frame late (Dart measures after a frame, then crosses a channel); 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; an `Offstage`/`IndexedStack` child is not reported while it is not painted; FP noise can add one pixel to an edge; only the implicit view (id 0) is placed on mobile; iOS is verified in portrait only. - Never treat lifecycle events as proof that a report exists or was sent. ## 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 39f673a..cdf455f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -48,11 +48,22 @@ 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 on re-attach, which recovers status but not report events. Phase 4/6 consumers must not rely on report events 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; iOS: factor 1, a Flutter logical pixel is a UIKit point, never the screen scale), adds the view's + on-screen position, then floors l/t and ceils r/b. 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 only when the set changed, ordered by `(epoch, sequence)`. Regions are + reported UNCLIPPED through every ancestor transform (bounding box); a region that is not painted + (scrolled out of its sliver, Offstage, a covered route) is not reported; an unmeasurable one covers the whole view. + 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/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..4226068 --- /dev/null +++ b/test/secure_rects_drift_test.dart @@ -0,0 +1,101 @@ +// 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, sequence) the same way', () { + expect( + store, + contains('epoch < last[0] || (epoch == last[0] && sequence <= last[1])'), + ); + expect( + mStore, + contains( + 'epoch < lastEpoch || (epoch == lastEpoch && sequence <= lastSequence)', + ), + ); + }); + + test('iOS converts logical pixels to points with a factor of 1, not the ' + 'screen scale (Android multiplies: physical pixels)', () { + expect(mTracker, contains('return 1.0;')); + expect(mTracker, isNot(contains('.scale'))); + final env = code('$java/SecureEnvironment.java'); + expect(env, contains('getDisplayMetrics().density')); + }); +} diff --git a/test/secure_region_test.dart b/test/secure_region_test.dart index cf80c49..2ec1ec2 100644 --- a/test/secure_region_test.dart +++ b/test/secure_region_test.dart @@ -3,6 +3,7 @@ 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'; @@ -581,6 +582,59 @@ void main() { }); }); + 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, 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};')); }, ); From a8f745d97b1763937fb2ef27c2f316e7da87e3e2 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 05:54:40 +0500 Subject: [PATCH 06/16] Dart secure regions: hide only on positive evidence, cover paint overflow, send the pixel ratio, random epoch A detached layer is not evidence of invisibility: SnapshotWidget (the Zoom page transition) paints a region into a temporary layer while its pixels are on screen. Hidden now means: an ancestor that does not paint it, an IndexedStack showing another child, a disabled TickerMode, or a clipping viewport it lies wholly outside of. Co-Authored-By: Claude Sonnet 5.5 Change-Id: I68ec6aa2cd9d7b91f9ac041b0fe37be0b90a0547 --- .../java/com/bugsee/flutter/Messages.java | 52 +++- .../Sources/bugsee_flutter/Messages.g.swift | 26 +- lib/src/bridge/messages.g.dart | 31 ++- lib/src/secure/secure_region.dart | 217 ++++++++++++---- pigeons/bridge.dart | 23 +- test/secure_region_test.dart | 237 ++++++++++++++++++ 6 files changed, 508 insertions(+), 78 deletions(-) diff --git a/android/src/main/java/com/bugsee/flutter/Messages.java b/android/src/main/java/com/bugsee/flutter/Messages.java index d3560d2..685d535 100644 --- a/android/src/main/java/com/bugsee/flutter/Messages.java +++ b/android/src/main/java/com/bugsee/flutter/Messages.java @@ -510,6 +510,26 @@ public void setViewId(@NonNull Long setterArg) { 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() { @@ -531,18 +551,18 @@ 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(rectangles, that.rectangles); + return pigeonDeepEquals(viewId, that.viewId) && pigeonDeepEquals(devicePixelRatio, that.devicePixelRatio) && pigeonDeepEquals(rectangles, that.rectangles); } @Override public int hashCode() { - Object[] fields = new Object[] {getClass(), viewId, rectangles}; + Object[] fields = new Object[] {getClass(), viewId, devicePixelRatio, rectangles}; return pigeonDeepHashCode(fields); } @Override public String toString() { - return "SecureViewRectanglesMessage{" + "viewId=" + viewId + ", " + "rectangles=" + rectangles + "}"; + return "SecureViewRectanglesMessage{" + "viewId=" + viewId + ", " + "devicePixelRatio=" + devicePixelRatio + ", " + "rectangles=" + rectangles + "}"; } public static final class Builder { @@ -555,6 +575,14 @@ public static final class Builder { return this; } + private @Nullable Double devicePixelRatio; + + @CanIgnoreReturnValue + public @NonNull Builder setDevicePixelRatio(@NonNull Double setterArg) { + this.devicePixelRatio = setterArg; + return this; + } + private @Nullable List rectangles; @CanIgnoreReturnValue @@ -566,6 +594,7 @@ public static final class Builder { public @NonNull SecureViewRectanglesMessage build() { SecureViewRectanglesMessage pigeonReturn = new SecureViewRectanglesMessage(); pigeonReturn.setViewId(viewId); + pigeonReturn.setDevicePixelRatio(devicePixelRatio); pigeonReturn.setRectangles(rectangles); return pigeonReturn; } @@ -573,8 +602,9 @@ public static final class Builder { @NonNull ArrayList toList() { - ArrayList toListResult = new ArrayList<>(2); + ArrayList toListResult = new ArrayList<>(3); toListResult.add(viewId); + toListResult.add(devicePixelRatio); toListResult.add(rectangles); return toListResult; } @@ -583,7 +613,9 @@ ArrayList toList() { SecureViewRectanglesMessage pigeonResult = new SecureViewRectanglesMessage(); Object viewId = pigeonVar_list.get(0); pigeonResult.setViewId((Long) viewId); - Object rectangles = pigeonVar_list.get(1); + Object devicePixelRatio = pigeonVar_list.get(1); + pigeonResult.setDevicePixelRatio((Double) devicePixelRatio); + Object rectangles = pigeonVar_list.get(2); pigeonResult.setRectangles((List) rectangles); return pigeonResult; } @@ -594,10 +626,12 @@ ArrayList toList() { * absent from [views] has none. Whole-state, not a delta, so a lost or late * message can never leave a rectangle behind. * - * Ordering: `(epoch, sequence)` is compared lexicographically and native - * ignores a message that is not newer than the newest it accepted. `epoch` - * identifies the Dart isolate (a hot restart starts a new one, whose counter - * restarts at 0, so it must still win); `sequence` counts publishes within it. + * 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. */ diff --git a/ios/bugsee_flutter/Sources/bugsee_flutter/Messages.g.swift b/ios/bugsee_flutter/Sources/bugsee_flutter/Messages.g.swift index 6dfea1e..6af4973 100644 --- a/ios/bugsee_flutter/Sources/bugsee_flutter/Messages.g.swift +++ b/ios/bugsee_flutter/Sources/bugsee_flutter/Messages.g.swift @@ -301,22 +301,31 @@ struct WrapperIdentityMessage: Hashable, CustomStringConvertible { 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 rectangles = pigeonVar_list[1] as! [Double] + 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, ] } @@ -324,17 +333,18 @@ struct SecureViewRectanglesMessage: Hashable, CustomStringConvertible { if Swift.type(of: lhs) != Swift.type(of: rhs) { return false } - return MessagesPigeonInternal.deepEquals(lhs.viewId, rhs.viewId) && MessagesPigeonInternal.deepEquals(lhs.rectangles, rhs.rectangles) + 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)), rectangles: \(String(describing: rectangles)))" + return "SecureViewRectanglesMessage(viewId: \(String(describing: viewId)), devicePixelRatio: \(String(describing: devicePixelRatio)), rectangles: \(String(describing: rectangles)))" } } @@ -342,10 +352,12 @@ struct SecureViewRectanglesMessage: Hashable, CustomStringConvertible { /// absent from [views] has none. Whole-state, not a delta, so a lost or late /// message can never leave a rectangle behind. /// -/// Ordering: `(epoch, sequence)` is compared lexicographically and native -/// ignores a message that is not newer than the newest it accepted. `epoch` -/// identifies the Dart isolate (a hot restart starts a new one, whose counter -/// restarts at 0, so it must still win); `sequence` counts publishes within it. +/// 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 { diff --git a/lib/src/bridge/messages.g.dart b/lib/src/bridge/messages.g.dart index fd89869..1053c33 100644 --- a/lib/src/bridge/messages.g.dart +++ b/lib/src/bridge/messages.g.dart @@ -232,15 +232,26 @@ class WrapperIdentityMessage { /// 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.rectangles}); + 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, rectangles]; + return [viewId, devicePixelRatio, rectangles]; } Object encode() { @@ -251,7 +262,8 @@ class SecureViewRectanglesMessage { result as List; return SecureViewRectanglesMessage( viewId: result[0]! as int, - rectangles: (result[1]! as List).cast(), + devicePixelRatio: result[1]! as double, + rectangles: (result[2]! as List).cast(), ); } @@ -266,6 +278,7 @@ class SecureViewRectanglesMessage { return true; } return _deepEquals(viewId, other.viewId) && + _deepEquals(devicePixelRatio, other.devicePixelRatio) && _deepEquals(rectangles, other.rectangles); } @@ -275,7 +288,7 @@ class SecureViewRectanglesMessage { @override String toString() { - return 'SecureViewRectanglesMessage(viewId: $viewId, rectangles: $rectangles)'; + return 'SecureViewRectanglesMessage(viewId: $viewId, devicePixelRatio: $devicePixelRatio, rectangles: $rectangles)'; } } @@ -283,10 +296,12 @@ class SecureViewRectanglesMessage { /// absent from [views] has none. Whole-state, not a delta, so a lost or late /// message can never leave a rectangle behind. /// -/// Ordering: `(epoch, sequence)` is compared lexicographically and native -/// ignores a message that is not newer than the newest it accepted. `epoch` -/// identifies the Dart isolate (a hot restart starts a new one, whose counter -/// restarts at 0, so it must still win); `sequence` counts publishes within it. +/// 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, diff --git a/lib/src/secure/secure_region.dart b/lib/src/secure/secure_region.dart index 6dfcc1f..a12ec70 100644 --- a/lib/src/secure/secure_region.dart +++ b/lib/src/secure/secure_region.dart @@ -16,12 +16,19 @@ // 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. -// * Scrolled out / offstage / not painted: a region that contributes nothing to -// the composited frame (its layer is not in the layer tree: the sliver did not -// paint it, an Offstage or an inactive route hides it) is NOT reported, and is -// removed the frame it stops painting. Reporting its stale geometry would -// black out whatever now sits at that spot. A region that has never been laid -// out or painted is in the same state. +// * 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, +// a disabled TickerMode (an inactive route or overlay entry, which is how a +// covered page is hidden), or a clipping scroll viewport that its region lies +// wholly outside of. Nothing else drops it. 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. @@ -34,6 +41,7 @@ // 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'; @@ -47,28 +55,41 @@ typedef SecureRectanglesPublisher = /// One region's measured box: logical pixels, relative to its view. @immutable class MeasuredRegion { - const MeasuredRegion(this.viewId, this.rect); + 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.rect == rect; + other is MeasuredRegion && + other.viewId == viewId && + other.ratio == ratio && + other.rect == rect; @override - int get hashCode => Object.hash(viewId, rect); + int get hashCode => Object.hash(viewId, ratio, rect); @override - String toString() => 'MeasuredRegion(view $viewId, $rect)'; + String toString() => 'MeasuredRegion(view $viewId @$ratio, $rect)'; } -/// The rectangles of every view: view id to its canonical rectangles. -typedef SecureState = Map>; +/// 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; +} -bool _finite(double v) => v.isFinite; +/// The rectangles of every view: view id to its ratio and rectangles. +typedef SecureState = Map; bool _rectFinite(Rect r) => - _finite(r.left) && _finite(r.top) && _finite(r.right) && _finite(r.bottom); + 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. @@ -85,25 +106,102 @@ List canonicalRects(Iterable rects) { return list; } -/// Whether two states are the same set of rectangles per view. +/// 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.length != entry.value.length) return false; - for (var i = 0; i < other.length; i++) { - if (other[i] != entry.value[i]) return false; + 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. Never throws (an exception means "not provable"). +bool provablyHidden(RenderSecureRegion box) { + try { + if (!box.tickerEnabled) return true; + 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 (parent is RenderViewportBase && parent.clipBehavior != Clip.none) { + // A viewport clips what lies outside it: a region wholly outside is + // invisible (a child kept in the cache extent, scrolled out of view). + final Rect inViewport = MatrixUtils.transformRect( + box.getTransformTo(parent), + Offset.zero & box.size, + ); + final Rect port = Offset.zero & parent.size; + if (_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. +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) { + 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; + } + rect = rect.expandToInclude( + MatrixUtils.transformRect( + child.getTransformTo(null), + child.paintBounds, + ), + ); + visit(child); + }); + } + + visit(box); + 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 || !box.isOnScreen) return null; + if (!box.attached || !box.hasSize || provablyHidden(box)) return null; // The root of this box's tree is its view. RenderObject root = box; while (root.parent != null) { @@ -116,12 +214,13 @@ MeasuredRegion? measureRegion(RenderSecureRegion box) { if (!(size.width > 0 && size.height > 0)) { return null; // zero or negative size: nothing to hide } - final Rect rect = MatrixUtils.transformRect( - box.getTransformTo(null), - Offset.zero & size, - ); - return _rectFinite(rect) - ? MeasuredRegion(view.flutterView.viewId, rect) + final Rect? rect = _paintedRect(box); + return rect != null && _rectFinite(rect) + ? MeasuredRegion( + view.flutterView.viewId, + view.flutterView.devicePixelRatio, + rect, + ) : _wholeView(view); } catch (_) { return view == null ? null : _wholeView(view); @@ -129,21 +228,24 @@ MeasuredRegion? measureRegion(RenderSecureRegion box) { } MeasuredRegion? _wholeView(RenderView view) { + final int id = view.flutterView.viewId; + final double ratio = view.flutterView.devicePixelRatio; try { final Size physical = view.flutterView.physicalSize; - final double ratio = view.flutterView.devicePixelRatio; 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( - view.flutterView.viewId, - Rect.fromLTWH(0, 0, math.max(w.toDouble(), 1), math.max(h.toDouble(), 1)), + id, + ratio, + Rect.fromLTWH(0, 0, math.max(w, 1), math.max(h, 1)), ); } catch (_) { return MeasuredRegion( - view.flutterView.viewId, + id, + ratio, const Rect.fromLTWH(0, 0, 1048576, 1048576), ); } @@ -157,7 +259,15 @@ class SecureRegionRegistry { int? epoch, this.retryDelay = const Duration(seconds: 1), this.maxRetries = 5, - }) : epoch = epoch ?? DateTime.now().microsecondsSinceEpoch; + }) : epoch = epoch ?? _randomEpoch(); + + /// 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 => @@ -185,7 +295,7 @@ class SecureRegionRegistry { /// 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 = >{}; + SecureState? _published = {}; /// How many publishes were attempted. For tests. @visibleForTesting @@ -203,6 +313,9 @@ class SecureRegionRegistry { if (_regions.remove(region)) _schedule(); } + /// Something a measurement depends on changed: measure this frame. + void touch() => _schedule(); + void _schedule() { final binding = SchedulerBinding.instance; if (!_scheduled) { @@ -238,15 +351,18 @@ class SecureRegionRegistry { /// 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) (byView[m.viewId] ??= []).add(m.rect); + if (m == null) continue; + (byView[m.viewId] ??= []).add(m.rect); + ratios[m.viewId] = m.ratio; } - final result = >{}; + final result = {}; final ids = byView.keys.toList()..sort(); for (final id in ids) { final rects = canonicalRects(byView[id]!); - if (rects.isNotEmpty) result[id] = rects; + if (rects.isNotEmpty) result[id] = ViewRects(ratios[id]!, rects); } return result; } @@ -260,8 +376,9 @@ class SecureRegionRegistry { for (final entry in state.entries) SecureViewRectanglesMessage( viewId: entry.key, + devicePixelRatio: entry.value.ratio, rectangles: [ - for (final r in entry.value) ...[ + for (final r in entry.value.rects) ...[ r.left, r.top, r.right, @@ -315,27 +432,27 @@ class SecureRegionRegistry { } /// The render object behind [BugseeSecureRegion]. -/// -/// A repaint boundary on purpose: its layer is in the composited tree exactly -/// when the region is being painted this frame, which is how [isOnScreen] tells -/// a visible region from one that is offstage or scrolled out of its sliver. class RenderSecureRegion extends RenderProxyBox { RenderSecureRegion({ required bool enabled, required SecureRegionRegistry registry, + bool tickerEnabled = true, }) : _enabled = enabled, - _registry = registry; + _registry = registry, + _tickerEnabled = tickerEnabled; bool _enabled; SecureRegionRegistry _registry; - - @override - bool get isRepaintBoundary => true; - - /// Whether this region is part of the composited frame. - bool get isOnScreen { - final l = layer; - return l != null && l.attached; + bool _tickerEnabled; + + /// `false` when an enclosing [TickerMode] is disabled: Flutter's way of + /// saying a route or overlay entry is inactive (covered, offstage). Positive + /// evidence that the region is not on screen. + bool get tickerEnabled => _tickerEnabled; + set tickerEnabled(bool value) { + if (value == _tickerEnabled) return; + _tickerEnabled = value; + if (attached && _enabled) _registry.touch(); } bool get enabled => _enabled; @@ -397,6 +514,7 @@ class BugseeSecureRegion extends SingleChildRenderObjectWidget { RenderSecureRegion( enabled: enabled, registry: registry ?? SecureRegionRegistry.instance, + tickerEnabled: TickerMode.valuesOf(context).enabled, ); @override @@ -406,6 +524,7 @@ class BugseeSecureRegion extends SingleChildRenderObjectWidget { ) { renderObject ..registry = registry ?? SecureRegionRegistry.instance - ..enabled = enabled; + ..enabled = enabled + ..tickerEnabled = TickerMode.valuesOf(context).enabled; } } diff --git a/pigeons/bridge.dart b/pigeons/bridge.dart index 9e1aca3..3c26990 100644 --- a/pigeons/bridge.dart +++ b/pigeons/bridge.dart @@ -57,10 +57,21 @@ class WrapperIdentityMessage { /// 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.rectangles}); + 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; } @@ -68,10 +79,12 @@ class SecureViewRectanglesMessage { /// absent from [views] has none. Whole-state, not a delta, so a lost or late /// message can never leave a rectangle behind. /// -/// Ordering: `(epoch, sequence)` is compared lexicographically and native -/// ignores a message that is not newer than the newest it accepted. `epoch` -/// identifies the Dart isolate (a hot restart starts a new one, whose counter -/// restarts at 0, so it must still win); `sequence` counts publishes within it. +/// 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, diff --git a/test/secure_region_test.dart b/test/secure_region_test.dart index 2ec1ec2..79f7184 100644 --- a/test/secure_region_test.dart +++ b/test/secure_region_test.dart @@ -429,6 +429,243 @@ void main() { }); }); + 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: 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('Cupertino', (tester) async { + await run(tester, const CupertinoPageTransitionsBuilder()); + }); + 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 (an inactive route or overlay entry) ' + 'hides it; enabling brings it back', (tester) async { + Widget tree(bool on) => + TickerMode(enabled: on, child: at(registry, 5, 5)); + await tester.pumpWidget(app(registry, tree(true))); + expect(native.latest[0], [5, 5, 105, 55]); + await tester.pumpWidget(app(registry, tree(false))); + expect(native.sent.last.views, isEmpty); + await tester.pumpWidget(app(registry, tree(true))); + expect(native.latest[0], [5, 5, 105, 55]); + }); + + 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('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, From 7378c26612ba0be8f6f2463552b01e0da769c13f Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 06:03:20 +0500 Subject: [PATCH 07/16] Secure rectangles: grace window, ratio check, view scale, retired epochs, fail-closed placement Native stores serve the previous set along with a changed one for 120 ms (the union shrinking back moves the version), scale by the ratio Dart measured at and cover the whole display while it differs from the display's density, apply the Flutter view's own on-screen scale (whole display if rotated or skewed), order messages by random epoch ids with retirement instead of numeric epochs, treat a missing activity as unknown rather than off screen, and use the fallback size when the display reads as zero. Co-Authored-By: Claude Sonnet 5.5 Change-Id: I3f627e7cad9698aed77d959d6d6e0b3843b7c9b6 --- .claude/CLAUDE.md | 5 +- PLAN.md | 7 +- .../bugsee/flutter/BugseeFlutterPlugin.java | 9 +- .../com/bugsee/flutter/SecureEnvironment.java | 7 +- .../com/bugsee/flutter/SecurePlacement.java | 42 +- .../bugsee/flutter/SecureRectangleStore.java | 387 ++++++++++++++---- .../java/com/bugsee/flutter/ViewScale.java | 38 ++ .../bugsee/flutter/FlutterWrapperTest.java | 13 +- .../flutter/SecureRectangleStoreTest.java | 226 +++++++++- .../com/bugsee/flutter/ViewScaleTest.java | 41 ++ .../bugsee_flutter/BugseeFlutterPlugin.swift | 6 +- .../BGSFlutterSecurePlacementTracker.m | 51 ++- .../BGSFlutterSecureRectangleStore.m | 251 +++++++++--- .../include/BGSFlutterSecureRectangleStore.h | 66 ++- .../BGSFlutterSecurePlacementTrackerTests.m | 33 +- .../BGSFlutterSecureRectangleStoreTests.m | 226 ++++++++-- .../BGSFlutterWrapperTests.m | 17 +- test/secure_rects_drift_test.dart | 41 +- 18 files changed, 1219 insertions(+), 247 deletions(-) create mode 100644 android/src/main/java/com/bugsee/flutter/ViewScale.java create mode 100644 android/src/test/java/com/bugsee/flutter/ViewScaleTest.java diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 7e3e942..4d0c696 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -23,9 +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 rectangles (workbook Part 5; Dart `lib/src/secure`, `SecureRectangleStore` / `BGSFlutterSecureRectangleStore`): Dart publishes Flutter LOGICAL pixels per view, whole state, ordered by `(epoch, sequence)`; native converts. The SDKs' units differ and are read from SDK source: Android 7.4.2 takes PHYSICAL PIXELS in `View.getLocationOnScreen` space (factor = 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 1; NEVER multiply by `UIScreen.scale`). Scale and add the view's screen position FIRST, 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 or mis-scaled serves the WHOLE display (fail closed); only a view known to be off screen serves nothing. Dart reports regions unclipped (never less than the region) and drops those that are not painted. The constants repeated in Java and Objective-C are compared by `test/secure_rects_drift_test.dart`. 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 moves is covered one frame late (Dart measures after a frame, then crosses a channel); 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; an `Offstage`/`IndexedStack` child is not reported while it is not painted; FP noise can add one pixel to an edge; only the implicit view (id 0) is placed on mobile; iOS is verified in portrait only. -- 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 the previous set together with a changed one, 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 disabled TickerMode, a clipping viewport it lies wholly outside of). A detached layer is NOT such evidence (SnapshotWidget, the Zoom page transition). 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; iOS is verified in portrait only. ## Checks diff --git a/PLAN.md b/PLAN.md index cdf455f..89a2088 100644 --- a/PLAN.md +++ b/PLAN.md @@ -54,15 +54,16 @@ Sources: `specs/sdk/wrapper-workbook` (authoritative order of work), `wrapper-da 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; iOS: factor 1, a Flutter logical pixel is a UIKit point, never the screen scale), adds the view's + pull and matched against the ratio Dart measured at; iOS: ratio / UIScreen.scale = 1, never the screen scale), adds the view's on-screen position and scale, a 0.12 s grace window that serves the previous set with a changed one, and the view's on-screen position, then floors l/t and ceils r/b. 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 only when the set changed, ordered by `(epoch, sequence)`. Regions are - reported UNCLIPPED through every ancestor transform (bounding box); a region that is not painted - (scrolled out of its sliver, Offstage, a covered route) is not reported; an unmeasurable one covers the whole view. + reported UNCLIPPED through every ancestor transform (bounding box, including paint outside their box); a region is dropped only on positive evidence + (paintsChild, IndexedStack, a disabled TickerMode, a clipping viewport) and never because its layer is detached; an unmeasurable one 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 5156857..b59842d 100644 --- a/android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java +++ b/android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java @@ -63,9 +63,9 @@ public void onAttachedToEngine(@NonNull final FlutterPluginBinding binding) { placement = new SecurePlacement(owner, binding.getFlutterEngine(), store, environment); PLACEMENTS.add(placement); store.setPullHook(PULLS::request); - // No activity yet: nothing of this engine is on screen. + // No activity yet: where the view is is not known, so cover rather than guess. store.setPlacement(owner, SecurePlacement.IMPLICIT_VIEW_ID, - SecureRectangleStore.Placement.ABSENT); + SecureRectangleStore.Placement.UNKNOWN); } @Override @@ -141,14 +141,15 @@ public void setWrapperIdentity(@NonNull final Messages.WrapperIdentityMessage id @NonNull @Override public Boolean setSecureRectangles(@NonNull final Messages.SecureRectanglesMessage state) { - final Map views = new HashMap<>(); + 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(), rects); + views.put(view.getViewId().intValue(), + new SecureRectangleStore.ViewRects(view.getDevicePixelRatio(), rects)); } final boolean applied = SecureRectangleStore.shared().publish( owner, state.getEpoch(), state.getSequence(), views); diff --git a/android/src/main/java/com/bugsee/flutter/SecureEnvironment.java b/android/src/main/java/com/bugsee/flutter/SecureEnvironment.java index bc03314..3951872 100644 --- a/android/src/main/java/com/bugsee/flutter/SecureEnvironment.java +++ b/android/src/main/java/com/bugsee/flutter/SecureEnvironment.java @@ -38,7 +38,12 @@ void setResources(final int display, @NonNull final Resources r) { } @Override - public double pixelsPerLogicalPixel(final int display) { + 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(); diff --git a/android/src/main/java/com/bugsee/flutter/SecurePlacement.java b/android/src/main/java/com/bugsee/flutter/SecurePlacement.java index cd1ac68..08a2f61 100644 --- a/android/src/main/java/com/bugsee/flutter/SecurePlacement.java +++ b/android/src/main/java/com/bugsee/flutter/SecurePlacement.java @@ -11,6 +11,9 @@ 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; @@ -26,11 +29,8 @@ * *

The state it reports, and why: *

    - *
  • no activity attached: {@link SecureRectangleStore.Placement#ABSENT}, no - * Flutter pixels can be on screen (also across a configuration change, - * where the new activity re-attaches within the same frame of the main - * thread);
  • - *
  • an activity but no (or an ambiguous, or detached) Flutter view: + *
  • 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.
  • @@ -95,8 +95,10 @@ public void onGlobalLayout() { 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.ABSENT); + store.setPlacement(owner, IMPLICIT_VIEW_ID, SecureRectangleStore.Placement.UNKNOWN); return; } final FlutterView view = find(activity.getWindow().getDecorView(), engine); @@ -110,8 +112,11 @@ void refresh() { final int displayId = display == null ? 0 : display.getDisplayId(); view.getLocationOnScreen(location); environment.setResources(displayId, view.getResources()); - store.setPlacement(owner, IMPLICIT_VIEW_ID, - SecureRectangleStore.Placement.known(displayId, location[0], location[1])); + 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()); @@ -123,6 +128,27 @@ void refresh() { } } + /** + * 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; diff --git a/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java b/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java index cc7f2c4..ffc55dd 100644 --- a/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java +++ b/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java @@ -5,8 +5,10 @@ import com.bugsee.library.contracts.internal.BugseeWrapper; +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; @@ -31,30 +33,48 @@ * ({@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. - * This class turns them into the SDK's buffer: multiply by the display's - * pixels-per-logical-pixel (read at every pull, never cached), add the view's - * position on the screen, then round OUTWARD (floor {@code l}/{@code t}, ceil + *

    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). + * 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 scale or the view's position 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]}). + * served before (so also when only the density, the view's position or the + * grace window 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 when the + * served set changes, the PREVIOUS set is served as well for + * {@link #GRACE_MS}: a moving region leaves no uncovered strip as wide as one + * frame's movement, and a region that has just gone is covered one pull 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) is served as one rectangle covering the whole display. A - * rectangle that is not finite does the same, and so does a scale that is not - * a positive finite number. A view that is known to be not on screen at all - * (its activity is detached) serves nothing. + * 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

    * @@ -76,23 +96,41 @@ final class SecureRectangleStore { /** * The size a whole-display rectangle gets when the display's real size is - * not known: more than any panel, small enough not to overflow an int when - * the SDK scales it. + * not known (or reads as zero): more than any panel, small enough not to + * overflow an int when the SDK scales it. */ 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; + /** How long the previous set is served along with a changed one. Repeated in ObjC. */ + static final long GRACE_MS = 120; + + /** More rectangles than this and the previous set is collapsed to its 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 { /** - * Physical pixels per Flutter logical pixel on {@code display}: the - * density Flutter itself uses (Android: {@code DisplayMetrics.density} - * of the Flutter view). Read per call, never cached. Not finite or not - * positive means "unknown" and fails closed. + * 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 pixelsPerLogicalPixel(int display); + 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 @@ -102,10 +140,15 @@ interface Environment { /** 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 pixelsPerLogicalPixel(final int display) { + 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) { @@ -113,6 +156,22 @@ public int[] displayBounds(final int display) { } }; + /** 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; @@ -120,25 +179,47 @@ static final class Placement { 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); + 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); + 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; - - private Placement(final int kind, final int display, 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 new Placement(KNOWN_KIND, display, x, 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 @@ -147,12 +228,13 @@ public boolean equals(final Object o) { return false; } final Placement p = (Placement) o; - return kind == p.kind && display == p.display && x == p.x && y == p.y; + 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}); + return Arrays.hashCode(new double[] {kind, display, x, y, scaleX, scaleY}); } } @@ -183,18 +265,29 @@ public int compareTo(final Lane other) { } } + /** 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 { - /** Logical-pixel rectangles as Dart sent them, flat ltrb. */ - final TreeMap rects; + final TreeMap rects; final Map placements; - /** owner to {epoch, sequence} of the newest applied publish. */ - final Map order; + final Map order; State( - final TreeMap rects, + final TreeMap rects, final Map placements, - final Map order + final Map order ) { this.rects = rects; this.placements = placements; @@ -205,13 +298,25 @@ private static final class State { /** What a display last served. Immutable. */ private static final class Served { final int version; - /** The rectangles only (no header), physical pixels. */ + /** What is served: the current set plus, within the grace window, the previous one. */ final int[] quads; + final int[] current; + final int[] previous; + final long changedAtMs; final int[] buffer; - Served(final int version, @NonNull final int[] quads) { + Served( + final int version, + @NonNull final int[] quads, + @NonNull final int[] current, + @NonNull final int[] previous, + final long changedAtMs + ) { this.version = version; this.quads = quads; + this.current = current; + this.previous = previous; + this.changedAtMs = changedAtMs; this.buffer = new int[HEADER_INTS + quads.length]; buffer[0] = version; buffer[1] = quads.length / 4; @@ -219,7 +324,10 @@ private static final class Served { } } - private static final SecureRectangleStore SHARED = new SecureRectangleStore(UNKNOWN_ENVIRONMENT); + 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() { @@ -227,14 +335,27 @@ static SecureRectangleStore shared() { } 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<>(); + /** 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. */ @@ -252,38 +373,54 @@ void setPullHook(@Nullable final Runnable hook) { /** * Applies Dart's whole state for {@code owner}: {@code views} maps a view - * id to its flat logical {@code [l, t, r, b, ...]}; a view not in the map - * has no rectangles any more. Ignored, and {@code false}, unless - * {@code (epoch, sequence)} is newer than the newest applied for - * {@code owner}: a delayed older message must never overwrite a newer one, - * while a new isolate (a larger epoch, its counter back at 0) still wins. + * 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 + @NonNull final Map views ) { synchronized (writeLock) { final State previous = state; - final long[] last = previous.order.get(owner); - if (last != null && (epoch < last[0] || (epoch == last[0] && sequence <= last[1]))) { - return false; + 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()) { + 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()) { - final double[] copy = e.getValue().clone(); - if (copy.length > 0) { - rects.put(new Lane(owner, e.getKey()), copy); + 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 long[] {epoch, sequence}); + final Map order = new HashMap<>(previous.order); + order.put(owner, new Order(epoch, sequence, retired)); state = new State(rects, previous.placements, order); return true; } @@ -307,8 +444,8 @@ void setPlacement(final long owner, final int viewId, @NonNull final Placement p void dropOwner(final long owner) { synchronized (writeLock) { final State previous = state; - final TreeMap rects = new TreeMap<>(); - for (final Map.Entry e : previous.rects.entrySet()) { + final TreeMap rects = new TreeMap<>(); + for (final Map.Entry e : previous.rects.entrySet()) { if (e.getKey().owner != owner) { rects.put(e.getKey(), e.getValue()); } @@ -319,7 +456,7 @@ void dropOwner(final long owner) { placements.put(e.getKey(), e.getValue()); } } - final Map order = new HashMap<>(previous.order); + final Map order = new HashMap<>(previous.order); order.remove(owner); state = new State(rects, placements, order); } @@ -341,28 +478,86 @@ int[] pull(final int display) { /** * The buffer for {@code display}: {@code [version, count, l, t, r, b, ...]}. - * Computed from the current state, scale and size at this call; a fresh + * Computed from the current state, density and size at this call; a fresh * array every time. */ @NonNull int[] snapshot(final int display) { synchronized (serveLock) { - final int[] quads = serve(state, display); - final Served previous = served.get(display); - if (previous == null && quads.length == 0) { - return new int[] {UNKNOWN_VERSION, 0}; + 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, NONE, now); + served.put(display, first); + return first.buffer.clone(); } - if (previous != null && Arrays.equals(previous.quads, quads)) { - return previous.buffer.clone(); + final int[] previous; + long changedAt = last.changedAtMs; + if (!Arrays.equals(target, last.current)) { + // Something moved: keep covering where it was, and where it was before that if + // that is still recent. + previous = graceMs <= 0 ? NONE + : union(last.current, inGrace(last, now) ? last.previous : NONE); + changedAt = now; + } else { + previous = last.previous.length > 0 && !inGrace(last, now) ? NONE : last.previous; } - final Served next = new Served(nextVersion(previous), quads); + final int[] out = previous.length == 0 ? target : union(target, previous); + final int version = Arrays.equals(out, last.quads) + ? last.version : advance(last.version); + final Served next = out == last.quads && previous == last.previous + && Arrays.equals(target, last.current) && changedAt == last.changedAtMs + ? last : new Served(version, out, target, previous, changedAt); served.put(display, next); return next.buffer.clone(); } } - private static int nextVersion(@Nullable final Served previous) { - return previous == null ? 1 : advance(previous.version); + private boolean inGrace(@NonNull final Served s, final long now) { + return now - s.changedAtMs < graceMs; + } + + /** 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"). */ @@ -371,21 +566,22 @@ static int advance(final int version) { return next == UNKNOWN_VERSION ? next + 1 : next; } - /** The physical-pixel rectangles {@code display} serves for {@code s}, flat ltrb. */ + /** 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 scale = Double.NaN; - boolean scaleRead = false; + double nativeRatio = Double.NaN; + double unit = 1; + boolean envRead = false; int[] bounds = null; - boolean boundsRead = false; int[] out = new int[16]; int n = 0; boolean whole = false; - for (final Map.Entry entry : s.rects.entrySet()) { - final double[] logical = entry.getValue(); + 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) { @@ -398,15 +594,21 @@ private int[] serve(@NonNull final State s, final int display) { if (placement.display != display) { continue; } - if (!scaleRead) { - scale = env.pixelsPerLogicalPixel(display); - scaleRead = true; - } - if (!boundsRead) { + if (!envRead) { + nativeRatio = env.nativePixelRatio(display); + unit = env.unitsPerPhysicalPixel(display); bounds = env.displayBounds(display); - boundsRead = true; + envRead = true; } - if (!(scale > 0) || Double.isInfinite(scale) || logical.length % 4 != 0) { + 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; } @@ -423,11 +625,11 @@ private int[] serve(@NonNull final State s, final int display) { continue; // degenerate: nothing to hide } // Scale and place first, round outward last (5.3.1). - int il = toInt(Math.floor(l * scale + placement.x)); - int it = toInt(Math.floor(t * scale + placement.y)); - int ir = toInt(Math.ceil(r * scale + placement.x)); - int ib = toInt(Math.ceil(b * scale + placement.y)); - if (bounds != null) { + 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]); @@ -449,16 +651,21 @@ private int[] serve(@NonNull final State s, final int display) { if (whole) { // Something could not be placed or measured: cover everything. The // other rectangles are inside it, so they add nothing. - if (!boundsRead) { + if (!envRead) { bounds = env.displayBounds(display); } - final int w = bounds == null ? FALLBACK_DISPLAY_SIZE : bounds[0]; - final int h = bounds == null ? FALLBACK_DISPLAY_SIZE : bounds[1]; - return w > 0 && h > 0 ? new int[] {0, 0, w, h} : new int[0]; + 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); } 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 94c4d9e..4c227cb 100644 --- a/android/src/test/java/com/bugsee/flutter/FlutterWrapperTest.java +++ b/android/src/test/java/com/bugsee/flutter/FlutterWrapperTest.java @@ -104,10 +104,15 @@ public void lifecycleEventsGoToTheHubAndNeverThrow() { private static final class FixedEnvironment implements SecureRectangleStore.Environment { @Override - public double pixelsPerLogicalPixel(final int display) { + 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}; @@ -117,7 +122,8 @@ public int[] displayBounds(final int display) { 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 double[] {10, 20, 30, 40})); + store.publish(1, 1, 1, Collections.singletonMap(0, + new SecureRectangleStore.ViewRects(3, new double[] {10, 20, 30, 40}))); } /** @@ -170,7 +176,8 @@ public void refiningTheIdentityReplacesTheWrapperAndKeepsTheSet() { 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 double[] {10, 20, 31, 40})); + 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]); diff --git a/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java b/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java index 4b0acfb..6c39d8c 100644 --- a/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java +++ b/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java @@ -31,14 +31,19 @@ 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 scale = 3; + volatile double density = 3; volatile int[] bounds = {1080, 2400}; volatile int scaleReads; @Override - public double pixelsPerLogicalPixel(final int display) { + public double nativePixelRatio(final int display) { scaleReads++; - return scale; + return density; + } + + @Override + public double unitsPerPhysicalPixel(final int display) { + return 1; } @Nullable @@ -60,8 +65,14 @@ public void setUp() { sequence = 0; } - private static Map view(final double... ltrb) { - return Collections.singletonMap(0, ltrb); + /** 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. */ @@ -135,19 +146,62 @@ public void degenerateRectanglesAreDropped() { assertArrayEquals(new int[] {1, 3, 3, 6, 6}, withoutVersion(store.snapshot(0))); } - /** The scale is read at every pull (5.3.2): a density change is seen by the next one. */ + /** + * 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 theScaleIsReadPerCall() { + 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.scale = 2; + 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("a density change moves the version", at3[0], at2[0]); - env.scale = 2.625; + 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 >= 3); + 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 @@ -208,6 +262,16 @@ public void aWholeDisplayCoverUsesTheFallbackSizeWhenTheDisplayIsUnknown() { 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); @@ -230,7 +294,7 @@ public void aRectangleThatIsNotFiniteCoversTheWholeDisplay() { public void aScaleThatIsNotUsableCoversTheWholeDisplay() { publish(1, 1, 2, 2); for (final double bad : new double[] {Double.NaN, 0, -1, Double.POSITIVE_INFINITY}) { - env.scale = bad; + env.density = bad; assertArrayEquals("scale " + bad, new int[] {1, 0, 0, 1080, 2400}, withoutVersion(store.snapshot(0))); } @@ -246,7 +310,8 @@ public void aMalformedViewListCoversTheWholeDisplay() { @Test public void aViewOtherThanTheImplicitOneIsNotPlacedSoItCoversTheDisplay() { store.setPlacement(1, 0, Placement.known(0, 0, 0)); - store.publish(1, 1, 1, Collections.singletonMap(7, new double[] {1, 1, 2, 2})); + 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))); } @@ -295,7 +360,7 @@ public void everyKindOfRealChangeMovesTheVersion() { assertNotEquals("view moved", last, v); last = v; - env.scale = 2; // the density changed + env.density = 2; // the density changed (mismatch: the whole display) v = store.snapshot(0)[0]; assertNotEquals("scale", last, v); last = v; @@ -358,19 +423,30 @@ public void aDelayedOlderMessageNeverOverwritesANewerOne() { 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))); - assertFalse("older epoch", store.publish(1, 4, 99, 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 aNewIsolateWinsEvenThoughItsCounterRestarted() { + public void aNewIsolateWinsWhateverItsEpochIsAndTheOldOnesStragglersAreIgnored() { store.setPlacement(1, 0, Placement.known(0, 0, 0)); - assertTrue(store.publish(1, 5, 500, view(1, 1, 2, 2))); - assertTrue("a hot restart: larger epoch, sequence back at 1", - store.publish(1, 6, 1, view(2, 2, 3, 3))); + 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 @@ -410,7 +486,7 @@ public void droppingAnOwnerClearsItsRectanglesAndOrdering() { 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, mine)); + 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]); @@ -426,7 +502,7 @@ public void snapshotsAreCopiesNobodyCanWriteThrough() { */ @Test public void readersOnAnotherThreadSeeOnlyWholeSetsAndConsistentVersions() throws Exception { - env.scale = 1; + env.density = 1; store.setPlacement(1, 0, Placement.known(0, 0, 0)); final AtomicBoolean stop = new AtomicBoolean(); final AtomicReference failure = new AtomicReference<>(); @@ -477,7 +553,7 @@ public void readersOnAnotherThreadSeeOnlyWholeSetsAndConsistentVersions() throws set[4 * r + 2] = r + 100; set[4 * r + 3] = r + 100; } - store.publish(1, 1, i, Collections.singletonMap(0, set)); + store.publish(1, 1, i, viewAt(1, set)); } stop.set(true); a.join(); @@ -501,6 +577,112 @@ public void aPullCallsTheHookFirstAndSurvivesItThrowing() { assertEquals(1, calls[0]); } + + // ---- 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 continuousMovementCoversEveryRecentPlace() { + final SecureRectangleStore g = graced(); + put(g, 0, 0, 10, 10); + g.snapshot(0); + now[0] += 10; + put(g, 5, 0, 15, 10); + g.snapshot(0); + now[0] += 40; + put(g, 10, 0, 20, 10); + final int[] s = g.snapshot(0); + assertEquals("current + both earlier places", 3, s[1]); + now[0] += SecureRectangleStore.GRACE_MS - 1; + assertEquals("all recent places stay until a window after the LAST change", 3, + g.snapshot(0)[1]); + now[0] += 1; + assertEquals(1, g.snapshot(0)[1]); + } + + @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 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]; 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 d94091d..7358162 100644 --- a/ios/bugsee_flutter/Sources/bugsee_flutter/BugseeFlutterPlugin.swift +++ b/ios/bugsee_flutter/Sources/bugsee_flutter/BugseeFlutterPlugin.swift @@ -124,9 +124,11 @@ public class BugseeFlutterPlugin: NSObject, FlutterPlugin, BugseeBridgeHostApi { } func setSecureRectangles(state: SecureRectanglesMessage) throws -> Bool { - var views: [NSNumber: Data] = [:] + var views: [NSNumber: BGSFlutterSecureViewRects] = [:] for view in state.views { - views[NSNumber(value: view.viewId)] = view.rectangles.withUnsafeBufferPointer { Data(buffer: $0) } + 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) diff --git a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecurePlacementTracker.m b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecurePlacementTracker.m index 96dfa75..56d2f47 100644 --- a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecurePlacementTracker.m +++ b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecurePlacementTracker.m @@ -42,9 +42,17 @@ - (UIScreen *)currentScreen { #pragma clang diagnostic pop } -- (double)pointsPerLogicalPixelForDisplay:(NSInteger)display { - // A Flutter logical pixel is a UIKit point (see the header). Not `UIScreen.scale`. - return 1.0; +- (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 { @@ -55,6 +63,25 @@ - (CGSize)displayBoundsInPointsForDisplay:(NSInteger)display { #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; @@ -106,9 +133,23 @@ - (void)refresh { return; } UIScreen *screen = window.screen; - CGPoint origin = [view convertPoint:CGPointZero toCoordinateSpace:screen.coordinateSpace]; + 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]; - [self place:[BGSFlutterSecurePlacement knownOnDisplay:kDefaultDisplay x:origin.x y:origin.y]]; + 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]]; } diff --git a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecureRectangleStore.m b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecureRectangleStore.m index 5aa19a5..0faaa9a 100644 --- a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecureRectangleStore.m +++ b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecureRectangleStore.m @@ -1,5 +1,7 @@ #import "BGSFlutterSecureRectangleStore.h" +#import + #include /// Version meaning "unknown" (`BGSSecureRectanglesVersionUnknown` in the SDK's BGSContracts.h, @@ -10,35 +12,70 @@ 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 the previous set is served along with a changed one. Repeated in Java (GRACE_MS). +static const NSTimeInterval kGraceSeconds = 0.12; +/// More rectangles than this and the previous set is collapsed to its 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 { + 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 alloc] initWithKind:BGSFlutterPlacementKnown display:display x:x y: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]; + 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]; + 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; + 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; @@ -50,9 +87,12 @@ - (NSUInteger)hash { @interface BGSFlutterUnknownEnvironment : NSObject @end @implementation BGSFlutterUnknownEnvironment -- (double)pointsPerLogicalPixelForDisplay:(NSInteger)display { +- (double)nativePixelRatioForDisplay:(NSInteger)display { return NAN; } +- (double)unitsPerPhysicalPixelForDisplay:(NSInteger)display { + return 1; +} - (CGSize)displayBoundsInPointsForDisplay:(NSInteger)display { return CGSizeZero; } @@ -64,14 +104,17 @@ - (CGSize)displayBoundsInPointsForDisplay:(NSInteger)display { @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 rects:(NSData *)rects { +- (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; @@ -88,8 +131,8 @@ @interface BGSFlutterSecureState : NSObject /// Sorted by (owner, viewId). @property(nonatomic, readonly) NSArray *lanes; @property(nonatomic, readonly) NSDictionary *placements; -/// owner -> @[epoch, sequence] of the newest applied publish. -@property(nonatomic, readonly) NSDictionary *> *order; +/// 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 { @@ -105,14 +148,25 @@ - (instancetype)initWithLanes:(NSArray *)lanes placements:(NSDictionary *)placem /// What a display last served. @interface BGSFlutterServed : NSObject @property(nonatomic, readonly) int32_t version; +/// What is served: the current set plus, within the grace window, the previous one. @property(nonatomic, readonly, copy) NSData *quads; +@property(nonatomic, readonly, copy) NSData *current; +@property(nonatomic, readonly, copy) NSData *previous; +@property(nonatomic, readonly) NSTimeInterval changedAt; @property(nonatomic, readonly, copy) NSData *buffer; @end @implementation BGSFlutterServed -- (instancetype)initWithVersion:(int32_t)version quads:(NSData *)quads { +- (instancetype)initWithVersion:(int32_t)version + quads:(NSData *)quads + current:(NSData *)current + previous:(NSData *)previous + changedAt:(NSTimeInterval)changedAt { if ((self = [super init])) { _version = version; _quads = [quads copy]; + _current = [current copy]; + _previous = [previous copy]; + _changedAt = changedAt; 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)]; @@ -127,6 +181,8 @@ - (instancetype)initWithVersion:(int32_t)version quads:(NSData *)quads { @implementation BGSFlutterSecureRectangleStore { id _environment; + NSTimeInterval (^_clock)(void); + NSTimeInterval _grace; BGSFlutterSecureState *_state; NSMutableDictionary *_served; void (^_pullHook)(void); @@ -134,11 +190,20 @@ @implementation BGSFlutterSecureRectangleStore { 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; } @@ -148,8 +213,20 @@ - (instancetype)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])) { _environment = environment; + _clock = [clock copy]; + _grace = grace; _state = [[BGSFlutterSecureState alloc] initWithLanes:@[] placements:@{} order:@{}]; _served = [NSMutableDictionary dictionary]; _writeLock = [[NSObject alloc] init]; @@ -179,26 +256,39 @@ - (BGSFlutterSecureState *)currentState { - (BOOL)publishForOwner:(int64_t)owner epoch:(int64_t)epoch sequence:(int64_t)sequence - views:(NSDictionary *)views { + views:(NSDictionary *)views { @synchronized(_writeLock) { BGSFlutterSecureState *previous = _state; - NSArray *last = previous.order[@(owner)]; + NSArray *last = previous.order[@(owner)]; + NSArray *retired = @[]; if (last != nil) { - int64_t lastEpoch = last[0].longLongValue; - int64_t lastSequence = last[1].longLongValue; - if (epoch < lastEpoch || (epoch == lastEpoch && sequence <= lastSequence)) { - return NO; + 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, NSData *rects, BOOL *stop) { - if (rects.length > 0) { + [views enumerateKeysAndObjectsUsingBlock:^(NSNumber *viewId, BGSFlutterSecureViewRects *view, BOOL *stop) { + if (view.rects.length > 0) { [lanes addObject:[[BGSFlutterLane alloc] initWithOwner:owner viewId:viewId.longLongValue - rects:rects]]; + ratio:view.ratio + rects:view.rects]]; } }]; [lanes sortUsingComparator:^NSComparisonResult(BGSFlutterLane *a, BGSFlutterLane *b) { @@ -207,7 +297,7 @@ - (BOOL)publishForOwner:(int64_t)owner return NSOrderedSame; }]; NSMutableDictionary *order = [previous.order mutableCopy]; - order[@(owner)] = @[ @(epoch), @(sequence) ]; + order[@(owner)] = @[ @(epoch), @(sequence), retired ]; _state = [[BGSFlutterSecureState alloc] initWithLanes:lanes placements:previous.placements order:order]; @@ -276,13 +366,17 @@ static int32_t BGSToInt(double v) { return (int32_t)fmax(-kLimit, fmin(kLimit, v)); } -/// The point rectangles `display` serves for `state`, as packed int32 quads. +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 scale = NAN; - BOOL scaleRead = NO; + double nativeRatio = NAN; + double unit = 1; CGSize bounds = CGSizeZero; - BOOL boundsRead = NO; + BOOL envRead = NO; NSMutableData *out = [NSMutableData data]; BOOL whole = NO; @@ -295,16 +389,20 @@ static int32_t BGSToInt(double v) { continue; } if (placement.display != display) continue; - if (!scaleRead) { - scale = [env pointsPerLogicalPixelForDisplay:display]; - scaleRead = YES; - } - if (!boundsRead) { + if (!envRead) { + nativeRatio = [env nativePixelRatioForDisplay:display]; + unit = [env unitsPerPhysicalPixelForDisplay:display]; bounds = [env displayBoundsInPointsForDisplay:display]; - boundsRead = YES; + 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 (!(scale > 0) || isinf(scale) || lane.rects.length % (4 * sizeof(double)) != 0) { + 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; } @@ -317,10 +415,10 @@ static int32_t BGSToInt(double v) { } 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 * scale + placement.x)); - int32_t it = BGSToInt(floor(t * scale + placement.y)); - int32_t ir = BGSToInt(ceil(rt * scale + placement.x)); - int32_t ib = BGSToInt(ceil(b * scale + placement.y)); + 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); @@ -336,7 +434,7 @@ static int32_t BGSToInt(double v) { if (whole) { // Something could not be placed or measured: cover everything. - if (!boundsRead) bounds = [env displayBoundsInPointsForDisplay:display]; + 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}; @@ -345,19 +443,80 @@ static int32_t BGSToInt(double v) { 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; +} + +- (BOOL)inGrace:(BGSFlutterServed *)served at:(NSTimeInterval)now { + return now - served.changedAt < _grace; +} + - (NSData *)snapshotForDisplay:(NSInteger)display { @synchronized(_serveLock) { - NSData *quads = BGSServe([self currentState], display, _environment); - BGSFlutterServed *previous = _served[@(display)]; - if (previous == nil && quads.length == 0) { - int32_t empty[2] = {kUnknownVersion, 0}; - return [NSData dataWithBytes:empty length:sizeof(empty)]; + 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 + previous:[NSData data] + changedAt:now]; + _served[@(display)] = first; + return first.buffer; } - if (previous != nil && [previous.quads isEqualToData:quads]) { - return previous.buffer; + NSData *previous; + NSTimeInterval changedAt = last.changedAt; + if (![target isEqualToData:last.current]) { + // Something moved: keep covering where it was, and where it was before that if that is recent. + previous = _grace <= 0 ? [NSData data] + : BGSUnion(last.current, [self inGrace:last at:now] ? last.previous : [NSData data]); + changedAt = now; + } else { + previous = last.previous.length > 0 && ![self inGrace:last at:now] ? [NSData data] : last.previous; } - int32_t version = previous == nil ? 1 : [BGSFlutterSecureRectangleStore versionAfter:previous.version]; - BGSFlutterServed *next = [[BGSFlutterServed alloc] initWithVersion:version quads:quads]; + NSData *out = previous.length == 0 ? target : BGSUnion(target, previous); + int32_t version = [out isEqualToData:last.quads] + ? last.version + : [BGSFlutterSecureRectangleStore versionAfter:last.version]; + BGSFlutterServed *next = [[BGSFlutterServed alloc] initWithVersion:version + quads:out + current:target + previous:previous + changedAt:changedAt]; _served[@(display)] = next; return next.buffer; } 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 9b92631..5c276a3 100644 --- a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/include/BGSFlutterSecureRectangleStore.h +++ b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/include/BGSFlutterSecureRectangleStore.h @@ -5,10 +5,15 @@ NS_ASSUME_NONNULL_BEGIN /// What the store reads off the device, at every pull. @protocol BGSFlutterSecureEnvironment -/// UIKit points per Flutter logical pixel on `display`. 1 on iOS (a Flutter logical pixel IS a -/// point); the store applies whatever it is told, at every pull, never cached. Not finite or not -/// positive means "unknown" and fails closed. -- (double)pointsPerLogicalPixelForDisplay:(NSInteger)display; +/// 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 @@ -22,13 +27,29 @@ typedef NS_ENUM(NSInteger, BGSFlutterPlacementKind) { 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 @@ -48,9 +69,18 @@ typedef NS_ENUM(NSInteger, BGSFlutterPlacementKind) { /// 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. This class multiplies -/// by `pointsPerLogicalPixel` (asked at every pull), adds the view's position on the -/// screen, then rounds outward (scale BEFORE rounding, 5.3.1). +/// 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: when the served set changes, the PREVIOUS set is served as well for +/// `+graceSeconds`, 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. @@ -68,8 +98,16 @@ typedef NS_ENUM(NSInteger, BGSFlutterPlacementKind) { /// 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 the previous set is served +/// along with a changed one (0 for none). - (instancetype)initWithEnvironment:(id)environment - NS_DESIGNATED_INITIALIZER; + clock:(NSTimeInterval (^)(void))clock + grace:(NSTimeInterval)grace NS_DESIGNATED_INITIALIZER; + +/// How long the previous set is served along with a changed one. Repeated in Java (`GRACE_MS`). ++ (NSTimeInterval)graceSeconds; - (void)setEnvironment:(id)environment; @@ -77,15 +115,15 @@ typedef NS_ENUM(NSInteger, BGSFlutterPlacementKind) { /// It must not block. - (void)setPullHook:(nullable void (^)(void))hook; -/// Applies Dart's whole state for `owner`: `views` maps a view id to its flat logical -/// `[l, t, r, b, ...]` as packed native-endian doubles; a view not in it has no -/// rectangles any more. Ignored (returns NO) unless `(epoch, sequence)` is newer than the -/// newest applied for `owner`, so a delayed older message never overwrites a newer one, -/// while a new isolate (larger epoch, counter back at 0) still wins. +/// 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; + views:(NSDictionary *)views; /// Where `owner`'s view `viewId` sits now. Not a change when it is the same. - (void)setPlacement:(BGSFlutterSecurePlacement *)placement diff --git a/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterSecurePlacementTrackerTests.m b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterSecurePlacementTrackerTests.m index e0ada25..f30d1dd 100644 --- a/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterSecurePlacementTrackerTests.m +++ b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterSecurePlacementTrackerTests.m @@ -48,7 +48,7 @@ - (void)tearDown { - (void)publishRect { double v[] = {10, 10, 20, 20}; - [_store publishForOwner:1 epoch:1 sequence:1 views:@{@0 : [NSData dataWithBytes:v length:sizeof(v)]}]; + [_store publishForOwner:1 epoch:1 sequence:1 views:@{@0 : [[BGSFlutterSecureViewRects alloc] initWithRatio:[UIScreen mainScreen].scale rects:[NSData dataWithBytes:v length:sizeof(v)]]}]; } - (void)testTheViewsOffsetFromTheScreenOriginIsAdded { @@ -78,6 +78,37 @@ - (void)testMovingTheViewMovesTheRectanglesAtTheNextPull { 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]; diff --git a/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterSecureRectangleStoreTests.m b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterSecureRectangleStoreTests.m index abde7c1..a25d901 100644 --- a/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterSecureRectangleStoreTests.m +++ b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterSecureRectangleStoreTests.m @@ -8,6 +8,10 @@ /// 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; @@ -16,14 +20,24 @@ @interface BGSMockScreen : NSObject @implementation BGSMockScreen - (instancetype)init { if ((self = [super init])) { - _factor = 1; // a logical pixel is a point + _nativeRatio = 3; // a 3x phone + _units = 1.0 / 3; // points per physical pixel _bounds = CGSizeMake(402, 874); // iPhone 17 Pro, points } return self; } -- (double)pointsPerLogicalPixelForDisplay:(NSInteger)display { +- (double)factor { + return _nativeRatio * _units; +} +- (void)setFactor:(double)factor { + _units = factor / _nativeRatio; +} +- (double)nativePixelRatioForDisplay:(NSInteger)display { _scaleReads++; - return _factor; + return _nativeRatio; +} +- (double)unitsPerPhysicalPixelForDisplay:(NSInteger)display { + return _units; } - (CGSize)displayBoundsInPointsForDisplay:(NSInteger)display { return _bounds; @@ -51,9 +65,17 @@ - (void)setUp { #define VIEW(...) \ ({ \ double v_[] = {__VA_ARGS__}; \ - @{@0 : Doubles(v_, sizeof(v_) / sizeof(double))}; \ + @{@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; @@ -71,7 +93,7 @@ static int32_t VersionOf(NSData *buffer) { return ((const int32_t *)buffer.bytes)[0]; } -- (BOOL)publishViews:(NSDictionary *)views { +- (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]; } @@ -98,13 +120,15 @@ - (void)testAfterARectangleVersionsAreRealAndAnEmptiedSetIsStillCounted { #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 the factor is 1, NOT the screen's scale. +/// 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 pointsPerLogicalPixelForDisplay:0], 1.0); + 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)); } @@ -175,7 +199,7 @@ - (void)testASetWithNoScaleChangeKeepsItsVersionAcrossPulls { - (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 : Doubles(v, 4)}]; + [_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 ])); } @@ -199,7 +223,7 @@ - (void)testRectanglesAreClampedToTheScreenAndOnesOffItAreDropped { - (void)testAViewWhosePlacementIsUnknownCoversTheWholeScreen { double v[] = {10, 10, 20, 20}; - [_store publishForOwner:1 epoch:1 sequence:1 views:@{@0 : Doubles(v, 4)}]; // no placement + [_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 ])); @@ -208,14 +232,14 @@ - (void)testAViewWhosePlacementIsUnknownCoversTheWholeScreen { - (void)testAWholeScreenCoverUsesTheFallbackSizeWhenTheScreenIsUnknown { _screen.bounds = CGSizeZero; double v[] = {10, 10, 20, 20}; - [_store publishForOwner:1 epoch:1 sequence:1 views:@{@0 : Doubles(v, 4)}]; + [_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 : Doubles(v, 4)}]; + [_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); @@ -245,7 +269,7 @@ - (void)testAMalformedViewListCoversTheWholeScreen { - (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 : Doubles(v, 4)}]; + [_store publishForOwner:1 epoch:1 sequence:1 views:@{@7 : [self viewOf:Doubles(v, 4)]}]; XCTAssertEqualObjects(WithoutVersion([_store snapshotForDisplay:0]), (@[ @1, @0, @0, @402, @874 ])); } @@ -309,14 +333,14 @@ - (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 : Doubles(a, 4)}]; - [_store publishForOwner:2 epoch:1 sequence:1 views:@{@0 : Doubles(b, 4)}]; + [_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 : Doubles(b2, 4)}]; + [_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); @@ -327,29 +351,173 @@ - (void)testVersionsArePerDisplay { - (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 : Doubles(a, 4)}]); - XCTAssertFalse([_store publishForOwner:1 epoch:5 sequence:9 views:@{@0 : Doubles(old, 4)}], @"older sequence"); - XCTAssertFalse([_store publishForOwner:1 epoch:5 sequence:10 views:@{@0 : Doubles(old, 4)}], @"same sequence"); - XCTAssertFalse([_store publishForOwner:1 epoch:4 sequence:99 views:@{@0 : Doubles(old, 4)}], @"older epoch"); + 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 : Doubles(b, 4)}]); + 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 ])); } -- (void)testANewIsolateWinsEvenThoughItsCounterRestarted { +/// 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}; - XCTAssertTrue([_store publishForOwner:1 epoch:5 sequence:500 views:@{@0 : Doubles(a, 4)}]); - XCTAssertTrue([_store publishForOwner:1 epoch:6 sequence:1 views:@{@0 : Doubles(b, 4)}]); + 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)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"); + now += [BGSFlutterSecureRectangleStore graceSeconds] - 0.001; + XCTAssertEqual(((const int32_t *)[g snapshotForDisplay:0].bytes)[1], 3); + now += 0.001; + XCTAssertEqual(((const int32_t *)[g snapshotForDisplay:0].bytes)[1], 1); + + 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:4 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:5 views:@{@0 : [self viewOf:Doubles(far, 4)]}]; + XCTAssertEqualObjects(WithoutVersion([g snapshotForDisplay:0]), + (@[ @2, @300, @300, @310, @310, @0, @0, @70, @10 ]), @"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 : Doubles(a, 4)}]); - XCTAssertTrue([_store publishForOwner:2 epoch:1 sequence:1 views:@{@0 : Doubles(b, 4)}]); + 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 ])); @@ -358,11 +526,11 @@ - (void)testTheOrderingIsPerOwnerAndAPublishReplacesOnlyItsOwnersViews { - (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 : Doubles(a, 4)}]; + [_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 : Doubles(a, 4)}]); + 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); } @@ -421,7 +589,7 @@ - (void)testReadersOnAnotherQueueSeeOnlyWholeSetsAndConsistentVersions { set[4 * r + 2] = r + 100; set[4 * r + 3] = r + 100; } - [_store publishForOwner:1 epoch:1 sequence:i views:@{@0 : Doubles(set, 4 * k)}]; + [_store publishForOwner:1 epoch:1 sequence:i views:@{@0 : [self viewOf:Doubles(set, 4 * k)]}]; } stop = YES; dispatch_group_wait(group, DISPATCH_TIME_FOREVER); diff --git a/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterWrapperTests.m b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterWrapperTests.m index c31763d..8fd7328 100644 --- a/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterWrapperTests.m +++ b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterWrapperTests.m @@ -10,8 +10,11 @@ @interface BGSWrapperTestScreen : NSObject @end @implementation BGSWrapperTestScreen -- (double)pointsPerLogicalPixelForDisplay:(NSInteger)display { - return 1.0; +- (double)nativePixelRatioForDisplay:(NSInteger)display { + return 3.0; +} +- (double)unitsPerPhysicalPixelForDisplay:(NSInteger)display { + return 1.0 / 3.0; } - (CGSize)displayBoundsInPointsForDisplay:(NSInteger)display { return CGSizeMake(402, 874); @@ -22,7 +25,7 @@ static void PublishOnDisplay(BGSFlutterSecureRectangleStore *store, NSInteger di [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 : [NSData dataWithBytes:rect length:sizeof(rect)]}]; + [store publishForOwner:1 epoch:1 sequence:1 views:@{@0 : [[BGSFlutterSecureViewRects alloc] initWithRatio:3 rects:[NSData dataWithBytes:rect length:sizeof(rect)]]}]; } static NSData *Buffer(int32_t version, int32_t l, int32_t t, int32_t r, int32_t b) { @@ -130,8 +133,12 @@ - (void)testReplacingTheWrapperKeepsTheSecureRectangleSet { [[BGSFlutterSecureRectangleStore shared] publishForOwner:1 epoch:1 sequence:2 - views:@{@0 : [NSData dataWithBytes:moved length:sizeof(moved)]}]; - XCTAssertEqualObjects([(id)refined secureRectanglesForDisplay:0], Buffer(2, 10, 20, 31, 40)); + 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/test/secure_rects_drift_test.dart b/test/secure_rects_drift_test.dart index 4226068..bcb6d73 100644 --- a/test/secure_rects_drift_test.dart +++ b/test/secure_rects_drift_test.dart @@ -78,24 +78,43 @@ void main() { expect(mStore, contains('next == kUnknownVersion ? next + 1 : next')); }); - test('both stores order messages by (epoch, sequence) the same way', () { + 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( - store, - contains('epoch < last[0] || (epoch == last[0] && sequence <= last[1])'), + 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( - mStore, - contains( - 'epoch < lastEpoch || (epoch == lastEpoch && sequence <= lastSequence)', - ), + 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('iOS converts logical pixels to points with a factor of 1, not the ' - 'screen scale (Android multiplies: physical pixels)', () { - expect(mTracker, contains('return 1.0;')); - expect(mTracker, isNot(contains('.scale'))); + 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;'))); }); } From 0c951c15885af2ebceeb5bb031a1539d833f19b6 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 06:13:53 +0500 Subject: [PATCH 08/16] Tests: do not depend on APIs that differ between the floor and stable Flutter Co-Authored-By: Claude Sonnet 5.5 Change-Id: Idf856c8db4f5c6fc426dec31f926769c406ec4cc --- test/secure_region_test.dart | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/test/secure_region_test.dart b/test/secure_region_test.dart index 79f7184..e1e0473 100644 --- a/test/secure_region_test.dart +++ b/test/secure_region_test.dart @@ -7,6 +7,9 @@ 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'; @@ -432,7 +435,7 @@ void main() { group('page transitions keep the region covered (pixels are on screen)', () { Future run( WidgetTester tester, - PageTransitionsBuilder builder, { + PageTransitionsBuilder? builder, { bool hero = false, }) async { final navigator = GlobalKey(); @@ -453,11 +456,15 @@ void main() { await tester.pumpWidget( MaterialApp( navigatorKey: navigator, - theme: ThemeData( - pageTransitionsTheme: PageTransitionsTheme( - builders: {for (final p in TargetPlatform.values) p: builder}, - ), - ), + 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'), ), ); @@ -492,8 +499,8 @@ void main() { testWidgets('FadeForwards', (tester) async { await run(tester, const FadeForwardsPageTransitionsBuilder()); }); - testWidgets('Cupertino', (tester) async { - await run(tester, const CupertinoPageTransitionsBuilder()); + testWidgets('the iOS default (Cupertino)', (tester) async { + await run(tester, null); }); testWidgets('Hero in flight', (tester) async { await run(tester, const FadeUpwardsPageTransitionsBuilder(), hero: true); From d8e4b4e3a80e14d930aaf286c2f6ccd9777266f8 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 06:26:00 +0500 Subject: [PATCH 09/16] Review round 1: TickerMode is not evidence of invisibility; viewport test uses painted overflow; one pass of transforms; grace counted per past set; one Android environment Co-Authored-By: Claude Sonnet 5.5 Change-Id: Ie6239d392d199101d317f3a62b9afc1c1882f2eb --- .claude/CLAUDE.md | 2 +- PLAN.md | 7 +- .../bugsee/flutter/BugseeFlutterPlugin.java | 13 ++- .../bugsee/flutter/SecureRectangleStore.java | 59 ++++++------ .../flutter/SecureRectangleStoreTest.java | 61 ++++++++++--- .../BGSFlutterSecureRectangleStore.m | 58 ++++++------ .../BGSFlutterSecureRectangleStoreTests.m | 67 ++++++++++++-- lib/src/secure/secure_region.dart | 76 +++++++--------- test/secure_region_test.dart | 90 +++++++++++++++++-- 9 files changed, 313 insertions(+), 120 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4d0c696..ad799ec 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -23,7 +23,7 @@ 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 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 the previous set together with a changed one, 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 disabled TickerMode, a clipping viewport it lies wholly outside of). A detached layer is NOT such evidence (SnapshotWidget, the Zoom page transition). Every one of these rules has a test that fails when it is broken; keep it that way. +- 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 of). A disabled TickerMode is NOT evidence (it only stops animations). A detached layer is NOT such evidence (SnapshotWidget, the Zoom page transition). 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; iOS is verified in portrait only. ## Checks diff --git a/PLAN.md b/PLAN.md index 89a2088..6c451ea 100644 --- a/PLAN.md +++ b/PLAN.md @@ -54,15 +54,16 @@ Sources: `specs/sdk/wrapper-workbook` (authoritative order of work), `wrapper-da 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), adds the view's on-screen position and scale, a 0.12 s grace window that serves the previous set with a changed one, and the view's - on-screen position, then floors l/t and ceils r/b. Versions are derived from what is served (so a density + 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 only when the set changed, 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 - (paintsChild, IndexedStack, a disabled TickerMode, a clipping viewport) and never because its layer is detached; an unmeasurable one covers the whole view. + (paintsChild, IndexedStack, a clipping viewport); a disabled TickerMode is not evidence and never because its layer is detached; an unmeasurable one 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 diff --git a/android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java b/android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java index b59842d..eb87adb 100644 --- a/android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java +++ b/android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java @@ -39,6 +39,17 @@ public class BugseeFlutterPlugin MAIN::post, 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() { for (final SecurePlacement placement : PLACEMENTS) { placement.refresh(); @@ -57,7 +68,7 @@ public void onAttachedToEngine(@NonNull final FlutterPluginBinding binding) { Messages.BugseeBridgeHostApi.setUp(messenger, this); final SecureRectangleStore store = SecureRectangleStore.shared(); - final SecureEnvironment environment = new SecureEnvironment(binding.getApplicationContext()); + 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); diff --git a/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java b/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java index ffc55dd..a992630 100644 --- a/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java +++ b/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java @@ -295,28 +295,37 @@ private static final class State { } } + /** 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, within the grace window, the previous one. */ + /** What is served: the current set plus every past set still inside its window. */ final int[] quads; final int[] current; - final int[] previous; - final long changedAtMs; + /** 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 int[] previous, - final long changedAtMs + @NonNull final List past ) { this.version = version; this.quads = quads; this.current = current; - this.previous = previous; - this.changedAtMs = changedAtMs; + this.past = past; this.buffer = new int[HEADER_INTS + quads.length]; buffer[0] = version; buffer[1] = quads.length / 4; @@ -491,36 +500,34 @@ int[] snapshot(final int display) { if (target.length == 0) { return new int[] {UNKNOWN_VERSION, 0}; } - final Served first = new Served(1, target, target, NONE, now); + final Served first = new Served(1, target, target, new ArrayList<>()); served.put(display, first); return first.buffer.clone(); } - final int[] previous; - long changedAt = last.changedAtMs; - if (!Arrays.equals(target, last.current)) { - // Something moved: keep covering where it was, and where it was before that if - // that is still recent. - previous = graceMs <= 0 ? NONE - : union(last.current, inGrace(last, now) ? last.previous : NONE); - changedAt = now; - } else { - previous = last.previous.length > 0 && !inGrace(last, now) ? NONE : last.previous; + // 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[] out = previous.length == 0 ? target : union(target, previous); final int version = Arrays.equals(out, last.quads) ? last.version : advance(last.version); - final Served next = out == last.quads && previous == last.previous - && Arrays.equals(target, last.current) && changedAt == last.changedAtMs - ? last : new Served(version, out, target, previous, changedAt); + final Served next = new Served(version, out, target, past); served.put(display, next); return next.buffer.clone(); } } - private boolean inGrace(@NonNull final Served s, final long now) { - return now - s.changedAtMs < graceMs; - } - /** 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) { diff --git a/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java b/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java index 6c39d8c..ed27d1d 100644 --- a/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java +++ b/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java @@ -634,22 +634,37 @@ public void aRegionThatWentIsStillCoveredForTheWindow() { } @Test - public void continuousMovementCoversEveryRecentPlace() { + public void everyPastPlaceIsServedForAWindowFromWhenItStoppedBeingCurrent() { final SecureRectangleStore g = graced(); - put(g, 0, 0, 10, 10); + put(g, 0, 0, 10, 10); // A g.snapshot(0); now[0] += 10; - put(g, 5, 0, 15, 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); - final int[] s = g.snapshot(0); - assertEquals("current + both earlier places", 3, s[1]); - now[0] += SecureRectangleStore.GRACE_MS - 1; - assertEquals("all recent places stay until a window after the LAST change", 3, - g.snapshot(0)[1]); - now[0] += 1; - assertEquals(1, g.snapshot(0)[1]); + 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 @@ -671,6 +686,30 @@ public void tooManyPreviousRectanglesCollapseToTheirBoundingBox() { 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(); diff --git a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecureRectangleStore.m b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecureRectangleStore.m index 0faaa9a..f9deb45 100644 --- a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecureRectangleStore.m +++ b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecureRectangleStore.m @@ -145,28 +145,41 @@ - (instancetype)initWithLanes:(NSArray *)lanes placements:(NSDictionary *)placem } @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, within the grace window, the previous one. +/// 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; -@property(nonatomic, readonly, copy) NSData *previous; -@property(nonatomic, readonly) NSTimeInterval changedAt; +/// 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 - previous:(NSData *)previous - changedAt:(NSTimeInterval)changedAt { + past:(NSArray *)past { if ((self = [super init])) { _version = version; _quads = [quads copy]; _current = [current copy]; - _previous = [previous copy]; - _changedAt = changedAt; + _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)]; @@ -476,10 +489,6 @@ static BOOL BGSUsable(double v) { return out; } -- (BOOL)inGrace:(BGSFlutterServed *)served at:(NSTimeInterval)now { - return now - served.changedAt < _grace; -} - - (NSData *)snapshotForDisplay:(NSInteger)display { @synchronized(_serveLock) { NSTimeInterval now = _clock(); @@ -493,30 +502,29 @@ - (NSData *)snapshotForDisplay:(NSInteger)display { BGSFlutterServed *first = [[BGSFlutterServed alloc] initWithVersion:1 quads:target current:target - previous:[NSData data] - changedAt:now]; + past:@[]]; _served[@(display)] = first; return first.buffer; } - NSData *previous; - NSTimeInterval changedAt = last.changedAt; - if (![target isEqualToData:last.current]) { - // Something moved: keep covering where it was, and where it was before that if that is recent. - previous = _grace <= 0 ? [NSData data] - : BGSUnion(last.current, [self inGrace:last at:now] ? last.previous : [NSData data]); - changedAt = now; - } else { - previous = last.previous.length > 0 && ![self inGrace:last at:now] ? [NSData data] : last.previous; + // 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 = previous.length == 0 ? target : BGSUnion(target, previous); + 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 - previous:previous - changedAt:changedAt]; + past:past]; _served[@(display)] = next; return next.buffer; } diff --git a/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterSecureRectangleStoreTests.m b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterSecureRectangleStoreTests.m index a25d901..c41a195 100644 --- a/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterSecureRectangleStoreTests.m +++ b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterSecureRectangleStoreTests.m @@ -450,6 +450,39 @@ - (void)testAMovedRegionIsCoveredWhereItWasTooThenTheUnionShrinksAndMovesTheVers 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] @@ -490,10 +523,25 @@ - (void)testContinuousMovementCoversEveryRecentPlaceAndTooManyCollapse { 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"); - now += [BGSFlutterSecureRectangleStore graceSeconds] - 0.001; + // 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 += 0.001; - XCTAssertEqual(((const int32_t *)[g snapshotForDisplay:0].bytes)[1], 1); + 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++) { @@ -502,14 +550,21 @@ - (void)testContinuousMovementCoversEveryRecentPlaceAndTooManyCollapse { 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:4 views:@{@0 : [self viewOf:Doubles(many, 280)]}]; + [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:5 views:@{@0 : [self viewOf:Doubles(far, 4)]}]; + [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, @10 ]), @"one box over the 70 old ones"); + (@[ @2, @300, @300, @310, @310, @0, @0, @70, @5 ]), @"one box over the 70 old ones"); } - (void)testTheOrderingIsPerOwnerAndAPublishReplacesOnlyItsOwnersViews { diff --git a/lib/src/secure/secure_region.dart b/lib/src/secure/secure_region.dart index a12ec70..2f20a75 100644 --- a/lib/src/secure/secure_region.dart +++ b/lib/src/secure/secure_region.dart @@ -19,9 +19,10 @@ // * 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, -// a disabled TickerMode (an inactive route or overlay entry, which is how a -// covered page is hidden), or a clipping scroll viewport that its region lies -// wholly outside of. Nothing else drops it. In particular a detached layer is +// or a clipping scroll viewport that its region (with what its descendants +// paint) lies wholly outside of. 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 @@ -129,10 +130,11 @@ bool sameState(SecureState a, SecureState b) { const int _maxDescendants = 2000; /// Whether [box] is PROVABLY not on screen. Only positive evidence counts; see -/// the library comment. Never throws (an exception means "not provable"). -bool provablyHidden(RenderSecureRegion box) { +/// the library comment. [painted] is the box together with what its +/// descendants paint, in the view's logical pixels. Never throws (an exception +/// means "not provable"). +bool provablyHidden(RenderSecureRegion box, Rect painted) { try { - if (!box.tickerEnabled) return true; RenderObject child = box; RenderObject? parent = box.parent; while (parent != null) { @@ -147,14 +149,18 @@ bool provablyHidden(RenderSecureRegion box) { if (!identical(shown, child)) return true; } if (parent is RenderViewportBase && parent.clipBehavior != Clip.none) { - // A viewport clips what lies outside it: a region wholly outside is - // invisible (a child kept in the cache extent, scrolled out of view). - final Rect inViewport = MatrixUtils.transformRect( - box.getTransformTo(parent), - Offset.zero & box.size, - ); + // 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 (_rectFinite(inViewport) && !inViewport.overlaps(port)) return true; + if (Matrix4.tryInvert(toView) != null && + _rectFinite(inViewport) && + !inViewport.overlaps(port)) { + return true; + } } child = parent; parent = parent.parent; @@ -166,13 +172,15 @@ bool provablyHidden(RenderSecureRegion box) { } /// 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. +/// 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) { + 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 @@ -182,17 +190,16 @@ Rect? _paintedRect(RenderSecureRegion box) { tooMany = true; return; } + final Matrix4 childToView = nodeToView.clone(); + node.applyPaintTransform(child, childToView); rect = rect.expandToInclude( - MatrixUtils.transformRect( - child.getTransformTo(null), - child.paintBounds, - ), + MatrixUtils.transformRect(childToView, child.paintBounds), ); - visit(child); + visit(child, childToView); }); } - visit(box); + visit(box, toView); return tooMany ? null : rect; } @@ -201,7 +208,7 @@ Rect? _paintedRect(RenderSecureRegion box) { MeasuredRegion? measureRegion(RenderSecureRegion box) { RenderView? view; try { - if (!box.attached || !box.hasSize || provablyHidden(box)) return null; + if (!box.attached || !box.hasSize) return null; // The root of this box's tree is its view. RenderObject root = box; while (root.parent != null) { @@ -215,6 +222,9 @@ MeasuredRegion? measureRegion(RenderSecureRegion box) { 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, @@ -313,9 +323,6 @@ class SecureRegionRegistry { if (_regions.remove(region)) _schedule(); } - /// Something a measurement depends on changed: measure this frame. - void touch() => _schedule(); - void _schedule() { final binding = SchedulerBinding.instance; if (!_scheduled) { @@ -436,24 +443,11 @@ class RenderSecureRegion extends RenderProxyBox { RenderSecureRegion({ required bool enabled, required SecureRegionRegistry registry, - bool tickerEnabled = true, }) : _enabled = enabled, - _registry = registry, - _tickerEnabled = tickerEnabled; + _registry = registry; bool _enabled; SecureRegionRegistry _registry; - bool _tickerEnabled; - - /// `false` when an enclosing [TickerMode] is disabled: Flutter's way of - /// saying a route or overlay entry is inactive (covered, offstage). Positive - /// evidence that the region is not on screen. - bool get tickerEnabled => _tickerEnabled; - set tickerEnabled(bool value) { - if (value == _tickerEnabled) return; - _tickerEnabled = value; - if (attached && _enabled) _registry.touch(); - } bool get enabled => _enabled; set enabled(bool value) { @@ -514,7 +508,6 @@ class BugseeSecureRegion extends SingleChildRenderObjectWidget { RenderSecureRegion( enabled: enabled, registry: registry ?? SecureRegionRegistry.instance, - tickerEnabled: TickerMode.valuesOf(context).enabled, ); @override @@ -524,7 +517,6 @@ class BugseeSecureRegion extends SingleChildRenderObjectWidget { ) { renderObject ..registry = registry ?? SecureRegionRegistry.instance - ..enabled = enabled - ..tickerEnabled = TickerMode.valuesOf(context).enabled; + ..enabled = enabled; } } diff --git a/test/secure_region_test.dart b/test/secure_region_test.dart index e1e0473..e38d902 100644 --- a/test/secure_region_test.dart +++ b/test/secure_region_test.dart @@ -526,18 +526,62 @@ void main() { expect(registry.measure()[0]!.rects, hasLength(1)); }); - testWidgets('a disabled TickerMode (an inactive route or overlay entry) ' - 'hides it; enabling brings it back', (tester) async { + 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))); - expect(native.latest[0], [5, 5, 105, 55]); await tester.pumpWidget(app(registry, tree(false))); - expect(native.sent.last.views, isEmpty); - await tester.pumpWidget(app(registry, tree(true))); 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 { @@ -617,6 +661,42 @@ void main() { 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('paint outside the region box is covered too', (tester) async { await tester.pumpWidget( app( From 34b1ef46a4ed6e59fc82a211344be68509024f26 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 06:34:06 +0500 Subject: [PATCH 10/16] Review round 2: ancestor evidence of invisibility is checked before any geometry Co-Authored-By: Claude Sonnet 5.5 Change-Id: I590b08b64c1c205480a10c24d4d4e1621ddeee7c --- lib/src/secure/secure_region.dart | 12 +++++++++--- test/secure_region_test.dart | 31 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/lib/src/secure/secure_region.dart b/lib/src/secure/secure_region.dart index 2f20a75..51313d5 100644 --- a/lib/src/secure/secure_region.dart +++ b/lib/src/secure/secure_region.dart @@ -131,9 +131,10 @@ 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. Never throws (an exception +/// 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) { +bool provablyHidden(RenderSecureRegion box, Rect? painted) { try { RenderObject child = box; RenderObject? parent = box.parent; @@ -148,7 +149,9 @@ bool provablyHidden(RenderSecureRegion box, Rect painted) { } if (!identical(shown, child)) return true; } - if (parent is RenderViewportBase && parent.clipBehavior != Clip.none) { + 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). @@ -209,6 +212,9 @@ 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) { diff --git a/test/secure_region_test.dart b/test/secure_region_test.dart index e38d902..ea26f59 100644 --- a/test/secure_region_test.dart +++ b/test/secure_region_test.dart @@ -697,6 +697,37 @@ void main() { ]); }); + 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( From f45d6907a4428f078e5eef433c96caeb3bfba632 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 06:44:06 +0500 Subject: [PATCH 11/16] Review round 3: stop resending a state native keeps failing on; clearer plan wording Co-Authored-By: Claude Sonnet 5.5 Change-Id: Ifa9569f68b53d727c4bb819e00c4ddee44a69b76 --- PLAN.md | 2 +- lib/src/secure/secure_region.dart | 13 +++++++++++++ test/secure_region_test.dart | 21 +++++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/PLAN.md b/PLAN.md index 6c451ea..d4f0c3f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -63,7 +63,7 @@ Sources: `specs/sdk/wrapper-workbook` (authoritative order of work), `wrapper-da Phase 4): `BugseeSecureRegion`/`RenderSecureRegion` + `SecureRegionRegistry`, measured every frame in a post-frame callback, published only when the set changed, 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 - (paintsChild, IndexedStack, a clipping viewport); a disabled TickerMode is not evidence and never because its layer is detached; an unmeasurable one covers the whole view. + (paintsChild, IndexedStack, 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 diff --git a/lib/src/secure/secure_region.dart b/lib/src/secure/secure_region.dart index 51313d5..da746b7 100644 --- a/lib/src/secure/secure_region.dart +++ b/lib/src/secure/secure_region.dart @@ -308,6 +308,7 @@ class SecureRegionRegistry { int _sequence = 0; int _failures = 0; Timer? _retry; + 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. @@ -358,6 +359,16 @@ class SecureRegionRegistry { final SecureState state = measure(); final SecureState? published = _published; if (published != null && sameState(published, state)) return; + // Native keeps failing for this very state (no plugin on this platform, a broken + // channel): stop resending it every frame once the retries are used up. A changed + // state is still sent, and a success resets the count. + final SecureState? failed = _failedState; + if (published == null && + _failures > maxRetries && + failed != null && + sameState(failed, state)) { + return; + } _send(state); } @@ -413,6 +424,7 @@ class SecureRegionRegistry { sent.then( (_) { _failures = 0; + _failedState = null; }, onError: (Object error, StackTrace stack) { // Only the newest attempt decides: an older one failing after a newer @@ -422,6 +434,7 @@ class SecureRegionRegistry { // (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; diff --git a/test/secure_region_test.dart b/test/secure_region_test.dart index ea26f59..2c2499f 100644 --- a/test/secure_region_test.dart +++ b/test/secure_region_test.dart @@ -1009,6 +1009,27 @@ void main() { 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'); + }); + testWidgets('a failed clear is sent again, even though it is empty', ( tester, ) async { From a2db41df4419f3a708b82371a3f14ee0c5c4086f Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 06:46:41 +0500 Subject: [PATCH 12/16] Review round 4: drop a test that could not fail; comments follow the per-set grace window; document two over-covering limits Co-Authored-By: Claude Sonnet 5.5 Change-Id: Icfe12e7a884b3e61bc272268d96380736bc9a4f0 --- .claude/CLAUDE.md | 2 +- .../com/bugsee/flutter/SecureRectangleStore.java | 14 ++++++++------ .../include/BGSFlutterSecureRectangleStore.h | 5 +++-- test/secure_region_test.dart | 15 --------------- 4 files changed, 12 insertions(+), 24 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index ad799ec..22a2a8a 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -24,7 +24,7 @@ progress; `PLAN.md` is the source of intent. The authoritative wrapper spec is - 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 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 of). A disabled TickerMode is NOT evidence (it only stops animations). A detached layer is NOT such evidence (SnapshotWidget, the Zoom page transition). 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; iOS is verified in portrait only. +- 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/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java b/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java index a992630..4506ec3 100644 --- a/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java +++ b/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java @@ -48,8 +48,8 @@ * *

    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 the - * grace window changed, which no write announces), and never otherwise (5.2.1, + * 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 @@ -58,10 +58,12 @@ *

    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 when the - * served set changes, the PREVIOUS set is served as well for - * {@link #GRACE_MS}: a moving region leaves no uncovered strip as wide as one - * frame's movement, and a region that has just gone is covered one pull longer. + * 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 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 5c276a3..903cf77 100644 --- a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/include/BGSFlutterSecureRectangleStore.h +++ b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/include/BGSFlutterSecureRectangleStore.h @@ -76,8 +76,9 @@ typedef NS_ENUM(NSInteger, BGSFlutterPlacementKind) { /// ratio: a mismatch (Flutter has not caught up with a display change) covers the whole display /// until Dart republishes. /// -/// Grace window: when the served set changes, the PREVIOUS set is served as well for -/// `+graceSeconds`, because Dart measures after a frame and the message crosses a channel while +/// 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. diff --git a/test/secure_region_test.dart b/test/secure_region_test.dart index 2c2499f..9337110 100644 --- a/test/secure_region_test.dart +++ b/test/secure_region_test.dart @@ -893,21 +893,6 @@ void main() { expect(native.latest[0], [500, 850, 600, 900]); }); - testWidgets('a pixel-ratio change with no relayout republishes nothing', ( - tester, - ) async { - tester.view.physicalSize = const Size(900, 600); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.reset); - await tester.pumpWidget(app(registry, at(registry, 10, 20))); - expect(native.sent, hasLength(1)); - // Logical rects are unchanged by the ratio; native rescales them with its - // own, per-call, scale. - tester.view.devicePixelRatio = 1; - await tester.pump(); - expect(native.sent, hasLength(1)); - }); - testWidgets('regions in two views are published per view', (tester) async { final second = OtherView(tester); await tester.pumpWidget( From 43eafe77f1e83b696d67421dd86adf4ff9a6cfe7 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 20:43:37 +0500 Subject: [PATCH 13/16] Secure rectangles: a failed publish is retried with a capped backoff and never abandoned; comments follow the per-set grace window The registry stopped resending a failed state after maxRetries and the retry timer was off by one, so a transport that recovered after a few seconds left native with a stale mask (a new static region recorded in the clear until its geometry changed). Now the retry timer backs off 1 s doubling to a 30 s cap and never gives up, frames do not resend the state that just failed, and once maxRetries consecutive failures passed a changed state waits for the timer too (no per-frame flood). Constant comments in the Java/ObjC stores described the old previous-set grace. Co-Authored-By: Claude Sonnet 5.5 Change-Id: Ib3670530c869ba9611564b75f9bbe120c753a502 --- .claude/CLAUDE.md | 2 +- .../bugsee/flutter/SecureRectangleStore.java | 4 +- .../BGSFlutterSecureRectangleStore.m | 4 +- .../include/BGSFlutterSecureRectangleStore.h | 6 +- lib/src/secure/secure_region.dart | 66 +++++++-- test/secure_region_test.dart | 134 ++++++++++++++++++ 6 files changed, 195 insertions(+), 21 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 22a2a8a..50032f6 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -23,7 +23,7 @@ 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 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 of). A disabled TickerMode is NOT evidence (it only stops animations). A detached layer is NOT such evidence (SnapshotWidget, the Zoom page transition). Every one of these rules has a test that fails when it is broken; keep it that way. +- 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 of). A disabled TickerMode is NOT evidence (it only stops animations). A detached layer is NOT such evidence (SnapshotWidget, the Zoom page transition). 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 even a changed state waits for the timer (no per-frame flood; a missing plugin costs one message per 30 s). 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/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java b/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java index 4506ec3..5cc4ffa 100644 --- a/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java +++ b/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java @@ -106,10 +106,10 @@ final class SecureRectangleStore { /** Largest magnitude served: keeps the SDK's own int arithmetic away from overflow. */ private static final double LIMIT = 1 << 28; - /** How long the previous set is served along with a changed one. Repeated in ObjC. */ + /** 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 previous set is collapsed to its bounding box. */ + /** 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). */ diff --git a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecureRectangleStore.m b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecureRectangleStore.m index f9deb45..23aea6c 100644 --- a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecureRectangleStore.m +++ b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterSecureRectangleStore.m @@ -12,9 +12,9 @@ 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 the previous set is served along with a changed one. Repeated in Java (GRACE_MS). +/// 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 previous set is collapsed to its bounding box. +/// 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; 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 903cf77..67ceb69 100644 --- a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/include/BGSFlutterSecureRectangleStore.h +++ b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/include/BGSFlutterSecureRectangleStore.h @@ -101,13 +101,13 @@ typedef NS_ENUM(NSInteger, BGSFlutterPlacementKind) { - (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 the previous set is served -/// along with a changed one (0 for none). +/// `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 the previous set is served along with a changed one. Repeated in Java (`GRACE_MS`). +/// How long each past set is served after it stopped being current. Repeated in Java (`GRACE_MS`). + (NSTimeInterval)graceSeconds; - (void)setEnvironment:(id)environment; diff --git a/lib/src/secure/secure_region.dart b/lib/src/secure/secure_region.dart index da746b7..0ab46e4 100644 --- a/lib/src/secure/secure_region.dart +++ b/lib/src/secure/secure_region.dart @@ -274,6 +274,7 @@ class SecureRegionRegistry { required this.publish, int? epoch, this.retryDelay = const Duration(seconds: 1), + this.maxRetryDelay = const Duration(seconds: 30), this.maxRetries = 5, }) : epoch = epoch ?? _randomEpoch(); @@ -299,8 +300,24 @@ class SecureRegionRegistry { final int epoch; /// How long to wait before trying again after a failed publish; `null` - /// leaves the retry to the next frame that happens. + /// 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] nothing but the retry timer sends (a changed state is + /// held for the timer, which sends the latest one); without one, only the + /// resend of the very same failed state is suppressed. final int maxRetries; final Set _regions = {}; @@ -308,6 +325,8 @@ class SecureRegionRegistry { int _sequence = 0; int _failures = 0; Timer? _retry; + bool _retryDue = false; + bool _disposed = false; SecureState? _failedState; /// What native is known to hold, or `null` when that is unknown (a publish @@ -359,15 +378,19 @@ class SecureRegionRegistry { final SecureState state = measure(); final SecureState? published = _published; if (published != null && sameState(published, state)) return; - // Native keeps failing for this very state (no plugin on this platform, a broken - // channel): stop resending it every frame once the retries are used up. A changed - // state is still sent, and a success resets the count. - final SecureState? failed = _failedState; - if (published == null && - _failures > maxRetries && - failed != null && - sameState(failed, 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 the + // timer sends the latest state, so an animation cannot flood it). + if (sameAsFailed || _failures > maxRetries) 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); } @@ -393,6 +416,9 @@ class SecureRegionRegistry { void _send(SecureState state) { final sequence = ++_sequence; + _retryDue = false; + _retry?.cancel(); + _retry = null; final message = SecureRectanglesMessage( epoch: epoch, sequence: sequence, @@ -423,6 +449,7 @@ class SecureRegionRegistry { } sent.then( (_) { + if (sequence != _sequence) return; _failures = 0; _failedState = null; }, @@ -438,20 +465,33 @@ class SecureRegionRegistry { _failures++; lastError = error; final delay = retryDelay; - if (delay != null && _failures <= maxRetries) { + if (delay != null && !_disposed) { _retry?.cancel(); - _retry = Timer(delay, _schedule); + _retry = Timer(_backoff(delay), () { + _retry = null; + _retryDue = true; + _schedule(); + }); } }, ); } + /// 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. For tests. + /// Cancels any pending retry and arms no more. For tests. @visibleForTesting void dispose() { + _disposed = true; _retry?.cancel(); _retry = null; } diff --git a/test/secure_region_test.dart b/test/secure_region_test.dart index 9337110..715bee3 100644 --- a/test/secure_region_test.dart +++ b/test/secure_region_test.dart @@ -1015,6 +1015,140 @@ void main() { expect(native.sent.length, before + 1, reason: 'a new state is tried'); }); + SecureRegionRegistry slowRetry(Recorder native, {int maxRetries = 5}) => + SecureRegionRegistry( + publish: native.call, + epoch: 7, + retryDelay: const Duration(milliseconds: 100), + maxRetryDelay: const Duration(seconds: 1), + maxRetries: maxRetries, + ); + + /// 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) + ) { + 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); + 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))); + await tester.pump(const Duration(milliseconds: 16)); + } + // 5 quick tries plus 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 while the transport is known bad is ' + 'held for the retry timer, which sends the latest one', (tester) async { + registry.dispose(); + registry = slowRetry(native, maxRetries: 1); + native.failNext = 4; + await tester.pumpWidget(app(registry, at(registry, 10, 20))); + await runFrames(tester, const Duration(milliseconds: 400)); + final before = native.sent.length; + await tester.pumpWidget(app(registry, at(registry, 30, 20))); + await tester.pumpWidget(app(registry, at(registry, 40, 20))); + expect( + native.sent, + hasLength(before), + reason: 'held, not sent per frame', + ); + await runFrames(tester, const Duration(seconds: 3)); + expect(native.latest[0], [40, 20, 140, 70]); + }); + testWidgets('a failed clear is sent again, even though it is empty', ( tester, ) async { From 151a6a8724b84941ec246cd6a0eea6bbf1e31225 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 21:11:20 +0500 Subject: [PATCH 14/16] Secure rectangles: a changed state after the retries are used up waits at most retryDelay, not the whole backoff (Claude review) After maxRetries failures every changed state was held for the retry timer (up to 30 s), so a region that appeared once the transport had recovered was recorded in the clear until the timer fired. A changed state now goes out once retryDelay has passed since the last attempt (the timer is brought forward to that instant), so a dead transport still sees at most one message per second, never one per frame. The registry takes an injectable clock for the tests. Co-Authored-By: Claude Sonnet 5.5 Change-Id: I784b1a573da7f2572b10be57a3ed2a8a0006e8a1 --- .claude/CLAUDE.md | 2 +- lib/src/secure/secure_region.dart | 66 +++++++++++++++++------ test/secure_region_test.dart | 87 +++++++++++++++++++++++-------- 3 files changed, 116 insertions(+), 39 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 50032f6..4e8b73a 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -23,7 +23,7 @@ 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 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 of). A disabled TickerMode is NOT evidence (it only stops animations). A detached layer is NOT such evidence (SnapshotWidget, the Zoom page transition). 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 even a changed state waits for the timer (no per-frame flood; a missing plugin costs one message per 30 s). Every one of these rules has a test that fails when it is broken; keep it that way. +- 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 of). A disabled TickerMode is NOT evidence (it only stops animations). A detached layer is NOT such evidence (SnapshotWidget, the Zoom page transition). 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/lib/src/secure/secure_region.dart b/lib/src/secure/secure_region.dart index 0ab46e4..a9c065b 100644 --- a/lib/src/secure/secure_region.dart +++ b/lib/src/secure/secure_region.dart @@ -276,7 +276,17 @@ class SecureRegionRegistry { this.retryDelay = const Duration(seconds: 1), this.maxRetryDelay = const Duration(seconds: 30), this.maxRetries = 5, - }) : epoch = epoch ?? _randomEpoch(); + 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). @@ -315,9 +325,12 @@ class SecureRegionRegistry { final Duration maxRetryDelay; /// After this many consecutive failures the transport counts as known bad: - /// with a [retryDelay] nothing but the retry timer sends (a changed state is - /// held for the timer, which sends the latest one); without one, only the - /// resend of the very same failed state is suppressed. + /// 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 = {}; @@ -326,6 +339,8 @@ class SecureRegionRegistry { int _failures = 0; Timer? _retry; bool _retryDue = false; + bool _expedited = false; + Duration _lastSend = Duration.zero; bool _disposed = false; SecureState? _failedState; @@ -383,9 +398,18 @@ class SecureRegionRegistry { 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 the - // timer sends the latest state, so an animation cannot flood it). - if (sameAsFailed || _failures > maxRetries) return; + // 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. @@ -417,6 +441,8 @@ class SecureRegionRegistry { void _send(SecureState state) { final sequence = ++_sequence; _retryDue = false; + _expedited = false; + _lastSend = _now(); _retry?.cancel(); _retry = null; final message = SecureRectanglesMessage( @@ -465,18 +491,28 @@ class SecureRegionRegistry { _failures++; lastError = error; final delay = retryDelay; - if (delay != null && !_disposed) { - _retry?.cancel(); - _retry = Timer(_backoff(delay), () { - _retry = null; - _retryDue = true; - _schedule(); - }); - } + 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) { diff --git a/test/secure_region_test.dart b/test/secure_region_test.dart index 715bee3..79d8578 100644 --- a/test/secure_region_test.dart +++ b/test/secure_region_test.dart @@ -1015,14 +1015,23 @@ void main() { expect(native.sent.length, before + 1, reason: 'a new state is tried'); }); - SecureRegionRegistry slowRetry(Recorder native, {int maxRetries = 5}) => - SecureRegionRegistry( - publish: native.call, - epoch: 7, - retryDelay: const Duration(milliseconds: 100), - maxRetryDelay: const Duration(seconds: 1), - maxRetries: maxRetries, - ); + // 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 { @@ -1031,6 +1040,7 @@ void main() { t < total; t += const Duration(milliseconds: 16) ) { + fakeNow += const Duration(milliseconds: 16); await tester.pump(const Duration(milliseconds: 16)); } } @@ -1103,15 +1113,20 @@ void main() { testWidgets('a transport that keeps failing is not flooded, frames or ' 'not, even while the region moves', (tester) async { registry.dispose(); - registry = slowRetry(native); + 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 about one per second, not one per frame (1250). + // 5 quick tries plus at most about one per second, not one per frame (1250). expect(native.sent.length, lessThan(40)); registry.dispose(); }); @@ -1130,23 +1145,49 @@ void main() { expect(native.sent, hasLength(settled), reason: 'delivered: quiet'); }); - testWidgets('a state that changes while the transport is known bad is ' - 'held for the retry timer, which sends the latest one', (tester) async { + 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, maxRetries: 1); - native.failNext = 4; + registry = slowRetry(native, cap: const Duration(seconds: 30)); + native.failNext = 1000000; await tester.pumpWidget(app(registry, at(registry, 10, 20))); - await runFrames(tester, const Duration(milliseconds: 400)); - final before = native.sent.length; - await tester.pumpWidget(app(registry, at(registry, 30, 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))); - expect( - native.sent, - hasLength(before), - reason: 'held, not sent per frame', - ); - await runFrames(tester, const Duration(seconds: 3)); + 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', ( From a52371a9948dc2f17cc3093525c78916df7fc496 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 22:11:25 +0500 Subject: [PATCH 15/16] Secure rectangles (Android): a placement refresh the main thread never answers covers the placed views whole (CodeRabbit) The pull asks the main thread for a refresh and answers from the last completed one. A refresh asked for and not completed for 1 s (a stuck main thread) now makes every placed view serve the whole display until one completes, tracked from the oldest unanswered request so the 100 ms throttle cannot hide it and independent of the pull rate. Marking every pull pending was rejected: the refresh is asynchronous, so it would blank the display on every pull. Wording nits in CLAUDE.md/PLAN.md/secure_region.dart. Co-Authored-By: Claude Sonnet 5.5 Change-Id: I3a9dc18ce909c4593983730ec2b762c22758f168 --- .claude/CLAUDE.md | 2 +- PLAN.md | 2 +- .../bugsee/flutter/BugseeFlutterPlugin.java | 8 ++- .../bugsee/flutter/SecureRectangleStore.java | 31 +++++++++++ .../flutter/SecureRectangleStoreTest.java | 52 +++++++++++++++++++ lib/src/secure/secure_region.dart | 2 +- 6 files changed, 92 insertions(+), 5 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4e8b73a..056993e 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -23,7 +23,7 @@ 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 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 of). A disabled TickerMode is NOT evidence (it only stops animations). A detached layer is NOT such evidence (SnapshotWidget, the Zoom page transition). 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 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/PLAN.md b/PLAN.md index d1da5c3..da0b02c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -59,7 +59,7 @@ Sources: `specs/sdk/wrapper-workbook` (authoritative order of work), `wrapper-da 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 + 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 only when the set changed, 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 diff --git a/android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java b/android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java index eb87adb..084f003 100644 --- a/android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java +++ b/android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java @@ -51,8 +51,12 @@ private static synchronized SecureEnvironment environment( } private static void refreshPlacements() { - for (final SecurePlacement placement : PLACEMENTS) { - placement.refresh(); + try { + for (final SecurePlacement placement : PLACEMENTS) { + placement.refresh(); + } + } finally { + SecureRectangleStore.shared().refreshCompleted(); } } diff --git a/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java b/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java index 5cc4ffa..b967e6d 100644 --- a/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java +++ b/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java @@ -106,6 +106,15 @@ final class SecureRectangleStore { /** 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; @@ -353,6 +362,10 @@ static SecureRectangleStore shared() { 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) { @@ -478,6 +491,8 @@ void dropOwner(final long owner) { int[] pull(final int display) { final Runnable hook = pullHook.get(); if (hook != null) { + // Before the (throttled, possibly suppressed) request: the oldest unanswered one counts. + refreshPendingSince.compareAndSet(NO_REFRESH_PENDING, clock.nowMs()); try { hook.run(); } catch (Throwable ignored) { @@ -487,6 +502,14 @@ int[] pull(final int display) { return snapshot(display); } + /** + * 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 @@ -587,6 +610,9 @@ private int[] serve(@NonNull final State s, final int display) { 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(); @@ -603,6 +629,11 @@ private int[] serve(@NonNull final State s, final int display) { 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); diff --git a/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java b/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java index ed27d1d..a2eb27f 100644 --- a/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java +++ b/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java @@ -14,6 +14,7 @@ import org.junit.Before; import org.junit.Test; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -577,6 +578,57 @@ public void aPullCallsTheHookFirstAndSurvivesItThrowing() { 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(() -> { /* 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 aRefreshThatCompletesEachTimeNeverCovers_whateverTheRateOfThePulls() { + final SecureRectangleStore g = pulledStore(); + g.setPullHook(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 --------------------------------------------------------------------------- diff --git a/lib/src/secure/secure_region.dart b/lib/src/secure/secure_region.dart index a9c065b..6e7e609 100644 --- a/lib/src/secure/secure_region.dart +++ b/lib/src/secure/secure_region.dart @@ -20,7 +20,7 @@ // 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 of. Nothing else drops it. TickerMode is NOT +// 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 From 977f0d4e7c9a3c6978e56ecbfd2d09e0e0d7c63d Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 22:40:48 +0500 Subject: [PATCH 16/16] Secure rectangles (Android): only a request actually posted to the main thread counts as a refresh in flight; PLAN.md wording (Claude, CodeRabbit) A pull dropped by the 100 ms throttle left 'pending' set with no task in flight, so a later pull after a quiet second could blank the display although the main thread was healthy. The plugin's executor now marks the request, so only a posted one counts. PLAN.md: retries after a failed publish, and the exact conditions for dropping a region. Co-Authored-By: Claude Sonnet 5.5 Change-Id: Id9953fa40258bfb4aef17191e17f8c0670ee9c2a --- PLAN.md | 4 +-- .../bugsee/flutter/BugseeFlutterPlugin.java | 6 +++- .../bugsee/flutter/SecureRectangleStore.java | 11 +++++-- .../flutter/SecureRectangleStoreTest.java | 29 +++++++++++++++++-- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/PLAN.md b/PLAN.md index da0b02c..adbf349 100644 --- a/PLAN.md +++ b/PLAN.md @@ -61,9 +61,9 @@ Sources: `specs/sdk/wrapper-workbook` (authoritative order of work), `wrapper-da 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 only when the set changed, ordered by `(epoch, sequence)`. Regions are + 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 - (paintsChild, IndexedStack, a clipping viewport); neither a disabled TickerMode nor a detached layer counts as such evidence. An unmeasurable region covers the whole view. + (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 diff --git a/android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java b/android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java index 084f003..965e449 100644 --- a/android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java +++ b/android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java @@ -36,7 +36,11 @@ public class BugseeFlutterPlugin private static final List PLACEMENTS = new CopyOnWriteArrayList<>(); private static final PullThrottle PULLS = new PullThrottle( () -> System.nanoTime() / 1_000_000L, - MAIN::post, + 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; diff --git a/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java b/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java index b967e6d..189f084 100644 --- a/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java +++ b/android/src/main/java/com/bugsee/flutter/SecureRectangleStore.java @@ -491,8 +491,6 @@ void dropOwner(final long owner) { int[] pull(final int display) { final Runnable hook = pullHook.get(); if (hook != null) { - // Before the (throttled, possibly suppressed) request: the oldest unanswered one counts. - refreshPendingSince.compareAndSet(NO_REFRESH_PENDING, clock.nowMs()); try { hook.run(); } catch (Throwable ignored) { @@ -502,6 +500,15 @@ int[] pull(final int display) { 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. diff --git a/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java b/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java index a2eb27f..ea6c9bd 100644 --- a/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java +++ b/android/src/test/java/com/bugsee/flutter/SecureRectangleStoreTest.java @@ -591,7 +591,7 @@ private SecureRectangleStore pulledStore() { @Test public void aPlacementRefreshThatIsNeverAnsweredCoversTheWholeDisplayUntilItIs() { final SecureRectangleStore g = pulledStore(); - g.setPullHook(() -> { /* posts to a main thread that is not answering */ }); + 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; @@ -603,10 +603,35 @@ public void aPlacementRefreshThatIsNeverAnsweredCoversTheWholeDisplayUntilItIs() 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::refreshCompleted); + 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;