Skip to content

fix(native_dio_adapter): gracefully fall back when Cronet unavailable#2479

Closed
DevMohammadSalameh wants to merge 1 commit into
cfug:mainfrom
DevMohammadSalameh:fix/cronet-graceful-fallback
Closed

fix(native_dio_adapter): gracefully fall back when Cronet unavailable#2479
DevMohammadSalameh wants to merge 1 commit into
cfug:mainfrom
DevMohammadSalameh:fix/cronet-graceful-fallback

Conversation

@DevMohammadSalameh

Copy link
Copy Markdown

Summary

Fixes #2444

When Google Play Services is unavailable (e.g., on Android emulators without Google APIs or devices without Play Services), CronetEngine initialization throws a JniException:

"All available Cronet providers are disabled. A provider should be enabled before it can be used."

This change:

  • Wraps CronetAdapter creation in a try-catch block
  • Falls back to the standard HttpClientAdapter (dart:io) when Cronet is unavailable
  • Adds an optional onCronetUnavailable callback for developers who want to be notified when the fallback occurs

Changes

  • native_adapter.dart: Added graceful fallback with try-catch
  • CHANGELOG.md: Documented the new behavior

Usage

// Basic usage - fallback happens silently
final dio = Dio();
dio.httpClientAdapter = NativeAdapter();

// With callback to log or handle the fallback
dio.httpClientAdapter = NativeAdapter(
  onCronetUnavailable: (error) {
    print('Cronet unavailable, using dart:io fallback: $error');
  },
);

Test Plan

  • melos run format - passes
  • melos run analyze - passes
  • flutter test in native_dio_adapter - all 10 tests pass
  • Manual test on Android emulator without Google Play Services (requires device testing)

  Fixes cfug#2444

  When Google Play Services is unavailable, CronetEngine initialization
  throws a JniException. This change catches the exception and falls
  back to the standard HttpClientAdapter using dart:io.

  Also adds an optional onCronetUnavailable callback for developers
  who want to be notified when the fallback occurs.
@DevMohammadSalameh
DevMohammadSalameh requested a review from a team as a code owner January 8, 2026 13:40
// HttpClient when unavailable (e.g., on emulators without Google APIs
// or devices without Google Play Services).
onCronetUnavailable?.call(e);
_adapter = HttpClientAdapter();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As #2444 (comment) said, I think this should be set as opt-in.

Comment on lines +45 to +49
} catch (e) {
// Cronet requires Google Play Services. Fall back to dart:io
// HttpClient when unavailable (e.g., on emulators without Google APIs
// or devices without Google Play Services).
onCronetUnavailable?.call(e);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
} catch (e) {
// Cronet requires Google Play Services. Fall back to dart:io
// HttpClient when unavailable (e.g., on emulators without Google APIs
// or devices without Google Play Services).
onCronetUnavailable?.call(e);
} catch (e, s) {
// Cronet requires Google Play Services. Fall back to dart:io
// HttpClient when unavailable (e.g., on emulators without Google APIs
// or devices without Google Play Services).
onCronetUnavailable?.call(e, s);

@AlexV525

Copy link
Copy Markdown
Member

Superseded by #2573, which takes a different approach based on the design in docs/plans/cronet-provider-fallback.md:

  • Opt-in, not default: existing apps are unaffected until they explicitly pass createFallbackAdapter. This avoids silently changing the networking stack (TLS/proxy/cookies/protocols/pooling) for apps that never asked for it.
  • Factory returns any HttpClientAdapter (not a fixed IOHttpClientAdapter), so callers can pick an adapter matching their TLS, proxy, cookie, transport, and observability requirements.
  • Classifier matches the exact Chromium provider-disabled RuntimeException on a typed JniException (adds jni as a direct dep) instead of a broad try/catch — post-init connection/TLS/timeout/redirect/cancellation errors remain Cronet errors.
  • Detection happens lazily on the first fetch, since CronetEngine.build() is deferred to first request; a constructor-level try/catch would miss it.
  • Selection is sticky and close before any request never initializes Cronet.

Thanks for the initial PR and for surfacing the fix — it prompted the design discussion. Closing this in favor of #2573.

@AlexV525 AlexV525 closed this Jul 22, 2026
AlexV525 added a commit that referenced this pull request Jul 22, 2026
## Summary

Fixes #2444.

Adds an opt-in `createFallbackAdapter` factory to `NativeAdapter`. When
Cronet reports that every installed provider on the device is disabled
(for example, AOSP emulators or devices without Google Play services),
the caller-supplied factory returns any `HttpClientAdapter` and requests
continue via that adapter — instead of every request failing on
`CronetEngine.build()`.

```dart
NativeAdapter(
  createFallbackAdapter: (error, stackTrace) => IOHttpClientAdapter(),
)
```

### Key properties

- **Opt-in.** The parameter defaults to `null`. Without it,
`NativeAdapter` continues to use Cronet and propagates initialization
errors exactly as it does today. No behavior change for existing
applications.
- **Precise error classification.** Matches the exact Chromium
provider-disabled `RuntimeException` on a typed `JniException` (adds
`jni` as a direct dependency). Does **not** fall back on any other
`JniException`, non-`JniException` errors carrying similar text, or
engine initialization errors of other kinds.
- **Post-init errors are untouched.** Connection, TLS, timeout,
redirect, cancellation, and response-stream errors from a
successfully-initialized Cronet path remain Cronet errors and are
propagated unchanged.
- **Sticky selection.** The choice is made lazily on the first `fetch`
and cached; later requests do not probe Cronet again. `close()` closes
the selected adapter once. Closing before the first request does not
initialize Cronet or create the fallback.
- **Adapter, not `Client`.** The factory returns any `HttpClientAdapter`
— callers pick the one whose TLS, proxy, cookie, transport, and
observability behavior matches their needs.

### Why this shape

This PR replaces the two prior attempts against #2444, which were
rejected on their approach:

- **#2445** wrapped `CronetEngine.build()` in `CronetAdapter` and
switched the internal `http.Client` mid-request. That is not safe once
`ConversionLayerAdapter` has consumed the Dio request stream.
- **#2479** made the fallback silent and default, fixed the fallback to
`HttpClientAdapter()` (dart:io), and matched on `.toString()`. That
silently changes networking behavior for existing apps and is fragile
against exception-message changes.

The rationale for the current design is captured in
`docs/plans/cronet-provider-fallback.md`.

## Changes

- `plugins/native_dio_adapter/lib/src/cronet_fallback_adapter.dart` —
new Android-only lazy selection wrapper `CronetWithFallbackAdapter`, the
classifier `isCronetProviderUnavailable`, and the
`CreateFallbackAdapter` typedef. Includes a `@visibleForTesting`
constructor that lets unit tests inject a controllable "build cronet
path" seam without native code.
- `plugins/native_dio_adapter/lib/src/native_adapter.dart` — new
`createFallbackAdapter` parameter; wired on Android only, only when the
factory is supplied.
- `plugins/native_dio_adapter/lib/native_dio_adapter.dart` — export the
`CreateFallbackAdapter` typedef.
- `plugins/native_dio_adapter/pubspec.yaml` — add direct `jni: ^0.14.0`
dependency (compatible with `cronet_http: ^1.5.0`'s transitive
`^0.14.2`).
- `plugins/native_dio_adapter/README.md` — opt-in example section with
the observable-behavior caveat.
- `plugins/native_dio_adapter/CHANGELOG.md` — Unreleased entry.
- `plugins/native_dio_adapter/test/cronet_fallback_adapter_test.dart` —
12 focused tests using a controllable "build cronet adapter" seam and
mock `HttpClientAdapter` implementations.

## Test plan

- [x] `melos run format` — passes.
- [x] `melos run analyze` — passes.
- [x] `melos run test` — passes (26 tests in `native_dio_adapter`,
including 12 new).
- [x] Adversarial second-opinion review against the plan — no blocking
issues.
- [ ] Android integration on an AOSP emulator without Google Play
services. This complements unit coverage; not blocking on it here. If
the CI matrix cannot provide the required emulator, this should be
recorded separately per the plan.

### Unit coverage (per plan)

- Classified error triggers the fallback factory exactly once and
forwards the unchanged request, body, and cancel future by identity.
- Non-matching `JniException` (wrong exception class or wrong message)
is rethrown and no fallback is created.
- Non-`JniException` error carrying the same text is rejected by the
classifier.
- `ArgumentError` during Cronet build is rethrown, not fallen back.
- Errors thrown by the successfully-built Cronet adapter (post-init
connection/TLS/timeout errors) do NOT trigger a fallback.
- Selected fallback and selected Cronet adapter are each reused across
subsequent requests.
- `close` after selection closes the selected adapter exactly once (and
later `close` calls are no-ops).
- `close` before any request does not invoke the build seam or create a
fallback.

The "no fallback factory" case is satisfied by construction —
`NativeAdapter` does not instantiate the wrapper when the factory is
`null`, and calling with a `null` factory is indistinguishable from
prior versions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[native_dio_adapter] All network requests fail when Google Play services are unavailable

2 participants