Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Enhancements

- Refactor `captureReplay` and `setReplayConfig` to use FFI/JNI ([#3318](https://github.com/getsentry/sentry-dart/pull/3318))

## 9.8.0

### Features
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,6 @@ class SentryFlutterPlugin :
when (call.method) {
"initNativeSdk" -> initNativeSdk(call, result)
"closeNativeSdk" -> closeNativeSdk(result)
"setReplayConfig" -> setReplayConfig(call, result)
"captureReplay" -> captureReplay(result)
else -> result.notImplemented()
}
}
Expand Down Expand Up @@ -354,73 +352,4 @@ class SentryFlutterPlugin :
}
}
}

private fun setReplayConfig(
call: MethodCall,
result: Result,
) {
// Since codec block size is 16, so we have to adjust the width and height to it,
// otherwise the codec might fail to configure on some devices, see
// https://cs.android.com/android/platform/superproject/+/master:frameworks/base/media/java/android/media/MediaCodecInfo.java;l=1999-2001
val windowWidth = call.argument("windowWidth") as? Double ?: 0.0
val windowHeight = call.argument("windowHeight") as? Double ?: 0.0

var width = call.argument("width") as? Double ?: 0.0
var height = call.argument("height") as? Double ?: 0.0

val invalidConfig =
width == 0.0 ||
height == 0.0 ||
windowWidth == 0.0 ||
windowHeight == 0.0

if (invalidConfig) {
result.error(
"5",
"Replay config is not valid: width: $width, height: $height, " +
"windowWidth: $windowWidth, windowHeight: $windowHeight",
null,
)
return
}

// First update the smaller dimension, as changing that will affect the screen ratio more.
if (width < height) {
val newWidth = width.adjustReplaySizeToBlockSize()
height = (height * (newWidth / width)).adjustReplaySizeToBlockSize()
width = newWidth
} else {
val newHeight = height.adjustReplaySizeToBlockSize()
width = (width * (newHeight / height)).adjustReplaySizeToBlockSize()
height = newHeight
}

val replayConfig =
ScreenshotRecorderConfig(
recordingWidth = width.roundToInt(),
recordingHeight = height.roundToInt(),
scaleFactorX = width.toFloat() / windowWidth.toFloat(),
scaleFactorY = height.toFloat() / windowHeight.toFloat(),
frameRate = call.argument("frameRate") as? Int ?: 0,
bitRate = call.argument("bitRate") as? Int ?: 0,
)
Log.i(
"Sentry",
"Configuring replay: %dx%d at %d FPS, %d BPS".format(
replayConfig.recordingWidth,
replayConfig.recordingHeight,
replayConfig.frameRate,
replayConfig.bitRate,
),
)
replay?.onConfigurationChanged(replayConfig)
result.success("")
}

private fun captureReplay(
result: Result,
) {
replay!!.captureReplay(isTerminating = false)
result.success(replay!!.getReplayId().toString())
}
}
228 changes: 203 additions & 25 deletions packages/flutter/example/integration_test/replay_test.dart
Original file line number Diff line number Diff line change
@@ -1,39 +1,217 @@
// ignore_for_file: invalid_use_of_internal_member

import 'dart:async';
import 'dart:io';

import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:sentry_flutter_example/main.dart';
import 'package:sentry_flutter/src/native/java/sentry_native_java.dart';
import 'package:sentry_flutter/src/replay/replay_config.dart';
import 'package:sentry_flutter/src/native/cocoa/sentry_native_cocoa.dart';
import 'package:sentry_flutter/src/replay/scheduled_recorder_config.dart';

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
IntegrationTestWidgetsFlutterBinding.instance.framePolicy =
LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;

const fakeDsn = 'https://[email protected]/1234567';

tearDown(() async {
await Sentry.close();
});

Future<void> setupSentryAndApp(WidgetTester tester, {String? dsn}) async {
await setupSentry(
() async {
await tester.pumpWidget(SentryScreenshotWidget(
child: DefaultAssetBundle(
bundle: SentryAssetBundle(enableStructuredDataTracing: true),
child: const MyApp(),
)));
},
dsn ?? fakeDsn,
isIntegrationTest: true,
);
}

group('Replay recording', () {
setUp(() async {
await SentryFlutter.init((options) {
// ignore: invalid_use_of_internal_member
options.automatedTestMode = true;
options.dsn = 'https://[email protected]/1234567';
options.debug = true;
options.replay.sessionSampleRate = 1.0;
});
testWidgets('native binding is initialized', (tester) async {
await setupSentryAndApp(tester);
expect(SentryFlutter.native, isNotNull);
});

tearDown(() async {
await Sentry.close();
testWidgets('supportsReplay matches platform', (tester) async {
await setupSentryAndApp(tester);
final supports = SentryFlutter.native?.supportsReplay ?? false;
expect(supports, Platform.isAndroid || Platform.isIOS ? isTrue : isFalse);
});

test('native binding is initialized', () async {
// ignore: invalid_use_of_internal_member
expect(SentryFlutter.native, isNotNull);
testWidgets('captureReplay returns a SentryId', (tester) async {
if (!(Platform.isAndroid || Platform.isIOS)) return;
await setupSentryAndApp(tester);
final id = await SentryFlutter.native?.captureReplay();
expect(id, isA<SentryId>());
if (Platform.isIOS) {
final current = SentryFlutter.native?.replayId;
expect(current?.toString(), equals(id?.toString()));
}
});

testWidgets('captureReplay sets native replay ID', (tester) async {
if (!(Platform.isAndroid || Platform.isIOS)) return;
await setupSentryAndApp(tester);
final id = await SentryFlutter.native?.captureReplay();
expect(id, isA<SentryId>());
expect(SentryFlutter.native?.replayId, isNotNull);
expect(SentryFlutter.native?.replayId, isNot(const SentryId.empty()));
});

test('session replay is captured', () async {
// TODO add when the beforeSend callback is implemented for replays.
}, skip: true);

test('replay is captured on errors', () async {
// TODO we may need an HTTP server for this because Android sends replays
// in a separate envelope.
}, skip: true);
},
skip: Platform.isAndroid
? false
: "Replay recording is not supported on this platform");
// We would like to add a test that ensures a native-initiated replay stop
// clears the replay ID from the scope. Currently we can't add that test
// because FFI/JNI cannot be mocked in this environment.
testWidgets('sets replay ID after capturing exception', (tester) async {
await setupSentryAndApp(tester);

try {
throw Exception('boom');
} catch (e, st) {
await Sentry.captureException(e, stackTrace: st);
}

// After capture, ReplayEventProcessor should set scope.replayId
await Sentry.configureScope((scope) async {
expect(
scope.replayId == null || scope.replayId == const SentryId.empty(),
isFalse);
});

final current = SentryFlutter.native?.replayId;
await Sentry.configureScope((scope) async {
expect(current?.toString(), equals(scope.replayId?.toString()));
});
});

testWidgets(
'replay recorder start emits frame and stop silences frames on Android',
(tester) async {
await setupSentryAndApp(tester);
final native = SentryFlutter.native as SentryNativeJava?;
expect(native, isNotNull);

await Future.delayed(const Duration(seconds: 2));
final recorder = native!.testRecorder;
expect(recorder, isNotNull);

await recorder!
.onConfigurationChanged(const ScheduledScreenshotRecorderConfig(
width: 800,
height: 600,
frameRate: 1,
));

var frameCount = 0;
final firstFrame = Completer<void>();
recorder.onScreenshotAddedForTest = () {
frameCount++;
if (!firstFrame.isCompleted) firstFrame.complete();
};

await tester.pump();
await firstFrame.future.timeout(const Duration(seconds: 5));

await recorder.stop();
await tester.pump();
final afterStopCount = frameCount;
await Future<void>.delayed(const Duration(seconds: 2));
expect(frameCount, equals(afterStopCount));
}, skip: !Platform.isAndroid);

testWidgets(
'replay recorder pause silences and resume restarts frames on Android',
(tester) async {
await setupSentryAndApp(tester);
final native = SentryFlutter.native as SentryNativeJava?;
expect(native, isNotNull);

await Future.delayed(const Duration(seconds: 2));
final recorder = native!.testRecorder;
expect(recorder, isNotNull);

await recorder!
.onConfigurationChanged(const ScheduledScreenshotRecorderConfig(
width: 800,
height: 600,
frameRate: 1,
));

var frameCount = 0;
final firstFrame = Completer<void>();
recorder.onScreenshotAddedForTest = () {
frameCount++;
if (!firstFrame.isCompleted) firstFrame.complete();
};

await tester.pump();
await firstFrame.future.timeout(const Duration(seconds: 5));

await recorder.pause();
await tester.pump();
final pausedCount = frameCount;
await Future<void>.delayed(const Duration(seconds: 2));
expect(frameCount, equals(pausedCount));

await recorder.resume();
await tester.pump();
final resumedBaseline = frameCount;
await Future<void>.delayed(const Duration(seconds: 3));
expect(frameCount, greaterThan(resumedBaseline));

await recorder.stop();
await tester.pump();
final afterStopCount = frameCount;
await Future<void>.delayed(const Duration(seconds: 2));
expect(frameCount, equals(afterStopCount));
}, skip: !Platform.isAndroid);

testWidgets('setReplayConfig applies without error on Android',
(tester) async {
await setupSentryAndApp(tester);
const config = ReplayConfig(
windowWidth: 1080,
windowHeight: 1920,
width: 800,
height: 600,
frameRate: 1,
);
await Future.delayed(const Duration(seconds: 2));

// Should not throw
await SentryFlutter.native?.setReplayConfig(config);
}, skip: !Platform.isAndroid);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Misnamed Test Skips iOS Functionality Verification

The test 'setReplayConfig applies without error on iOS' is misnamed. Its platform conditions cause it to run exclusively on Android, meaning it doesn't test iOS functionality as the name implies.

Fix in Cursor Fix in Web


testWidgets('capture screenshot via test recorder returns metadata on iOS',
(tester) async {
await setupSentryAndApp(tester);
final native = SentryFlutter.native as SentryNativeCocoa?;
expect(native, isNotNull);

await Future.delayed(const Duration(seconds: 2));
final json = await native!.testRecorder?.captureScreenshot();
expect(json, isNotNull);
expect(json!['length'], isNotNull);
expect(json['address'], isNotNull);
expect(json['width'], isNotNull);
expect(json['height'], isNotNull);
expect((json['width'] as int) > 0, isTrue);
expect((json['height'] as int) > 0, isTrue);

// Capture again to ensure subsequent captures still succeed
final json2 = await native.testRecorder?.captureScreenshot();
expect(json2, isNotNull);
}, skip: !Platform.isIOS);
});
}
2 changes: 2 additions & 0 deletions packages/flutter/ffi-jni.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ log_level: all
classes:
- io.sentry.android.core.InternalSentrySdk
- io.sentry.android.replay.ReplayIntegration
- io.sentry.android.replay.ScreenshotRecorderConfig
- io.sentry.flutter.SentryFlutterPlugin
- io.sentry.Sentry
- io.sentry.Breadcrumb
- io.sentry.ScopesAdapter
- io.sentry.Scope
- io.sentry.ScopeCallback
- io.sentry.protocol.User
- io.sentry.protocol.SentryId
- android.graphics.Bitmap
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,6 @@ public class SentryFlutterPlugin: NSObject, FlutterPlugin {
collectProfile(call, result)
#endif

case "captureReplay":
#if canImport(UIKit) && !SENTRY_NO_UIKIT && (os(iOS) || os(tvOS))
PrivateSentrySDKOnly.captureReplay()
result(PrivateSentrySDKOnly.getReplayId())
#else
result(nil)
#endif

default:
result(FlutterMethodNotImplemented)
}
Expand Down Expand Up @@ -274,6 +266,15 @@ public class SentryFlutterPlugin: NSObject, FlutterPlugin {
//
// Purpose: Called from the Flutter plugin's native bridge (FFI) - bindings are created from SentryFlutterPlugin.h

@objc public class func captureReplay() -> String? {
#if canImport(UIKit) && !SENTRY_NO_UIKIT && (os(iOS) || os(tvOS))
PrivateSentrySDKOnly.captureReplay()
return PrivateSentrySDKOnly.getReplayId()
#else
return nil
#endif
}

#if os(iOS)
// Taken from the Flutter engine:
// https://github.com/flutter/engine/blob/main/shell/platform/darwin/ios/framework/Source/vsync_waiter_ios.mm#L150
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@
+ (nullable NSData *)fetchNativeAppStartAsBytes;
+ (nullable NSData *)loadContextsAsBytes;
+ (nullable NSData *)loadDebugImagesAsBytes:(NSSet<NSString *> *)instructionAddresses;
+ (nullable NSString *)captureReplay;
@end
#endif
Loading
Loading