Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
e4de028
perf(flutter): Optimize Android scope sync
buenaflor May 19, 2026
3640d58
docs(flutter): Document JNI payload guidance
buenaflor May 19, 2026
b85800e
style(flutter): Wrap JNI JSON reader helper
buenaflor May 19, 2026
e15b49d
style(flutter): Apply ktlint when-branch formatting
buenaflor May 19, 2026
7d8ec5f
ref(flutter): Reuse native data normalizer
buenaflor May 19, 2026
b43bb60
fix(flutter): Preserve nulls in Android JSON bridge
buenaflor May 19, 2026
a713631
perf(flutter): Reuse Android JSON deserializers
buenaflor May 19, 2026
c666afd
ref(flutter): Use SDK JSON object reader
buenaflor May 19, 2026
ab3bce0
Merge branch 'main' into perf/android-scope-sync-json
buenaflor May 19, 2026
f9a84b1
docs(flutter): Clarify JSON reader parsing
buenaflor May 19, 2026
0c34206
fix(flutter): Wrap Android primitive contexts
buenaflor May 19, 2026
f4d8991
fix(flutter): Release Android JNI refs
buenaflor May 19, 2026
e2bd84b
test(flutter): Expect wrapped Android contexts
buenaflor May 19, 2026
68412d3
fix(flutter): Release replay JNI refs safely
buenaflor May 19, 2026
f5c65b6
Merge branch 'perf/android-scope-sync-json' into buenaflor/fix/flutte…
buenaflor May 19, 2026
bdc2b8f
ref(flutter): Move Android JNI work to core worker
buenaflor May 19, 2026
696867d
Merge branch 'main' into buenaflor/fix/flutter-android-jni-leaks
buenaflor May 19, 2026
d484926
Merge branch 'buenaflor/fix/flutter-android-jni-leaks' into buenaflor…
buenaflor May 20, 2026
73efad7
Merge main into branch
buenaflor May 21, 2026
310effb
fix(flutter): Harden Android core worker
buenaflor May 26, 2026
716eada
Merge branch 'main' into buenaflor/ref/android-core-worker-fire-forget
buenaflor May 26, 2026
f4d9e97
fix(flutter): Await Android scope worker sync
buenaflor May 26, 2026
b163866
fix(flutter): Serialize Android scope updates
buenaflor May 26, 2026
5d9efbc
fix(flutter): Skip Android worker reads after close
buenaflor May 26, 2026
099cefb
style(flutter): Format Android core worker
buenaflor May 26, 2026
95727b7
ref(flutter): Expand Android worker update helpers
buenaflor May 26, 2026
d010d5e
ref(flutter): Reuse Android scope normalizer
buenaflor May 26, 2026
739f508
ref(flutter): Use normalize for worker payloads
buenaflor May 26, 2026
73a206c
Merge branch 'main' into buenaflor/ref/android-core-worker-fire-forget
buenaflor May 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
737 changes: 737 additions & 0 deletions packages/flutter/lib/src/native/java/android_core_worker.dart

Large diffs are not rendered by default.

120 changes: 0 additions & 120 deletions packages/flutter/lib/src/native/java/android_envelope_sender.dart

This file was deleted.

146 changes: 17 additions & 129 deletions packages/flutter/lib/src/native/java/sentry_native_java.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
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;

Expand All @@ -25,14 +25,14 @@
@internal
class SentryNativeJava extends SentryNativeChannel {
AndroidReplayRecorder? _replayRecorder;
AndroidEnvelopeSender? _envelopeSender;
AndroidCoreWorker? _coreWorker;
native.ReplayIntegration? _nativeReplay;

SentryNativeJava(super.options) {
Comment thread
buenaflor marked this conversation as resolved.
// 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
Expand All @@ -58,98 +58,15 @@
@override
FutureOr<void> captureEnvelope(
Uint8List envelopeData, bool containsUnhandledException) {
_envelopeSender?.captureEnvelope(envelopeData, containsUnhandledException);
_coreWorker?.captureEnvelope(envelopeData, containsUnhandledException);
}

@override
FutureOr<List<DebugImage>?> loadDebugImages(SentryStackTrace stackTrace) {
JSet<JString>? instructionAddressSet;
Set<JString>? 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<JString>()
.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 <JString>[]) {
js.release();
}
instructionAddressSet?.release();
imagesUtf8JsonBytes?.release();
}

return null;
}
FutureOr<List<DebugImage>?> loadDebugImages(SentryStackTrace stackTrace) =>
_coreWorker?.loadDebugImages(stackTrace);

@override
FutureOr<Map<String, dynamic>?> 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) {
Comment thread
sentry[bot] marked this conversation as resolved.
rethrow;
}
} finally {
contextsUtf8JsonBytes?.release();
}

return null;
}
FutureOr<Map<String, dynamic>?> loadContexts() => _coreWorker?.loadContexts();

Check warning on line 69 in packages/flutter/lib/src/native/java/sentry_native_java.dart

View check run for this annotation

@sentry/warden / warden: find-bugs

Shallow map conversion in `_loadContextsFromWorker` may cause TypeError in `Contexts.fromJson` when worker is used

`_loadContextsFromWorker` in `android_core_worker.dart` does only a shallow `Map<String, dynamic>.from(response as Map)` when receiving the contexts payload from the worker isolate. The peer method `_loadDebugImagesFromWorker` explicitly converts each element via `Map<String, dynamic>.from(e)`, indicating the author already treats the isolate boundary as one that can degrade map generic types. After the worker sends the JSON-decoded contexts map back, nested context values (`contexts`, `device`, `app`, etc.) are read by `Contexts.fromJson` via `SentryDevice.fromJson(Map.from(data[SentryDevice.type]))` and similar (contexts.dart:44+). Because `data[key]` is statically `dynamic`, `Map.from(dynamic)` infers `<dynamic, dynamic>`, which then can fail the `Map<String, dynamic>` parameter type of the various `fromJson` constructors. The failure is caught and logged (and only rethrown in `automatedTestMode`), so the practical impact is that native-loaded contexts silently fall back to nothing when the worker is enabled. Fix `_loadContextsFromWorker` to recursively normalize nested maps (e.g. round-trip via `json.encode/decode` or a recursive `Map<String,dynamic>` cast helper), matching the per-element conversion already used for debug images.
Comment thread
buenaflor marked this conversation as resolved.

@override
int? displayRefreshRate() => tryCatchSync('displayRefreshRate', () {
Expand Down Expand Up @@ -198,56 +115,27 @@
@override
Future<void> close() async {
await _replayRecorder?.stop();
await _envelopeSender?.close();
await _coreWorker?.close();
Comment thread
denrase marked this conversation as resolved.
_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<void> addBreadcrumb(Breadcrumb breadcrumb) =>
_coreWorker?.addBreadcrumb(breadcrumb);

@override
void clearBreadcrumbs() => tryCatchSync('clearBreadcrumbs', () {
native.Sentry.clearBreadcrumbs();
});
FutureOr<void> clearBreadcrumbs() => _coreWorker?.clearBreadcrumbs();

@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<void> 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<void> setContexts(String key, value) =>
_coreWorker?.setContexts(key, value);

@override
void removeContexts(String key) => tryCatchSync('removeContexts', () {
using((arena) {
final jKey = key.toJString()..releasedBy(arena);

native.SentryFlutterPlugin.removeContext(jKey);
});
});
FutureOr<void> removeContexts(String key) => _coreWorker?.removeContexts(key);

@override
void setTag(String key, String value) => tryCatchSync('setTag', () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Loading
Loading