Skip to content

Commit fcd5f68

Browse files
[url_launcher] Add Android support for externalNonBrowserApplication (flutter#9993)
Sets `FLAG_ACTIVITY_REQUIRE_NON_BROWSER` on the launch intent when `externalNonBrowserApplication` is requested. This only works on API 30+, but since that's already >80% of devices, and launch modes are already documented to be best-effort based on platform capabilities, that should be enough. (A fallback is possible, as demonstrated in flutter/plugins#5953, but it's not fully reliable, adds complexity, and relies on adding extra query permissions to the app bundle in order to work, so I am not including that approach.) Fixes flutter/flutter#66721 ## Pre-Review Checklist [^1]: Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling.
1 parent b1db3a5 commit fcd5f68

File tree

10 files changed

+106
-25
lines changed

10 files changed

+106
-25
lines changed

packages/url_launcher/url_launcher_android/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 6.3.22
2+
3+
* Adds support for `externalNonBrowserApplication` on API 30+.
4+
15
## 6.3.21
26

37
* Updates minimum supported SDK version to Flutter 3.35.

packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/Messages.java

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright 2013 The Flutter Authors. All rights reserved.
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
4-
// Autogenerated from Pigeon (v22.4.1), do not edit directly.
4+
// Autogenerated from Pigeon (v22.7.4), do not edit directly.
55
// See also: https://pub.dev/packages/pigeon
66

77
package io.flutter.plugins.urllauncher;
@@ -298,7 +298,10 @@ public interface UrlLauncherApi {
298298
Boolean canLaunchUrl(@NonNull String url);
299299
/** Opens the URL externally, returning true if successful. */
300300
@NonNull
301-
Boolean launchUrl(@NonNull String url, @NonNull Map<String, String> headers);
301+
Boolean launchUrl(
302+
@NonNull String url,
303+
@NonNull Map<String, String> headers,
304+
@NonNull Boolean requireNonBrowser);
302305
/**
303306
* Opens the URL in an in-app Custom Tab or WebView, returning true if it opens successfully.
304307
*/
@@ -367,8 +370,9 @@ static void setUp(
367370
ArrayList<Object> args = (ArrayList<Object>) message;
368371
String urlArg = (String) args.get(0);
369372
Map<String, String> headersArg = (Map<String, String>) args.get(1);
373+
Boolean requireNonBrowserArg = (Boolean) args.get(2);
370374
try {
371-
Boolean output = api.launchUrl(urlArg, headersArg);
375+
Boolean output = api.launchUrl(urlArg, headersArg, requireNonBrowserArg);
372376
wrapped.add(0, output);
373377
} catch (Throwable exception) {
374378
wrapped = wrapError(exception);

packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/UrlLauncher.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import android.content.Context;
1111
import android.content.Intent;
1212
import android.net.Uri;
13+
import android.os.Build;
1314
import android.os.Bundle;
1415
import android.provider.Browser;
1516
import android.util.Log;
@@ -80,14 +81,20 @@ void setActivity(@Nullable Activity activity) {
8081
}
8182

8283
@Override
83-
public @NonNull Boolean launchUrl(@NonNull String url, @NonNull Map<String, String> headers) {
84+
public @NonNull Boolean launchUrl(
85+
@NonNull String url,
86+
@NonNull Map<String, String> headers,
87+
@NonNull Boolean requireNonBrowser) {
8488
ensureActivity();
8589
assert activity != null;
8690

8791
Intent launchIntent =
8892
new Intent(Intent.ACTION_VIEW)
8993
.setData(Uri.parse(url))
9094
.putExtra(Browser.EXTRA_HEADERS, extractBundle(headers));
95+
if (requireNonBrowser && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
96+
launchIntent.addFlags(Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER);
97+
}
9198
try {
9299
activity.startActivity(launchIntent);
93100
} catch (ActivityNotFoundException e) {

packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/UrlLauncherTest.java

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import org.junit.runner.RunWith;
3131
import org.mockito.ArgumentCaptor;
3232
import org.robolectric.RobolectricTestRunner;
33+
import org.robolectric.annotation.Config;
3334

3435
@RunWith(RobolectricTestRunner.class)
3536
public class UrlLauncherTest {
@@ -88,7 +89,7 @@ public void launch_throwsForNoCurrentActivity() {
8889
Messages.FlutterError exception =
8990
assertThrows(
9091
Messages.FlutterError.class,
91-
() -> api.launchUrl("https://flutter.dev", new HashMap<>()));
92+
() -> api.launchUrl("https://flutter.dev", new HashMap<>(), false));
9293
assertEquals("NO_ACTIVITY", exception.code);
9394
}
9495

@@ -100,11 +101,30 @@ public void launch_createsIntentWithPassedUrl() {
100101
api.setActivity(activity);
101102
doThrow(new ActivityNotFoundException()).when(activity).startActivity(any());
102103

103-
api.launchUrl("https://flutter.dev", new HashMap<>());
104+
api.launchUrl("https://flutter.dev", new HashMap<>(), false);
104105

105106
final ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
106107
verify(activity).startActivity(intentCaptor.capture());
107108
assertEquals(url, intentCaptor.getValue().getData().toString());
109+
assertEquals(0, intentCaptor.getValue().getFlags() & Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER);
110+
}
111+
112+
@Config(minSdk = 30)
113+
@Test
114+
public void launch_setsRequireNonBrowserWhenRequested() {
115+
Activity activity = mock(Activity.class);
116+
String url = "https://flutter.dev";
117+
UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext());
118+
api.setActivity(activity);
119+
doThrow(new ActivityNotFoundException()).when(activity).startActivity(any());
120+
121+
api.launchUrl("https://flutter.dev", new HashMap<>(), true);
122+
123+
final ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
124+
verify(activity).startActivity(intentCaptor.capture());
125+
assertEquals(
126+
Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER,
127+
intentCaptor.getValue().getFlags() & Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER);
108128
}
109129

110130
@Test
@@ -114,7 +134,7 @@ public void launch_returnsFalse() {
114134
api.setActivity(activity);
115135
doThrow(new ActivityNotFoundException()).when(activity).startActivity(any());
116136

117-
boolean result = api.launchUrl("https://flutter.dev", new HashMap<>());
137+
boolean result = api.launchUrl("https://flutter.dev", new HashMap<>(), false);
118138

119139
assertFalse(result);
120140
}
@@ -125,7 +145,7 @@ public void launch_returnsTrue() {
125145
UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext());
126146
api.setActivity(activity);
127147

128-
boolean result = api.launchUrl("https://flutter.dev", new HashMap<>());
148+
boolean result = api.launchUrl("https://flutter.dev", new HashMap<>(), false);
129149

130150
assertTrue(result);
131151
}

packages/url_launcher/url_launcher_android/example/lib/main.dart

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,17 @@ class _MyHomePageState extends State<MyHomePage> {
6969
}
7070
}
7171

72+
Future<void> _launchInNonBrowserExternalApp(String url) async {
73+
if (!await launcher.launchUrl(
74+
url,
75+
const LaunchOptions(
76+
mode: PreferredLaunchMode.externalNonBrowserApplication,
77+
),
78+
)) {
79+
throw Exception('Could not launch $url');
80+
}
81+
}
82+
7283
Future<void> _launchInCustomTab(String url) async {
7384
if (!await launcher.launchUrl(
7485
url,
@@ -180,18 +191,24 @@ class _MyHomePageState extends State<MyHomePage> {
180191
child: Text(toLaunch),
181192
),
182193
ElevatedButton(
183-
onPressed: _hasCustomTabSupport
184-
? () => setState(() {
185-
_launched = _launchInBrowser(toLaunch);
186-
})
187-
: null,
194+
onPressed: () => setState(() {
195+
_launched = _launchInBrowser(toLaunch);
196+
}),
188197
child: const Text('Launch in browser'),
189198
),
190-
const Padding(padding: EdgeInsets.all(16.0)),
191199
ElevatedButton(
192200
onPressed: () => setState(() {
193-
_launched = _launchInCustomTab(toLaunch);
201+
_launched = _launchInNonBrowserExternalApp(toLaunch);
194202
}),
203+
child: const Text('Launch in non-browser app'),
204+
),
205+
const Padding(padding: EdgeInsets.all(16.0)),
206+
ElevatedButton(
207+
onPressed: _hasCustomTabSupport
208+
? () => setState(() {
209+
_launched = _launchInCustomTab(toLaunch);
210+
})
211+
: null,
195212
child: const Text('Launch in Android Custom Tab'),
196213
),
197214
const Padding(padding: EdgeInsets.all(16.0)),

packages/url_launcher/url_launcher_android/lib/src/messages.g.dart

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright 2013 The Flutter Authors. All rights reserved.
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
4-
// Autogenerated from Pigeon (v22.4.1), do not edit directly.
4+
// Autogenerated from Pigeon (v22.7.4), do not edit directly.
55
// See also: https://pub.dev/packages/pigeon
66
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
77

@@ -142,7 +142,11 @@ class UrlLauncherApi {
142142
}
143143

144144
/// Opens the URL externally, returning true if successful.
145-
Future<bool> launchUrl(String url, Map<String, String> headers) async {
145+
Future<bool> launchUrl(
146+
String url,
147+
Map<String, String> headers,
148+
bool requireNonBrowser,
149+
) async {
146150
final String pigeonVar_channelName =
147151
'dev.flutter.pigeon.url_launcher_android.UrlLauncherApi.launchUrl$pigeonVar_messageChannelSuffix';
148152
final BasicMessageChannel<Object?> pigeonVar_channel =
@@ -152,7 +156,8 @@ class UrlLauncherApi {
152156
binaryMessenger: pigeonVar_binaryMessenger,
153157
);
154158
final List<Object?>? pigeonVar_replyList =
155-
await pigeonVar_channel.send(<Object?>[url, headers]) as List<Object?>?;
159+
await pigeonVar_channel.send(<Object?>[url, headers, requireNonBrowser])
160+
as List<Object?>?;
156161
if (pigeonVar_replyList == null) {
157162
throw _createConnectionError(pigeonVar_channelName);
158163
} else if (pigeonVar_replyList.length > 1) {

packages/url_launcher/url_launcher_android/lib/url_launcher_android.dart

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,17 +77,16 @@ class UrlLauncherAndroid extends UrlLauncherPlatform {
7777
@override
7878
Future<bool> launchUrl(String url, LaunchOptions options) async {
7979
final bool inApp;
80+
bool requireNonBrowser = false;
8081
switch (options.mode) {
8182
case PreferredLaunchMode.inAppWebView:
8283
case PreferredLaunchMode.inAppBrowserView:
8384
inApp = true;
8485
case PreferredLaunchMode.externalApplication:
86+
inApp = false;
8587
case PreferredLaunchMode.externalNonBrowserApplication:
86-
// TODO(stuartmorgan): Add full support for
87-
// externalNonBrowsingApplication; see
88-
// https://github.com/flutter/flutter/issues/66721.
89-
// Currently it's treated the same as externalApplication.
9088
inApp = false;
89+
requireNonBrowser = true;
9190
case PreferredLaunchMode.platformDefault:
9291
// Intentionally treat any new values as platformDefault; see comment in
9392
// supportsMode.
@@ -114,6 +113,7 @@ class UrlLauncherAndroid extends UrlLauncherPlatform {
114113
succeeded = await _hostApi.launchUrl(
115114
url,
116115
options.webViewConfiguration.headers,
116+
requireNonBrowser,
117117
);
118118
}
119119

packages/url_launcher/url_launcher_android/pigeons/messages.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ abstract class UrlLauncherApi {
4040
bool canLaunchUrl(String url);
4141

4242
/// Opens the URL externally, returning true if successful.
43-
bool launchUrl(String url, Map<String, String> headers);
43+
bool launchUrl(
44+
String url,
45+
Map<String, String> headers,
46+
bool requireNonBrowser,
47+
);
4448

4549
/// Opens the URL in an in-app Custom Tab or WebView, returning true if it
4650
/// opens successfully.

packages/url_launcher/url_launcher_android/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: url_launcher_android
22
description: Android implementation of the url_launcher plugin.
33
repository: https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher_android
44
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22
5-
version: 6.3.21
5+
version: 6.3.22
66

77
environment:
88
sdk: ^3.9.0

packages/url_launcher/url_launcher_android/test/url_launcher_android_test.dart

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ void main() {
236236
);
237237
expect(launched, true);
238238
expect(api.usedWebView, false);
239+
expect(api.requiredNonBrowser, false);
239240
expect(api.passedWebViewOptions?.headers, isEmpty);
240241
});
241242

@@ -254,6 +255,19 @@ void main() {
254255
expect(api.passedWebViewOptions?.headers['key'], 'value');
255256
});
256257

258+
test('passes non-browser flag', () async {
259+
final UrlLauncherAndroid launcher = UrlLauncherAndroid(api: api);
260+
final bool launched = await launcher.launchUrl(
261+
'http://example.com/',
262+
const LaunchOptions(
263+
mode: PreferredLaunchMode.externalNonBrowserApplication,
264+
),
265+
);
266+
expect(launched, true);
267+
expect(api.usedWebView, false);
268+
expect(api.requiredNonBrowser, true);
269+
});
270+
257271
test('passes through no-activity exception', () async {
258272
final UrlLauncherAndroid launcher = UrlLauncherAndroid(api: api);
259273
await expectLater(
@@ -484,6 +498,7 @@ class _FakeUrlLauncherApi implements UrlLauncherApi {
484498
BrowserOptions? passedBrowserOptions;
485499
bool? usedWebView;
486500
bool? allowedCustomTab;
501+
bool? requiredNonBrowser;
487502
bool? closed;
488503

489504
/// A domain that will be treated as having no handler, even for http(s).
@@ -495,13 +510,18 @@ class _FakeUrlLauncherApi implements UrlLauncherApi {
495510
}
496511

497512
@override
498-
Future<bool> launchUrl(String url, Map<String, String> headers) async {
513+
Future<bool> launchUrl(
514+
String url,
515+
Map<String, String> headers,
516+
bool requireNonBrowser,
517+
) async {
499518
passedWebViewOptions = WebViewOptions(
500519
enableJavaScript: false,
501520
enableDomStorage: false,
502521
headers: headers,
503522
);
504523

524+
requiredNonBrowser = requireNonBrowser;
505525
usedWebView = false;
506526
return _launch(url);
507527
}

0 commit comments

Comments
 (0)