diff --git a/CHANGELOG.md b/CHANGELOG.md index 4193d61d71..f747639031 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,16 @@ - Add `beforeCapture` for View Hierarchy ([#2523](https://github.com/getsentry/sentry-dart/pull/2523)) - View hierarchy calls are now debounced for 2 seconds. - + ### Enhancements - Replay: improve iOS native interop performance ([#2530](https://github.com/getsentry/sentry-dart/pull/2530)) - Replay: improve orientation change tracking accuracy on Android ([#2540](https://github.com/getsentry/sentry-dart/pull/2540)) +### Fixes + +- Replay: fix masking for frames captured during UI changes ([#2553](https://github.com/getsentry/sentry-dart/pull/2553)) + ### Dependencies - Bump Cocoa SDK from v8.42.0 to v8.43.0 ([#2542](https://github.com/getsentry/sentry-dart/pull/2542), [#2548](https://github.com/getsentry/sentry-dart/pull/2548)) diff --git a/flutter/example/devtools_options.yaml b/flutter/example/devtools_options.yaml new file mode 100644 index 0000000000..fa0b357c4f --- /dev/null +++ b/flutter/example/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/flutter/example/integration_test/integration_test.dart b/flutter/example/integration_test/integration_test.dart index 8934deb156..6fd6916b7d 100644 --- a/flutter/example/integration_test/integration_test.dart +++ b/flutter/example/integration_test/integration_test.dart @@ -20,6 +20,8 @@ void main() { const fakeDsn = 'https://abc@def.ingest.sentry.io/1234567'; IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + IntegrationTestWidgetsFlutterBinding.instance.framePolicy = + LiveTestWidgetsFlutterBindingFramePolicy.fullyLive; tearDown(() async { await Sentry.close(); diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index d5c7d71d71..1f1d1d65e0 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -79,7 +79,7 @@ Future setupSentry( // We can enable Sentry debug logging during development. This is likely // going to log too much for your app, but can be useful when figuring out // configuration issues, e.g. finding out why your events are not uploaded. - options.debug = true; + options.debug = kDebugMode; options.spotlight = Spotlight(enabled: true); options.enableTimeToFullDisplayTracing = true; options.enableMetrics = true; diff --git a/flutter/lib/src/event_processor/screenshot_event_processor.dart b/flutter/lib/src/event_processor/screenshot_event_processor.dart index 6f8618bf28..4d2446c2a4 100644 --- a/flutter/lib/src/event_processor/screenshot_event_processor.dart +++ b/flutter/lib/src/event_processor/screenshot_event_processor.dart @@ -9,6 +9,7 @@ import '../screenshot/recorder.dart'; import '../screenshot/recorder_config.dart'; import 'package:flutter/widgets.dart' as widget; +import '../screenshot/stabilizer.dart'; import '../utils/debouncer.dart'; class ScreenshotEventProcessor implements EventProcessor { @@ -125,20 +126,37 @@ class ScreenshotEventProcessor implements EventProcessor { } @internal - Future createScreenshot() => - _recorder.capture(_convertImageToUint8List); - - Future _convertImageToUint8List(Screenshot screenshot) async { - final byteData = - await screenshot.image.toByteData(format: ImageByteFormat.png); - - final bytes = byteData?.buffer.asUint8List(); - if (bytes?.isNotEmpty == true) { - return bytes; + Future createScreenshot() async { + if (_options.experimental.privacyForScreenshots == null) { + return _recorder.capture((screenshot) => + screenshot.pngData.then((v) => v.buffer.asUint8List())); } else { - _options.logger( - SentryLevel.debug, 'Screenshot is 0 bytes, not attaching the image.'); - return null; + // If masking is enabled, we need to use [ScreenshotStabilizer]. + final completer = Completer(); + final stabilizer = ScreenshotStabilizer( + _recorder, _options, + (screenshot) async { + final pngData = await screenshot.pngData; + completer.complete(pngData.buffer.asUint8List()); + }, + // This limits the amount of time to take a stable masked screenshot. + maxTries: 5, + // We need to force the frame the frame or this could hang indefinitely. + frameSchedulingMode: FrameSchedulingMode.forced, + ); + try { + unawaited( + stabilizer.capture(Duration.zero).onError(completer.completeError)); + // DO NOT return completer.future directly - we need to dispose first. + return await completer.future.timeout(const Duration(seconds: 1), + onTimeout: () { + _options.logger( + SentryLevel.warning, 'Timed out taking a stable screenshot.'); + return null; + }); + } finally { + stabilizer.dispose(); + } } } } diff --git a/flutter/lib/src/native/cocoa/cocoa_replay_recorder.dart b/flutter/lib/src/native/cocoa/cocoa_replay_recorder.dart new file mode 100644 index 0000000000..8404dfaa9f --- /dev/null +++ b/flutter/lib/src/native/cocoa/cocoa_replay_recorder.dart @@ -0,0 +1,40 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +import '../../../sentry_flutter.dart'; +import '../../replay/replay_recorder.dart'; +import '../../screenshot/recorder.dart'; +import '../../screenshot/recorder_config.dart'; +import '../../screenshot/stabilizer.dart'; + +@internal +class CocoaReplayRecorder { + final SentryFlutterOptions _options; + final ScreenshotRecorder _recorder; + late final ScreenshotStabilizer _stabilizer; + var _completer = Completer(); + + CocoaReplayRecorder(this._options) + : _recorder = + ReplayScreenshotRecorder(ScreenshotRecorderConfig(), _options) { + _stabilizer = ScreenshotStabilizer(_recorder, _options, (screenshot) async { + final pngData = await screenshot.pngData; + _options.logger( + SentryLevel.debug, + 'Replay: captured screenshot (' + '${screenshot.width}x${screenshot.height} pixels, ' + '${pngData.lengthInBytes} bytes)'); + _completer.complete(pngData.buffer.asUint8List()); + }); + } + + Future captureScreenshot() async { + _completer = Completer(); + _stabilizer.ensureFrameAndAddCallback((msSinceEpoch) { + _stabilizer.capture(msSinceEpoch).onError(_completer.completeError); + }); + return _completer.future; + } +} diff --git a/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart b/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart index 621d8f8a40..658b5f8af0 100644 --- a/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart +++ b/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart @@ -1,23 +1,19 @@ import 'dart:async'; import 'dart:ffi'; -import 'dart:typed_data'; -import 'dart:ui'; import 'package:meta/meta.dart'; import '../../../sentry_flutter.dart'; import '../../replay/replay_config.dart'; -import '../../replay/replay_recorder.dart'; -import '../../screenshot/recorder.dart'; -import '../../screenshot/recorder_config.dart'; import '../native_memory.dart'; import '../sentry_native_channel.dart'; import 'binding.dart' as cocoa; +import 'cocoa_replay_recorder.dart'; @internal class SentryNativeCocoa extends SentryNativeChannel { late final _lib = cocoa.SentryCocoa(DynamicLibrary.process()); - ScreenshotRecorder? _replayRecorder; + CocoaReplayRecorder? _replayRecorder; SentryId? _replayId; SentryNativeCocoa(super.options); @@ -33,12 +29,12 @@ class SentryNativeCocoa extends SentryNativeChannel { channel.setMethodCallHandler((call) async { switch (call.method) { case 'captureReplayScreenshot': - _replayRecorder ??= - ReplayScreenshotRecorder(ScreenshotRecorderConfig(), options); + _replayRecorder ??= CocoaReplayRecorder(options); final replayId = call.arguments['replayId'] == null ? null : SentryId.fromId(call.arguments['replayId'] as String); + if (_replayId != replayId) { _replayId = replayId; hub.configureScope((s) { @@ -47,35 +43,7 @@ class SentryNativeCocoa extends SentryNativeChannel { }); } - final widgetsBinding = options.bindingUtils.instance; - if (widgetsBinding == null) { - options.logger(SentryLevel.warning, - 'Replay: failed to capture screenshot, WidgetsBinding.instance is null'); - return null; - } - - final completer = Completer(); - widgetsBinding.ensureVisualUpdate(); - widgetsBinding.addPostFrameCallback((_) { - _replayRecorder?.capture((screenshot) async { - final image = screenshot.image; - final imageData = - await image.toByteData(format: ImageByteFormat.png); - if (imageData != null) { - options.logger( - SentryLevel.debug, - 'Replay: captured screenshot (' - '${image.width}x${image.height} pixels, ' - '${imageData.lengthInBytes} bytes)'); - return imageData.buffer.asUint8List(); - } else { - options.logger(SentryLevel.warning, - 'Replay: failed to convert screenshot to PNG'); - } - }).then(completer.complete, onError: completer.completeError); - }); - final uint8List = await completer.future; - + final uint8List = await _replayRecorder!.captureScreenshot(); // Malloc memory and copy the data. Native must free it. return uint8List?.toNativeMemory().toJson(); default: diff --git a/flutter/lib/src/native/java/android_replay_recorder.dart b/flutter/lib/src/native/java/android_replay_recorder.dart index 021c2672c7..2719558fef 100644 --- a/flutter/lib/src/native/java/android_replay_recorder.dart +++ b/flutter/lib/src/native/java/android_replay_recorder.dart @@ -2,6 +2,7 @@ import 'package:meta/meta.dart'; import '../../../sentry_flutter.dart'; import '../../replay/scheduled_recorder.dart'; +import '../../screenshot/screenshot.dart'; import '../sentry_safe_method_channel.dart'; @internal @@ -15,19 +16,20 @@ class AndroidReplayRecorder extends ScheduledScreenshotRecorder { } Future _addReplayScreenshot( - ScreenshotPng screenshot, bool isNewlyCaptured) async { + Screenshot screenshot, bool isNewlyCaptured) async { final timestamp = screenshot.timestamp.millisecondsSinceEpoch; final filePath = "$_cacheDir/$timestamp.png"; - options.logger( - SentryLevel.debug, - '$logName: saving ${isNewlyCaptured ? 'new' : 'repeated'} screenshot to' - ' $filePath (${screenshot.width}x${screenshot.height} pixels, ' - '${screenshot.data.lengthInBytes} bytes)'); try { + final pngData = await screenshot.pngData; + options.logger( + SentryLevel.debug, + '$logName: saving ${isNewlyCaptured ? 'new' : 'repeated'} screenshot to' + ' $filePath (${screenshot.width}x${screenshot.height} pixels, ' + '${pngData.lengthInBytes} bytes)'); await options.fileSystem .file(filePath) - .writeAsBytes(screenshot.data.buffer.asUint8List(), flush: true); + .writeAsBytes(pngData.buffer.asUint8List(), flush: true); await _channel.invokeMethod( 'addReplayScreenshot', diff --git a/flutter/lib/src/replay/replay_recorder.dart b/flutter/lib/src/replay/replay_recorder.dart index 360ce53a5e..bb136f41e5 100644 --- a/flutter/lib/src/replay/replay_recorder.dart +++ b/flutter/lib/src/replay/replay_recorder.dart @@ -16,8 +16,8 @@ class ReplayScreenshotRecorder extends ScreenshotRecorder { @override @protected - Future executeTask(void Function() task, Flow flow) { - // Future() schedules the task to be executed asynchronously with TImer.run. + Future executeTask(Future Function() task, Flow flow) { + // Future() schedules the task to be executed asynchronously with Timer.run. return Future(task); } } diff --git a/flutter/lib/src/replay/scheduled_recorder.dart b/flutter/lib/src/replay/scheduled_recorder.dart index 3fc4cec65c..06b75a2a60 100644 --- a/flutter/lib/src/replay/scheduled_recorder.dart +++ b/flutter/lib/src/replay/scheduled_recorder.dart @@ -1,18 +1,17 @@ import 'dart:async'; -import 'dart:typed_data'; -import 'dart:ui'; import 'package:meta/meta.dart'; import '../../sentry_flutter.dart'; -import '../screenshot/recorder.dart'; +import '../screenshot/stabilizer.dart'; +import '../screenshot/screenshot.dart'; import 'replay_recorder.dart'; import 'scheduled_recorder_config.dart'; import 'scheduler.dart'; @internal typedef ScheduledScreenshotRecorderCallback = Future Function( - ScreenshotPng screenshot, bool isNewlyCaptured); + Screenshot screenshot, bool isNewlyCaptured); @internal class ScheduledScreenshotRecorder extends ReplayScreenshotRecorder { @@ -20,6 +19,7 @@ class ScheduledScreenshotRecorder extends ReplayScreenshotRecorder { late final ScheduledScreenshotRecorderCallback _callback; var _status = _Status.running; late final Duration _frameDuration; + late final ScreenshotStabilizer _stabilizer; // late final _idleFrameFiller = _IdleFrameFiller(_frameDuration, _onScreenshot); @override @@ -35,7 +35,9 @@ class ScheduledScreenshotRecorder extends ReplayScreenshotRecorder { _frameDuration = Duration(milliseconds: 1000 ~/ config.frameRate); assert(_frameDuration.inMicroseconds > 0); - _scheduler = Scheduler(_frameDuration, _capture, _addPostFrameCallback); + _stabilizer = ScreenshotStabilizer(this, options, _onImageCaptured); + _scheduler = Scheduler(_frameDuration, _stabilizer.capture, + _stabilizer.ensureFrameAndAddCallback); if (callback != null) { _callback = callback; @@ -46,12 +48,6 @@ class ScheduledScreenshotRecorder extends ReplayScreenshotRecorder { _callback = callback; } - void _addPostFrameCallback(FrameCallback callback) { - options.bindingUtils.instance! - ..ensureVisualUpdate() - ..addPostFrameCallback(callback); - } - void start() { assert(() { // The following fails if callback hasn't been provided @@ -66,7 +62,13 @@ class ScheduledScreenshotRecorder extends ReplayScreenshotRecorder { _startScheduler(); } + Future _stopScheduler() { + _stabilizer.stopped = true; + return _scheduler.stop(); + } + void _startScheduler() { + _stabilizer.stopped = false; _scheduler.start(); // We need to schedule a frame because if this happens in-between user @@ -79,8 +81,9 @@ class ScheduledScreenshotRecorder extends ReplayScreenshotRecorder { Future stop() async { options.logger(SentryLevel.debug, "$logName: stopping capture."); _status = _Status.stopped; - await _scheduler.stop(); - // await Future.wait([_scheduler.stop(), _idleFrameFiller.stop()]); + await _stopScheduler(); + _stabilizer.dispose(); + // await Future.wait([_stopScheduler(), _idleFrameFiller.stop()]); options.logger(SentryLevel.debug, "$logName: capture stopped."); } @@ -88,7 +91,7 @@ class ScheduledScreenshotRecorder extends ReplayScreenshotRecorder { if (_status == _Status.running) { _status = _Status.paused; // _idleFrameFiller.pause(); - await _scheduler.stop(); + await _stopScheduler(); } } @@ -100,23 +103,10 @@ class ScheduledScreenshotRecorder extends ReplayScreenshotRecorder { } } - void _capture(Duration sinceSchedulerEpoch) => capture(_onImageCaptured); - - Future _onImageCaptured(Screenshot capturedScreenshot) async { - final image = capturedScreenshot.image; + Future _onImageCaptured(Screenshot screenshot) async { if (_status == _Status.running) { - var imageData = await image.toByteData(format: ImageByteFormat.png); - if (imageData != null) { - final screenshot = ScreenshotPng( - image.width, image.height, imageData, capturedScreenshot.timestamp); - await _onScreenshot(screenshot, true); - // _idleFrameFiller.actualFrameReceived(screenshot); - } else { - options.logger( - SentryLevel.debug, - '$logName: failed to convert screenshot to PNG, ' - 'toByteData() returned null. (${image.width}x${image.height} pixels)'); - } + await _onScreenshot(screenshot, true); + // _idleFrameFiller.actualFrameReceived(screenshot); } else { // drop any screenshots from callbacks if the replay has already been stopped/paused. options.logger(SentryLevel.debug, @@ -125,7 +115,7 @@ class ScheduledScreenshotRecorder extends ReplayScreenshotRecorder { } Future _onScreenshot( - ScreenshotPng screenshot, bool isNewlyCaptured) async { + Screenshot screenshot, bool isNewlyCaptured) async { if (_status == _Status.running) { await _callback(screenshot, isNewlyCaptured); } else { @@ -136,16 +126,6 @@ class ScheduledScreenshotRecorder extends ReplayScreenshotRecorder { } } -@internal -@immutable -class ScreenshotPng { - final int width; - final int height; - final ByteData data; - final DateTime timestamp; - - const ScreenshotPng(this.width, this.height, this.data, this.timestamp); -} // TODO this is currently unused because we've decided to capture on every // frame. Consider removing if we don't reverse the decision in the future. @@ -159,11 +139,11 @@ class ScreenshotPng { // final ScheduledScreenshotRecorderCallback _callback; // var _status = _Status.running; // Future? _scheduled; -// ScreenshotPng? _mostRecent; +// Screenshot? _mostRecent; // _IdleFrameFiller(this._interval, this._callback); -// void actualFrameReceived(ScreenshotPng screenshot) { +// void actualFrameReceived(Screenshot screenshot) { // // We store the most recent frame but only repost it when the most recent // // one is the same instance (unchanged). // _mostRecent = screenshot; @@ -192,7 +172,7 @@ class ScreenshotPng { // } // } -// void repostLater(Duration delay, ScreenshotPng screenshot) { +// void repostLater(Duration delay, Screenshot screenshot) { // _scheduled = Future.delayed(delay, () async { // if (_status == _Status.stopped) { // return; diff --git a/flutter/lib/src/replay/scheduler.dart b/flutter/lib/src/replay/scheduler.dart index 4c5882356e..11e30fd516 100644 --- a/flutter/lib/src/replay/scheduler.dart +++ b/flutter/lib/src/replay/scheduler.dart @@ -2,7 +2,7 @@ import 'package:flutter/scheduler.dart'; import 'package:meta/meta.dart'; @internal -typedef SchedulerCallback = void Function(Duration); +typedef SchedulerCallback = Future Function(Duration); /// This is a low-priority scheduler. /// We're not using Timer.periodic() because it may schedule a callback @@ -16,6 +16,7 @@ class Scheduler { final Duration _interval; bool _running = false; Future? _scheduled; + Future? _runningCallback; final void Function(FrameCallback callback) _addPostFrameCallback; @@ -46,13 +47,16 @@ class Scheduler { @pragma('vm:prefer-inline') void _runAfterNextFrame() { - _scheduled = null; - _addPostFrameCallback(_run); + final runningCallback = _runningCallback ?? Future.value(); + runningCallback.whenComplete(() { + _scheduled = null; + _addPostFrameCallback(_run); + }); } void _run(Duration sinceSchedulerEpoch) { if (!_running) return; - _callback(sinceSchedulerEpoch); + _runningCallback = _callback(sinceSchedulerEpoch); _scheduleNext(); } } diff --git a/flutter/lib/src/screenshot/recorder.dart b/flutter/lib/src/screenshot/recorder.dart index 979fb6a313..497eb4ebfd 100644 --- a/flutter/lib/src/screenshot/recorder.dart +++ b/flutter/lib/src/screenshot/recorder.dart @@ -11,6 +11,7 @@ import 'package:meta/meta.dart'; import '../../sentry_flutter.dart'; import 'masking_config.dart'; import 'recorder_config.dart'; +import 'screenshot.dart'; import 'widget_filter.dart'; @internal @@ -21,9 +22,7 @@ class ScreenshotRecorder { @protected final SentryFlutterOptions options; - @protected final String logName; - bool _warningLogged = false; late final SentryMaskingConfig? _maskingConfig; @@ -60,9 +59,9 @@ class ScreenshotRecorder { /// To prevent accidental addition of await before that happens, /// /// THIS FUNCTION MUST NOT BE ASYNC. - Future capture(Future Function(Screenshot) callback) { + Future capture(Future Function(Screenshot) callback, [Flow? flow]) { try { - final flow = Flow.begin(); + flow ??= Flow.begin(); Timeline.startSync('Sentry::captureScreenshot', flow: flow); final context = sentryScreenshotWidgetGlobalKey.currentContext; final renderObject = @@ -111,7 +110,7 @@ class ScreenshotRecorder { } @protected - Future executeTask(void Function() task, Flow flow) { + Future executeTask(Future Function() task, Flow flow) { // Future.sync() starts executing the function synchronously, until the // first await, i.e. it's the same as if the code was executed directly. return Future.sync(task); @@ -179,11 +178,12 @@ class _Capture { /// - call the callback /// /// See [task] which is what gets completed with the callback result. - void Function() createTask( - Future futureImage, - Future Function(Screenshot) callback, - List? obscureItems, - Flow flow) { + Future Function() createTask( + Future futureImage, + Future Function(Screenshot) callback, + List? obscureItems, + Flow flow, + ) { final timestamp = DateTime.now(); return () async { Timeline.startSync('Sentry::renderScreenshot', flow: flow); @@ -203,21 +203,23 @@ class _Capture { final picture = recorder.endRecording(); Timeline.finishSync(); // Sentry::renderScreenshot + late Image finalImage; try { Timeline.startSync('Sentry::screenshotToImage', flow: flow); - final finalImage = await picture.toImage(width, height); + finalImage = await picture.toImage(width, height); Timeline.finishSync(); // Sentry::screenshotToImage - try { - Timeline.startSync('Sentry::screenshotCallback', flow: flow); - _completer - .complete(await callback(Screenshot(finalImage, timestamp))); - Timeline.finishSync(); // Sentry::screenshotCallback - } finally { - finalImage.dispose(); // image needs to be disposed-of manually - } } finally { picture.dispose(); } + + final screenshot = Screenshot(finalImage, timestamp, flow); + try { + Timeline.startSync('Sentry::screenshotCallback', flow: flow); + _completer.complete(await callback(screenshot)); + Timeline.finishSync(); // Sentry::screenshotCallback + } finally { + screenshot.dispose(); + } }; } @@ -295,11 +297,3 @@ extension on widgets.BuildContext { return null; } } - -@internal -class Screenshot { - final Image image; - final DateTime timestamp; - - const Screenshot(this.image, this.timestamp); -} diff --git a/flutter/lib/src/screenshot/screenshot.dart b/flutter/lib/src/screenshot/screenshot.dart new file mode 100644 index 0000000000..4abfd45637 --- /dev/null +++ b/flutter/lib/src/screenshot/screenshot.dart @@ -0,0 +1,118 @@ +import 'dart:async'; +import 'dart:developer'; +import 'dart:ui'; +// ignore: unnecessary_import // backcompatibility for Flutter < 3.3 +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:meta/meta.dart'; + +@internal +class Screenshot { + final Image _image; + final DateTime timestamp; + final Flow flow; + Future? _rawData; + Future? _pngData; + bool _disposed = false; + + Screenshot(this._image, this.timestamp, this.flow); + Screenshot._cloned( + this._image, this.timestamp, this.flow, this._rawData, this._pngData); + + int get width => _image.width; + int get height => _image.height; + + Future get rawData { + _rawData ??= _encode(ImageByteFormat.rawUnmodified); + return _rawData!; + } + + Future get pngData { + _pngData ??= _encode(ImageByteFormat.png); + return _pngData!; + } + + Future _encode(ImageByteFormat format) async { + Timeline.startSync('Sentry::screenshotTo${format.name}', flow: flow); + final result = + await _image.toByteData(format: format).then((data) => data!); + Timeline.finishSync(); + return result; + } + + Future hasSameImageAs(Screenshot other) async { + if (other.width != width || other.height != height) { + return false; + } + + return listEquals(await rawData, await other.rawData); + } + + Screenshot clone() { + assert(!_disposed, 'Cannot clone a disposed screenshot'); + return Screenshot._cloned( + _image.clone(), timestamp, flow, _rawData, _pngData); + } + + void dispose() { + if (!_disposed) { + _disposed = true; + _image.dispose(); + _rawData = null; + _pngData = null; + } + } + + /// Efficiently compares two memory regions for data equality.. + @visibleForTesting + static bool listEquals(ByteData dataA, ByteData dataB) { + if (identical(dataA, dataB)) { + return true; + } + if (dataA.lengthInBytes != dataB.lengthInBytes) { + return false; + } + + /// Ideally, we would use memcmp with Uint8List.address but that's only + /// available since Dart 3.5.0. The relevant code is commented out below and + /// Should be used once we can bump the Dart SDK in the next major version. + /// For now, the best we can do is compare by chunks of 8 bytes. + // return 0 == memcmp(dataA.address, dataB.address, dataA.lengthInBytes); + + late final int processed; + try { + final numWords = dataA.lengthInBytes ~/ 8; + final wordsA = dataA.buffer.asUint64List(0, numWords); + final wordsB = dataB.buffer.asUint64List(0, numWords); + + for (var i = 0; i < wordsA.length; i++) { + if (wordsA[i] != wordsB[i]) { + return false; + } + } + processed = wordsA.lengthInBytes; + } on UnsupportedError { + // This should only trigger on dart2js: + // Unsupported operation: Uint64List not supported by dart2js. + processed = 0; + } + + // Compare any remaining bytes. + final bytesA = dataA.buffer.asUint8List(processed); + final bytesB = dataB.buffer.asUint8List(processed); + for (var i = 0; i < bytesA.lengthInBytes; i++) { + if (bytesA[i] != bytesB[i]) { + return false; + } + } + + return true; + } +} + +// /// Compares the first num bytes of the block of memory pointed by ptr1 to the +// /// first num bytes pointed by ptr2, returning zero if they all match or a value +// /// different from zero representing which is greater if they do not. +// @Native(symbol: 'memcmp', isLeaf: true) +// external int memcmp(Pointer ptr1, Pointer ptr2, int len); diff --git a/flutter/lib/src/screenshot/stabilizer.dart b/flutter/lib/src/screenshot/stabilizer.dart new file mode 100644 index 0000000000..72ee1df15a --- /dev/null +++ b/flutter/lib/src/screenshot/stabilizer.dart @@ -0,0 +1,147 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/scheduler.dart'; +import 'package:meta/meta.dart'; + +import '../../sentry_flutter.dart'; +import 'recorder.dart'; +import 'screenshot.dart'; + +/// We're facing an issue: the tree walked with visitChildElements() is out of +/// sync to what is currently rendered by RenderRepaintBoundary.toImage(), +/// even though there's no async gap between these two. This causes masks to +/// be off during repaints, e.g. when scrolling a view or when text is rendered +/// in different places between two screens. This is most easily reproducible +/// when there's no animation between the two screens. +/// For example, Spotube's Search vs Library (2nd and 3rd bottom bar buttons). +/// +/// To get around this issue, we're taking two subsequent screenshots +/// (after two frames) and only actually capture a screenshot if the +/// two are exactly the same. +@internal +class ScreenshotStabilizer { + final SentryFlutterOptions _options; + final ScreenshotRecorder _recorder; + final Future Function(Screenshot screenshot) _callback; + final int? maxTries; + final FrameSchedulingMode frameSchedulingMode; + Screenshot? _previousScreenshot; + int _tries = 0; + bool stopped = false; + + ScreenshotStabilizer(this._recorder, this._options, this._callback, + {this.maxTries, this.frameSchedulingMode = FrameSchedulingMode.normal}) { + assert(maxTries == null || maxTries! > 1, + "Cannot use ScreenshotStabilizer if we cannot retry at least once."); + } + + void dispose() { + _previousScreenshot?.dispose(); + _previousScreenshot = null; + } + + void ensureFrameAndAddCallback(FrameCallback callback) { + final binding = _options.bindingUtils.instance!; + switch (frameSchedulingMode) { + case FrameSchedulingMode.normal: + binding.scheduleFrame(); + break; + case FrameSchedulingMode.forced: + binding.scheduleForcedFrame(); + break; + } + binding.addPostFrameCallback(callback); + } + + Future capture(Duration _) { + _tries++; + return _recorder.capture(_onImageCaptured); + } + + Future _onImageCaptured(Screenshot screenshot) async { + if (stopped) { + _tries = 0; + return; + } + + var prevScreenshot = _previousScreenshot; + try { + _previousScreenshot = screenshot.clone(); + if (prevScreenshot != null && + await prevScreenshot.hasSameImageAs(screenshot)) { + // Sucessfully captured a stable screenshot (repeated at least twice). + _tries = 0; + + // If it's from the same (retry) flow, use the first screenshot + // timestamp. Otherwise this was called from a scheduler (in a new flow) + // so use the new timestamp. + await _callback((prevScreenshot.flow.id == screenshot.flow.id) + ? prevScreenshot + : screenshot); + + // Do not just return the Future resulting from callback(). + // We need to await here so that the dispose runs ASAP. + return; + } + } finally { + // [prevScreenshot] and [screenshot] are unnecessary after this line. + // Note: we need to dispose (free the memory) before recursion. + // Also, we need to reset the variable to null so that the whole object + // can be garbage collected. + prevScreenshot?.dispose(); + prevScreenshot = null; + // Note: while the caller will also do `screenshot.dispose()`, + // it would be a problem in a long recursion because we only return + // from this function when the screenshot is ultimately stable. + // At that point, the caller would have accumulated a lot of screenshots + // on stack. This would lead to OOM. + screenshot.dispose(); + } + + if (maxTries != null && _tries >= maxTries!) { + throw Exception('Failed to capture a stable screenshot. ' + 'Giving up after $_tries tries.'); + } else { + // Add a delay to give the UI a chance to stabilize. + // Only do this on every other frame so that there's a greater chance + // of two subsequent frames being the same. + final sleepMs = _tries % 2 == 1 ? min(100, 10 * (_tries - 1)) : 0; + + if (_tries > 1) { + _options.logger( + SentryLevel.debug, + '${_recorder.logName}: ' + 'Retrying screenshot capture due to UI changes. ' + 'Delay before next capture: $sleepMs ms.'); + } + + if (sleepMs > 0) { + await Future.delayed(Duration(milliseconds: sleepMs)); + } + + final completer = Completer(); + ensureFrameAndAddCallback((Duration sinceSchedulerEpoch) async { + _tries++; + try { + await _recorder.capture(_onImageCaptured, screenshot.flow); + completer.complete(); + } catch (e, stackTrace) { + completer.completeError(e, stackTrace); + } + }); + return completer.future; + } + } +} + +@internal +enum FrameSchedulingMode { + /// The frame is scheduled only if the UI is visible. + /// If you await for the callback, it may take indefinitely long if the + /// app is in the background. + normal, + + /// A forced frame is scheduled immediately regardless of the UI visibility. + forced, +} diff --git a/flutter/test/event_processor/screenshot_event_processor_test.dart b/flutter/test/event_processor/screenshot_event_processor_test.dart index b544c9a114..dae0bf468d 100644 --- a/flutter/test/event_processor/screenshot_event_processor_test.dart +++ b/flutter/test/event_processor/screenshot_event_processor_test.dart @@ -11,6 +11,8 @@ import 'package:sentry_flutter/src/renderer/renderer.dart'; import '../mocks.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import '../replay/replay_test_util.dart'; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); late Fixture fixture; @@ -40,7 +42,7 @@ void main() { final throwable = Exception(); event = SentryEvent(throwable: throwable); hint = Hint(); - await sut.apply(event, hint); + await tester.pumpAndWaitUntil(sut.apply(event, hint)); expect(hint.screenshot != null, added); if (expectedMaxWidthOrHeight != null) { @@ -60,6 +62,33 @@ void main() { await _addScreenshotAttachment(tester, null, added: true, isWeb: false); }); + testWidgets('adds screenshot attachment with masking enabled dart:io', + (tester) async { + fixture.options.experimental.privacy.maskAllText = true; + await _addScreenshotAttachment(tester, null, added: true, isWeb: false); + }); + + testWidgets('does not block if the screenshot fails to stabilize', + (tester) async { + fixture.options.automatedTestMode = false; + fixture.options.experimental.privacy.maskAllText = true; + // Run with real async https://stackoverflow.com/a/54021863 + await tester.runAsync(() async { + final sut = fixture.getSut(null, false); + + await tester.pumpWidget(SentryScreenshotWidget( + child: Text('Catching Pokémon is a snap!', + textDirection: TextDirection.ltr))); + + final throwable = Exception(); + event = SentryEvent(throwable: throwable); + hint = Hint(); + await sut.apply(event, hint); + + expect(hint.screenshot, isNull); + }); + }); + testWidgets('adds screenshot attachment with canvasKit renderer', (tester) async { await _addScreenshotAttachment(tester, FlutterRenderer.canvasKit, diff --git a/flutter/test/replay/replay_native_test.dart b/flutter/test/replay/replay_native_test.dart index 424da6706c..983f4274c6 100644 --- a/flutter/test/replay/replay_native_test.dart +++ b/flutter/test/replay/replay_native_test.dart @@ -18,6 +18,7 @@ import 'package:sentry_flutter/src/native/sentry_native_binding.dart'; import '../mocks.dart'; import '../mocks.mocks.dart'; import '../screenshot/test_widget.dart'; +import 'replay_test_util.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -80,6 +81,7 @@ void main() { testWidgets('sets replayID to context', (tester) async { await tester.runAsync(() async { + await pumpTestElement(tester); // verify there was no scope configured before verifyNever(hub.configureScope(any)); when(hub.configureScope(captureAny)).thenReturn(null); @@ -90,8 +92,8 @@ void main() { ? 'ReplayRecorder.start' : 'captureReplayScreenshot', replayConfig); - await tester.pumpAndSettle(const Duration(seconds: 1)); - await future; + tester.binding.scheduleFrame(); + await tester.pumpAndWaitUntil(future); // verify the replay ID was set final closure = @@ -126,20 +128,12 @@ void main() { when(hub.configureScope(captureAny)).thenReturn(null); await pumpTestElement(tester); - pumpAndSettle() => tester.pumpAndSettle(const Duration(seconds: 1)); - if (mockPlatform.isAndroid) { var callbackFinished = Completer(); nextFrame({bool wait = true}) async { final future = callbackFinished.future; - await pumpAndSettle(); - await future.timeout(Duration(milliseconds: wait ? 1000 : 100), - onTimeout: () { - if (wait) { - fail('native callback not called'); - } - }); + await tester.pumpAndWaitUntil(future, requiredToComplete: wait); } imageSizeBytes(File file) => file.readAsBytesSync().length; @@ -210,8 +204,8 @@ void main() { Future captureAndVerify() async { final future = native.invokeFromNative( 'captureReplayScreenshot', replayConfig); - await pumpAndSettle(); - final json = (await future) as Map; + final json = (await tester.pumpAndWaitUntil(future)) + as Map; expect(json['length'], greaterThan(3000)); expect(json['address'], greaterThan(0)); diff --git a/flutter/test/replay/replay_test_util.dart b/flutter/test/replay/replay_test_util.dart new file mode 100644 index 0000000000..230af1cb1e --- /dev/null +++ b/flutter/test/replay/replay_test_util.dart @@ -0,0 +1,30 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; + +extension ReplayWidgetTesterUtil on WidgetTester { + Future pumpAndWaitUntil(Future future, + {bool requiredToComplete = true}) async { + final timeout = + requiredToComplete ? Duration(seconds: 10) : Duration(seconds: 1); + final startTime = DateTime.now(); + bool completed = false; + do { + await pumpAndSettle(const Duration(seconds: 1)); + await Future.delayed(const Duration(milliseconds: 1)); + completed = await future + .then((v) => true) + .timeout(const Duration(milliseconds: 10), onTimeout: () => false); + } while (!completed && DateTime.now().difference(startTime) < timeout); + + if (requiredToComplete) { + if (!completed) { + throw TimeoutException( + 'Future not completed', DateTime.now().difference(startTime)); + } + return future; + } else { + return Future.value(null); + } + } +} diff --git a/flutter/test/replay/scheduled_recorder_test.dart b/flutter/test/replay/scheduled_recorder_test.dart index dffd21592d..add5c67278 100644 --- a/flutter/test/replay/scheduled_recorder_test.dart +++ b/flutter/test/replay/scheduled_recorder_test.dart @@ -4,7 +4,6 @@ library dart_test; import 'dart:async'; -import 'dart:developer'; import 'package:flutter_test/flutter_test.dart'; import 'package:sentry_flutter/src/replay/scheduled_recorder.dart'; @@ -12,6 +11,7 @@ import 'package:sentry_flutter/src/replay/scheduled_recorder_config.dart'; import '../mocks.dart'; import '../screenshot/test_widget.dart'; +import 'replay_test_util.dart'; void main() async { TestWidgetsFlutterBinding.ensureInitialized(); @@ -34,14 +34,14 @@ void main() async { class _Fixture { final WidgetTester _tester; - late final _TestScheduledRecorder _sut; + late final ScheduledScreenshotRecorder _sut; final capturedImages = []; late Completer _completer; ScheduledScreenshotRecorder get sut => _sut; _Fixture._(this._tester) { - _sut = _TestScheduledRecorder( + _sut = ScheduledScreenshotRecorder( ScheduledScreenshotRecorderConfig( width: 1000, height: 1000, @@ -50,9 +50,7 @@ class _Fixture { defaultTestOptions()..bindingUtils = TestBindingWrapper(), (image, isNewlyCaptured) async { capturedImages.add('${image.width}x${image.height}'); - if (!_completer.isCompleted) { - _completer.complete(); - } + _completer.complete(); }, ); } @@ -67,19 +65,8 @@ class _Fixture { Future nextFrame(bool imageIsExpected) async { _completer = Completer(); _tester.binding.scheduleFrame(); - await _tester.pumpAndSettle(const Duration(seconds: 1)); - if (imageIsExpected) { - await _completer.future.timeout(Duration(seconds: 10)); - } - } -} - -class _TestScheduledRecorder extends ScheduledScreenshotRecorder { - _TestScheduledRecorder(super.config, super.options, super.callback); - - @override - Future executeTask(void Function() task, Flow flow) { - task(); - return Future.value(); + await _tester.pumpAndWaitUntil(_completer.future, + requiredToComplete: imageIsExpected); + expect(_completer.isCompleted, imageIsExpected); } } diff --git a/flutter/test/replay/scheduler_test.dart b/flutter/test/replay/scheduler_test.dart index 300fa25280..bd82a28910 100644 --- a/flutter/test/replay/scheduler_test.dart +++ b/flutter/test/replay/scheduler_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/scheduler.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sentry_flutter/src/replay/scheduler.dart'; @@ -32,8 +34,9 @@ void main() { await fixture.drawFrame(); expect(fixture.calls, 1); await fixture.drawFrame(); + expect(fixture.calls, 2); await fixture.sut.stop(); - await fixture.drawFrame(); + await fixture.drawFrame(awaitCallback: false); expect(fixture.calls, 2); }); @@ -45,41 +48,68 @@ void main() { await fixture.drawFrame(); expect(fixture.calls, 1); await fixture.sut.stop(); - await fixture.drawFrame(); + await fixture.drawFrame(awaitCallback: false); expect(fixture.calls, 1); fixture.sut.start(); await fixture.drawFrame(); expect(fixture.calls, 2); }); + + test('does not trigger until previous call finished', () async { + final guard = Completer(); + var fixture = _Fixture((_) async => guard.future); + + fixture.sut.start(); + + expect(fixture.calls, 0); + await fixture.drawFrame(); + expect(fixture.calls, 1); + await fixture.drawFrame(awaitCallback: false); + expect(fixture.calls, 1); + + guard.complete(); + await fixture.drawFrame(); + expect(fixture.calls, 2); + }); } class _Fixture { var calls = 0; late final Scheduler sut; - FrameCallback? registeredCallback; + var registeredCallback = Completer(); var _frames = 0; - _Fixture() { + _Fixture([SchedulerCallback? callback]) { sut = Scheduler( const Duration(milliseconds: 1), - (_) async => calls++, + (timestamp) async { + calls++; + await callback?.call(timestamp); + }, _addPostFrameCallbackMock, ); } void _addPostFrameCallbackMock(FrameCallback callback, {String debugLabel = 'callback'}) { - registeredCallback = callback; + if (!registeredCallback.isCompleted) { + registeredCallback.complete(callback); + } } factory _Fixture.started() { return _Fixture()..sut.start(); } - Future drawFrame() async { - await Future.delayed(const Duration(milliseconds: 8), () {}); - _frames++; - registeredCallback?.call(Duration(milliseconds: _frames)); - registeredCallback = null; + Future drawFrame({bool awaitCallback = true}) async { + registeredCallback = Completer(); + final timestamp = Duration(milliseconds: ++_frames); + final future = registeredCallback.future.then((fn) => fn(timestamp)); + if (awaitCallback) { + return future; + } else { + return future.timeout(const Duration(milliseconds: 200), + onTimeout: () {}); + } } } diff --git a/flutter/test/screenshot/recorder_test.dart b/flutter/test/screenshot/recorder_test.dart index 109b32e575..85ff006748 100644 --- a/flutter/test/screenshot/recorder_test.dart +++ b/flutter/test/screenshot/recorder_test.dart @@ -3,6 +3,7 @@ @TestOn('vm') library dart_test; +import 'dart:typed_data'; import 'dart:ui'; import 'package:flutter/widgets.dart' as widgets; @@ -11,6 +12,7 @@ import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/replay/replay_recorder.dart'; import 'package:sentry_flutter/src/screenshot/recorder.dart'; import 'package:sentry_flutter/src/screenshot/recorder_config.dart'; +import 'package:sentry_flutter/src/screenshot/screenshot.dart'; import '../mocks.dart'; import 'test_widget.dart'; @@ -23,55 +25,68 @@ void main() async { // The `device screen resolution = logical resolution * devicePixelRatio` testWidgets('captures full resolution images - portrait', (tester) async { - await tester.binding.setSurfaceSize(Size(2000, 4000)); - final fixture = await _Fixture.create(tester); + await tester.runAsync(() async { + await tester.binding.setSurfaceSize(Size(20, 40)); + final fixture = await _Fixture.create(tester); - //devicePixelRatio is 3.0 therefore the resolution multiplied by 3 - expect(await fixture.capture(), '6000x12000'); + //devicePixelRatio is 3.0 therefore the resolution multiplied by 3 + expect(await fixture.capture(), '60x120'); + }); }); testWidgets('captures full resolution images - landscape', (tester) async { - await tester.binding.setSurfaceSize(Size(4000, 2000)); - final fixture = await _Fixture.create(tester); + await tester.runAsync(() async { + await tester.binding.setSurfaceSize(Size(40, 20)); + final fixture = await _Fixture.create(tester); - //devicePixelRatio is 3.0 therefore the resolution multiplied by 3 - expect(await fixture.capture(), '12000x6000'); + //devicePixelRatio is 3.0 therefore the resolution multiplied by 3 + expect(await fixture.capture(), '120x60'); + }); }); testWidgets('captures high resolution images - portrait', (tester) async { - await tester.binding.setSurfaceSize(Size(2000, 4000)); - final targetResolution = SentryScreenshotQuality.high.targetResolution(); - final fixture = await _Fixture.create(tester, - width: targetResolution, height: targetResolution); + await tester.runAsync(() async { + await tester.binding.setSurfaceSize(Size(20, 40)); + final targetResolution = SentryScreenshotQuality.high.targetResolution(); + final fixture = await _Fixture.create(tester, + width: targetResolution, height: targetResolution); - expect(await fixture.capture(), '960x1920'); + expect(await fixture.capture(), '960x1920'); + }); }); testWidgets('captures high resolution images - landscape', (tester) async { - await tester.binding.setSurfaceSize(Size(4000, 2000)); - final targetResolution = SentryScreenshotQuality.high.targetResolution(); - final fixture = await _Fixture.create(tester, - width: targetResolution, height: targetResolution); + await tester.runAsync(() async { + await tester.binding.setSurfaceSize(Size(40, 20)); + final targetResolution = SentryScreenshotQuality.high.targetResolution(); + final fixture = await _Fixture.create(tester, + width: targetResolution, height: targetResolution); - expect(await fixture.capture(), '1920x960'); + expect(await fixture.capture(), '1920x960'); + }); }); testWidgets('captures medium resolution images', (tester) async { - await tester.binding.setSurfaceSize(Size(2000, 4000)); - final targetResolution = SentryScreenshotQuality.medium.targetResolution(); - final fixture = await _Fixture.create(tester, - width: targetResolution, height: targetResolution); - - expect(await fixture.capture(), '640x1280'); + await tester.runAsync(() async { + await tester.binding.setSurfaceSize(Size(20, 40)); + final targetResolution = + SentryScreenshotQuality.medium.targetResolution(); + final fixture = await _Fixture.create(tester, + width: targetResolution, height: targetResolution); + + expect(await fixture.capture(), '640x1280'); + }); }); testWidgets('captures low resolution images', (tester) async { - await tester.binding.setSurfaceSize(Size(2000, 4000)); - final targetResolution = SentryScreenshotQuality.low.targetResolution(); - final fixture = await _Fixture.create(tester, - width: targetResolution, height: targetResolution); + await tester.runAsync(() async { + await tester.binding.setSurfaceSize(Size(20, 40)); + final targetResolution = SentryScreenshotQuality.low.targetResolution(); + final fixture = await _Fixture.create(tester, + width: targetResolution, height: targetResolution); - expect(await fixture.capture(), '427x854'); + expect(await fixture.capture(), '427x854'); + }); }); testWidgets('propagates errors in test-mode', (tester) async { @@ -123,6 +138,64 @@ void main() async { expect(sut.hasWidgetFilter, isTrue); }); }); + + group('$Screenshot', () { + test('listEquals()', () { + expect( + Screenshot.listEquals( + Uint8List(0).buffer.asByteData(), + Uint8List(0).buffer.asByteData(), + ), + isTrue); + expect( + Screenshot.listEquals( + Uint8List.fromList([1, 2, 3]).buffer.asByteData(), + Uint8List.fromList([1, 2, 3]).buffer.asByteData(), + ), + isTrue); + expect( + Screenshot.listEquals( + Uint8List.fromList([1, 0, 3]).buffer.asByteData(), + Uint8List.fromList([1, 2, 3]).buffer.asByteData(), + ), + isFalse); + expect( + Screenshot.listEquals( + Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8]).buffer.asByteData(), + Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8]).buffer.asByteData(), + ), + isTrue); + expect( + Screenshot.listEquals( + Uint8List.fromList([1, 2, 3, 4, 5, 6, 0, 8]).buffer.asByteData(), + Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8]).buffer.asByteData(), + ), + isFalse); + expect( + Screenshot.listEquals( + Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8, 9]).buffer.asByteData(), + Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8, 9]).buffer.asByteData(), + ), + isTrue); + expect( + Screenshot.listEquals( + Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8, 9]).buffer.asByteData(), + Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8, 0]).buffer.asByteData(), + ), + isFalse); + + final dataA = Uint8List.fromList( + List.generate(10 * 1000 * 1000, (index) => index % 256)) + .buffer + .asByteData(); + final dataB = ByteData(dataA.lengthInBytes) + ..buffer.asUint8List().setAll(0, dataA.buffer.asUint8List()); + expect(Screenshot.listEquals(dataA, dataB), isTrue); + + dataB.setInt8(dataB.lengthInBytes >> 2, 0); + expect(Screenshot.listEquals(dataA, dataB), isFalse); + }); + }); } class _Fixture { @@ -142,8 +215,7 @@ class _Fixture { return fixture; } - Future capture() => sut.capture((Screenshot screenshot) { - final image = screenshot.image; - return Future.value("${image.width}x${image.height}"); + Future capture() => sut.capture((screenshot) { + return Future.value("${screenshot.width}x${screenshot.height}"); }); } diff --git a/metrics/metrics-ios.yml b/metrics/metrics-ios.yml index 6c7e175b04..2aca441660 100644 --- a/metrics/metrics-ios.yml +++ b/metrics/metrics-ios.yml @@ -10,5 +10,5 @@ startupTimeTest: diffMax: 150 binarySizeTest: - diffMin: 1200 KiB - diffMax: 1500 KiB + diffMin: 1400 KiB + diffMax: 1600 KiB