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 () {