diff --git a/dart/lib/src/protocol/sentry_trace_context.dart b/dart/lib/src/protocol/sentry_trace_context.dart index 25c4ca7ad8..2a9c3bb2fc 100644 --- a/dart/lib/src/protocol/sentry_trace_context.dart +++ b/dart/lib/src/protocol/sentry_trace_context.dart @@ -17,6 +17,9 @@ class SentryTraceContext { /// Id of a parent span final SpanId? parentSpanId; + /// Replay associated with this trace. + final SentryId? replayId; + /// Whether the span is sampled or not final bool? sampled; @@ -45,6 +48,9 @@ class SentryTraceContext { ? null : SpanId.fromId(json['parent_span_id'] as String), traceId: SentryId.fromId(json['trace_id'] as String), + replayId: json['replay_id'] == null + ? null + : SentryId.fromId(json['replay_id'] as String), description: json['description'] as String?, status: json['status'] == null ? null @@ -61,6 +67,7 @@ class SentryTraceContext { 'trace_id': traceId.toString(), 'op': operation, if (parentSpanId != null) 'parent_span_id': parentSpanId!.toString(), + if (replayId != null) 'replay_id': replayId!.toString(), if (description != null) 'description': description, if (status != null) 'status': status!.toString(), if (origin != null) 'origin': origin, @@ -76,6 +83,7 @@ class SentryTraceContext { parentSpanId: parentSpanId, sampled: sampled, origin: origin, + replayId: replayId, ); SentryTraceContext({ @@ -87,6 +95,7 @@ class SentryTraceContext { this.description, this.status, this.origin, + this.replayId, }) : traceId = traceId ?? SentryId.newId(), spanId = spanId ?? SpanId.newId(); @@ -94,9 +103,9 @@ class SentryTraceContext { factory SentryTraceContext.fromPropagationContext( PropagationContext propagationContext) { return SentryTraceContext( - traceId: propagationContext.traceId, - spanId: propagationContext.spanId, - operation: 'default', - ); + traceId: propagationContext.traceId, + spanId: propagationContext.spanId, + operation: 'default', + replayId: propagationContext.baggage?.getReplayId()); } } diff --git a/dart/lib/src/scope.dart b/dart/lib/src/scope.dart index 3fef9a92a2..0b35137ac5 100644 --- a/dart/lib/src/scope.dart +++ b/dart/lib/src/scope.dart @@ -97,6 +97,13 @@ class Scope { /// they must be JSON-serializable. Map get extra => Map.unmodifiable(_extra); + /// Active replay recording. + @internal + SentryId? get replayId => _replayId; + @internal + set replayId(SentryId? value) => _replayId = value; + SentryId? _replayId; + final Contexts _contexts = Contexts(); /// Unmodifiable map of the scope contexts key/value @@ -237,6 +244,7 @@ class Scope { _tags.clear(); _extra.clear(); _eventProcessors.clear(); + _replayId = null; _clearBreadcrumbsSync(); _setUserSync(null); @@ -425,7 +433,8 @@ class Scope { ..fingerprint = List.from(fingerprint) .._transaction = _transaction ..span = span - .._enableScopeSync = false; + .._enableScopeSync = false + .._replayId = _replayId; clone._setUserSync(user); diff --git a/dart/lib/src/sentry_baggage.dart b/dart/lib/src/sentry_baggage.dart index ebed8765b1..b6fc8b7dac 100644 --- a/dart/lib/src/sentry_baggage.dart +++ b/dart/lib/src/sentry_baggage.dart @@ -111,6 +111,9 @@ class SentryBaggage { // ignore: deprecated_member_use_from_same_package setUserSegment(scope.user!.segment!); } + if (scope.replayId != null && scope.replayId != SentryId.empty()) { + setReplayId(scope.replayId.toString()); + } } static Map _extractKeyValuesFromBaggageString( @@ -205,5 +208,12 @@ class SentryBaggage { return double.tryParse(sampleRate); } + void setReplayId(String value) => set('sentry-replay_id', value); + + SentryId? getReplayId() { + final replayId = get('sentry-replay_id'); + return replayId == null ? null : SentryId.fromId(replayId); + } + Map get keyValues => Map.unmodifiable(_keyValues); } diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index 63fcbfb421..2fa461f7f4 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -145,15 +145,15 @@ class SentryClient { var traceContext = scope?.span?.traceContext(); if (traceContext == null) { - if (scope?.propagationContext.baggage == null) { - scope?.propagationContext.baggage = - SentryBaggage({}, logger: _options.logger); - scope?.propagationContext.baggage?.setValuesFromScope(scope, _options); - } if (scope != null) { + scope.propagationContext.baggage ??= + SentryBaggage({}, logger: _options.logger) + ..setValuesFromScope(scope, _options); traceContext = SentryTraceContextHeader.fromBaggage( scope.propagationContext.baggage!); } + } else { + traceContext.replayId = scope?.replayId; } final envelope = SentryEnvelope.fromEvent( diff --git a/dart/lib/src/sentry_trace_context_header.dart b/dart/lib/src/sentry_trace_context_header.dart index d1ee5368af..9b34ef72f5 100644 --- a/dart/lib/src/sentry_trace_context_header.dart +++ b/dart/lib/src/sentry_trace_context_header.dart @@ -1,3 +1,5 @@ +import 'package:meta/meta.dart'; + import 'protocol/sentry_id.dart'; import 'sentry_baggage.dart'; import 'sentry_options.dart'; @@ -13,6 +15,7 @@ class SentryTraceContextHeader { this.transaction, this.sampleRate, this.sampled, + this.replayId, }); final SentryId traceId; @@ -27,6 +30,9 @@ class SentryTraceContextHeader { final String? sampleRate; final String? sampled; + @internal + SentryId? replayId; + /// Deserializes a [SentryTraceContextHeader] from JSON [Map]. factory SentryTraceContextHeader.fromJson(Map json) { return SentryTraceContextHeader( @@ -39,6 +45,8 @@ class SentryTraceContextHeader { transaction: json['transaction'], sampleRate: json['sample_rate'], sampled: json['sampled'], + replayId: + json['replay_id'] == null ? null : SentryId.fromId(json['replay_id']), ); } @@ -55,6 +63,7 @@ class SentryTraceContextHeader { if (transaction != null) 'transaction': transaction, if (sampleRate != null) 'sample_rate': sampleRate, if (sampled != null) 'sampled': sampled, + if (replayId != null) 'replay_id': replayId.toString(), }; } @@ -88,6 +97,9 @@ class SentryTraceContextHeader { if (sampled != null) { baggage.setSampled(sampled!); } + if (replayId != null) { + baggage.setReplayId(replayId.toString()); + } return baggage; } @@ -97,6 +109,7 @@ class SentryTraceContextHeader { baggage.get('sentry-public_key').toString(), release: baggage.get('sentry-release'), environment: baggage.get('sentry-environment'), + replayId: baggage.getReplayId(), ); } } diff --git a/dart/test/protocol/sentry_baggage_header_test.dart b/dart/test/protocol/sentry_baggage_header_test.dart index 3e8555aba9..910929776e 100644 --- a/dart/test/protocol/sentry_baggage_header_test.dart +++ b/dart/test/protocol/sentry_baggage_header_test.dart @@ -22,11 +22,23 @@ void main() { baggage.setTransaction('transaction'); baggage.setSampleRate('1.0'); baggage.setSampled('false'); + final replayId = SentryId.newId().toString(); + baggage.setReplayId(replayId); final baggageHeader = SentryBaggageHeader.fromBaggage(baggage); - expect(baggageHeader.value, - 'sentry-trace_id=$id,sentry-public_key=publicKey,sentry-release=release,sentry-environment=environment,sentry-user_id=userId,sentry-user_segment=userSegment,sentry-transaction=transaction,sentry-sample_rate=1.0,sentry-sampled=false'); + expect( + baggageHeader.value, + 'sentry-trace_id=$id,' + 'sentry-public_key=publicKey,' + 'sentry-release=release,' + 'sentry-environment=environment,' + 'sentry-user_id=userId,' + 'sentry-user_segment=userSegment,' + 'sentry-transaction=transaction,' + 'sentry-sample_rate=1.0,' + 'sentry-sampled=false,' + 'sentry-replay_id=$replayId'); }); }); } diff --git a/dart/test/scope_test.dart b/dart/test/scope_test.dart index 66cc543b6b..6593a58638 100644 --- a/dart/test/scope_test.dart +++ b/dart/test/scope_test.dart @@ -86,6 +86,14 @@ void main() { expect(sut.fingerprint, fingerprints); }); + test('sets replay ID', () { + final sut = fixture.getSut(); + + sut.replayId = SentryId.fromId('1'); + + expect(sut.replayId, SentryId.fromId('1')); + }); + test('adds $Breadcrumb', () { final sut = fixture.getSut(); @@ -305,6 +313,7 @@ void main() { sut.level = SentryLevel.debug; sut.transaction = 'test'; sut.span = null; + sut.replayId = SentryId.newId(); final user = SentryUser(id: 'test'); sut.setUser(user); @@ -320,21 +329,15 @@ void main() { sut.clear(); expect(sut.breadcrumbs.length, 0); - expect(sut.level, null); - expect(sut.transaction, null); expect(sut.span, null); - expect(sut.user, null); - expect(sut.fingerprint.length, 0); - expect(sut.tags.length, 0); - expect(sut.extra.length, 0); - expect(sut.eventProcessors.length, 0); + expect(sut.replayId, isNull); }); test('clones', () async { @@ -347,6 +350,7 @@ void main() { sut.addAttachment(SentryAttachment.fromIntList([0, 0, 0, 0], 'test.txt')); sut.span = NoOpSentrySpan(); sut.level = SentryLevel.warning; + sut.replayId = SentryId.newId(); await sut.setUser(SentryUser(id: 'id')); await sut.setTag('key', 'vakye'); await sut.setExtra('key', 'vakye'); @@ -367,6 +371,7 @@ void main() { true, ); expect(sut.span, clone.span); + expect(sut.replayId, clone.replayId); }); test('clone does not additionally call observers', () async { diff --git a/dart/test/sentry_client_test.dart b/dart/test/sentry_client_test.dart index 153c1515f0..d89a6323fd 100644 --- a/dart/test/sentry_client_test.dart +++ b/dart/test/sentry_client_test.dart @@ -812,7 +812,8 @@ void main() { ..fingerprint = fingerprint ..addBreadcrumb(crumb) ..setTag(scopeTagKey, scopeTagValue) - ..setExtra(scopeExtraKey, scopeExtraValue); + ..setExtra(scopeExtraKey, scopeExtraValue) + ..replayId = SentryId.fromId('1'); scope.setUser(user); }); @@ -838,6 +839,8 @@ void main() { scopeExtraKey: scopeExtraValue, eventExtraKey: eventExtraValue, }); + expect( + capturedEnvelope.header.traceContext?.replayId, SentryId.fromId('1')); }); }); @@ -1324,6 +1327,7 @@ void main() { final client = fixture.getSut(); final scope = Scope(fixture.options); + scope.replayId = SentryId.newId(); scope.span = SentrySpan(fixture.tracer, fixture.tracer.context, MockHub()); @@ -1331,6 +1335,7 @@ void main() { final envelope = fixture.transport.envelopes.first; expect(envelope.header.traceContext, isNotNull); + expect(envelope.header.traceContext?.replayId, scope.replayId); }); test('captureEvent adds attachments from hint', () async { @@ -1387,12 +1392,14 @@ void main() { final context = SentryTraceContextHeader.fromJson({ 'trace_id': '${tr.eventId}', 'public_key': '123', + 'replay_id': '456', }); await client.captureTransaction(tr, traceContext: context); final envelope = fixture.transport.envelopes.first; expect(envelope.header.traceContext, isNotNull); + expect(envelope.header.traceContext?.replayId, SentryId.fromId('456')); }); test('captureUserFeedback calls flush', () async { diff --git a/dart/test/sentry_trace_context_header_test.dart b/dart/test/sentry_trace_context_header_test.dart index c4f856f344..f4aaa4620a 100644 --- a/dart/test/sentry_trace_context_header_test.dart +++ b/dart/test/sentry_trace_context_header_test.dart @@ -14,7 +14,8 @@ void main() { 'user_segment': 'user_segment', 'transaction': 'transaction', 'sample_rate': '1.0', - 'sampled': 'false' + 'sampled': 'false', + 'replay_id': '456', }; final context = SentryTraceContextHeader.fromJson(mapJson); @@ -29,6 +30,7 @@ void main() { expect(context.transaction, 'transaction'); expect(context.sampleRate, '1.0'); expect(context.sampled, 'false'); + expect(context.replayId, SentryId.fromId('456')); }); test('toJson', () { @@ -40,8 +42,19 @@ void main() { test('to baggage', () { final baggage = context.toBaggage(); - expect(baggage.toHeaderString(), - 'sentry-trace_id=${id.toString()},sentry-public_key=123,sentry-release=release,sentry-environment=environment,sentry-user_id=user_id,sentry-user_segment=user_segment,sentry-transaction=transaction,sentry-sample_rate=1.0,sentry-sampled=false'); + expect( + baggage.toHeaderString(), + 'sentry-trace_id=${id.toString()},' + 'sentry-public_key=123,' + 'sentry-release=release,' + 'sentry-environment=environment,' + 'sentry-user_id=user_id,' + 'sentry-user_segment=user_segment,' + 'sentry-transaction=transaction,' + 'sentry-sample_rate=1.0,' + 'sentry-sampled=false,' + 'sentry-replay_id=456', + ); }); }); } diff --git a/dart/test/sentry_trace_context_test.dart b/dart/test/sentry_trace_context_test.dart index dde599bef1..13dbe1fd62 100644 --- a/dart/test/sentry_trace_context_test.dart +++ b/dart/test/sentry_trace_context_test.dart @@ -16,27 +16,31 @@ void main() { expect(map['description'], 'desc'); expect(map['status'], 'aborted'); expect(map['origin'], 'auto.ui'); + expect(map['replay_id'], isNotNull); }); test('fromJson deserializes', () { final map = { 'op': 'op', - 'span_id': '0000000000000000', - 'trace_id': '00000000000000000000000000000000', - 'parent_span_id': '0000000000000000', + 'span_id': '0000000000000001', + 'trace_id': '00000000000000000000000000000002', + 'parent_span_id': '0000000000000003', 'description': 'desc', 'status': 'aborted', - 'origin': 'auto.ui' + 'origin': 'auto.ui', + 'replay_id': '00000000000000000000000000000004' }; final traceContext = SentryTraceContext.fromJson(map); expect(traceContext.description, 'desc'); expect(traceContext.operation, 'op'); - expect(traceContext.spanId.toString(), '0000000000000000'); - expect(traceContext.traceId.toString(), '00000000000000000000000000000000'); - expect(traceContext.parentSpanId.toString(), '0000000000000000'); + expect(traceContext.spanId.toString(), '0000000000000001'); + expect(traceContext.traceId.toString(), '00000000000000000000000000000002'); + expect(traceContext.parentSpanId.toString(), '0000000000000003'); expect(traceContext.status.toString(), 'aborted'); expect(traceContext.sampled, true); + expect( + traceContext.replayId.toString(), '00000000000000000000000000000004'); }); } @@ -48,6 +52,7 @@ class Fixture { description: 'desc', sampled: true, status: SpanStatus.aborted(), - origin: 'auto.ui'); + origin: 'auto.ui', + replayId: SentryId.newId()); } } diff --git a/flutter/android/build.gradle b/flutter/android/build.gradle index 3242dddd64..f761c24941 100644 --- a/flutter/android/build.gradle +++ b/flutter/android/build.gradle @@ -1,12 +1,12 @@ buildscript { - ext.kotlin_version = '1.6.21' + ext.kotlin_version = '1.8.0' repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.2.2' + classpath 'com.android.tools.build:gradle:7.4.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/flutter/android/gradle.properties b/flutter/android/gradle.properties index 8bd86f6805..d9cf55df7c 100644 --- a/flutter/android/gradle.properties +++ b/flutter/android/gradle.properties @@ -1 +1,2 @@ org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true diff --git a/flutter/android/gradlew b/flutter/android/gradlew new file mode 120000 index 0000000000..a9a4ed1b8d --- /dev/null +++ b/flutter/android/gradlew @@ -0,0 +1 @@ +../example/android/gradlew \ No newline at end of file diff --git a/flutter/android/gradlew.bat b/flutter/android/gradlew.bat new file mode 120000 index 0000000000..2418d41fea --- /dev/null +++ b/flutter/android/gradlew.bat @@ -0,0 +1 @@ +../example/android/gradlew.bat \ No newline at end of file diff --git a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutter.kt b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutter.kt index c06a8b0dc2..fb17c3af0f 100644 --- a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutter.kt +++ b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutter.kt @@ -1,6 +1,7 @@ package io.sentry.flutter import io.sentry.SentryLevel +import io.sentry.SentryReplayOptions import io.sentry.android.core.BuildConfig import io.sentry.android.core.SentryAndroidOptions import io.sentry.protocol.SdkVersion @@ -119,6 +120,18 @@ class SentryFlutter( data.getIfNotNull("readTimeoutMillis") { options.readTimeoutMillis = it } + + data.getIfNotNull>("replay") { + updateReplayOptions(options.experimental.sessionReplay, it) + } + } + + fun updateReplayOptions( + options: SentryReplayOptions, + data: Map, + ) { + options.sessionSampleRate = data["sessionSampleRate"] as? Double + options.errorSampleRate = data["errorSampleRate"] as? Double } } diff --git a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt index 4f154a2465..7db86b0604 100644 --- a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt +++ b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt @@ -25,16 +25,20 @@ import io.sentry.android.core.SentryAndroid import io.sentry.android.core.SentryAndroidOptions import io.sentry.android.core.performance.AppStartMetrics import io.sentry.android.core.performance.TimeSpan +import io.sentry.android.replay.ReplayIntegration import io.sentry.protocol.DebugImage import io.sentry.protocol.SdkVersion import io.sentry.protocol.SentryId import io.sentry.protocol.User +import io.sentry.transport.CurrentDateProvider +import java.io.File import java.lang.ref.WeakReference class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { private lateinit var channel: MethodChannel private lateinit var context: Context private lateinit var sentryFlutter: SentryFlutter + private lateinit var replay: ReplayIntegration private var activity: WeakReference? = null private var framesTracker: ActivityFramesTracker? = null @@ -54,7 +58,11 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { ) } - override fun onMethodCall(call: MethodCall, result: Result) { + @Suppress("CyclomaticComplexMethod") + override fun onMethodCall( + call: MethodCall, + result: Result, + ) { when (call.method) { "initNativeSdk" -> initNativeSdk(call, result) "captureEnvelope" -> captureEnvelope(call, result) @@ -74,6 +82,8 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { "removeTag" -> removeTag(call.argument("key"), result) "loadContexts" -> loadContexts(result) "displayRefreshRate" -> displayRefreshRate(result) + "addReplayScreenshot" -> addReplayScreenshot(call.argument("path"), call.argument("timestamp"), result) + "sendReplayForEvent" -> sendReplayForEvent(call.argument("eventId"), call.argument("isCrash"), result) else -> result.notImplemented() } } @@ -103,7 +113,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { // Stub } - private fun initNativeSdk(call: MethodCall, result: Result) { + private fun initNativeSdk( + call: MethodCall, + result: Result, + ) { if (!this::context.isInitialized) { result.error("1", "Context is null", null) return @@ -123,6 +136,27 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } options.beforeSend = BeforeSendCallbackImpl(options.sdkVersion) + + // Replace the default ReplayIntegration with a Flutter-specific recorder. + options.integrations.removeAll { it is ReplayIntegration } + val cacheDirPath = options.cacheDirPath + val replayOptions = options.experimental.sessionReplay + val isReplayEnabled = replayOptions.isSessionReplayEnabled || replayOptions.isSessionReplayForErrorsEnabled + if (cacheDirPath != null && isReplayEnabled) { + replay = + ReplayIntegration( + context, + dateProvider = CurrentDateProvider.getInstance(), + recorderProvider = { SentryFlutterReplayRecorder(channel, replay) }, + recorderConfigProvider = null, + replayCacheProvider = null, + ) + + options.addIntegration(replay) + options.setReplayController(replay) + } else { + options.setReplayController(null) + } } result.success("") } @@ -145,6 +179,7 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } else { val appStartTimeMillis = DateUtils.nanosToMillis(appStartTime.nanoTimestamp().toDouble()) val item = + mutableMapOf( "pluginRegistrationTime" to pluginRegistrationTime, "appStartTime" to appStartTimeMillis, @@ -228,7 +263,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.success(null) } - private fun endNativeFrames(id: String?, result: Result) { + private fun endNativeFrames( + id: String?, + result: Result, + ) { val activity = activity?.get() if (!sentryFlutter.autoPerformanceTracingEnabled || activity == null || id == null) { if (id == null) { @@ -248,16 +286,21 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { if (total == 0 && slow == 0 && frozen == 0) { result.success(null) } else { - val frames = mapOf( - "totalFrames" to total, - "slowFrames" to slow, - "frozenFrames" to frozen, - ) + val frames = + mapOf( + "totalFrames" to total, + "slowFrames" to slow, + "frozenFrames" to frozen, + ) result.success(frames) } } - private fun setContexts(key: String?, value: Any?, result: Result) { + private fun setContexts( + key: String?, + value: Any?, + result: Result, + ) { if (key == null || value == null) { result.success("") return @@ -269,7 +312,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } } - private fun removeContexts(key: String?, result: Result) { + private fun removeContexts( + key: String?, + result: Result, + ) { if (key == null) { result.success("") return @@ -281,7 +327,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } } - private fun setUser(user: Map?, result: Result) { + private fun setUser( + user: Map?, + result: Result, + ) { if (user != null) { val options = HubAdapter.getInstance().options val userInstance = User.fromMap(user, options) @@ -292,7 +341,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.success("") } - private fun addBreadcrumb(breadcrumb: Map?, result: Result) { + private fun addBreadcrumb( + breadcrumb: Map?, + result: Result, + ) { if (breadcrumb != null) { val options = HubAdapter.getInstance().options val breadcrumbInstance = Breadcrumb.fromMap(breadcrumb, options) @@ -307,7 +359,11 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.success("") } - private fun setExtra(key: String?, value: String?, result: Result) { + private fun setExtra( + key: String?, + value: String?, + result: Result, + ) { if (key == null || value == null) { result.success("") return @@ -317,7 +373,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.success("") } - private fun removeExtra(key: String?, result: Result) { + private fun removeExtra( + key: String?, + result: Result, + ) { if (key == null) { result.success("") return @@ -327,7 +386,11 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.success("") } - private fun setTag(key: String?, value: String?, result: Result) { + private fun setTag( + key: String?, + value: String?, + result: Result, + ) { if (key == null || value == null) { result.success("") return @@ -337,7 +400,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.success("") } - private fun removeTag(key: String?, result: Result) { + private fun removeTag( + key: String?, + result: Result, + ) { if (key == null) { result.success("") return @@ -347,7 +413,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.success("") } - private fun captureEnvelope(call: MethodCall, result: Result) { + private fun captureEnvelope( + call: MethodCall, + result: Result, + ) { if (!Sentry.isEnabled()) { result.error("1", "The Sentry Android SDK is disabled", null) return @@ -405,7 +474,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { private class BeforeSendCallbackImpl( private val sdkVersion: SdkVersion?, ) : SentryOptions.BeforeSendCallback { - override fun execute(event: SentryEvent, hint: Hint): SentryEvent { + override fun execute( + event: SentryEvent, + hint: Hint, + ): SentryEvent { setEventOriginTag(event) addPackages(event, sdkVersion) return event @@ -413,10 +485,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } companion object { - private const val flutterSdk = "sentry.dart.flutter" private const val androidSdk = "sentry.java.android.flutter" private const val nativeSdk = "sentry.native.android.flutter" + private fun setEventOriginTag(event: SentryEvent) { event.sdk?.let { when (it.name) { @@ -437,7 +509,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { event.setTag("event.environment", environment) } - private fun addPackages(event: SentryEvent, sdk: SdkVersion?) { + private fun addPackages( + event: SentryEvent, + sdk: SdkVersion?, + ) { event.sdk?.let { if (it.name == flutterSdk) { sdk?.packageSet?.forEach { sentryPackage -> @@ -466,4 +541,30 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { ) result.success(serializedScope) } + + private fun addReplayScreenshot( + path: String?, + timestamp: Long?, + result: Result, + ) { + if (path == null || timestamp == null) { + result.error("5", "Arguments are null", null) + return + } + replay.onScreenshotRecorded(File(path), timestamp) + result.success("") + } + + private fun sendReplayForEvent( + eventId: String?, + isCrash: Boolean?, + result: Result, + ) { + if (eventId == null || isCrash == null) { + result.error("5", "Arguments are null", null) + return + } + replay.sendReplay(isCrash, eventId, null) + result.success(replay.getReplayId().toString()) + } } diff --git a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt new file mode 100644 index 0000000000..41209f75b6 --- /dev/null +++ b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt @@ -0,0 +1,72 @@ +package io.sentry.flutter + +import android.os.Handler +import android.os.Looper +import android.util.Log +import io.flutter.plugin.common.MethodChannel +import io.sentry.android.replay.Recorder +import io.sentry.android.replay.ReplayIntegration +import io.sentry.android.replay.ScreenshotRecorderConfig + +internal class SentryFlutterReplayRecorder( + private val channel: MethodChannel, + private val integration: ReplayIntegration, +) : Recorder { + override fun start(config: ScreenshotRecorderConfig) { + val cacheDirPath = integration.replayCacheDir?.absolutePath + if (cacheDirPath == null) { + Log.w("Sentry", "Replay cache directory is null, can't start replay recorder.") + return + } + Handler(Looper.getMainLooper()).post { + try { + channel.invokeMethod( + "ReplayRecorder.start", + mapOf( + "directory" to cacheDirPath, + "width" to config.recordingWidth, + "height" to config.recordingHeight, + "frameRate" to config.frameRate, + "replayId" to integration.getReplayId().toString(), + ), + ) + } catch (ignored: Exception) { + Log.w("Sentry", "Failed to start replay recorder", ignored) + } + } + } + + override fun resume() { + Handler(Looper.getMainLooper()).post { + try { + channel.invokeMethod("ReplayRecorder.resume", null) + } catch (ignored: Exception) { + Log.w("Sentry", "Failed to resume replay recorder", ignored) + } + } + } + + override fun pause() { + Handler(Looper.getMainLooper()).post { + try { + channel.invokeMethod("ReplayRecorder.pause", null) + } catch (ignored: Exception) { + Log.w("Sentry", "Failed to pause replay recorder", ignored) + } + } + } + + override fun stop() { + Handler(Looper.getMainLooper()).post { + try { + channel.invokeMethod("ReplayRecorder.stop", null) + } catch (ignored: Exception) { + Log.w("Sentry", "Failed to stop replay recorder", ignored) + } + } + } + + override fun close() { + stop() + } +} diff --git a/flutter/android/src/test/kotlin/io/sentry/flutter/SentryFlutterTest.kt b/flutter/android/src/test/kotlin/io/sentry/flutter/SentryFlutterTest.kt index 724559bb76..1d8141f163 100644 --- a/flutter/android/src/test/kotlin/io/sentry/flutter/SentryFlutterTest.kt +++ b/flutter/android/src/test/kotlin/io/sentry/flutter/SentryFlutterTest.kt @@ -60,6 +60,17 @@ class SentryFlutterTest { assertEquals(9006, fixture.options.connectionTimeoutMillis) assertEquals(9007, fixture.options.readTimeoutMillis) + + assertEquals(0.5, fixture.options.experimental.sessionReplay.sessionSampleRate) + assertEquals(0.6, fixture.options.experimental.sessionReplay.errorSampleRate) + + // Note: these are currently read-only in SentryReplayOptions so we're only asserting the default values here to + // know when there's a change in the native SDK, as it may require a manual change in the Flutter implementation. + assertEquals(100_000, fixture.options.experimental.sessionReplay.bitRate) + assertEquals(1, fixture.options.experimental.sessionReplay.frameRate) + assertEquals(30_000L, fixture.options.experimental.sessionReplay.errorReplayDuration) + assertEquals(5000L, fixture.options.experimental.sessionReplay.sessionSegmentDuration) + assertEquals(60 * 60 * 1000L, fixture.options.experimental.sessionReplay.sessionDuration) } @Test @@ -127,6 +138,11 @@ class Fixture { "enableAutoPerformanceTracing" to true, "connectionTimeoutMillis" to 9006, "readTimeoutMillis" to 9007, + "replay" to + mapOf( + "sessionSampleRate" to 0.5, + "errorSampleRate" to 0.6, + ), ) fun getSut(): SentryFlutter { diff --git a/flutter/example/android/app/build.gradle b/flutter/example/android/app/build.gradle index e9ac4161a5..ed3e1a1b6b 100644 --- a/flutter/example/android/app/build.gradle +++ b/flutter/example/android/app/build.gradle @@ -65,8 +65,6 @@ android { } } - // TODO: we need to fix CI as the version 21.1 (default) is not installed by default on - // GH Actions. ndkVersion "25.1.8937393" externalNativeBuild { diff --git a/flutter/example/android/build.gradle b/flutter/example/android/build.gradle index 693111bbb3..741fa44529 100644 --- a/flutter/example/android/build.gradle +++ b/flutter/example/android/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = '1.6.21' + ext.kotlin_version = '1.8.0' repositories { google() @@ -8,7 +8,7 @@ buildscript { dependencies { classpath 'io.sentry:sentry-android-gradle-plugin:4.5.0' - classpath 'com.android.tools.build:gradle:7.2.2' + classpath 'com.android.tools.build:gradle:7.4.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath 'io.github.howardpang:androidNativeBundle:1.1.3' } diff --git a/flutter/example/integration_test/all.dart b/flutter/example/integration_test/all.dart index 69cc5a6641..77a4b2923d 100644 --- a/flutter/example/integration_test/all.dart +++ b/flutter/example/integration_test/all.dart @@ -1,8 +1,10 @@ // Workaround for https://github.com/flutter/flutter/issues/101031 import 'integration_test.dart' as a; import 'profiling_test.dart' as b; +import 'replay_test.dart' as c; void main() { a.main(); b.main(); + c.main(); } diff --git a/flutter/example/integration_test/replay_test.dart b/flutter/example/integration_test/replay_test.dart new file mode 100644 index 0000000000..00d85ca14f --- /dev/null +++ b/flutter/example/integration_test/replay_test.dart @@ -0,0 +1,39 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +void main() { + group('Replay recording', () { + setUp(() async { + await SentryFlutter.init((options) { + // ignore: invalid_use_of_internal_member + options.automatedTestMode = true; + options.dsn = 'https://abc@def.ingest.sentry.io/1234567'; + options.debug = true; + options.experimental.replay.sessionSampleRate = 1.0; + }); + }); + + tearDown(() async { + await Sentry.close(); + }); + + test('native binding is initialized', () async { + // ignore: invalid_use_of_internal_member + expect(SentryFlutter.native, isNotNull); + }); + + test('session replay is captured', () async { + // TODO add when the beforeSend callback is implemented for replays. + }); + + 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: Platform.isAndroid + ? false + : "Replay recording is not supported on this platform"); +} diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index 86da143e31..7744a884e7 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -90,6 +90,9 @@ Future setupSentry( options.maxResponseBodySize = MaxResponseBodySize.always; options.navigatorKey = navigatorKey; + options.experimental.replay.sessionSampleRate = 1.0; + options.experimental.replay.errorSampleRate = 1.0; + _isIntegrationTest = isIntegrationTest; if (_isIntegrationTest) { options.dist = '1'; diff --git a/flutter/lib/sentry_flutter.dart b/flutter/lib/sentry_flutter.dart index d15c8b7a70..bea9016630 100644 --- a/flutter/lib/sentry_flutter.dart +++ b/flutter/lib/sentry_flutter.dart @@ -8,6 +8,7 @@ export 'src/integrations/load_release_integration.dart'; export 'src/navigation/sentry_navigator_observer.dart'; export 'src/sentry_flutter.dart'; export 'src/sentry_flutter_options.dart'; +export 'src/sentry_replay_options.dart'; export 'src/flutter_sentry_attachment.dart'; export 'src/sentry_asset_bundle.dart'; export 'src/integrations/on_error_integration.dart'; diff --git a/flutter/lib/src/event_processor/replay_event_processor.dart b/flutter/lib/src/event_processor/replay_event_processor.dart new file mode 100644 index 0000000000..4be68a4d00 --- /dev/null +++ b/flutter/lib/src/event_processor/replay_event_processor.dart @@ -0,0 +1,22 @@ +import 'dart:async'; + +import 'package:sentry/sentry.dart'; + +import '../native/sentry_native_binding.dart'; + +class ReplayEventProcessor implements EventProcessor { + final SentryNativeBinding _binding; + + ReplayEventProcessor(this._binding); + + @override + Future apply(SentryEvent event, Hint hint) async { + if (event.eventId != SentryId.empty() && + event.exceptions?.isNotEmpty == true) { + final isCrash = + event.exceptions!.any((e) => e.mechanism?.handled == false); + await _binding.sendReplayForEvent(event.eventId, isCrash); + } + return event; + } +} diff --git a/flutter/lib/src/native/java/sentry_native_java.dart b/flutter/lib/src/native/java/sentry_native_java.dart index 1b0ef13cc5..0ce7117afd 100644 --- a/flutter/lib/src/native/java/sentry_native_java.dart +++ b/flutter/lib/src/native/java/sentry_native_java.dart @@ -1,10 +1,121 @@ +import 'dart:io'; +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'; // Note: currently this doesn't do anything. Later, it shall be used with // generated JNI bindings. See https://github.com/getsentry/sentry-dart/issues/1444 @internal class SentryNativeJava extends SentryNativeChannel { + ScreenshotRecorder? _replayRecorder; + late final SentryFlutterOptions _options; SentryNativeJava(super.options, super.channel); + + @override + Future init(SentryFlutterOptions options) 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 = options; + + // 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 'ReplayRecorder.start': + final replayId = + SentryId.fromId(call.arguments['replayId'] as String); + + _startRecorder( + call.arguments['directory'] as String, + ScreenshotRecorderConfig( + width: call.arguments['width'] as int, + height: call.arguments['height'] as int, + frameRate: call.arguments['frameRate'] as int, + ), + ); + + Sentry.configureScope((s) { + // ignore: invalid_use_of_internal_member + s.replayId = replayId; + }); + + break; + case 'ReplayRecorder.stop': + await _replayRecorder?.stop(); + _replayRecorder = null; + + Sentry.configureScope((s) { + // ignore: invalid_use_of_internal_member + s.replayId = null; + }); + + break; + case 'ReplayRecorder.pause': + await _replayRecorder?.stop(); + break; + case 'ReplayRecorder.resume': + _replayRecorder?.start(); + break; + default: + throw UnimplementedError('Method ${call.method} not implemented'); + } + }); + } + + return super.init(options); + } + + void _startRecorder(String cacheDir, ScreenshotRecorderConfig 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 + // image size: 25401 bytes + // save to file: 3.677 ms + // onScreenshotRecorded1: 1.237 ms + // released and exiting callback: 0.021 ms + ScreenshotRecorderCallback callback = (image) async { + var imageData = await image.toByteData(format: ImageByteFormat.png); + if (imageData != null) { + final timestamp = DateTime.now().millisecondsSinceEpoch; + final filePath = "$cacheDir/$timestamp.png"; + + _options.logger( + SentryLevel.debug, + 'Replay: Saving screenshot to $filePath (' + '${image.width}x${image.height} pixels, ' + '${imageData.lengthInBytes} bytes)'); + await File(filePath).writeAsBytes(imageData.buffer.asUint8List()); + + try { + await channel.invokeMethod( + 'addReplayScreenshot', + {'path': filePath, 'timestamp': timestamp}, + ); + } catch (error, stackTrace) { + _options.logger( + SentryLevel.error, + 'Native call `addReplayScreenshot` failed', + exception: error, + stackTrace: stackTrace, + ); + } + } + }; + + _replayRecorder = ScreenshotRecorder( + config, + callback, + _options, + )..start(); + } } diff --git a/flutter/lib/src/native/sentry_native_binding.dart b/flutter/lib/src/native/sentry_native_binding.dart index 002790fc32..20afea7b4c 100644 --- a/flutter/lib/src/native/sentry_native_binding.dart +++ b/flutter/lib/src/native/sentry_native_binding.dart @@ -57,4 +57,6 @@ abstract class SentryNativeBinding { Future pauseAppHangTracking(); Future resumeAppHangTracking(); + + Future sendReplayForEvent(SentryId eventId, bool isCrash); } diff --git a/flutter/lib/src/native/sentry_native_channel.dart b/flutter/lib/src/native/sentry_native_channel.dart index 0a3b97820d..e8f3bb1404 100644 --- a/flutter/lib/src/native/sentry_native_channel.dart +++ b/flutter/lib/src/native/sentry_native_channel.dart @@ -22,15 +22,16 @@ class SentryNativeChannel @override final SentryFlutterOptions options; - final SentrySafeMethodChannel _channel; + @protected + final SentrySafeMethodChannel channel; SentryNativeChannel(this.options, MethodChannel channel) - : _channel = SentrySafeMethodChannel(channel, options); + : channel = SentrySafeMethodChannel(channel, options); @override Future init(SentryFlutterOptions options) async { assert(this.options == options); - return _channel.invokeMethod('initNativeSdk', { + return channel.invokeMethod('initNativeSdk', { 'dsn': options.dsn, 'debug': options.debug, 'environment': options.environment, @@ -66,37 +67,40 @@ class SentryNativeChannel 'readTimeoutMillis': options.readTimeout.inMilliseconds, 'appHangTimeoutIntervalMillis': options.appHangTimeoutInterval.inMilliseconds, + 'replay': { + 'sessionSampleRate': options.experimental.replay.sessionSampleRate, + 'errorSampleRate': options.experimental.replay.errorSampleRate, + }, }); } @override - Future close() async => _channel.invokeMethod('closeNativeSdk'); + Future close() async => channel.invokeMethod('closeNativeSdk'); @override Future fetchNativeAppStart() async { final json = - await _channel.invokeMapMethod('fetchNativeAppStart'); + await channel.invokeMapMethod('fetchNativeAppStart'); return (json != null) ? NativeAppStart.fromJson(json) : null; } @override Future captureEnvelope( Uint8List envelopeData, bool containsUnhandledException) { - return _channel.invokeMethod( + return channel.invokeMethod( 'captureEnvelope', [envelopeData, containsUnhandledException]); } @override Future?> loadContexts() => - _channel.invokeMapMethod('loadContexts'); + channel.invokeMapMethod('loadContexts'); @override - Future beginNativeFrames() => - _channel.invokeMethod('beginNativeFrames'); + Future beginNativeFrames() => channel.invokeMethod('beginNativeFrames'); @override Future endNativeFrames(SentryId id) async { - final json = await _channel.invokeMapMethod( + final json = await channel.invokeMapMethod( 'endNativeFrames', {'id': id.toString()}); return (json != null) ? NativeFrames.fromJson(json) : null; } @@ -106,7 +110,7 @@ class SentryNativeChannel final normalizedUser = user?.copyWith( data: MethodChannelHelper.normalizeMap(user.data), ); - await _channel.invokeMethod( + await channel.invokeMethod( 'setUser', {'user': normalizedUser?.toJson()}, ); @@ -117,42 +121,42 @@ class SentryNativeChannel final normalizedBreadcrumb = breadcrumb.copyWith( data: MethodChannelHelper.normalizeMap(breadcrumb.data), ); - await _channel.invokeMethod( + await channel.invokeMethod( 'addBreadcrumb', {'breadcrumb': normalizedBreadcrumb.toJson()}, ); } @override - Future clearBreadcrumbs() => _channel.invokeMethod('clearBreadcrumbs'); + Future clearBreadcrumbs() => channel.invokeMethod('clearBreadcrumbs'); @override - Future setContexts(String key, dynamic value) => _channel.invokeMethod( + Future setContexts(String key, dynamic value) => channel.invokeMethod( 'setContexts', {'key': key, 'value': MethodChannelHelper.normalize(value)}, ); @override Future removeContexts(String key) => - _channel.invokeMethod('removeContexts', {'key': key}); + channel.invokeMethod('removeContexts', {'key': key}); @override - Future setExtra(String key, dynamic value) => _channel.invokeMethod( + Future setExtra(String key, dynamic value) => channel.invokeMethod( 'setExtra', {'key': key, 'value': MethodChannelHelper.normalize(value)}, ); @override Future removeExtra(String key) => - _channel.invokeMethod('removeExtra', {'key': key}); + channel.invokeMethod('removeExtra', {'key': key}); @override Future setTag(String key, String value) => - _channel.invokeMethod('setTag', {'key': key, 'value': value}); + channel.invokeMethod('setTag', {'key': key, 'value': value}); @override Future removeTag(String key) => - _channel.invokeMethod('removeTag', {'key': key}); + channel.invokeMethod('removeTag', {'key': key}); @override int? startProfiler(SentryId traceId) => @@ -160,12 +164,12 @@ class SentryNativeChannel @override Future discardProfiler(SentryId traceId) => - _channel.invokeMethod('discardProfiler', traceId.toString()); + channel.invokeMethod('discardProfiler', traceId.toString()); @override Future?> collectProfile( SentryId traceId, int startTimeNs, int endTimeNs) => - _channel.invokeMapMethod('collectProfile', { + channel.invokeMapMethod('collectProfile', { 'traceId': traceId.toString(), 'startTime': startTimeNs, 'endTime': endTimeNs, @@ -174,7 +178,7 @@ class SentryNativeChannel @override Future?> loadDebugImages() => tryCatchAsync('loadDebugImages', () async { - final images = await _channel + final images = await channel .invokeListMethod>('loadImageList'); return images ?.map((e) => e.cast()) @@ -184,13 +188,20 @@ class SentryNativeChannel @override Future displayRefreshRate() => - _channel.invokeMethod('displayRefreshRate'); + channel.invokeMethod('displayRefreshRate'); @override Future pauseAppHangTracking() => - _channel.invokeMethod('pauseAppHangTracking'); + channel.invokeMethod('pauseAppHangTracking'); @override Future resumeAppHangTracking() => - _channel.invokeMethod('resumeAppHangTracking'); + channel.invokeMethod('resumeAppHangTracking'); + + @override + Future sendReplayForEvent(SentryId eventId, bool isCrash) => + channel.invokeMethod('sendReplayForEvent', { + 'eventId': eventId.toString(), + 'isCrash': isCrash, + }).then((value) => SentryId.fromId(value as String)); } diff --git a/flutter/lib/src/native/sentry_safe_method_channel.dart b/flutter/lib/src/native/sentry_safe_method_channel.dart index aa44c08f44..8cd258c8dc 100644 --- a/flutter/lib/src/native/sentry_safe_method_channel.dart +++ b/flutter/lib/src/native/sentry_safe_method_channel.dart @@ -14,6 +14,10 @@ class SentrySafeMethodChannel with SentryNativeSafeInvoker { SentrySafeMethodChannel(this._channel, this.options); + void setMethodCallHandler( + Future Function(MethodCall call)? handler) => + _channel.setMethodCallHandler(handler); + @optionalTypeArgs Future invokeMethod(String method, [dynamic args]) => tryCatchAsync(method, () => _channel.invokeMethod(method, args)); diff --git a/flutter/lib/src/replay/recorder.dart b/flutter/lib/src/replay/recorder.dart new file mode 100644 index 0000000000..ff64711d05 --- /dev/null +++ b/flutter/lib/src/replay/recorder.dart @@ -0,0 +1,134 @@ +import 'dart:async'; +import 'dart:math'; +import 'dart:ui'; + +import 'package:flutter/rendering.dart'; +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; + WidgetFilter? _widgetFilter; + late final Scheduler _scheduler; + bool warningLogged = false; + + ScreenshotRecorder(this._config, this._callback, SentryFlutterOptions options) + : _logger = options.logger, + _options = options.experimental.replay { + final frameDuration = Duration(milliseconds: 1000 ~/ _config.frameRate); + _scheduler = Scheduler(frameDuration, _capture, + options.bindingUtils.instance!.addPostFrameCallback); + + if (_options.redactAllText || _options.redactAllImages) { + _widgetFilter = WidgetFilter( + redactText: _options.redactAllText, + redactImages: _options.redactAllImages, + logger: _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 { + final context = sentryScreenshotWidgetGlobalKey.currentContext; + final renderObject = context?.findRenderObject() as RenderRepaintBoundary?; + if (context == null || renderObject == null) { + if (!warningLogged) { + _logger( + SentryLevel.warning, + "Replay: SentryScreenshotWidget is not attached. " + "Skipping replay capture."); + warningLogged = true; + } + return; + } + + 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. + final srcWidth = renderObject.size.width; + final srcHeight = renderObject.size.height; + final pixelRatio = + min(_config.width / srcWidth, _config.height / srcHeight); + + // First, we synchronously capture the image and enumerate widgets on the main UI loop. + final futureImage = renderObject.toImage(pixelRatio: pixelRatio); + + final filter = _widgetFilter; + if (filter != null) { + filter.obscure( + context, + pixelRatio, + Rect.fromLTWH(0, 0, srcWidth * pixelRatio, srcHeight * pixelRatio), + ); + } + + final blockingTime = watch.elapsedMilliseconds; + + // Then we draw the image and obscure collected coordinates asynchronously. + final recorder = PictureRecorder(); + final canvas = Canvas(recorder); + final image = await futureImage; + try { + canvas.drawImage(image, Offset.zero, Paint()); + } finally { + image.dispose(); + } + + if (filter != null) { + _obscureWidgets(canvas, filter.items); + } + + final picture = recorder.endRecording(); + + try { + final finalImage = await picture.toImage( + (srcWidth * pixelRatio).round(), (srcHeight * pixelRatio).round()); + try { + await _callback(finalImage); + } finally { + finalImage.dispose(); + } + } finally { + picture.dispose(); + } + + _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.", + exception: e, stackTrace: stackTrace); + } + } + + void _obscureWidgets(Canvas canvas, List items) { + final paint = Paint()..style = PaintingStyle.fill; + for (var item in items) { + paint.color = item.color; + canvas.drawRect(item.bounds, paint); + } + } +} diff --git a/flutter/lib/src/replay/recorder_config.dart b/flutter/lib/src/replay/recorder_config.dart new file mode 100644 index 0000000000..b7e4fd4c86 --- /dev/null +++ b/flutter/lib/src/replay/recorder_config.dart @@ -0,0 +1,11 @@ +import 'package:meta/meta.dart'; + +@internal +class ScreenshotRecorderConfig { + final int width; + final int height; + final int frameRate; + + ScreenshotRecorderConfig( + {required this.width, required this.height, required this.frameRate}); +} diff --git a/flutter/lib/src/replay/scheduler.dart b/flutter/lib/src/replay/scheduler.dart new file mode 100644 index 0000000000..4d246360e3 --- /dev/null +++ b/flutter/lib/src/replay/scheduler.dart @@ -0,0 +1,55 @@ +import 'package:flutter/scheduler.dart'; +import 'package:meta/meta.dart'; + +@internal +typedef SchedulerCallback = Future Function(Duration); + +/// This is a low-priority scheduler. +/// We're not using Timer.periodic() because it may schedule a callback +/// even if the previous call hasn't finished (or started) yet. +/// Instead, we manually schedule a callback with a given delay after the +/// previous callback finished. Therefore, if the capture takes too long, we +/// won't overload the system. We sacrifice the frame rate for performance. +@internal +class Scheduler { + final SchedulerCallback _callback; + final Duration _interval; + bool _running = false; + Future? _scheduled; + + final void Function(FrameCallback callback) _addPostFrameCallback; + + Scheduler(this._interval, this._callback, this._addPostFrameCallback); + + void start() { + _running = true; + if (_scheduled == null) { + _runAfterNextFrame(); + } + } + + Future stop() async { + _running = false; + final scheduled = _scheduled; + _scheduled = null; + if (scheduled != null) { + await scheduled; + } + } + + @pragma('vm:prefer-inline') + void _scheduleNext() { + _scheduled ??= Future.delayed(_interval, _runAfterNextFrame); + } + + @pragma('vm:prefer-inline') + void _runAfterNextFrame() { + _scheduled = null; + _addPostFrameCallback(_run); + } + + void _run(Duration sinceSchedulerEpoch) { + if (!_running) return; + _callback(sinceSchedulerEpoch).then((_) => _scheduleNext()); + } +} diff --git a/flutter/lib/src/replay/widget_filter.dart b/flutter/lib/src/replay/widget_filter.dart new file mode 100644 index 0000000000..83e069cb97 --- /dev/null +++ b/flutter/lib/src/replay/widget_filter.dart @@ -0,0 +1,133 @@ +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; +import 'package:sentry/sentry.dart'; + +import '../../sentry_flutter.dart'; + +@internal +class WidgetFilter { + final items = []; + final SentryLogger logger; + final bool redactText; + final bool redactImages; + static const _defaultColor = Color.fromARGB(255, 0, 0, 0); + late double _pixelRatio; + late Rect _bounds; + final _warnedWidgets = {}; + + WidgetFilter( + {required this.redactText, + required this.redactImages, + required this.logger}); + + void obscure(BuildContext context, double pixelRatio, Rect bounds) { + _pixelRatio = pixelRatio; + _bounds = bounds; + items.clear(); + if (context is Element) { + _obscure(context); + } else { + context.visitChildElements(_obscure); + } + } + + void _obscure(Element element) { + final widget = element.widget; + + if (!_isVisible(widget)) { + assert(() { + logger(SentryLevel.debug, "WidgetFilter skipping invisible: $widget"); + return true; + }()); + return; + } + + final obscured = _obscureIfNeeded(element, widget); + if (!obscured) { + element.visitChildElements(_obscure); + } + } + + @pragma('vm:prefer-inline') + bool _obscureIfNeeded(Element element, Widget widget) { + Color? color; + + if (redactText && widget is Text) { + color = widget.style?.color; + } else if (redactText && widget is EditableText) { + color = widget.style.color; + } else if (redactImages && widget is Image) { + color = widget.color; + } else { + // No other type is currently obscured. + return false; + } + + final renderObject = element.renderObject; + if (renderObject is! RenderBox) { + _cantObscure(widget, "it's renderObject is not a RenderBox"); + return false; + } + + final size = element.size; + if (size == null) { + _cantObscure(widget, "it's renderObject has a null size"); + return false; + } + + final offset = renderObject.localToGlobal(Offset.zero); + + final rect = Rect.fromLTWH( + offset.dx * _pixelRatio, + offset.dy * _pixelRatio, + size.width * _pixelRatio, + size.height * _pixelRatio, + ); + + if (!rect.overlaps(_bounds)) { + assert(() { + logger(SentryLevel.debug, "WidgetFilter skipping offscreen: $widget"); + return true; + }()); + return false; + } + + items.add(WidgetFilterItem(color ?? _defaultColor, rect)); + assert(() { + logger(SentryLevel.debug, "WidgetFilter obscuring: $widget"); + return true; + }()); + + return true; + } + + // We cut off some widgets early because they're not visible at all. + bool _isVisible(Widget widget) { + if (widget is Visibility) { + return widget.visible; + } + if (widget is Opacity) { + return widget.opacity > 0; + } + if (widget is Offstage) { + return !widget.offstage; + } + return true; + } + + @pragma('vm:prefer-inline') + void _cantObscure(Widget widget, String message) { + if (!_warnedWidgets.contains(widget.hashCode)) { + _warnedWidgets.add(widget.hashCode); + logger(SentryLevel.warning, + "WidgetFilter cannot obscure widget $widget: $message"); + } + } +} + +class WidgetFilterItem { + final Color color; + final Rect bounds; + + const WidgetFilterItem(this.color, this.bounds); +} diff --git a/flutter/lib/src/screenshot/sentry_screenshot_widget.dart b/flutter/lib/src/screenshot/sentry_screenshot_widget.dart index e83d46d0c5..6eafb935a5 100644 --- a/flutter/lib/src/screenshot/sentry_screenshot_widget.dart +++ b/flutter/lib/src/screenshot/sentry_screenshot_widget.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; -import '../../sentry_flutter.dart'; - /// Key which is used to identify the [RepaintBoundary] @internal final sentryScreenshotWidgetGlobalKey = @@ -25,36 +23,19 @@ final sentryScreenshotWidgetGlobalKey = /// times. class SentryScreenshotWidget extends StatefulWidget { final Widget child; - late final Hub _hub; - - SentryFlutterOptions? get _options => - // ignore: invalid_use_of_internal_member - _hub.options is SentryFlutterOptions - // ignore: invalid_use_of_internal_member - ? _hub.options as SentryFlutterOptions - : null; - SentryScreenshotWidget({ - super.key, - required this.child, - @internal Hub? hub, - }) : _hub = hub ?? HubAdapter(); + const SentryScreenshotWidget({super.key, required this.child}); @override _SentryScreenshotWidgetState createState() => _SentryScreenshotWidgetState(); } class _SentryScreenshotWidgetState extends State { - SentryFlutterOptions? get _options => widget._options; - @override Widget build(BuildContext context) { - if (_options?.attachScreenshot ?? false) { - return RepaintBoundary( - key: sentryScreenshotWidgetGlobalKey, - child: widget.child, - ); - } - return widget.child; + return RepaintBoundary( + key: sentryScreenshotWidgetGlobalKey, + child: widget.child, + ); } } diff --git a/flutter/lib/src/sentry_flutter_options.dart b/flutter/lib/src/sentry_flutter_options.dart index 913070e9ca..449dea7484 100644 --- a/flutter/lib/src/sentry_flutter_options.dart +++ b/flutter/lib/src/sentry_flutter_options.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'package:meta/meta.dart'; +import 'package:meta/meta.dart' as meta; import 'package:sentry/sentry.dart'; import 'package:flutter/widgets.dart'; @@ -10,6 +10,7 @@ import 'screenshot/sentry_screenshot_quality.dart'; import 'event_processor/screenshot_event_processor.dart'; import 'screenshot/sentry_screenshot_widget.dart'; import 'sentry_flutter.dart'; +import 'sentry_replay_options.dart'; import 'user_interaction/sentry_user_interaction_widget.dart'; /// This class adds options which are only available in a Flutter environment. @@ -203,14 +204,14 @@ class SentryFlutterOptions extends SentryOptions { /// Sets the Proguard uuid for Android platform. String? proguardUuid; - @internal + @meta.internal late RendererWrapper rendererWrapper = RendererWrapper(); /// Enables the View Hierarchy feature. /// /// Renders an ASCII represention of the entire view hierarchy of the /// application when an error happens and includes it as an attachment. - @experimental + @meta.experimental bool attachViewHierarchy = false; /// When enabled, the SDK tracks when the application stops responding for a @@ -294,14 +295,14 @@ class SentryFlutterOptions extends SentryOptions { } /// Setting this to a custom [BindingWrapper] allows you to use a custom [WidgetsBinding]. - @experimental + @meta.experimental BindingWrapper bindingUtils = BindingWrapper(); /// The sample rate for profiling traces in the range of 0.0 to 1.0. /// This is relative to tracesSampleRate - it is a ratio of profiled traces out of all sampled traces. /// At the moment, only apps targeting iOS and macOS are supported. @override - @experimental + @meta.experimental double? get profilesSampleRate { // ignore: invalid_use_of_internal_member return super.profilesSampleRate; @@ -311,7 +312,7 @@ class SentryFlutterOptions extends SentryOptions { /// This is relative to tracesSampleRate - it is a ratio of profiled traces out of all sampled traces. /// At the moment, only apps targeting iOS and macOS are supported. @override - @experimental + @meta.experimental set profilesSampleRate(double? value) { // ignore: invalid_use_of_internal_member super.profilesSampleRate = value; @@ -319,6 +320,17 @@ class SentryFlutterOptions extends SentryOptions { /// The [navigatorKey] is used to add information of the currently used locale to the contexts. GlobalKey? navigatorKey; + + /// Configuration of experimental features that may change or be removed + /// without prior notice. Additionally, these features may not be ready for + /// production use yet. + @meta.experimental + final experimental = _SentryFlutterExperimentalOptions(); +} + +class _SentryFlutterExperimentalOptions { + /// Replay recording configuration. + final replay = SentryReplayOptions(); } /// Callback being executed in [ScreenshotEventProcessor], deciding if a diff --git a/flutter/lib/src/sentry_replay_options.dart b/flutter/lib/src/sentry_replay_options.dart new file mode 100644 index 0000000000..e98aed7418 --- /dev/null +++ b/flutter/lib/src/sentry_replay_options.dart @@ -0,0 +1,40 @@ +import 'package:meta/meta.dart'; + +/// Configuration of the experimental replay feature. +class SentryReplayOptions { + double? _sessionSampleRate; + + /// A percentage of sessions in which a replay will be created. + /// The value needs to be >= 0.0 and <= 1.0. + /// Specifying 0 means none, 1.0 means 100 %. Defaults to null (disabled). + double? get sessionSampleRate => _sessionSampleRate; + set sessionSampleRate(double? value) { + assert(value == null || (value >= 0 && value <= 1)); + _sessionSampleRate = value; + } + + double? _errorSampleRate; + + /// A percentage of errors that will be accompanied by a 30 seconds replay. + /// The value needs to be >= 0.0 and <= 1.0. + /// Specifying 0 means none, 1.0 means 100 %. Defaults to null (disabled). + double? get errorSampleRate => _errorSampleRate; + set errorSampleRate(double? value) { + assert(value == null || (value >= 0 && value <= 1)); + _errorSampleRate = value; + } + + /// Redact all text content. Draws a rectangle of text bounds with text color + /// on top. Currently, only [Text] and [EditableText] Widgets are redacted. + /// Default is enabled. + var redactAllText = true; + + /// Redact all image content. Draws a rectangle of image bounds with image's + /// dominant color on top. Currently, only [Image] widgets are redacted. + /// Default is enabled. + var redactAllImages = true; + + @internal + bool get isEnabled => + ((sessionSampleRate ?? 0) > 0) || ((errorSampleRate ?? 0) > 0); +} diff --git a/flutter/test/event_processor/screenshot_event_processor_test.dart b/flutter/test/event_processor/screenshot_event_processor_test.dart index 3a00f10ced..819e3b9b7b 100644 --- a/flutter/test/event_processor/screenshot_event_processor_test.dart +++ b/flutter/test/event_processor/screenshot_event_processor_test.dart @@ -34,7 +34,6 @@ void main() { final sut = fixture.getSut(renderer, isWeb); await tester.pumpWidget(SentryScreenshotWidget( - hub: fixture.hub, child: Text('Catching Pokémon is a snap!', textDirection: TextDirection.ltr))); diff --git a/flutter/test/integrations/init_native_sdk_test.dart b/flutter/test/integrations/init_native_sdk_test.dart index 0dcf3af502..fd96ad2127 100644 --- a/flutter/test/integrations/init_native_sdk_test.dart +++ b/flutter/test/integrations/init_native_sdk_test.dart @@ -64,6 +64,10 @@ void main() { 'connectionTimeoutMillis': 5000, 'readTimeoutMillis': 5000, 'appHangTimeoutIntervalMillis': 2000, + 'replay': { + 'sessionSampleRate': null, + 'errorSampleRate': null, + }, }); }); @@ -104,7 +108,9 @@ void main() { ..enableAppHangTracking = false ..connectionTimeout = Duration(milliseconds: 9001) ..readTimeout = Duration(milliseconds: 9002) - ..appHangTimeoutInterval = Duration(milliseconds: 9003); + ..appHangTimeoutInterval = Duration(milliseconds: 9003) + ..experimental.replay.sessionSampleRate = 0.1 + ..experimental.replay.errorSampleRate = 0.2; fixture.options.sdk.addIntegration('foo'); fixture.options.sdk.addPackage('bar', '1'); @@ -149,6 +155,10 @@ void main() { 'connectionTimeoutMillis': 9001, 'readTimeoutMillis': 9002, 'appHangTimeoutIntervalMillis': 9003, + 'replay': { + 'sessionSampleRate': 0.1, + 'errorSampleRate': 0.2, + }, }); }); } diff --git a/flutter/test/replay/recorder_test.dart b/flutter/test/replay/recorder_test.dart new file mode 100644 index 0000000000..99176c4c89 --- /dev/null +++ b/flutter/test/replay/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/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 ScreenshotRecorder sut; + final capturedImages = []; + + _Fixture._(this._tester) { + sut = ScreenshotRecorder( + ScreenshotRecorderConfig( + 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)); + } +} diff --git a/flutter/test/replay/scheduler_test.dart b/flutter/test/replay/scheduler_test.dart new file mode 100644 index 0000000000..c41260c854 --- /dev/null +++ b/flutter/test/replay/scheduler_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/scheduler.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/src/replay/scheduler.dart'; + +void main() { + test('does not trigger callback between frames', () async { + var fixture = _Fixture.started(); + + expect(fixture.calls, 0); + await Future.delayed(const Duration(milliseconds: 100), () {}); + expect(fixture.calls, 0); + }); + + test('triggers callback after a frame', () async { + var fixture = _Fixture(); + fixture.sut.start(); + + expect(fixture.calls, 0); + await fixture.drawFrame(); + expect(fixture.calls, 1); + await fixture.drawFrame(); + await fixture.drawFrame(); + await fixture.drawFrame(); + expect(fixture.calls, 4); + }); + + test('does not trigger when stopped', () async { + var fixture = _Fixture(); + fixture.sut.start(); + + expect(fixture.calls, 0); + await fixture.drawFrame(); + expect(fixture.calls, 1); + await fixture.drawFrame(); + await fixture.sut.stop(); + await fixture.drawFrame(); + expect(fixture.calls, 2); + }); + + test('triggers after a restart', () async { + var fixture = _Fixture(); + fixture.sut.start(); + + expect(fixture.calls, 0); + await fixture.drawFrame(); + expect(fixture.calls, 1); + await fixture.sut.stop(); + await fixture.drawFrame(); + expect(fixture.calls, 1); + fixture.sut.start(); + await fixture.drawFrame(); + expect(fixture.calls, 2); + }); +} + +class _Fixture { + var calls = 0; + late final Scheduler sut; + FrameCallback? registeredCallback; + var _frames = 0; + + _Fixture() { + sut = Scheduler( + const Duration(milliseconds: 1), + (_) async => calls++, + (FrameCallback callback, {String debugLabel = 'callback'}) { + registeredCallback = callback; + }, + ); + } + + factory _Fixture.started() { + return _Fixture()..sut.start(); + } + + Future drawFrame() async { + await Future.delayed(const Duration(milliseconds: 8), () {}); + _frames++; + registeredCallback!(Duration(milliseconds: _frames)); + registeredCallback = null; + } +} diff --git a/flutter/test/replay/test_widget.dart b/flutter/test/replay/test_widget.dart new file mode 100644 index 0000000000..e85dfacaf8 --- /dev/null +++ b/flutter/test/replay/test_widget.dart @@ -0,0 +1,59 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +Future pumpTestElement(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: SentryWidget( + child: SingleChildScrollView( + child: Visibility( + visible: true, + child: Opacity( + opacity: 0.5, + child: Column( + children: [ + newImage(), + const Padding( + padding: EdgeInsets.all(15), + child: Center(child: Text('Centered text')), + ), + ElevatedButton( + onPressed: () {}, + child: Text('Button title'), + ), + newImage(), + // Invisible widgets won't be obscured. + Visibility(visible: false, child: Text('Invisible text')), + Visibility(visible: false, child: newImage()), + Opacity(opacity: 0, child: Text('Invisible text')), + Opacity(opacity: 0, child: newImage()), + Offstage(offstage: true, child: Text('Offstage text')), + Offstage(offstage: true, child: newImage()), + ], + ), + ), + ), + ), + ), + ), + ); + return TestWidgetsFlutterBinding.instance.rootElement!; +} + +Image newImage() => Image.memory( + Uint8List.fromList([ + 66, 77, 142, 0, 0, 0, 0, 0, 0, 0, 138, 0, 0, 0, 124, 0, 0, 0, 1, 0, + 0, 0, 255, 255, 255, 255, 1, 0, 32, 0, 3, 0, 0, 0, 4, 0, 0, 0, 19, + 11, 0, 0, 19, 11, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, + 255, 0, 0, 255, 0, 0, 0, 0, 0, 0, 255, 66, 71, 82, 115, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 135, 135, 135, 255, + // This comment prevents dartfmt reformatting this to single-item lines. + ]), + width: 1, + height: 1, + ); diff --git a/flutter/test/replay/widget_filter_test.dart b/flutter/test/replay/widget_filter_test.dart new file mode 100644 index 0000000000..3e17f2b5b6 --- /dev/null +++ b/flutter/test/replay/widget_filter_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/src/replay/widget_filter.dart'; + +import 'test_widget.dart'; + +void main() async { + TestWidgetsFlutterBinding.ensureInitialized(); + const defaultBounds = Rect.fromLTRB(0, 0, 1000, 1000); + + final createSut = + ({bool redactImages = false, bool redactText = false}) => WidgetFilter( + logger: (level, message, {exception, logger, stackTrace}) {}, + redactImages: redactImages, + redactText: redactText, + ); + + group('redact text', () { + testWidgets('redacts the correct number of elements', (tester) async { + final sut = createSut(redactText: true); + final element = await pumpTestElement(tester); + sut.obscure(element, 1.0, defaultBounds); + expect(sut.items.length, 2); + }); + + testWidgets('does not redact text when disabled', (tester) async { + final sut = createSut(redactText: false); + final element = await pumpTestElement(tester); + sut.obscure(element, 1.0, defaultBounds); + expect(sut.items.length, 0); + }); + + testWidgets('does not redact elements that are outside the screen', + (tester) async { + final sut = createSut(redactText: true); + final element = await pumpTestElement(tester); + sut.obscure(element, 1.0, Rect.fromLTRB(0, 0, 100, 100)); + expect(sut.items.length, 1); + }); + }); + + group('redact images', () { + testWidgets('redacts the correct number of elements', (tester) async { + final sut = createSut(redactImages: true); + final element = await pumpTestElement(tester); + sut.obscure(element, 1.0, defaultBounds); + expect(sut.items.length, 2); + }); + + testWidgets('does not redact text when disabled', (tester) async { + final sut = createSut(redactImages: false); + final element = await pumpTestElement(tester); + sut.obscure(element, 1.0, defaultBounds); + expect(sut.items.length, 0); + }); + + testWidgets('does not redact elements that are outside the screen', + (tester) async { + final sut = createSut(redactImages: true); + final element = await pumpTestElement(tester); + sut.obscure(element, 1.0, Rect.fromLTRB(0, 0, 500, 100)); + expect(sut.items.length, 1); + }); + }); +} diff --git a/flutter/test/screenshot/sentry_screenshot_widget_test.dart b/flutter/test/screenshot/sentry_screenshot_widget_test.dart index 57379387d0..0b6df7ffad 100644 --- a/flutter/test/screenshot/sentry_screenshot_widget_test.dart +++ b/flutter/test/screenshot/sentry_screenshot_widget_test.dart @@ -64,7 +64,6 @@ class Fixture { hub = Hub(_options); return SentryScreenshotWidget( - hub: hub, child: MaterialApp(home: MyApp()), ); } diff --git a/min_version_test/android/build.gradle b/min_version_test/android/build.gradle index 70ef661422..e269d91443 100644 --- a/min_version_test/android/build.gradle +++ b/min_version_test/android/build.gradle @@ -1,12 +1,12 @@ buildscript { - ext.kotlin_version = '1.6.21' + ext.kotlin_version = '1.8.0' repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.2.2' + classpath 'com.android.tools.build:gradle:7.4.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } }