diff --git a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt index 9e675e1bb5..7f1e7d3c94 100644 --- a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt +++ b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt @@ -15,8 +15,12 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result import io.sentry.Breadcrumb import io.sentry.DateUtils +import io.sentry.JsonObjectDeserializer +import io.sentry.JsonObjectReader +import io.sentry.ObjectReader import io.sentry.ScopesAdapter import io.sentry.Sentry +import io.sentry.SentryOptions import io.sentry.android.core.InternalSentrySdk import io.sentry.android.core.SentryAndroid import io.sentry.android.core.SentryAndroidOptions @@ -29,6 +33,7 @@ import io.sentry.protocol.User import io.sentry.transport.CurrentDateProvider import org.json.JSONObject import org.json.JSONArray +import java.io.StringReader import java.lang.ref.WeakReference import kotlin.math.roundToInt @@ -65,8 +70,6 @@ class SentryFlutterPlugin : "setContexts" -> setContexts(call.argument("key"), call.argument("value"), result) "removeContexts" -> removeContexts(call.argument("key"), result) "setUser" -> setUser(call.argument("user"), result) - "addBreadcrumb" -> addBreadcrumb(call.argument("breadcrumb"), result) - "clearBreadcrumbs" -> clearBreadcrumbs(result) "setExtra" -> setExtra(call.argument("key"), call.argument("value"), result) "removeExtra" -> removeExtra(call.argument("key"), result) "setTag" -> setTag(call.argument("key"), call.argument("value"), result) @@ -190,24 +193,6 @@ class SentryFlutterPlugin : result.success("") } - private fun addBreadcrumb( - breadcrumb: Map?, - result: Result, - ) { - if (breadcrumb != null) { - val options = ScopesAdapter.getInstance().options - val breadcrumbInstance = Breadcrumb.fromMap(breadcrumb, options) - Sentry.addBreadcrumb(breadcrumbInstance) - } - result.success("") - } - - private fun clearBreadcrumbs(result: Result) { - Sentry.clearBreadcrumbs() - - result.success("") - } - private fun setExtra( key: String?, value: String?, @@ -450,6 +435,22 @@ class SentryFlutterPlugin : return json.toByteArray(Charsets.UTF_8) } + @Suppress("unused") // Used by native/jni bindings + @JvmStatic + fun addBreadcrumbAsBytes(breadcrumbBytes: ByteArray) { + val logger = ScopesAdapter.getInstance().options.logger + val breadcrumbJson = breadcrumbBytes.toString(Charsets.UTF_8) + val reader = JsonObjectReader(StringReader(breadcrumbJson)) + val breadcrumb = Breadcrumb.Deserializer().deserialize(reader, logger) + Sentry.addBreadcrumb(breadcrumb) + } + + @Suppress("unused") // Used by native/jni bindings + @JvmStatic + fun clearBreadcrumbs() { + Sentry.clearBreadcrumbs() + } + private fun List?.serialize() = this?.map { it.serialize() } private fun DebugImage.serialize() = diff --git a/packages/flutter/example/integration_test/integration_test.dart b/packages/flutter/example/integration_test/integration_test.dart index 82b2c0e703..0c121748ce 100644 --- a/packages/flutter/example/integration_test/integration_test.dart +++ b/packages/flutter/example/integration_test/integration_test.dart @@ -564,6 +564,51 @@ void main() { } }); + testWidgets('addBreadcrumb and clearBreadcrumbs sync to native', + (tester) async { + await restoreFlutterOnErrorAfter(() async { + await setupSentryAndApp(tester); + }); + + // 1. Add a breadcrumb via Dart + final testBreadcrumb = Breadcrumb( + message: 'test-breadcrumb-message', + category: 'test-category', + level: SentryLevel.info, + ); + await Sentry.addBreadcrumb(testBreadcrumb); + + // 2. Verify it appears in native via loadContexts + var contexts = await SentryFlutter.native?.loadContexts(); + expect(contexts, isNotNull); + + var breadcrumbs = contexts!['breadcrumbs'] as List?; + expect(breadcrumbs, isNotNull, + reason: 'Breadcrumbs should not be null after adding'); + expect(breadcrumbs!.isNotEmpty, isTrue, + reason: 'Breadcrumbs should not be empty after adding'); + + // Find our test breadcrumb + final testCrumb = breadcrumbs.firstWhere( + (b) => b['message'] == 'test-breadcrumb-message', + orElse: () => null, + ); + expect(testCrumb, isNotNull, + reason: 'Test breadcrumb should exist in native breadcrumbs'); + expect(testCrumb['category'], equals('test-category')); + + // 3. Clear breadcrumbs + await Sentry.configureScope((scope) async { + await scope.clearBreadcrumbs(); + }); + + // 4. Verify they're cleared in native + contexts = await SentryFlutter.native?.loadContexts(); + breadcrumbs = contexts!['breadcrumbs'] as List?; + expect(breadcrumbs == null || breadcrumbs.isEmpty, isTrue, + reason: 'Breadcrumbs should be null or empty after clearing'); + }); + testWidgets('loads debug images through loadDebugImages', (tester) async { await restoreFlutterOnErrorAfter(() async { await setupSentryAndApp(tester); diff --git a/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter/SentryFlutterPlugin.swift b/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter/SentryFlutterPlugin.swift index 44bb1eaff0..957db3974d 100644 --- a/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter/SentryFlutterPlugin.swift +++ b/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter/SentryFlutterPlugin.swift @@ -92,14 +92,6 @@ public class SentryFlutterPlugin: NSObject, FlutterPlugin { let user = arguments?["user"] as? [String: Any] setUser(user: user, result: result) - case "addBreadcrumb": - let arguments = call.arguments as? [String: Any?] - let breadcrumb = arguments?["breadcrumb"] as? [String: Any] - addBreadcrumb(breadcrumb: breadcrumb, result: result) - - case "clearBreadcrumbs": - clearBreadcrumbs(result: result) - case "setExtra": let arguments = call.arguments as? [String: Any?] let key = arguments?["key"] as? String @@ -322,22 +314,6 @@ public class SentryFlutterPlugin: NSObject, FlutterPlugin { result("") } - private func addBreadcrumb(breadcrumb: [String: Any]?, result: @escaping FlutterResult) { - if let breadcrumb = breadcrumb { - let breadcrumbInstance = PrivateSentrySDKOnly.breadcrumb(with: breadcrumb) - SentrySDK.addBreadcrumb(breadcrumbInstance) - } - result("") - } - - private func clearBreadcrumbs(result: @escaping FlutterResult) { - SentrySDK.configureScope { scope in - scope.clearBreadcrumbs() - - result("") - } - } - private func setExtra(key: String?, value: Any?, result: @escaping FlutterResult) { guard let key = key else { result("") @@ -646,6 +622,24 @@ public class SentryFlutterPlugin: NSObject, FlutterPlugin { } return nil } + + @objc public class func addBreadcrumbAsBytes(_ breadcrumbBytes: NSData) { + guard let breadcrumbDict = try? JSONSerialization.jsonObject( + with: breadcrumbBytes as Data, + options: [] + ) as? [String: Any] else { + print("addBreadcrumb failed in native cocoa: could not parse bytes") + return + } + let breadcrumbInstance = PrivateSentrySDKOnly.breadcrumb(with: breadcrumbDict) + SentrySDK.addBreadcrumb(breadcrumbInstance) + } + + @objc public class func clearBreadcrumbs() { + SentrySDK.configureScope { scope in + scope.clearBreadcrumbs() + } + } } // swiftlint:enable type_body_length diff --git a/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter_objc/SentryFlutterPlugin.h b/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter_objc/SentryFlutterPlugin.h index 61af58310d..83a6b0288b 100644 --- a/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter_objc/SentryFlutterPlugin.h +++ b/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter_objc/SentryFlutterPlugin.h @@ -8,6 +8,8 @@ + (nullable NSData *)fetchNativeAppStartAsBytes; + (nullable NSData *)loadContextsAsBytes; + (nullable NSData *)loadDebugImagesAsBytes:(NSSet *)instructionAddresses; ++ (void)addBreadcrumbAsBytes:(NSData *)breadcrumbBytes; ++ (void)clearBreadcrumbs; + (void)nativeCrash; + (void)pauseAppHangTracking; + (void)resumeAppHangTracking; diff --git a/packages/flutter/lib/src/native/cocoa/binding.dart b/packages/flutter/lib/src/native/cocoa/binding.dart index 73d693b44d..b7fd3891c4 100644 --- a/packages/flutter/lib/src/native/cocoa/binding.dart +++ b/packages/flutter/lib/src/native/cocoa/binding.dart @@ -1128,7 +1128,9 @@ late final _sel_fetchNativeAppStartAsBytes = late final _sel_loadContextsAsBytes = objc.registerName("loadContextsAsBytes"); late final _sel_loadDebugImagesAsBytes_ = objc.registerName("loadDebugImagesAsBytes:"); -late final _sel_nativeCrash = objc.registerName("nativeCrash"); +late final _sel_addBreadcrumbAsBytes_ = + objc.registerName("addBreadcrumbAsBytes:"); +late final _sel_clearBreadcrumbs = objc.registerName("clearBreadcrumbs"); final _objc_msgSend_1pl9qdv = objc.msgSendPointer .cast< ffi.NativeFunction< @@ -1137,6 +1139,7 @@ final _objc_msgSend_1pl9qdv = objc.msgSendPointer .asFunction< void Function( ffi.Pointer, ffi.Pointer)>(); +late final _sel_nativeCrash = objc.registerName("nativeCrash"); late final _sel_pauseAppHangTracking = objc.registerName("pauseAppHangTracking"); late final _sel_resumeAppHangTracking = @@ -1199,6 +1202,17 @@ class SentryFlutterPlugin extends objc.NSObject { : objc.NSData.castFromPointer(_ret, retain: true, release: true); } + /// addBreadcrumbAsBytes: + static void addBreadcrumbAsBytes(objc.NSData breadcrumbBytes) { + _objc_msgSend_xtuoz7(_class_SentryFlutterPlugin, _sel_addBreadcrumbAsBytes_, + breadcrumbBytes.ref.pointer); + } + + /// clearBreadcrumbs + static void clearBreadcrumbs() { + _objc_msgSend_1pl9qdv(_class_SentryFlutterPlugin, _sel_clearBreadcrumbs); + } + /// nativeCrash static void nativeCrash() { _objc_msgSend_1pl9qdv(_class_SentryFlutterPlugin, _sel_nativeCrash); diff --git a/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart b/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart index 097b26bc30..b578b5c42c 100644 --- a/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart +++ b/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:typed_data'; import 'package:meta/meta.dart'; import 'package:objective_c/objective_c.dart'; @@ -7,6 +8,7 @@ import '../../../sentry_flutter.dart'; import '../../replay/replay_config.dart'; import '../native_app_start.dart'; import '../sentry_native_channel.dart'; +import '../utils/data_normalizer.dart'; import '../utils/utf8_json.dart'; import 'binding.dart' as cocoa; import 'cocoa_replay_recorder.dart'; @@ -197,4 +199,22 @@ class SentryNativeCocoa extends SentryNativeChannel { cocoa.SentryFlutterPlugin.resumeAppHangTracking(); }); } + + @override + Future addBreadcrumb(Breadcrumb breadcrumb) async { + tryCatchSync('addBreadcrumb', () { + final jsonString = json.encode(breadcrumb.toJson()); + final bytes = utf8.encode(jsonString); + final nsData = bytes.toNSData(); + + cocoa.SentryFlutterPlugin.addBreadcrumbAsBytes(nsData); + }); + } + + @override + Future clearBreadcrumbs() async { + tryCatchSync('clearBreadcrumbs', () { + cocoa.SentryFlutterPlugin.clearBreadcrumbs(); + }); + } } diff --git a/packages/flutter/lib/src/native/java/binding.dart b/packages/flutter/lib/src/native/java/binding.dart index f9730b691a..281d75701e 100644 --- a/packages/flutter/lib/src/native/java/binding.dart +++ b/packages/flutter/lib/src/native/java/binding.dart @@ -1279,6 +1279,31 @@ class SentryFlutterPlugin$Companion extends jni$_.JObject { /// The type which includes information such as the signature of this class. static const nullableType = $SentryFlutterPlugin$Companion$NullableType(); static const type = $SentryFlutterPlugin$Companion$Type(); + static final _id_getAutoPerformanceTracingEnabled = _class.instanceMethodId( + r'getAutoPerformanceTracingEnabled', + r'()Z', + ); + + static final _getAutoPerformanceTracingEnabled = + jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>>('globalEnv_CallBooleanMethod') + .asFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>(); + + /// from: `public final boolean getAutoPerformanceTracingEnabled()` + bool getAutoPerformanceTracingEnabled() { + return _getAutoPerformanceTracingEnabled(reference.pointer, + _id_getAutoPerformanceTracingEnabled as jni$_.JMethodIDPtr) + .boolean; + } + static final _id_privateSentryGetReplayIntegration = _class.instanceMethodId( r'privateSentryGetReplayIntegration', r'()Lio/sentry/android/replay/ReplayIntegration;', @@ -1456,6 +1481,56 @@ class SentryFlutterPlugin$Companion extends jni$_.JObject { .object(const jni$_.JByteArrayNullableType()); } + static final _id_addBreadcrumbAsBytes = _class.instanceMethodId( + r'addBreadcrumbAsBytes', + r'([B)V', + ); + + static final _addBreadcrumbAsBytes = jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JThrowablePtr Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + jni$_.VarArgs<(jni$_.Pointer,)>)>>( + 'globalEnv_CallVoidMethod') + .asFunction< + jni$_.JThrowablePtr Function(jni$_.Pointer, + jni$_.JMethodIDPtr, jni$_.Pointer)>(); + + /// from: `public final void addBreadcrumbAsBytes(byte[] bs)` + void addBreadcrumbAsBytes( + jni$_.JByteArray bs, + ) { + final _$bs = bs.reference; + _addBreadcrumbAsBytes(reference.pointer, + _id_addBreadcrumbAsBytes as jni$_.JMethodIDPtr, _$bs.pointer) + .check(); + } + + static final _id_clearBreadcrumbs = _class.instanceMethodId( + r'clearBreadcrumbs', + r'()V', + ); + + static final _clearBreadcrumbs = jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JThrowablePtr Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>>('globalEnv_CallVoidMethod') + .asFunction< + jni$_.JThrowablePtr Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>(); + + /// from: `public final void clearBreadcrumbs()` + void clearBreadcrumbs() { + _clearBreadcrumbs( + reference.pointer, _id_clearBreadcrumbs as jni$_.JMethodIDPtr) + .check(); + } + static final _id_new$ = _class.constructorId( r'(Lkotlin/jvm/internal/DefaultConstructorMarker;)V', ); @@ -1990,6 +2065,56 @@ class SentryFlutterPlugin extends jni$_.JObject { _id_loadDebugImagesAsBytes as jni$_.JMethodIDPtr, _$set.pointer) .object(const jni$_.JByteArrayNullableType()); } + + static final _id_addBreadcrumbAsBytes = _class.staticMethodId( + r'addBreadcrumbAsBytes', + r'([B)V', + ); + + static final _addBreadcrumbAsBytes = jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JThrowablePtr Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + jni$_.VarArgs<(jni$_.Pointer,)>)>>( + 'globalEnv_CallStaticVoidMethod') + .asFunction< + jni$_.JThrowablePtr Function(jni$_.Pointer, + jni$_.JMethodIDPtr, jni$_.Pointer)>(); + + /// from: `static public final void addBreadcrumbAsBytes(byte[] bs)` + static void addBreadcrumbAsBytes( + jni$_.JByteArray bs, + ) { + final _$bs = bs.reference; + _addBreadcrumbAsBytes(_class.reference.pointer, + _id_addBreadcrumbAsBytes as jni$_.JMethodIDPtr, _$bs.pointer) + .check(); + } + + static final _id_clearBreadcrumbs = _class.staticMethodId( + r'clearBreadcrumbs', + r'()V', + ); + + static final _clearBreadcrumbs = jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JThrowablePtr Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>>('globalEnv_CallStaticVoidMethod') + .asFunction< + jni$_.JThrowablePtr Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>(); + + /// from: `static public final void clearBreadcrumbs()` + static void clearBreadcrumbs() { + _clearBreadcrumbs(_class.reference.pointer, + _id_clearBreadcrumbs as jni$_.JMethodIDPtr) + .check(); + } } final class $SentryFlutterPlugin$NullableType diff --git a/packages/flutter/lib/src/native/java/sentry_native_java.dart b/packages/flutter/lib/src/native/java/sentry_native_java.dart index 817d51c00d..3284b34a50 100644 --- a/packages/flutter/lib/src/native/java/sentry_native_java.dart +++ b/packages/flutter/lib/src/native/java/sentry_native_java.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:typed_data'; import 'package:jni/jni.dart'; @@ -8,6 +9,7 @@ import '../../../sentry_flutter.dart'; import '../../replay/scheduled_recorder_config.dart'; import '../native_app_start.dart'; import '../sentry_native_channel.dart'; +import '../utils/data_normalizer.dart'; import '../utils/utf8_json.dart'; import 'android_envelope_sender.dart'; import 'android_replay_recorder.dart'; @@ -220,4 +222,27 @@ class SentryNativeJava extends SentryNativeChannel { await _envelopeSender?.close(); return super.close(); } + + @override + Future addBreadcrumb(Breadcrumb breadcrumb) async { + JByteArray? breadcrumbBytes; + + tryCatchSync('addBreadcrumb', () { + final jsonString = json.encode(breadcrumb.toJson()); + final bytes = utf8.encode(jsonString); + breadcrumbBytes = JByteArray.from(bytes); + + native.SentryFlutterPlugin.Companion + .addBreadcrumbAsBytes(breadcrumbBytes!); + }, finallyFn: () { + breadcrumbBytes?.release(); + }); + } + + @override + Future clearBreadcrumbs() async { + tryCatchSync('clearBreadcrumbs', () { + native.SentryFlutterPlugin.Companion.clearBreadcrumbs(); + }); + } } diff --git a/packages/flutter/lib/src/native/method_channel_helper.dart b/packages/flutter/lib/src/native/method_channel_helper.dart deleted file mode 100644 index bd3c8864b8..0000000000 --- a/packages/flutter/lib/src/native/method_channel_helper.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:meta/meta.dart'; - -/// Makes sure no invalid data is sent over method channels. -@internal -class MethodChannelHelper { - static dynamic normalize(dynamic data) { - if (data == null) { - return null; - } - if (_isPrimitive(data)) { - return data; - } else if (data is List) { - return _normalizeList(data); - } else if (data is Map) { - return normalizeMap(data); - } else { - return data.toString(); - } - } - - static Map? normalizeMap(Map? data) { - if (data == null) { - return null; - } - return data.map((key, value) => MapEntry(key, normalize(value))); - } - - static List? _normalizeList(List? data) { - if (data == null) { - return null; - } - return data.map((e) => normalize(e)).toList(); - } - - static bool _isPrimitive(dynamic value) { - return value == null || value is String || value is num || value is bool; - } -} diff --git a/packages/flutter/lib/src/native/sentry_native_channel.dart b/packages/flutter/lib/src/native/sentry_native_channel.dart index b1f2f20ab4..4cc4c6dec0 100644 --- a/packages/flutter/lib/src/native/sentry_native_channel.dart +++ b/packages/flutter/lib/src/native/sentry_native_channel.dart @@ -8,11 +8,11 @@ import 'package:meta/meta.dart'; import '../../sentry_flutter.dart'; import '../replay/replay_config.dart'; -import 'method_channel_helper.dart'; import 'native_app_start.dart'; import 'sentry_native_binding.dart'; import 'sentry_native_invoker.dart'; import 'sentry_safe_method_channel.dart'; +import 'utils/data_normalizer.dart'; /// Provide typed methods to access native layer via MethodChannel. @internal @@ -134,7 +134,7 @@ class SentryNativeChannel username: user.username, email: user.email, ipAddress: user.ipAddress, - data: MethodChannelHelper.normalizeMap(user.data), + data: normalizeMap(user.data), // ignore: deprecated_member_use extras: user.extras, geo: user.geo, @@ -151,29 +151,19 @@ class SentryNativeChannel @override Future addBreadcrumb(Breadcrumb breadcrumb) async { - final normalizedBreadcrumb = Breadcrumb( - message: breadcrumb.message, - category: breadcrumb.category, - data: MethodChannelHelper.normalizeMap(breadcrumb.data), - level: breadcrumb.level, - type: breadcrumb.type, - timestamp: breadcrumb.timestamp, - // ignore: invalid_use_of_internal_member - unknown: breadcrumb.unknown, - ); - await channel.invokeMethod( - 'addBreadcrumb', - {'breadcrumb': normalizedBreadcrumb.toJson()}, - ); + assert(false, "addBreadcrumb should not be used through method channels."); } @override - Future clearBreadcrumbs() => channel.invokeMethod('clearBreadcrumbs'); + Future clearBreadcrumbs() async { + assert( + false, "clearBreadcrumbs should not be used through method channels."); + } @override Future setContexts(String key, dynamic value) => channel.invokeMethod( 'setContexts', - {'key': key, 'value': MethodChannelHelper.normalize(value)}, + {'key': key, 'value': normalize(value)}, ); @override @@ -183,7 +173,7 @@ class SentryNativeChannel @override Future setExtra(String key, dynamic value) => channel.invokeMethod( 'setExtra', - {'key': key, 'value': MethodChannelHelper.normalize(value)}, + {'key': key, 'value': normalize(value)}, ); @override diff --git a/packages/flutter/lib/src/native/utils/data_normalizer.dart b/packages/flutter/lib/src/native/utils/data_normalizer.dart new file mode 100644 index 0000000000..8d0535ae30 --- /dev/null +++ b/packages/flutter/lib/src/native/utils/data_normalizer.dart @@ -0,0 +1,27 @@ +import 'package:meta/meta.dart'; + +/// Normalizes data for serialization across native boundaries. +/// Converts non-primitive types to strings for safe serialization. +@internal +dynamic normalize(dynamic data) { + if (data == null) return null; + if (_isPrimitive(data)) return data; + if (data is List) return _normalizeList(data); + if (data is Map) return normalizeMap(data); + return data.toString(); +} + +@internal +Map? normalizeMap(Map? data) { + if (data == null) return null; + return data.map((key, value) => MapEntry(key, normalize(value))); +} + +List? _normalizeList(List? data) { + if (data == null) return null; + return data.map((e) => normalize(e)).toList(); +} + +bool _isPrimitive(dynamic value) { + return value == null || value is String || value is num || value is bool; +} diff --git a/packages/flutter/test/sentry_native_channel_test.dart b/packages/flutter/test/sentry_native_channel_test.dart index 7288d00971..3cd2d925f6 100644 --- a/packages/flutter/test/sentry_native_channel_test.dart +++ b/packages/flutter/test/sentry_native_channel_test.dart @@ -10,8 +10,8 @@ import 'package:mockito/mockito.dart'; import 'package:sentry/src/platform/mock_platform.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/native/factory.dart'; -import 'package:sentry_flutter/src/native/method_channel_helper.dart'; import 'package:sentry_flutter/src/native/sentry_native_binding.dart'; +import 'package:sentry_flutter/src/native/utils/data_normalizer.dart'; import 'package:sentry_flutter/src/replay/replay_config.dart'; import 'mocks.dart'; @@ -59,7 +59,7 @@ void main() { username: user.username, email: user.email, ipAddress: user.ipAddress, - data: MethodChannelHelper.normalizeMap(user.data), + data: normalizeMap(user.data), // ignore: deprecated_member_use extras: user.extras, geo: user.geo, @@ -77,41 +77,36 @@ void main() { }); test('addBreadcrumb', () async { + final matcher = _nativeUnavailableMatcher( + mockPlatform, + includeLookupSymbol: true, + includeFailedToLoadClassException: true, + ); + final breadcrumb = Breadcrumb( data: {'object': Object()}, ); - final normalizedBreadcrumb = Breadcrumb( - message: breadcrumb.message, - category: breadcrumb.category, - data: MethodChannelHelper.normalizeMap(breadcrumb.data), - level: breadcrumb.level, - type: breadcrumb.type, - timestamp: breadcrumb.timestamp, - // ignore: invalid_use_of_internal_member - unknown: breadcrumb.unknown, - ); - when(channel.invokeMethod( - 'addBreadcrumb', {'breadcrumb': normalizedBreadcrumb.toJson()})) - .thenAnswer((_) => Future.value()); - await sut.addBreadcrumb(breadcrumb); + expect(() => sut.addBreadcrumb(breadcrumb), matcher); - verify(channel.invokeMethod( - 'addBreadcrumb', {'breadcrumb': normalizedBreadcrumb.toJson()})); + verifyZeroInteractions(channel); }); test('clearBreadcrumbs', () async { - when(channel.invokeMethod('clearBreadcrumbs')) - .thenAnswer((_) => Future.value()); + final matcher = _nativeUnavailableMatcher( + mockPlatform, + includeLookupSymbol: true, + includeFailedToLoadClassException: true, + ); - await sut.clearBreadcrumbs(); + expect(() => sut.clearBreadcrumbs(), matcher); - verify(channel.invokeMethod('clearBreadcrumbs')); + verifyZeroInteractions(channel); }); test('setContexts', () async { final value = {'object': Object()}; - final normalizedValue = MethodChannelHelper.normalize(value); + final normalizedValue = normalize(value); when(channel.invokeMethod('setContexts', { 'key': 'fixture-key', 'value': normalizedValue @@ -134,7 +129,7 @@ void main() { test('setExtra', () async { final value = {'object': Object()}; - final normalizedValue = MethodChannelHelper.normalize(value); + final normalizedValue = normalize(value); when(channel.invokeMethod( 'setExtra', {'key': 'fixture-key', 'value': normalizedValue})) .thenAnswer((_) => Future.value()); diff --git a/packages/flutter/test/method_channel_helper_test.dart b/packages/flutter/test/utils/data_normalizer_test.dart similarity index 76% rename from packages/flutter/test/method_channel_helper_test.dart rename to packages/flutter/test/utils/data_normalizer_test.dart index 12729e7313..e1cc2fc1fd 100644 --- a/packages/flutter/test/method_channel_helper_test.dart +++ b/packages/flutter/test/utils/data_normalizer_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:sentry_flutter/src/native/method_channel_helper.dart'; import 'package:collection/collection.dart'; +import 'package:sentry_flutter/src/native/utils/data_normalizer.dart'; void main() { group('normalize', () { @@ -13,21 +13,21 @@ void main() { 'string': 'Foo', }; - var actual = MethodChannelHelper.normalizeMap(expected); + var actual = normalizeMap(expected); expect( DeepCollectionEquality().equals(actual, expected), true, ); - expect(MethodChannelHelper.normalize(null), null); - expect(MethodChannelHelper.normalize(1), 1); - expect(MethodChannelHelper.normalize(1.1), 1.1); - expect(MethodChannelHelper.normalize(true), true); - expect(MethodChannelHelper.normalize('Foo'), 'Foo'); + expect(normalize(null), null); + expect(normalize(1), 1); + expect(normalize(1.1), 1.1); + expect(normalize(true), true); + expect(normalize('Foo'), 'Foo'); }); test('object', () { - expect(MethodChannelHelper.normalize(_CustomObject()), 'CustomObject()'); + expect(normalize(_CustomObject()), 'CustomObject()'); }); test('object in list', () { @@ -38,7 +38,7 @@ void main() { 'object': ['CustomObject()'] }; - var actual = MethodChannelHelper.normalize(input); + var actual = normalize(input); expect( DeepCollectionEquality().equals(actual, expected), true, @@ -53,7 +53,7 @@ void main() { 'object': {'object': 'CustomObject()'} }; - var actual = MethodChannelHelper.normalize(input); + var actual = normalize(input); expect( DeepCollectionEquality().equals(actual, expected), true, @@ -71,7 +71,7 @@ void main() { 'string': 'Foo', }; - var actual = MethodChannelHelper.normalizeMap(expected); + var actual = normalizeMap(expected); expect( DeepCollectionEquality().equals(actual, expected), true, @@ -83,7 +83,7 @@ void main() { 'list': [null, 1, 1.1, true, 'Foo'], }; - var actual = MethodChannelHelper.normalizeMap(expected); + var actual = normalizeMap(expected); expect( DeepCollectionEquality().equals(actual, expected), true, @@ -101,7 +101,7 @@ void main() { }, }; - var actual = MethodChannelHelper.normalizeMap(expected); + var actual = normalizeMap(expected); expect( DeepCollectionEquality().equals(actual, expected), true, @@ -112,7 +112,7 @@ void main() { var input = {'object': _CustomObject()}; var expected = {'object': 'CustomObject()'}; - var actual = MethodChannelHelper.normalizeMap(input); + var actual = normalizeMap(input); expect( DeepCollectionEquality().equals(actual, expected), true, @@ -127,7 +127,7 @@ void main() { 'object': ['CustomObject()'] }; - var actual = MethodChannelHelper.normalizeMap(input); + var actual = normalizeMap(input); expect( DeepCollectionEquality().equals(actual, expected), true, @@ -142,7 +142,7 @@ void main() { 'object': {'object': 'CustomObject()'} }; - var actual = MethodChannelHelper.normalizeMap(input); + var actual = normalizeMap(input); expect( DeepCollectionEquality().equals(actual, expected), true,