Skip to content

Commit a80fbd1

Browse files
authored
fix/screenshot masking during changes (#2553)
* capture screenshots in sequence until they're stable * improve timestamps * fix tests & future handling * example: debug log only in debug mode * formatting * min-version compat * linter issue * fix scheduler tests * cleanup * fix: scheduler test * improve performance * improve screenshot comparison performance * comments * screenshot list-equal fix & tests * min-version failure * rename retrier to stabilizer * chore: changelog * capture stable issue screenshots when masking is enabled * fixes and tests * better logging * cocoa replay fix * fix dart2js tests * fix screenshot capture hanging * fix integration test * time out if we can't take a screenshot for events * update ios app size metrics check * fix: oom
1 parent 0c08054 commit a80fbd1

21 files changed

+637
-215
lines changed

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,16 @@
66

77
- Add `beforeCapture` for View Hierarchy ([#2523](https://github.com/getsentry/sentry-dart/pull/2523))
88
- View hierarchy calls are now debounced for 2 seconds.
9-
9+
1010
### Enhancements
1111

1212
- Replay: improve iOS native interop performance ([#2530](https://github.com/getsentry/sentry-dart/pull/2530))
1313
- Replay: improve orientation change tracking accuracy on Android ([#2540](https://github.com/getsentry/sentry-dart/pull/2540))
1414

15+
### Fixes
16+
17+
- Replay: fix masking for frames captured during UI changes ([#2553](https://github.com/getsentry/sentry-dart/pull/2553))
18+
1519
### Dependencies
1620

1721
- 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))
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
description: This file stores settings for Dart & Flutter DevTools.
2+
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
3+
extensions:

flutter/example/integration_test/integration_test.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ void main() {
2020
const fakeDsn = 'https://[email protected]/1234567';
2121

2222
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
23+
IntegrationTestWidgetsFlutterBinding.instance.framePolicy =
24+
LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
2325

2426
tearDown(() async {
2527
await Sentry.close();

flutter/example/lib/main.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ Future<void> setupSentry(
7979
// We can enable Sentry debug logging during development. This is likely
8080
// going to log too much for your app, but can be useful when figuring out
8181
// configuration issues, e.g. finding out why your events are not uploaded.
82-
options.debug = true;
82+
options.debug = kDebugMode;
8383
options.spotlight = Spotlight(enabled: true);
8484
options.enableTimeToFullDisplayTracing = true;
8585
options.enableMetrics = true;

flutter/lib/src/event_processor/screenshot_event_processor.dart

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import '../screenshot/recorder.dart';
99
import '../screenshot/recorder_config.dart';
1010
import 'package:flutter/widgets.dart' as widget;
1111

12+
import '../screenshot/stabilizer.dart';
1213
import '../utils/debouncer.dart';
1314

1415
class ScreenshotEventProcessor implements EventProcessor {
@@ -125,20 +126,37 @@ class ScreenshotEventProcessor implements EventProcessor {
125126
}
126127

127128
@internal
128-
Future<Uint8List?> createScreenshot() =>
129-
_recorder.capture(_convertImageToUint8List);
130-
131-
Future<Uint8List?> _convertImageToUint8List(Screenshot screenshot) async {
132-
final byteData =
133-
await screenshot.image.toByteData(format: ImageByteFormat.png);
134-
135-
final bytes = byteData?.buffer.asUint8List();
136-
if (bytes?.isNotEmpty == true) {
137-
return bytes;
129+
Future<Uint8List?> createScreenshot() async {
130+
if (_options.experimental.privacyForScreenshots == null) {
131+
return _recorder.capture((screenshot) =>
132+
screenshot.pngData.then((v) => v.buffer.asUint8List()));
138133
} else {
139-
_options.logger(
140-
SentryLevel.debug, 'Screenshot is 0 bytes, not attaching the image.');
141-
return null;
134+
// If masking is enabled, we need to use [ScreenshotStabilizer].
135+
final completer = Completer<Uint8List?>();
136+
final stabilizer = ScreenshotStabilizer(
137+
_recorder, _options,
138+
(screenshot) async {
139+
final pngData = await screenshot.pngData;
140+
completer.complete(pngData.buffer.asUint8List());
141+
},
142+
// This limits the amount of time to take a stable masked screenshot.
143+
maxTries: 5,
144+
// We need to force the frame the frame or this could hang indefinitely.
145+
frameSchedulingMode: FrameSchedulingMode.forced,
146+
);
147+
try {
148+
unawaited(
149+
stabilizer.capture(Duration.zero).onError(completer.completeError));
150+
// DO NOT return completer.future directly - we need to dispose first.
151+
return await completer.future.timeout(const Duration(seconds: 1),
152+
onTimeout: () {
153+
_options.logger(
154+
SentryLevel.warning, 'Timed out taking a stable screenshot.');
155+
return null;
156+
});
157+
} finally {
158+
stabilizer.dispose();
159+
}
142160
}
143161
}
144162
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import 'dart:async';
2+
import 'dart:typed_data';
3+
4+
import 'package:meta/meta.dart';
5+
6+
import '../../../sentry_flutter.dart';
7+
import '../../replay/replay_recorder.dart';
8+
import '../../screenshot/recorder.dart';
9+
import '../../screenshot/recorder_config.dart';
10+
import '../../screenshot/stabilizer.dart';
11+
12+
@internal
13+
class CocoaReplayRecorder {
14+
final SentryFlutterOptions _options;
15+
final ScreenshotRecorder _recorder;
16+
late final ScreenshotStabilizer<void> _stabilizer;
17+
var _completer = Completer<Uint8List?>();
18+
19+
CocoaReplayRecorder(this._options)
20+
: _recorder =
21+
ReplayScreenshotRecorder(ScreenshotRecorderConfig(), _options) {
22+
_stabilizer = ScreenshotStabilizer(_recorder, _options, (screenshot) async {
23+
final pngData = await screenshot.pngData;
24+
_options.logger(
25+
SentryLevel.debug,
26+
'Replay: captured screenshot ('
27+
'${screenshot.width}x${screenshot.height} pixels, '
28+
'${pngData.lengthInBytes} bytes)');
29+
_completer.complete(pngData.buffer.asUint8List());
30+
});
31+
}
32+
33+
Future<Uint8List?> captureScreenshot() async {
34+
_completer = Completer<Uint8List?>();
35+
_stabilizer.ensureFrameAndAddCallback((msSinceEpoch) {
36+
_stabilizer.capture(msSinceEpoch).onError(_completer.completeError);
37+
});
38+
return _completer.future;
39+
}
40+
}

flutter/lib/src/native/cocoa/sentry_native_cocoa.dart

Lines changed: 5 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,19 @@
11
import 'dart:async';
22
import 'dart:ffi';
3-
import 'dart:typed_data';
4-
import 'dart:ui';
53

64
import 'package:meta/meta.dart';
75

86
import '../../../sentry_flutter.dart';
97
import '../../replay/replay_config.dart';
10-
import '../../replay/replay_recorder.dart';
11-
import '../../screenshot/recorder.dart';
12-
import '../../screenshot/recorder_config.dart';
138
import '../native_memory.dart';
149
import '../sentry_native_channel.dart';
1510
import 'binding.dart' as cocoa;
11+
import 'cocoa_replay_recorder.dart';
1612

1713
@internal
1814
class SentryNativeCocoa extends SentryNativeChannel {
1915
late final _lib = cocoa.SentryCocoa(DynamicLibrary.process());
20-
ScreenshotRecorder? _replayRecorder;
16+
CocoaReplayRecorder? _replayRecorder;
2117
SentryId? _replayId;
2218

2319
SentryNativeCocoa(super.options);
@@ -33,12 +29,12 @@ class SentryNativeCocoa extends SentryNativeChannel {
3329
channel.setMethodCallHandler((call) async {
3430
switch (call.method) {
3531
case 'captureReplayScreenshot':
36-
_replayRecorder ??=
37-
ReplayScreenshotRecorder(ScreenshotRecorderConfig(), options);
32+
_replayRecorder ??= CocoaReplayRecorder(options);
3833

3934
final replayId = call.arguments['replayId'] == null
4035
? null
4136
: SentryId.fromId(call.arguments['replayId'] as String);
37+
4238
if (_replayId != replayId) {
4339
_replayId = replayId;
4440
hub.configureScope((s) {
@@ -47,35 +43,7 @@ class SentryNativeCocoa extends SentryNativeChannel {
4743
});
4844
}
4945

50-
final widgetsBinding = options.bindingUtils.instance;
51-
if (widgetsBinding == null) {
52-
options.logger(SentryLevel.warning,
53-
'Replay: failed to capture screenshot, WidgetsBinding.instance is null');
54-
return null;
55-
}
56-
57-
final completer = Completer<Uint8List?>();
58-
widgetsBinding.ensureVisualUpdate();
59-
widgetsBinding.addPostFrameCallback((_) {
60-
_replayRecorder?.capture((screenshot) async {
61-
final image = screenshot.image;
62-
final imageData =
63-
await image.toByteData(format: ImageByteFormat.png);
64-
if (imageData != null) {
65-
options.logger(
66-
SentryLevel.debug,
67-
'Replay: captured screenshot ('
68-
'${image.width}x${image.height} pixels, '
69-
'${imageData.lengthInBytes} bytes)');
70-
return imageData.buffer.asUint8List();
71-
} else {
72-
options.logger(SentryLevel.warning,
73-
'Replay: failed to convert screenshot to PNG');
74-
}
75-
}).then(completer.complete, onError: completer.completeError);
76-
});
77-
final uint8List = await completer.future;
78-
46+
final uint8List = await _replayRecorder!.captureScreenshot();
7947
// Malloc memory and copy the data. Native must free it.
8048
return uint8List?.toNativeMemory().toJson();
8149
default:

flutter/lib/src/native/java/android_replay_recorder.dart

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'package:meta/meta.dart';
22

33
import '../../../sentry_flutter.dart';
44
import '../../replay/scheduled_recorder.dart';
5+
import '../../screenshot/screenshot.dart';
56
import '../sentry_safe_method_channel.dart';
67

78
@internal
@@ -15,19 +16,20 @@ class AndroidReplayRecorder extends ScheduledScreenshotRecorder {
1516
}
1617

1718
Future<void> _addReplayScreenshot(
18-
ScreenshotPng screenshot, bool isNewlyCaptured) async {
19+
Screenshot screenshot, bool isNewlyCaptured) async {
1920
final timestamp = screenshot.timestamp.millisecondsSinceEpoch;
2021
final filePath = "$_cacheDir/$timestamp.png";
2122

22-
options.logger(
23-
SentryLevel.debug,
24-
'$logName: saving ${isNewlyCaptured ? 'new' : 'repeated'} screenshot to'
25-
' $filePath (${screenshot.width}x${screenshot.height} pixels, '
26-
'${screenshot.data.lengthInBytes} bytes)');
2723
try {
24+
final pngData = await screenshot.pngData;
25+
options.logger(
26+
SentryLevel.debug,
27+
'$logName: saving ${isNewlyCaptured ? 'new' : 'repeated'} screenshot to'
28+
' $filePath (${screenshot.width}x${screenshot.height} pixels, '
29+
'${pngData.lengthInBytes} bytes)');
2830
await options.fileSystem
2931
.file(filePath)
30-
.writeAsBytes(screenshot.data.buffer.asUint8List(), flush: true);
32+
.writeAsBytes(pngData.buffer.asUint8List(), flush: true);
3133

3234
await _channel.invokeMethod(
3335
'addReplayScreenshot',

flutter/lib/src/replay/replay_recorder.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ class ReplayScreenshotRecorder extends ScreenshotRecorder {
1616

1717
@override
1818
@protected
19-
Future<void> executeTask(void Function() task, Flow flow) {
20-
// Future() schedules the task to be executed asynchronously with TImer.run.
19+
Future<void> executeTask(Future<void> Function() task, Flow flow) {
20+
// Future() schedules the task to be executed asynchronously with Timer.run.
2121
return Future(task);
2222
}
2323
}

0 commit comments

Comments
 (0)