1
1
import 'dart:ui' ;
2
2
3
+ import 'package:flutter/services.dart' ;
3
4
import 'package:meta/meta.dart' ;
4
5
5
6
import '../../../sentry_flutter.dart' ;
@@ -13,6 +14,8 @@ import '../sentry_native_channel.dart';
13
14
@internal
14
15
class SentryNativeJava extends SentryNativeChannel {
15
16
ScheduledScreenshotRecorder ? _replayRecorder;
17
+ String ? _replayCacheDir;
18
+ _IdleFrameFiller ? _idleFrameFiller;
16
19
SentryNativeJava (super .options, super .channel);
17
20
18
21
@override
@@ -47,8 +50,7 @@ class SentryNativeJava extends SentryNativeChannel {
47
50
48
51
break ;
49
52
case 'ReplayRecorder.stop' :
50
- await _replayRecorder? .stop ();
51
- _replayRecorder = null ;
53
+ await _stopRecorder ();
52
54
53
55
hub.configureScope ((s) {
54
56
// ignore: invalid_use_of_internal_member
@@ -58,9 +60,11 @@ class SentryNativeJava extends SentryNativeChannel {
58
60
break ;
59
61
case 'ReplayRecorder.pause' :
60
62
await _replayRecorder? .stop ();
63
+ await _idleFrameFiller? .pause ();
61
64
break ;
62
65
case 'ReplayRecorder.resume' :
63
66
_replayRecorder? .start ();
67
+ await _idleFrameFiller? .resume ();
64
68
break ;
65
69
default :
66
70
throw UnimplementedError ('Method ${call .method } not implemented' );
@@ -73,13 +77,22 @@ class SentryNativeJava extends SentryNativeChannel {
73
77
74
78
@override
75
79
Future <void > close () async {
80
+ await _stopRecorder ();
81
+ return super .close ();
82
+ }
83
+
84
+ Future <void > _stopRecorder () async {
76
85
await _replayRecorder? .stop ();
86
+ await _idleFrameFiller? .stop ();
77
87
_replayRecorder = null ;
78
- return super . close () ;
88
+ _idleFrameFiller = null ;
79
89
}
80
90
81
91
void _startRecorder (
82
92
String cacheDir, ScheduledScreenshotRecorderConfig config) {
93
+ _idleFrameFiller = _IdleFrameFiller (
94
+ Duration (milliseconds: 1000 ~ / config.frameRate), _addReplayScreenshot);
95
+
83
96
// Note: time measurements using a Stopwatch in a debug build:
84
97
// save as rawRgba (1230876 bytes): 0.257 ms -- discarded
85
98
// save as PNG (25401 bytes): 43.110 ms -- used for the final image
@@ -90,39 +103,107 @@ class SentryNativeJava extends SentryNativeChannel {
90
103
ScreenshotRecorderCallback callback = (image) async {
91
104
var imageData = await image.toByteData (format: ImageByteFormat .png);
92
105
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);
122
109
}
123
110
};
124
111
112
+ _replayCacheDir = cacheDir;
125
113
_replayRecorder = ScheduledScreenshotRecorder (config, callback, options)
126
114
..start ();
127
115
}
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
+ }
128
209
}
0 commit comments