Skip to content
Merged
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Fixes

- Normalize data properties of `SentryUser` and `Breadcrumb` before sending over method channel ([#1591](https://github.com/getsentry/sentry-dart/pull/1591))

## 7.9.0

### Features
Expand Down
44 changes: 44 additions & 0 deletions flutter/lib/src/method_channel_helper.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import 'package:meta/meta.dart';

/// Makes sure no invalid data is sent over method channels.
@internal
class MethodChannelHelper {
static Map<String, dynamic>? normalizeMap(Map<String, dynamic>? data) {
if (data == null) {
return null;
}
final mapToReturn = <String, dynamic>{};
data.forEach((key, value) {
if (_isPrimitive(value)) {
mapToReturn[key] = value;
} else if (value is List<dynamic>) {
mapToReturn[key] = _normalizeList(value);
} else if (value is Map<String, dynamic>) {
mapToReturn[key] = normalizeMap(value);
} else if (value is Object) {
mapToReturn[key] = value.toString();
}
});
return mapToReturn;
}

static List<dynamic> _normalizeList(List<dynamic> data) {
final listToReturn = <dynamic>[];
for (var element in data) {
if (_isPrimitive(element)) {
listToReturn.add(element);
} else if (element is List<dynamic>) {
listToReturn.add(_normalizeList(element));
} else if (element is Map<String, dynamic>) {
listToReturn.add(normalizeMap(element));
} else if (element is Object) {
listToReturn.add(element.toString());
}
}
return listToReturn;
}

static bool _isPrimitive(dynamic value) {
return value == null || value is String || value is num || value is bool;
}
}
18 changes: 15 additions & 3 deletions flutter/lib/src/sentry_native_channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import 'package:meta/meta.dart';

import '../sentry_flutter.dart';
import 'method_channel_helper.dart';

/// Provide typed methods to access native layer.
@internal
Expand Down Expand Up @@ -47,16 +48,27 @@ class SentryNativeChannel {

Future<void> setUser(SentryUser? user) async {
try {
await _channel.invokeMethod('setUser', {'user': user?.toJson()});
final normalizedUser = user?.copyWith(
data: MethodChannelHelper.normalizeMap(user.data),
);
await _channel.invokeMethod(
'setUser',
{'user': normalizedUser?.toJson()},
);
} catch (error, stackTrace) {
_logError('setUser', error, stackTrace);
}
}

Future<void> addBreadcrumb(Breadcrumb breadcrumb) async {
try {
await _channel
.invokeMethod('addBreadcrumb', {'breadcrumb': breadcrumb.toJson()});
final normalizedBreadcrumb = breadcrumb.copyWith(
data: MethodChannelHelper.normalizeMap(breadcrumb.data),
);
await _channel.invokeMethod(
'addBreadcrumb',
{'breadcrumb': normalizedBreadcrumb.toJson()},
);
} catch (error, stackTrace) {
_logError('addBreadcrumb', error, stackTrace);
}
Expand Down
1 change: 1 addition & 0 deletions flutter/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ dev_dependencies:
mockito: ^5.1.0
yaml: ^3.1.0 # needed for version match (code and pubspec)
flutter_lints: ^2.0.0
collection: ^1.16.0

flutter:
plugin:
Expand Down
99 changes: 99 additions & 0 deletions flutter/test/method_channel_helper_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sentry_flutter/src/method_channel_helper.dart';
import 'package:collection/collection.dart';

void main() {
test('primitives', () {
var expected = <String, dynamic>{
'null': null,
'int': 1,
'float': 1.1,
'bool': true,
'string': 'Foo',
};

var actual = MethodChannelHelper.normalizeMap(expected);
expect(
DeepCollectionEquality().equals(actual, expected),
true,
);
});

test('list with primitives', () {
var expected = <String, dynamic>{
'list': [null, 1, 1.1, true, 'Foo'],
};

var actual = MethodChannelHelper.normalizeMap(expected);
expect(
DeepCollectionEquality().equals(actual, expected),
true,
);
});

test('map with primitives', () {
var expected = <String, dynamic>{
'map': <String, dynamic>{
'null': null,
'int': 1,
'float': 1.1,
'bool': true,
'string': 'Foo',
},
};

var actual = MethodChannelHelper.normalizeMap(expected);
expect(
DeepCollectionEquality().equals(actual, expected),
true,
);
});

test('object', () {
var input = <String, dynamic>{'object': _CustomObject()};
var expected = <String, dynamic>{'object': 'CustomObject()'};

var actual = MethodChannelHelper.normalizeMap(input);
expect(
DeepCollectionEquality().equals(actual, expected),
true,
);
});

test('object in list', () {
var input = <String, dynamic>{
'object': [_CustomObject()]
};
var expected = <String, dynamic>{
'object': ['CustomObject()']
};

var actual = MethodChannelHelper.normalizeMap(input);
expect(
DeepCollectionEquality().equals(actual, expected),
true,
);
});

test('object in map', () {
var input = <String, dynamic>{
'object': <String, dynamic>{'object': _CustomObject()}
};
var expected = <String, dynamic>{
'object': <String, dynamic>{'object': 'CustomObject()'}
};

var actual = MethodChannelHelper.normalizeMap(input);
expect(
DeepCollectionEquality().equals(actual, expected),
true,
);
});
}

class _CustomObject {
@override
String toString() {
return 'CustomObject()';
}
}
29 changes: 22 additions & 7 deletions flutter/test/sentry_native_channel_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:sentry_flutter/src/method_channel_helper.dart';
import 'package:sentry_flutter/src/sentry_native.dart';
import 'package:sentry_flutter/src/sentry_native_channel.dart';
import 'mocks.mocks.dart';
Expand Down Expand Up @@ -64,26 +65,40 @@ void main() {
});

test('setUser', () async {
when(fixture.methodChannel.invokeMethod('setUser', {'user': null}))
final user = SentryUser(
id: "fixture-id",
data: {'object': Object()},
);
final normalizedUser = user.copyWith(
data: MethodChannelHelper.normalizeMap(user.data),
);
when(fixture.methodChannel
.invokeMethod('setUser', {'user': normalizedUser.toJson()}))
.thenAnswer((_) => Future.value());

final sut = fixture.getSut();
await sut.setUser(null);
await sut.setUser(user);

verify(fixture.methodChannel.invokeMethod('setUser', {'user': null}));
verify(fixture.methodChannel
.invokeMethod('setUser', {'user': normalizedUser.toJson()}));
});

test('addBreadcrumb', () async {
final breadcrumb = Breadcrumb();
final breadcrumb = Breadcrumb(
data: {'object': Object()},
);
final normalizedBreadcrumb = breadcrumb.copyWith(
data: MethodChannelHelper.normalizeMap(breadcrumb.data));

when(fixture.methodChannel.invokeMethod(
'addBreadcrumb', {'breadcrumb': breadcrumb.toJson()}))
'addBreadcrumb', {'breadcrumb': normalizedBreadcrumb.toJson()}))
.thenAnswer((_) => Future.value());

final sut = fixture.getSut();
await sut.addBreadcrumb(breadcrumb);

verify(fixture.methodChannel
.invokeMethod('addBreadcrumb', {'breadcrumb': breadcrumb.toJson()}));
verify(fixture.methodChannel.invokeMethod(
'addBreadcrumb', {'breadcrumb': normalizedBreadcrumb.toJson()}));
});

test('clearBreadcrumbs', () async {
Expand Down