diff --git a/CHANGELOG.md b/CHANGELOG.md index ca2accebf7..f06f3785e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Android not sending events when `autoInitializedNativeSdk` is disabled ([#3420](https://github.com/getsentry/sentry-dart/pull/3420)) + ## 9.9.1 ### Fixes diff --git a/packages/flutter/lib/src/native/java/android_envelope_sender.dart b/packages/flutter/lib/src/native/java/android_envelope_sender.dart index 344d2e5153..c5bf9669a6 100644 --- a/packages/flutter/lib/src/native/java/android_envelope_sender.dart +++ b/packages/flutter/lib/src/native/java/android_envelope_sender.dart @@ -11,17 +11,19 @@ import '../../utils/internal_logger.dart'; import 'binding.dart' as native; class AndroidEnvelopeSender { - final SentryFlutterOptions _options; final WorkerConfig _config; final SpawnWorkerFn _spawn; + + bool _isClosed = false; Worker? _worker; - AndroidEnvelopeSender(this._options, {SpawnWorkerFn? spawn}) + AndroidEnvelopeSender(SentryOptions options, {SpawnWorkerFn? spawn}) : _config = WorkerConfig( debugName: 'SentryAndroidEnvelopeSender', - debug: _options.debug, - diagnosticLevel: _options.diagnosticLevel, - automatedTestMode: _options.automatedTestMode, + debug: options.debug, + diagnosticLevel: options.diagnosticLevel, + // ignore: invalid_use_of_internal_member + automatedTestMode: options.automatedTestMode, ), _spawn = spawn ?? spawnWorker; @@ -30,30 +32,41 @@ class AndroidEnvelopeSender { AndroidEnvelopeSender.new; FutureOr start() async { + if (_isClosed) return; if (_worker != null) return; - _worker = await _spawn(_config, _entryPoint); + 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) { - _options.log( - SentryLevel.warning, - 'captureEnvelope called before worker started; dropping', + if (client != null) { + client.send(( + TransferableTypedData.fromList([envelopeData]), + containsUnhandledException + )); + } else { + internalLogger.info( + 'captureEnvelope called before worker started: sending envelope in main isolate instead', ); - return; + _captureEnvelope(envelopeData, containsUnhandledException, + automatedTestMode: _config.automatedTestMode); } - client.send(( - TransferableTypedData.fromList([envelopeData]), - containsUnhandledException - )); } static void _entryPoint((SendPort, WorkerConfig) init) { @@ -72,35 +85,36 @@ class _AndroidEnvelopeHandler extends WorkerHandler { if (msg is (TransferableTypedData, bool)) { final (transferable, containsUnhandledException) = msg; final data = transferable.materialize().asUint8List(); - _captureEnvelope(data, containsUnhandledException); + _captureEnvelope(data, containsUnhandledException, + automatedTestMode: _config.automatedTestMode); } else { internalLogger .warning('${_config.debugName}: unexpected message type: $msg'); } } +} - void _captureEnvelope( - Uint8List envelopeData, bool containsUnhandledException) { - JObject? id; - JByteArray? byteArray; - try { - byteArray = JByteArray.from(envelopeData); - id = native.InternalSentrySdk.captureEnvelope( - byteArray, containsUnhandledException); - - if (id == null) { - internalLogger.error( - '${_config.debugName}: native Android SDK returned null when capturing envelope'); - } - } catch (exception, stackTrace) { - internalLogger.error('${_config.debugName}: failed to capture envelope', - error: exception, stackTrace: stackTrace); - if (_config.automatedTestMode) { - rethrow; - } - } finally { - byteArray?.release(); - id?.release(); +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 e2c7a773eb..5f56f35040 100644 --- a/packages/flutter/lib/src/native/java/sentry_native_java.dart +++ b/packages/flutter/lib/src/native/java/sentry_native_java.dart @@ -23,7 +23,12 @@ class SentryNativeJava extends SentryNativeChannel { AndroidEnvelopeSender? _envelopeSender; native.ReplayIntegration? _nativeReplay; - SentryNativeJava(super.options); + SentryNativeJava(super.options) { + // Initialize envelope sender here in the ctor instead of init(). + // Ensures it starts when autoInitializeNativeSdk is enabled and disabled. + _envelopeSender = AndroidEnvelopeSender.factory(options); + _envelopeSender?.start(); + } @override bool get supportsReplay => true; @@ -36,11 +41,8 @@ class SentryNativeJava extends SentryNativeChannel { AndroidReplayRecorder? get testRecorder => _replayRecorder; @override - Future init(Hub hub) async { + void init(Hub hub) { initSentryAndroid(hub: hub, options: options, owner: this); - - _envelopeSender = AndroidEnvelopeSender.factory(options); - await _envelopeSender?.start(); } @override diff --git a/packages/flutter/test/native/android_envelope_sender_test_real.dart b/packages/flutter/test/native/android_envelope_sender_test_real.dart index 64067db2bb..d2b12732a9 100644 --- a/packages/flutter/test/native/android_envelope_sender_test_real.dart +++ b/packages/flutter/test/native/android_envelope_sender_test_real.dart @@ -12,23 +12,31 @@ import 'package:sentry_flutter/src/isolate/isolate_worker.dart'; void main() { group('AndroidEnvelopeSender host behavior', () { - test('warns and drops when not started', () { + test('logs when sending envelopes in main isolate', () { final options = SentryFlutterOptions(); - options.debug = true; - options.diagnosticLevel = SentryLevel.debug; final logs = <(SentryLevel, String)>[]; - options.log = (level, message, {logger, exception, stackTrace}) { - logs.add((level, message)); - }; + 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.warning && + e.$1 == SentryLevel.info && e.$2.contains( - 'captureEnvelope called before worker started; dropping')), + 'captureEnvelope called before worker started: sending envelope in main isolate instead')), isTrue, ); }); @@ -40,30 +48,6 @@ void main() { expect(() => sender.close(), returnsNormally); }); - test('warns and drops after close', () async { - final options = SentryFlutterOptions(); - options.debug = true; - options.diagnosticLevel = SentryLevel.debug; - final logs = <(SentryLevel, String)>[]; - options.log = (level, message, {logger, exception, stackTrace}) { - logs.add((level, message)); - }; - - final sender = AndroidEnvelopeSender(options); - await sender.start(); - sender.close(); - - sender.captureEnvelope(Uint8List.fromList([9]), false); - - expect( - logs.any((e) => - e.$1 == SentryLevel.warning && - e.$2.contains( - 'captureEnvelope called before worker started; dropping')), - isTrue, - ); - }); - test('start is a no-op when already started', () async { final options = SentryFlutterOptions(); options.debug = true; @@ -88,7 +72,7 @@ void main() { spawnCount = 0; await sender.start(); - expect(spawnCount, 1); + expect(spawnCount, 0); // Close twice should be safe. expect(() => sender.close(), returnsNormally); diff --git a/packages/flutter/test/native/android_envelope_sender_test_web.dart b/packages/flutter/test/native/android_envelope_sender_test_web.dart index 6b061ee80a..3a62a53377 100644 --- a/packages/flutter/test/native/android_envelope_sender_test_web.dart +++ b/packages/flutter/test/native/android_envelope_sender_test_web.dart @@ -1,10 +1,5 @@ -// Stub for web - these tests only run on VM -import 'package:flutter_test/flutter_test.dart'; - void main() { - test('Android envelope sender tests are not supported on web', () { - // 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 - // executed on VM platforms. - }); + // 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 + // executed on VM platforms. } diff --git a/packages/flutter/test/native/sentry_native_java_test.dart b/packages/flutter/test/native/sentry_native_java_test.dart index a11e552fa6..5e6285f1b0 100644 --- a/packages/flutter/test/native/sentry_native_java_test.dart +++ b/packages/flutter/test/native/sentry_native_java_test.dart @@ -1,49 +1,2 @@ -@TestOn('vm') -library; - -import 'package:flutter_test/flutter_test.dart'; -import 'sentry_native_java_web_stub.dart' - if (dart.library.io) 'package:sentry_flutter/src/native/java/sentry_native_java.dart'; - -void main() { - // the ReplaySizeAdjustment tests assumes a constant video block size of 16 - group('ReplaySizeAdjustment', () { - test('rounds down when remainder is less than or equal to half block size', - () { - expect(0.0.adjustReplaySizeToBlockSize(), 0.0); - expect(8.0.adjustReplaySizeToBlockSize(), 0.0); - expect(16.0.adjustReplaySizeToBlockSize(), 16.0); - expect(24.0.adjustReplaySizeToBlockSize(), 16.0); - expect(100.0.adjustReplaySizeToBlockSize(), 96.0); - }); - - test('rounds up when remainder is greater than half block size', () { - expect(9.0.adjustReplaySizeToBlockSize(), 16.0); - expect(15.0.adjustReplaySizeToBlockSize(), 16.0); - expect(25.0.adjustReplaySizeToBlockSize(), 32.0); - expect(108.0.adjustReplaySizeToBlockSize(), 112.0); - expect(109.0.adjustReplaySizeToBlockSize(), 112.0); - }); - - test('returns exact value when already multiple of block size', () { - expect(32.0.adjustReplaySizeToBlockSize(), 32.0); - expect(48.0.adjustReplaySizeToBlockSize(), 48.0); - expect(64.0.adjustReplaySizeToBlockSize(), 64.0); - expect(128.0.adjustReplaySizeToBlockSize(), 128.0); - }); - - test('handles edge cases at half block size boundaries', () { - expect(8.0.adjustReplaySizeToBlockSize(), 0.0); - expect(24.0.adjustReplaySizeToBlockSize(), 16.0); - expect(40.0.adjustReplaySizeToBlockSize(), 32.0); - }); - - test('handles fractional values', () { - expect(7.5.adjustReplaySizeToBlockSize(), 0.0); - expect(8.5.adjustReplaySizeToBlockSize(), 16.0); - expect(15.5.adjustReplaySizeToBlockSize(), 16.0); - expect(16.5.adjustReplaySizeToBlockSize(), 16.0); - expect(24.5.adjustReplaySizeToBlockSize(), 32.0); - }); - }); -} +export 'sentry_native_java_test_real.dart' + if (dart.library.js_interop) 'sentry_native_java_test_web.dart'; diff --git a/packages/flutter/test/native/sentry_native_java_test_real.dart b/packages/flutter/test/native/sentry_native_java_test_real.dart new file mode 100644 index 0000000000..d320852b23 --- /dev/null +++ b/packages/flutter/test/native/sentry_native_java_test_real.dart @@ -0,0 +1,108 @@ +@TestOn('vm') +// ignore_for_file: invalid_use_of_internal_member +library; + +import 'dart:async'; +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/sentry_native_java.dart'; + +void main() { + // the ReplaySizeAdjustment tests assumes a constant video block size of 16 + group('ReplaySizeAdjustment', () { + test('rounds down when remainder is less than or equal to half block size', + () { + expect(0.0.adjustReplaySizeToBlockSize(), 0.0); + expect(8.0.adjustReplaySizeToBlockSize(), 0.0); + expect(16.0.adjustReplaySizeToBlockSize(), 16.0); + expect(24.0.adjustReplaySizeToBlockSize(), 16.0); + expect(100.0.adjustReplaySizeToBlockSize(), 96.0); + }); + + test('rounds up when remainder is greater than half block size', () { + expect(9.0.adjustReplaySizeToBlockSize(), 16.0); + expect(15.0.adjustReplaySizeToBlockSize(), 16.0); + expect(25.0.adjustReplaySizeToBlockSize(), 32.0); + expect(108.0.adjustReplaySizeToBlockSize(), 112.0); + expect(109.0.adjustReplaySizeToBlockSize(), 112.0); + }); + + test('returns exact value when already multiple of block size', () { + expect(32.0.adjustReplaySizeToBlockSize(), 32.0); + expect(48.0.adjustReplaySizeToBlockSize(), 48.0); + expect(64.0.adjustReplaySizeToBlockSize(), 64.0); + expect(128.0.adjustReplaySizeToBlockSize(), 128.0); + }); + + test('handles edge cases at half block size boundaries', () { + expect(8.0.adjustReplaySizeToBlockSize(), 0.0); + expect(24.0.adjustReplaySizeToBlockSize(), 16.0); + expect(40.0.adjustReplaySizeToBlockSize(), 32.0); + }); + + test('handles fractional values', () { + expect(7.5.adjustReplaySizeToBlockSize(), 0.0); + expect(8.5.adjustReplaySizeToBlockSize(), 16.0); + expect(15.5.adjustReplaySizeToBlockSize(), 16.0); + expect(16.5.adjustReplaySizeToBlockSize(), 16.0); + expect(24.5.adjustReplaySizeToBlockSize(), 32.0); + }); + }); + + group('EnvelopeSender initialization', () { + late AndroidEnvelopeSender Function(SentryFlutterOptions) originalFactory; + + setUp(() { + originalFactory = AndroidEnvelopeSender.factory; + }); + + tearDown(() { + AndroidEnvelopeSender.factory = originalFactory; + }); + + test('starts envelope sender in constructor', () { + var factoryCalled = false; + var startCalled = false; + + AndroidEnvelopeSender.factory = (options) { + factoryCalled = true; + return _FakeEnvelopeSender(onStart: () => startCalled = true); + }; + + final options = + SentryFlutterOptions(dsn: 'https://abc@def.ingest.sentry.io/1234567'); + SentryNativeJava(options); + + expect(factoryCalled, isTrue, + reason: 'Factory should be called during construction'); + expect(startCalled, isTrue, + reason: 'start() should be called during construction'); + }); + }); +} + +/// Fake envelope sender for testing that tracks method calls. +class _FakeEnvelopeSender implements AndroidEnvelopeSender { + final void Function()? onStart; + + _FakeEnvelopeSender({this.onStart}); + + @override + FutureOr start() { + onStart?.call(); + } + + @override + FutureOr close() { + // No-op for testing + } + + @override + void captureEnvelope( + Uint8List envelopeData, bool containsUnhandledException) { + // No-op for testing + } +} diff --git a/packages/flutter/test/native/sentry_native_java_test_web.dart b/packages/flutter/test/native/sentry_native_java_test_web.dart new file mode 100644 index 0000000000..e077692e8a --- /dev/null +++ b/packages/flutter/test/native/sentry_native_java_test_web.dart @@ -0,0 +1,5 @@ +void main() { + // This test file exists only to satisfy the compiler when running web tests. + // The actual tests in sentry_native_java_test_real.dart are only + // executed on VM platforms. +} diff --git a/packages/flutter/test/sentry_native_channel_test.dart b/packages/flutter/test/sentry_native_channel_test.dart index cc634c4016..3d2a590ea1 100644 --- a/packages/flutter/test/sentry_native_channel_test.dart +++ b/packages/flutter/test/sentry_native_channel_test.dart @@ -328,9 +328,11 @@ void main() { test('captureEnvelope', () async { if (mockPlatform.isAndroid) { + final matcher = _nativeUnavailableMatcher(); + final data = Uint8List.fromList([1, 2, 3]); - await sut.captureEnvelope(data, false); + expect(() => sut.captureEnvelope(data, false), matcher); verifyZeroInteractions(channel); } else {