From e4de0285eda4bc566f975b167c4364d474ebca26 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 19 May 2026 10:40:32 +0200 Subject: [PATCH 01/22] perf(flutter): Optimize Android scope sync Send large Android scope payloads as JSON bytes instead of recursively constructing Java maps and lists through JNI. This keeps nested user data structured while reducing per-entry JNI object churn. Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- .../io/sentry/flutter/SentryFlutterPlugin.kt | 93 ++++++++- .../integration_test/integration_test.dart | 81 ++++++-- .../native_jni_utils_test.dart | 37 +++- .../flutter/lib/src/native/java/binding.dart | 180 ++++++++++++++++++ .../src/native/java/sentry_native_java.dart | 46 ++--- .../lib/src/native/utils/utf8_json.dart | 21 ++ 6 files changed, 410 insertions(+), 48 deletions(-) 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 420ffcf69b..4476eb4023 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,6 +15,7 @@ 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.JsonObjectReader import io.sentry.ScopesAdapter import io.sentry.Sentry import io.sentry.SentryOptions @@ -31,8 +32,11 @@ import io.sentry.protocol.DebugImage import io.sentry.protocol.SdkVersion import io.sentry.protocol.User import io.sentry.transport.CurrentDateProvider -import org.json.JSONObject import org.json.JSONArray +import org.json.JSONObject +import org.json.JSONTokener +import java.io.ByteArrayInputStream +import java.io.InputStreamReader import java.lang.ref.WeakReference import java.net.Proxy.Type import kotlin.math.roundToInt @@ -172,6 +176,57 @@ class SentryFlutterPlugin : } } + @Suppress("unused", "TooGenericExceptionCaught") // Used by native/jni bindings + @JvmStatic + fun addBreadcrumbFromJsonBytes(bytes: ByteArray) { + try { + val options = ScopesAdapter.getInstance().options + val breadcrumb = + jsonObjectReader(bytes).use { reader -> + Breadcrumb.Deserializer().deserialize(reader, options.logger) + } + Sentry.addBreadcrumb(breadcrumb) + } catch (e: Exception) { + Log.e("Sentry", "Failed to add breadcrumb from JSON bytes", e) + } + } + + @Suppress("unused", "TooGenericExceptionCaught") // Used by native/jni bindings + @JvmStatic + fun setUserFromJsonBytes(bytes: ByteArray?) { + try { + if (bytes == null) { + Sentry.setUser(null) + return + } + + val options = ScopesAdapter.getInstance().options + val user = + jsonObjectReader(bytes).use { reader -> + User.Deserializer().deserialize(reader, options.logger) + } + Sentry.setUser(user) + } catch (e: Exception) { + Log.e("Sentry", "Failed to set user from JSON bytes", e) + } + } + + @Suppress("unused", "TooGenericExceptionCaught") // Used by native/jni bindings + @JvmStatic + fun setContextFromJsonBytes( + key: String, + bytes: ByteArray, + ) { + try { + val value = parseJsonBytes(bytes) + Sentry.configureScope { scope -> + scope.setContexts(key, value) + } + } catch (e: Exception) { + Log.e("Sentry", "Failed to set context from JSON bytes", e) + } + } + @Suppress("unused") // Used by native/jni bindings @JvmStatic fun removeContext(key: String) { @@ -395,6 +450,42 @@ class SentryFlutterPlugin : "debug_file" to debugFile, ) + private fun parseJsonBytes(bytes: ByteArray): Any? { + val json = String(bytes, Charsets.UTF_8) + return JSONTokener(json).nextValue().toKotlinJsonValue() + } + + private fun jsonObjectReader(bytes: ByteArray): JsonObjectReader = + JsonObjectReader(InputStreamReader(ByteArrayInputStream(bytes), Charsets.UTF_8)) + + private fun Any?.toKotlinJsonValue(): Any? = + when (this) { + null, JSONObject.NULL -> null + is JSONObject -> { + val map = mutableMapOf() + val keys = keys() + while (keys.hasNext()) { + val key = keys.next() + val value = opt(key).toKotlinJsonValue() + if (value != null) { + map[key] = value + } + } + map + } + is JSONArray -> { + val list = mutableListOf() + for (i in 0 until length()) { + val value = opt(i).toKotlinJsonValue() + if (value != null) { + list.add(value) + } + } + list + } + else -> this + } + private fun Double.adjustReplaySizeToBlockSize(): Double { val remainder = this % VIDEO_BLOCK_SIZE return if (remainder <= VIDEO_BLOCK_SIZE / 2) { diff --git a/packages/flutter/example/integration_test/integration_test.dart b/packages/flutter/example/integration_test/integration_test.dart index ae0dd8a597..c51bc5951b 100644 --- a/packages/flutter/example/integration_test/integration_test.dart +++ b/packages/flutter/example/integration_test/integration_test.dart @@ -151,6 +151,64 @@ void main() { }); }); + testWidgets('syncs large scope maps to native on Android', (tester) async { + await restoreFlutterOnErrorAfter(() async { + await setupSentryAndApp(tester); + }); + + final largeItems = List.generate( + 64, + (index) => { + 'index': index, + 'label': 'item-$index', + 'nested': { + 'enabled': index.isEven, + 'value': index * 1.5, + 'nullEntry': null, + }, + }, + ); + + await Sentry.configureScope((scope) async { + await scope.setContexts('large_context', { + 'items': largeItems, + 'customObject': CustomObject(), + 'nullEntry': null, + }); + await scope.setUser(SentryUser( + id: 'large-user', + data: { + 'items': largeItems, + 'customObject': CustomObject(), + }, + )); + await scope.addBreadcrumb(Breadcrumb( + message: 'large-breadcrumb', + data: { + 'items': largeItems, + 'customObject': CustomObject(), + 'nullEntry': null, + }, + )); + }); + + final nativeContexts = await SentryFlutter.native?.loadContexts(); + final contextData = nativeContexts?['contexts'] as Map?; + final largeContext = contextData?['large_context'] as Map?; + final nativeUser = nativeContexts?['user'] as Map?; + final breadcrumbs = nativeContexts?['breadcrumbs'] as List?; + final nativeBreadcrumb = breadcrumbs?.cast().firstWhere( + (breadcrumb) => breadcrumb['message'] == 'large-breadcrumb', + ); + + expect((largeContext?['items'] as List?)?.length, 64); + expect(largeContext?['customObject'], CustomObject().toString()); + expect(nativeUser?['id'], 'large-user'); + expect(((nativeUser?['data'] as Map?)?['items'] as List?)?.length, 64); + expect( + ((nativeBreadcrumb?['data'] as Map?)?['items'] as List?)?.length, 64); + }, skip: !Platform.isAndroid); + testWidgets('setup sentry and start transaction', (tester) async { await setupSentryAndApp(tester); @@ -806,23 +864,12 @@ void main() { 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)); - } + 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 index 21c5bdbccf..16e163b232 100644 --- a/packages/flutter/example/integration_test/native_jni_utils_test.dart +++ b/packages/flutter/example/integration_test/native_jni_utils_test.dart @@ -1,4 +1,4 @@ -// ignore_for_file: depend_on_referenced_packages +// ignore_for_file: depend_on_referenced_packages, invalid_use_of_internal_member @TestOn('vm') import 'dart:io'; @@ -6,6 +6,7 @@ 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 'package:sentry_flutter/src/native/utils/utf8_json.dart'; import 'utils.dart'; @@ -47,6 +48,10 @@ void main() { 'innerList': [1, 2], 'innerNull': null, }; + final expectedNormalizedNestedMap = { + 'innerString': 'nested', + 'innerList': [1, 2], + }; final expectedList = [ 'value', 1, @@ -66,6 +71,19 @@ void main() { 'list': expectedList, 'nestedMap': expectedNestedMap, }; + final expectedNormalizedMap = { + ...expectedMap, + 'list': [ + 'value', + 1, + 1.1, + true, + customObject.toString(), + expectedNestedList, + expectedNormalizedNestedMap, + ], + 'nestedMap': expectedNormalizedNestedMap, + }; group('JNI (Android)', () { test('dartToJObject converts primitives', () { @@ -112,6 +130,23 @@ void main() { _expectJniMap(javaMap, expectedMap, arena); }); }); + + test('normalizeNativeJson normalizes values for JSON bytes', () { + final actual = normalizeNativeJson(inputMap); + + expect(actual, expectedNormalizedMap); + }); + + test('jsonToJByteArray converts normalized JSON to bytes', () { + using((arena) { + final javaBytes = jsonToJByteArray(inputMap)..releasedBy(arena); + final byteRange = javaBytes.getRange(0, javaBytes.length); + final bytes = byteRange.buffer + .asUint8List(byteRange.offsetInBytes, byteRange.length); + + expect(bytes, isNotEmpty); + }); + }); }, skip: !Platform.isAndroid); } diff --git a/packages/flutter/lib/src/native/java/binding.dart b/packages/flutter/lib/src/native/java/binding.dart index 295842db8f..989a1c4adc 100644 --- a/packages/flutter/lib/src/native/java/binding.dart +++ b/packages/flutter/lib/src/native/java/binding.dart @@ -4661,6 +4661,96 @@ class SentryFlutterPlugin$Companion extends jni$_.JObject { .check(); } + static final _id_addBreadcrumbFromJsonBytes = _class.instanceMethodId( + r'addBreadcrumbFromJsonBytes', + r'([B)V', + ); + + static final _addBreadcrumbFromJsonBytes = + 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 addBreadcrumbFromJsonBytes(byte[] bs)` + void addBreadcrumbFromJsonBytes( + jni$_.JByteArray bs, + ) { + final _$bs = bs.reference; + _addBreadcrumbFromJsonBytes(reference.pointer, + _id_addBreadcrumbFromJsonBytes as jni$_.JMethodIDPtr, _$bs.pointer) + .check(); + } + + static final _id_setUserFromJsonBytes = _class.instanceMethodId( + r'setUserFromJsonBytes', + r'([B)V', + ); + + static final _setUserFromJsonBytes = 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 setUserFromJsonBytes(byte[] bs)` + void setUserFromJsonBytes( + jni$_.JByteArray? bs, + ) { + final _$bs = bs?.reference ?? jni$_.jNullReference; + _setUserFromJsonBytes(reference.pointer, + _id_setUserFromJsonBytes as jni$_.JMethodIDPtr, _$bs.pointer) + .check(); + } + + static final _id_setContextFromJsonBytes = _class.instanceMethodId( + r'setContextFromJsonBytes', + r'(Ljava/lang/String;[B)V', + ); + + static final _setContextFromJsonBytes = jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JThrowablePtr Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + jni$_.VarArgs< + ( + jni$_.Pointer, + jni$_.Pointer + )>)>>('globalEnv_CallVoidMethod') + .asFunction< + jni$_.JThrowablePtr Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + jni$_.Pointer, + jni$_.Pointer)>(); + + /// from: `public final void setContextFromJsonBytes(java.lang.String string, byte[] bs)` + void setContextFromJsonBytes( + jni$_.JString string, + jni$_.JByteArray bs, + ) { + final _$string = string.reference; + final _$bs = bs.reference; + _setContextFromJsonBytes( + reference.pointer, + _id_setContextFromJsonBytes as jni$_.JMethodIDPtr, + _$string.pointer, + _$bs.pointer) + .check(); + } + static final _id_removeContext = _class.instanceMethodId( r'removeContext', r'(Ljava/lang/String;)V', @@ -5317,6 +5407,96 @@ class SentryFlutterPlugin extends jni$_.JObject { .check(); } + static final _id_addBreadcrumbFromJsonBytes = _class.staticMethodId( + r'addBreadcrumbFromJsonBytes', + r'([B)V', + ); + + static final _addBreadcrumbFromJsonBytes = + 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 addBreadcrumbFromJsonBytes(byte[] bs)` + static void addBreadcrumbFromJsonBytes( + jni$_.JByteArray bs, + ) { + final _$bs = bs.reference; + _addBreadcrumbFromJsonBytes(_class.reference.pointer, + _id_addBreadcrumbFromJsonBytes as jni$_.JMethodIDPtr, _$bs.pointer) + .check(); + } + + static final _id_setUserFromJsonBytes = _class.staticMethodId( + r'setUserFromJsonBytes', + r'([B)V', + ); + + static final _setUserFromJsonBytes = 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 setUserFromJsonBytes(byte[] bs)` + static void setUserFromJsonBytes( + jni$_.JByteArray? bs, + ) { + final _$bs = bs?.reference ?? jni$_.jNullReference; + _setUserFromJsonBytes(_class.reference.pointer, + _id_setUserFromJsonBytes as jni$_.JMethodIDPtr, _$bs.pointer) + .check(); + } + + static final _id_setContextFromJsonBytes = _class.staticMethodId( + r'setContextFromJsonBytes', + r'(Ljava/lang/String;[B)V', + ); + + static final _setContextFromJsonBytes = jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JThrowablePtr Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + jni$_.VarArgs< + ( + jni$_.Pointer, + jni$_.Pointer + )>)>>('globalEnv_CallStaticVoidMethod') + .asFunction< + jni$_.JThrowablePtr Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + jni$_.Pointer, + jni$_.Pointer)>(); + + /// from: `static public final void setContextFromJsonBytes(java.lang.String string, byte[] bs)` + static void setContextFromJsonBytes( + jni$_.JString string, + jni$_.JByteArray bs, + ) { + final _$string = string.reference; + final _$bs = bs.reference; + _setContextFromJsonBytes( + _class.reference.pointer, + _id_setContextFromJsonBytes as jni$_.JMethodIDPtr, + _$string.pointer, + _$bs.pointer) + .check(); + } + static final _id_removeContext = _class.staticMethodId( r'removeContext', r'(Ljava/lang/String;)V', 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 dc3c2b1f88..45a9dc3a69 100644 --- a/packages/flutter/lib/src/native/java/sentry_native_java.dart +++ b/packages/flutter/lib/src/native/java/sentry_native_java.dart @@ -207,19 +207,9 @@ class SentryNativeJava extends SentryNativeChannel { void addBreadcrumb(Breadcrumb breadcrumb) => tryCatchSync('addBreadcrumb', () { using((arena) { - final scopesAdapter = native.ScopesAdapter.getInstance() - ?..releasedBy(arena); - if (scopesAdapter == null) return; - final nativeOptions = scopesAdapter.getOptions()..releasedBy(arena); - - final jMap = dartToJMap(breadcrumb.toJson()); - final nativeBreadcrumb = - native.Breadcrumb.fromMap(jMap, nativeOptions) - ?..releasedBy(arena); - // release jMap directly after use - jMap.release(); - if (nativeBreadcrumb == null) return; - native.Sentry.addBreadcrumb$1(nativeBreadcrumb); + final jBytes = jsonToJByteArray(breadcrumb.toJson()) + ..releasedBy(arena); + native.SentryFlutterPlugin.addBreadcrumbFromJsonBytes(jBytes); }); }); @@ -232,21 +222,10 @@ class SentryNativeJava extends SentryNativeChannel { void setUser(SentryUser? user) => tryCatchSync('setUser', () { using((arena) { if (user == null) { - native.Sentry.setUser(null); + native.SentryFlutterPlugin.setUserFromJsonBytes(null); } else { - final scopesAdapter = native.ScopesAdapter.getInstance() - ?..releasedBy(arena); - if (scopesAdapter == null) return; - final nativeOptions = scopesAdapter.getOptions()..releasedBy(arena); - - final jMap = dartToJMap(user.toJson()); - final nativeUser = native.User.fromMap(jMap, nativeOptions) - ?..releasedBy(arena); - // release jMap directly after use - jMap.release(); - if (nativeUser == null) return; - - native.Sentry.setUser(nativeUser); + final jBytes = jsonToJByteArray(user.toJson())..releasedBy(arena); + native.SentryFlutterPlugin.setUserFromJsonBytes(jBytes); } }); }); @@ -255,9 +234,9 @@ class SentryNativeJava extends SentryNativeChannel { void setContexts(String key, value) => tryCatchSync('setContexts', () { using((arena) { final jKey = key.toJString()..releasedBy(arena); - final jVal = dartToJObject(value)..releasedBy(arena); + final jBytes = jsonToJByteArray(value)..releasedBy(arena); - native.SentryFlutterPlugin.setContext(jKey, jVal); + native.SentryFlutterPlugin.setContextFromJsonBytes(jKey, jBytes); }); }); @@ -412,6 +391,11 @@ class SentryNativeJava extends SentryNativeChannel { } } +// Direct JNI conversion is fine for primitives. Use the Map/List conversion +// branches below only for small, known-shape payloads. Arbitrary, +// user-controlled, or potentially large maps/lists should cross JNI as UTF-8 +// JSON bytes and be deserialized on the Java/Kotlin side to avoid per-entry JNI +// calls and local reference churn. @visibleForTesting JObject dartToJObject(Object? value) => switch (value) { String s => s.toJString(), @@ -423,6 +407,10 @@ JObject dartToJObject(Object? value) => switch (value) { _ => value.toString().toJString() }; +@visibleForTesting +JByteArray jsonToJByteArray(Object? value) => + JByteArray.from(encodeUtf8Json(normalizeNativeJson(value))); + @visibleForTesting JList dartToJList(List values) { final jList = JList.array(JObject.type); diff --git a/packages/flutter/lib/src/native/utils/utf8_json.dart b/packages/flutter/lib/src/native/utils/utf8_json.dart index c47a0b8b33..fb47dc5c0f 100644 --- a/packages/flutter/lib/src/native/utils/utf8_json.dart +++ b/packages/flutter/lib/src/native/utils/utf8_json.dart @@ -3,6 +3,27 @@ import 'dart:typed_data'; import 'package:meta/meta.dart'; +@internal +Uint8List encodeUtf8Json(Object? data) { + final jsonString = json.encode(data); + return Uint8List.fromList(utf8.encode(jsonString)); +} + +@internal +Object? normalizeNativeJson(Object? value) => switch (value) { + null => 'null', + String s => s, + bool b => b, + num n => n, + List l => + l.nonNulls.map(normalizeNativeJson).toList(growable: false), + Map m => { + for (final entry in m.entries.where((e) => e.value != null)) + entry.key: normalizeNativeJson(entry.value) + }, + _ => value.toString() + }; + @internal Map decodeUtf8JsonMap(Uint8List bytes) { final jsonString = utf8.decode(bytes); From 3640d580bb9a73eef6a493339edc90f942e79e4f Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 19 May 2026 10:46:22 +0200 Subject: [PATCH 02/22] docs(flutter): Document JNI payload guidance Clarify that large or arbitrary Dart collection payloads should cross JNI as JSON bytes, while primitives and small controlled payloads can use direct conversion. Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- packages/flutter/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flutter/AGENTS.md b/packages/flutter/AGENTS.md index 43304b1e99..964c65e84d 100644 --- a/packages/flutter/AGENTS.md +++ b/packages/flutter/AGENTS.md @@ -20,9 +20,9 @@ Flutter SDK with native integrations across all platforms. - Release all native memory (JNI local refs, malloc allocations) - Handle native exceptions gracefully — never crash the host app - JNI bindings use `package:jni`; FFI bindings use `dart:ffi` +- JNI should pass primitives directly. Small, controlled `Map`/`List` payloads may use direct conversion; arbitrary or large payloads should cross as UTF-8 JSON bytes and deserialize on Kotlin/Java. - JNI and FFI can currently only be tested through integration test since they cannot be injected / mocked or faked. - ## Key Directories - `lib/src/integrations/` — Integration implementations (reference for new integrations) From b85800e074f9e77c3f73593fcc1cbb0f7445b0fb Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 19 May 2026 11:07:54 +0200 Subject: [PATCH 03/22] style(flutter): Wrap JNI JSON reader helper Keep the Android JSON reader helper within ktlint formatting limits after adding the scope sync byte-array bridge. Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- .../src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 4476eb4023..0567ccb3ae 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 @@ -456,7 +456,9 @@ class SentryFlutterPlugin : } private fun jsonObjectReader(bytes: ByteArray): JsonObjectReader = - JsonObjectReader(InputStreamReader(ByteArrayInputStream(bytes), Charsets.UTF_8)) + JsonObjectReader( + InputStreamReader(ByteArrayInputStream(bytes), Charsets.UTF_8), + ) private fun Any?.toKotlinJsonValue(): Any? = when (this) { From e15b49d67ee3d82afcf33542c44a279266bd17e8 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 19 May 2026 11:17:33 +0200 Subject: [PATCH 04/22] style(flutter): Apply ktlint when-branch formatting Wrap all native JSON conversion when branches consistently so ktlint accepts the multiline Kotlin helper added for Android scope sync. Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- .../kotlin/io/sentry/flutter/SentryFlutterPlugin.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 0567ccb3ae..1920ff3790 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 @@ -462,7 +462,10 @@ class SentryFlutterPlugin : private fun Any?.toKotlinJsonValue(): Any? = when (this) { - null, JSONObject.NULL -> null + null, JSONObject.NULL -> { + null + } + is JSONObject -> { val map = mutableMapOf() val keys = keys() @@ -475,6 +478,7 @@ class SentryFlutterPlugin : } map } + is JSONArray -> { val list = mutableListOf() for (i in 0 until length()) { @@ -485,7 +489,10 @@ class SentryFlutterPlugin : } list } - else -> this + + else -> { + this + } } private fun Double.adjustReplaySizeToBlockSize(): Double { From 7d8ec5f4208acb74f33d7acc9f8b0d954a69ea8c Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 19 May 2026 11:29:28 +0200 Subject: [PATCH 05/22] ref(flutter): Reuse native data normalizer Use the existing native boundary normalizer before encoding Android scope payloads as JSON bytes instead of maintaining a second normalization helper. Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- .../integration_test/native_jni_utils_test.dart | 13 ++++++++----- .../lib/src/native/java/sentry_native_java.dart | 2 +- .../flutter/lib/src/native/utils/utf8_json.dart | 15 --------------- 3 files changed, 9 insertions(+), 21 deletions(-) diff --git a/packages/flutter/example/integration_test/native_jni_utils_test.dart b/packages/flutter/example/integration_test/native_jni_utils_test.dart index 16e163b232..d8f05eb451 100644 --- a/packages/flutter/example/integration_test/native_jni_utils_test.dart +++ b/packages/flutter/example/integration_test/native_jni_utils_test.dart @@ -6,7 +6,7 @@ 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 'package:sentry_flutter/src/native/utils/utf8_json.dart'; +import 'package:sentry_flutter/src/native/utils/data_normalizer.dart'; import 'utils.dart'; @@ -50,7 +50,8 @@ void main() { }; final expectedNormalizedNestedMap = { 'innerString': 'nested', - 'innerList': [1, 2], + 'innerList': [1, null, 2], + 'innerNull': null, }; final expectedList = [ 'value', @@ -73,14 +74,16 @@ void main() { }; final expectedNormalizedMap = { ...expectedMap, + 'nullEntry': null, 'list': [ 'value', 1, 1.1, true, customObject.toString(), - expectedNestedList, + ['nestedList', 2], expectedNormalizedNestedMap, + null, ], 'nestedMap': expectedNormalizedNestedMap, }; @@ -131,8 +134,8 @@ void main() { }); }); - test('normalizeNativeJson normalizes values for JSON bytes', () { - final actual = normalizeNativeJson(inputMap); + test('normalize normalizes values for JSON bytes', () { + final actual = normalize(inputMap); expect(actual, expectedNormalizedMap); }); 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 45a9dc3a69..4b3281e8f2 100644 --- a/packages/flutter/lib/src/native/java/sentry_native_java.dart +++ b/packages/flutter/lib/src/native/java/sentry_native_java.dart @@ -409,7 +409,7 @@ JObject dartToJObject(Object? value) => switch (value) { @visibleForTesting JByteArray jsonToJByteArray(Object? value) => - JByteArray.from(encodeUtf8Json(normalizeNativeJson(value))); + JByteArray.from(encodeUtf8Json(normalize(value))); @visibleForTesting JList dartToJList(List values) { diff --git a/packages/flutter/lib/src/native/utils/utf8_json.dart b/packages/flutter/lib/src/native/utils/utf8_json.dart index fb47dc5c0f..e29636cdaf 100644 --- a/packages/flutter/lib/src/native/utils/utf8_json.dart +++ b/packages/flutter/lib/src/native/utils/utf8_json.dart @@ -9,21 +9,6 @@ Uint8List encodeUtf8Json(Object? data) { return Uint8List.fromList(utf8.encode(jsonString)); } -@internal -Object? normalizeNativeJson(Object? value) => switch (value) { - null => 'null', - String s => s, - bool b => b, - num n => n, - List l => - l.nonNulls.map(normalizeNativeJson).toList(growable: false), - Map m => { - for (final entry in m.entries.where((e) => e.value != null)) - entry.key: normalizeNativeJson(entry.value) - }, - _ => value.toString() - }; - @internal Map decodeUtf8JsonMap(Uint8List bytes) { final jsonString = utf8.decode(bytes); From b43bb6051a01b5f9674d65ede223bee1bbf5f07a Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 19 May 2026 11:54:30 +0200 Subject: [PATCH 06/22] fix(flutter): Preserve nulls in Android JSON bridge Keep null values when converting JSON object and array payloads on Android so the bridge remains lossless before native model deserialization. Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- .../io/sentry/flutter/SentryFlutterPlugin.kt | 10 ++-------- .../integration_test/integration_test.dart | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 11 deletions(-) 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 1920ff3790..ebbc109ef4 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 @@ -471,10 +471,7 @@ class SentryFlutterPlugin : val keys = keys() while (keys.hasNext()) { val key = keys.next() - val value = opt(key).toKotlinJsonValue() - if (value != null) { - map[key] = value - } + map[key] = opt(key).toKotlinJsonValue() } map } @@ -482,10 +479,7 @@ class SentryFlutterPlugin : is JSONArray -> { val list = mutableListOf() for (i in 0 until length()) { - val value = opt(i).toKotlinJsonValue() - if (value != null) { - list.add(value) - } + list.add(opt(i).toKotlinJsonValue()) } list } diff --git a/packages/flutter/example/integration_test/integration_test.dart b/packages/flutter/example/integration_test/integration_test.dart index c51bc5951b..2ada7fc875 100644 --- a/packages/flutter/example/integration_test/integration_test.dart +++ b/packages/flutter/example/integration_test/integration_test.dart @@ -174,12 +174,15 @@ void main() { 'items': largeItems, 'customObject': CustomObject(), 'nullEntry': null, + 'sparseList': ['a', null, 'c'], }); await scope.setUser(SentryUser( id: 'large-user', data: { 'items': largeItems, 'customObject': CustomObject(), + 'nullEntry': null, + 'sparseList': ['a', null, 'c'], }, )); await scope.addBreadcrumb(Breadcrumb( @@ -188,6 +191,7 @@ void main() { 'items': largeItems, 'customObject': CustomObject(), 'nullEntry': null, + 'sparseList': ['a', null, 'c'], }, )); }); @@ -203,10 +207,17 @@ void main() { expect((largeContext?['items'] as List?)?.length, 64); expect(largeContext?['customObject'], CustomObject().toString()); + expect(largeContext?.containsKey('nullEntry'), isTrue); + expect(largeContext?['nullEntry'], isNull); + expect(largeContext?['sparseList'], ['a', null, 'c']); expect(nativeUser?['id'], 'large-user'); - expect(((nativeUser?['data'] as Map?)?['items'] as List?)?.length, 64); - expect( - ((nativeBreadcrumb?['data'] as Map?)?['items'] as List?)?.length, 64); + final userData = nativeUser?['data'] as Map?; + expect((userData?['items'] as List?)?.length, 64); + expect(userData?['sparseList'], ['a', null, 'c']); + + final breadcrumbData = nativeBreadcrumb?['data'] as Map?; + expect((breadcrumbData?['items'] as List?)?.length, 64); + expect(breadcrumbData?['sparseList'], ['a', null, 'c']); }, skip: !Platform.isAndroid); testWidgets('setup sentry and start transaction', (tester) async { From a713631b6fda53aef307d0eb6ab4fdd2f894bcdc Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 19 May 2026 14:28:49 +0200 Subject: [PATCH 07/22] perf(flutter): Reuse Android JSON deserializers Cache JSON deserializers for scope sync payloads and replace the broad Any extension with a private helper function for Kotlin JSON conversion. Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- .../io/sentry/flutter/SentryFlutterPlugin.kt | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) 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 ebbc109ef4..c0485a7f6f 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 @@ -117,6 +117,14 @@ class SentryFlutterPlugin : private const val NATIVE_CRASH_WAIT_TIME = 500L + private val breadcrumbDeserializer by lazy { + Breadcrumb.Deserializer() + } + + private val userDeserializer by lazy { + User.Deserializer() + } + /** * Tears down the current ReplayIntegration to avoid invoking callbacks from a stale * Flutter isolate after hot restart. @@ -183,7 +191,7 @@ class SentryFlutterPlugin : val options = ScopesAdapter.getInstance().options val breadcrumb = jsonObjectReader(bytes).use { reader -> - Breadcrumb.Deserializer().deserialize(reader, options.logger) + breadcrumbDeserializer.deserialize(reader, options.logger) } Sentry.addBreadcrumb(breadcrumb) } catch (e: Exception) { @@ -203,7 +211,7 @@ class SentryFlutterPlugin : val options = ScopesAdapter.getInstance().options val user = jsonObjectReader(bytes).use { reader -> - User.Deserializer().deserialize(reader, options.logger) + userDeserializer.deserialize(reader, options.logger) } Sentry.setUser(user) } catch (e: Exception) { @@ -452,7 +460,7 @@ class SentryFlutterPlugin : private fun parseJsonBytes(bytes: ByteArray): Any? { val json = String(bytes, Charsets.UTF_8) - return JSONTokener(json).nextValue().toKotlinJsonValue() + return toKotlinJsonValue(JSONTokener(json).nextValue()) } private fun jsonObjectReader(bytes: ByteArray): JsonObjectReader = @@ -460,32 +468,32 @@ class SentryFlutterPlugin : InputStreamReader(ByteArrayInputStream(bytes), Charsets.UTF_8), ) - private fun Any?.toKotlinJsonValue(): Any? = - when (this) { + private fun toKotlinJsonValue(value: Any?): Any? = + when (value) { null, JSONObject.NULL -> { null } is JSONObject -> { val map = mutableMapOf() - val keys = keys() + val keys = value.keys() while (keys.hasNext()) { val key = keys.next() - map[key] = opt(key).toKotlinJsonValue() + map[key] = toKotlinJsonValue(value.opt(key)) } map } is JSONArray -> { val list = mutableListOf() - for (i in 0 until length()) { - list.add(opt(i).toKotlinJsonValue()) + for (i in 0 until value.length()) { + list.add(toKotlinJsonValue(value.opt(i))) } list } else -> { - this + value } } From c666afdc7fbde82d40f080dfe2ca2e4691987b48 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 19 May 2026 15:12:52 +0200 Subject: [PATCH 08/22] ref(flutter): Use SDK JSON object reader Use the Java SDK JSON reader for context payload parsing instead of a custom recursive org.json conversion helper. Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- .../io/sentry/flutter/SentryFlutterPlugin.kt | 38 ++----------------- 1 file changed, 4 insertions(+), 34 deletions(-) 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 c0485a7f6f..7366f5bf86 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 @@ -34,7 +34,6 @@ import io.sentry.protocol.User import io.sentry.transport.CurrentDateProvider import org.json.JSONArray import org.json.JSONObject -import org.json.JSONTokener import java.io.ByteArrayInputStream import java.io.InputStreamReader import java.lang.ref.WeakReference @@ -458,45 +457,16 @@ class SentryFlutterPlugin : "debug_file" to debugFile, ) - private fun parseJsonBytes(bytes: ByteArray): Any? { - val json = String(bytes, Charsets.UTF_8) - return toKotlinJsonValue(JSONTokener(json).nextValue()) - } + private fun parseJsonBytes(bytes: ByteArray): Any? = + jsonObjectReader(bytes).use { reader -> + reader.nextObjectOrNull() + } private fun jsonObjectReader(bytes: ByteArray): JsonObjectReader = JsonObjectReader( InputStreamReader(ByteArrayInputStream(bytes), Charsets.UTF_8), ) - private fun toKotlinJsonValue(value: Any?): Any? = - when (value) { - null, JSONObject.NULL -> { - null - } - - is JSONObject -> { - val map = mutableMapOf() - val keys = value.keys() - while (keys.hasNext()) { - val key = keys.next() - map[key] = toKotlinJsonValue(value.opt(key)) - } - map - } - - is JSONArray -> { - val list = mutableListOf() - for (i in 0 until value.length()) { - list.add(toKotlinJsonValue(value.opt(i))) - } - list - } - - else -> { - value - } - } - private fun Double.adjustReplaySizeToBlockSize(): Double { val remainder = this % VIDEO_BLOCK_SIZE return if (remainder <= VIDEO_BLOCK_SIZE / 2) { From f9a84b1ae5866de0a4d51944cef6c16400937c8e Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 19 May 2026 15:35:08 +0200 Subject: [PATCH 09/22] docs(flutter): Clarify JSON reader parsing Document that the Android JSON reader accepts root-level primitives so future changes do not replace it with object-only parsing. Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- .../src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt | 2 ++ 1 file changed, 2 insertions(+) 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 7366f5bf86..3223666510 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 @@ -459,6 +459,8 @@ class SentryFlutterPlugin : private fun parseJsonBytes(bytes: ByteArray): Any? = jsonObjectReader(bytes).use { reader -> + // Despite the name, sentry-java's JsonObjectReader accepts + // primitives here as well as objects and arrays. reader.nextObjectOrNull() } From 0c34206778d1ed7167e6ab016237631dd06d527c Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 19 May 2026 15:55:57 +0200 Subject: [PATCH 10/22] fix(flutter): Wrap Android primitive contexts Route Android context values through sentry-java's typed overloads so primitive Dart context values are serialized as valid context objects. Regenerate JNI bindings after removing the unused object overload entrypoint. Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- .../io/sentry/flutter/SentryFlutterPlugin.kt | 33 +++++---- .../flutter/lib/src/native/java/binding.dart | 68 ------------------- 2 files changed, 21 insertions(+), 80 deletions(-) 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 3223666510..57663860b9 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,6 +15,7 @@ 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.IScope import io.sentry.JsonObjectReader import io.sentry.ScopesAdapter import io.sentry.Sentry @@ -172,17 +173,6 @@ class SentryFlutterPlugin : } } - @Suppress("unused") // Used by native/jni bindings - @JvmStatic - fun setContext( - key: String, - value: Any?, - ) { - Sentry.configureScope { scope -> - scope.setContexts(key, value) - } - } - @Suppress("unused", "TooGenericExceptionCaught") // Used by native/jni bindings @JvmStatic fun addBreadcrumbFromJsonBytes(bytes: ByteArray) { @@ -227,7 +217,7 @@ class SentryFlutterPlugin : try { val value = parseJsonBytes(bytes) Sentry.configureScope { scope -> - scope.setContexts(key, value) + setContextValue(scope, key, value) } } catch (e: Exception) { Log.e("Sentry", "Failed to set context from JSON bytes", e) @@ -457,6 +447,25 @@ class SentryFlutterPlugin : "debug_file" to debugFile, ) + private fun setContextValue( + scope: IScope, + key: String, + value: Any?, + ) { + // Force sentry-java's typed overloads so primitive contexts are wrapped + // as {"value": ...} instead of being stored as invalid raw values. + when (value) { + null -> scope.setContexts(key, null as Any?) + is Boolean -> scope.setContexts(key, value) + is String -> scope.setContexts(key, value) + is Number -> scope.setContexts(key, value) + is Collection<*> -> scope.setContexts(key, value) + is Array<*> -> scope.setContexts(key, value) + is Char -> scope.setContexts(key, value) + else -> scope.setContexts(key, value) + } + } + private fun parseJsonBytes(bytes: ByteArray): Any? = jsonObjectReader(bytes).use { reader -> // Despite the name, sentry-java's JsonObjectReader accepts diff --git a/packages/flutter/lib/src/native/java/binding.dart b/packages/flutter/lib/src/native/java/binding.dart index 989a1c4adc..49cb7ae2f2 100644 --- a/packages/flutter/lib/src/native/java/binding.dart +++ b/packages/flutter/lib/src/native/java/binding.dart @@ -4627,40 +4627,6 @@ class SentryFlutterPlugin$Companion extends jni$_.JObject { .check(); } - static final _id_setContext = _class.instanceMethodId( - r'setContext', - r'(Ljava/lang/String;Ljava/lang/Object;)V', - ); - - static final _setContext = jni$_.ProtectedJniExtensions.lookup< - jni$_.NativeFunction< - jni$_.JThrowablePtr Function( - jni$_.Pointer, - jni$_.JMethodIDPtr, - jni$_.VarArgs< - ( - jni$_.Pointer, - jni$_.Pointer - )>)>>('globalEnv_CallVoidMethod') - .asFunction< - jni$_.JThrowablePtr Function( - jni$_.Pointer, - jni$_.JMethodIDPtr, - jni$_.Pointer, - jni$_.Pointer)>(); - - /// from: `public final void setContext(java.lang.String string, java.lang.Object object)` - void setContext( - jni$_.JString string, - jni$_.JObject? object, - ) { - final _$string = string.reference; - final _$object = object?.reference ?? jni$_.jNullReference; - _setContext(reference.pointer, _id_setContext as jni$_.JMethodIDPtr, - _$string.pointer, _$object.pointer) - .check(); - } - static final _id_addBreadcrumbFromJsonBytes = _class.instanceMethodId( r'addBreadcrumbFromJsonBytes', r'([B)V', @@ -5373,40 +5339,6 @@ class SentryFlutterPlugin extends jni$_.JObject { .check(); } - static final _id_setContext = _class.staticMethodId( - r'setContext', - r'(Ljava/lang/String;Ljava/lang/Object;)V', - ); - - static final _setContext = jni$_.ProtectedJniExtensions.lookup< - jni$_.NativeFunction< - jni$_.JThrowablePtr Function( - jni$_.Pointer, - jni$_.JMethodIDPtr, - jni$_.VarArgs< - ( - jni$_.Pointer, - jni$_.Pointer - )>)>>('globalEnv_CallStaticVoidMethod') - .asFunction< - jni$_.JThrowablePtr Function( - jni$_.Pointer, - jni$_.JMethodIDPtr, - jni$_.Pointer, - jni$_.Pointer)>(); - - /// from: `static public final void setContext(java.lang.String string, java.lang.Object object)` - static void setContext( - jni$_.JString string, - jni$_.JObject? object, - ) { - final _$string = string.reference; - final _$object = object?.reference ?? jni$_.jNullReference; - _setContext(_class.reference.pointer, _id_setContext as jni$_.JMethodIDPtr, - _$string.pointer, _$object.pointer) - .check(); - } - static final _id_addBreadcrumbFromJsonBytes = _class.staticMethodId( r'addBreadcrumbFromJsonBytes', r'([B)V', From f4d89910c20be94a5fc28032815cdae7cb0ff1ac Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 19 May 2026 16:13:18 +0200 Subject: [PATCH 11/22] fix(flutter): Release Android JNI refs Release replay callback JNI handles after processing replay privacy options, and avoid creating duplicate JNI strings when loading debug images. This reduces leaked global refs in Android replay and debug image paths. Fixes #3696 Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- .../src/native/java/sentry_native_java.dart | 10 ++-- .../native/java/sentry_native_java_init.dart | 51 ++++++++++--------- 2 files changed, 33 insertions(+), 28 deletions(-) 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 4b3281e8f2..abff1a5368 100644 --- a/packages/flutter/lib/src/native/java/sentry_native_java.dart +++ b/packages/flutter/lib/src/native/java/sentry_native_java.dart @@ -68,11 +68,11 @@ class SentryNativeJava extends SentryNativeChannel { JByteArray? imagesUtf8JsonBytes; try { - instructionAddressJStrings = stackTrace.frames - .map((f) => f.instructionAddr) - .nonNulls - .map((s) => s.toJString()) - .toSet(); + final instructionAddresses = + stackTrace.frames.map((f) => f.instructionAddr).nonNulls.toSet(); + + instructionAddressJStrings = + instructionAddresses.map((s) => s.toJString()).toSet(); instructionAddressSet = instructionAddressJStrings.nonNulls .cast() 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 04a4904e0b..5ca2a92eed 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 @@ -61,31 +61,36 @@ native.SentryOptions$BeforeSendReplayCallback createBeforeSendReplayCallback( return native.SentryOptions$BeforeSendReplayCallback.implement( native.$SentryOptions$BeforeSendReplayCallback( execute: (sentryReplayEvent, hint) { - using((arena) { - final data = hint - .getReplayRecording() - ?.getPayload() - ?.use((payload) => payload.firstOrNull) - ?..releasedBy(arena); - if (data is native.$RRWebOptionsEvent$Type) { - final payload = data - ?.as(native.RRWebOptionsEvent.type) - .getOptionsPayload() + try { + using((arena) { + final replayRecording = hint.getReplayRecording() ?..releasedBy(arena); - payload?.removeWhere((key, value) { - final shouldRemove = - key?.toDartString(releaseOriginal: true).contains('mask') ?? - false; - value?.release(); // release the materialized value handle - return shouldRemove; - }); + final data = replayRecording + ?.getPayload() + ?.use((payload) => payload.firstOrNull) + ?..releasedBy(arena); + if (data is native.$RRWebOptionsEvent$Type) { + final payload = data + ?.as(native.RRWebOptionsEvent.type) + .getOptionsPayload() + ?..releasedBy(arena); + payload?.removeWhere((key, value) { + final shouldRemove = + key?.toDartString(releaseOriginal: true).contains('mask') ?? + false; + value?.release(); // release the materialized value handle + return shouldRemove; + }); - final jMap = dartToJMap(options.privacy.toJson()); - payload?.addAll(jMap); - jMap.release(); - } - }); - return sentryReplayEvent; + final jMap = dartToJMap(options.privacy.toJson()); + payload?.addAll(jMap); + jMap.release(); + } + }); + return sentryReplayEvent; + } finally { + hint.release(); + } }, ), ); From e2bd84b18c4b092c71e76150a46c49e5e5a12b97 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 19 May 2026 16:35:16 +0200 Subject: [PATCH 12/22] test(flutter): Expect wrapped Android contexts Update the native context sync test to match the Android bridge's valid serialized shape for primitive context values. Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- .../example/integration_test/integration_test.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/flutter/example/integration_test/integration_test.dart b/packages/flutter/example/integration_test/integration_test.dart index 2ada7fc875..9ad2224d55 100644 --- a/packages/flutter/example/integration_test/integration_test.dart +++ b/packages/flutter/example/integration_test/integration_test.dart @@ -1012,13 +1012,13 @@ void main() { expect(values['key4'], {'value': 12}, reason: 'key4 mismatch'); expect(values['key5'], {'value': 12.3}, reason: 'key5 mismatch'); } else if (Platform.isAndroid) { - expect(values['key1'], 'randomValue', reason: 'key1 mismatch'); + expect(values['key1'], {'value': 'randomValue'}, reason: 'key1 mismatch'); expect(values['key2'], {'String': 'Value', 'Bool': true, 'Int': 123, 'Double': 12.3}, reason: 'key2 mismatch'); - expect(values['key3'], true, reason: 'key3 mismatch'); - expect(values['key4'], 12, reason: 'key4 mismatch'); - expect(values['key5'], 12.3, reason: 'key5 mismatch'); + expect(values['key3'], {'value': true}, reason: 'key3 mismatch'); + expect(values['key4'], {'value': 12}, reason: 'key4 mismatch'); + expect(values['key5'], {'value': 12.3}, reason: 'key5 mismatch'); } await Sentry.configureScope((scope) async { From 68412d3058f1498eddd144e208ffea9edc3f6327 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 19 May 2026 18:25:46 +0200 Subject: [PATCH 13/22] fix(flutter): Release replay JNI refs safely Use a single arena for replay callback JNI temporaries and avoid releasing map keys before removing privacy options from the payload. Refs #3696 Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- .../native/java/sentry_native_java_init.dart | 44 +++++++++++-------- 1 file changed, 25 insertions(+), 19 deletions(-) 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 5ca2a92eed..25d028b63a 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 @@ -65,26 +65,32 @@ native.SentryOptions$BeforeSendReplayCallback createBeforeSendReplayCallback( using((arena) { final replayRecording = hint.getReplayRecording() ?..releasedBy(arena); - final data = replayRecording - ?.getPayload() - ?.use((payload) => payload.firstOrNull) - ?..releasedBy(arena); - if (data is native.$RRWebOptionsEvent$Type) { - final payload = data - ?.as(native.RRWebOptionsEvent.type) - .getOptionsPayload() - ?..releasedBy(arena); - payload?.removeWhere((key, value) { - final shouldRemove = - key?.toDartString(releaseOriginal: true).contains('mask') ?? - false; - value?.release(); // release the materialized value handle - return shouldRemove; - }); + final data = replayRecording?.getPayload()?.use( + (payload) => payload.firstOrNull, + )?..releasedBy(arena); + if (data?.isA(native.RRWebOptionsEvent.type) ?? false) { + final optionsEvent = data!.as(native.RRWebOptionsEvent.type) + ..releasedBy(arena); + final payload = optionsEvent.getOptionsPayload() + ..releasedBy(arena); + + final keys = payload.keys..releasedBy(arena); + final iterator = keys.iterator..releasedBy(arena); + final keysToRemove = []; + while (iterator.moveNext()) { + final key = iterator.current?..releasedBy(arena); + if (key?.toDartString().contains('mask') ?? false) { + keysToRemove.add(key!); + } + } + + for (final key in keysToRemove) { + payload.remove(key)?.releasedBy(arena); + } - final jMap = dartToJMap(options.privacy.toJson()); - payload?.addAll(jMap); - jMap.release(); + final jMap = dartToJMap(options.privacy.toJson()) + ..releasedBy(arena); + payload.addAll(jMap); } }); return sentryReplayEvent; From bdc2b8f40aa4abce81be47fd78db4b1650c319ae Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 19 May 2026 21:05:25 +0200 Subject: [PATCH 14/22] ref(flutter): Move Android JNI work to core worker Route Android native scope and context work through the core worker so JNI calls can run off the main isolate when the worker is available. Keep current-isolate fallbacks for calls made before startup. Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- .../src/native/java/android_core_worker.dart | 462 ++++++++++++++++++ .../native/java/android_envelope_sender.dart | 120 ----- .../src/native/java/sentry_native_java.dart | 138 +----- ...est.dart => android_core_worker_test.dart} | 4 +- .../native/android_core_worker_test_real.dart | 286 +++++++++++ ...dart => android_core_worker_test_web.dart} | 2 +- .../android_envelope_sender_test_real.dart | 178 ------- .../native/sentry_native_java_test_real.dart | 47 +- 8 files changed, 806 insertions(+), 431 deletions(-) create mode 100644 packages/flutter/lib/src/native/java/android_core_worker.dart delete mode 100644 packages/flutter/lib/src/native/java/android_envelope_sender.dart rename packages/flutter/test/native/{android_envelope_sender_test.dart => android_core_worker_test.dart} (51%) create mode 100644 packages/flutter/test/native/android_core_worker_test_real.dart rename packages/flutter/test/native/{android_envelope_sender_test_web.dart => android_core_worker_test_web.dart} (63%) delete mode 100644 packages/flutter/test/native/android_envelope_sender_test_real.dart diff --git a/packages/flutter/lib/src/native/java/android_core_worker.dart b/packages/flutter/lib/src/native/java/android_core_worker.dart new file mode 100644 index 0000000000..dc7a43fb35 --- /dev/null +++ b/packages/flutter/lib/src/native/java/android_core_worker.dart @@ -0,0 +1,462 @@ +import 'dart:async'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:jni/jni.dart'; +import 'package:meta/meta.dart'; + +import '../../../sentry_flutter.dart'; +import '../../isolate/isolate_worker.dart'; +import '../utils/data_normalizer.dart'; +import '../utils/utf8_json.dart'; +import '../../utils/internal_logger.dart'; +import 'binding.dart' as native; + +/// Runs Android JNI work on a background isolate when available. +class AndroidCoreWorker { + final WorkerConfig _config; + final SpawnWorkerFn _spawn; + + bool _isClosed = false; + Future? _startFuture; + Worker? _worker; + + AndroidCoreWorker(SentryOptions options, {SpawnWorkerFn? spawn}) + : _config = WorkerConfig( + debugName: 'SentryAndroidCoreWorker', + debug: options.debug, + diagnosticLevel: options.diagnosticLevel, + // ignore: invalid_use_of_internal_member + automatedTestMode: options.automatedTestMode, + ), + _spawn = spawn ?? spawnWorker; + + @internal + static AndroidCoreWorker Function(SentryFlutterOptions) factory = + AndroidCoreWorker.new; + + FutureOr start() { + if (_isClosed) return null; + if (_worker != null) return null; + if (_startFuture != null) return _startFuture; + _startFuture = _start(); + return _startFuture; + } + + Future _start() async { + try { + final worker = await _spawn(_config, _entryPoint); + // Guard against close() being called during spawn. + if (_isClosed) { + worker.close(); + return; + } + _worker = worker; + } finally { + _startFuture = null; + } + } + + FutureOr close() { + _worker?.close(); + _worker = null; + _isClosed = true; + } + + void captureEnvelope( + Uint8List envelopeData, bool containsUnhandledException) { + if (_isClosed) return; + + final client = _worker; + if (client == null) { + internalLogger.info( + 'captureEnvelope called before core worker started: sending envelope in main isolate instead', + ); + _captureEnvelope(envelopeData, containsUnhandledException, + automatedTestMode: _config.automatedTestMode); + return; + } + + _captureEnvelopeFromWorker( + client, envelopeData, containsUnhandledException); + } + + void _captureEnvelopeFromWorker( + Worker client, + Uint8List envelopeData, + bool containsUnhandledException, + ) { + client.send(_CaptureEnvelopeRequest( + TransferableTypedData.fromList([envelopeData]), + containsUnhandledException, + )); + } + + FutureOr?> loadDebugImages(SentryStackTrace stackTrace) { + final instructionAddresses = + stackTrace.frames.map((f) => f.instructionAddr).nonNulls.toList( + growable: false, + ); + + final client = _worker; + if (client == null) { + return _loadDebugImages(instructionAddresses, + automatedTestMode: _config.automatedTestMode); + } + + return _loadDebugImagesFromWorker(client, instructionAddresses); + } + + Future?> _loadDebugImagesFromWorker( + Worker client, + List instructionAddresses, + ) async { + final response = + await client.request(_LoadDebugImagesRequest(instructionAddresses)); + final maps = (response as List?) + ?.whereType>() + .map((e) => Map.from(e)) + .toList(growable: false); + return maps?.map(DebugImage.fromJson).toList(growable: false); + } + + FutureOr?> loadContexts() { + final client = _worker; + if (client == null) { + return _loadContexts(automatedTestMode: _config.automatedTestMode); + } + + return _loadContextsFromWorker(client); + } + + Future?> _loadContextsFromWorker(Worker client) async { + final response = await client.request(const _LoadContextsRequest()); + return response == null ? null : Map.from(response as Map); + } + + void addBreadcrumb(Breadcrumb breadcrumb) { + if (_isClosed) return; + + final client = _worker; + if (client == null) { + _addBreadcrumb(breadcrumb.toJson(), + automatedTestMode: _config.automatedTestMode); + return; + } + + _addBreadcrumbFromWorker(client, breadcrumb); + } + + void _addBreadcrumbFromWorker( + Worker client, + Breadcrumb breadcrumb, + ) { + client.send(_AddBreadcrumbRequest(breadcrumb.toJson())); + } + + void setUser(SentryUser? user) { + if (_isClosed) return; + + final client = _worker; + if (client == null) { + _setUser(user?.toJson(), automatedTestMode: _config.automatedTestMode); + return; + } + + _setUserFromWorker(client, user); + } + + void _setUserFromWorker( + Worker client, + SentryUser? user, + ) { + client.send(_SetUserRequest(user?.toJson())); + } + + void setContexts(String key, dynamic value) { + if (_isClosed) return; + + final normalizedValue = normalize(value); + final client = _worker; + if (client == null) { + _setContexts(key, normalizedValue, + automatedTestMode: _config.automatedTestMode); + return; + } + + _setContextsFromWorker(client, key, normalizedValue); + } + + void _setContextsFromWorker( + Worker client, + String key, + Object? value, + ) { + client.send(_SetContextsRequest(key, value)); + } + + static void _entryPoint((SendPort, WorkerConfig) init) { + final (host, config) = init; + runWorker(config, host, _AndroidCoreWorkerHandler(config)); + } +} + +class _AndroidCoreWorkerHandler extends WorkerHandler { + final WorkerConfig _config; + Future _queue = Future.value(); + + _AndroidCoreWorkerHandler(this._config); + + @override + FutureOr onMessage(Object? msg) => _enqueue(() { + switch (msg) { + case _CaptureEnvelopeRequest request: + final data = request.envelopeData.materialize().asUint8List(); + _captureEnvelope(data, request.containsUnhandledException, + automatedTestMode: _config.automatedTestMode); + case _AddBreadcrumbRequest request: + _addBreadcrumb(request.breadcrumb, + automatedTestMode: _config.automatedTestMode); + case _SetUserRequest request: + _setUser(request.user, + automatedTestMode: _config.automatedTestMode); + case _SetContextsRequest request: + _setContexts(request.key, request.value, + automatedTestMode: _config.automatedTestMode); + default: + _unexpectedMessage(msg); + } + }); + + @override + FutureOr onRequest(Object? payload) => _enqueue(() { + return switch (payload) { + _LoadDebugImagesRequest request => _loadDebugImageMaps( + request.instructionAddresses, + automatedTestMode: _config.automatedTestMode, + ), + _LoadContextsRequest _ => + _loadContexts(automatedTestMode: _config.automatedTestMode), + _ => _unexpectedPayload(payload), + }; + }); + + /// Serializes JNI work inside the worker isolate. + Future _enqueue(FutureOr Function() action) { + final next = _queue.then((_) => action()); + _queue = next.then((_) {}, onError: (_) {}); + return next; + } + + Object? _unexpectedPayload(Object? payload) { + internalLogger + .warning('${_config.debugName}: unexpected payload type: $payload'); + return null; + } + + void _unexpectedMessage(Object? msg) { + internalLogger + .warning('${_config.debugName}: unexpected message type: $msg'); + } +} + +class _CaptureEnvelopeRequest { + final TransferableTypedData envelopeData; + final bool containsUnhandledException; + + const _CaptureEnvelopeRequest( + this.envelopeData, this.containsUnhandledException); +} + +class _LoadDebugImagesRequest { + final List instructionAddresses; + + const _LoadDebugImagesRequest(this.instructionAddresses); +} + +class _LoadContextsRequest { + const _LoadContextsRequest(); +} + +class _AddBreadcrumbRequest { + final Map breadcrumb; + + const _AddBreadcrumbRequest(this.breadcrumb); +} + +class _SetUserRequest { + final Map? user; + + const _SetUserRequest(this.user); +} + +class _SetContextsRequest { + final String key; + final Object? value; + + const _SetContextsRequest(this.key, this.value); +} + +void _captureEnvelope(Uint8List envelopeData, bool containsUnhandledException, + {bool automatedTestMode = false}) { + JObject? id; + JByteArray? byteArray; + try { + byteArray = JByteArray.from(envelopeData); + id = native.InternalSentrySdk.captureEnvelope( + byteArray, containsUnhandledException); + + if (id == null) { + internalLogger + .error('Native Android SDK returned null when capturing envelope'); + } + } catch (exception, stackTrace) { + internalLogger.error('Failed to capture envelope', + error: exception, stackTrace: stackTrace); + if (automatedTestMode) { + rethrow; + } + } finally { + byteArray?.release(); + id?.release(); + } +} + +List? _loadDebugImages(List instructionAddresses, + {bool automatedTestMode = false}) { + final debugImageMaps = _loadDebugImageMaps(instructionAddresses, + automatedTestMode: automatedTestMode); + return debugImageMaps?.map(DebugImage.fromJson).toList(growable: false); +} + +List>? _loadDebugImageMaps( + List instructionAddresses, + {bool automatedTestMode = false}) { + JSet? instructionAddressSet; + Set? instructionAddressJStrings; + JByteArray? imagesUtf8JsonBytes; + + try { + instructionAddressJStrings = + instructionAddresses.map((s) => s.toJString()).toSet(); + + instructionAddressSet = instructionAddressJStrings.nonNulls + .cast() + .toJSet(JString.type); + + imagesUtf8JsonBytes = native.SentryFlutterPlugin.loadDebugImagesAsBytes( + instructionAddressSet); + if (imagesUtf8JsonBytes == null) return null; + + final byteRange = + imagesUtf8JsonBytes.getRange(0, imagesUtf8JsonBytes.length); + final bytes = Uint8List.view( + byteRange.buffer, byteRange.offsetInBytes, byteRange.length); + return decodeUtf8JsonListOfMaps(bytes); + } catch (exception, stackTrace) { + internalLogger.error( + 'JNI: Failed to load debug images', + error: exception, + stackTrace: stackTrace, + ); + if (automatedTestMode) { + rethrow; + } + } finally { + for (final js in instructionAddressJStrings ?? const []) { + js.release(); + } + instructionAddressSet?.release(); + imagesUtf8JsonBytes?.release(); + } + + return null; +} + +Map? _loadContexts({bool automatedTestMode = false}) { + JByteArray? contextsUtf8JsonBytes; + + try { + contextsUtf8JsonBytes = native.SentryFlutterPlugin.loadContextsAsBytes(); + if (contextsUtf8JsonBytes == null) return null; + + final byteRange = + contextsUtf8JsonBytes.getRange(0, contextsUtf8JsonBytes.length); + final bytes = Uint8List.view( + byteRange.buffer, byteRange.offsetInBytes, byteRange.length); + return decodeUtf8JsonMap(bytes); + } catch (exception, stackTrace) { + internalLogger.error( + 'JNI: Failed to load contexts', + error: exception, + stackTrace: stackTrace, + ); + if (automatedTestMode) { + rethrow; + } + } finally { + contextsUtf8JsonBytes?.release(); + } + + return null; +} + +void _addBreadcrumb(Map breadcrumb, + {bool automatedTestMode = false}) { + JByteArray? jBytes; + try { + jBytes = _jsonToJByteArray(breadcrumb); + native.SentryFlutterPlugin.addBreadcrumbFromJsonBytes(jBytes); + } catch (exception, stackTrace) { + internalLogger.error('JNI: Failed to add breadcrumb', + error: exception, stackTrace: stackTrace); + if (automatedTestMode) { + rethrow; + } + } finally { + jBytes?.release(); + } +} + +void _setUser(Map? user, {bool automatedTestMode = false}) { + JByteArray? jBytes; + try { + if (user == null) { + native.SentryFlutterPlugin.setUserFromJsonBytes(null); + } else { + jBytes = _jsonToJByteArray(user); + native.SentryFlutterPlugin.setUserFromJsonBytes(jBytes); + } + } catch (exception, stackTrace) { + internalLogger.error('JNI: Failed to set user', + error: exception, stackTrace: stackTrace); + if (automatedTestMode) { + rethrow; + } + } finally { + jBytes?.release(); + } +} + +void _setContexts(String key, Object? value, {bool automatedTestMode = false}) { + JString? jKey; + JByteArray? jBytes; + try { + jKey = key.toJString(); + jBytes = _jsonToJByteArray(value); + + native.SentryFlutterPlugin.setContextFromJsonBytes(jKey, jBytes); + } catch (exception, stackTrace) { + internalLogger.error('JNI: Failed to set context', + error: exception, stackTrace: stackTrace); + if (automatedTestMode) { + rethrow; + } + } finally { + jKey?.release(); + jBytes?.release(); + } +} + +JByteArray _jsonToJByteArray(Object? value) => + JByteArray.from(encodeUtf8Json(normalize(value))); diff --git a/packages/flutter/lib/src/native/java/android_envelope_sender.dart b/packages/flutter/lib/src/native/java/android_envelope_sender.dart deleted file mode 100644 index c5bf9669a6..0000000000 --- a/packages/flutter/lib/src/native/java/android_envelope_sender.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'dart:async'; -import 'dart:isolate'; -import 'dart:typed_data'; - -import 'package:jni/jni.dart'; -import 'package:meta/meta.dart'; - -import '../../../sentry_flutter.dart'; -import '../../isolate/isolate_worker.dart'; -import '../../utils/internal_logger.dart'; -import 'binding.dart' as native; - -class AndroidEnvelopeSender { - final WorkerConfig _config; - final SpawnWorkerFn _spawn; - - bool _isClosed = false; - Worker? _worker; - - AndroidEnvelopeSender(SentryOptions options, {SpawnWorkerFn? spawn}) - : _config = WorkerConfig( - debugName: 'SentryAndroidEnvelopeSender', - debug: options.debug, - diagnosticLevel: options.diagnosticLevel, - // ignore: invalid_use_of_internal_member - automatedTestMode: options.automatedTestMode, - ), - _spawn = spawn ?? spawnWorker; - - @internal - static AndroidEnvelopeSender Function(SentryFlutterOptions) factory = - AndroidEnvelopeSender.new; - - FutureOr start() async { - if (_isClosed) return; - if (_worker != null) return; - final worker = await _spawn(_config, _entryPoint); - // Guard against close() being called during spawn. - if (_isClosed) { - worker.close(); - return; - } - _worker = worker; - } - - FutureOr close() { - _worker?.close(); - _worker = null; - _isClosed = true; - } - - /// Fire-and-forget send of envelope bytes to the worker. - void captureEnvelope( - Uint8List envelopeData, bool containsUnhandledException) { - if (_isClosed) return; - - final client = _worker; - if (client != null) { - client.send(( - TransferableTypedData.fromList([envelopeData]), - containsUnhandledException - )); - } else { - internalLogger.info( - 'captureEnvelope called before worker started: sending envelope in main isolate instead', - ); - _captureEnvelope(envelopeData, containsUnhandledException, - automatedTestMode: _config.automatedTestMode); - } - } - - static void _entryPoint((SendPort, WorkerConfig) init) { - final (host, config) = init; - runWorker(config, host, _AndroidEnvelopeHandler(config)); - } -} - -class _AndroidEnvelopeHandler extends WorkerHandler { - final WorkerConfig _config; - - _AndroidEnvelopeHandler(this._config); - - @override - FutureOr onMessage(Object? msg) { - if (msg is (TransferableTypedData, bool)) { - final (transferable, containsUnhandledException) = msg; - final data = transferable.materialize().asUint8List(); - _captureEnvelope(data, containsUnhandledException, - automatedTestMode: _config.automatedTestMode); - } else { - internalLogger - .warning('${_config.debugName}: unexpected message type: $msg'); - } - } -} - -void _captureEnvelope(Uint8List envelopeData, bool containsUnhandledException, - {bool automatedTestMode = false}) { - JObject? id; - JByteArray? byteArray; - try { - byteArray = JByteArray.from(envelopeData); - id = native.InternalSentrySdk.captureEnvelope( - byteArray, containsUnhandledException); - - if (id == null) { - internalLogger - .error('Native Android SDK returned null when capturing envelope'); - } - } catch (exception, stackTrace) { - internalLogger.error('Failed to capture envelope', - error: exception, stackTrace: stackTrace); - if (automatedTestMode) { - rethrow; - } - } finally { - byteArray?.release(); - id?.release(); - } -} 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 abff1a5368..4523e1d18c 100644 --- a/packages/flutter/lib/src/native/java/sentry_native_java.dart +++ b/packages/flutter/lib/src/native/java/sentry_native_java.dart @@ -16,7 +16,7 @@ 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_core_worker.dart'; import 'android_replay_recorder.dart'; import 'binding.dart' as native; @@ -25,14 +25,14 @@ part 'sentry_native_java_init.dart'; @internal class SentryNativeJava extends SentryNativeChannel { AndroidReplayRecorder? _replayRecorder; - AndroidEnvelopeSender? _envelopeSender; + AndroidCoreWorker? _coreWorker; native.ReplayIntegration? _nativeReplay; SentryNativeJava(super.options) { - // Initialize envelope sender here in the ctor instead of init(). + // Initialize core worker here in the ctor instead of init(). // Ensures it starts when autoInitializeNativeSdk is enabled and disabled. - _envelopeSender = AndroidEnvelopeSender.factory(options); - _envelopeSender?.start(); + _coreWorker = AndroidCoreWorker.factory(options); + _coreWorker?.start(); } @override @@ -58,98 +58,15 @@ class SentryNativeJava extends SentryNativeChannel { @override FutureOr captureEnvelope( Uint8List envelopeData, bool containsUnhandledException) { - _envelopeSender?.captureEnvelope(envelopeData, containsUnhandledException); + _coreWorker?.captureEnvelope(envelopeData, containsUnhandledException); } @override - FutureOr?> loadDebugImages(SentryStackTrace stackTrace) { - JSet? instructionAddressSet; - Set? instructionAddressJStrings; - JByteArray? imagesUtf8JsonBytes; - - try { - final instructionAddresses = - stackTrace.frames.map((f) => f.instructionAddr).nonNulls.toSet(); - - instructionAddressJStrings = - instructionAddresses.map((s) => s.toJString()).toSet(); - - instructionAddressSet = instructionAddressJStrings.nonNulls - .cast() - .toJSet(JString.type); - - // Use a single JNI call to get images as UTF-8 encoded JSON instead of - // making multiple JNI calls to convert each object individually. This approach - // is significantly faster because images can be large. - // Local benchmarks show this method is ~4x faster than the alternative - // approach of converting JNI objects to Dart objects one by one. - - // NOTE: when instructionAddressSet is empty, loadDebugImagesAsBytes will return - // all debug images as fallback. - imagesUtf8JsonBytes = native.SentryFlutterPlugin.loadDebugImagesAsBytes( - instructionAddressSet); - if (imagesUtf8JsonBytes == null) return null; - - final byteRange = - imagesUtf8JsonBytes.getRange(0, imagesUtf8JsonBytes.length); - final bytes = Uint8List.view( - byteRange.buffer, byteRange.offsetInBytes, byteRange.length); - final debugImageMaps = decodeUtf8JsonListOfMaps(bytes); - return debugImageMaps.map(DebugImage.fromJson).toList(growable: false); - } catch (exception, stackTrace) { - internalLogger.error( - 'JNI: Failed to load debug images', - error: exception, - stackTrace: stackTrace, - ); - if (options.automatedTestMode) { - rethrow; - } - } finally { - // Release JNI refs - for (final js in instructionAddressJStrings ?? const []) { - js.release(); - } - instructionAddressSet?.release(); - imagesUtf8JsonBytes?.release(); - } - - return null; - } + FutureOr?> loadDebugImages(SentryStackTrace stackTrace) => + _coreWorker?.loadDebugImages(stackTrace); @override - FutureOr?> loadContexts() { - JByteArray? contextsUtf8JsonBytes; - - try { - // Use a single JNI call to get contexts as UTF-8 encoded JSON instead of - // making multiple JNI calls to convert each object individually. This approach - // is significantly faster because contexts can be large and contain many nested - // objects. Local benchmarks show this method is ~4x faster than the alternative - // approach of converting JNI objects to Dart objects one by one. - contextsUtf8JsonBytes = native.SentryFlutterPlugin.loadContextsAsBytes(); - if (contextsUtf8JsonBytes == null) return null; - - final byteRange = - contextsUtf8JsonBytes.getRange(0, contextsUtf8JsonBytes.length); - final bytes = Uint8List.view( - byteRange.buffer, byteRange.offsetInBytes, byteRange.length); - return decodeUtf8JsonMap(bytes); - } catch (exception, stackTrace) { - internalLogger.error( - 'JNI: Failed to load contexts', - error: exception, - stackTrace: stackTrace, - ); - if (options.automatedTestMode) { - rethrow; - } - } finally { - contextsUtf8JsonBytes?.release(); - } - - return null; - } + FutureOr?> loadContexts() => _coreWorker?.loadContexts(); @override int? displayRefreshRate() => tryCatchSync('displayRefreshRate', () { @@ -198,20 +115,15 @@ class SentryNativeJava extends SentryNativeChannel { @override Future close() async { await _replayRecorder?.stop(); - await _envelopeSender?.close(); + await _coreWorker?.close(); _setNativeReplay(null); return super.close(); } @override - void addBreadcrumb(Breadcrumb breadcrumb) => - tryCatchSync('addBreadcrumb', () { - using((arena) { - final jBytes = jsonToJByteArray(breadcrumb.toJson()) - ..releasedBy(arena); - native.SentryFlutterPlugin.addBreadcrumbFromJsonBytes(jBytes); - }); - }); + FutureOr addBreadcrumb(Breadcrumb breadcrumb) { + _coreWorker?.addBreadcrumb(breadcrumb); + } @override void clearBreadcrumbs() => tryCatchSync('clearBreadcrumbs', () { @@ -219,26 +131,14 @@ class SentryNativeJava extends SentryNativeChannel { }); @override - void setUser(SentryUser? user) => tryCatchSync('setUser', () { - using((arena) { - if (user == null) { - native.SentryFlutterPlugin.setUserFromJsonBytes(null); - } else { - final jBytes = jsonToJByteArray(user.toJson())..releasedBy(arena); - native.SentryFlutterPlugin.setUserFromJsonBytes(jBytes); - } - }); - }); + FutureOr setUser(SentryUser? user) { + _coreWorker?.setUser(user); + } @override - void setContexts(String key, value) => tryCatchSync('setContexts', () { - using((arena) { - final jKey = key.toJString()..releasedBy(arena); - final jBytes = jsonToJByteArray(value)..releasedBy(arena); - - native.SentryFlutterPlugin.setContextFromJsonBytes(jKey, jBytes); - }); - }); + FutureOr setContexts(String key, value) { + _coreWorker?.setContexts(key, value); + } @override void removeContexts(String key) => tryCatchSync('removeContexts', () { diff --git a/packages/flutter/test/native/android_envelope_sender_test.dart b/packages/flutter/test/native/android_core_worker_test.dart similarity index 51% rename from packages/flutter/test/native/android_envelope_sender_test.dart rename to packages/flutter/test/native/android_core_worker_test.dart index f66a7063f2..b8f181adad 100644 --- a/packages/flutter/test/native/android_envelope_sender_test.dart +++ b/packages/flutter/test/native/android_core_worker_test.dart @@ -4,7 +4,7 @@ library; import 'package:flutter_test/flutter_test.dart'; // ignore: unused_import -import 'android_envelope_sender_test_web.dart' - if (dart.library.io) 'android_envelope_sender_test_real.dart' as actual; +import 'android_core_worker_test_web.dart' + if (dart.library.io) 'android_core_worker_test_real.dart' as actual; void main() => actual.main(); diff --git a/packages/flutter/test/native/android_core_worker_test_real.dart b/packages/flutter/test/native/android_core_worker_test_real.dart new file mode 100644 index 0000000000..4a423ebc50 --- /dev/null +++ b/packages/flutter/test/native/android_core_worker_test_real.dart @@ -0,0 +1,286 @@ +@TestOn('vm') +// ignore_for_file: invalid_use_of_internal_member +library; + +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/isolate/isolate_worker.dart'; +import 'package:sentry_flutter/src/native/java/android_core_worker.dart'; + +void main() { + group('AndroidCoreWorker host behavior', () { + test('logs when sending envelopes in main isolate', () { + final options = SentryFlutterOptions(); + final logs = <(SentryLevel, String)>[]; + SentryInternalLogger.configure( + isEnabled: true, + minLevel: SentryLevel.debug, + logOutput: ({ + required String name, + required SentryLevel level, + required String message, + Object? error, + StackTrace? stackTrace, + }) { + logs.add((level, message.toString())); + }, + ); + + final worker = AndroidCoreWorker(options); + worker.captureEnvelope(Uint8List.fromList([1, 2, 3]), false); + + expect( + logs.any((e) => + e.$1 == SentryLevel.info && + e.$2.contains( + 'captureEnvelope called before core worker started: sending envelope in main isolate instead')), + isTrue, + ); + }); + + test('close is a no-op when not started', () { + final options = SentryFlutterOptions(); + final worker = AndroidCoreWorker(options); + expect(() => worker.close(), returnsNormally); + expect(() => worker.close(), returnsNormally); + }); + + test('start is a no-op when already started', () async { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + + var spawnCount = 0; + Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { + spawnCount++; + final inbox = ReceivePort(); + addTearDown(() => inbox.close()); + final replies = ReceivePort(); + return Worker(inbox.sendPort, replies); + } + + final worker = AndroidCoreWorker(options, spawn: fakeSpawn); + + await worker.start(); + await worker.start(); + expect(spawnCount, 1); + + worker.close(); + spawnCount = 0; + + await worker.start(); + expect(spawnCount, 0); + + // Close twice should be safe. + expect(() => worker.close(), returnsNormally); + expect(() => worker.close(), returnsNormally); + }); + + test('sends envelope capture request after start', () async { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + + final inboxes = []; + Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { + final inbox = ReceivePort(); + inboxes.add(inbox); + addTearDown(() => inbox.close()); + final replies = ReceivePort(); + return Worker(inbox.sendPort, replies); + } + + final worker = AndroidCoreWorker(options, spawn: fakeSpawn); + await worker.start(); + + final payload = Uint8List.fromList([4, 5, 6]); + worker.captureEnvelope(payload, true); + + final msg = await inboxes.last.first as dynamic; + expect(msg.containsUnhandledException, true); + final transferable = msg.envelopeData as TransferableTypedData; + final data = transferable.materialize().asUint8List(); + expect(data, [4, 5, 6]); + + worker.close(); + }); + + test('uses expected WorkerConfig', () async { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + + WorkerConfig? seenConfig; + Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { + seenConfig = config; + final inbox = ReceivePort(); + addTearDown(() => inbox.close()); + final replies = ReceivePort(); + return Worker(inbox.sendPort, replies); + } + + final worker = AndroidCoreWorker(options, spawn: fakeSpawn); + await worker.start(); + + expect(seenConfig, isNotNull); + expect(seenConfig!.debugName, 'SentryAndroidCoreWorker'); + expect(seenConfig!.debug, options.debug); + expect(seenConfig!.diagnosticLevel, options.diagnosticLevel); + + worker.close(); + }); + + test('sends envelope capture requests sequentially with flags', () async { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + + final inboxes = []; + Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { + final inbox = ReceivePort(); + inboxes.add(inbox); + addTearDown(() => inbox.close()); + final replies = ReceivePort(); + return Worker(inbox.sendPort, replies); + } + + final worker = AndroidCoreWorker(options, spawn: fakeSpawn); + await worker.start(); + + worker.captureEnvelope(Uint8List.fromList([10]), true); + worker.captureEnvelope(Uint8List.fromList([11]), false); + + final inbox = inboxes.last; + final msgs = await inbox.take(2).toList(); + final msg1 = msgs[0] as dynamic; + final msg2 = msgs[1] as dynamic; + + expect(msg1.containsUnhandledException, true); + expect(msg2.containsUnhandledException, false); + + final t1 = msg1.envelopeData as TransferableTypedData; + final t2 = msg2.envelopeData as TransferableTypedData; + final data1 = t1.materialize().asUint8List(); + final data2 = t2.materialize().asUint8List(); + expect(data1, [10]); + expect(data2, [11]); + + worker.close(); + }); + + test('requests debug images by instruction addresses', () async { + final fixture = _Fixture(); + final worker = fixture.getSut(); + await worker.start(); + + final resultFuture = worker.loadDebugImages(SentryStackTrace(frames: [ + SentryStackFrame(instructionAddr: '0x1'), + SentryStackFrame(), + ])); + + final (id, payload) = await fixture.nextRequest; + expect((payload as dynamic).instructionAddresses, ['0x1']); + fixture.respond(id, [ + {'type': 'elf', 'debug_id': 'debug-id'} + ]); + + final result = await resultFuture; + expect(result, hasLength(1)); + expect(result!.first.type, 'elf'); + expect(result.first.debugId, 'debug-id'); + }); + + test('requests native contexts', () async { + final fixture = _Fixture(); + final worker = fixture.getSut(); + await worker.start(); + + final resultFuture = worker.loadContexts(); + + final (id, payload) = await fixture.nextRequest; + expect(payload.runtimeType.toString(), '_LoadContextsRequest'); + fixture.respond(id, { + 'contexts': {'fixture': true} + }); + + expect(await resultFuture, { + 'contexts': {'fixture': true} + }); + }); + + test('sends breadcrumb update without awaiting response', () async { + final fixture = _Fixture(); + final worker = fixture.getSut(); + await worker.start(); + + worker.addBreadcrumb(Breadcrumb(message: 'crumb')); + + final payload = await fixture.nextMessage; + expect((payload as dynamic).breadcrumb['message'], 'crumb'); + }); + + test('sends user update without awaiting response', () async { + final fixture = _Fixture(); + final worker = fixture.getSut(); + await worker.start(); + + worker.setUser(SentryUser(id: 'fixture-user')); + + final payload = await fixture.nextMessage; + expect((payload as dynamic).user['id'], 'fixture-user'); + }); + + test('sends context update without awaiting response', () async { + final fixture = _Fixture(); + final worker = fixture.getSut(); + await worker.start(); + + worker.setContexts('fixture-key', { + 'nested': {'value': true} + }); + + final payload = await fixture.nextMessage; + expect((payload as dynamic).key, 'fixture-key'); + expect((payload as dynamic).value, { + 'nested': {'value': true} + }); + }); + }); +} + +class _Fixture { + final options = SentryFlutterOptions() + ..debug = true + ..diagnosticLevel = SentryLevel.debug; + + final inboxes = []; + final responsePorts = []; + + AndroidCoreWorker getSut() => AndroidCoreWorker(options, spawn: _fakeSpawn); + + Future<(int, Object?)> get nextRequest async { + final request = await inboxes.last.first as (int, Object?); + return request; + } + + Future get nextMessage => inboxes.last.first; + + void respond(int id, Object? response) { + responsePorts.last.send((id, response)); + } + + Future _fakeSpawn(WorkerConfig config, WorkerEntry entry) async { + final inbox = ReceivePort(); + inboxes.add(inbox); + addTearDown(inbox.close); + + final replies = ReceivePort(); + responsePorts.add(replies.sendPort); + addTearDown(replies.close); + + return Worker(inbox.sendPort, replies); + } +} diff --git a/packages/flutter/test/native/android_envelope_sender_test_web.dart b/packages/flutter/test/native/android_core_worker_test_web.dart similarity index 63% rename from packages/flutter/test/native/android_envelope_sender_test_web.dart rename to packages/flutter/test/native/android_core_worker_test_web.dart index 3a62a53377..fe3ede60c6 100644 --- a/packages/flutter/test/native/android_envelope_sender_test_web.dart +++ b/packages/flutter/test/native/android_core_worker_test_web.dart @@ -1,5 +1,5 @@ void main() { // This test file exists only to satisfy the compiler when running web tests. - // The actual tests in android_envelope_sender_test_real.dart are only + // The actual tests in android_core_worker_test_real.dart are only // executed on VM platforms. } diff --git a/packages/flutter/test/native/android_envelope_sender_test_real.dart b/packages/flutter/test/native/android_envelope_sender_test_real.dart deleted file mode 100644 index d2b12732a9..0000000000 --- a/packages/flutter/test/native/android_envelope_sender_test_real.dart +++ /dev/null @@ -1,178 +0,0 @@ -@TestOn('vm') -// ignore_for_file: invalid_use_of_internal_member -library; - -import 'dart:isolate'; -import 'dart:typed_data'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:sentry_flutter/src/native/java/android_envelope_sender.dart'; -import 'package:sentry_flutter/src/isolate/isolate_worker.dart'; - -void main() { - group('AndroidEnvelopeSender host behavior', () { - test('logs when sending envelopes in main isolate', () { - final options = SentryFlutterOptions(); - final logs = <(SentryLevel, String)>[]; - SentryInternalLogger.configure( - isEnabled: true, - minLevel: SentryLevel.debug, - logOutput: ({ - required String name, - required SentryLevel level, - required String message, - Object? error, - StackTrace? stackTrace, - }) { - logs.add((level, message.toString())); - }, - ); - - final sender = AndroidEnvelopeSender(options); - sender.captureEnvelope(Uint8List.fromList([1, 2, 3]), false); - - expect( - logs.any((e) => - e.$1 == SentryLevel.info && - e.$2.contains( - 'captureEnvelope called before worker started: sending envelope in main isolate instead')), - isTrue, - ); - }); - - test('close is a no-op when not started', () { - final options = SentryFlutterOptions(); - final sender = AndroidEnvelopeSender(options); - expect(() => sender.close(), returnsNormally); - expect(() => sender.close(), returnsNormally); - }); - - test('start is a no-op when already started', () async { - final options = SentryFlutterOptions(); - options.debug = true; - options.diagnosticLevel = SentryLevel.debug; - - var spawnCount = 0; - Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { - spawnCount++; - final inbox = ReceivePort(); - addTearDown(() => inbox.close()); - final replies = ReceivePort(); - return Worker(inbox.sendPort, replies); - } - - final sender = AndroidEnvelopeSender(options, spawn: fakeSpawn); - - await sender.start(); - await sender.start(); - expect(spawnCount, 1); - - sender.close(); - spawnCount = 0; - - await sender.start(); - expect(spawnCount, 0); - - // Close twice should be safe. - expect(() => sender.close(), returnsNormally); - expect(() => sender.close(), returnsNormally); - }); - - test('delivers tuple to worker after start', () async { - final options = SentryFlutterOptions(); - options.debug = true; - options.diagnosticLevel = SentryLevel.debug; - - final inboxes = []; - Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { - final inbox = ReceivePort(); - inboxes.add(inbox); - addTearDown(() => inbox.close()); - final replies = ReceivePort(); - return Worker(inbox.sendPort, replies); - } - - final sender = AndroidEnvelopeSender(options, spawn: fakeSpawn); - await sender.start(); - - final payload = Uint8List.fromList([4, 5, 6]); - sender.captureEnvelope(payload, true); - - final msg = await inboxes.last.first; - expect(msg, isA<(TransferableTypedData, bool)>()); - final (transferable, containsUnhandled) = - msg as (TransferableTypedData, bool); - expect(containsUnhandled, true); - final data = transferable.materialize().asUint8List(); - expect(data, [4, 5, 6]); - - sender.close(); - }); - - test('uses expected WorkerConfig', () async { - final options = SentryFlutterOptions(); - options.debug = true; - options.diagnosticLevel = SentryLevel.debug; - - WorkerConfig? seenConfig; - Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { - seenConfig = config; - final inbox = ReceivePort(); - addTearDown(() => inbox.close()); - final replies = ReceivePort(); - return Worker(inbox.sendPort, replies); - } - - final sender = AndroidEnvelopeSender(options, spawn: fakeSpawn); - await sender.start(); - - expect(seenConfig, isNotNull); - expect(seenConfig!.debugName, 'SentryAndroidEnvelopeSender'); - expect(seenConfig!.debug, options.debug); - expect(seenConfig!.diagnosticLevel, options.diagnosticLevel); - - sender.close(); - }); - - test('sends are delivered sequentially with flags', () async { - final options = SentryFlutterOptions(); - options.debug = true; - options.diagnosticLevel = SentryLevel.debug; - - final inboxes = []; - Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { - final inbox = ReceivePort(); - inboxes.add(inbox); - addTearDown(() => inbox.close()); - final replies = ReceivePort(); - return Worker(inbox.sendPort, replies); - } - - final sender = AndroidEnvelopeSender(options, spawn: fakeSpawn); - await sender.start(); - - sender.captureEnvelope(Uint8List.fromList([10]), true); - sender.captureEnvelope(Uint8List.fromList([11]), false); - - final inbox = inboxes.last; - final msgs = await inbox.take(2).toList(); - final msg1 = msgs[0]; - final msg2 = msgs[1]; - - expect(msg1, isA<(TransferableTypedData, bool)>()); - expect(msg2, isA<(TransferableTypedData, bool)>()); - - final (t1, f1) = msg1 as (TransferableTypedData, bool); - final (t2, f2) = msg2 as (TransferableTypedData, bool); - expect(f1, true); - expect(f2, false); - final data1 = t1.materialize().asUint8List(); - final data2 = t2.materialize().asUint8List(); - expect(data1, [10]); - expect(data2, [11]); - - sender.close(); - }); - }); -} diff --git a/packages/flutter/test/native/sentry_native_java_test_real.dart b/packages/flutter/test/native/sentry_native_java_test_real.dart index d320852b23..5598a80584 100644 --- a/packages/flutter/test/native/sentry_native_java_test_real.dart +++ b/packages/flutter/test/native/sentry_native_java_test_real.dart @@ -7,7 +7,7 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:sentry_flutter/src/native/java/android_envelope_sender.dart'; +import 'package:sentry_flutter/src/native/java/android_core_worker.dart'; import 'package:sentry_flutter/src/native/java/sentry_native_java.dart'; void main() { @@ -52,24 +52,24 @@ void main() { }); }); - group('EnvelopeSender initialization', () { - late AndroidEnvelopeSender Function(SentryFlutterOptions) originalFactory; + group('CoreWorker initialization', () { + late AndroidCoreWorker Function(SentryFlutterOptions) originalFactory; setUp(() { - originalFactory = AndroidEnvelopeSender.factory; + originalFactory = AndroidCoreWorker.factory; }); tearDown(() { - AndroidEnvelopeSender.factory = originalFactory; + AndroidCoreWorker.factory = originalFactory; }); - test('starts envelope sender in constructor', () { + test('starts core worker in constructor', () { var factoryCalled = false; var startCalled = false; - AndroidEnvelopeSender.factory = (options) { + AndroidCoreWorker.factory = (options) { factoryCalled = true; - return _FakeEnvelopeSender(onStart: () => startCalled = true); + return _FakeCoreWorker(onStart: () => startCalled = true); }; final options = @@ -84,11 +84,11 @@ void main() { }); } -/// Fake envelope sender for testing that tracks method calls. -class _FakeEnvelopeSender implements AndroidEnvelopeSender { +/// Fake core worker for testing that tracks method calls. +class _FakeCoreWorker implements AndroidCoreWorker { final void Function()? onStart; - _FakeEnvelopeSender({this.onStart}); + _FakeCoreWorker({this.onStart}); @override FutureOr start() { @@ -105,4 +105,29 @@ class _FakeEnvelopeSender implements AndroidEnvelopeSender { Uint8List envelopeData, bool containsUnhandledException) { // No-op for testing } + + @override + FutureOr?> loadDebugImages(SentryStackTrace stackTrace) { + return null; + } + + @override + FutureOr?> loadContexts() { + return null; + } + + @override + void addBreadcrumb(Breadcrumb breadcrumb) { + // No-op for testing + } + + @override + void setUser(SentryUser? user) { + // No-op for testing + } + + @override + void setContexts(String key, value) { + // No-op for testing + } } From 310effbe6c5633c716c44bc97cbb45cb59c14f95 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 26 May 2026 10:13:44 +0200 Subject: [PATCH 15/22] fix(flutter): Harden Android core worker Handle worker request failures without crashing callers and normalize payloads before sending them across isolates. Track JNI strings as they are allocated so partial failures still release native refs. Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- .../src/native/java/android_core_worker.dart | 82 ++++++++++++++----- .../native/android_core_worker_test_real.dart | 79 +++++++++++++++++- 2 files changed, 139 insertions(+), 22 deletions(-) diff --git a/packages/flutter/lib/src/native/java/android_core_worker.dart b/packages/flutter/lib/src/native/java/android_core_worker.dart index dc7a43fb35..d179877fed 100644 --- a/packages/flutter/lib/src/native/java/android_core_worker.dart +++ b/packages/flutter/lib/src/native/java/android_core_worker.dart @@ -111,13 +111,25 @@ class AndroidCoreWorker { Worker client, List instructionAddresses, ) async { - final response = - await client.request(_LoadDebugImagesRequest(instructionAddresses)); - final maps = (response as List?) - ?.whereType>() - .map((e) => Map.from(e)) - .toList(growable: false); - return maps?.map(DebugImage.fromJson).toList(growable: false); + try { + final response = + await client.request(_LoadDebugImagesRequest(instructionAddresses)); + final maps = (response as List?) + ?.whereType>() + .map((e) => Map.from(e)) + .toList(growable: false); + return maps?.map(DebugImage.fromJson).toList(growable: false); + } catch (exception, stackTrace) { + internalLogger.error( + 'Android core worker failed to load debug images', + error: exception, + stackTrace: stackTrace, + ); + if (_config.automatedTestMode) { + rethrow; + } + return null; + } } FutureOr?> loadContexts() { @@ -130,8 +142,22 @@ class AndroidCoreWorker { } Future?> _loadContextsFromWorker(Worker client) async { - final response = await client.request(const _LoadContextsRequest()); - return response == null ? null : Map.from(response as Map); + try { + final response = await client.request(const _LoadContextsRequest()); + return response == null + ? null + : Map.from(response as Map); + } catch (exception, stackTrace) { + internalLogger.error( + 'Android core worker failed to load contexts', + error: exception, + stackTrace: stackTrace, + ); + if (_config.automatedTestMode) { + rethrow; + } + return null; + } } void addBreadcrumb(Breadcrumb breadcrumb) { @@ -151,7 +177,7 @@ class AndroidCoreWorker { Worker client, Breadcrumb breadcrumb, ) { - client.send(_AddBreadcrumbRequest(breadcrumb.toJson())); + client.send(_AddBreadcrumbRequest(_normalizeJsonMap(breadcrumb.toJson()))); } void setUser(SentryUser? user) { @@ -170,13 +196,15 @@ class AndroidCoreWorker { Worker client, SentryUser? user, ) { - client.send(_SetUserRequest(user?.toJson())); + client.send(_SetUserRequest( + user == null ? null : _normalizeJsonMap(user.toJson()), + )); } void setContexts(String key, dynamic value) { if (_isClosed) return; - final normalizedValue = normalize(value); + final normalizedValue = _normalizeJson(value); final client = _worker; if (client == null) { _setContexts(key, normalizedValue, @@ -333,16 +361,15 @@ List>? _loadDebugImageMaps( List instructionAddresses, {bool automatedTestMode = false}) { JSet? instructionAddressSet; - Set? instructionAddressJStrings; + final instructionAddressJStrings = []; JByteArray? imagesUtf8JsonBytes; try { - instructionAddressJStrings = - instructionAddresses.map((s) => s.toJString()).toSet(); + for (final instructionAddress in instructionAddresses) { + instructionAddressJStrings.add(instructionAddress.toJString()); + } - instructionAddressSet = instructionAddressJStrings.nonNulls - .cast() - .toJSet(JString.type); + instructionAddressSet = instructionAddressJStrings.toJSet(JString.type); imagesUtf8JsonBytes = native.SentryFlutterPlugin.loadDebugImagesAsBytes( instructionAddressSet); @@ -363,7 +390,7 @@ List>? _loadDebugImageMaps( rethrow; } } finally { - for (final js in instructionAddressJStrings ?? const []) { + for (final js in instructionAddressJStrings) { js.release(); } instructionAddressSet?.release(); @@ -459,4 +486,19 @@ void _setContexts(String key, Object? value, {bool automatedTestMode = false}) { } JByteArray _jsonToJByteArray(Object? value) => - JByteArray.from(encodeUtf8Json(normalize(value))); + JByteArray.from(encodeUtf8Json(_normalizeJson(value))); + +Map _normalizeJsonMap(Map value) => + _normalizeJson(value) as Map; + +Object? _normalizeJson(Object? value) { + if (value is Map) { + return value.map( + (key, value) => MapEntry(key.toString(), _normalizeJson(value)), + ); + } + if (value is List) { + return value.map(_normalizeJson).toList(growable: false); + } + return normalize(value); +} diff --git a/packages/flutter/test/native/android_core_worker_test_real.dart b/packages/flutter/test/native/android_core_worker_test_real.dart index 4a423ebc50..a6bf5010d2 100644 --- a/packages/flutter/test/native/android_core_worker_test_real.dart +++ b/packages/flutter/test/native/android_core_worker_test_real.dart @@ -193,6 +193,21 @@ void main() { expect(result.first.debugId, 'debug-id'); }); + test('returns null when debug image request fails', () async { + final fixture = _Fixture(); + final worker = fixture.getSut(); + await worker.start(); + + final resultFuture = worker.loadDebugImages(SentryStackTrace(frames: [ + SentryStackFrame(instructionAddr: '0x1'), + ])); + + final (id, _) = await fixture.nextRequest; + fixture.respondError(id); + + expect(await resultFuture, isNull); + }); + test('requests native contexts', () async { final fixture = _Fixture(); final worker = fixture.getSut(); @@ -211,6 +226,19 @@ void main() { }); }); + test('returns null when native contexts request fails', () async { + final fixture = _Fixture(); + final worker = fixture.getSut(); + await worker.start(); + + final resultFuture = worker.loadContexts(); + + final (id, _) = await fixture.nextRequest; + fixture.respondError(id); + + expect(await resultFuture, isNull); + }); + test('sends breadcrumb update without awaiting response', () async { final fixture = _Fixture(); final worker = fixture.getSut(); @@ -222,6 +250,22 @@ void main() { expect((payload as dynamic).breadcrumb['message'], 'crumb'); }); + test('normalizes breadcrumb update before sending', () async { + final fixture = _Fixture(); + final worker = fixture.getSut(); + await worker.start(); + + worker.addBreadcrumb(Breadcrumb( + message: 'crumb', + data: {'value': _UnserializableValue()}, + )); + + final payload = await fixture.nextMessage; + expect((payload as dynamic).breadcrumb['data'], { + 'value': 'normalized-value', + }); + }); + test('sends user update without awaiting response', () async { final fixture = _Fixture(); final worker = fixture.getSut(); @@ -233,13 +277,35 @@ void main() { expect((payload as dynamic).user['id'], 'fixture-user'); }); + test('normalizes user update before sending', () async { + final fixture = _Fixture(); + final worker = fixture.getSut(); + await worker.start(); + + worker.setUser(SentryUser( + id: 'fixture-user', + data: {'value': _UnserializableValue()}, + // ignore: deprecated_member_use + extras: {'extra': _UnserializableValue()}, + )); + + final payload = await fixture.nextMessage; + final user = (payload as dynamic).user as Map; + expect(user['data'], { + 'value': 'normalized-value', + }); + expect(user['extras'], { + 'extra': 'normalized-value', + }); + }); + test('sends context update without awaiting response', () async { final fixture = _Fixture(); final worker = fixture.getSut(); await worker.start(); - worker.setContexts('fixture-key', { - 'nested': {'value': true} + worker.setContexts('fixture-key', { + 'nested': {'value': true} }); final payload = await fixture.nextMessage; @@ -272,6 +338,10 @@ class _Fixture { responsePorts.last.send((id, response)); } + void respondError(int id) { + respond(id, RemoteError('worker failure', StackTrace.current.toString())); + } + Future _fakeSpawn(WorkerConfig config, WorkerEntry entry) async { final inbox = ReceivePort(); inboxes.add(inbox); @@ -284,3 +354,8 @@ class _Fixture { return Worker(inbox.sendPort, replies); } } + +class _UnserializableValue { + @override + String toString() => 'normalized-value'; +} From f4d9e972f2037d51bf08f018a9746553969f95b5 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 26 May 2026 10:49:27 +0200 Subject: [PATCH 16/22] fix(flutter): Await Android scope worker sync Return worker request futures for Android scope updates so awaited scope observer calls complete after native scope synchronization finishes. Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- .../src/native/java/android_core_worker.dart | 106 ++++++++++++------ .../src/native/java/sentry_native_java.dart | 14 +-- .../native/android_core_worker_test_real.dart | 69 +++++++----- 3 files changed, 123 insertions(+), 66 deletions(-) diff --git a/packages/flutter/lib/src/native/java/android_core_worker.dart b/packages/flutter/lib/src/native/java/android_core_worker.dart index d179877fed..ec95613fff 100644 --- a/packages/flutter/lib/src/native/java/android_core_worker.dart +++ b/packages/flutter/lib/src/native/java/android_core_worker.dart @@ -160,67 +160,95 @@ class AndroidCoreWorker { } } - void addBreadcrumb(Breadcrumb breadcrumb) { - if (_isClosed) return; + FutureOr addBreadcrumb(Breadcrumb breadcrumb) { + if (_isClosed) return null; final client = _worker; if (client == null) { _addBreadcrumb(breadcrumb.toJson(), automatedTestMode: _config.automatedTestMode); - return; + return null; } - _addBreadcrumbFromWorker(client, breadcrumb); + return _addBreadcrumbFromWorker(client, breadcrumb); } - void _addBreadcrumbFromWorker( + Future _addBreadcrumbFromWorker( Worker client, Breadcrumb breadcrumb, - ) { - client.send(_AddBreadcrumbRequest(_normalizeJsonMap(breadcrumb.toJson()))); - } + ) => + _sendScopeUpdateToWorker( + client, + _AddBreadcrumbRequest(_normalizeJsonMap(breadcrumb.toJson())), + 'add breadcrumb', + ); - void setUser(SentryUser? user) { - if (_isClosed) return; + FutureOr setUser(SentryUser? user) { + if (_isClosed) return null; final client = _worker; if (client == null) { _setUser(user?.toJson(), automatedTestMode: _config.automatedTestMode); - return; + return null; } - _setUserFromWorker(client, user); + return _setUserFromWorker(client, user); } - void _setUserFromWorker( + Future _setUserFromWorker( Worker client, SentryUser? user, - ) { - client.send(_SetUserRequest( - user == null ? null : _normalizeJsonMap(user.toJson()), - )); - } + ) => + _sendScopeUpdateToWorker( + client, + _SetUserRequest( + user == null ? null : _normalizeJsonMap(user.toJson()), + ), + 'set user', + ); - void setContexts(String key, dynamic value) { - if (_isClosed) return; + FutureOr setContexts(String key, dynamic value) { + if (_isClosed) return null; final normalizedValue = _normalizeJson(value); final client = _worker; if (client == null) { _setContexts(key, normalizedValue, automatedTestMode: _config.automatedTestMode); - return; + return null; } - _setContextsFromWorker(client, key, normalizedValue); + return _setContextsFromWorker(client, key, normalizedValue); } - void _setContextsFromWorker( + Future _setContextsFromWorker( Worker client, String key, Object? value, - ) { - client.send(_SetContextsRequest(key, value)); + ) => + _sendScopeUpdateToWorker( + client, + _SetContextsRequest(key, value), + 'set context', + ); + + Future _sendScopeUpdateToWorker( + Worker client, + Object request, + String operation, + ) async { + try { + await client.request(request); + } catch (exception, stackTrace) { + internalLogger.error( + 'Android core worker failed to $operation', + error: exception, + stackTrace: stackTrace, + ); + if (_config.automatedTestMode) { + rethrow; + } + } } static void _entryPoint((SendPort, WorkerConfig) init) { @@ -258,15 +286,29 @@ class _AndroidCoreWorkerHandler extends WorkerHandler { @override FutureOr onRequest(Object? payload) => _enqueue(() { - return switch (payload) { - _LoadDebugImagesRequest request => _loadDebugImageMaps( + switch (payload) { + case _LoadDebugImagesRequest request: + return _loadDebugImageMaps( request.instructionAddresses, automatedTestMode: _config.automatedTestMode, - ), - _LoadContextsRequest _ => - _loadContexts(automatedTestMode: _config.automatedTestMode), - _ => _unexpectedPayload(payload), - }; + ); + case _LoadContextsRequest _: + return _loadContexts(automatedTestMode: _config.automatedTestMode); + case _AddBreadcrumbRequest request: + _addBreadcrumb(request.breadcrumb, + automatedTestMode: _config.automatedTestMode); + return null; + case _SetUserRequest request: + _setUser(request.user, + automatedTestMode: _config.automatedTestMode); + return null; + case _SetContextsRequest request: + _setContexts(request.key, request.value, + automatedTestMode: _config.automatedTestMode); + return null; + default: + return _unexpectedPayload(payload); + } }); /// Serializes JNI work inside the worker isolate. 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 4523e1d18c..cb84b98038 100644 --- a/packages/flutter/lib/src/native/java/sentry_native_java.dart +++ b/packages/flutter/lib/src/native/java/sentry_native_java.dart @@ -121,9 +121,8 @@ class SentryNativeJava extends SentryNativeChannel { } @override - FutureOr addBreadcrumb(Breadcrumb breadcrumb) { - _coreWorker?.addBreadcrumb(breadcrumb); - } + FutureOr addBreadcrumb(Breadcrumb breadcrumb) => + _coreWorker?.addBreadcrumb(breadcrumb); @override void clearBreadcrumbs() => tryCatchSync('clearBreadcrumbs', () { @@ -131,14 +130,11 @@ class SentryNativeJava extends SentryNativeChannel { }); @override - FutureOr setUser(SentryUser? user) { - _coreWorker?.setUser(user); - } + FutureOr setUser(SentryUser? user) => _coreWorker?.setUser(user); @override - FutureOr setContexts(String key, value) { - _coreWorker?.setContexts(key, value); - } + FutureOr setContexts(String key, value) => + _coreWorker?.setContexts(key, value); @override void removeContexts(String key) => tryCatchSync('removeContexts', () { diff --git a/packages/flutter/test/native/android_core_worker_test_real.dart b/packages/flutter/test/native/android_core_worker_test_real.dart index a6bf5010d2..eab2d84294 100644 --- a/packages/flutter/test/native/android_core_worker_test_real.dart +++ b/packages/flutter/test/native/android_core_worker_test_real.dart @@ -2,6 +2,7 @@ // ignore_for_file: invalid_use_of_internal_member library; +import 'dart:async'; import 'dart:isolate'; import 'dart:typed_data'; @@ -239,14 +240,15 @@ void main() { expect(await resultFuture, isNull); }); - test('sends breadcrumb update without awaiting response', () async { + test('sends breadcrumb update and awaits response', () async { final fixture = _Fixture(); final worker = fixture.getSut(); await worker.start(); - worker.addBreadcrumb(Breadcrumb(message: 'crumb')); + final payload = await fixture.expectPendingRequest( + worker.addBreadcrumb(Breadcrumb(message: 'crumb')), + ); - final payload = await fixture.nextMessage; expect((payload as dynamic).breadcrumb['message'], 'crumb'); }); @@ -255,25 +257,27 @@ void main() { final worker = fixture.getSut(); await worker.start(); - worker.addBreadcrumb(Breadcrumb( - message: 'crumb', - data: {'value': _UnserializableValue()}, - )); + final payload = await fixture.expectPendingRequest( + worker.addBreadcrumb(Breadcrumb( + message: 'crumb', + data: {'value': _UnserializableValue()}, + )), + ); - final payload = await fixture.nextMessage; expect((payload as dynamic).breadcrumb['data'], { 'value': 'normalized-value', }); }); - test('sends user update without awaiting response', () async { + test('sends user update and awaits response', () async { final fixture = _Fixture(); final worker = fixture.getSut(); await worker.start(); - worker.setUser(SentryUser(id: 'fixture-user')); + final payload = await fixture.expectPendingRequest( + worker.setUser(SentryUser(id: 'fixture-user')), + ); - final payload = await fixture.nextMessage; expect((payload as dynamic).user['id'], 'fixture-user'); }); @@ -282,14 +286,15 @@ void main() { final worker = fixture.getSut(); await worker.start(); - worker.setUser(SentryUser( - id: 'fixture-user', - data: {'value': _UnserializableValue()}, - // ignore: deprecated_member_use - extras: {'extra': _UnserializableValue()}, - )); + final payload = await fixture.expectPendingRequest( + worker.setUser(SentryUser( + id: 'fixture-user', + data: {'value': _UnserializableValue()}, + // ignore: deprecated_member_use + extras: {'extra': _UnserializableValue()}, + )), + ); - final payload = await fixture.nextMessage; final user = (payload as dynamic).user as Map; expect(user['data'], { 'value': 'normalized-value', @@ -299,16 +304,17 @@ void main() { }); }); - test('sends context update without awaiting response', () async { + test('sends context update and awaits response', () async { final fixture = _Fixture(); final worker = fixture.getSut(); await worker.start(); - worker.setContexts('fixture-key', { - 'nested': {'value': true} - }); + final payload = await fixture.expectPendingRequest( + worker.setContexts('fixture-key', { + 'nested': {'value': true} + }), + ); - final payload = await fixture.nextMessage; expect((payload as dynamic).key, 'fixture-key'); expect((payload as dynamic).value, { 'nested': {'value': true} @@ -332,8 +338,6 @@ class _Fixture { return request; } - Future get nextMessage => inboxes.last.first; - void respond(int id, Object? response) { responsePorts.last.send((id, response)); } @@ -342,6 +346,21 @@ class _Fixture { respond(id, RemoteError('worker failure', StackTrace.current.toString())); } + Future expectPendingRequest(FutureOr update) async { + final updateFuture = Future.value(update); + var completed = false; + unawaited(updateFuture.then((_) => completed = true)); + + final (id, payload) = await nextRequest; + await pumpEventQueue(); + expect(completed, isFalse); + + respond(id, null); + await updateFuture; + expect(completed, isTrue); + return payload; + } + Future _fakeSpawn(WorkerConfig config, WorkerEntry entry) async { final inbox = ReceivePort(); inboxes.add(inbox); From b163866fca6f10e14231e5e783370e439520ee57 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 26 May 2026 11:11:40 +0200 Subject: [PATCH 17/22] fix(flutter): Serialize Android scope updates Route paired Android scope mutations through the same worker queue so call order is preserved across breadcrumb and context updates. Handle worker startup failures without delaying native SDK initialization. Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- .../src/native/java/android_core_worker.dart | 102 +++++++++++++++++- .../src/native/java/sentry_native_java.dart | 12 +-- .../native/android_core_worker_test_real.dart | 87 +++++++++++++++ .../native/sentry_native_java_test_real.dart | 10 ++ 4 files changed, 199 insertions(+), 12 deletions(-) diff --git a/packages/flutter/lib/src/native/java/android_core_worker.dart b/packages/flutter/lib/src/native/java/android_core_worker.dart index ec95613fff..4bf18a8a98 100644 --- a/packages/flutter/lib/src/native/java/android_core_worker.dart +++ b/packages/flutter/lib/src/native/java/android_core_worker.dart @@ -52,15 +52,22 @@ class AndroidCoreWorker { return; } _worker = worker; + } catch (exception, stackTrace) { + internalLogger.error( + 'Failed to start Android core worker', + error: exception, + stackTrace: stackTrace, + ); } finally { _startFuture = null; } } - FutureOr close() { + FutureOr close() async { + _isClosed = true; + await _startFuture; _worker?.close(); _worker = null; - _isClosed = true; } void captureEnvelope( @@ -183,6 +190,25 @@ class AndroidCoreWorker { 'add breadcrumb', ); + FutureOr clearBreadcrumbs() { + if (_isClosed) return null; + + final client = _worker; + if (client == null) { + _clearBreadcrumbs(automatedTestMode: _config.automatedTestMode); + return null; + } + + return _clearBreadcrumbsFromWorker(client); + } + + Future _clearBreadcrumbsFromWorker(Worker client) => + _sendScopeUpdateToWorker( + client, + const _ClearBreadcrumbsRequest(), + 'clear breadcrumbs', + ); + FutureOr setUser(SentryUser? user) { if (_isClosed) return null; @@ -232,6 +258,28 @@ class AndroidCoreWorker { 'set context', ); + FutureOr removeContexts(String key) { + if (_isClosed) return null; + + final client = _worker; + if (client == null) { + _removeContexts(key, automatedTestMode: _config.automatedTestMode); + return null; + } + + return _removeContextsFromWorker(client, key); + } + + Future _removeContextsFromWorker( + Worker client, + String key, + ) => + _sendScopeUpdateToWorker( + client, + _RemoveContextsRequest(key), + 'remove context', + ); + Future _sendScopeUpdateToWorker( Worker client, Object request, @@ -273,12 +321,17 @@ class _AndroidCoreWorkerHandler extends WorkerHandler { case _AddBreadcrumbRequest request: _addBreadcrumb(request.breadcrumb, automatedTestMode: _config.automatedTestMode); + case _ClearBreadcrumbsRequest _: + _clearBreadcrumbs(automatedTestMode: _config.automatedTestMode); case _SetUserRequest request: _setUser(request.user, automatedTestMode: _config.automatedTestMode); case _SetContextsRequest request: _setContexts(request.key, request.value, automatedTestMode: _config.automatedTestMode); + case _RemoveContextsRequest request: + _removeContexts(request.key, + automatedTestMode: _config.automatedTestMode); default: _unexpectedMessage(msg); } @@ -298,6 +351,9 @@ class _AndroidCoreWorkerHandler extends WorkerHandler { _addBreadcrumb(request.breadcrumb, automatedTestMode: _config.automatedTestMode); return null; + case _ClearBreadcrumbsRequest _: + _clearBreadcrumbs(automatedTestMode: _config.automatedTestMode); + return null; case _SetUserRequest request: _setUser(request.user, automatedTestMode: _config.automatedTestMode); @@ -306,6 +362,10 @@ class _AndroidCoreWorkerHandler extends WorkerHandler { _setContexts(request.key, request.value, automatedTestMode: _config.automatedTestMode); return null; + case _RemoveContextsRequest request: + _removeContexts(request.key, + automatedTestMode: _config.automatedTestMode); + return null; default: return _unexpectedPayload(payload); } @@ -354,6 +414,10 @@ class _AddBreadcrumbRequest { const _AddBreadcrumbRequest(this.breadcrumb); } +class _ClearBreadcrumbsRequest { + const _ClearBreadcrumbsRequest(); +} + class _SetUserRequest { final Map? user; @@ -367,6 +431,12 @@ class _SetContextsRequest { const _SetContextsRequest(this.key, this.value); } +class _RemoveContextsRequest { + final String key; + + const _RemoveContextsRequest(this.key); +} + void _captureEnvelope(Uint8List envelopeData, bool containsUnhandledException, {bool automatedTestMode = false}) { JObject? id; @@ -487,6 +557,18 @@ void _addBreadcrumb(Map breadcrumb, } } +void _clearBreadcrumbs({bool automatedTestMode = false}) { + try { + native.Sentry.clearBreadcrumbs(); + } catch (exception, stackTrace) { + internalLogger.error('JNI: Failed to clear breadcrumbs', + error: exception, stackTrace: stackTrace); + if (automatedTestMode) { + rethrow; + } + } +} + void _setUser(Map? user, {bool automatedTestMode = false}) { JByteArray? jBytes; try { @@ -527,6 +609,22 @@ void _setContexts(String key, Object? value, {bool automatedTestMode = false}) { } } +void _removeContexts(String key, {bool automatedTestMode = false}) { + JString? jKey; + try { + jKey = key.toJString(); + native.SentryFlutterPlugin.removeContext(jKey); + } catch (exception, stackTrace) { + internalLogger.error('JNI: Failed to remove context', + error: exception, stackTrace: stackTrace); + if (automatedTestMode) { + rethrow; + } + } finally { + jKey?.release(); + } +} + JByteArray _jsonToJByteArray(Object? value) => JByteArray.from(encodeUtf8Json(_normalizeJson(value))); 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 cb84b98038..f3a9fbd0cd 100644 --- a/packages/flutter/lib/src/native/java/sentry_native_java.dart +++ b/packages/flutter/lib/src/native/java/sentry_native_java.dart @@ -125,9 +125,7 @@ class SentryNativeJava extends SentryNativeChannel { _coreWorker?.addBreadcrumb(breadcrumb); @override - void clearBreadcrumbs() => tryCatchSync('clearBreadcrumbs', () { - native.Sentry.clearBreadcrumbs(); - }); + FutureOr clearBreadcrumbs() => _coreWorker?.clearBreadcrumbs(); @override FutureOr setUser(SentryUser? user) => _coreWorker?.setUser(user); @@ -137,13 +135,7 @@ class SentryNativeJava extends SentryNativeChannel { _coreWorker?.setContexts(key, value); @override - void removeContexts(String key) => tryCatchSync('removeContexts', () { - using((arena) { - final jKey = key.toJString()..releasedBy(arena); - - native.SentryFlutterPlugin.removeContext(jKey); - }); - }); + FutureOr removeContexts(String key) => _coreWorker?.removeContexts(key); @override void setTag(String key, String value) => tryCatchSync('setTag', () { diff --git a/packages/flutter/test/native/android_core_worker_test_real.dart b/packages/flutter/test/native/android_core_worker_test_real.dart index eab2d84294..bbe2f20397 100644 --- a/packages/flutter/test/native/android_core_worker_test_real.dart +++ b/packages/flutter/test/native/android_core_worker_test_real.dart @@ -80,6 +80,69 @@ void main() { expect(() => worker.close(), returnsNormally); }); + test('logs when start fails', () async { + final options = SentryFlutterOptions(); + final logs = <(SentryLevel, String)>[]; + SentryInternalLogger.configure( + isEnabled: true, + minLevel: SentryLevel.debug, + logOutput: ({ + required String name, + required SentryLevel level, + required String message, + Object? error, + StackTrace? stackTrace, + }) { + logs.add((level, message.toString())); + }, + ); + + Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { + throw StateError('spawn failed'); + } + + final worker = AndroidCoreWorker(options, spawn: fakeSpawn); + await worker.start(); + + expect( + logs.any((e) => + e.$1 == SentryLevel.error && + e.$2.contains('Failed to start Android core worker')), + isTrue, + ); + }); + + test('close waits for in-flight start', () async { + final options = SentryFlutterOptions(); + final spawnCompleter = Completer(); + late ReceivePort inbox; + late ReceivePort replies; + + Future fakeSpawn(WorkerConfig config, WorkerEntry entry) { + inbox = ReceivePort(); + addTearDown(inbox.close); + replies = ReceivePort(); + addTearDown(replies.close); + return spawnCompleter.future; + } + + final worker = AndroidCoreWorker(options, spawn: fakeSpawn); + unawaited(Future.value(worker.start())); + + final closeFuture = Future.value(worker.close()); + var closeCompleted = false; + unawaited(closeFuture.then((_) => closeCompleted = true)); + + await pumpEventQueue(); + expect(closeCompleted, isFalse); + + spawnCompleter.complete(Worker(inbox.sendPort, replies)); + + await closeFuture; + expect(closeCompleted, isTrue); + expect(await inbox.first, '_shutdown_'); + }); + test('sends envelope capture request after start', () async { final options = SentryFlutterOptions(); options.debug = true; @@ -269,6 +332,18 @@ void main() { }); }); + test('sends breadcrumb clear request and awaits response', () async { + final fixture = _Fixture(); + final worker = fixture.getSut(); + await worker.start(); + + final payload = await fixture.expectPendingRequest( + worker.clearBreadcrumbs(), + ); + + expect(payload.runtimeType.toString(), '_ClearBreadcrumbsRequest'); + }); + test('sends user update and awaits response', () async { final fixture = _Fixture(); final worker = fixture.getSut(); @@ -320,6 +395,18 @@ void main() { 'nested': {'value': true} }); }); + + test('sends context remove request and awaits response', () async { + final fixture = _Fixture(); + final worker = fixture.getSut(); + await worker.start(); + + final payload = await fixture.expectPendingRequest( + worker.removeContexts('fixture-key'), + ); + + expect((payload as dynamic).key, 'fixture-key'); + }); }); } diff --git a/packages/flutter/test/native/sentry_native_java_test_real.dart b/packages/flutter/test/native/sentry_native_java_test_real.dart index 5598a80584..479e9bca19 100644 --- a/packages/flutter/test/native/sentry_native_java_test_real.dart +++ b/packages/flutter/test/native/sentry_native_java_test_real.dart @@ -121,6 +121,11 @@ class _FakeCoreWorker implements AndroidCoreWorker { // No-op for testing } + @override + FutureOr clearBreadcrumbs() { + // No-op for testing + } + @override void setUser(SentryUser? user) { // No-op for testing @@ -130,4 +135,9 @@ class _FakeCoreWorker implements AndroidCoreWorker { void setContexts(String key, value) { // No-op for testing } + + @override + FutureOr removeContexts(String key) { + // No-op for testing + } } From 5d9efbc41d068db9193bfeb85a6cf59fe1c48598 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 26 May 2026 11:47:03 +0200 Subject: [PATCH 18/22] fix(flutter): Skip Android worker reads after close Return null for Android core worker read paths after shutdown so closed workers do not fall back to JNI work on the main isolate. Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- .../src/native/java/android_core_worker.dart | 4 +++ .../native/android_core_worker_test_real.dart | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/flutter/lib/src/native/java/android_core_worker.dart b/packages/flutter/lib/src/native/java/android_core_worker.dart index 4bf18a8a98..72e6c91b31 100644 --- a/packages/flutter/lib/src/native/java/android_core_worker.dart +++ b/packages/flutter/lib/src/native/java/android_core_worker.dart @@ -100,6 +100,8 @@ class AndroidCoreWorker { } FutureOr?> loadDebugImages(SentryStackTrace stackTrace) { + if (_isClosed) return null; + final instructionAddresses = stackTrace.frames.map((f) => f.instructionAddr).nonNulls.toList( growable: false, @@ -140,6 +142,8 @@ class AndroidCoreWorker { } FutureOr?> loadContexts() { + if (_isClosed) return null; + final client = _worker; if (client == null) { return _loadContexts(automatedTestMode: _config.automatedTestMode); diff --git a/packages/flutter/test/native/android_core_worker_test_real.dart b/packages/flutter/test/native/android_core_worker_test_real.dart index bbe2f20397..d605ca915d 100644 --- a/packages/flutter/test/native/android_core_worker_test_real.dart +++ b/packages/flutter/test/native/android_core_worker_test_real.dart @@ -272,6 +272,20 @@ void main() { expect(await resultFuture, isNull); }); + test('returns null for debug images after close', () async { + final fixture = _Fixture(); + fixture.options.automatedTestMode = true; + final worker = fixture.getSut(); + await worker.start(); + await worker.close(); + + final result = worker.loadDebugImages(SentryStackTrace(frames: [ + SentryStackFrame(instructionAddr: '0x1'), + ])); + + expect(await Future?>.value(result), isNull); + }); + test('requests native contexts', () async { final fixture = _Fixture(); final worker = fixture.getSut(); @@ -303,6 +317,18 @@ void main() { expect(await resultFuture, isNull); }); + test('returns null for native contexts after close', () async { + final fixture = _Fixture(); + fixture.options.automatedTestMode = true; + final worker = fixture.getSut(); + await worker.start(); + await worker.close(); + + final result = worker.loadContexts(); + + expect(await Future?>.value(result), isNull); + }); + test('sends breadcrumb update and awaits response', () async { final fixture = _Fixture(); final worker = fixture.getSut(); From 099cefb8eb22520787ef6ff4f663b266ec95b344 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 26 May 2026 11:50:23 +0200 Subject: [PATCH 19/22] style(flutter): Format Android core worker Apply formatting to the Android core worker and clarify that its internal queue preserves JNI request order. Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- .../src/native/java/android_core_worker.dart | 244 +++++++++++------- 1 file changed, 157 insertions(+), 87 deletions(-) diff --git a/packages/flutter/lib/src/native/java/android_core_worker.dart b/packages/flutter/lib/src/native/java/android_core_worker.dart index 72e6c91b31..d4b3edba9a 100644 --- a/packages/flutter/lib/src/native/java/android_core_worker.dart +++ b/packages/flutter/lib/src/native/java/android_core_worker.dart @@ -71,7 +71,9 @@ class AndroidCoreWorker { } void captureEnvelope( - Uint8List envelopeData, bool containsUnhandledException) { + Uint8List envelopeData, + bool containsUnhandledException, + ) { if (_isClosed) return; final client = _worker; @@ -79,13 +81,19 @@ class AndroidCoreWorker { internalLogger.info( 'captureEnvelope called before core worker started: sending envelope in main isolate instead', ); - _captureEnvelope(envelopeData, containsUnhandledException, - automatedTestMode: _config.automatedTestMode); + _captureEnvelope( + envelopeData, + containsUnhandledException, + automatedTestMode: _config.automatedTestMode, + ); return; } _captureEnvelopeFromWorker( - client, envelopeData, containsUnhandledException); + client, + envelopeData, + containsUnhandledException, + ); } void _captureEnvelopeFromWorker( @@ -93,24 +101,28 @@ class AndroidCoreWorker { Uint8List envelopeData, bool containsUnhandledException, ) { - client.send(_CaptureEnvelopeRequest( - TransferableTypedData.fromList([envelopeData]), - containsUnhandledException, - )); + client.send( + _CaptureEnvelopeRequest( + TransferableTypedData.fromList([envelopeData]), + containsUnhandledException, + ), + ); } FutureOr?> loadDebugImages(SentryStackTrace stackTrace) { if (_isClosed) return null; - final instructionAddresses = - stackTrace.frames.map((f) => f.instructionAddr).nonNulls.toList( - growable: false, - ); + final instructionAddresses = stackTrace.frames + .map((f) => f.instructionAddr) + .nonNulls + .toList(growable: false); final client = _worker; if (client == null) { - return _loadDebugImages(instructionAddresses, - automatedTestMode: _config.automatedTestMode); + return _loadDebugImages( + instructionAddresses, + automatedTestMode: _config.automatedTestMode, + ); } return _loadDebugImagesFromWorker(client, instructionAddresses); @@ -121,8 +133,9 @@ class AndroidCoreWorker { List instructionAddresses, ) async { try { - final response = - await client.request(_LoadDebugImagesRequest(instructionAddresses)); + final response = await client.request( + _LoadDebugImagesRequest(instructionAddresses), + ); final maps = (response as List?) ?.whereType>() .map((e) => Map.from(e)) @@ -176,18 +189,17 @@ class AndroidCoreWorker { final client = _worker; if (client == null) { - _addBreadcrumb(breadcrumb.toJson(), - automatedTestMode: _config.automatedTestMode); + _addBreadcrumb( + breadcrumb.toJson(), + automatedTestMode: _config.automatedTestMode, + ); return null; } return _addBreadcrumbFromWorker(client, breadcrumb); } - Future _addBreadcrumbFromWorker( - Worker client, - Breadcrumb breadcrumb, - ) => + Future _addBreadcrumbFromWorker(Worker client, Breadcrumb breadcrumb) => _sendScopeUpdateToWorker( client, _AddBreadcrumbRequest(_normalizeJsonMap(breadcrumb.toJson())), @@ -225,15 +237,10 @@ class AndroidCoreWorker { return _setUserFromWorker(client, user); } - Future _setUserFromWorker( - Worker client, - SentryUser? user, - ) => + Future _setUserFromWorker(Worker client, SentryUser? user) => _sendScopeUpdateToWorker( client, - _SetUserRequest( - user == null ? null : _normalizeJsonMap(user.toJson()), - ), + _SetUserRequest(user == null ? null : _normalizeJsonMap(user.toJson())), 'set user', ); @@ -243,8 +250,11 @@ class AndroidCoreWorker { final normalizedValue = _normalizeJson(value); final client = _worker; if (client == null) { - _setContexts(key, normalizedValue, - automatedTestMode: _config.automatedTestMode); + _setContexts( + key, + normalizedValue, + automatedTestMode: _config.automatedTestMode, + ); return null; } @@ -274,10 +284,7 @@ class AndroidCoreWorker { return _removeContextsFromWorker(client, key); } - Future _removeContextsFromWorker( - Worker client, - String key, - ) => + Future _removeContextsFromWorker(Worker client, String key) => _sendScopeUpdateToWorker( client, _RemoveContextsRequest(key), @@ -320,22 +327,32 @@ class _AndroidCoreWorkerHandler extends WorkerHandler { switch (msg) { case _CaptureEnvelopeRequest request: final data = request.envelopeData.materialize().asUint8List(); - _captureEnvelope(data, request.containsUnhandledException, - automatedTestMode: _config.automatedTestMode); + _captureEnvelope( + data, + request.containsUnhandledException, + automatedTestMode: _config.automatedTestMode, + ); case _AddBreadcrumbRequest request: - _addBreadcrumb(request.breadcrumb, - automatedTestMode: _config.automatedTestMode); + _addBreadcrumb( + request.breadcrumb, + automatedTestMode: _config.automatedTestMode, + ); case _ClearBreadcrumbsRequest _: _clearBreadcrumbs(automatedTestMode: _config.automatedTestMode); case _SetUserRequest request: _setUser(request.user, automatedTestMode: _config.automatedTestMode); case _SetContextsRequest request: - _setContexts(request.key, request.value, - automatedTestMode: _config.automatedTestMode); + _setContexts( + request.key, + request.value, + automatedTestMode: _config.automatedTestMode, + ); case _RemoveContextsRequest request: - _removeContexts(request.key, - automatedTestMode: _config.automatedTestMode); + _removeContexts( + request.key, + automatedTestMode: _config.automatedTestMode, + ); default: _unexpectedMessage(msg); } @@ -352,8 +369,10 @@ class _AndroidCoreWorkerHandler extends WorkerHandler { case _LoadContextsRequest _: return _loadContexts(automatedTestMode: _config.automatedTestMode); case _AddBreadcrumbRequest request: - _addBreadcrumb(request.breadcrumb, - automatedTestMode: _config.automatedTestMode); + _addBreadcrumb( + request.breadcrumb, + automatedTestMode: _config.automatedTestMode, + ); return null; case _ClearBreadcrumbsRequest _: _clearBreadcrumbs(automatedTestMode: _config.automatedTestMode); @@ -363,19 +382,24 @@ class _AndroidCoreWorkerHandler extends WorkerHandler { automatedTestMode: _config.automatedTestMode); return null; case _SetContextsRequest request: - _setContexts(request.key, request.value, - automatedTestMode: _config.automatedTestMode); + _setContexts( + request.key, + request.value, + automatedTestMode: _config.automatedTestMode, + ); return null; case _RemoveContextsRequest request: - _removeContexts(request.key, - automatedTestMode: _config.automatedTestMode); + _removeContexts( + request.key, + automatedTestMode: _config.automatedTestMode, + ); return null; default: return _unexpectedPayload(payload); } }); - /// Serializes JNI work inside the worker isolate. + /// Serializes worker actions so JNI calls run in request order. Future _enqueue(FutureOr Function() action) { final next = _queue.then((_) => action()); _queue = next.then((_) {}, onError: (_) {}); @@ -383,14 +407,16 @@ class _AndroidCoreWorkerHandler extends WorkerHandler { } Object? _unexpectedPayload(Object? payload) { - internalLogger - .warning('${_config.debugName}: unexpected payload type: $payload'); + internalLogger.warning( + '${_config.debugName}: unexpected payload type: $payload', + ); return null; } void _unexpectedMessage(Object? msg) { - internalLogger - .warning('${_config.debugName}: unexpected message type: $msg'); + internalLogger.warning( + '${_config.debugName}: unexpected message type: $msg', + ); } } @@ -399,7 +425,9 @@ class _CaptureEnvelopeRequest { final bool containsUnhandledException; const _CaptureEnvelopeRequest( - this.envelopeData, this.containsUnhandledException); + this.envelopeData, + this.containsUnhandledException, + ); } class _LoadDebugImagesRequest { @@ -441,22 +469,31 @@ class _RemoveContextsRequest { const _RemoveContextsRequest(this.key); } -void _captureEnvelope(Uint8List envelopeData, bool containsUnhandledException, - {bool automatedTestMode = false}) { +void _captureEnvelope( + Uint8List envelopeData, + bool containsUnhandledException, { + bool automatedTestMode = false, +}) { JObject? id; JByteArray? byteArray; try { byteArray = JByteArray.from(envelopeData); id = native.InternalSentrySdk.captureEnvelope( - byteArray, containsUnhandledException); + byteArray, + containsUnhandledException, + ); if (id == null) { - internalLogger - .error('Native Android SDK returned null when capturing envelope'); + internalLogger.error( + 'Native Android SDK returned null when capturing envelope', + ); } } catch (exception, stackTrace) { - internalLogger.error('Failed to capture envelope', - error: exception, stackTrace: stackTrace); + internalLogger.error( + 'Failed to capture envelope', + error: exception, + stackTrace: stackTrace, + ); if (automatedTestMode) { rethrow; } @@ -466,16 +503,21 @@ void _captureEnvelope(Uint8List envelopeData, bool containsUnhandledException, } } -List? _loadDebugImages(List instructionAddresses, - {bool automatedTestMode = false}) { - final debugImageMaps = _loadDebugImageMaps(instructionAddresses, - automatedTestMode: automatedTestMode); +List? _loadDebugImages( + List instructionAddresses, { + bool automatedTestMode = false, +}) { + final debugImageMaps = _loadDebugImageMaps( + instructionAddresses, + automatedTestMode: automatedTestMode, + ); return debugImageMaps?.map(DebugImage.fromJson).toList(growable: false); } List>? _loadDebugImageMaps( - List instructionAddresses, - {bool automatedTestMode = false}) { + List instructionAddresses, { + bool automatedTestMode = false, +}) { JSet? instructionAddressSet; final instructionAddressJStrings = []; JByteArray? imagesUtf8JsonBytes; @@ -488,13 +530,19 @@ List>? _loadDebugImageMaps( instructionAddressSet = instructionAddressJStrings.toJSet(JString.type); imagesUtf8JsonBytes = native.SentryFlutterPlugin.loadDebugImagesAsBytes( - instructionAddressSet); + instructionAddressSet, + ); if (imagesUtf8JsonBytes == null) return null; - final byteRange = - imagesUtf8JsonBytes.getRange(0, imagesUtf8JsonBytes.length); + final byteRange = imagesUtf8JsonBytes.getRange( + 0, + imagesUtf8JsonBytes.length, + ); final bytes = Uint8List.view( - byteRange.buffer, byteRange.offsetInBytes, byteRange.length); + byteRange.buffer, + byteRange.offsetInBytes, + byteRange.length, + ); return decodeUtf8JsonListOfMaps(bytes); } catch (exception, stackTrace) { internalLogger.error( @@ -523,10 +571,15 @@ Map? _loadContexts({bool automatedTestMode = false}) { contextsUtf8JsonBytes = native.SentryFlutterPlugin.loadContextsAsBytes(); if (contextsUtf8JsonBytes == null) return null; - final byteRange = - contextsUtf8JsonBytes.getRange(0, contextsUtf8JsonBytes.length); + final byteRange = contextsUtf8JsonBytes.getRange( + 0, + contextsUtf8JsonBytes.length, + ); final bytes = Uint8List.view( - byteRange.buffer, byteRange.offsetInBytes, byteRange.length); + byteRange.buffer, + byteRange.offsetInBytes, + byteRange.length, + ); return decodeUtf8JsonMap(bytes); } catch (exception, stackTrace) { internalLogger.error( @@ -544,15 +597,20 @@ Map? _loadContexts({bool automatedTestMode = false}) { return null; } -void _addBreadcrumb(Map breadcrumb, - {bool automatedTestMode = false}) { +void _addBreadcrumb( + Map breadcrumb, { + bool automatedTestMode = false, +}) { JByteArray? jBytes; try { jBytes = _jsonToJByteArray(breadcrumb); native.SentryFlutterPlugin.addBreadcrumbFromJsonBytes(jBytes); } catch (exception, stackTrace) { - internalLogger.error('JNI: Failed to add breadcrumb', - error: exception, stackTrace: stackTrace); + internalLogger.error( + 'JNI: Failed to add breadcrumb', + error: exception, + stackTrace: stackTrace, + ); if (automatedTestMode) { rethrow; } @@ -565,8 +623,11 @@ void _clearBreadcrumbs({bool automatedTestMode = false}) { try { native.Sentry.clearBreadcrumbs(); } catch (exception, stackTrace) { - internalLogger.error('JNI: Failed to clear breadcrumbs', - error: exception, stackTrace: stackTrace); + internalLogger.error( + 'JNI: Failed to clear breadcrumbs', + error: exception, + stackTrace: stackTrace, + ); if (automatedTestMode) { rethrow; } @@ -583,8 +644,11 @@ void _setUser(Map? user, {bool automatedTestMode = false}) { native.SentryFlutterPlugin.setUserFromJsonBytes(jBytes); } } catch (exception, stackTrace) { - internalLogger.error('JNI: Failed to set user', - error: exception, stackTrace: stackTrace); + internalLogger.error( + 'JNI: Failed to set user', + error: exception, + stackTrace: stackTrace, + ); if (automatedTestMode) { rethrow; } @@ -602,8 +666,11 @@ void _setContexts(String key, Object? value, {bool automatedTestMode = false}) { native.SentryFlutterPlugin.setContextFromJsonBytes(jKey, jBytes); } catch (exception, stackTrace) { - internalLogger.error('JNI: Failed to set context', - error: exception, stackTrace: stackTrace); + internalLogger.error( + 'JNI: Failed to set context', + error: exception, + stackTrace: stackTrace, + ); if (automatedTestMode) { rethrow; } @@ -619,8 +686,11 @@ void _removeContexts(String key, {bool automatedTestMode = false}) { jKey = key.toJString(); native.SentryFlutterPlugin.removeContext(jKey); } catch (exception, stackTrace) { - internalLogger.error('JNI: Failed to remove context', - error: exception, stackTrace: stackTrace); + internalLogger.error( + 'JNI: Failed to remove context', + error: exception, + stackTrace: stackTrace, + ); if (automatedTestMode) { rethrow; } From 95727b7b60662629da6ec5a06baefbe224c04cf1 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 26 May 2026 11:58:52 +0200 Subject: [PATCH 20/22] ref(flutter): Expand Android worker update helpers Use explicit worker request helpers for each Android scope mutation so request construction and error logging stay local to each operation. Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- .../src/native/java/android_core_worker.dart | 92 +++++++++++++------ 1 file changed, 63 insertions(+), 29 deletions(-) diff --git a/packages/flutter/lib/src/native/java/android_core_worker.dart b/packages/flutter/lib/src/native/java/android_core_worker.dart index d4b3edba9a..6d33cf8694 100644 --- a/packages/flutter/lib/src/native/java/android_core_worker.dart +++ b/packages/flutter/lib/src/native/java/android_core_worker.dart @@ -199,12 +199,25 @@ class AndroidCoreWorker { return _addBreadcrumbFromWorker(client, breadcrumb); } - Future _addBreadcrumbFromWorker(Worker client, Breadcrumb breadcrumb) => - _sendScopeUpdateToWorker( - client, + Future _addBreadcrumbFromWorker( + Worker client, + Breadcrumb breadcrumb, + ) async { + try { + await client.request( _AddBreadcrumbRequest(_normalizeJsonMap(breadcrumb.toJson())), - 'add breadcrumb', ); + } catch (exception, stackTrace) { + internalLogger.error( + 'Android core worker failed to add breadcrumb', + error: exception, + stackTrace: stackTrace, + ); + if (_config.automatedTestMode) { + rethrow; + } + } + } FutureOr clearBreadcrumbs() { if (_isClosed) return null; @@ -218,12 +231,22 @@ class AndroidCoreWorker { return _clearBreadcrumbsFromWorker(client); } - Future _clearBreadcrumbsFromWorker(Worker client) => - _sendScopeUpdateToWorker( - client, + Future _clearBreadcrumbsFromWorker(Worker client) async { + try { + await client.request( const _ClearBreadcrumbsRequest(), - 'clear breadcrumbs', ); + } catch (exception, stackTrace) { + internalLogger.error( + 'Android core worker failed to clear breadcrumbs', + error: exception, + stackTrace: stackTrace, + ); + if (_config.automatedTestMode) { + rethrow; + } + } + } FutureOr setUser(SentryUser? user) { if (_isClosed) return null; @@ -237,12 +260,22 @@ class AndroidCoreWorker { return _setUserFromWorker(client, user); } - Future _setUserFromWorker(Worker client, SentryUser? user) => - _sendScopeUpdateToWorker( - client, + Future _setUserFromWorker(Worker client, SentryUser? user) async { + try { + await client.request( _SetUserRequest(user == null ? null : _normalizeJsonMap(user.toJson())), - 'set user', ); + } catch (exception, stackTrace) { + internalLogger.error( + 'Android core worker failed to set user', + error: exception, + stackTrace: stackTrace, + ); + if (_config.automatedTestMode) { + rethrow; + } + } + } FutureOr setContexts(String key, dynamic value) { if (_isClosed) return null; @@ -265,12 +298,22 @@ class AndroidCoreWorker { Worker client, String key, Object? value, - ) => - _sendScopeUpdateToWorker( - client, + ) async { + try { + await client.request( _SetContextsRequest(key, value), - 'set context', ); + } catch (exception, stackTrace) { + internalLogger.error( + 'Android core worker failed to set context', + error: exception, + stackTrace: stackTrace, + ); + if (_config.automatedTestMode) { + rethrow; + } + } + } FutureOr removeContexts(String key) { if (_isClosed) return null; @@ -284,23 +327,14 @@ class AndroidCoreWorker { return _removeContextsFromWorker(client, key); } - Future _removeContextsFromWorker(Worker client, String key) => - _sendScopeUpdateToWorker( - client, + Future _removeContextsFromWorker(Worker client, String key) async { + try { + await client.request( _RemoveContextsRequest(key), - 'remove context', ); - - Future _sendScopeUpdateToWorker( - Worker client, - Object request, - String operation, - ) async { - try { - await client.request(request); } catch (exception, stackTrace) { internalLogger.error( - 'Android core worker failed to $operation', + 'Android core worker failed to remove context', error: exception, stackTrace: stackTrace, ); From d010d5e2488d1f080047aca63fe2c7cb945c3467 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 26 May 2026 13:26:09 +0200 Subject: [PATCH 21/22] ref(flutter): Reuse Android scope normalizer Route Android core worker scope payloads through the shared normalizer to avoid broadening behavior in this refactor. Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- .../src/native/java/android_core_worker.dart | 23 ++++--------------- .../native/android_core_worker_test_real.dart | 4 ++-- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/packages/flutter/lib/src/native/java/android_core_worker.dart b/packages/flutter/lib/src/native/java/android_core_worker.dart index 6d33cf8694..63edbff95c 100644 --- a/packages/flutter/lib/src/native/java/android_core_worker.dart +++ b/packages/flutter/lib/src/native/java/android_core_worker.dart @@ -205,7 +205,7 @@ class AndroidCoreWorker { ) async { try { await client.request( - _AddBreadcrumbRequest(_normalizeJsonMap(breadcrumb.toJson())), + _AddBreadcrumbRequest(normalizeMap(breadcrumb.toJson())!), ); } catch (exception, stackTrace) { internalLogger.error( @@ -263,7 +263,7 @@ class AndroidCoreWorker { Future _setUserFromWorker(Worker client, SentryUser? user) async { try { await client.request( - _SetUserRequest(user == null ? null : _normalizeJsonMap(user.toJson())), + _SetUserRequest(user == null ? null : normalizeMap(user.toJson())), ); } catch (exception, stackTrace) { internalLogger.error( @@ -280,7 +280,7 @@ class AndroidCoreWorker { FutureOr setContexts(String key, dynamic value) { if (_isClosed) return null; - final normalizedValue = _normalizeJson(value); + final normalizedValue = normalize(value); final client = _worker; if (client == null) { _setContexts( @@ -734,19 +734,4 @@ void _removeContexts(String key, {bool automatedTestMode = false}) { } JByteArray _jsonToJByteArray(Object? value) => - JByteArray.from(encodeUtf8Json(_normalizeJson(value))); - -Map _normalizeJsonMap(Map value) => - _normalizeJson(value) as Map; - -Object? _normalizeJson(Object? value) { - if (value is Map) { - return value.map( - (key, value) => MapEntry(key.toString(), _normalizeJson(value)), - ); - } - if (value is List) { - return value.map(_normalizeJson).toList(growable: false); - } - return normalize(value); -} + JByteArray.from(encodeUtf8Json(normalize(value))); diff --git a/packages/flutter/test/native/android_core_worker_test_real.dart b/packages/flutter/test/native/android_core_worker_test_real.dart index d605ca915d..cb00d3704f 100644 --- a/packages/flutter/test/native/android_core_worker_test_real.dart +++ b/packages/flutter/test/native/android_core_worker_test_real.dart @@ -411,8 +411,8 @@ void main() { await worker.start(); final payload = await fixture.expectPendingRequest( - worker.setContexts('fixture-key', { - 'nested': {'value': true} + worker.setContexts('fixture-key', { + 'nested': {'value': true} }), ); From 739f508279784af9982b18f4d98eed678be084e7 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 26 May 2026 14:29:49 +0200 Subject: [PATCH 22/22] ref(flutter): Use normalize for worker payloads Align Android worker scope payload normalization with the shared helper without changing the surrounding worker request flow. Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- .../src/native/java/android_core_worker.dart | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/flutter/lib/src/native/java/android_core_worker.dart b/packages/flutter/lib/src/native/java/android_core_worker.dart index 63edbff95c..100e86dd19 100644 --- a/packages/flutter/lib/src/native/java/android_core_worker.dart +++ b/packages/flutter/lib/src/native/java/android_core_worker.dart @@ -205,7 +205,9 @@ class AndroidCoreWorker { ) async { try { await client.request( - _AddBreadcrumbRequest(normalizeMap(breadcrumb.toJson())!), + _AddBreadcrumbRequest( + normalize(breadcrumb.toJson()) as Map, + ), ); } catch (exception, stackTrace) { internalLogger.error( @@ -233,9 +235,7 @@ class AndroidCoreWorker { Future _clearBreadcrumbsFromWorker(Worker client) async { try { - await client.request( - const _ClearBreadcrumbsRequest(), - ); + await client.request(const _ClearBreadcrumbsRequest()); } catch (exception, stackTrace) { internalLogger.error( 'Android core worker failed to clear breadcrumbs', @@ -263,7 +263,11 @@ class AndroidCoreWorker { Future _setUserFromWorker(Worker client, SentryUser? user) async { try { await client.request( - _SetUserRequest(user == null ? null : normalizeMap(user.toJson())), + _SetUserRequest( + user == null + ? null + : normalize(user.toJson()) as Map, + ), ); } catch (exception, stackTrace) { internalLogger.error( @@ -300,9 +304,7 @@ class AndroidCoreWorker { Object? value, ) async { try { - await client.request( - _SetContextsRequest(key, value), - ); + await client.request(_SetContextsRequest(key, value)); } catch (exception, stackTrace) { internalLogger.error( 'Android core worker failed to set context', @@ -329,9 +331,7 @@ class AndroidCoreWorker { Future _removeContextsFromWorker(Worker client, String key) async { try { - await client.request( - _RemoveContextsRequest(key), - ); + await client.request(_RemoveContextsRequest(key)); } catch (exception, stackTrace) { internalLogger.error( 'Android core worker failed to remove context',