[url_launcher] Add canLaunch fallback for web on Android (#5399)

diff --git a/packages/url_launcher/url_launcher_android/CHANGELOG.md b/packages/url_launcher/url_launcher_android/CHANGELOG.md
index 9ec1f65..69b9615 100644
--- a/packages/url_launcher/url_launcher_android/CHANGELOG.md
+++ b/packages/url_launcher/url_launcher_android/CHANGELOG.md
@@ -1,3 +1,8 @@
+## 6.0.16
+
+* Adds fallback querying for `canLaunch` with web URLs, to avoid false negatives
+  when there is a custom scheme handler.
+
 ## 6.0.15
 
 * Switches to an in-package method channel implementation.
diff --git a/packages/url_launcher/url_launcher_android/example/lib/main.dart b/packages/url_launcher/url_launcher_android/example/lib/main.dart
index 8721c58..7abc734 100644
--- a/packages/url_launcher/url_launcher_android/example/lib/main.dart
+++ b/packages/url_launcher/url_launcher_android/example/lib/main.dart
@@ -35,73 +35,74 @@
 }
 
 class _MyHomePageState extends State<MyHomePage> {
+  final UrlLauncherPlatform launcher = UrlLauncherPlatform.instance;
+  bool _hasCallSupport = false;
   Future<void>? _launched;
   String _phone = '';
 
+  @override
+  void initState() {
+    super.initState();
+    // Check for phone call support.
+    launcher.canLaunch('tel:123').then((bool result) {
+      setState(() {
+        _hasCallSupport = result;
+      });
+    });
+  }
+
   Future<void> _launchInBrowser(String url) async {
-    final UrlLauncherPlatform launcher = UrlLauncherPlatform.instance;
-    if (await launcher.canLaunch(url)) {
-      await launcher.launch(
-        url,
-        useSafariVC: false,
-        useWebView: false,
-        enableJavaScript: false,
-        enableDomStorage: false,
-        universalLinksOnly: false,
-        headers: <String, String>{'my_header_key': 'my_header_value'},
-      );
-    } else {
+    if (!await launcher.launch(
+      url,
+      useSafariVC: false,
+      useWebView: false,
+      enableJavaScript: false,
+      enableDomStorage: false,
+      universalLinksOnly: false,
+      headers: <String, String>{},
+    )) {
       throw 'Could not launch $url';
     }
   }
 
-  Future<void> _launchInWebViewOrVC(String url) async {
-    final UrlLauncherPlatform launcher = UrlLauncherPlatform.instance;
-    if (await launcher.canLaunch(url)) {
-      await launcher.launch(
-        url,
-        useSafariVC: true,
-        useWebView: true,
-        enableJavaScript: false,
-        enableDomStorage: false,
-        universalLinksOnly: false,
-        headers: <String, String>{'my_header_key': 'my_header_value'},
-      );
-    } else {
+  Future<void> _launchInWebView(String url) async {
+    if (!await launcher.launch(
+      url,
+      useSafariVC: true,
+      useWebView: true,
+      enableJavaScript: false,
+      enableDomStorage: false,
+      universalLinksOnly: false,
+      headers: <String, String>{'my_header_key': 'my_header_value'},
+    )) {
       throw 'Could not launch $url';
     }
   }
 
   Future<void> _launchInWebViewWithJavaScript(String url) async {
-    final UrlLauncherPlatform launcher = UrlLauncherPlatform.instance;
-    if (await launcher.canLaunch(url)) {
-      await launcher.launch(
-        url,
-        useSafariVC: true,
-        useWebView: true,
-        enableJavaScript: true,
-        enableDomStorage: false,
-        universalLinksOnly: false,
-        headers: <String, String>{},
-      );
-    } else {
+    if (!await launcher.launch(
+      url,
+      useSafariVC: true,
+      useWebView: true,
+      enableJavaScript: true,
+      enableDomStorage: false,
+      universalLinksOnly: false,
+      headers: <String, String>{},
+    )) {
       throw 'Could not launch $url';
     }
   }
 
   Future<void> _launchInWebViewWithDomStorage(String url) async {
-    final UrlLauncherPlatform launcher = UrlLauncherPlatform.instance;
-    if (await launcher.canLaunch(url)) {
-      await launcher.launch(
-        url,
-        useSafariVC: true,
-        useWebView: true,
-        enableJavaScript: false,
-        enableDomStorage: true,
-        universalLinksOnly: false,
-        headers: <String, String>{},
-      );
-    } else {
+    if (!await launcher.launch(
+      url,
+      useSafariVC: true,
+      useWebView: true,
+      enableJavaScript: false,
+      enableDomStorage: true,
+      universalLinksOnly: false,
+      headers: <String, String>{},
+    )) {
       throw 'Could not launch $url';
     }
   }
@@ -114,25 +115,30 @@
     }
   }
 
-  Future<void> _makePhoneCall(String url) async {
-    final UrlLauncherPlatform launcher = UrlLauncherPlatform.instance;
-    if (await launcher.canLaunch(url)) {
-      await launcher.launch(
-        url,
-        useSafariVC: false,
-        useWebView: false,
-        enableJavaScript: false,
-        enableDomStorage: false,
-        universalLinksOnly: true,
-        headers: <String, String>{},
-      );
-    } else {
-      throw 'Could not launch $url';
-    }
+  Future<void> _makePhoneCall(String phoneNumber) async {
+    // Use `Uri` to ensure that `phoneNumber` is properly URL-encoded.
+    // Just using 'tel:$phoneNumber' would create invalid URLs in some cases,
+    // such as spaces in the input, which would cause `launch` to fail on some
+    // platforms.
+    final Uri launchUri = Uri(
+      scheme: 'tel',
+      path: phoneNumber,
+    );
+    await launcher.launch(
+      launchUri.toString(),
+      useSafariVC: false,
+      useWebView: false,
+      enableJavaScript: false,
+      enableDomStorage: false,
+      universalLinksOnly: true,
+      headers: <String, String>{},
+    );
   }
 
   @override
   Widget build(BuildContext context) {
+    // onPressed calls using this URL are not gated on a 'canLaunch' check
+    // because the assumption is that every device can launch a web URL.
     const String toLaunch = 'https://www.cylog.org/headers/';
     return Scaffold(
       appBar: AppBar(
@@ -151,10 +157,14 @@
                         hintText: 'Input the phone number to launch')),
               ),
               ElevatedButton(
-                onPressed: () => setState(() {
-                  _launched = _makePhoneCall('tel:$_phone');
-                }),
-                child: const Text('Make phone call'),
+                onPressed: _hasCallSupport
+                    ? () => setState(() {
+                          _launched = _makePhoneCall(_phone);
+                        })
+                    : null,
+                child: _hasCallSupport
+                    ? const Text('Make phone call')
+                    : const Text('Calling not supported'),
               ),
               const Padding(
                 padding: EdgeInsets.all(16.0),
@@ -169,7 +179,7 @@
               const Padding(padding: EdgeInsets.all(16.0)),
               ElevatedButton(
                 onPressed: () => setState(() {
-                  _launched = _launchInWebViewOrVC(toLaunch);
+                  _launched = _launchInWebView(toLaunch);
                 }),
                 child: const Text('Launch in app'),
               ),
@@ -177,21 +187,21 @@
                 onPressed: () => setState(() {
                   _launched = _launchInWebViewWithJavaScript(toLaunch);
                 }),
-                child: const Text('Launch in app(JavaScript ON)'),
+                child: const Text('Launch in app (JavaScript ON)'),
               ),
               ElevatedButton(
                 onPressed: () => setState(() {
                   _launched = _launchInWebViewWithDomStorage(toLaunch);
                 }),
-                child: const Text('Launch in app(DOM storage ON)'),
+                child: const Text('Launch in app (DOM storage ON)'),
               ),
               const Padding(padding: EdgeInsets.all(16.0)),
               ElevatedButton(
                 onPressed: () => setState(() {
-                  _launched = _launchInWebViewOrVC(toLaunch);
+                  _launched = _launchInWebView(toLaunch);
                   Timer(const Duration(seconds: 5), () {
                     print('Closing WebView after 5 seconds...');
-                    UrlLauncherPlatform.instance.closeWebView();
+                    launcher.closeWebView();
                   });
                 }),
                 child: const Text('Launch in app + close after 5 seconds'),
diff --git a/packages/url_launcher/url_launcher_android/lib/url_launcher_android.dart b/packages/url_launcher/url_launcher_android/lib/url_launcher_android.dart
index 52c4635..1aa093a 100644
--- a/packages/url_launcher/url_launcher_android/lib/url_launcher_android.dart
+++ b/packages/url_launcher/url_launcher_android/lib/url_launcher_android.dart
@@ -22,7 +22,24 @@
   final LinkDelegate? linkDelegate = null;
 
   @override
-  Future<bool> canLaunch(String url) {
+  Future<bool> canLaunch(String url) async {
+    final bool canLaunchSpecificUrl = await _canLaunchUrl(url);
+    if (!canLaunchSpecificUrl) {
+      final String scheme = _getUrlScheme(url);
+      // canLaunch can return false when a custom application is registered to
+      // handle a web URL, but the caller doesn't have permission to see what
+      // that handler is. If that happens, try a web URL (with the same scheme
+      // variant, to be safe) that should not have a custom handler. If that
+      // returns true, then there is a browser, which means that there is
+      // at least one handler for the original URL.
+      if (scheme == 'http' || scheme == 'https') {
+        return await _canLaunchUrl('$scheme://flutter.dev');
+      }
+    }
+    return canLaunchSpecificUrl;
+  }
+
+  Future<bool> _canLaunchUrl(String url) {
     return _channel.invokeMethod<bool>(
       'canLaunch',
       <String, Object>{'url': url},
@@ -57,4 +74,16 @@
       },
     ).then((bool? value) => value ?? false);
   }
+
+  // Returns the part of [url] up to the first ':', or an empty string if there
+  // is no ':'. This deliberately does not use [Uri] to extract the scheme
+  // so that it works on strings that aren't actually valid URLs, since Android
+  // is very lenient about what it accepts for launching.
+  String _getUrlScheme(String url) {
+    final int schemeEnd = url.indexOf(':');
+    if (schemeEnd == -1) {
+      return '';
+    }
+    return url.substring(0, schemeEnd);
+  }
 }
diff --git a/packages/url_launcher/url_launcher_android/pubspec.yaml b/packages/url_launcher/url_launcher_android/pubspec.yaml
index b8706ae..3230dfe 100644
--- a/packages/url_launcher/url_launcher_android/pubspec.yaml
+++ b/packages/url_launcher/url_launcher_android/pubspec.yaml
@@ -2,7 +2,7 @@
 description: Android implementation of the url_launcher plugin.
 repository: https://github.com/flutter/plugins/tree/main/packages/url_launcher/url_launcher_android
 issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22
-version: 6.0.15
+version: 6.0.16
 
 environment:
   sdk: ">=2.14.0 <3.0.0"
diff --git a/packages/url_launcher/url_launcher_android/test/url_launcher_android_test.dart b/packages/url_launcher/url_launcher_android/test/url_launcher_android_test.dart
index 909d2c1..eebd8cd 100644
--- a/packages/url_launcher/url_launcher_android/test/url_launcher_android_test.dart
+++ b/packages/url_launcher/url_launcher_android/test/url_launcher_android_test.dart
@@ -10,10 +10,12 @@
 void main() {
   TestWidgetsFlutterBinding.ensureInitialized();
 
-  group('$UrlLauncherAndroid', () {
-    const MethodChannel channel =
-        MethodChannel('plugins.flutter.io/url_launcher_android');
-    final List<MethodCall> log = <MethodCall>[];
+  const MethodChannel channel =
+      MethodChannel('plugins.flutter.io/url_launcher_android');
+  late List<MethodCall> log;
+
+  setUp(() {
+    log = <MethodCall>[];
     channel.setMockMethodCallHandler((MethodCall methodCall) async {
       log.add(methodCall);
 
@@ -21,19 +23,21 @@
       // returned by the method channel if no return statement is specified.
       return null;
     });
+  });
 
-    tearDown(() {
-      log.clear();
-    });
+  test('registers instance', () {
+    UrlLauncherAndroid.registerWith();
+    expect(UrlLauncherPlatform.instance, isA<UrlLauncherAndroid>());
+  });
 
-    test('registers instance', () {
-      UrlLauncherAndroid.registerWith();
-      expect(UrlLauncherPlatform.instance, isA<UrlLauncherAndroid>());
-    });
-
-    test('canLaunch', () async {
+  group('canLaunch', () {
+    test('calls through', () async {
+      channel.setMockMethodCallHandler((MethodCall methodCall) async {
+        log.add(methodCall);
+        return true;
+      });
       final UrlLauncherAndroid launcher = UrlLauncherAndroid();
-      await launcher.canLaunch('http://example.com/');
+      final bool canLaunch = await launcher.canLaunch('http://example.com/');
       expect(
         log,
         <Matcher>[
@@ -42,16 +46,64 @@
           })
         ],
       );
+      expect(canLaunch, true);
     });
 
-    test('canLaunch should return false if platform returns null', () async {
+    test('returns false if platform returns null', () async {
       final UrlLauncherAndroid launcher = UrlLauncherAndroid();
       final bool canLaunch = await launcher.canLaunch('http://example.com/');
 
       expect(canLaunch, false);
     });
 
-    test('launch', () async {
+    test('checks a generic URL if an http URL returns false', () async {
+      const String specificUrl = 'http://example.com/';
+      const String genericUrl = 'http://flutter.dev';
+      channel.setMockMethodCallHandler((MethodCall methodCall) async {
+        log.add(methodCall);
+        return methodCall.arguments['url'] != specificUrl;
+      });
+
+      final UrlLauncherAndroid launcher = UrlLauncherAndroid();
+      final bool canLaunch = await launcher.canLaunch(specificUrl);
+
+      expect(canLaunch, true);
+      expect(log.length, 2);
+      expect(log[1].arguments['url'], genericUrl);
+    });
+
+    test('checks a generic URL if an https URL returns false', () async {
+      const String specificUrl = 'https://example.com/';
+      const String genericUrl = 'https://flutter.dev';
+      channel.setMockMethodCallHandler((MethodCall methodCall) async {
+        log.add(methodCall);
+        return methodCall.arguments['url'] != specificUrl;
+      });
+
+      final UrlLauncherAndroid launcher = UrlLauncherAndroid();
+      final bool canLaunch = await launcher.canLaunch(specificUrl);
+
+      expect(canLaunch, true);
+      expect(log.length, 2);
+      expect(log[1].arguments['url'], genericUrl);
+    });
+
+    test('does not a generic URL if a non-web URL returns false', () async {
+      channel.setMockMethodCallHandler((MethodCall methodCall) async {
+        log.add(methodCall);
+        return false;
+      });
+
+      final UrlLauncherAndroid launcher = UrlLauncherAndroid();
+      final bool canLaunch = await launcher.canLaunch('sms:12345');
+
+      expect(canLaunch, false);
+      expect(log.length, 1);
+    });
+  });
+
+  group('launch', () {
+    test('calls through', () async {
       final UrlLauncherAndroid launcher = UrlLauncherAndroid();
       await launcher.launch(
         'http://example.com/',
@@ -77,7 +129,7 @@
       );
     });
 
-    test('launch with headers', () async {
+    test('passes headers', () async {
       final UrlLauncherAndroid launcher = UrlLauncherAndroid();
       await launcher.launch(
         'http://example.com/',
@@ -103,7 +155,7 @@
       );
     });
 
-    test('launch universal links only', () async {
+    test('handles universal links only', () async {
       final UrlLauncherAndroid launcher = UrlLauncherAndroid();
       await launcher.launch(
         'http://example.com/',
@@ -129,7 +181,7 @@
       );
     });
 
-    test('launch force WebView', () async {
+    test('handles force WebView', () async {
       final UrlLauncherAndroid launcher = UrlLauncherAndroid();
       await launcher.launch(
         'http://example.com/',
@@ -155,7 +207,7 @@
       );
     });
 
-    test('launch force WebView enable javascript', () async {
+    test('handles force WebView with javascript', () async {
       final UrlLauncherAndroid launcher = UrlLauncherAndroid();
       await launcher.launch(
         'http://example.com/',
@@ -181,7 +233,7 @@
       );
     });
 
-    test('launch force WebView enable DOM storage', () async {
+    test('handles force WebView with DOM storage', () async {
       final UrlLauncherAndroid launcher = UrlLauncherAndroid();
       await launcher.launch(
         'http://example.com/',
@@ -207,7 +259,7 @@
       );
     });
 
-    test('launch should return false if platform returns null', () async {
+    test('returns false if platform returns null', () async {
       final UrlLauncherAndroid launcher = UrlLauncherAndroid();
       final bool launched = await launcher.launch(
         'http://example.com/',
@@ -221,8 +273,10 @@
 
       expect(launched, false);
     });
+  });
 
-    test('closeWebView default behavior', () async {
+  group('closeWebView', () {
+    test('calls through', () async {
       final UrlLauncherAndroid launcher = UrlLauncherAndroid();
       await launcher.closeWebView();
       expect(