From 32f1fd78c48f4316060997d4bb5b72f5833f3ea0 Mon Sep 17 00:00:00 2001 From: sorou3h1 Date: Tue, 16 Jun 2026 18:39:51 +0330 Subject: [PATCH] [url_launcher_android] Support intent:// URIs in launchUrl and canLaunchUrl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `intent://` is a standard Android URI scheme used by browsers and apps to encode arbitrary intents — including a custom action, data URI, and target package — inside a URL string. Previously, `launchUrl` and `canLaunchUrl` both created a new `Intent(Intent.ACTION_VIEW)` regardless of the URL scheme, silently dropping the encoded action and making `intent://` URIs with a non-VIEW action fail to behave as expected. This change detects `intent://` URLs and delegates to `Intent.parseUri(url, Intent.URI_INTENT_SCHEME)`, which correctly reconstructs the full intent including action, data URI, and package. Non-intent URLs are unaffected. Fixes: flutter/flutter#188068 --- .../url_launcher_android/CHANGELOG.md | 6 ++ .../plugins/urllauncher/UrlLauncher.java | 31 ++++++++-- .../plugins/urllauncher/UrlLauncherTest.java | 61 +++++++++++++++++++ 3 files changed, 92 insertions(+), 6 deletions(-) diff --git a/packages/url_launcher/url_launcher_android/CHANGELOG.md b/packages/url_launcher/url_launcher_android/CHANGELOG.md index 9506a8daa6e7..210ae2f7f248 100644 --- a/packages/url_launcher/url_launcher_android/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_android/CHANGELOG.md @@ -1,3 +1,9 @@ +## NEXT + +* Supports `intent://` URIs in `launchUrl` and `canLaunchUrl` by parsing them + with `Intent.parseUri`, preserving the action, data URI, and package encoded + in the URI rather than silently replacing the action with `ACTION_VIEW`. + ## 6.3.32 * Bumps the androidx group across 10 directories with 1 update. diff --git a/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/UrlLauncher.java b/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/UrlLauncher.java index b28870d1cd5f..b32ecf581793 100644 --- a/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/UrlLauncher.java +++ b/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/UrlLauncher.java @@ -19,6 +19,7 @@ import androidx.annotation.VisibleForTesting; import androidx.browser.customtabs.CustomTabsClient; import androidx.browser.customtabs.CustomTabsIntent; +import java.net.URISyntaxException; import java.util.Collections; import java.util.Locale; import java.util.Map; @@ -64,8 +65,17 @@ void setActivity(@Nullable Activity activity) { @Override public boolean canLaunchUrl(@NonNull String url) { - Intent launchIntent = new Intent(Intent.ACTION_VIEW); - launchIntent.setData(Uri.parse(url)); + Intent launchIntent; + if (url.startsWith("intent://")) { + try { + launchIntent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME); + } catch (URISyntaxException e) { + return false; + } + } else { + launchIntent = new Intent(Intent.ACTION_VIEW); + launchIntent.setData(Uri.parse(url)); + } String componentName = intentResolver.getHandlerComponentName(launchIntent); if (BuildConfig.DEBUG) { Log.i(TAG, "component name for " + url + " is " + componentName); @@ -84,10 +94,19 @@ public boolean launchUrl( ensureActivity(); assert activity != null; - Intent launchIntent = - new Intent(Intent.ACTION_VIEW) - .setData(Uri.parse(url)) - .putExtra(Browser.EXTRA_HEADERS, extractBundle(headers)); + Intent launchIntent; + if (url.startsWith("intent://")) { + try { + launchIntent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME); + } catch (URISyntaxException e) { + return false; + } + } else { + launchIntent = + new Intent(Intent.ACTION_VIEW) + .setData(Uri.parse(url)) + .putExtra(Browser.EXTRA_HEADERS, extractBundle(headers)); + } if (requireNonBrowser && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { launchIntent.addFlags(Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER); } diff --git a/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/UrlLauncherTest.java b/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/UrlLauncherTest.java index 971302c4a5bf..78302448b535 100644 --- a/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/UrlLauncherTest.java +++ b/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/UrlLauncherTest.java @@ -66,6 +66,35 @@ public void canLaunch_returnsFalse() { assertFalse(result); } + @Test + public void canLaunch_parsesIntentSchemeUri() { + UrlLauncher.IntentResolver resolver = mock(UrlLauncher.IntentResolver.class); + UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext(), resolver); + String intentUrl = + "intent://details?id=com.example.app" + + "#Intent;scheme=bazaar;package=com.example.store;" + + "action=android.intent.action.EDIT;end"; + when(resolver.getHandlerComponentName(any())).thenReturn(null); + + api.canLaunchUrl(intentUrl); + + final ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); + verify(resolver).getHandlerComponentName(intentCaptor.capture()); + assertEquals(Intent.ACTION_EDIT, intentCaptor.getValue().getAction()); + assertEquals( + Uri.parse("bazaar://details?id=com.example.app"), intentCaptor.getValue().getData()); + assertEquals("com.example.store", intentCaptor.getValue().getPackage()); + } + + @Test + public void canLaunch_returnsFalseForMalformedIntentSchemeUri() { + UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext(), intent -> null); + + boolean result = api.canLaunchUrl("intent://[malformed"); + + assertFalse(result); + } + // Integration testing on emulators won't work as expected without the workaround this tests // for, since it will be returned even for intentionally bogus schemes. @Test @@ -147,6 +176,38 @@ public void launch_returnsTrue() { assertTrue(result); } + @Test + public void launch_parsesIntentSchemeUri() { + Activity activity = mock(Activity.class); + UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext()); + api.setActivity(activity); + String intentUrl = + "intent://details?id=com.example.app" + + "#Intent;scheme=bazaar;package=com.example.store;" + + "action=android.intent.action.EDIT;end"; + doThrow(new ActivityNotFoundException()).when(activity).startActivity(any()); + + api.launchUrl(intentUrl, new HashMap<>(), false); + + final ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); + verify(activity).startActivity(intentCaptor.capture()); + assertEquals(Intent.ACTION_EDIT, intentCaptor.getValue().getAction()); + assertEquals( + Uri.parse("bazaar://details?id=com.example.app"), intentCaptor.getValue().getData()); + assertEquals("com.example.store", intentCaptor.getValue().getPackage()); + } + + @Test + public void launch_returnsFalseForMalformedIntentSchemeUri() { + Activity activity = mock(Activity.class); + UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext()); + api.setActivity(activity); + + boolean result = api.launchUrl("intent://[malformed", new HashMap<>(), false); + + assertFalse(result); + } + @Test public void openUrlInApp_opensUrlInWebViewIfNecessary() { Activity activity = mock(Activity.class);