Skip to content

Commit 6e7d494

Browse files
buenaflorgetsentry-botdependabot[bot]
authored
enh: refactor captureReplay and setReplayConfig to use FFI/JNI (#3318)
* Add support for captureReplay * Remove method channel methods * Update * Update * Update * Add test for adjustReplaySizeToBlockSize * Update * Add comment * Update tests * Clean up * Update tests * Update tests * Update tests * Update tests * Update tests * Update tests * release: 9.8.0 * Replace Android emulator test step with unit test (#3319) * build(deps): bump actions/upload-artifact from 4 to 5 (#3315) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 5. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](actions/upload-artifact@v4...v5) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump ruby/setup-ruby from 1.263.0 to 1.267.0 (#3316) Bumps [ruby/setup-ruby](https://github.com/ruby/setup-ruby) from 1.263.0 to 1.267.0. - [Release notes](https://github.com/ruby/setup-ruby/releases) - [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb) - [Commits](ruby/setup-ruby@0481980...d5126b9) --- updated-dependencies: - dependency-name: ruby/setup-ruby dependency-version: 1.267.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Update CHANGELOG * Use round() instead of toInt() * Fix CHANGELOG * Update * Bug bot reviews * Add test * Fix test --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: getsentry-bot <bot@sentry.io> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1 parent e45380d commit 6e7d494

File tree

16 files changed

+4358
-2924
lines changed

16 files changed

+4358
-2924
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Enhancements
6+
7+
- Refactor `captureReplay` and `setReplayConfig` to use FFI/JNI ([#3318](https://github.com/getsentry/sentry-dart/pull/3318))
8+
39
## 9.8.0
410

511
### Features

packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt

Lines changed: 0 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,6 @@ class SentryFlutterPlugin :
6161
when (call.method) {
6262
"initNativeSdk" -> initNativeSdk(call, result)
6363
"closeNativeSdk" -> closeNativeSdk(result)
64-
"setReplayConfig" -> setReplayConfig(call, result)
65-
"captureReplay" -> captureReplay(result)
6664
else -> result.notImplemented()
6765
}
6866
}
@@ -354,73 +352,4 @@ class SentryFlutterPlugin :
354352
}
355353
}
356354
}
357-
358-
private fun setReplayConfig(
359-
call: MethodCall,
360-
result: Result,
361-
) {
362-
// Since codec block size is 16, so we have to adjust the width and height to it,
363-
// otherwise the codec might fail to configure on some devices, see
364-
// https://cs.android.com/android/platform/superproject/+/master:frameworks/base/media/java/android/media/MediaCodecInfo.java;l=1999-2001
365-
val windowWidth = call.argument("windowWidth") as? Double ?: 0.0
366-
val windowHeight = call.argument("windowHeight") as? Double ?: 0.0
367-
368-
var width = call.argument("width") as? Double ?: 0.0
369-
var height = call.argument("height") as? Double ?: 0.0
370-
371-
val invalidConfig =
372-
width == 0.0 ||
373-
height == 0.0 ||
374-
windowWidth == 0.0 ||
375-
windowHeight == 0.0
376-
377-
if (invalidConfig) {
378-
result.error(
379-
"5",
380-
"Replay config is not valid: width: $width, height: $height, " +
381-
"windowWidth: $windowWidth, windowHeight: $windowHeight",
382-
null,
383-
)
384-
return
385-
}
386-
387-
// First update the smaller dimension, as changing that will affect the screen ratio more.
388-
if (width < height) {
389-
val newWidth = width.adjustReplaySizeToBlockSize()
390-
height = (height * (newWidth / width)).adjustReplaySizeToBlockSize()
391-
width = newWidth
392-
} else {
393-
val newHeight = height.adjustReplaySizeToBlockSize()
394-
width = (width * (newHeight / height)).adjustReplaySizeToBlockSize()
395-
height = newHeight
396-
}
397-
398-
val replayConfig =
399-
ScreenshotRecorderConfig(
400-
recordingWidth = width.roundToInt(),
401-
recordingHeight = height.roundToInt(),
402-
scaleFactorX = width.toFloat() / windowWidth.toFloat(),
403-
scaleFactorY = height.toFloat() / windowHeight.toFloat(),
404-
frameRate = call.argument("frameRate") as? Int ?: 0,
405-
bitRate = call.argument("bitRate") as? Int ?: 0,
406-
)
407-
Log.i(
408-
"Sentry",
409-
"Configuring replay: %dx%d at %d FPS, %d BPS".format(
410-
replayConfig.recordingWidth,
411-
replayConfig.recordingHeight,
412-
replayConfig.frameRate,
413-
replayConfig.bitRate,
414-
),
415-
)
416-
replay?.onConfigurationChanged(replayConfig)
417-
result.success("")
418-
}
419-
420-
private fun captureReplay(
421-
result: Result,
422-
) {
423-
replay!!.captureReplay(isTerminating = false)
424-
result.success(replay!!.getReplayId().toString())
425-
}
426355
}
Lines changed: 203 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,217 @@
1+
// ignore_for_file: invalid_use_of_internal_member
2+
3+
import 'dart:async';
14
import 'dart:io';
25

6+
import 'package:flutter/widgets.dart';
37
import 'package:flutter_test/flutter_test.dart';
8+
import 'package:integration_test/integration_test.dart';
49
import 'package:sentry_flutter/sentry_flutter.dart';
10+
import 'package:sentry_flutter_example/main.dart';
11+
import 'package:sentry_flutter/src/native/java/sentry_native_java.dart';
12+
import 'package:sentry_flutter/src/replay/replay_config.dart';
13+
import 'package:sentry_flutter/src/native/cocoa/sentry_native_cocoa.dart';
14+
import 'package:sentry_flutter/src/replay/scheduled_recorder_config.dart';
515

616
void main() {
17+
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
18+
IntegrationTestWidgetsFlutterBinding.instance.framePolicy =
19+
LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
20+
21+
const fakeDsn = 'https://abc@def.ingest.sentry.io/1234567';
22+
23+
tearDown(() async {
24+
await Sentry.close();
25+
});
26+
27+
Future<void> setupSentryAndApp(WidgetTester tester, {String? dsn}) async {
28+
await setupSentry(
29+
() async {
30+
await tester.pumpWidget(SentryScreenshotWidget(
31+
child: DefaultAssetBundle(
32+
bundle: SentryAssetBundle(enableStructuredDataTracing: true),
33+
child: const MyApp(),
34+
)));
35+
},
36+
dsn ?? fakeDsn,
37+
isIntegrationTest: true,
38+
);
39+
}
40+
741
group('Replay recording', () {
8-
setUp(() async {
9-
await SentryFlutter.init((options) {
10-
// ignore: invalid_use_of_internal_member
11-
options.automatedTestMode = true;
12-
options.dsn = 'https://abc@def.ingest.sentry.io/1234567';
13-
options.debug = true;
14-
options.replay.sessionSampleRate = 1.0;
15-
});
42+
testWidgets('native binding is initialized', (tester) async {
43+
await setupSentryAndApp(tester);
44+
expect(SentryFlutter.native, isNotNull);
1645
});
1746

18-
tearDown(() async {
19-
await Sentry.close();
47+
testWidgets('supportsReplay matches platform', (tester) async {
48+
await setupSentryAndApp(tester);
49+
final supports = SentryFlutter.native?.supportsReplay ?? false;
50+
expect(supports, Platform.isAndroid || Platform.isIOS ? isTrue : isFalse);
2051
});
2152

22-
test('native binding is initialized', () async {
23-
// ignore: invalid_use_of_internal_member
24-
expect(SentryFlutter.native, isNotNull);
53+
testWidgets('captureReplay returns a SentryId', (tester) async {
54+
if (!(Platform.isAndroid || Platform.isIOS)) return;
55+
await setupSentryAndApp(tester);
56+
final id = await SentryFlutter.native?.captureReplay();
57+
expect(id, isA<SentryId>());
58+
if (Platform.isIOS) {
59+
final current = SentryFlutter.native?.replayId;
60+
expect(current?.toString(), equals(id?.toString()));
61+
}
62+
});
63+
64+
testWidgets('captureReplay sets native replay ID', (tester) async {
65+
if (!(Platform.isAndroid || Platform.isIOS)) return;
66+
await setupSentryAndApp(tester);
67+
final id = await SentryFlutter.native?.captureReplay();
68+
expect(id, isA<SentryId>());
69+
expect(SentryFlutter.native?.replayId, isNotNull);
70+
expect(SentryFlutter.native?.replayId, isNot(const SentryId.empty()));
2571
});
2672

27-
test('session replay is captured', () async {
28-
// TODO add when the beforeSend callback is implemented for replays.
29-
}, skip: true);
30-
31-
test('replay is captured on errors', () async {
32-
// TODO we may need an HTTP server for this because Android sends replays
33-
// in a separate envelope.
34-
}, skip: true);
35-
},
36-
skip: Platform.isAndroid
37-
? false
38-
: "Replay recording is not supported on this platform");
73+
// We would like to add a test that ensures a native-initiated replay stop
74+
// clears the replay ID from the scope. Currently we can't add that test
75+
// because FFI/JNI cannot be mocked in this environment.
76+
testWidgets('sets replay ID after capturing exception', (tester) async {
77+
await setupSentryAndApp(tester);
78+
79+
try {
80+
throw Exception('boom');
81+
} catch (e, st) {
82+
await Sentry.captureException(e, stackTrace: st);
83+
}
84+
85+
// After capture, ReplayEventProcessor should set scope.replayId
86+
await Sentry.configureScope((scope) async {
87+
expect(
88+
scope.replayId == null || scope.replayId == const SentryId.empty(),
89+
isFalse);
90+
});
91+
92+
final current = SentryFlutter.native?.replayId;
93+
await Sentry.configureScope((scope) async {
94+
expect(current?.toString(), equals(scope.replayId?.toString()));
95+
});
96+
});
97+
98+
testWidgets(
99+
'replay recorder start emits frame and stop silences frames on Android',
100+
(tester) async {
101+
await setupSentryAndApp(tester);
102+
final native = SentryFlutter.native as SentryNativeJava?;
103+
expect(native, isNotNull);
104+
105+
await Future.delayed(const Duration(seconds: 2));
106+
final recorder = native!.testRecorder;
107+
expect(recorder, isNotNull);
108+
109+
await recorder!
110+
.onConfigurationChanged(const ScheduledScreenshotRecorderConfig(
111+
width: 800,
112+
height: 600,
113+
frameRate: 1,
114+
));
115+
116+
var frameCount = 0;
117+
final firstFrame = Completer<void>();
118+
recorder.onScreenshotAddedForTest = () {
119+
frameCount++;
120+
if (!firstFrame.isCompleted) firstFrame.complete();
121+
};
122+
123+
await tester.pump();
124+
await firstFrame.future.timeout(const Duration(seconds: 5));
125+
126+
await recorder.stop();
127+
await tester.pump();
128+
final afterStopCount = frameCount;
129+
await Future<void>.delayed(const Duration(seconds: 2));
130+
expect(frameCount, equals(afterStopCount));
131+
}, skip: !Platform.isAndroid);
132+
133+
testWidgets(
134+
'replay recorder pause silences and resume restarts frames on Android',
135+
(tester) async {
136+
await setupSentryAndApp(tester);
137+
final native = SentryFlutter.native as SentryNativeJava?;
138+
expect(native, isNotNull);
139+
140+
await Future.delayed(const Duration(seconds: 2));
141+
final recorder = native!.testRecorder;
142+
expect(recorder, isNotNull);
143+
144+
await recorder!
145+
.onConfigurationChanged(const ScheduledScreenshotRecorderConfig(
146+
width: 800,
147+
height: 600,
148+
frameRate: 1,
149+
));
150+
151+
var frameCount = 0;
152+
final firstFrame = Completer<void>();
153+
recorder.onScreenshotAddedForTest = () {
154+
frameCount++;
155+
if (!firstFrame.isCompleted) firstFrame.complete();
156+
};
157+
158+
await tester.pump();
159+
await firstFrame.future.timeout(const Duration(seconds: 5));
160+
161+
await recorder.pause();
162+
await tester.pump();
163+
final pausedCount = frameCount;
164+
await Future<void>.delayed(const Duration(seconds: 2));
165+
expect(frameCount, equals(pausedCount));
166+
167+
await recorder.resume();
168+
await tester.pump();
169+
final resumedBaseline = frameCount;
170+
await Future<void>.delayed(const Duration(seconds: 3));
171+
expect(frameCount, greaterThan(resumedBaseline));
172+
173+
await recorder.stop();
174+
await tester.pump();
175+
final afterStopCount = frameCount;
176+
await Future<void>.delayed(const Duration(seconds: 2));
177+
expect(frameCount, equals(afterStopCount));
178+
}, skip: !Platform.isAndroid);
179+
180+
testWidgets('setReplayConfig applies without error on Android',
181+
(tester) async {
182+
await setupSentryAndApp(tester);
183+
const config = ReplayConfig(
184+
windowWidth: 1080,
185+
windowHeight: 1920,
186+
width: 800,
187+
height: 600,
188+
frameRate: 1,
189+
);
190+
await Future.delayed(const Duration(seconds: 2));
191+
192+
// Should not throw
193+
await SentryFlutter.native?.setReplayConfig(config);
194+
}, skip: !Platform.isAndroid);
195+
196+
testWidgets('capture screenshot via test recorder returns metadata on iOS',
197+
(tester) async {
198+
await setupSentryAndApp(tester);
199+
final native = SentryFlutter.native as SentryNativeCocoa?;
200+
expect(native, isNotNull);
201+
202+
await Future.delayed(const Duration(seconds: 2));
203+
final json = await native!.testRecorder?.captureScreenshot();
204+
expect(json, isNotNull);
205+
expect(json!['length'], isNotNull);
206+
expect(json['address'], isNotNull);
207+
expect(json['width'], isNotNull);
208+
expect(json['height'], isNotNull);
209+
expect((json['width'] as int) > 0, isTrue);
210+
expect((json['height'] as int) > 0, isTrue);
211+
212+
// Capture again to ensure subsequent captures still succeed
213+
final json2 = await native.testRecorder?.captureScreenshot();
214+
expect(json2, isNotNull);
215+
}, skip: !Platform.isIOS);
216+
});
39217
}

packages/flutter/ffi-jni.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ log_level: all
1515
classes:
1616
- io.sentry.android.core.InternalSentrySdk
1717
- io.sentry.android.replay.ReplayIntegration
18+
- io.sentry.android.replay.ScreenshotRecorderConfig
1819
- io.sentry.flutter.SentryFlutterPlugin
1920
- io.sentry.Sentry
2021
- io.sentry.Breadcrumb
2122
- io.sentry.ScopesAdapter
2223
- io.sentry.Scope
2324
- io.sentry.ScopeCallback
2425
- io.sentry.protocol.User
26+
- io.sentry.protocol.SentryId
2527
- android.graphics.Bitmap

packages/flutter/ios/sentry_flutter/Sources/sentry_flutter/SentryFlutterPlugin.swift

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,6 @@ public class SentryFlutterPlugin: NSObject, FlutterPlugin {
8383
collectProfile(call, result)
8484
#endif
8585

86-
case "captureReplay":
87-
#if canImport(UIKit) && !SENTRY_NO_UIKIT && (os(iOS) || os(tvOS))
88-
PrivateSentrySDKOnly.captureReplay()
89-
result(PrivateSentrySDKOnly.getReplayId())
90-
#else
91-
result(nil)
92-
#endif
93-
9486
default:
9587
result(FlutterMethodNotImplemented)
9688
}
@@ -274,6 +266,15 @@ public class SentryFlutterPlugin: NSObject, FlutterPlugin {
274266
//
275267
// Purpose: Called from the Flutter plugin's native bridge (FFI) - bindings are created from SentryFlutterPlugin.h
276268

269+
@objc public class func captureReplay() -> String? {
270+
#if canImport(UIKit) && !SENTRY_NO_UIKIT && (os(iOS) || os(tvOS))
271+
PrivateSentrySDKOnly.captureReplay()
272+
return PrivateSentrySDKOnly.getReplayId()
273+
#else
274+
return nil
275+
#endif
276+
}
277+
277278
#if os(iOS)
278279
// Taken from the Flutter engine:
279280
// https://github.com/flutter/engine/blob/main/shell/platform/darwin/ios/framework/Source/vsync_waiter_ios.mm#L150

packages/flutter/ios/sentry_flutter/Sources/sentry_flutter_objc/SentryFlutterPlugin.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@
88
+ (nullable NSData *)fetchNativeAppStartAsBytes;
99
+ (nullable NSData *)loadContextsAsBytes;
1010
+ (nullable NSData *)loadDebugImagesAsBytes:(NSSet<NSString *> *)instructionAddresses;
11+
+ (nullable NSString *)captureReplay;
1112
@end
1213
#endif

0 commit comments

Comments
 (0)