Skip to content

Commit b3b2719

Browse files
buenaflorcodexcursoragent
authored
perf(flutter): Move Android JNI work to core worker to avoid work on main isolate (#3713)
* 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 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent e8f0fc4 commit b3b2719

8 files changed

Lines changed: 1296 additions & 441 deletions

packages/flutter/lib/src/native/java/android_core_worker.dart

Lines changed: 737 additions & 0 deletions
Large diffs are not rendered by default.

packages/flutter/lib/src/native/java/android_envelope_sender.dart

Lines changed: 0 additions & 120 deletions
This file was deleted.

packages/flutter/lib/src/native/java/sentry_native_java.dart

Lines changed: 17 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import '../native_app_start.dart';
1616
import '../sentry_native_channel.dart';
1717
import '../utils/data_normalizer.dart';
1818
import '../utils/utf8_json.dart';
19-
import 'android_envelope_sender.dart';
19+
import 'android_core_worker.dart';
2020
import 'android_replay_recorder.dart';
2121
import 'binding.dart' as native;
2222

@@ -25,14 +25,14 @@ part 'sentry_native_java_init.dart';
2525
@internal
2626
class SentryNativeJava extends SentryNativeChannel {
2727
AndroidReplayRecorder? _replayRecorder;
28-
AndroidEnvelopeSender? _envelopeSender;
28+
AndroidCoreWorker? _coreWorker;
2929
native.ReplayIntegration? _nativeReplay;
3030

3131
SentryNativeJava(super.options) {
32-
// Initialize envelope sender here in the ctor instead of init().
32+
// Initialize core worker here in the ctor instead of init().
3333
// Ensures it starts when autoInitializeNativeSdk is enabled and disabled.
34-
_envelopeSender = AndroidEnvelopeSender.factory(options);
35-
_envelopeSender?.start();
34+
_coreWorker = AndroidCoreWorker.factory(options);
35+
_coreWorker?.start();
3636
}
3737

3838
@override
@@ -58,98 +58,15 @@ class SentryNativeJava extends SentryNativeChannel {
5858
@override
5959
FutureOr<void> captureEnvelope(
6060
Uint8List envelopeData, bool containsUnhandledException) {
61-
_envelopeSender?.captureEnvelope(envelopeData, containsUnhandledException);
61+
_coreWorker?.captureEnvelope(envelopeData, containsUnhandledException);
6262
}
6363

6464
@override
65-
FutureOr<List<DebugImage>?> loadDebugImages(SentryStackTrace stackTrace) {
66-
JSet<JString>? instructionAddressSet;
67-
Set<JString>? instructionAddressJStrings;
68-
JByteArray? imagesUtf8JsonBytes;
69-
70-
try {
71-
final instructionAddresses =
72-
stackTrace.frames.map((f) => f.instructionAddr).nonNulls.toSet();
73-
74-
instructionAddressJStrings =
75-
instructionAddresses.map((s) => s.toJString()).toSet();
76-
77-
instructionAddressSet = instructionAddressJStrings.nonNulls
78-
.cast<JString>()
79-
.toJSet(JString.type);
80-
81-
// Use a single JNI call to get images as UTF-8 encoded JSON instead of
82-
// making multiple JNI calls to convert each object individually. This approach
83-
// is significantly faster because images can be large.
84-
// Local benchmarks show this method is ~4x faster than the alternative
85-
// approach of converting JNI objects to Dart objects one by one.
86-
87-
// NOTE: when instructionAddressSet is empty, loadDebugImagesAsBytes will return
88-
// all debug images as fallback.
89-
imagesUtf8JsonBytes = native.SentryFlutterPlugin.loadDebugImagesAsBytes(
90-
instructionAddressSet);
91-
if (imagesUtf8JsonBytes == null) return null;
92-
93-
final byteRange =
94-
imagesUtf8JsonBytes.getRange(0, imagesUtf8JsonBytes.length);
95-
final bytes = Uint8List.view(
96-
byteRange.buffer, byteRange.offsetInBytes, byteRange.length);
97-
final debugImageMaps = decodeUtf8JsonListOfMaps(bytes);
98-
return debugImageMaps.map(DebugImage.fromJson).toList(growable: false);
99-
} catch (exception, stackTrace) {
100-
internalLogger.error(
101-
'JNI: Failed to load debug images',
102-
error: exception,
103-
stackTrace: stackTrace,
104-
);
105-
if (options.automatedTestMode) {
106-
rethrow;
107-
}
108-
} finally {
109-
// Release JNI refs
110-
for (final js in instructionAddressJStrings ?? const <JString>[]) {
111-
js.release();
112-
}
113-
instructionAddressSet?.release();
114-
imagesUtf8JsonBytes?.release();
115-
}
116-
117-
return null;
118-
}
65+
FutureOr<List<DebugImage>?> loadDebugImages(SentryStackTrace stackTrace) =>
66+
_coreWorker?.loadDebugImages(stackTrace);
11967

12068
@override
121-
FutureOr<Map<String, dynamic>?> loadContexts() {
122-
JByteArray? contextsUtf8JsonBytes;
123-
124-
try {
125-
// Use a single JNI call to get contexts as UTF-8 encoded JSON instead of
126-
// making multiple JNI calls to convert each object individually. This approach
127-
// is significantly faster because contexts can be large and contain many nested
128-
// objects. Local benchmarks show this method is ~4x faster than the alternative
129-
// approach of converting JNI objects to Dart objects one by one.
130-
contextsUtf8JsonBytes = native.SentryFlutterPlugin.loadContextsAsBytes();
131-
if (contextsUtf8JsonBytes == null) return null;
132-
133-
final byteRange =
134-
contextsUtf8JsonBytes.getRange(0, contextsUtf8JsonBytes.length);
135-
final bytes = Uint8List.view(
136-
byteRange.buffer, byteRange.offsetInBytes, byteRange.length);
137-
return decodeUtf8JsonMap(bytes);
138-
} catch (exception, stackTrace) {
139-
internalLogger.error(
140-
'JNI: Failed to load contexts',
141-
error: exception,
142-
stackTrace: stackTrace,
143-
);
144-
if (options.automatedTestMode) {
145-
rethrow;
146-
}
147-
} finally {
148-
contextsUtf8JsonBytes?.release();
149-
}
150-
151-
return null;
152-
}
69+
FutureOr<Map<String, dynamic>?> loadContexts() => _coreWorker?.loadContexts();
15370

15471
@override
15572
int? displayRefreshRate() => tryCatchSync('displayRefreshRate', () {
@@ -198,56 +115,27 @@ class SentryNativeJava extends SentryNativeChannel {
198115
@override
199116
Future<void> close() async {
200117
await _replayRecorder?.stop();
201-
await _envelopeSender?.close();
118+
await _coreWorker?.close();
202119
_setNativeReplay(null);
203120
return super.close();
204121
}
205122

206123
@override
207-
void addBreadcrumb(Breadcrumb breadcrumb) =>
208-
tryCatchSync('addBreadcrumb', () {
209-
using((arena) {
210-
final jBytes = jsonToJByteArray(breadcrumb.toJson())
211-
..releasedBy(arena);
212-
native.SentryFlutterPlugin.addBreadcrumbFromJsonBytes(jBytes);
213-
});
214-
});
124+
FutureOr<void> addBreadcrumb(Breadcrumb breadcrumb) =>
125+
_coreWorker?.addBreadcrumb(breadcrumb);
215126

216127
@override
217-
void clearBreadcrumbs() => tryCatchSync('clearBreadcrumbs', () {
218-
native.Sentry.clearBreadcrumbs();
219-
});
128+
FutureOr<void> clearBreadcrumbs() => _coreWorker?.clearBreadcrumbs();
220129

221130
@override
222-
void setUser(SentryUser? user) => tryCatchSync('setUser', () {
223-
using((arena) {
224-
if (user == null) {
225-
native.SentryFlutterPlugin.setUserFromJsonBytes(null);
226-
} else {
227-
final jBytes = jsonToJByteArray(user.toJson())..releasedBy(arena);
228-
native.SentryFlutterPlugin.setUserFromJsonBytes(jBytes);
229-
}
230-
});
231-
});
131+
FutureOr<void> setUser(SentryUser? user) => _coreWorker?.setUser(user);
232132

233133
@override
234-
void setContexts(String key, value) => tryCatchSync('setContexts', () {
235-
using((arena) {
236-
final jKey = key.toJString()..releasedBy(arena);
237-
final jBytes = jsonToJByteArray(value)..releasedBy(arena);
238-
239-
native.SentryFlutterPlugin.setContextFromJsonBytes(jKey, jBytes);
240-
});
241-
});
134+
FutureOr<void> setContexts(String key, value) =>
135+
_coreWorker?.setContexts(key, value);
242136

243137
@override
244-
void removeContexts(String key) => tryCatchSync('removeContexts', () {
245-
using((arena) {
246-
final jKey = key.toJString()..releasedBy(arena);
247-
248-
native.SentryFlutterPlugin.removeContext(jKey);
249-
});
250-
});
138+
FutureOr<void> removeContexts(String key) => _coreWorker?.removeContexts(key);
251139

252140
@override
253141
void setTag(String key, String value) => tryCatchSync('setTag', () {

packages/flutter/test/native/android_envelope_sender_test.dart renamed to packages/flutter/test/native/android_core_worker_test.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ library;
44
import 'package:flutter_test/flutter_test.dart';
55

66
// ignore: unused_import
7-
import 'android_envelope_sender_test_web.dart'
8-
if (dart.library.io) 'android_envelope_sender_test_real.dart' as actual;
7+
import 'android_core_worker_test_web.dart'
8+
if (dart.library.io) 'android_core_worker_test_real.dart' as actual;
99

1010
void main() => actual.main();

0 commit comments

Comments
 (0)