Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/flutter/example/integration_test/all.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
58 changes: 54 additions & 4 deletions packages/flutter/example/integration_test/integration_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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);
Expand All @@ -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<String, String>.
// 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 {
Expand Down
174 changes: 174 additions & 0 deletions packages/flutter/example/integration_test/native_jni_utils_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// ignore_for_file: depend_on_referenced_packages

@TestOn('vm')

import 'dart:io';

import 'package:flutter_test/flutter_test.dart';
import 'package:jni/jni.dart';
import 'package:sentry_flutter/src/native/java/sentry_native_java.dart';

import 'utils.dart';

final _customObject = CustomObject();

final _nestedMap = {
'innerString': 'nested',
'innerList': [1, null, 2],
'innerNull': null,
};

final _testList = [
'value',
1,
1.1,
true,
_customObject,
['nestedList', 2],
_nestedMap,
null,
];

final _testMap = {
'key': 'value',
'key2': 1,
'key3': 1.1,
'key4': true,
'key5': _customObject,
'list': _testList,
'nestedMap': _nestedMap,
'nullEntry': null,
};

const _expectedListLength = 7;
const _expectedNestedListLength = 2;
const _expectedMapLength = 7;

void main() {
group('JNI (Android)', () {
test('dartToJObject converts primitives', () {
_expectJniString(dartToJObject('value'), 'value');
_expectJniInt(dartToJObject(1), 1);
_expectJniDouble(dartToJObject(1.1), 1.1);
_expectJniBool(dartToJObject(true), true);
_expectJniString(dartToJObject(_customObject), _customObject.toString());
});

test('dartToJObject converts list (drops nulls)', () {
final jList = dartToJObject(_testList).as(JList.type(JObject.type));
addTearDown(jList.release);
_verifyJniList(jList);
});

test('dartToJObject converts map (drops null values)', () {
final jMap =
dartToJObject(_testMap).as(JMap.type(JString.type, JObject.type));
addTearDown(jMap.release);
_verifyJniMap(jMap);
});

test('dartToJList', () {
final jList = dartToJList(_testList);
addTearDown(jList.release);
_verifyJniList(jList);
});

test('dartToJMap', () {
final jMap = dartToJMap(_testMap);
addTearDown(jMap.release);
_verifyJniMap(jMap);
});
}, skip: !Platform.isAndroid);
}

void _expectJniString(JObject obj, String expected) {
expect(obj, isA<JString>());
expect((obj as JString).toDartString(releaseOriginal: true), expected);
}

void _expectJniInt(JObject obj, int expected) {
expect(obj, isA<JLong>());
expect((obj as JLong).longValue(releaseOriginal: true), expected);
}

void _expectJniDouble(JObject obj, double expected) {
expect(obj, isA<JDouble>());
expect((obj as JDouble).doubleValue(releaseOriginal: true), expected);
}

void _expectJniBool(JObject obj, bool expected) {
expect(obj, isA<JBoolean>());
expect((obj as JBoolean).booleanValue(releaseOriginal: true), expected);
}

JObject? _jniGet(JMap<JString, JObject> map, String key) {
final jKey = key.toJString();
final value = map[jKey];
jKey.release();
return value;
}

bool _jniIsNull(JObject? obj) => obj == null || obj.toString() == 'null';

void _verifyJniList(JList<JObject> list) {
expect(list.length, _expectedListLength);

// Verify primitives
expect(list[0].as(JString.type).toDartString(), 'value');
expect(list[1].as(JLong.type).longValue(), 1);
expect(list[2].as(JDouble.type).doubleValue(), 1.1);
expect(list[3].as(JBoolean.type).booleanValue(), isTrue);
expect(list[4].as(JString.type).toDartString(), _customObject.toString());

// Verify nested list
final nestedList = list[5].as(JList.type(JObject.type));
expect(nestedList.length, 2);
expect(nestedList[0].as(JString.type).toDartString(), 'nestedList');
expect(nestedList[1].as(JLong.type).longValue(), 2);
nestedList.release();

// Verify nested map
final nestedMap = list[6].as(JMap.type(JString.type, JObject.type));
_verifyJniNestedMap(nestedMap);
nestedMap.release();
}

void _verifyJniMap(JMap<JString, JObject> map) {
expect(map.length, _expectedMapLength);

// Verify primitives
expect(_jniGet(map, 'key')!.as(JString.type).toDartString(), 'value');
expect(_jniGet(map, 'key2')!.as(JLong.type).longValue(), 1);
expect(_jniGet(map, 'key3')!.as(JDouble.type).doubleValue(), 1.1);
expect(_jniGet(map, 'key4')!.as(JBoolean.type).booleanValue(), isTrue);
expect(_jniGet(map, 'key5')!.as(JString.type).toDartString(),
_customObject.toString());

// Verify nested list
final nestedList = _jniGet(map, 'list')!.as(JList.type(JObject.type));
_verifyJniList(nestedList);
nestedList.release();

// Verify nested map
final nestedMap =
_jniGet(map, 'nestedMap')!.as(JMap.type(JString.type, JObject.type));
_verifyJniNestedMap(nestedMap);
nestedMap.release();

// Verify null was dropped
expect(_jniIsNull(_jniGet(map, 'nullEntry')), isTrue);
}

void _verifyJniNestedMap(JMap<JString, JObject> map) {
expect(
_jniGet(map, 'innerString')!.as(JString.type).toDartString(), 'nested');

final innerList = _jniGet(map, 'innerList')!.as(JList.type(JObject.type));
expect(innerList.length, _expectedNestedListLength);
expect(innerList[0].as(JLong.type).longValue(), 1);
expect(innerList[1].as(JLong.type).longValue(), 2);
innerList.release();

// Verify null was dropped
expect(_jniIsNull(_jniGet(map, 'innerNull')), isTrue);
}
3 changes: 3 additions & 0 deletions packages/flutter/example/integration_test/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ FutureOr<void> restoreFlutterOnErrorAfter(FutureOr<void> 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 {}
41 changes: 21 additions & 20 deletions packages/flutter/lib/src/native/java/sentry_native_java.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -233,15 +233,14 @@ class SentryNativeJava extends SentryNativeChannel {

@override
void setContexts(String key, value) => tryCatchSync('setContexts', () {
if (value == null) return;
native.Sentry.configureScope(
native.ScopeCallback.implement(
native.$ScopeCallback(
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);
Expand Down Expand Up @@ -390,35 +389,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<dynamic> l => _dartToJList(l),
Map<String, dynamic> m => _dartToJMap(m),
_ => null
List<dynamic> l => dartToJList(l),
Map<String, dynamic> m => dartToJMap(m),
_ => value.toString().toJString()
};

JList<JObject?> _dartToJList(List<dynamic> values) {
final jList = JList.array(JObject.nullableType);
for (final v in values) {
final j = _dartToJObject(v);
@visibleForTesting
JList<JObject> dartToJList(List<dynamic> 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<JString, JObject?> _dartToJMap(Map<String, dynamic> json) {
final jMap = JMap.hash(JString.type, JObject.nullableType);
for (final entry in json.entries) {
@visibleForTesting
JMap<JString, JObject> dartToJMap(Map<String, dynamic> 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;
Comment on lines +392 to 424
Copy link
Contributor Author

Choose a reason for hiding this comment

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

java drops null values so we can just directly filter null values out

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Loading