commit af3d9ccb7baf7c153ccd117d8566ef015f49237f
parent 9e946915e46ae82fe0d609c763e117d9e40d0d0d
Author: Roger Yang <royang@mozilla.com>
Date: Wed, 5 Nov 2025 19:39:54 +0000
Bug 1991601 - Use fallback url to app links only if original URL is not supported by the engine. r=android-reviewers,tthibaud
Differential Revision: https://phabricator.services.mozilla.com/D267498
Diffstat:
4 files changed, 228 insertions(+), 22 deletions(-)
diff --git a/mobile/android/android-components/components/feature/app-links/src/main/java/mozilla/components/feature/app/links/AppLinksUseCases.kt b/mobile/android/android-components/components/feature/app-links/src/main/java/mozilla/components/feature/app/links/AppLinksUseCases.kt
@@ -112,17 +112,11 @@ class AppLinksUseCases(
getAppNameFromResolveInfo(context, resolveInfo)
} ?: ""
- // Only set fallback URL if url is not a Google PlayStore URL
- // The reason here is we already handled that case with the market place URL
- val fallbackUrl: String? = redirectData.fallbackUrl?.takeIf { url ->
- !isPlayStoreURL(url) || redirectData.resolveInfo == null
- }
-
val appIntent = when {
redirectData.resolveInfo == null -> null
isBrowserRedirect && isEngineSupportedScheme -> null
includeHttpAppLinks && isAppIntentHttpOrHttps -> redirectData.appIntent
- !launchInApp() && (isEngineSupportedScheme || fallbackUrl != null) -> null
+ !launchInApp() && (isEngineSupportedScheme || redirectData.fallbackUrl != null) -> null
else -> redirectData.appIntent
}
@@ -130,7 +124,7 @@ class AppLinksUseCases(
val appLinkRedirect = AppLinkRedirect(
appIntent = appIntent,
appName = appName,
- fallbackUrl = fallbackUrl,
+ fallbackUrl = redirectData.fallbackUrl,
marketplaceIntent = redirectData.marketplaceIntent,
)
@@ -198,9 +192,25 @@ class AppLinksUseCases(
}
}
+ /**
+ * Determines the fallback URL to use when attempting to redirect to an external app.
+ *
+ * The fallback URL is taken from the intent's `EXTRA_BROWSER_FALLBACK_URL` only if:
+ * - The original URL scheme is not supported by the engine, AND
+ * - The provided fallback URL is not a Google Play Store URL OR application is not
+ * installed. (Handled by marketplace intent)
+ */
+ val fallbackUrl = appIntent?.getStringExtra(EXTRA_BROWSER_FALLBACK_URL)?.takeIf {
+ val schemeEngineSupported = url.toUri().scheme in ENGINE_SUPPORTED_SCHEMES
+ val appInstalled = resolveInfo != null
+
+ val isPlayStoreUrlForInstalledApp = isPlayStoreURL(it) && appInstalled
+ !schemeEngineSupported && !isPlayStoreUrlForInstalledApp
+ }
+
return RedirectData(
appIntent = appIntent,
- fallbackUrl = null,
+ fallbackUrl = fallbackUrl,
marketplaceIntent = marketplaceIntent,
resolveInfo = resolveInfo,
)
diff --git a/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/AppLinksUseCasesTest.kt b/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/AppLinksUseCasesTest.kt
@@ -58,9 +58,11 @@ class AppLinksUseCasesTest {
private val appIntentWithPackageAndPlayStoreFallback =
"intent://com.example.app#Intent;package=com.example.com;S.browser_fallback_url=https://play.google.com/store/abc;end"
private val urlWithAndroidFallbackLink =
- "https://example.com/?afl=https://example.com"
+ "https://mozilla.org/?afl=https://example.com"
private val urlWithFallbackLink =
- "https://example.com/?link=https://example.com"
+ "https://mozilla.org/?link=https://example.com"
+ private val urlWithBrowserFallbackLink =
+ "https://mozilla.org/?S.browser_fallback_url=https://example.com"
@Before
fun setup() {
@@ -154,7 +156,7 @@ class AppLinksUseCasesTest {
val redirect = subject.interceptedAppLinkRedirect(appIntentWithPackageAndFallback)
assertFalse(redirect.hasMarketplaceIntent())
- assertFalse(redirect.hasFallback())
+ assertTrue(redirect.hasFallback())
}
@Test
@@ -165,7 +167,7 @@ class AppLinksUseCasesTest {
val redirect = subject.interceptedAppLinkRedirect(appIntentWithPackageAndFallback)
assertFalse(redirect.hasExternalApp())
assertTrue(redirect.hasMarketplaceIntent())
- assertFalse(redirect.hasFallback())
+ assertTrue(redirect.hasFallback())
}
@Test
@@ -348,7 +350,7 @@ class AppLinksUseCasesTest {
val redirect = subject.interceptedAppLinkRedirect(uri)
assertFalse(redirect.hasExternalApp())
- assertFalse(redirect.hasFallback())
+ assertTrue(redirect.hasFallback())
}
@Test
@@ -672,11 +674,12 @@ class AppLinksUseCasesTest {
val subject = AppLinksUseCases(context, { false })
val redirect = subject.interceptedAppLinkRedirect(appIntentWithPackageAndPlayStoreFallback)
assertFalse(redirect.hasExternalApp())
- assertFalse(redirect.hasFallback())
+ assertTrue(redirect.hasFallback())
+ assertEquals("https://play.google.com/store/abc", redirect.fallbackUrl)
}
@Test
- fun `WHEN A intent WITH android fallback link THEN fallback should NOT be used`() {
+ fun `GIVEN an url with android fallback link WHEN scheme is supported by the engine THEN fallback should not be used`() {
val context = createContext()
val subject = AppLinksUseCases(context, { true })
@@ -686,7 +689,7 @@ class AppLinksUseCasesTest {
}
@Test
- fun `WHEN A intent WITH fallback link THEN fallback should NOT be used`() {
+ fun `GIVEN an url with fallback link WHEN scheme is supported by the engine THEN fallback should not be used`() {
val context = createContext()
val subject = AppLinksUseCases(context, { true })
@@ -694,4 +697,14 @@ class AppLinksUseCasesTest {
assertNull(redirect.fallbackUrl)
assertFalse(redirect.hasFallback())
}
+
+ @Test
+ fun `GIVEN an url with browser fallback link WHEN scheme is supported by the engine THEN fallback should not be used`() {
+ val context = createContext()
+ val subject = AppLinksUseCases(context, { true })
+
+ val redirect = subject.interceptedAppLinkRedirect(urlWithBrowserFallbackLink)
+ assertNull(redirect.fallbackUrl)
+ assertFalse(redirect.hasFallback())
+ }
}
diff --git a/mobile/android/fenix/app/src/androidTest/assets/pages/appLinksLinks.html b/mobile/android/fenix/app/src/androidTest/assets/pages/appLinksLinks.html
@@ -34,4 +34,30 @@
Telephone post navigation link
</a>
</section>
+
+ <section>
+ <a
+ href="intent://1234567890#Intent;scheme=tel;S.browser_fallback_url=https://www.mozilla.org;end;"
+ >
+ Telephone with fallback URL
+ </a>
+ </section>
+
+ <section>
+ <a href="https://mozilla.org/?afl=https://youtube.com">
+ Link with android fallback link
+ </a>
+ </section>
+
+ <section>
+ <a href="https://mozilla.org/?link=https://youtube.com">
+ Link with fallback link
+ </a>
+ </section>
+
+ <section>
+ <a href="https://mozilla.org/?S.browser_fallback_url=https://youtube.com">
+ Link with browser fallback link
+ </a>
+ </section>
</html>
diff --git a/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/AppLinksTest.kt b/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/AppLinksTest.kt
@@ -36,7 +36,10 @@ class AppLinksTest : TestSetup() {
private val phoneUrlLink = itemContainingText("Telephone link")
private val formRedirectLink = itemContainingText("Telephone post navigation link")
private val intentSchemeWithExampleAppLink = itemContainingText("Example app link")
-
+ private val phoneWithFallbackLink = itemContainingText("Telephone with fallback URL")
+ private val linkWithAndroidFallbackLink = itemContainingText("Link with android fallback link")
+ private val linkWithFallbackLink = itemContainingText("Link with fallback link")
+ private val linkWithBrowserFallbackLink = itemContainingText("Link with browser fallback link")
private val phoneSchemaLink = "tel://1234567890"
@get:Rule
@@ -56,6 +59,12 @@ class AppLinksTest : TestSetup() {
externalLinksPage = mockWebServer.appLinksRedirectAsset
}
+ /**
+ * User setting: Ask
+ * Tests that when opening a youtube:// scheme link under “Ask”, the app prompt appears.
+ * After tapping “Cancel”, the browser stays on the same page (no external app opened).
+ * vnd.youtube://@Mozilla
+ */
@Test
fun askBeforeOpeningLinkInAppYoutubeSchemeCancelTest() {
navigationToolbar {
@@ -68,6 +77,12 @@ class AppLinksTest : TestSetup() {
}
}
+ /**
+ * User setting: Ask
+ * Clicking an intent:// link without corresponding app should not trigger the
+ * external-app prompt. The user stays on the same page.
+ * intent://com.example.app
+ */
@Test
fun askBeforeOpeningLinkWithIntentSchemeTest() {
navigationToolbar {
@@ -79,6 +94,12 @@ class AppLinksTest : TestSetup() {
}
}
+ /**
+ * User setting: Ask
+ * After canceling once for youtube://, tapping the same link again in the same tab
+ * should not show the prompt again. The browser remains on the test page.
+ * vnd.youtube://@Mozilla
+ */
@Test
fun askBeforeOpeningLinkInAppYoutubeSchemeCancelMultiTapTest() {
navigationToolbar {
@@ -98,6 +119,12 @@ class AppLinksTest : TestSetup() {
}
}
+ /**
+ * User setting: Ask
+ * Canceling a youtube:// link prompt affects only the current tab.
+ * In a new tab, the same link still shows the prompt.
+ * vnd.youtube://@Mozilla
+ */
@Test
fun askBeforeOpeningLinkInAppYoutubeSchemeCancelOnlyAffectCurrentTabTest() {
navigationToolbar {
@@ -118,6 +145,12 @@ class AppLinksTest : TestSetup() {
}
}
+ /**
+ * User setting: Never
+ * For an https YouTube link, no external-app prompt is shown.
+ * The page loads directly in-browser (verify “youtube.com”).
+ * https://m.youtube.com/user/mozilla
+ */
@Test
fun neverOpeningLinkInAppYoutubeTest() {
composeTestRule.activityRule.applySettingsExceptions {
@@ -133,6 +166,12 @@ class AppLinksTest : TestSetup() {
}
}
+ /**
+ * User setting: Never
+ * For a youtube:// scheme link, the app prompt still appears.
+ * After “Cancel”, the browser stays on the same page.
+ * vnd.youtube://@Mozilla
+ */
@Test
fun neverOpeningLinkInAppYoutubeSchemeCancelTest() {
composeTestRule.activityRule.applySettingsExceptions {
@@ -149,6 +188,12 @@ class AppLinksTest : TestSetup() {
}
}
+ /**
+ * User setting: Ask
+ * When opening a normal YouTube link, the app prompt appears.
+ * Tapping “Cancel” opens the YouTube website in-browser.
+ * https://m.youtube.com/user/mozilla
+ */
@Test
fun askBeforeOpeningLinkInAppYoutubeCancelTest() {
navigationToolbar {
@@ -161,27 +206,44 @@ class AppLinksTest : TestSetup() {
}
}
+ /**
+ * User setting: Ask
+ * Verifies that the “Open in Phone” prompt appears when tapping a tel: link.
+ * tel://1234567890
+ */
@Test
fun appLinksRedirectPhoneLinkPromptTest() {
navigationToolbar {
}.enterURLAndEnterToBrowser(externalLinksPage.url) {
clickPageObject(phoneUrlLink)
verifyOpenLinkInAnotherAppPrompt(appName = "Phone")
- clickPageObject(itemWithResIdAndText("android:id/button2", "Cancel"))
- mDevice.waitForIdle()
- verifyUrl(externalLinksPage.url.toString())
}
}
+ /**
+ * User setting: Ask
+ * Clicking a tel: link triggers the Phone prompt.
+ * Tapping “Cancel” keeps the user on the same page.
+ * tel://1234567890
+ */
@Test
fun askBeforeOpeningLinkInAppPhoneCancelTest() {
navigationToolbar {
}.enterURLAndEnterToBrowser(externalLinksPage.url) {
clickPageObject(phoneUrlLink)
verifyOpenLinkInAnotherAppPrompt(appName = "Phone")
+ clickPageObject(itemWithResIdAndText("android:id/button2", "Cancel"))
+ mDevice.waitForIdle()
+ verifyUrl(externalLinksPage.url.toString())
}
}
+ /**
+ * User setting: Always
+ * For tel: links, no prompt is shown.
+ * The native Phone app opens automatically with the correct URI.
+ * tel://1234567890
+ */
@Test
fun alwaysOpenPhoneLinkInAppTest() {
composeTestRule.activityRule.applySettingsExceptions {
@@ -196,6 +258,12 @@ class AppLinksTest : TestSetup() {
}
}
+ /**
+ * User setting: Ask
+ * When prompted for a tel: link and user taps “Open”,
+ * the Phone app launches, then control returns to the same browser page.
+ * tel://1234567890
+ */
@Test
fun askBeforeOpeningPhoneLinkInAcceptTest() {
navigationToolbar {
@@ -210,6 +278,11 @@ class AppLinksTest : TestSetup() {
}
}
+ /**
+ * User setting: Ask
+ * Form redirect leading to a tel: link should trigger the Phone app prompt.
+ * <form action="tel://1234567890" method="POST"></form>
+ */
@Test
fun appLinksNewTabRedirectAskTest() {
navigationToolbar {
@@ -219,6 +292,11 @@ class AppLinksTest : TestSetup() {
}
}
+ /**
+ * User setting: Always
+ * Form redirect leading to a tel: Phone app launches directly with no prompt.
+ * <form action="tel://1234567890" method="POST"></form>
+ */
@Test
fun appLinksNewTabRedirectAlwaysTest() {
composeTestRule.activityRule.applySettingsExceptions {
@@ -233,6 +311,11 @@ class AppLinksTest : TestSetup() {
}
}
+ /**
+ * User setting: Never
+ * Form redirect leading to a tel: prompt is still shown for the tel: link.
+ * <form action="tel://1234567890" method="POST"></form>
+ */
@Test
fun appLinksNewTabRedirectNeverTest() {
composeTestRule.activityRule.applySettingsExceptions {
@@ -246,11 +329,22 @@ class AppLinksTest : TestSetup() {
}
}
+ /**
+ * User setting: Ask
+ * When prompted for a external application not installed: user taps “Open”,
+ * a marketing intent should be used.
+ * intent://com.example.app#Intent;package=com.example.app;end
+ */
@Test
fun marketingIntentWhenOpeningLinkWithoutApp() {
// Use ACTION_DIAL as a non-ACTION_VIEW intent to verify that the marketing flow always
// launches with ACTION_VIEW instead of reusing the original intent action.
- intending(hasAction(Intent.ACTION_DIAL)).respondWith(Instrumentation.ActivityResult(0, null))
+ intending(hasAction(Intent.ACTION_DIAL)).respondWith(
+ Instrumentation.ActivityResult(
+ 0,
+ null,
+ ),
+ )
navigationToolbar {
}.enterURLAndEnterToBrowser(externalLinksPage.url) {
@@ -261,4 +355,67 @@ class AppLinksTest : TestSetup() {
intended(hasDataString(equalTo("market://details?id=com.example.app")))
}
}
+
+ /**
+ * User setting: Ask
+ * For a tel: link with a browser fallback, tapping “Cancel” navigates
+ * to the fallback URL (mozilla.org).
+ * intent://1234567890#Intent;scheme=tel;S.browser_fallback_url=https://www.mozilla.org;end;
+ */
+ @Test
+ fun appLinksBrowserFallbackURLTest() {
+ navigationToolbar {
+ }.enterURLAndEnterToBrowser(externalLinksPage.url) {
+ clickPageObject(phoneWithFallbackLink)
+ verifyOpenLinkInAnotherAppPrompt(appName = "Phone")
+ clickPageObject(itemWithResIdAndText("android:id/button2", "Cancel"))
+ mDevice.waitForIdle()
+ verifyUrl("mozilla.org")
+ }
+ }
+
+ /**
+ * User setting: Ask
+ * Link with supported scheme will never load the "afl" fallback URL
+ * https://mozilla.org/?afl=https://youtube.com
+ */
+ @Test
+ fun linkWithAndroidFallbackLinkTest() {
+ navigationToolbar {
+ }.enterURLAndEnterToBrowser(externalLinksPage.url) {
+ clickPageObject(linkWithAndroidFallbackLink)
+ mDevice.waitForIdle()
+ verifyUrl("mozilla.org")
+ }
+ }
+
+ /**
+ * User setting: Ask
+ * Link with supported scheme will never load the "link" fallback URL
+ * https://mozilla.org/?link=https://youtube.com
+ */
+ @Test
+ fun linkWithFallbackLinkTest() {
+ navigationToolbar {
+ }.enterURLAndEnterToBrowser(externalLinksPage.url) {
+ clickPageObject(linkWithFallbackLink)
+ mDevice.waitForIdle()
+ verifyUrl("mozilla.org")
+ }
+ }
+
+ /**
+ * User setting: Ask
+ * Link with supported scheme will never load the "S.browser_fallback_url" fallback URL
+ * https://mozilla.org/?S.browser_fallback_url=https://youtube.com
+ */
+ @Test
+ fun linkWithBrowserFallbackLinkTest() {
+ navigationToolbar {
+ }.enterURLAndEnterToBrowser(externalLinksPage.url) {
+ clickPageObject(linkWithBrowserFallbackLink)
+ mDevice.waitForIdle()
+ verifyUrl("mozilla.org")
+ }
+ }
}