Skip to content

Commit 3d722ba

Browse files
committed
fix: repost replay screenshots on android while idle
1 parent 3adbea9 commit 3d722ba

File tree

2 files changed

+142
-42
lines changed

2 files changed

+142
-42
lines changed

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

Lines changed: 113 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'dart:ui';
22

3+
import 'package:flutter/services.dart';
34
import 'package:meta/meta.dart';
45

56
import '../../../sentry_flutter.dart';
@@ -13,6 +14,8 @@ import '../sentry_native_channel.dart';
1314
@internal
1415
class SentryNativeJava extends SentryNativeChannel {
1516
ScheduledScreenshotRecorder? _replayRecorder;
17+
String? _replayCacheDir;
18+
_IdleFrameFiller? _idleFrameFiller;
1619
SentryNativeJava(super.options, super.channel);
1720

1821
@override
@@ -47,8 +50,7 @@ class SentryNativeJava extends SentryNativeChannel {
4750

4851
break;
4952
case 'ReplayRecorder.stop':
50-
await _replayRecorder?.stop();
51-
_replayRecorder = null;
53+
await _stopRecorder();
5254

5355
hub.configureScope((s) {
5456
// ignore: invalid_use_of_internal_member
@@ -58,9 +60,11 @@ class SentryNativeJava extends SentryNativeChannel {
5860
break;
5961
case 'ReplayRecorder.pause':
6062
await _replayRecorder?.stop();
63+
await _idleFrameFiller?.pause();
6164
break;
6265
case 'ReplayRecorder.resume':
6366
_replayRecorder?.start();
67+
await _idleFrameFiller?.resume();
6468
break;
6569
default:
6670
throw UnimplementedError('Method ${call.method} not implemented');
@@ -73,13 +77,22 @@ class SentryNativeJava extends SentryNativeChannel {
7377

7478
@override
7579
Future<void> close() async {
80+
await _stopRecorder();
81+
return super.close();
82+
}
83+
84+
Future<void> _stopRecorder() async {
7685
await _replayRecorder?.stop();
86+
await _idleFrameFiller?.stop();
7787
_replayRecorder = null;
78-
return super.close();
88+
_idleFrameFiller = null;
7989
}
8090

8191
void _startRecorder(
8292
String cacheDir, ScheduledScreenshotRecorderConfig config) {
93+
_idleFrameFiller = _IdleFrameFiller(
94+
Duration(milliseconds: 1000 ~/ config.frameRate), _addReplayScreenshot);
95+
8396
// Note: time measurements using a Stopwatch in a debug build:
8497
// save as rawRgba (1230876 bytes): 0.257 ms -- discarded
8598
// save as PNG (25401 bytes): 43.110 ms -- used for the final image
@@ -90,39 +103,107 @@ class SentryNativeJava extends SentryNativeChannel {
90103
ScreenshotRecorderCallback callback = (image) async {
91104
var imageData = await image.toByteData(format: ImageByteFormat.png);
92105
if (imageData != null) {
93-
final timestamp = DateTime.now().millisecondsSinceEpoch;
94-
final filePath = "$cacheDir/$timestamp.png";
95-
96-
options.logger(
97-
SentryLevel.debug,
98-
'Replay: Saving screenshot to $filePath ('
99-
'${image.width}x${image.height} pixels, '
100-
'${imageData.lengthInBytes} bytes)');
101-
try {
102-
await options.fileSystem
103-
.file(filePath)
104-
.writeAsBytes(imageData.buffer.asUint8List(), flush: true);
105-
106-
await channel.invokeMethod(
107-
'addReplayScreenshot',
108-
{'path': filePath, 'timestamp': timestamp},
109-
);
110-
} catch (error, stackTrace) {
111-
options.logger(
112-
SentryLevel.error,
113-
'Native call `addReplayScreenshot` failed',
114-
exception: error,
115-
stackTrace: stackTrace,
116-
);
117-
// ignore: invalid_use_of_internal_member
118-
if (options.automatedTestMode) {
119-
rethrow;
120-
}
121-
}
106+
final screenshot = _Screenshot(image.width, image.height, imageData);
107+
await _addReplayScreenshot(screenshot);
108+
_idleFrameFiller?.actualFrameReceived(screenshot);
122109
}
123110
};
124111

112+
_replayCacheDir = cacheDir;
125113
_replayRecorder = ScheduledScreenshotRecorder(config, callback, options)
126114
..start();
127115
}
116+
117+
Future<void> _addReplayScreenshot(_Screenshot screenshot) async {
118+
final timestamp = DateTime.now().millisecondsSinceEpoch;
119+
final filePath = "$_replayCacheDir/$timestamp.png";
120+
121+
options.logger(
122+
SentryLevel.debug,
123+
'Replay: Saving screenshot to $filePath ('
124+
'${screenshot.width}x${screenshot.height} pixels, '
125+
'${screenshot.data.lengthInBytes} bytes)');
126+
try {
127+
await options.fileSystem
128+
.file(filePath)
129+
.writeAsBytes(screenshot.data.buffer.asUint8List(), flush: true);
130+
131+
await channel.invokeMethod(
132+
'addReplayScreenshot',
133+
{'path': filePath, 'timestamp': timestamp},
134+
);
135+
} catch (error, stackTrace) {
136+
options.logger(
137+
SentryLevel.error,
138+
'Native call `addReplayScreenshot` failed',
139+
exception: error,
140+
stackTrace: stackTrace,
141+
);
142+
// ignore: invalid_use_of_internal_member
143+
if (options.automatedTestMode) {
144+
rethrow;
145+
}
146+
}
147+
}
148+
}
149+
150+
class _Screenshot {
151+
final int width;
152+
final int height;
153+
final ByteData data;
154+
155+
_Screenshot(this.width, this.height, this.data);
156+
}
157+
158+
// Workaround for https://github.com/getsentry/sentry-java/issues/3677
159+
// In short: when there are no postFrameCallbacks issued by Flutter (because
160+
// there are no animations or user interactions), the replay recorder will
161+
// need to get screenshots at a fixed frame rate. This class is responsible for
162+
// filling the gaps between actual frames with the most recent frame.
163+
class _IdleFrameFiller {
164+
final Duration _interval;
165+
final Future<void> Function(_Screenshot screenshot) _callback;
166+
bool running = true;
167+
Future<void>? _scheduled;
168+
_Screenshot? _mostRecent;
169+
170+
_IdleFrameFiller(this._interval, this._callback);
171+
172+
void actualFrameReceived(_Screenshot screenshot) {
173+
// We store the most recent frame but only repost it when the most recent
174+
// one is the same instance (unchanged).
175+
_mostRecent = screenshot;
176+
// Also, the initial reposted frame will be delayed to allow actual frames
177+
// to cancel the reposting.
178+
repostLater(_interval * 1.5, screenshot);
179+
}
180+
181+
Future<void> stop() async {
182+
// Clearing [_mostRecent] stops the delayed callback from posting the image.
183+
_mostRecent = null;
184+
running = false;
185+
await _scheduled;
186+
_scheduled = null;
187+
}
188+
189+
Future<void> pause() async {
190+
running = false;
191+
}
192+
193+
Future<void> resume() async {
194+
running = true;
195+
}
196+
197+
void repostLater(Duration delay, _Screenshot screenshot) {
198+
_scheduled = Future.delayed(delay, () async {
199+
// Only repost if the screenshot haven't changed.
200+
if (screenshot == _mostRecent) {
201+
if (running) {
202+
await _callback(screenshot);
203+
}
204+
// On subsequent frames, we stick to the actual frame rate.
205+
repostLater(_interval, screenshot);
206+
}
207+
});
208+
}
128209
}

flutter/test/replay/replay_native_test.dart

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ void main() {
4646
'directory': 'dir',
4747
'width': 800,
4848
'height': 600,
49-
'frameRate': 1000,
49+
'frameRate': 10,
5050
};
5151
}
5252

@@ -142,16 +142,15 @@ void main() {
142142
var callbackFinished = Completer<void>();
143143

144144
nextFrame({bool wait = true}) async {
145+
final future = callbackFinished.future;
145146
tester.binding.scheduleFrame();
146-
await Future<void>.delayed(const Duration(milliseconds: 100));
147147
await tester.pumpAndSettle(const Duration(seconds: 1));
148-
await callbackFinished.future.timeout(
149-
Duration(milliseconds: wait ? 1000 : 100), onTimeout: () {
148+
await future.timeout(Duration(milliseconds: wait ? 1000 : 100),
149+
onTimeout: () {
150150
if (wait) {
151151
fail('native callback not called');
152152
}
153153
});
154-
callbackFinished = Completer<void>();
155154
}
156155

157156
imageInfo(File file) => file.readAsBytesSync().length;
@@ -162,10 +161,11 @@ void main() {
162161
final capturedImages = <String, int>{};
163162
when(native.handler('addReplayScreenshot', any))
164163
.thenAnswer((invocation) async {
165-
callbackFinished.complete();
166164
final path =
167165
invocation.positionalArguments[1]["path"] as String;
168166
capturedImages[path] = imageInfo(fs.file(path));
167+
callbackFinished.complete();
168+
callbackFinished = Completer<void>();
169169
return null;
170170
});
171171

@@ -191,18 +191,37 @@ void main() {
191191
expect(capturedImages, equals(fsImages()));
192192

193193
await nextFrame();
194-
expect(fsImages().values, [size, size]);
194+
fsImages().values.forEach((s) => expect(s, size));
195195
expect(capturedImages, equals(fsImages()));
196196

197-
await native.invokeFromNative('ReplayRecorder.stop');
197+
await native.invokeFromNative('ReplayRecorder.pause');
198+
var count = capturedImages.length;
199+
200+
await nextFrame(wait: false);
201+
await Future<void>.delayed(const Duration(milliseconds: 100));
202+
fsImages().values.forEach((s) => expect(s, size));
203+
expect(capturedImages, equals(fsImages()));
204+
expect(capturedImages.length, count);
198205

199206
await nextFrame(wait: false);
200-
expect(fsImages().values, [size, size]);
207+
fsImages().values.forEach((s) => expect(s, size));
201208
expect(capturedImages, equals(fsImages()));
209+
expect(capturedImages.length, count);
202210

211+
await native.invokeFromNative('ReplayRecorder.resume');
212+
213+
await nextFrame();
214+
fsImages().values.forEach((s) => expect(s, size));
215+
expect(capturedImages, equals(fsImages()));
216+
expect(capturedImages.length, greaterThan(count));
217+
218+
await native.invokeFromNative('ReplayRecorder.stop');
219+
count = capturedImages.length;
220+
await Future<void>.delayed(const Duration(milliseconds: 100));
203221
await nextFrame(wait: false);
204-
expect(fsImages().values, [size, size]);
222+
fsImages().values.forEach((s) => expect(s, size));
205223
expect(capturedImages, equals(fsImages()));
224+
expect(capturedImages.length, count);
206225
} else if (mockPlatform.isIOS) {
207226
// configureScope() is called on iOS
208227
when(hub.configureScope(captureAny)).thenReturn(null);

0 commit comments

Comments
 (0)