fix(flutter): Release Android JNI refs#3712
Conversation
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 <[email protected]> Co-authored-by: Cursor <[email protected]>
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 <[email protected]> Co-authored-by: Cursor <[email protected]>
Keep the Android JSON reader helper within ktlint formatting limits after adding the scope sync byte-array bridge. Co-Authored-By: GPT-5.5 <[email protected]> Co-authored-by: Cursor <[email protected]>
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 <[email protected]> Co-authored-by: Cursor <[email protected]>
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 <[email protected]> Co-authored-by: Cursor <[email protected]>
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 <[email protected]> Co-authored-by: Cursor <[email protected]>
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 <[email protected]> Co-authored-by: Cursor <[email protected]>
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 <[email protected]> Co-authored-by: Cursor <[email protected]>
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 <[email protected]> Co-authored-by: Cursor <[email protected]>
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 <[email protected]> Co-authored-by: Cursor <[email protected]>
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 <[email protected]> Co-authored-by: Cursor <[email protected]>
Semver Impact of This PR🟢 Patch (bug fixes) 📋 Changelog PreviewThis is how your changes will appear in the changelog. FixesDart
Flutter
Enhancements
Internal Changes
🤖 This preview updates automatically when you update the PR. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #3712 +/- ##
==========================================
+ Coverage 85.07% 91.91% +6.83%
==========================================
Files 238 105 -133
Lines 8485 3737 -4748
==========================================
- Hits 7219 3435 -3784
+ Misses 1266 302 -964
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. |
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 <[email protected]> Co-authored-by: Cursor <[email protected]>
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 <[email protected]> Co-authored-by: Cursor <[email protected]>
There was a problem hiding this comment.
Pull request overview
This PR tightens JNI reference cleanup in the Android-specific Flutter paths to prevent global ref retention while replay and debug-image loading are active. It deduplicates instruction addresses before allocating JString refs, switches the rrweb privacy-option mutation to explicit arena-managed handles (replacing removeWhere with a manual iterator so each materialized key/value can be released), and releases the replay callback's hint reference once the callback finishes.
Changes:
- In
loadDebugImages, dedupe instruction-address strings before converting them toJString, so duplicate addresses don't produce duplicate JNI refs that all need to be released. - In
createBeforeSendReplayCallback, route every intermediate JNI object (replayRecording, payloaddata,optionsEvent, options payload map, key iterator and current keys, removed values, andjMap) through the existingarena. - Wrap the callback body in
try/finallysohint.release()is always invoked after processing.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| packages/flutter/lib/src/native/java/sentry_native_java.dart | Deduplicate instruction addresses before allocating JString refs in loadDebugImages. |
| packages/flutter/lib/src/native/java/sentry_native_java_init.dart | Arena-release intermediate replay JNI handles and release the hint ref in a finally block in the BeforeSendReplay callback. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…r-android-jni-leaks
Android Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| fec56a1 | 370.66 ms | 369.28 ms | -1.38 ms |
| ea06d23 | 371.10 ms | 358.56 ms | -12.54 ms |
| dbd526b | 504.88 ms | 569.02 ms | 64.15 ms |
| 2cf9161 | 454.12 ms | 512.67 ms | 58.55 ms |
| 640ad0c | 466.00 ms | 552.67 ms | 86.67 ms |
| 79f6b41 | 469.66 ms | 525.90 ms | 56.24 ms |
| 40751bf | 421.09 ms | 424.82 ms | 3.73 ms |
| 13b8895 | 372.91 ms | 370.13 ms | -2.79 ms |
| cba2765 | 405.98 ms | 422.02 ms | 16.04 ms |
| a920b0f | 391.75 ms | 378.98 ms | -12.77 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| fec56a1 | 14.31 MiB | 15.49 MiB | 1.19 MiB |
| ea06d23 | 13.93 MiB | 15.18 MiB | 1.25 MiB |
| dbd526b | 6.54 MiB | 7.69 MiB | 1.15 MiB |
| 2cf9161 | 6.54 MiB | 7.70 MiB | 1.16 MiB |
| 640ad0c | 6.54 MiB | 7.69 MiB | 1.15 MiB |
| 79f6b41 | 6.54 MiB | 7.69 MiB | 1.15 MiB |
| 40751bf | 14.31 MiB | 15.49 MiB | 1.19 MiB |
| 13b8895 | 13.93 MiB | 15.18 MiB | 1.25 MiB |
| cba2765 | 13.93 MiB | 15.18 MiB | 1.25 MiB |
| a920b0f | 14.09 MiB | 15.28 MiB | 1.19 MiB |
Previous results on branch: buenaflor/fix/flutter-android-jni-leaks
Startup times
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| f4d8991 | 351.66 ms | 342.51 ms | -9.15 ms |
| 68412d3 | 430.18 ms | 442.46 ms | 12.27 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| f4d8991 | 14.55 MiB | 15.87 MiB | 1.31 MiB |
| 68412d3 | 14.55 MiB | 15.87 MiB | 1.31 MiB |
iOS Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 79df88f | 1255.16 ms | 1263.51 ms | 8.35 ms |
| 765aa8b | 1259.09 ms | 1269.90 ms | 10.82 ms |
| 7cfee3b | 1260.90 ms | 1273.14 ms | 12.24 ms |
| dc53d48 | 1252.06 ms | 1255.61 ms | 3.55 ms |
| 027fcfd | 1239.04 ms | 1243.65 ms | 4.61 ms |
| 584a4b7 | 1235.62 ms | 1233.62 ms | -2.00 ms |
| 396cb30 | 1265.06 ms | 1265.73 ms | 0.67 ms |
| e1ab497 | 1260.92 ms | 1260.22 ms | -0.69 ms |
| a920b0f | 1254.67 ms | 1261.62 ms | 6.95 ms |
| 640ad0c | 1241.04 ms | 1253.96 ms | 12.92 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 79df88f | 5.83 MiB | 6.28 MiB | 461.20 KiB |
| 765aa8b | 7.86 MiB | 9.44 MiB | 1.58 MiB |
| 7cfee3b | 20.70 MiB | 22.46 MiB | 1.75 MiB |
| dc53d48 | 5.65 MiB | 6.09 MiB | 446.96 KiB |
| 027fcfd | 5.73 MiB | 6.17 MiB | 455.48 KiB |
| 584a4b7 | 5.66 MiB | 6.10 MiB | 451.31 KiB |
| 396cb30 | 5.53 MiB | 6.01 MiB | 488.17 KiB |
| e1ab497 | 5.53 MiB | 6.01 MiB | 487.96 KiB |
| a920b0f | 5.66 MiB | 6.10 MiB | 453.28 KiB |
| 640ad0c | 7.86 MiB | 9.44 MiB | 1.58 MiB |
Previous results on branch: buenaflor/fix/flutter-android-jni-leaks
Startup times
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 68412d3 | 1235.45 ms | 1240.79 ms | 5.34 ms |
| f4d8991 | 1246.58 ms | 1257.05 ms | 10.47 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 68412d3 | 5.83 MiB | 6.28 MiB | 461.19 KiB |
| f4d8991 | 5.83 MiB | 6.28 MiB | 461.20 KiB |
| final instructionAddresses = | ||
| stackTrace.frames.map((f) => f.instructionAddr).nonNulls.toSet(); | ||
|
|
||
| instructionAddressJStrings = | ||
| instructionAddresses.map((s) => s.toJString()).toSet(); |
There was a problem hiding this comment.
dedupe before transforming to java string (micro optimization)
| 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 jMap = dartToJMap(options.privacy.toJson()); | ||
| payload?.addAll(jMap); | ||
| jMap.release(); | ||
| } | ||
| }); | ||
| return sentryReplayEvent; | ||
| final keys = payload.keys..releasedBy(arena); | ||
| final iterator = keys.iterator..releasedBy(arena); | ||
| final keysToRemove = <JString>[]; | ||
| 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()) | ||
| ..releasedBy(arena); | ||
| payload.addAll(jMap); | ||
| } | ||
| }); | ||
| return sentryReplayEvent; | ||
| } finally { | ||
| hint.release(); |
There was a problem hiding this comment.
release hint and consolidate to releasing via the arena
📜 Description
Release Android JNI refs in the Flutter replay and load debug image paths
This releases the replay
Hintcallback object after processing replay privacy options, releases the intermediate replay recording through the existing arena, and dedupes debug-image instruction addresses before creatingJStringrefs.💡 Motivation and Context
These paths can otherwise retain JNI global refs longer than needed while Android replay and debug-image loading are active.
Closes #3696
💚 How did you test it?
fvm dart format lib/src/native/java/sentry_native_java.dart lib/src/native/java/sentry_native_java_init.dartfvm flutter analyze lib/src/native/java/sentry_native_java.dart lib/src/native/java/sentry_native_java_init.dartfvm flutter test test/native/sentry_native_java_test.dartTried
fvm flutter test -d emulator-5554 example/integration_test/native_jni_utils_test.dart, but it fails to compile in the current device-test context becausepackage:testis unavailable for that target.📝 Checklist
sendDefaultPiiis enabled🔮 Next steps
Confirm the Android JNI integration test setup separately if reviewers want device-level coverage for these helpers.
Made with Cursor