diff --git a/CHANGELOG.md b/CHANGELOG.md index d69dad8d90..70184b1704 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes +- Dart to native type conversion ([#3372](https://github.com/getsentry/sentry-dart/pull/3372)) - Revert FFI usage on iOS/macOS due to symbol stripping issues ([#3379](https://github.com/getsentry/sentry-dart/pull/3379)) ## 9.9.0-beta.3 diff --git a/packages/flutter/example/integration_test/all.dart b/packages/flutter/example/integration_test/all.dart index e1b57dd2ec..c91f875af0 100644 --- a/packages/flutter/example/integration_test/all.dart +++ b/packages/flutter/example/integration_test/all.dart @@ -3,10 +3,12 @@ import 'integration_test.dart' as a; import 'profiling_test.dart' as b; import 'replay_test.dart' as c; import 'platform_integrations_test.dart' as d; +import 'native_jni_utils_test.dart' as e; void main() { a.main(); b.main(); c.main(); d.main(); + e.main(); } diff --git a/packages/flutter/example/integration_test/integration_test.dart b/packages/flutter/example/integration_test/integration_test.dart index c0b8d6139c..4d7f9dce72 100644 --- a/packages/flutter/example/integration_test/integration_test.dart +++ b/packages/flutter/example/integration_test/integration_test.dart @@ -707,11 +707,20 @@ void main() { }); // 1. Add a breadcrumb via Dart + final customObject = CustomObject(); final testBreadcrumb = Breadcrumb( - message: 'test-breadcrumb-message', - category: 'test-category', - level: SentryLevel.info, - ); + message: 'test-breadcrumb-message', + category: 'test-category', + level: SentryLevel.info, + data: { + 'string': 'data', + 'int': 12, + 'bool': true, + 'double': 12.34, + 'map': {'nested': 'data', 'custom object': customObject}, + 'list': [1, customObject, 3], + 'custom object': customObject + }); await Sentry.addBreadcrumb(testBreadcrumb); // 2. Verify it appears in native via loadContexts @@ -732,6 +741,17 @@ void main() { expect(testCrumb, isNotNull, reason: 'Test breadcrumb should exist in native breadcrumbs'); expect(testCrumb['category'], equals('test-category')); + expect(testCrumb['level'], equals('info')); + expect(testCrumb['data'], isNotNull); + expect(testCrumb['data']['map'], isNotNull); + expect(testCrumb['data']['map']['nested'], equals('data')); + expect(testCrumb['data']['map']['custom object'], + equals(customObject.toString())); + expect(testCrumb['data']['list'], isNotNull); + expect(testCrumb['data']['list'][0], equals(1)); + expect(testCrumb['data']['list'][1], equals(customObject.toString())); + expect(testCrumb['data']['list'][2], equals(3)); + expect(testCrumb['data']['custom object'], equals(customObject.toString())); // 3. Clear breadcrumbs await Sentry.configureScope((scope) async { @@ -751,10 +771,20 @@ void main() { }); // 1. Set a user via Dart + final customObject = CustomObject(); final testUser = SentryUser( id: 'test-user-id', email: 'test@example.com', username: 'test-username', + data: { + 'string': 'data', + 'int': 12, + 'bool': true, + 'double': 12.34, + 'map': {'nested': 'data', 'custom object': customObject}, + 'list': [1, customObject, 3], + 'custom object': customObject + }, ); await Sentry.configureScope((scope) async { await scope.setUser(testUser); @@ -769,6 +799,26 @@ void main() { expect(user!['id'], equals('test-user-id')); expect(user['email'], equals('test@example.com')); expect(user['username'], equals('test-username')); + expect(user['data']['map'], isNotNull); + expect(user['data']['list'], isNotNull); + expect(user['data']['custom object'], equals(customObject.toString())); + + if (Platform.isAndroid) { + // On Android, the Java SDK's User.data field only supports Map. + // Nested Maps and Lists are converted to Java's HashMap/ArrayList toString() + // format (e.g., {key=value} instead of {"key":"value"}). + expect(user['data']['map'], + equals('{nested=data, custom object=${customObject.toString()}}')); + expect( + user['data']['list'], equals('[1, ${customObject.toString()}, 3]')); + } else { + expect(user['data']['map']['nested'], equals('data')); + expect(user['data']['map']['custom object'], + equals(customObject.toString())); + expect(user['data']['list'][0], equals(1)); + expect(user['data']['list'][1], equals(customObject.toString())); + expect(user['data']['list'][2], equals(3)); + } // 3. Clear user (after clearing the id should remain) await Sentry.configureScope((scope) async { diff --git a/packages/flutter/example/integration_test/native_jni_utils_test.dart b/packages/flutter/example/integration_test/native_jni_utils_test.dart new file mode 100644 index 0000000000..21c5bdbccf --- /dev/null +++ b/packages/flutter/example/integration_test/native_jni_utils_test.dart @@ -0,0 +1,230 @@ +// ignore_for_file: depend_on_referenced_packages +@TestOn('vm') + +import 'dart:io'; + +import 'package:test/test.dart'; +import 'package:jni/jni.dart'; +import 'package:sentry_flutter/src/native/java/sentry_native_java.dart'; + +import 'utils.dart'; + +void main() { + final customObject = CustomObject(); + + final inputNestedMap = { + 'innerString': 'nested', + 'innerList': [1, null, 2], + 'innerNull': null, + }; + + final inputList = [ + 'value', + 1, + 1.1, + true, + customObject, + ['nestedList', 2], + inputNestedMap, + null, + ]; + + final inputMap = { + 'key': 'value', + 'key2': 1, + 'key3': 1.1, + 'key4': true, + 'key5': customObject, + 'list': inputList, + 'nestedMap': inputNestedMap, + 'nullEntry': null, + }; + + final expectedNestedList = ['nestedList', 2]; + + final expectedNestedMap = { + 'innerString': 'nested', + 'innerList': [1, 2], + 'innerNull': null, + }; + final expectedList = [ + 'value', + 1, + 1.1, + true, + customObject.toString(), + expectedNestedList, + expectedNestedMap, + ]; + + final expectedMap = { + 'key': 'value', + 'key2': 1, + 'key3': 1.1, + 'key4': true, + 'key5': customObject.toString(), + 'list': expectedList, + 'nestedMap': expectedNestedMap, + }; + + group('JNI (Android)', () { + test('dartToJObject converts primitives', () { + using((arena) { + _expectJniStringEquals( + dartToJObject('value')..releasedBy(arena), 'value'); + _expectJniLongEquals(dartToJObject(1)..releasedBy(arena), 1); + _expectJniDoubleEquals(dartToJObject(1.1)..releasedBy(arena), 1.1); + _expectJniBoolEquals(dartToJObject(true)..releasedBy(arena), true); + _expectJniStringEquals( + dartToJObject(customObject)..releasedBy(arena), + customObject.toString(), + ); + }); + }); + + test('dartToJObject converts list (drops nulls)', () { + using((arena) { + final javaList = dartToJObject(inputList).as(JList.type(JObject.type)) + ..releasedBy(arena); + _expectJniList(javaList, expectedList, arena); + }); + }); + + test('dartToJObject converts map (drops null values)', () { + using((arena) { + final javaMap = dartToJObject(inputMap) + .as(JMap.type(JString.type, JObject.type)) + ..releasedBy(arena); + _expectJniMap(javaMap, expectedMap, arena); + }); + }); + + test('dartToJList', () { + using((arena) { + final javaList = dartToJList(inputList)..releasedBy(arena); + _expectJniList(javaList, expectedList, arena); + }); + }); + + test('dartToJMap', () { + using((arena) { + final javaMap = dartToJMap(inputMap)..releasedBy(arena); + _expectJniMap(javaMap, expectedMap, arena); + }); + }); + }, skip: !Platform.isAndroid); +} + +void _expectJniStringEquals(JObject? javaObject, String expected) { + expect(javaObject, isNotNull); + final javaString = javaObject!.as(JString.type); + expect(javaString.toDartString(releaseOriginal: true), expected); +} + +void _expectJniLongEquals(JObject? javaObject, int expected) { + expect(javaObject, isNotNull); + final javaLong = javaObject!.as(JLong.type); + expect(javaLong.longValue(releaseOriginal: true), expected); +} + +void _expectJniDoubleEquals(JObject? javaObject, double expected) { + expect(javaObject, isNotNull); + final javaDouble = javaObject!.as(JDouble.type); + expect(javaDouble.doubleValue(releaseOriginal: true), expected); +} + +void _expectJniBoolEquals(JObject? javaObject, bool expected) { + expect(javaObject, isNotNull); + final javaBoolean = javaObject!.as(JBoolean.type); + expect(javaBoolean.booleanValue(releaseOriginal: true), expected); +} + +JObject? _get(JMap javaMap, String key, Arena arena) => + javaMap[key.toJString()..releasedBy(arena)]; + +void _expectJniList( + JList javaList, + List expectedListValues, + Arena arena, +) { + expect(javaList.length, expectedListValues.length); + + _expectJniStringEquals(javaList[0], expectedListValues[0] as String); + _expectJniLongEquals(javaList[1], expectedListValues[1] as int); + _expectJniDoubleEquals(javaList[2], expectedListValues[2] as double); + _expectJniBoolEquals(javaList[3], expectedListValues[3] as bool); + _expectJniStringEquals(javaList[4], expectedListValues[4] as String); + + final nestedList = javaList[5].as(JList.type(JObject.type)) + ..releasedBy(arena); + final expectedNestedList = expectedListValues[5] as List; + expect(nestedList.length, expectedNestedList.length); + _expectJniStringEquals(nestedList[0], expectedNestedList[0] as String); + _expectJniLongEquals(nestedList[1], expectedNestedList[1] as int); + + final nestedMap = javaList[6].as(JMap.type(JString.type, JObject.type)) + ..releasedBy(arena); + _expectJniNestedMap( + nestedMap, + expectedListValues[6] as Map, + expectedNestedList.length, + arena, + ); +} + +void _expectJniMap( + JMap javaMap, + Map expectedMapValues, + Arena arena, +) { + expect(javaMap.length, expectedMapValues.length); + + final expectedList = expectedMapValues['list']! as List; + final expectedNestedList = expectedList[5] as List; + final expectedNestedMap = + expectedMapValues['nestedMap']! as Map; + + _expectJniStringEquals( + _get(javaMap, 'key', arena), expectedMapValues['key'] as String); + _expectJniLongEquals( + _get(javaMap, 'key2', arena), expectedMapValues['key2'] as int); + _expectJniDoubleEquals( + _get(javaMap, 'key3', arena), expectedMapValues['key3'] as double); + _expectJniBoolEquals( + _get(javaMap, 'key4', arena), expectedMapValues['key4'] as bool); + _expectJniStringEquals( + _get(javaMap, 'key5', arena), expectedMapValues['key5'] as String); + + final nestedList = _get(javaMap, 'list', arena)!.as(JList.type(JObject.type)) + ..releasedBy(arena); + _expectJniList(nestedList, expectedList, arena); + + final nestedMap = _get(javaMap, 'nestedMap', arena)! + .as(JMap.type(JString.type, JObject.type)) + ..releasedBy(arena); + _expectJniNestedMap( + nestedMap, expectedNestedMap, expectedNestedList.length, arena); + + expect(_get(javaMap, 'nullEntry', arena), isNull); +} + +void _expectJniNestedMap( + JMap javaNestedMap, + Map expectedNestedMapValues, + int expectedNestedListLength, + Arena arena, +) { + _expectJniStringEquals(_get(javaNestedMap, 'innerString', arena), + expectedNestedMapValues['innerString'] as String); + + final innerList = _get(javaNestedMap, 'innerList', arena)! + .as(JList.type(JObject.type)) + ..releasedBy(arena); + expect(innerList.length, expectedNestedListLength); + _expectJniLongEquals(innerList[0], + (expectedNestedMapValues['innerList']! as List)[0] as int); + _expectJniLongEquals(innerList[1], + (expectedNestedMapValues['innerList']! as List)[1] as int); + + expect(_get(javaNestedMap, 'innerNull', arena), isNull); +} diff --git a/packages/flutter/example/integration_test/utils.dart b/packages/flutter/example/integration_test/utils.dart index 3e1b42d2d7..89ba185c10 100644 --- a/packages/flutter/example/integration_test/utils.dart +++ b/packages/flutter/example/integration_test/utils.dart @@ -22,3 +22,6 @@ FutureOr restoreFlutterOnErrorAfter(FutureOr Function() fn) async { } const fakeDsn = 'https://abc@def.ingest.sentry.io/1234567'; + +// Used to test for correct serialization of custom object in attributes / data. +class CustomObject {} 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 3697c6e618..62d88b4df1 100644 --- a/packages/flutter/lib/src/native/java/sentry_native_java.dart +++ b/packages/flutter/lib/src/native/java/sentry_native_java.dart @@ -192,7 +192,7 @@ class SentryNativeJava extends SentryNativeChannel { final nativeOptions = native.ScopesAdapter.getInstance()?.getOptions() ?..releasedBy(arena); if (nativeOptions == null) return; - final jMap = _dartToJMap(breadcrumb.toJson()); + final jMap = dartToJMap(breadcrumb.toJson()); final nativeBreadcrumb = native.Breadcrumb.fromMap(jMap, nativeOptions) ?..releasedBy(arena); @@ -219,7 +219,7 @@ class SentryNativeJava extends SentryNativeChannel { ?..releasedBy(arena); if (nativeOptions == null) return; - final jMap = _dartToJMap(user.toJson()); + final jMap = dartToJMap(user.toJson()); final nativeUser = native.User.fromMap(jMap, nativeOptions) ?..releasedBy(arena); // release jMap directly after use @@ -239,9 +239,7 @@ class SentryNativeJava extends SentryNativeChannel { run: (iScope) { using((arena) { final jKey = key.toJString()..releasedBy(arena); - final jVal = _dartToJObject(value)?..releasedBy(arena); - - if (jVal == null) return; + final jVal = dartToJObject(value)..releasedBy(arena); final scope = iScope.as(const native.$Scope$Type()) ..releasedBy(arena); @@ -285,8 +283,6 @@ class SentryNativeJava extends SentryNativeChannel { @override void setExtra(String key, dynamic value) => tryCatchSync('setExtra', () { - if (value == null) return; - using((arena) { final jKey = key.toJString()..releasedBy(arena); final jVal = normalize(value).toString().toJString() @@ -390,35 +386,37 @@ class SentryNativeJava extends SentryNativeChannel { }); } -JObject? _dartToJObject(Object? value) => switch (value) { - null => null, +@visibleForTesting +JObject dartToJObject(Object? value) => switch (value) { String s => s.toJString(), bool b => b.toJBoolean(), int i => i.toJLong(), double d => d.toJDouble(), - List l => _dartToJList(l), - Map m => _dartToJMap(m), - _ => null + List l => dartToJList(l), + Map m => dartToJMap(m), + _ => value.toString().toJString() }; -JList _dartToJList(List values) { - final jList = JList.array(JObject.nullableType); - for (final v in values) { - final j = _dartToJObject(v); +@visibleForTesting +JList dartToJList(List values) { + final jList = JList.array(JObject.type); + for (final v in values.nonNulls) { + final j = dartToJObject(v); jList.add(j); - j?.release(); + j.release(); } return jList; } -JMap _dartToJMap(Map json) { - final jMap = JMap.hash(JString.type, JObject.nullableType); - for (final entry in json.entries) { +@visibleForTesting +JMap dartToJMap(Map json) { + final jMap = JMap.hash(JString.type, JObject.type); + for (final entry in json.entries.where((e) => e.value != null)) { final jk = entry.key.toJString(); - final jv = _dartToJObject(entry.value); + final jv = dartToJObject(entry.value); jMap[jk] = jv; jk.release(); - jv?.release(); + jv.release(); } return jMap; } diff --git a/packages/flutter/lib/src/native/java/sentry_native_java_init.dart b/packages/flutter/lib/src/native/java/sentry_native_java_init.dart index 36642754be..98af364019 100644 --- a/packages/flutter/lib/src/native/java/sentry_native_java_init.dart +++ b/packages/flutter/lib/src/native/java/sentry_native_java_init.dart @@ -125,7 +125,7 @@ native.SentryOptions$BeforeSendReplayCallback createBeforeSendReplayCallback( return shouldRemove; }); - final jMap = _dartToJMap(options.privacy.toJson()); + final jMap = dartToJMap(options.privacy.toJson()); payload?.addAll(jMap); jMap.release(); }