diff --git a/plugins/native_dio_adapter/CHANGELOG.md b/plugins/native_dio_adapter/CHANGELOG.md index 6fb04c3c9..7727e8020 100644 --- a/plugins/native_dio_adapter/CHANGELOG.md +++ b/plugins/native_dio_adapter/CHANGELOG.md @@ -2,7 +2,15 @@ ## Unreleased -*None.* +- Add opt-in `createFallbackAdapter` to `NativeAdapter`. On Android, when the + device has installed Cronet providers but every provider is disabled (for + example AOSP emulators or devices without Google Play services), the + supplied factory returns any `HttpClientAdapter` and requests continue via + that adapter. Detection is strictly limited to Cronet's + provider-disabled `RuntimeException`; every other Cronet error is + propagated unchanged. Adapter selection is sticky for the lifetime of the + `NativeAdapter`. Fixes + [#2444](https://github.com/cfug/dio/issues/2444). ## 1.6.0 diff --git a/plugins/native_dio_adapter/README.md b/plugins/native_dio_adapter/README.md index 8992aa7cf..fa7260df8 100644 --- a/plugins/native_dio_adapter/README.md +++ b/plugins/native_dio_adapter/README.md @@ -41,6 +41,40 @@ final dioClient = Dio(); dioClient.httpClientAdapter = NativeAdapter(); ``` +### Opt-in Cronet provider fallback (Android) + +On Android, `NativeAdapter` uses Cronet. Some devices — for example, AOSP +emulators or devices without Google Play services — install Cronet providers +but leave every provider disabled. On those devices `CronetEngine.build()` +throws and every request fails +([issue #2444](https://github.com/cfug/dio/issues/2444)). + +If your application needs to support that environment, pass an opt-in +`createFallbackAdapter`. The factory is invoked **only** when the provider is +known to be disabled and lets you choose any `HttpClientAdapter`: + +```dart +import 'package:dio/io.dart'; +import 'package:native_dio_adapter/native_dio_adapter.dart'; + +dioClient.httpClientAdapter = NativeAdapter( + createFallbackAdapter: (error, stackTrace) => IOHttpClientAdapter(), +); +``` + +Notes: + +- Detection is limited to Cronet's provider-disabled `RuntimeException`. + Connection, TLS, timeout, redirect, cancellation, and response-stream + errors remain Cronet errors and are propagated unchanged. +- The selection is sticky for the lifetime of the `NativeAdapter`. Once + Cronet is picked, later requests do not probe again; once the fallback is + picked, later requests reuse it. +- Changing adapters can change observable networking behavior: TLS + configuration, proxy handling, cookie storage, supported protocols + (HTTP/2, HTTP/3), and connection pooling. Callers opting in own that + tradeoff — pick the adapter that best matches your requirements. + ### Use embedded Cronet Starting from `cronet_http` v1.2.0, diff --git a/plugins/native_dio_adapter/lib/native_dio_adapter.dart b/plugins/native_dio_adapter/lib/native_dio_adapter.dart index 61b82877a..7be4d9915 100644 --- a/plugins/native_dio_adapter/lib/native_dio_adapter.dart +++ b/plugins/native_dio_adapter/lib/native_dio_adapter.dart @@ -5,5 +5,6 @@ export 'package:cupertino_http/cupertino_http.dart'; export 'src/conversion_layer_adapter.dart'; export 'src/cronet_adapter.dart'; +export 'src/cronet_fallback_adapter.dart' show CreateFallbackAdapter; export 'src/cupertino_adapter.dart'; export 'src/native_adapter.dart'; diff --git a/plugins/native_dio_adapter/lib/src/cronet_fallback_adapter.dart b/plugins/native_dio_adapter/lib/src/cronet_fallback_adapter.dart new file mode 100644 index 000000000..18a3e3008 --- /dev/null +++ b/plugins/native_dio_adapter/lib/src/cronet_fallback_adapter.dart @@ -0,0 +1,131 @@ +import 'dart:typed_data' show Uint8List; + +import 'package:cronet_http/cronet_http.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:jni/jni.dart' show JniException; + +import 'cronet_adapter.dart'; + +/// Signature for building the fallback [HttpClientAdapter] used when the +/// default Cronet provider is unavailable on the current device. +/// +/// See [NativeAdapter.new] and the package README for the intended contract. +typedef CreateFallbackAdapter = HttpClientAdapter Function( + Object error, + StackTrace stackTrace, +); + +/// Exact `RuntimeException` message thrown by Chromium's Cronet API when +/// every registered `CronetProvider` on the device is disabled. +/// +/// Chromium's provider-selection branch throws this exact `RuntimeException`: +/// https://chromium.googlesource.com/chromium/src/+/lkgr/components/cronet/android/api/src/org/chromium/net/CronetEngine.java +/// +/// The referenced `CronetEngine.Builder.getPreferredCronetProvider` branch +/// throws this message when providers exist but all are disabled. It is +/// distinct from the separate "Unable to find any Cronet provider" error, +/// which reports that no provider was discovered at all. +const cronetProvidersDisabledMessage = + 'java.lang.RuntimeException: All available Cronet providers are disabled. ' + 'A provider should be enabled before it can be used.'; + +/// Classifies the failure that indicates all installed Cronet providers on +/// the device are disabled. +/// +/// `contains` is intentional: [JniException.message] also includes the Java +/// stack trace appended to the message. Do not broaden the predicate to all +/// [JniException]s or all engine-initialization failures. +bool isCronetProviderUnavailable(Object error) => + error is JniException && + error.message.contains(cronetProvidersDisabledMessage); + +/// Builds the Cronet-backed [HttpClientAdapter] to use when Cronet is +/// available. May throw when the underlying Cronet provider is disabled or +/// otherwise unavailable. +typedef BuildCronetAdapter = HttpClientAdapter Function(); + +/// Android-only lazy adapter selection that either uses a [CronetAdapter] or, +/// if the default Cronet provider is known to be unavailable on the device, +/// a caller-supplied fallback [HttpClientAdapter]. +/// +/// The choice is sticky for the lifetime of this instance: once made, later +/// requests do not probe Cronet again. This wrapper is created only when +/// [NativeAdapter] is opted-in via `createFallbackAdapter`. +class CronetWithFallbackAdapter implements HttpClientAdapter { + /// Production constructor used by [NativeAdapter]. + /// + /// The "build the Cronet path" step is invoked lazily on the first + /// [fetch] call: it synchronously creates a [CronetEngine] so that the + /// provider-disabled failure surfaces here, before the request is + /// delegated to any adapter. When [createCronetEngine] or + /// [androidCronetEngine] is supplied, the caller-provided engine is used; + /// otherwise [CronetEngine.build] is invoked. + CronetWithFallbackAdapter({ + required CronetEngine Function()? createCronetEngine, + required CronetEngine? androidCronetEngine, + required CreateFallbackAdapter createFallbackAdapter, + }) : _buildCronetAdapter = (() { + final engine = createCronetEngine?.call() ?? + androidCronetEngine ?? + CronetEngine.build(); + return CronetAdapter(engine); + }), + _createFallbackAdapter = createFallbackAdapter; + + /// Test-only constructor: lets a test inject a controllable "build cronet + /// adapter" seam without linking real native Cronet code. Not part of the + /// public API. + @visibleForTesting + CronetWithFallbackAdapter.forTesting({ + required BuildCronetAdapter buildCronetAdapter, + required CreateFallbackAdapter createFallbackAdapter, + }) : _buildCronetAdapter = buildCronetAdapter, + _createFallbackAdapter = createFallbackAdapter; + + final BuildCronetAdapter _buildCronetAdapter; + final CreateFallbackAdapter _createFallbackAdapter; + + HttpClientAdapter? _selected; + bool _closed = false; + + /// The adapter chosen for this instance, or `null` if selection has not + /// happened yet. Visible for tests. + @visibleForTesting + HttpClientAdapter? get selectedAdapter => _selected; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) { + final adapter = _selectAdapter(); + return adapter.fetch(options, requestStream, cancelFuture); + } + + @override + void close({bool force = false}) { + if (_closed) { + return; + } + _closed = true; + // If no request was ever made, do NOT initialize Cronet just to close it. + _selected?.close(force: force); + } + + HttpClientAdapter _selectAdapter() { + final existing = _selected; + if (existing != null) { + return existing; + } + try { + return _selected = _buildCronetAdapter(); + } catch (error, stackTrace) { + if (isCronetProviderUnavailable(error)) { + return _selected = _createFallbackAdapter(error, stackTrace); + } + rethrow; + } + } +} diff --git a/plugins/native_dio_adapter/lib/src/native_adapter.dart b/plugins/native_dio_adapter/lib/src/native_adapter.dart index 3ff8527f1..381d5fa30 100644 --- a/plugins/native_dio_adapter/lib/src/native_adapter.dart +++ b/plugins/native_dio_adapter/lib/src/native_adapter.dart @@ -6,6 +6,7 @@ import 'package:cupertino_http/cupertino_http.dart'; import 'package:dio/dio.dart'; import 'cronet_adapter.dart'; +import 'cronet_fallback_adapter.dart'; import 'cupertino_adapter.dart'; /// A [HttpClientAdapter] for Dio which delegates HTTP requests @@ -17,9 +18,49 @@ import 'cupertino_adapter.dart'; /// On Android this uses [cronet_http](https://pub.dev/packages/cronet_http) to /// make HTTP requests. class NativeAdapter implements HttpClientAdapter { + /// Creates a [NativeAdapter]. + /// + /// {@template native_dio_adapter.NativeAdapter.createFallbackAdapter} + /// [createFallbackAdapter] is an **opt-in** fallback for Android devices on + /// which every installed Cronet provider is disabled (for example, AOSP + /// emulators or devices without Google Play services, see + /// [issue #2444](https://github.com/cfug/dio/issues/2444)). It is invoked + /// **only** when Cronet reports that all providers are disabled; every other + /// error — including connection, TLS, timeout, redirect, cancellation, and + /// response-stream errors — remains a Cronet error and is propagated + /// unchanged. + /// + /// The factory returns any [HttpClientAdapter]. This lets callers choose an + /// adapter that matches their TLS, proxy, cookie, transport, and + /// observability requirements (for example, `IOHttpClientAdapter` from + /// `package:dio` or a custom adapter). Note: switching adapters can change + /// observable networking behavior — TLS configuration, proxy handling, + /// cookies, supported protocols, connection pooling, etc. Callers opting in + /// own that tradeoff. + /// + /// Detection happens synchronously before the first request is delegated. + /// After that, the selection is sticky for the lifetime of this + /// [NativeAdapter]; later requests do not probe Cronet again. Closing this + /// [NativeAdapter] before any request is made does **not** initialize + /// Cronet or create the fallback. + /// + /// Defaults to `null`. When omitted, [NativeAdapter] continues to use + /// Cronet and propagates initialization errors exactly as it did before. + /// This factory is only consulted on Android; it is ignored on other + /// platforms. + /// + /// Example: + /// + /// ```dart + /// NativeAdapter( + /// createFallbackAdapter: (error, stackTrace) => IOHttpClientAdapter(), + /// ) + /// ``` + /// {@endtemplate} NativeAdapter({ CronetEngine Function()? createCronetEngine, URLSessionConfiguration Function()? createCupertinoConfiguration, + CreateFallbackAdapter? createFallbackAdapter, @Deprecated( 'Use createCronetEngine instead. ' 'This will cause platform exception on iOS/macOS platforms. ' @@ -34,9 +75,17 @@ class NativeAdapter implements HttpClientAdapter { URLSessionConfiguration? cupertinoConfiguration, }) { if (Platform.isAndroid) { - _adapter = CronetAdapter( - createCronetEngine?.call() ?? androidCronetEngine, - ); + if (createFallbackAdapter != null) { + _adapter = CronetWithFallbackAdapter( + createCronetEngine: createCronetEngine, + androidCronetEngine: androidCronetEngine, + createFallbackAdapter: createFallbackAdapter, + ); + } else { + _adapter = CronetAdapter( + createCronetEngine?.call() ?? androidCronetEngine, + ); + } } else if (Platform.isIOS || Platform.isMacOS) { _adapter = CupertinoAdapter( createCupertinoConfiguration?.call() ?? diff --git a/plugins/native_dio_adapter/pubspec.yaml b/plugins/native_dio_adapter/pubspec.yaml index afffeeabe..bc1b725f5 100644 --- a/plugins/native_dio_adapter/pubspec.yaml +++ b/plugins/native_dio_adapter/pubspec.yaml @@ -24,6 +24,10 @@ dependencies: cupertino_http: '>=2.3.0 <4.0.0' cronet_http: ^1.5.0 http: ^1.5.0 + # Direct dependency for `JniException`, used to classify the + # Cronet-provider-disabled failure surfaced by `CronetEngine.build()`. + # The constraint tracks the version resolved by `cronet_http: ^1.5.0`. + jni: ^0.14.0 dev_dependencies: lints: ^2.0.0 diff --git a/plugins/native_dio_adapter/test/cronet_fallback_adapter_test.dart b/plugins/native_dio_adapter/test/cronet_fallback_adapter_test.dart new file mode 100644 index 000000000..9d62284e7 --- /dev/null +++ b/plugins/native_dio_adapter/test/cronet_fallback_adapter_test.dart @@ -0,0 +1,352 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jni/jni.dart' show JniException; +import 'package:native_dio_adapter/src/cronet_fallback_adapter.dart'; + +/// A minimal recording [HttpClientAdapter] used to observe what the fallback +/// wrapper delegates to it. +class _RecordingAdapter implements HttpClientAdapter { + int fetchCallCount = 0; + int closeCallCount = 0; + bool lastCloseForce = false; + + RequestOptions? lastOptions; + Stream? lastRequestStream; + Future? lastCancelFuture; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + fetchCallCount += 1; + lastOptions = options; + lastRequestStream = requestStream; + lastCancelFuture = cancelFuture; + return ResponseBody.fromString('', 200); + } + + @override + void close({bool force = false}) { + closeCallCount += 1; + lastCloseForce = force; + } +} + +class _ThrowOnFetchAdapter implements HttpClientAdapter { + _ThrowOnFetchAdapter({required this.error}); + + final Object error; + int closeCallCount = 0; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + throw error; + } + + @override + void close({bool force = false}) { + closeCallCount += 1; + } +} + +JniException _providerDisabledException() => JniException( + // Real JniException.message includes the throwable string followed by + // the Java stack trace; the classifier relies on `contains`. + '$cronetProvidersDisabledMessage\n' + '\tat org.chromium.net.CronetEngine\$Builder.build(CronetEngine.java:123)\n' + '\tat org.chromium.net.CronetProvider.createBuilder(CronetProvider.java:45)', + 'stack from java', + ); + +void main() { + group('isCronetProviderUnavailable', () { + test('matches the provider-disabled message including trailing stack', () { + expect( + isCronetProviderUnavailable(_providerDisabledException()), + isTrue, + ); + }); + + test('does not match a different JniException message', () { + final other = JniException( + 'java.lang.RuntimeException: Unable to find any Cronet provider.\n' + '\tat org.chromium.net.CronetEngine.build(CronetEngine.java:200)', + 'stack from java', + ); + expect(isCronetProviderUnavailable(other), isFalse); + }); + + test('does not match a non-JniException carrying the same text', () { + final wrong = StateError(cronetProvidersDisabledMessage); + expect(isCronetProviderUnavailable(wrong), isFalse); + }); + + test('does not match a JniException with the wrong exception class', () { + // Different Java throwable type with a similar-looking message must + // NOT match; the classifier requires the full RuntimeException prefix. + final wrong = JniException( + 'java.lang.IllegalStateException: All available Cronet providers are ' + 'disabled. A provider should be enabled before it can be used.', + 'stack', + ); + expect(isCronetProviderUnavailable(wrong), isFalse); + }); + }); + + group('CronetWithFallbackAdapter', () { + test( + 'classified error triggers the fallback factory exactly once and ' + 'forwards the unchanged request, body, and cancel future', () async { + final fallback = _RecordingAdapter(); + var factoryCallCount = 0; + Object? seenError; + StackTrace? seenStack; + + final wrapper = CronetWithFallbackAdapter.forTesting( + buildCronetAdapter: () => throw _providerDisabledException(), + createFallbackAdapter: (error, stack) { + factoryCallCount += 1; + seenError = error; + seenStack = stack; + return fallback; + }, + ); + + final requestStream = Stream.fromIterable( + [ + Uint8List.fromList([1, 2, 3]), + ], + ); + final cancelCompleter = Completer(); + + final options = RequestOptions( + path: 'https://example.com/first', + method: 'POST', + ); + + final response = await wrapper.fetch( + options, + requestStream, + cancelCompleter.future, + ); + // Drain the response so the returned Future/stream doesn't dangle. + await response.stream.drain(); + + expect(factoryCallCount, 1); + expect(seenError, isA()); + expect(seenStack, isNotNull); + expect(fallback.fetchCallCount, 1); + expect(identical(fallback.lastOptions, options), isTrue); + expect(identical(fallback.lastRequestStream, requestStream), isTrue); + expect( + identical(fallback.lastCancelFuture, cancelCompleter.future), + isTrue, + ); + expect(identical(wrapper.selectedAdapter, fallback), isTrue); + }); + + test('non-matching JniException is rethrown and no fallback is created', + () async { + var fallbackCreated = 0; + final nonMatching = JniException( + 'java.lang.IllegalArgumentException: bad config', + 'stack', + ); + final wrapper = CronetWithFallbackAdapter.forTesting( + buildCronetAdapter: () => throw nonMatching, + createFallbackAdapter: (_, __) { + fallbackCreated += 1; + return _RecordingAdapter(); + }, + ); + + await expectLater( + () => wrapper.fetch( + RequestOptions(path: 'https://example.com'), + null, + null, + ), + throwsA(isA()), + ); + expect(fallbackCreated, 0); + expect(wrapper.selectedAdapter, isNull); + }); + + test('ArgumentError during cronet build is rethrown, not fallen back', + () async { + var fallbackCreated = 0; + final wrapper = CronetWithFallbackAdapter.forTesting( + buildCronetAdapter: () => throw ArgumentError('bad'), + createFallbackAdapter: (_, __) { + fallbackCreated += 1; + return _RecordingAdapter(); + }, + ); + + await expectLater( + () => wrapper.fetch( + RequestOptions(path: 'https://example.com'), + null, + null, + ), + throwsA(isA()), + ); + expect(fallbackCreated, 0); + expect(wrapper.selectedAdapter, isNull); + }); + + test( + 'errors thrown by the successfully-built Cronet adapter do NOT ' + 'trigger a fallback (post-init connection/TLS/timeout errors ' + 'remain Cronet errors)', () async { + final cronet = _ThrowOnFetchAdapter( + error: StateError('post-init connection reset'), + ); + var fallbackCreated = 0; + final wrapper = CronetWithFallbackAdapter.forTesting( + buildCronetAdapter: () => cronet, + createFallbackAdapter: (_, __) { + fallbackCreated += 1; + return _RecordingAdapter(); + }, + ); + + await expectLater( + () => wrapper.fetch( + RequestOptions(path: 'https://example.com'), + null, + null, + ), + throwsA(isA()), + ); + expect(fallbackCreated, 0); + expect(identical(wrapper.selectedAdapter, cronet), isTrue); + }); + + test('selected fallback is reused across subsequent requests', () async { + final fallback = _RecordingAdapter(); + var factoryCallCount = 0; + final wrapper = CronetWithFallbackAdapter.forTesting( + buildCronetAdapter: () => throw _providerDisabledException(), + createFallbackAdapter: (_, __) { + factoryCallCount += 1; + return fallback; + }, + ); + + await (await wrapper.fetch( + RequestOptions(path: 'https://example.com/one'), + null, + null, + )) + .stream + .drain(); + await (await wrapper.fetch( + RequestOptions(path: 'https://example.com/two'), + null, + null, + )) + .stream + .drain(); + await (await wrapper.fetch( + RequestOptions(path: 'https://example.com/three'), + null, + null, + )) + .stream + .drain(); + + expect(factoryCallCount, 1); + expect(fallback.fetchCallCount, 3); + }); + + test('selected Cronet adapter is reused across subsequent requests', + () async { + final cronet = _RecordingAdapter(); + var buildCount = 0; + final wrapper = CronetWithFallbackAdapter.forTesting( + buildCronetAdapter: () { + buildCount += 1; + return cronet; + }, + createFallbackAdapter: (_, __) => throw StateError( + 'fallback must not be created when Cronet initialization succeeded', + ), + ); + + await (await wrapper.fetch( + RequestOptions(path: 'https://example.com/one'), + null, + null, + )) + .stream + .drain(); + await (await wrapper.fetch( + RequestOptions(path: 'https://example.com/two'), + null, + null, + )) + .stream + .drain(); + + expect(buildCount, 1); + expect(cronet.fetchCallCount, 2); + expect(identical(wrapper.selectedAdapter, cronet), isTrue); + }); + + test('close after selection closes the selected adapter exactly once', + () async { + final fallback = _RecordingAdapter(); + final wrapper = CronetWithFallbackAdapter.forTesting( + buildCronetAdapter: () => throw _providerDisabledException(), + createFallbackAdapter: (_, __) => fallback, + ); + + await (await wrapper.fetch( + RequestOptions(path: 'https://example.com/one'), + null, + null, + )) + .stream + .drain(); + + wrapper.close(force: true); + wrapper.close(); + + expect(fallback.closeCallCount, 1); + expect(fallback.lastCloseForce, isTrue); + }); + + test( + 'close before any request does NOT invoke the build seam or create ' + 'a fallback', () async { + var buildInvoked = false; + var fallbackCreated = false; + final wrapper = CronetWithFallbackAdapter.forTesting( + buildCronetAdapter: () { + buildInvoked = true; + return _RecordingAdapter(); + }, + createFallbackAdapter: (_, __) { + fallbackCreated = true; + return _RecordingAdapter(); + }, + ); + + wrapper.close(); + + expect(buildInvoked, isFalse); + expect(fallbackCreated, isFalse); + expect(wrapper.selectedAdapter, isNull); + }); + }); +}