[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(