Implement the ability to run a particular Client implementation in a Zone (#697)

diff --git a/lib/http.dart b/lib/http.dart
index 1ea751e..34eebbb 100644
--- a/lib/http.dart
+++ b/lib/http.dart
@@ -16,7 +16,7 @@
 export 'src/base_request.dart';
 export 'src/base_response.dart';
 export 'src/byte_stream.dart';
-export 'src/client.dart';
+export 'src/client.dart' hide zoneClient;
 export 'src/exception.dart';
 export 'src/multipart_file.dart';
 export 'src/multipart_request.dart';
diff --git a/lib/src/client.dart b/lib/src/client.dart
index e1ee89a..56ddcbc 100644
--- a/lib/src/client.dart
+++ b/lib/src/client.dart
@@ -2,11 +2,13 @@
 // for details. All rights reserved. Use of this source code is governed by a
 // BSD-style license that can be found in the LICENSE file.
 
+import 'dart:async';
 import 'dart:convert';
 import 'dart:typed_data';
 
-import '../http.dart' as http;
+import 'package:meta/meta.dart';
 
+import '../http.dart' as http;
 import 'base_client.dart';
 import 'base_request.dart';
 import 'client_stub.dart'
@@ -32,7 +34,7 @@
   ///
   /// Creates an `IOClient` if `dart:io` is available and a `BrowserClient` if
   /// `dart:html` is available, otherwise it will throw an unsupported error.
-  factory Client() => createClient();
+  factory Client() => zoneClient ?? createClient();
 
   /// Sends an HTTP HEAD request with the given headers to the given URL.
   ///
@@ -145,3 +147,45 @@
   /// do so can cause the Dart process to hang.
   void close();
 }
+
+/// The [Client] for the current [Zone], if one has been set.
+///
+/// NOTE: This property is explicitly hidden from the public API.
+@internal
+Client? get zoneClient {
+  final client = Zone.current[#_clientToken];
+  return client == null ? null : (client as Client Function())();
+}
+
+/// Runs [body] in its own [Zone] with the [Client] returned by [clientFactory]
+/// set as the default [Client].
+///
+/// For example:
+///
+/// ```
+/// class MyAndroidHttpClient extends BaseClient {
+///   @override
+///   Future<http.StreamedResponse> send(http.BaseRequest request) {
+///     // your implementation here
+///   }
+/// }
+///
+/// void main() {
+///   Client client =  Platform.isAndroid ? MyAndroidHttpClient() : Client();
+///   runWithClient(myFunction, () => client);
+/// }
+///
+/// void myFunction() {
+///   // Uses the `Client` configured in `main`.
+///   final response = await get(Uri.parse("https://www.example.com/"));
+///   final client = Client();
+/// }
+/// ```
+///
+/// The [Client] returned by [clientFactory] is used by the [Client.new] factory
+/// and the convenience HTTP functions (e.g. [http.get])
+R runWithClient<R>(R Function() body, Client Function() clientFactory,
+        {ZoneSpecification? zoneSpecification}) =>
+    runZoned(body,
+        zoneValues: {#_clientToken: Zone.current.bindCallback(clientFactory)},
+        zoneSpecification: zoneSpecification);
diff --git a/test/io/client_test.dart b/test/io/client_test.dart
index 2115e1c..d03a4bb 100644
--- a/test/io/client_test.dart
+++ b/test/io/client_test.dart
@@ -12,6 +12,20 @@
 
 import '../utils.dart';
 
+class TestClient extends http.BaseClient {
+  @override
+  Future<http.StreamedResponse> send(http.BaseRequest request) {
+    throw UnimplementedError();
+  }
+}
+
+class TestClient2 extends http.BaseClient {
+  @override
+  Future<http.StreamedResponse> send(http.BaseRequest request) {
+    throw UnimplementedError();
+  }
+}
+
 void main() {
   late Uri serverUrl;
   setUpAll(() async {
@@ -133,4 +147,30 @@
 
     expect(socket, isNotNull);
   });
+
+  test('runWithClient', () {
+    http.Client client =
+        http.runWithClient(() => http.Client(), () => TestClient());
+    expect(client, isA<TestClient>());
+  });
+
+  test('runWithClient nested', () {
+    late final http.Client client;
+    late final http.Client nestedClient;
+    http.runWithClient(() {
+      http.runWithClient(
+          () => nestedClient = http.Client(), () => TestClient2());
+      client = http.Client();
+    }, () => TestClient());
+    expect(client, isA<TestClient>());
+    expect(nestedClient, isA<TestClient2>());
+  });
+
+  test('runWithClient recursion', () {
+    // Verify that calling the http.Client() factory inside nested Zones does
+    // not provoke an infinite recursion.
+    http.runWithClient(() {
+      http.runWithClient(() => http.Client(), () => http.Client());
+    }, () => http.Client());
+  });
 }
diff --git a/test/io/http_test.dart b/test/io/http_test.dart
index 7bdf7da..a551230 100644
--- a/test/io/http_test.dart
+++ b/test/io/http_test.dart
@@ -9,6 +9,13 @@
 
 import '../utils.dart';
 
+class TestClient extends http.BaseClient {
+  @override
+  Future<http.StreamedResponse> send(http.BaseRequest request) {
+    throw UnimplementedError();
+  }
+}
+
 void main() {
   late Uri serverUrl;
   setUpAll(() async {
@@ -22,6 +29,13 @@
       expect(response.body, equals(''));
     });
 
+    test('head runWithClient', () {
+      expect(
+          () => http.runWithClient(
+              () => http.head(serverUrl), () => TestClient()),
+          throwsUnimplementedError);
+    });
+
     test('get', () async {
       var response = await http.get(serverUrl, headers: {
         'X-Random-Header': 'Value',
@@ -43,6 +57,13 @@
                       containsPair('x-other-header', ['Other Value']))))));
     });
 
+    test('get runWithClient', () {
+      expect(
+          () =>
+              http.runWithClient(() => http.get(serverUrl), () => TestClient()),
+          throwsUnimplementedError);
+    });
+
     test('post', () async {
       var response = await http.post(serverUrl, headers: {
         'X-Random-Header': 'Value',
@@ -151,6 +172,13 @@
           })));
     });
 
+    test('post runWithClient', () {
+      expect(
+          () => http.runWithClient(
+              () => http.post(serverUrl, body: 'testing'), () => TestClient()),
+          throwsUnimplementedError);
+    });
+
     test('put', () async {
       var response = await http.put(serverUrl, headers: {
         'X-Random-Header': 'Value',
@@ -259,6 +287,13 @@
           })));
     });
 
+    test('put runWithClient', () {
+      expect(
+          () => http.runWithClient(
+              () => http.put(serverUrl, body: 'testing'), () => TestClient()),
+          throwsUnimplementedError);
+    });
+
     test('patch', () async {
       var response = await http.patch(serverUrl, headers: {
         'X-Random-Header': 'Value',
@@ -388,6 +423,13 @@
                       containsPair('x-other-header', ['Other Value']))))));
     });
 
+    test('patch runWithClient', () {
+      expect(
+          () => http.runWithClient(
+              () => http.patch(serverUrl, body: 'testing'), () => TestClient()),
+          throwsUnimplementedError);
+    });
+
     test('read', () async {
       var response = await http.read(serverUrl, headers: {
         'X-Random-Header': 'Value',
@@ -412,6 +454,13 @@
       expect(http.read(serverUrl.resolve('/error')), throwsClientException());
     });
 
+    test('read runWithClient', () {
+      expect(
+          () => http.runWithClient(
+              () => http.read(serverUrl), () => TestClient()),
+          throwsUnimplementedError);
+    });
+
     test('readBytes', () async {
       var bytes = await http.readBytes(serverUrl, headers: {
         'X-Random-Header': 'Value',
@@ -437,5 +486,12 @@
       expect(
           http.readBytes(serverUrl.resolve('/error')), throwsClientException());
     });
+
+    test('readBytes runWithClient', () {
+      expect(
+          () => http.runWithClient(
+              () => http.readBytes(serverUrl), () => TestClient()),
+          throwsUnimplementedError);
+    });
   });
 }