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..100e86dd19 --- /dev/null +++ b/packages/flutter/lib/src/native/java/android_core_worker.dart @@ -0,0 +1,737 @@ +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; + } catch (exception, stackTrace) { + internalLogger.error( + 'Failed to start Android core worker', + error: exception, + stackTrace: stackTrace, + ); + } finally { + _startFuture = null; + } + } + + FutureOr close() async { + _isClosed = true; + await _startFuture; + _worker?.close(); + _worker = null; + } + + 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) { + if (_isClosed) return null; + + 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 { + 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() { + if (_isClosed) return null; + + final client = _worker; + if (client == null) { + return _loadContexts(automatedTestMode: _config.automatedTestMode); + } + + return _loadContextsFromWorker(client); + } + + Future?> _loadContextsFromWorker(Worker client) async { + 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; + } + } + + FutureOr addBreadcrumb(Breadcrumb breadcrumb) { + if (_isClosed) return null; + + final client = _worker; + if (client == null) { + _addBreadcrumb( + breadcrumb.toJson(), + automatedTestMode: _config.automatedTestMode, + ); + return null; + } + + return _addBreadcrumbFromWorker(client, breadcrumb); + } + + Future _addBreadcrumbFromWorker( + Worker client, + Breadcrumb breadcrumb, + ) async { + try { + await client.request( + _AddBreadcrumbRequest( + normalize(breadcrumb.toJson()) as Map, + ), + ); + } 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; + + final client = _worker; + if (client == null) { + _clearBreadcrumbs(automatedTestMode: _config.automatedTestMode); + return null; + } + + return _clearBreadcrumbsFromWorker(client); + } + + Future _clearBreadcrumbsFromWorker(Worker client) async { + try { + await client.request(const _ClearBreadcrumbsRequest()); + } 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; + + final client = _worker; + if (client == null) { + _setUser(user?.toJson(), automatedTestMode: _config.automatedTestMode); + return null; + } + + return _setUserFromWorker(client, user); + } + + Future _setUserFromWorker(Worker client, SentryUser? user) async { + try { + await client.request( + _SetUserRequest( + user == null + ? null + : normalize(user.toJson()) as Map, + ), + ); + } 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; + + final normalizedValue = normalize(value); + final client = _worker; + if (client == null) { + _setContexts( + key, + normalizedValue, + automatedTestMode: _config.automatedTestMode, + ); + return null; + } + + return _setContextsFromWorker(client, key, normalizedValue); + } + + Future _setContextsFromWorker( + Worker client, + String key, + Object? value, + ) async { + try { + await client.request(_SetContextsRequest(key, value)); + } 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; + + final client = _worker; + if (client == null) { + _removeContexts(key, automatedTestMode: _config.automatedTestMode); + return null; + } + + return _removeContextsFromWorker(client, key); + } + + Future _removeContextsFromWorker(Worker client, String key) async { + try { + await client.request(_RemoveContextsRequest(key)); + } catch (exception, stackTrace) { + internalLogger.error( + 'Android core worker failed to remove context', + error: exception, + stackTrace: stackTrace, + ); + if (_config.automatedTestMode) { + rethrow; + } + } + } + + 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 _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); + } + }); + + @override + FutureOr onRequest(Object? payload) => _enqueue(() { + switch (payload) { + case _LoadDebugImagesRequest request: + return _loadDebugImageMaps( + request.instructionAddresses, + automatedTestMode: _config.automatedTestMode, + ); + case _LoadContextsRequest _: + return _loadContexts(automatedTestMode: _config.automatedTestMode); + case _AddBreadcrumbRequest request: + _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); + return null; + case _SetContextsRequest request: + _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); + } + }); + + /// Serializes worker actions so JNI calls run in request order. + 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 _ClearBreadcrumbsRequest { + const _ClearBreadcrumbsRequest(); +} + +class _SetUserRequest { + final Map? user; + + const _SetUserRequest(this.user); +} + +class _SetContextsRequest { + final String key; + final Object? value; + + 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; + 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; + final instructionAddressJStrings = []; + JByteArray? imagesUtf8JsonBytes; + + try { + for (final instructionAddress in instructionAddresses) { + instructionAddressJStrings.add(instructionAddress.toJString()); + } + + instructionAddressSet = instructionAddressJStrings.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) { + 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 _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 { + 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(); + } +} + +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(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..f3a9fbd0cd 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,56 +115,27 @@ 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', () { - native.Sentry.clearBreadcrumbs(); - }); + FutureOr 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 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', () { - 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_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..cb00d3704f --- /dev/null +++ b/packages/flutter/test/native/android_core_worker_test_real.dart @@ -0,0 +1,493 @@ +@TestOn('vm') +// ignore_for_file: invalid_use_of_internal_member +library; + +import 'dart:async'; +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('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; + 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('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('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(); + 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('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('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(); + await worker.start(); + + final payload = await fixture.expectPendingRequest( + worker.addBreadcrumb(Breadcrumb(message: 'crumb')), + ); + + expect((payload as dynamic).breadcrumb['message'], 'crumb'); + }); + + test('normalizes breadcrumb update before sending', () async { + final fixture = _Fixture(); + final worker = fixture.getSut(); + await worker.start(); + + final payload = await fixture.expectPendingRequest( + worker.addBreadcrumb(Breadcrumb( + message: 'crumb', + data: {'value': _UnserializableValue()}, + )), + ); + + expect((payload as dynamic).breadcrumb['data'], { + 'value': 'normalized-value', + }); + }); + + 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(); + await worker.start(); + + final payload = await fixture.expectPendingRequest( + worker.setUser(SentryUser(id: 'fixture-user')), + ); + + 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(); + + final payload = await fixture.expectPendingRequest( + worker.setUser(SentryUser( + id: 'fixture-user', + data: {'value': _UnserializableValue()}, + // ignore: deprecated_member_use + extras: {'extra': _UnserializableValue()}, + )), + ); + + final user = (payload as dynamic).user as Map; + expect(user['data'], { + 'value': 'normalized-value', + }); + expect(user['extras'], { + 'extra': 'normalized-value', + }); + }); + + test('sends context update and awaits response', () async { + final fixture = _Fixture(); + final worker = fixture.getSut(); + await worker.start(); + + final payload = await fixture.expectPendingRequest( + worker.setContexts('fixture-key', { + 'nested': {'value': true} + }), + ); + + expect((payload as dynamic).key, 'fixture-key'); + expect((payload as dynamic).value, { + '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'); + }); + }); +} + +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; + } + + void respond(int id, Object? response) { + responsePorts.last.send((id, response)); + } + + void respondError(int id) { + 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); + addTearDown(inbox.close); + + final replies = ReceivePort(); + responsePorts.add(replies.sendPort); + addTearDown(replies.close); + + return Worker(inbox.sendPort, replies); + } +} + +class _UnserializableValue { + @override + String toString() => 'normalized-value'; +} 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..479e9bca19 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,39 @@ 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 + FutureOr clearBreadcrumbs() { + // No-op for testing + } + + @override + void setUser(SentryUser? user) { + // No-op for testing + } + + @override + void setContexts(String key, value) { + // No-op for testing + } + + @override + FutureOr removeContexts(String key) { + // No-op for testing + } }