diff --git a/CHANGELOG.md b/CHANGELOG.md index b731e7af8e..4781fb4b1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- iOS Session Replay Alpha ([#2209](https://github.com/getsentry/sentry-dart/pull/2209)) + ## 8.6.0 ### Improvements diff --git a/flutter/ios/Classes/SentryFlutter.swift b/flutter/ios/Classes/SentryFlutter.swift index b26bcfc30d..120561d687 100644 --- a/flutter/ios/Classes/SentryFlutter.swift +++ b/flutter/ios/Classes/SentryFlutter.swift @@ -70,6 +70,14 @@ public final class SentryFlutter { if let appHangTimeoutIntervalMillis = data["appHangTimeoutIntervalMillis"] as? NSNumber { options.appHangTimeoutInterval = appHangTimeoutIntervalMillis.doubleValue / 1000 } +#if canImport(UIKit) && !SENTRY_NO_UIKIT && (os(iOS) || os(tvOS)) + if let replayOptions = data["replay"] as? [String: Any] { + options.experimental.sessionReplay.sessionSampleRate = + (replayOptions["sessionSampleRate"] as? NSNumber)?.floatValue ?? 0 + options.experimental.sessionReplay.errorSampleRate = + (replayOptions["errorSampleRate"] as? NSNumber)?.floatValue ?? 0 + } +#endif } private func logLevelFrom(diagnosticLevel: String) -> SentryLevel { diff --git a/flutter/ios/Classes/SentryFlutterPluginApple.swift b/flutter/ios/Classes/SentryFlutterPluginApple.swift index 35249ef5d1..15efcf2772 100644 --- a/flutter/ios/Classes/SentryFlutterPluginApple.swift +++ b/flutter/ios/Classes/SentryFlutterPluginApple.swift @@ -12,6 +12,7 @@ import CoreVideo // swiftlint:disable:next type_body_length public class SentryFlutterPluginApple: NSObject, FlutterPlugin { + private let channel: FlutterMethodChannel private static let nativeClientName = "sentry.cocoa.flutter" @@ -38,12 +39,16 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin { let channel = FlutterMethodChannel(name: "sentry_flutter", binaryMessenger: registrar.messenger) #endif - let instance = SentryFlutterPluginApple() + let instance = SentryFlutterPluginApple(channel: channel) instance.registerObserver() - registrar.addMethodCallDelegate(instance, channel: channel) } + private init(channel: FlutterMethodChannel) { + self.channel = channel + super.init() + } + private lazy var sentryFlutter = SentryFlutter() private func registerObserver() { @@ -174,6 +179,14 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin { case "resumeAppHangTracking": resumeAppHangTracking(result) + case "sendReplayForEvent": +#if canImport(UIKit) && !SENTRY_NO_UIKIT && (os(iOS) || os(tvOS)) + PrivateSentrySDKOnly.captureReplay() + result(PrivateSentrySDKOnly.getReplayId()) +#else + result(nil) +#endif + default: result(FlutterMethodNotImplemented) } @@ -323,6 +336,14 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin { didReceiveDidBecomeActiveNotification = false } +#if canImport(UIKit) && !SENTRY_NO_UIKIT +#if os(iOS) || os(tvOS) + let breadcrumbConverter = SentryFlutterReplayBreadcrumbConverter() + let screenshotProvider = SentryFlutterReplayScreenshotProvider(channel: self.channel) + PrivateSentrySDKOnly.configureSessionReplay(with: breadcrumbConverter, screenshotProvider: screenshotProvider) +#endif +#endif + result("") } diff --git a/flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.h b/flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.h new file mode 100644 index 0000000000..1260268ced --- /dev/null +++ b/flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.h @@ -0,0 +1,15 @@ +@import Sentry; + +#if SENTRY_TARGET_REPLAY_SUPPORTED +@class SentryRRWebEvent; + +@interface SentryFlutterReplayBreadcrumbConverter + : NSObject + +- (instancetype _Nonnull)init; + +- (id _Nullable)convertFrom: + (SentryBreadcrumb *_Nonnull)breadcrumb; + +@end +#endif diff --git a/flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.m b/flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.m new file mode 100644 index 0000000000..75b073de82 --- /dev/null +++ b/flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.m @@ -0,0 +1,117 @@ +#import "SentryFlutterReplayBreadcrumbConverter.h" + +@import Sentry; + +#if SENTRY_TARGET_REPLAY_SUPPORTED + +@implementation SentryFlutterReplayBreadcrumbConverter { + SentrySRDefaultBreadcrumbConverter *defaultConverter; +} + +- (instancetype _Nonnull)init { + if (self = [super init]) { + self->defaultConverter = + [SentrySessionReplayIntegration createDefaultBreadcrumbConverter]; + } + return self; +} + +- (id _Nullable)convertFrom: + (SentryBreadcrumb *_Nonnull)breadcrumb { + assert(breadcrumb.timestamp != nil); + + if (breadcrumb.category == nil + // Do not add Sentry Event breadcrumbs to replay + || [breadcrumb.category isEqualToString:@"sentry.event"] || + [breadcrumb.category isEqualToString:@"sentry.transaction"]) { + return nil; + } + + if ([breadcrumb.category isEqualToString:@"http"]) { + return [self convertNetwork:breadcrumb]; + } + + if ([breadcrumb.category isEqualToString:@"navigation"]) { + return [self convertFrom:breadcrumb withCategory:nil andMessage:nil]; + } + + if ([breadcrumb.category isEqualToString:@"ui.click"]) { + return [self convertFrom:breadcrumb + withCategory:@"ui.tap" + andMessage:breadcrumb.data[@"path"]]; + } + + SentryRRWebEvent *nativeBreadcrumb = + [self->defaultConverter convertFrom:breadcrumb]; + + // ignore native navigation breadcrumbs + if (nativeBreadcrumb && nativeBreadcrumb.data && + nativeBreadcrumb.data[@"payload"] && + nativeBreadcrumb.data[@"payload"][@"category"] && + [nativeBreadcrumb.data[@"payload"][@"category"] + isEqualToString:@"navigation"]) { + return nil; + } + + return nativeBreadcrumb; +} + +- (id _Nullable)convertFrom: + (SentryBreadcrumb *_Nonnull)breadcrumb + withCategory:(NSString *)category + andMessage:(NSString *)message { + return [SentrySessionReplayIntegration + createBreadcrumbwithTimestamp:breadcrumb.timestamp + category:category ?: breadcrumb.category + message:message ?: breadcrumb.message + level:breadcrumb.level + data:breadcrumb.data]; +} + +- (id _Nullable)convertNetwork: + (SentryBreadcrumb *_Nonnull)breadcrumb { + NSNumber *startTimestamp = + [breadcrumb.data[@"start_timestamp"] isKindOfClass:[NSNumber class]] + ? breadcrumb.data[@"start_timestamp"] + : nil; + NSNumber *endTimestamp = + [breadcrumb.data[@"end_timestamp"] isKindOfClass:[NSNumber class]] + ? breadcrumb.data[@"end_timestamp"] + : nil; + NSString *url = [breadcrumb.data[@"url"] isKindOfClass:[NSString class]] + ? breadcrumb.data[@"url"] + : nil; + + if (startTimestamp == nil || endTimestamp == nil || url == nil) { + return nil; + } + + NSMutableDictionary *data = [[NSMutableDictionary alloc] init]; + if ([breadcrumb.data[@"method"] isKindOfClass:[NSString class]]) { + data[@"method"] = breadcrumb.data[@"method"]; + } + if ([breadcrumb.data[@"status_code"] isKindOfClass:[NSNumber class]]) { + data[@"statusCode"] = breadcrumb.data[@"status_code"]; + } + if ([breadcrumb.data[@"request_body_size"] isKindOfClass:[NSNumber class]]) { + data[@"requestBodySize"] = breadcrumb.data[@"request_body_size"]; + } + if ([breadcrumb.data[@"response_body_size"] isKindOfClass:[NSNumber class]]) { + data[@"responseBodySize"] = breadcrumb.data[@"response_body_size"]; + } + + return [SentrySessionReplayIntegration + createNetworkBreadcrumbWithTimestamp:[self dateFrom:startTimestamp] + endTimestamp:[self dateFrom:endTimestamp] + operation:@"resource.http" + description:url + data:data]; +} + +- (NSDate *_Nonnull)dateFrom:(NSNumber *_Nonnull)timestamp { + return [NSDate dateWithTimeIntervalSince1970:(timestamp.doubleValue / 1000)]; +} + +@end + +#endif diff --git a/flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.h b/flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.h new file mode 100644 index 0000000000..d59e5f4612 --- /dev/null +++ b/flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.h @@ -0,0 +1,12 @@ +@import Sentry; + +#if SENTRY_TARGET_REPLAY_SUPPORTED +@class SentryRRWebEvent; + +@interface SentryFlutterReplayScreenshotProvider + : NSObject + +- (instancetype)initWithChannel:(id)FlutterMethodChannel; + +@end +#endif diff --git a/flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.m b/flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.m new file mode 100644 index 0000000000..fc03fd5365 --- /dev/null +++ b/flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.m @@ -0,0 +1,46 @@ +@import Sentry; + +#if SENTRY_TARGET_REPLAY_SUPPORTED +#import "SentryFlutterReplayScreenshotProvider.h" +#import + +@implementation SentryFlutterReplayScreenshotProvider { + FlutterMethodChannel *channel; +} + +- (instancetype _Nonnull)initWithChannel: + (FlutterMethodChannel *_Nonnull)channel { + if (self = [super init]) { + self->channel = channel; + } + return self; +} + +- (void)imageWithView:(UIView *_Nonnull)view + options:(id _Nonnull)options + onComplete:(void (^_Nonnull)(UIImage *_Nonnull))onComplete { + [self->channel + invokeMethod:@"captureReplayScreenshot" + arguments:@{@"replayId" : [PrivateSentrySDKOnly getReplayId]} + result:^(id value) { + if (value == nil) { + NSLog(@"SentryFlutterReplayScreenshotProvider received null " + @"result. " + @"Cannot capture a replay screenshot."); + } else if ([value + isKindOfClass:[FlutterStandardTypedData class]]) { + FlutterStandardTypedData *typedData = + (FlutterStandardTypedData *)value; + UIImage *image = [UIImage imageWithData:typedData.data]; + onComplete(image); + } else { + NSLog(@"SentryFlutterReplayScreenshotProvider received an " + @"unexpected result. " + @"Cannot capture a replay screenshot."); + } + }]; +} + +@end + +#endif diff --git a/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart b/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart index 9bb5af98b6..5666246472 100644 --- a/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart +++ b/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart @@ -1,17 +1,76 @@ import 'dart:ffi'; +import 'dart:typed_data'; +import 'dart:ui'; import 'package:meta/meta.dart'; import '../../../sentry_flutter.dart'; +import '../../event_processor/replay_event_processor.dart'; +import '../../replay/recorder.dart'; +import '../../replay/recorder_config.dart'; import '../sentry_native_channel.dart'; import 'binding.dart' as cocoa; @internal class SentryNativeCocoa extends SentryNativeChannel { late final _lib = cocoa.SentryCocoa(DynamicLibrary.process()); + ScreenshotRecorder? _replayRecorder; + SentryId? _replayId; SentryNativeCocoa(super.options, super.channel); + @override + Future init(Hub hub) async { + // We only need these when replay is enabled (session or error capture) + // so let's set it up conditionally. This allows Dart to trim the code. + if (options.experimental.replay.isEnabled && + options.platformChecker.platform.isIOS) { + // We only need the integration when error-replay capture is enabled. + if ((options.experimental.replay.errorSampleRate ?? 0) > 0) { + options.addEventProcessor(ReplayEventProcessor(this)); + } + + channel.setMethodCallHandler((call) async { + switch (call.method) { + case 'captureReplayScreenshot': + _replayRecorder ??= + ScreenshotRecorder(ScreenshotRecorderConfig(), options); + final replayId = + SentryId.fromId(call.arguments['replayId'] as String); + if (_replayId != replayId) { + _replayId = replayId; + hub.configureScope((s) { + // ignore: invalid_use_of_internal_member + s.replayId = replayId; + }); + } + + Uint8List? imageBytes; + await _replayRecorder?.capture((image) async { + final imageData = + await image.toByteData(format: ImageByteFormat.png); + if (imageData != null) { + options.logger( + SentryLevel.debug, + 'Replay: captured screenshot (' + '${image.width}x${image.height} pixels, ' + '${imageData.lengthInBytes} bytes)'); + imageBytes = imageData.buffer.asUint8List(); + } else { + options.logger(SentryLevel.warning, + 'Replay: failed to convert screenshot to PNG'); + } + }); + return imageBytes; + default: + throw UnimplementedError('Method ${call.method} not implemented'); + } + }); + } + + return super.init(hub); + } + @override int? startProfiler(SentryId traceId) => tryCatchSync('startProfiler', () { final cSentryId = cocoa.SentryId1.alloc(_lib) diff --git a/flutter/lib/src/native/java/sentry_native_java.dart b/flutter/lib/src/native/java/sentry_native_java.dart index 30c157e3ad..5ccd3a1c67 100644 --- a/flutter/lib/src/native/java/sentry_native_java.dart +++ b/flutter/lib/src/native/java/sentry_native_java.dart @@ -4,7 +4,7 @@ import 'package:meta/meta.dart'; import '../../../sentry_flutter.dart'; import '../../event_processor/replay_event_processor.dart'; -import '../../replay/recorder.dart'; +import '../../replay/scheduled_recorder.dart'; import '../../replay/recorder_config.dart'; import '../sentry_native_channel.dart'; @@ -12,7 +12,7 @@ import '../sentry_native_channel.dart'; // generated JNI bindings. See https://github.com/getsentry/sentry-dart/issues/1444 @internal class SentryNativeJava extends SentryNativeChannel { - ScreenshotRecorder? _replayRecorder; + ScheduledScreenshotRecorder? _replayRecorder; SentryNativeJava(super.options, super.channel); @override @@ -33,7 +33,7 @@ class SentryNativeJava extends SentryNativeChannel { _startRecorder( call.arguments['directory'] as String, - ScreenshotRecorderConfig( + ScheduledScreenshotRecorderConfig( width: call.arguments['width'] as int, height: call.arguments['height'] as int, frameRate: call.arguments['frameRate'] as int, @@ -78,7 +78,8 @@ class SentryNativeJava extends SentryNativeChannel { return super.close(); } - void _startRecorder(String cacheDir, ScreenshotRecorderConfig config) { + void _startRecorder( + String cacheDir, ScheduledScreenshotRecorderConfig config) { // Note: time measurements using a Stopwatch in a debug build: // save as rawRgba (1230876 bytes): 0.257 ms -- discarded // save as PNG (25401 bytes): 43.110 ms -- used for the final image @@ -121,6 +122,7 @@ class SentryNativeJava extends SentryNativeChannel { } }; - _replayRecorder = ScreenshotRecorder(config, callback, options)..start(); + _replayRecorder = ScheduledScreenshotRecorder(config, callback, options) + ..start(); } } diff --git a/flutter/lib/src/replay/recorder.dart b/flutter/lib/src/replay/recorder.dart index e17167f87f..a1f4ea1a0b 100644 --- a/flutter/lib/src/replay/recorder.dart +++ b/flutter/lib/src/replay/recorder.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:math'; import 'dart:ui'; import 'package:flutter/rendering.dart'; @@ -8,55 +7,35 @@ import 'package:meta/meta.dart'; import '../../sentry_flutter.dart'; import 'recorder_config.dart'; import 'widget_filter.dart'; -import 'scheduler.dart'; @internal typedef ScreenshotRecorderCallback = Future Function(Image); @internal class ScreenshotRecorder { - final ScreenshotRecorderConfig _config; - final ScreenshotRecorderCallback _callback; - final SentryLogger _logger; - final SentryReplayOptions _options; - final bool rethrowExceptions; + @protected + final ScreenshotRecorderConfig config; + @protected + final SentryFlutterOptions options; WidgetFilter? _widgetFilter; - late final Scheduler _scheduler; bool warningLogged = false; - ScreenshotRecorder(this._config, this._callback, SentryFlutterOptions options) - : _logger = options.logger, - _options = options.experimental.replay, - // ignore: invalid_use_of_internal_member - rethrowExceptions = options.automatedTestMode { - final frameDuration = Duration(milliseconds: 1000 ~/ _config.frameRate); - _scheduler = Scheduler(frameDuration, _capture, - options.bindingUtils.instance!.addPostFrameCallback); - - if (_options.redactAllText || _options.redactAllImages) { + ScreenshotRecorder(this.config, this.options) { + final replayOptions = options.experimental.replay; + if (replayOptions.redactAllText || replayOptions.redactAllImages) { _widgetFilter = WidgetFilter( - redactText: _options.redactAllText, - redactImages: _options.redactAllImages, - logger: _logger); + redactText: replayOptions.redactAllText, + redactImages: replayOptions.redactAllImages, + logger: options.logger); } } - void start() { - _logger(SentryLevel.debug, "Replay: starting replay capture."); - _scheduler.start(); - } - - Future stop() async { - await _scheduler.stop(); - _logger(SentryLevel.debug, "Replay: replay capture stopped."); - } - - Future _capture(Duration sinceSchedulerEpoch) async { + Future capture(ScreenshotRecorderCallback callback) async { final context = sentryScreenshotWidgetGlobalKey.currentContext; final renderObject = context?.findRenderObject() as RenderRepaintBoundary?; if (context == null || renderObject == null) { if (!warningLogged) { - _logger( + options.logger( SentryLevel.warning, "Replay: SentryScreenshotWidget is not attached. " "Skipping replay capture."); @@ -68,12 +47,12 @@ class ScreenshotRecorder { try { final watch = Stopwatch()..start(); - // The desired resolution (coming from the configuration) is usually - // rounded to next multitude of 16. Therefore, we scale the image. + // On Android, the desired resolution (coming from the configuration) + // is rounded to next multitude of 16 . Therefore, we scale the image. + // On iOS, the screenshot resolution is not adjusted. final srcWidth = renderObject.size.width; final srcHeight = renderObject.size.height; - final pixelRatio = - min(_config.width / srcWidth, _config.height / srcHeight); + final pixelRatio = config.getPixelRatio(srcWidth, srcHeight); // First, we synchronously capture the image and enumerate widgets on the main UI loop. final futureImage = renderObject.toImage(pixelRatio: pixelRatio); @@ -109,7 +88,7 @@ class ScreenshotRecorder { final finalImage = await picture.toImage( (srcWidth * pixelRatio).round(), (srcHeight * pixelRatio).round()); try { - await _callback(finalImage); + await callback(finalImage); } finally { finalImage.dispose(); } @@ -117,14 +96,15 @@ class ScreenshotRecorder { picture.dispose(); } - _logger( + options.logger( SentryLevel.debug, "Replay: captured a screenshot in ${watch.elapsedMilliseconds}" " ms ($blockingTime ms blocking)."); } catch (e, stackTrace) { - _logger(SentryLevel.error, "Replay: failed to capture screenshot.", + options.logger(SentryLevel.error, "Replay: failed to capture screenshot.", exception: e, stackTrace: stackTrace); - if (rethrowExceptions) { + // ignore: invalid_use_of_internal_member + if (options.automatedTestMode) { rethrow; } } diff --git a/flutter/lib/src/replay/recorder_config.dart b/flutter/lib/src/replay/recorder_config.dart index b7e4fd4c86..9649a33823 100644 --- a/flutter/lib/src/replay/recorder_config.dart +++ b/flutter/lib/src/replay/recorder_config.dart @@ -1,11 +1,29 @@ +import 'dart:math'; + import 'package:meta/meta.dart'; @internal class ScreenshotRecorderConfig { - final int width; - final int height; + final int? width; + final int? height; + + const ScreenshotRecorderConfig({this.width, this.height}); + + double getPixelRatio(double srcWidth, double srcHeight) { + assert((width == null) == (height == null)); + if (width == null || height == null) { + return 1.0; + } + return min(width! / srcWidth, height! / srcHeight); + } +} + +class ScheduledScreenshotRecorderConfig extends ScreenshotRecorderConfig { final int frameRate; - ScreenshotRecorderConfig( - {required this.width, required this.height, required this.frameRate}); + const ScheduledScreenshotRecorderConfig({ + super.width, + super.height, + required this.frameRate, + }); } diff --git a/flutter/lib/src/replay/scheduled_recorder.dart b/flutter/lib/src/replay/scheduled_recorder.dart new file mode 100644 index 0000000000..c575278a74 --- /dev/null +++ b/flutter/lib/src/replay/scheduled_recorder.dart @@ -0,0 +1,40 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:meta/meta.dart'; + +import '../../sentry_flutter.dart'; +import 'recorder.dart'; +import 'recorder_config.dart'; +import 'scheduler.dart'; + +@internal +typedef ScreenshotRecorderCallback = Future Function(Image); + +@internal +class ScheduledScreenshotRecorder extends ScreenshotRecorder { + late final Scheduler _scheduler; + final ScreenshotRecorderCallback _callback; + + ScheduledScreenshotRecorder(ScheduledScreenshotRecorderConfig config, + this._callback, SentryFlutterOptions options) + : super(config, options) { + assert(config.frameRate > 0); + final frameDuration = Duration(milliseconds: 1000 ~/ config.frameRate); + _scheduler = Scheduler(frameDuration, _capture, + options.bindingUtils.instance!.addPostFrameCallback); + } + + void start() { + options.logger(SentryLevel.debug, "Replay: starting replay capture."); + _scheduler.start(); + } + + Future stop() async { + await _scheduler.stop(); + options.logger(SentryLevel.debug, "Replay: replay capture stopped."); + } + + Future _capture(Duration sinceSchedulerEpoch) async => + capture(_callback); +} diff --git a/flutter/test/mocks.dart b/flutter/test/mocks.dart index 4e59f6d389..ee74889877 100644 --- a/flutter/test/mocks.dart +++ b/flutter/test/mocks.dart @@ -54,46 +54,24 @@ ISentrySpan startTransactionShim( void main() {} class MockPlatform with NoSuchMethodProvider implements Platform { - MockPlatform({ - String? os, - String? osVersion, - String? hostname, - }) : operatingSystem = os ?? '', - operatingSystemVersion = osVersion ?? '', - localHostname = hostname ?? ''; - - factory MockPlatform.android() { - return MockPlatform(os: 'android'); - } - - factory MockPlatform.iOs() { - return MockPlatform(os: 'ios'); - } - - factory MockPlatform.macOs() { - return MockPlatform(os: 'macos'); - } + const MockPlatform(this.operatingSystem, + {this.operatingSystemVersion = '', this.localHostname = ''}); - factory MockPlatform.windows() { - return MockPlatform(os: 'windows'); - } - - factory MockPlatform.linux() { - return MockPlatform(os: 'linux'); - } - - factory MockPlatform.fuchsia() { - return MockPlatform(os: 'fuchsia'); - } + const MockPlatform.android() : this('android'); + const MockPlatform.iOs() : this('ios'); + const MockPlatform.macOs() : this('macos'); + const MockPlatform.windows() : this('windows'); + const MockPlatform.linux() : this('linux'); + const MockPlatform.fuchsia() : this('fuchsia'); @override - String operatingSystem; + final String operatingSystem; @override - String operatingSystemVersion; + final String operatingSystemVersion; @override - String localHostname; + final String localHostname; @override bool get isLinux => (operatingSystem == 'linux'); @@ -122,7 +100,7 @@ class MockPlatformChecker with NoSuchMethodProvider implements PlatformChecker { this.isWebValue = false, this.hasNativeIntegration = false, Platform? mockPlatform, - }) : _mockPlatform = mockPlatform ?? MockPlatform(); + }) : _mockPlatform = mockPlatform ?? MockPlatform(''); final bool isDebug; final bool isProfile; diff --git a/flutter/test/replay/recorder_config_test.dart b/flutter/test/replay/recorder_config_test.dart new file mode 100644 index 0000000000..d884073e91 --- /dev/null +++ b/flutter/test/replay/recorder_config_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/src/replay/recorder_config.dart'; + +void main() async { + group('$ScreenshotRecorderConfig', () { + test('defaults', () { + var sut = ScreenshotRecorderConfig(); + expect(sut.height, isNull); + expect(sut.width, isNull); + }); + + test('pixel ratio calculation', () { + expect(ScreenshotRecorderConfig().getPixelRatio(100, 100), 1.0); + expect( + ScreenshotRecorderConfig(width: 5, height: 10) + .getPixelRatio(100, 100), + 0.05); + expect( + ScreenshotRecorderConfig(width: 20, height: 10) + .getPixelRatio(100, 100), + 0.1); + }); + }); +} diff --git a/flutter/test/replay/recorder_test.dart b/flutter/test/replay/recorder_test.dart index 99176c4c89..16db1513b5 100644 --- a/flutter/test/replay/recorder_test.dart +++ b/flutter/test/replay/recorder_test.dart @@ -18,46 +18,31 @@ void main() async { testWidgets('captures images', (tester) async { final fixture = await _Fixture.create(tester); - expect(fixture.capturedImages, isEmpty); - await fixture.nextFrame(); - expect(fixture.capturedImages, ['1000x750']); - await fixture.nextFrame(); - expect(fixture.capturedImages, ['1000x750', '1000x750']); - final stopFuture = fixture.sut.stop(); - await fixture.nextFrame(); - await stopFuture; - expect(fixture.capturedImages, ['1000x750', '1000x750']); + expect(fixture.capture(), completion('800x600')); }); } class _Fixture { - final WidgetTester _tester; late final ScreenshotRecorder sut; - final capturedImages = []; - _Fixture._(this._tester) { + _Fixture._() { sut = ScreenshotRecorder( - ScreenshotRecorderConfig( - width: 1000, - height: 1000, - frameRate: 1000, - ), - (Image image) async { - capturedImages.add("${image.width}x${image.height}"); - }, + ScreenshotRecorderConfig(), SentryFlutterOptions()..bindingUtils = TestBindingWrapper(), ); } static Future<_Fixture> create(WidgetTester tester) async { - final fixture = _Fixture._(tester); + final fixture = _Fixture._(); await pumpTestElement(tester); - fixture.sut.start(); return fixture; } - Future nextFrame() async { - _tester.binding.scheduleFrame(); - await _tester.pumpAndSettle(const Duration(seconds: 1)); + Future capture() async { + String? captured; + await sut.capture((Image image) async { + captured = "${image.width}x${image.height}"; + }); + return captured; } } diff --git a/flutter/test/replay/replay_native_test.dart b/flutter/test/replay/replay_native_test.dart index afff3343ae..319bb5f88f 100644 --- a/flutter/test/replay/replay_native_test.dart +++ b/flutter/test/replay/replay_native_test.dart @@ -4,6 +4,7 @@ library flutter_test; import 'dart:async'; +import 'dart:typed_data'; import 'package:file/file.dart'; import 'package:file/memory.dart'; @@ -21,8 +22,9 @@ import 'test_widget.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - for (var mockPlatform in [ + for (final mockPlatform in [ MockPlatform.android(), + MockPlatform.iOs(), ]) { group('$SentryNativeBinding ($mockPlatform)', () { late SentryNativeBinding sut; @@ -31,13 +33,22 @@ void main() { late MockHub hub; late FileSystem fs; late Directory replayDir; - final replayConfig = { - 'replayId': '123', - 'directory': 'dir', - 'width': 1000, - 'height': 1000, - 'frameRate': 1000, - }; + late final Map replayConfig; + + if (mockPlatform.isIOS) { + replayConfig = { + 'replayId': '123', + 'directory': 'dir', + }; + } else if (mockPlatform.isAndroid) { + replayConfig = { + 'replayId': '123', + 'directory': 'dir', + 'width': 800, + 'height': 600, + 'frameRate': 1000, + }; + } setUp(() { hub = MockHub(); @@ -88,12 +99,16 @@ void main() { await sut.init(hub); }); - test('start() sets replay ID to context', () async { + test('sets replay ID to context', () async { // verify there was no scope configured before verifyNever(hub.configureScope(any)); // emulate the native platform invoking the method - await native.invokeFromNative('ReplayRecorder.start', replayConfig); + await native.invokeFromNative( + mockPlatform.isAndroid + ? 'ReplayRecorder.start' + : 'captureReplayScreenshot', + replayConfig); // verify the replay ID was set final closure = @@ -104,7 +119,7 @@ void main() { expect(scope.replayId.toString(), replayConfig['replayId']); }); - test('stop() clears replay ID from context', () async { + test('clears replay ID from context', () async { // verify there was no scope configured before verifyNever(hub.configureScope(any)); @@ -119,72 +134,94 @@ void main() { expect(scope.replayId, isNotNull); await closure(scope); expect(scope.replayId, isNull); - }); + }, skip: mockPlatform.isIOS ? 'iOS does not clear replay ID' : false); testWidgets('captures images', (tester) async { await tester.runAsync(() async { - var callbackFinished = Completer(); - - nextFrame({bool wait = true}) async { - tester.binding.scheduleFrame(); - await Future.delayed(const Duration(milliseconds: 100)); - await tester.pumpAndSettle(const Duration(seconds: 1)); - await callbackFinished.future.timeout( - Duration(milliseconds: wait ? 1000 : 100), onTimeout: () { - if (wait) { - fail('native callback not called'); - } + if (mockPlatform.isAndroid) { + var callbackFinished = Completer(); + + nextFrame({bool wait = true}) async { + tester.binding.scheduleFrame(); + await Future.delayed(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(const Duration(seconds: 1)); + await callbackFinished.future.timeout( + Duration(milliseconds: wait ? 1000 : 100), onTimeout: () { + if (wait) { + fail('native callback not called'); + } + }); + callbackFinished = Completer(); + } + + imageInfo(File file) => file.readAsBytesSync().length; + + fileToImageMap(Iterable files) => + {for (var file in files) file.path: imageInfo(file)}; + + final capturedImages = {}; + when(native.handler('addReplayScreenshot', any)) + .thenAnswer((invocation) async { + callbackFinished.complete(); + final path = + invocation.positionalArguments[1]["path"] as String; + capturedImages[path] = imageInfo(fs.file(path)); + return null; }); - callbackFinished = Completer(); - } - - imageInfo(File file) => file.readAsBytesSync().length; - - fileToImageMap(Iterable files) => - {for (var file in files) file.path: imageInfo(file)}; - final capturedImages = {}; - when(native.handler('addReplayScreenshot', any)) - .thenAnswer((invocation) async { - callbackFinished.complete(); - final path = invocation.positionalArguments[1]["path"] as String; - capturedImages[path] = imageInfo(fs.file(path)); - return null; - }); - - fsImages() { - final files = replayDir.listSync().map((f) => f as File); - return fileToImageMap(files); + fsImages() { + final files = replayDir.listSync().map((f) => f as File); + return fileToImageMap(files); + } + + await pumpTestElement(tester); + + await nextFrame(wait: false); + expect(fsImages(), isEmpty); + verifyNever(native.handler('addReplayScreenshot', any)); + + await native.invokeFromNative( + 'ReplayRecorder.start', replayConfig); + + await nextFrame(); + expect(fsImages().values, isNotEmpty); + final size = fsImages().values.first; + expect(size, greaterThan(3000)); + expect(fsImages().values, [size]); + expect(capturedImages, equals(fsImages())); + + await nextFrame(); + expect(fsImages().values, [size, size]); + expect(capturedImages, equals(fsImages())); + + await native.invokeFromNative('ReplayRecorder.stop'); + + await nextFrame(wait: false); + expect(fsImages().values, [size, size]); + expect(capturedImages, equals(fsImages())); + + await nextFrame(wait: false); + expect(fsImages().values, [size, size]); + expect(capturedImages, equals(fsImages())); + } else if (mockPlatform.isIOS) { + // configureScope() is called on iOS + when(hub.configureScope(captureAny)).thenReturn(null); + + nextFrame() async { + tester.binding.scheduleFrame(); + await Future.delayed(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(const Duration(seconds: 1)); + } + + await pumpTestElement(tester); + await nextFrame(); + + final imagaData = await native.invokeFromNative( + 'captureReplayScreenshot', replayConfig) as ByteData; + expect(imagaData.lengthInBytes, greaterThan(3000)); + } else { + fail('unsupported platform'); } - - await pumpTestElement(tester); - - await nextFrame(wait: false); - expect(fsImages(), isEmpty); - verifyNever(native.handler('addReplayScreenshot', any)); - - await native.invokeFromNative('ReplayRecorder.start', replayConfig); - - await nextFrame(); - expect(fsImages().values, isNotEmpty); - final size = fsImages().values.first; - expect(size, greaterThan(5000)); - expect(fsImages().values, [size]); - expect(capturedImages, equals(fsImages())); - - await nextFrame(); - expect(fsImages().values, [size, size]); - expect(capturedImages, equals(fsImages())); - - await native.invokeFromNative('ReplayRecorder.stop'); - - await nextFrame(wait: false); - expect(fsImages().values, [size, size]); - expect(capturedImages, equals(fsImages())); - - await nextFrame(wait: false); - expect(fsImages().values, [size, size]); - expect(capturedImages, equals(fsImages())); }); }, timeout: Timeout(Duration(seconds: 10))); }); diff --git a/flutter/test/replay/scheduled_recorder_test.dart b/flutter/test/replay/scheduled_recorder_test.dart new file mode 100644 index 0000000000..f859b27d53 --- /dev/null +++ b/flutter/test/replay/scheduled_recorder_test.dart @@ -0,0 +1,63 @@ +// For some reason, this test is not working in the browser but that's OK, we +// don't support video recording anyway. +@TestOn('vm') +library dart_test; + +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/replay/scheduled_recorder.dart'; +import 'package:sentry_flutter/src/replay/recorder_config.dart'; + +import '../mocks.dart'; +import 'test_widget.dart'; + +void main() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('captures images', (tester) async { + final fixture = await _Fixture.create(tester); + expect(fixture.capturedImages, isEmpty); + await fixture.nextFrame(); + expect(fixture.capturedImages, ['1000x750']); + await fixture.nextFrame(); + expect(fixture.capturedImages, ['1000x750', '1000x750']); + final stopFuture = fixture.sut.stop(); + await fixture.nextFrame(); + await stopFuture; + expect(fixture.capturedImages, ['1000x750', '1000x750']); + }); +} + +class _Fixture { + final WidgetTester _tester; + late final ScheduledScreenshotRecorder sut; + final capturedImages = []; + + _Fixture._(this._tester) { + sut = ScheduledScreenshotRecorder( + ScheduledScreenshotRecorderConfig( + width: 1000, + height: 1000, + frameRate: 1000, + ), + (Image image) async { + capturedImages.add("${image.width}x${image.height}"); + }, + SentryFlutterOptions()..bindingUtils = TestBindingWrapper(), + ); + } + + static Future<_Fixture> create(WidgetTester tester) async { + final fixture = _Fixture._(tester); + await pumpTestElement(tester); + fixture.sut.start(); + return fixture; + } + + Future nextFrame() async { + _tester.binding.scheduleFrame(); + await _tester.pumpAndSettle(const Duration(seconds: 1)); + } +}