From b55b16ba09f9aaa8ba656912769b976c89362a29 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 22 Oct 2026 20:30:04 +0100 Subject: [PATCH] Open the app's own links in a browser on Android Browser::open() fired a plain ACTION_VIEW intent. When the URL is one the app holds a verified App Link for, Android resolves that intent straight back to the app's own MainActivity, so the browser never opens and the link comes in as a deep link instead. On core 4.6 an unrouted deep link is handed to Browser::open() again, so the app loops. Custom Tabs (inApp, and the auth tab) go through the same resolution and have the same problem. Before launching, check whether the app itself is one of the activities that would handle the URL. Only then set a selector for the same request with a host-less URL, which only browsers can match, so the default browser gets the real URL. Every other URL is left alone, so links to YouTube or Maps still open in those apps. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +++ resources/android/AuthTab.kt | 6 +++- resources/android/BrowserFunctions.kt | 52 ++++++++++++++++++++++++--- tests/PluginTest.php | 13 +++++++ 4 files changed, 70 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 877bbb5..3ed7e33 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,10 @@ app registered `myapp`, if one is installed. The returned `success` is the real answer: `false` means the system found nothing to open it with, which is how you detect that the target app is not installed. +A web link that another app handles (YouTube, Maps) opens in that app. A link your +own app handles (one on your `NATIVEPHP_DEEPLINK_HOST`) opens in the browser rather +than coming back into your app. `inApp()` and `auth()` do the same on Android. + ### `auth()` Opens a URL in a specialized authentication browser designed for OAuth flows with automatic `nativephp://` redirect handling. diff --git a/resources/android/AuthTab.kt b/resources/android/AuthTab.kt index e4d0c98..f75e06e 100644 --- a/resources/android/AuthTab.kt +++ b/resources/android/AuthTab.kt @@ -144,7 +144,11 @@ class AuthTabActivity : Activity() { tabOpened = true try { - CustomTabsIntent.Builder().build().launchUrl(this, Uri.parse(intent.getStringExtra(EXTRA_URL))) + val uri = Uri.parse(intent.getStringExtra(EXTRA_URL)) + val tab = CustomTabsIntent.Builder().build() + tab.intent.data = uri + keepOutOfThisApp(tab.intent) + tab.launchUrl(this, uri) } catch (e: ActivityNotFoundException) { Log.e("Browser.OpenAuth", "No browser to open the auth URL", e) AuthTab.closed(this) diff --git a/resources/android/BrowserFunctions.kt b/resources/android/BrowserFunctions.kt index 757f228..a3eeb81 100644 --- a/resources/android/BrowserFunctions.kt +++ b/resources/android/BrowserFunctions.kt @@ -1,6 +1,8 @@ package com.nativephp.browser +import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri import android.util.Log import androidx.browser.customtabs.CustomTabsIntent @@ -31,7 +33,7 @@ object BrowserFunctions { return try { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - activity.startActivity(intent) + activity.startActivity(activity.keepOutOfThisApp(intent)) Log.d("Browser.Open", "Successfully opened URL in system browser") mapOf("success" to true) @@ -57,9 +59,12 @@ object BrowserFunctions { Log.d("Browser.OpenInApp", "Opening URL in in-app browser: $url") return try { + val uri = Uri.parse(url) val builder = CustomTabsIntent.Builder() val customTabsIntent = builder.build() - customTabsIntent.launchUrl(activity, Uri.parse(url)) + customTabsIntent.intent.data = uri + activity.keepOutOfThisApp(customTabsIntent.intent) + customTabsIntent.launchUrl(activity, uri) Log.d("Browser.OpenInApp", "Successfully opened URL in in-app browser") mapOf("success" to true) @@ -68,7 +73,7 @@ object BrowserFunctions { // Fallback to system browser if Custom Tabs fail return try { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) - activity.startActivity(intent) + activity.startActivity(activity.keepOutOfThisApp(intent)) Log.d("Browser.OpenInApp", "Opened URL in system browser (fallback)") mapOf("success" to true) } catch (fallbackError: Exception) { @@ -105,4 +110,43 @@ object BrowserFunctions { } } } -} \ No newline at end of file +} + +/** + * Make sure [intent], a VIEW intent for a URL, opens in a browser if Android would otherwise + * hand it back to this app. + * + * For a link the app holds an App Link for, a plain VIEW intent resolves straight to the app's + * own MainActivity, so the browser never opens and the link arrives as a deep link instead. + * Before Android 12 an unverified link can also put the app in the chooser next to the browsers. + * + * In those cases the intent gets a selector: the same request for the scheme alone, with no + * host. Android picks the activity from the selector and still delivers the real URL, and only a + * browser handles a web URL with no host, since an app's link filter always names one. So the + * user's default browser opens the URL (or, with no default set, the system asks which + * browser). Every other URL is left alone, so links to other apps (YouTube, Maps) still open in + * those apps. + */ +@Suppress("DEPRECATION") +internal fun Context.keepOutOfThisApp(intent: Intent): Intent { + val scheme = intent.data?.scheme?.lowercase() + + if (scheme != "http" && scheme != "https") { + return intent + } + + // The app's own package is always visible to it, so no entry is needed. + val candidates = packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + + if (candidates.none { it.activityInfo.packageName == packageName }) { + return intent + } + + Log.d("Browser", "${intent.data} would open in this app, sending it to a browser instead") + + return intent.apply { + addCategory(Intent.CATEGORY_BROWSABLE) + selector = Intent(Intent.ACTION_VIEW, Uri.parse("$scheme://")) + .addCategory(Intent.CATEGORY_BROWSABLE) + } +} diff --git a/tests/PluginTest.php b/tests/PluginTest.php index b646f81..ecd627f 100644 --- a/tests/PluginTest.php +++ b/tests/PluginTest.php @@ -96,6 +96,19 @@ $parts = explode('.', $manifest['android']['init_function']); expect($kotlinContent)->toContain('fun '.end($parts).'(context: Context)'); }); + + it('sends the app\'s own links to a browser on Android instead of back into the app', function () { + $functions = file_get_contents($this->pluginPath.'/resources/android/BrowserFunctions.kt'); + $authTab = file_get_contents($this->pluginPath.'/resources/android/AuthTab.kt'); + + expect($functions)->toContain('internal fun Context.keepOutOfThisApp(intent: Intent): Intent'); + expect($functions)->toContain('selector = Intent(Intent.ACTION_VIEW, Uri.parse("$scheme://"))'); + + // Open, OpenInApp (and its plain fallback) and OpenAuth's tab all launch through it. + expect($functions)->toContain('activity.startActivity(activity.keepOutOfThisApp(intent))'); + expect($functions)->toContain('activity.keepOutOfThisApp(customTabsIntent.intent)'); + expect($authTab)->toContain('keepOutOfThisApp(tab.intent)'); + }); }); describe('Composer Configuration', function () {