Bug 1915612 - Enable app links interceptor to also handle user prompts r=android-reviewers,tthibaud

Differential Revision: https://phabricator.services.mozilla.com/D223887
This commit is contained in:
Roger Yang 2024-10-14 18:31:05 +00:00
parent d3f5a90afc
commit 2d73b99c54
13 changed files with 634 additions and 88 deletions

View File

@ -8,6 +8,7 @@ import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.CustomTabSessionState
import mozilla.components.browser.state.state.SessionState
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.concept.engine.EngineSession
import mozilla.components.support.base.log.logger.Logger
import java.net.URI
import java.net.URISyntaxException
@ -39,6 +40,17 @@ fun BrowserState.findTab(tabId: String): TabSessionState? {
return tabs.firstOrNull { it.id == tabId }
}
/**
* Finds and returns the tab with the given [EngineSession]. Returns null if no matching tab could be
* found.
*
* @param engineSession The engineSession of the tab to search for.
* @return The [TabSessionState] with the provided [EngineSession] or null if it could not be found.
*/
fun BrowserState.findTab(engineSession: EngineSession): TabSessionState? {
return tabs.firstOrNull { it.engineState.engineSession == engineSession }
}
/**
* Finds and returns the Custom Tab with the given id. Returns null if no matching tab could be
* found.

View File

@ -104,6 +104,22 @@ class SelectorsKtTest {
assertNull(state.findTab(customTab.id))
}
@Test
fun `WHEN findTab WITH engine session THEN currect tab is returned`() {
val tab = createTab("https://www.firefox.com")
val otherTab = createTab("https://getpocket.com")
val customTab = createCustomTab("https://www.mozilla.org")
val state = BrowserState(
tabs = listOf(tab, otherTab),
customTabs = listOf(customTab),
)
assertEquals(tab, state.findTab(tab.id))
assertEquals(otherTab, state.findTab(otherTab.id))
assertNull(state.findTab(customTab.id))
}
@Test
fun `findNormalTab extension function`() {
val privateTab = createTab("https://www.firefox.com", private = true)

View File

@ -95,7 +95,7 @@ class AppLinksFeature(
}
val doNotOpenApp = {
AppLinksInterceptor.addUserDoNotIntercept(url, appIntent)
AppLinksInterceptor.addUserDoNotIntercept(url, appIntent, tab.id)
loadUrlIfSchemeSupported(tab, url)
}

View File

@ -10,10 +10,16 @@ import android.content.Intent
import android.net.Uri
import android.os.SystemClock
import androidx.annotation.VisibleForTesting
import androidx.fragment.app.FragmentManager
import mozilla.components.browser.state.selector.findTab
import mozilla.components.browser.state.state.SessionState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.EngineSession
import mozilla.components.concept.engine.request.RequestInterceptor
import mozilla.components.feature.app.links.AppLinksUseCases.Companion.ALWAYS_DENY_SCHEMES
import mozilla.components.feature.app.links.AppLinksUseCases.Companion.ENGINE_SUPPORTED_SCHEMES
import mozilla.components.feature.app.links.RedirectDialogFragment.Companion.FRAGMENT_TAG
import mozilla.components.support.ktx.android.content.appName
import mozilla.components.support.ktx.android.net.isHttpOrHttps
import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl
@ -47,7 +53,11 @@ private const val MAPS = "maps."
* of security concerns.
* @param useCases These use cases allow for the detection of, and opening of links that other apps
* have registered to open.
* @param launchFromInterceptor If {true} then the interceptor will launch the link in third-party apps if available.
* @param launchFromInterceptor If {true} then the interceptor will prompt and launch the link in
* third-party apps if available. Do not use this in conjunction with [AppLinksFeature]
* @param store [BrowserStore] containing the information about the currently open tabs.
* @param shouldPrompt If {true} then we should prompt the user before redirect.
* @param failedToLaunchAction Action to perform when failing to launch in third party app.
*/
class AppLinksInterceptor(
private val context: Context,
@ -61,7 +71,20 @@ class AppLinksInterceptor(
alwaysDeniedSchemes = alwaysDeniedSchemes,
),
private val launchFromInterceptor: Boolean = false,
private val store: BrowserStore? = null,
private val shouldPrompt: () -> Boolean = { true },
private val failedToLaunchAction: (fallbackUrl: String?) -> Unit = {},
) : RequestInterceptor {
private var fragmentManager: FragmentManager? = null
private val dialog: RedirectDialogFragment? = null
/**
* Update [FragmentManager] for this instance of AppLinksInterceptor
* @param fragmentManager the new value of [FragmentManager]
*/
fun updateFragmentManger(fragmentManager: FragmentManager?) {
this.fragmentManager = fragmentManager
}
/**
* Update launchInApp for this instance of AppLinksInterceptor
@ -87,6 +110,7 @@ class AppLinksInterceptor(
val uriScheme = encodedUri.scheme
val engineSupportsScheme = engineSupportedSchemes.contains(uriScheme)
val isAllowedRedirect = (isRedirect && !isSubframeRequest)
val tabSessionState = store?.state?.findTab(engineSession)
val doNotIntercept = when {
uriScheme == null -> true
@ -109,8 +133,9 @@ class AppLinksInterceptor(
return null
}
val tabId = tabSessionState?.id ?: ""
val redirect = useCases.interceptedAppLinkRedirect(uri)
val result = handleRedirect(redirect, uri, engineSupportedSchemes.contains(uriScheme))
val result = handleRedirect(redirect, uri, tabId, engineSupportedSchemes.contains(uriScheme))
if (redirect.hasExternalApp()) {
val packageName = redirect.appIntent?.component?.packageName
@ -126,9 +151,12 @@ class AppLinksInterceptor(
}
if (redirect.isRedirect()) {
if (launchFromInterceptor && result is RequestInterceptor.InterceptionResponse.AppIntent) {
result.appIntent.flags = result.appIntent.flags or Intent.FLAG_ACTIVITY_NEW_TASK
useCases.openAppLink(result.appIntent)
if (
launchFromInterceptor &&
result is RequestInterceptor.InterceptionResponse.AppIntent
) {
handleAppIntent(tabSessionState, uri, redirect.appIntent)
return null
}
return result
@ -143,15 +171,16 @@ class AppLinksInterceptor(
internal fun handleRedirect(
redirect: AppLinkRedirect,
uri: String,
tabId: String,
schemeSupported: Boolean,
): RequestInterceptor.InterceptionResponse? {
if (!launchInApp() || inUserDoNotIntercept(uri, redirect.appIntent)) {
if (!launchInApp() || inUserDoNotIntercept(uri, redirect.appIntent, tabId)) {
redirect.fallbackUrl?.let {
return RequestInterceptor.InterceptionResponse.Url(it)
}
}
if (schemeSupported && inUserDoNotIntercept(uri, redirect.appIntent)) {
if (schemeSupported && inUserDoNotIntercept(uri, redirect.appIntent, tabId)) {
return null
}
@ -193,6 +222,105 @@ class AppLinksInterceptor(
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun handleAppIntent(
sessionState: SessionState?,
url: String,
appIntent: Intent?,
) {
if (appIntent == null) {
return
}
val fragmentManager = fragmentManager
val isPrivate = sessionState?.content?.private == true
val doNotOpenApp = {
addUserDoNotIntercept(url, appIntent, sessionState?.id)
}
val doOpenApp = {
useCases.openAppLink(
appIntent,
failedToLaunchAction = failedToLaunchAction,
)
}
// Without fragment manager we are unable to prompt
// Only non private tabs we can redirect to external app without prompt
// Authentication flow should not prompt
val isAuthenticationFlow =
sessionState?.let { isAuthentication(sessionState, appIntent) } == true
val shouldShowPrompt = isPrivate || shouldPrompt()
if (fragmentManager == null || !shouldShowPrompt || isAuthenticationFlow) {
doOpenApp()
return
}
if (isADialogAlreadyCreated()) {
return
}
getOrCreateDialog(isPrivate, url).apply {
onConfirmRedirect = doOpenApp
onCancelRedirect = doNotOpenApp
}.showNow(fragmentManager, FRAGMENT_TAG)
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun getOrCreateDialog(isPrivate: Boolean, url: String): RedirectDialogFragment {
if (dialog != null) {
return dialog
}
val dialogTitle = if (isPrivate) {
R.string.mozac_feature_applinks_confirm_dialog_title
} else {
R.string.mozac_feature_applinks_normal_confirm_dialog_title
}
val dialogMessage = if (isPrivate) {
url
} else {
context.getString(
R.string.mozac_feature_applinks_normal_confirm_dialog_message,
context.appName,
)
}
return SimpleRedirectDialogFragment.newInstance(
dialogTitleText = dialogTitle,
dialogMessageString = dialogMessage,
positiveButtonText = R.string.mozac_feature_applinks_confirm_dialog_confirm,
negativeButtonText = R.string.mozac_feature_applinks_confirm_dialog_deny,
)
}
private fun isADialogAlreadyCreated(): Boolean {
return fragmentManager?.findFragmentByTag(FRAGMENT_TAG) as? RedirectDialogFragment != null
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun isAuthentication(sessionState: SessionState, appIntent: Intent): Boolean {
return when (sessionState.source) {
is SessionState.Source.External.ActionSend,
is SessionState.Source.External.ActionSearch,
-> false
// CustomTab and ActionView can be used for authentication
is SessionState.Source.External.CustomTab,
is SessionState.Source.External.ActionView,
-> {
(sessionState.source as? SessionState.Source.External)?.let { externalSource ->
when (externalSource.caller?.packageId) {
null -> false
appIntent.component?.packageName -> true
else -> false
}
} ?: false
}
else -> false
}
}
companion object {
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal var userDoNotInterceptCache: MutableMap<Int, Long> = mutableMapOf()
@ -201,19 +329,21 @@ class AppLinksInterceptor(
internal var lastApplinksPackageWithTimestamp: Pair<String?, Long> = Pair(null, 0L)
@VisibleForTesting
internal fun getCacheKey(url: String, appIntent: Intent?): Int? {
internal fun getCacheKey(url: String, appIntent: Intent?, tabId: String?): Int? {
return Uri.parse(url)?.let { uri ->
when {
appIntent?.component?.packageName != null -> appIntent.component?.packageName
!uri.isHttpOrHttps -> uri.scheme
else -> uri.host // worst case we do not prompt again on this host
}.hashCode()
}?.let {
(it + (tabId.orEmpty())).hashCode() // do not open cache should only apply to this tab
}
}
}
@VisibleForTesting
internal fun inUserDoNotIntercept(url: String, appIntent: Intent?): Boolean {
val cacheKey = getCacheKey(url, appIntent)
internal fun inUserDoNotIntercept(url: String, appIntent: Intent?, tabId: String?): Boolean {
val cacheKey = getCacheKey(url, appIntent, tabId)
val cacheTimeStamp = userDoNotInterceptCache[cacheKey]
val currentTimeStamp = SystemClock.elapsedRealtime()
@ -221,8 +351,8 @@ class AppLinksInterceptor(
currentTimeStamp <= (cacheTimeStamp + APP_LINKS_DO_NOT_OPEN_CACHE_INTERVAL)
}
internal fun addUserDoNotIntercept(url: String, appIntent: Intent?) {
val cacheKey = getCacheKey(url, appIntent)
internal fun addUserDoNotIntercept(url: String, appIntent: Intent?, tabId: String?) {
val cacheKey = getCacheKey(url, appIntent, tabId)
cacheKey?.let {
userDoNotInterceptCache[it] = SystemClock.elapsedRealtime()
}

View File

@ -223,7 +223,7 @@ class AppLinksFeatureTest {
}
@Test
fun `WHEN tab have action view and caller is the same as external app THEN an external app dialog is shown`() {
fun `WHEN tab have action view and caller is the same as external app THEN an external app dialog is not shown`() {
feature = spy(
AppLinksFeature(
context = mockContext,

View File

@ -7,7 +7,16 @@ package mozilla.components.feature.app.links
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.fragment.app.FragmentManager
import androidx.test.ext.junit.runners.AndroidJUnit4
import mozilla.components.browser.state.state.ContentState
import mozilla.components.browser.state.state.ExternalPackage
import mozilla.components.browser.state.state.PackageCategory
import mozilla.components.browser.state.state.SessionState
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.state.state.createCustomTab
import mozilla.components.browser.state.state.createTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.EngineSession
import mozilla.components.concept.engine.request.RequestInterceptor
import mozilla.components.feature.app.links.AppLinksInterceptor.Companion.APP_LINKS_DO_NOT_INTERCEPT_INTERVAL
@ -16,7 +25,9 @@ import mozilla.components.feature.app.links.AppLinksInterceptor.Companion.addUse
import mozilla.components.feature.app.links.AppLinksInterceptor.Companion.inUserDoNotIntercept
import mozilla.components.feature.app.links.AppLinksInterceptor.Companion.lastApplinksPackageWithTimestamp
import mozilla.components.feature.app.links.AppLinksInterceptor.Companion.userDoNotInterceptCache
import mozilla.components.feature.session.SessionUseCases
import mozilla.components.support.test.any
import mozilla.components.support.test.eq
import mozilla.components.support.test.mock
import mozilla.components.support.test.whenever
import org.junit.Assert.assertEquals
@ -27,16 +38,24 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.anyBoolean
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.never
import org.mockito.Mockito.spy
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
@RunWith(AndroidJUnit4::class)
class AppLinksInterceptorTest {
private lateinit var mockContext: Context
private lateinit var store: BrowserStore
private lateinit var mockUseCases: AppLinksUseCases
private lateinit var mockGetRedirect: AppLinksUseCases.GetAppLinkRedirect
private lateinit var mockEngineSession: EngineSession
private lateinit var mockOpenRedirect: AppLinksUseCases.OpenAppLinkRedirect
private lateinit var mockFragmentManager: FragmentManager
private lateinit var mockDialog: RedirectDialogFragment
private lateinit var mockLoadUrlUseCase: SessionUseCases.DefaultLoadUrlUseCase
private lateinit var appLinksInterceptor: AppLinksInterceptor
@ -49,12 +68,17 @@ class AppLinksInterceptorTest {
@Before
fun setup() {
mockContext = mock()
store = BrowserStore()
mockUseCases = mock()
mockEngineSession = mock()
mockGetRedirect = mock()
mockOpenRedirect = mock()
mockDialog = mock()
mockLoadUrlUseCase = mock()
mockFragmentManager = mock()
whenever(mockUseCases.interceptedAppLinkRedirect).thenReturn(mockGetRedirect)
whenever(mockUseCases.openAppLink).thenReturn(mockOpenRedirect)
whenever(mockFragmentManager.beginTransaction()).thenReturn(mock())
userDoNotInterceptCache.clear()
lastApplinksPackageWithTimestamp = Pair(null, -APP_LINKS_DO_NOT_INTERCEPT_INTERVAL)
@ -450,11 +474,28 @@ class AppLinksInterceptorTest {
launchInApp = { true },
useCases = mockUseCases,
launchFromInterceptor = true,
shouldPrompt = { false },
)
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, webUrlWithAppLink, null, true, false, false, false, false)
assertNull(response)
verify(mockOpenRedirect).invoke(any(), anyBoolean(), any())
}
@Test
fun `WHEN launch from intercept is false AND launch in app is set to true and it is user triggered THEN app intent is returned`() {
appLinksInterceptor = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = true,
launchInApp = { true },
useCases = mockUseCases,
launchFromInterceptor = false,
shouldPrompt = { false },
)
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, webUrlWithAppLink, null, true, false, false, false, false)
assert(response is RequestInterceptor.InterceptionResponse.AppIntent)
verify(mockOpenRedirect).invoke(any(), anyBoolean(), any())
verify(mockOpenRedirect, times(0)).invoke(any(), anyBoolean(), any())
}
@Test
@ -468,7 +509,7 @@ class AppLinksInterceptorTest {
)
val testRedirect = AppLinkRedirect(Intent.parseUri(intentUrl, 0), "", fallbackUrl, null)
val response = appLinksInterceptor.handleRedirect(testRedirect, intentUrl, true)
val response = appLinksInterceptor.handleRedirect(testRedirect, intentUrl, "", true)
assert(response is RequestInterceptor.InterceptionResponse.Url)
}
@ -483,10 +524,25 @@ class AppLinksInterceptorTest {
)
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, intentUrl, null, false, true, false, false, false)
assert(response is RequestInterceptor.InterceptionResponse.AppIntent)
assertNull(response)
verify(mockOpenRedirect).invoke(any(), anyBoolean(), any())
}
@Test
fun `WHEN url scheme is not supported by the engine AND launch from interceptor is false THEN app intent is returned`() {
appLinksInterceptor = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = true,
launchInApp = { false },
useCases = mockUseCases,
launchFromInterceptor = false,
)
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, intentUrl, null, false, true, false, false, false)
assert(response is RequestInterceptor.InterceptionResponse.AppIntent)
verify(mockOpenRedirect, times(0)).invoke(any(), anyBoolean(), any())
}
@Test
fun `do not use fallback url if trigger by user gesture and preference is to launch in app`() {
appLinksInterceptor = AppLinksInterceptor(
@ -498,7 +554,7 @@ class AppLinksInterceptorTest {
)
val testRedirect = AppLinkRedirect(Intent.parseUri(intentUrl, 0), "", fallbackUrl, null)
val response = appLinksInterceptor.handleRedirect(testRedirect, intentUrl, true)
val response = appLinksInterceptor.handleRedirect(testRedirect, intentUrl, "", true)
assert(response is RequestInterceptor.InterceptionResponse.AppIntent)
}
@ -513,7 +569,7 @@ class AppLinksInterceptorTest {
)
val testRedirect = AppLinkRedirect(null, "", fallbackUrl, Intent.parseUri(marketplaceUrl, 0))
val response = appLinksInterceptor.handleRedirect(testRedirect, webUrl, true)
val response = appLinksInterceptor.handleRedirect(testRedirect, webUrl, "", true)
assert(response is RequestInterceptor.InterceptionResponse.AppIntent)
}
@ -528,7 +584,7 @@ class AppLinksInterceptorTest {
)
val testRedirect = AppLinkRedirect(null, "", fallbackUrl, null)
val response = appLinksInterceptor.handleRedirect(testRedirect, webUrl, true)
val response = appLinksInterceptor.handleRedirect(testRedirect, webUrl, "", true)
assert(response is RequestInterceptor.InterceptionResponse.Url)
}
@ -560,7 +616,7 @@ class AppLinksInterceptorTest {
useCases = mockUseCases,
)
addUserDoNotIntercept("https://soundcloud.com", null)
addUserDoNotIntercept("https://soundcloud.com", null, "")
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, webUrlWithAppLink, null, true, false, false, false, false)
assertNull(response)
@ -576,9 +632,9 @@ class AppLinksInterceptorTest {
launchFromInterceptor = true,
)
addUserDoNotIntercept(intentUrl, null)
addUserDoNotIntercept(intentUrl, null, "")
val testRedirect = AppLinkRedirect(Intent.parseUri(intentUrl, 0), "", fallbackUrl, null)
val response = appLinksInterceptor.handleRedirect(testRedirect, intentUrl, true)
val response = appLinksInterceptor.handleRedirect(testRedirect, intentUrl, "", true)
assert(response is RequestInterceptor.InterceptionResponse.Url)
}
@ -598,7 +654,7 @@ class AppLinksInterceptorTest {
)
val notSupportedUrl = "$notSupportedScheme://example.com"
addUserDoNotIntercept(notSupportedUrl, null)
addUserDoNotIntercept(notSupportedUrl, null, "")
val notSupportedRedirect = AppLinkRedirect(Intent.parseUri(notSupportedUrl, 0), "", null, null)
whenever(mockGetRedirect.invoke(notSupportedUrl)).thenReturn(notSupportedRedirect)
val response = feature.onLoadRequest(engineSession, notSupportedUrl, null, false, false, false, false, false)
@ -607,26 +663,26 @@ class AppLinksInterceptorTest {
@Test
fun `WHEN added to user do not open cache THEN return true if user do no intercept cache exists`() {
addUserDoNotIntercept("test://test.com", null)
assertTrue(inUserDoNotIntercept("test://test.com", null))
assertFalse(inUserDoNotIntercept("https://test.com", null))
addUserDoNotIntercept("test://test.com", null, "")
assertTrue(inUserDoNotIntercept("test://test.com", null, ""))
assertFalse(inUserDoNotIntercept("https://test.com", null, ""))
addUserDoNotIntercept("http://test.com", null)
assertTrue(inUserDoNotIntercept("https://test.com", null))
assertFalse(inUserDoNotIntercept("https://example.com", null))
addUserDoNotIntercept("http://test.com", null, "")
assertTrue(inUserDoNotIntercept("https://test.com", null, ""))
assertFalse(inUserDoNotIntercept("https://example.com", null, ""))
val testIntent: Intent = mock()
val componentName: ComponentName = mock()
doReturn(componentName).`when`(testIntent).component
doReturn("app.example.com").`when`(componentName).packageName
addUserDoNotIntercept("https://example.com", testIntent)
assertTrue(inUserDoNotIntercept("https://example.com", testIntent))
assertTrue(inUserDoNotIntercept("https://test.com", testIntent))
addUserDoNotIntercept("https://example.com", testIntent, "")
assertTrue(inUserDoNotIntercept("https://example.com", testIntent, ""))
assertTrue(inUserDoNotIntercept("https://test.com", testIntent, ""))
doReturn("app.test.com").`when`(componentName).packageName
assertFalse(inUserDoNotIntercept("https://test.com", testIntent))
assertFalse(inUserDoNotIntercept("https://mozilla.org", null))
assertFalse(inUserDoNotIntercept("https://test.com", testIntent, ""))
assertFalse(inUserDoNotIntercept("https://mozilla.org", null, ""))
}
@Test
@ -636,13 +692,13 @@ class AppLinksInterceptorTest {
doReturn(componentName).`when`(testIntent).component
doReturn("app.example.com").`when`(componentName).packageName
addUserDoNotIntercept("https://example.com", testIntent)
assertTrue(inUserDoNotIntercept("https://example.com", testIntent))
assertTrue(inUserDoNotIntercept("https://test.com", testIntent))
addUserDoNotIntercept("https://example.com", testIntent, "")
assertTrue(inUserDoNotIntercept("https://example.com", testIntent, ""))
assertTrue(inUserDoNotIntercept("https://test.com", testIntent, ""))
userDoNotInterceptCache["app.example.com".hashCode()] = -APP_LINKS_DO_NOT_OPEN_CACHE_INTERVAL
assertFalse(inUserDoNotIntercept("https://example.com", testIntent))
assertFalse(inUserDoNotIntercept("https://test.com", testIntent))
assertFalse(inUserDoNotIntercept("https://example.com", testIntent, ""))
assertFalse(inUserDoNotIntercept("https://test.com", testIntent, ""))
}
@Test
@ -676,4 +732,354 @@ class AppLinksInterceptorTest {
response = appLinksInterceptor.onLoadRequest(mockEngineSession, webUrlWithAppLink, null, true, false, false, false, false)
assertTrue(response is RequestInterceptor.InterceptionResponse.AppIntent)
}
@Test
fun `WHEN should prompt AND in non-private mode THEN an external app dialog is shown`() {
appLinksInterceptor = spy(
AppLinksInterceptor(
context = mockContext,
store = store,
interceptLinkClicks = true,
launchInApp = { true },
useCases = mockUseCases,
shouldPrompt = { true },
),
)
appLinksInterceptor.updateFragmentManger(mockFragmentManager)
val tab = createTab(webUrl)
val mockAppIntent: Intent = mock()
val mockComponentName: ComponentName = mock()
whenever(mockAppIntent.component).thenReturn(mockComponentName)
whenever(mockComponentName.packageName).thenReturn("com.zxing.app")
doReturn(mockDialog).`when`(appLinksInterceptor).getOrCreateDialog(anyBoolean(), any())
appLinksInterceptor.handleAppIntent(tab, intentUrl, mockAppIntent)
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
}
@Test
fun `WHEN should not prompt AND in non-private mode THEN an external app dialog is not shown`() {
appLinksInterceptor = spy(
AppLinksInterceptor(
context = mockContext,
store = store,
interceptLinkClicks = true,
launchInApp = { true },
useCases = mockUseCases,
shouldPrompt = { false },
),
)
appLinksInterceptor.updateFragmentManger(mockFragmentManager)
val tab = createTab(webUrl)
appLinksInterceptor.handleAppIntent(tab, intentUrl, mock())
verify(mockDialog, never()).showNow(eq(mockFragmentManager), anyString())
}
@Test
fun `WHEN custom tab and caller is the same as external app THEN an external app dialog is not shown`() {
appLinksInterceptor = spy(
AppLinksInterceptor(
context = mockContext,
store = store,
interceptLinkClicks = true,
launchInApp = { true },
useCases = mockUseCases,
shouldPrompt = { true },
),
)
appLinksInterceptor.updateFragmentManger(mockFragmentManager)
val tab = createCustomTab(
id = "c",
url = webUrl,
source = SessionState.Source.External.CustomTab(
ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY),
),
)
val appIntent: Intent = mock()
val componentName: ComponentName = mock()
whenever(appIntent.component).thenReturn(componentName)
whenever(componentName.packageName).thenReturn("com.zxing.app")
appLinksInterceptor.handleAppIntent(tab, intentUrl, appIntent)
verify(mockDialog, never()).showNow(eq(mockFragmentManager), anyString())
}
@Test
fun `WHEN tab have action view and caller is the same as external app THEN an external app dialog is not shown`() {
appLinksInterceptor = spy(
AppLinksInterceptor(
context = mockContext,
store = store,
interceptLinkClicks = true,
launchInApp = { true },
useCases = mockUseCases,
shouldPrompt = { true },
),
)
appLinksInterceptor.updateFragmentManger(mockFragmentManager)
val tab = createCustomTab(
id = "d",
url = webUrl,
source = SessionState.Source.External.ActionView(
ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY),
),
)
val appIntent: Intent = mock()
val componentName: ComponentName = mock()
whenever(appIntent.component).thenReturn(componentName)
whenever(componentName.packageName).thenReturn("com.zxing.app")
appLinksInterceptor.handleAppIntent(tab, intentUrl, appIntent)
verify(mockDialog, never()).showNow(eq(mockFragmentManager), anyString())
}
@Test
fun `WHEN tab have action send and caller is the same as external app THEN an external app dialog is shown`() {
appLinksInterceptor = spy(
AppLinksInterceptor(
context = mockContext,
store = store,
interceptLinkClicks = true,
launchInApp = { true },
useCases = mockUseCases,
shouldPrompt = { true },
),
)
appLinksInterceptor.updateFragmentManger(mockFragmentManager)
val tab = createCustomTab(
id = "d",
url = webUrl,
source = SessionState.Source.External.ActionSend(
ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY),
),
)
val appIntent: Intent = mock()
val componentName: ComponentName = mock()
whenever(appIntent.component).thenReturn(componentName)
whenever(componentName.packageName).thenReturn("com.zxing.app")
doReturn(mockDialog).`when`(appLinksInterceptor).getOrCreateDialog(anyBoolean(), any())
appLinksInterceptor.handleAppIntent(tab, intentUrl, appIntent)
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
}
@Test
fun `WHEN tab have action search and caller is the same as external app THEN an external app dialog is shown`() {
appLinksInterceptor = spy(
AppLinksInterceptor(
context = mockContext,
store = store,
interceptLinkClicks = true,
launchInApp = { true },
useCases = mockUseCases,
shouldPrompt = { true },
),
)
appLinksInterceptor.updateFragmentManger(mockFragmentManager)
val tab = createCustomTab(
id = "d",
url = webUrl,
source = SessionState.Source.External.ActionSearch(
ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY),
),
)
val appIntent: Intent = mock()
val componentName: ComponentName = mock()
whenever(appIntent.component).thenReturn(componentName)
whenever(componentName.packageName).thenReturn("com.zxing.app")
doReturn(mockDialog).`when`(appLinksInterceptor).getOrCreateDialog(anyBoolean(), any())
appLinksInterceptor.handleAppIntent(tab, intentUrl, appIntent)
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
}
@Test
fun `WHEN should prompt and in private mode THEN an external app dialog is shown`() {
appLinksInterceptor = spy(
AppLinksInterceptor(
context = mockContext,
store = store,
interceptLinkClicks = true,
launchInApp = { true },
useCases = mockUseCases,
shouldPrompt = { true },
),
)
appLinksInterceptor.updateFragmentManger(mockFragmentManager)
val tabSessionState = TabSessionState(
id = "tab1",
content = ContentState(
url = "https://mozilla.org",
private = true,
isProductUrl = false,
),
)
val mockAppIntent: Intent = mock()
val mockComponentName: ComponentName = mock()
whenever(mockAppIntent.component).thenReturn(mockComponentName)
whenever(mockComponentName.packageName).thenReturn("com.zxing.app")
doReturn(mockDialog).`when`(appLinksInterceptor).getOrCreateDialog(anyBoolean(), any())
appLinksInterceptor.handleAppIntent(tabSessionState, intentUrl, mockAppIntent)
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
}
@Test
fun `WHEN should not prompt and in private mode THEN an external app dialog is shown`() {
appLinksInterceptor = spy(
AppLinksInterceptor(
context = mockContext,
store = store,
interceptLinkClicks = true,
launchInApp = { true },
useCases = mockUseCases,
shouldPrompt = { false },
),
)
appLinksInterceptor.updateFragmentManger(mockFragmentManager)
val tabSessionState = TabSessionState(
id = "tab1",
content = ContentState(
url = "https://mozilla.org",
private = true,
isProductUrl = false,
),
)
val mockAppIntent: Intent = mock()
val mockComponentName: ComponentName = mock()
whenever(mockAppIntent.component).thenReturn(mockComponentName)
whenever(mockComponentName.packageName).thenReturn("com.zxing.app")
doReturn(mockDialog).`when`(appLinksInterceptor).getOrCreateDialog(anyBoolean(), any())
appLinksInterceptor.handleAppIntent(tabSessionState, intentUrl, mockAppIntent)
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
}
@Test
fun `redirect dialog is only added once`() {
appLinksInterceptor = spy(
AppLinksInterceptor(
context = mockContext,
store = store,
interceptLinkClicks = true,
launchInApp = { true },
useCases = mockUseCases,
shouldPrompt = { true },
),
)
appLinksInterceptor.updateFragmentManger(mockFragmentManager)
val tabSessionState = TabSessionState(
id = "tab1",
content = ContentState(
url = "https://mozilla.org",
private = false,
isProductUrl = false,
),
)
val appIntent: Intent = mock()
val componentName: ComponentName = mock()
whenever(appIntent.component).thenReturn(componentName)
whenever(componentName.packageName).thenReturn("com.zxing.app")
doReturn(mockDialog).`when`(appLinksInterceptor).getOrCreateDialog(anyBoolean(), any())
appLinksInterceptor.handleAppIntent(tabSessionState, intentUrl, appIntent)
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
doReturn(mockDialog).`when`(appLinksInterceptor).getOrCreateDialog(false, "")
doReturn(mockDialog).`when`(mockFragmentManager).findFragmentByTag(RedirectDialogFragment.FRAGMENT_TAG)
appLinksInterceptor.handleAppIntent(tabSessionState, intentUrl, mock())
verify(mockDialog, times(1)).showNow(mockFragmentManager, RedirectDialogFragment.FRAGMENT_TAG)
}
@Test
fun `WHEN caller and intent have the same package name THEN return true`() {
appLinksInterceptor = spy(
AppLinksInterceptor(
context = mockContext,
store = store,
interceptLinkClicks = true,
launchInApp = { true },
useCases = mockUseCases,
shouldPrompt = { true },
),
)
appLinksInterceptor.updateFragmentManger(mockFragmentManager)
val tabSessionState = TabSessionState(
id = "tab1",
content = ContentState(
url = "https://mozilla.org",
private = false,
isProductUrl = false,
),
source = SessionState.Source.External.CustomTab(ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY)),
)
val appIntent: Intent = mock()
val componentName: ComponentName = mock()
whenever(appIntent.component).thenReturn(componentName)
whenever(componentName.packageName).thenReturn("com.zxing.app")
assertTrue(appLinksInterceptor.isAuthentication(tabSessionState, appIntent))
val tabSessionState2 = TabSessionState(
id = "tab1",
content = ContentState(
url = "https://mozilla.org",
private = false,
isProductUrl = false,
),
)
assertFalse(appLinksInterceptor.isAuthentication(tabSessionState2, appIntent))
val tabSessionState3 = TabSessionState(
id = "tab1",
content = ContentState(
url = "https://mozilla.org",
private = false,
isProductUrl = false,
),
source = SessionState.Source.External.CustomTab(ExternalPackage("com.example.app", PackageCategory.PRODUCTIVITY)),
)
assertFalse(appLinksInterceptor.isAuthentication(tabSessionState3, appIntent))
doReturn(null).`when`(componentName).packageName
assertFalse(appLinksInterceptor.isAuthentication(tabSessionState, appIntent))
}
}

View File

@ -15,7 +15,6 @@ import androidx.fragment.app.Fragment
import kotlinx.coroutines.flow.mapNotNull
import mozilla.components.browser.state.selector.findCustomTabOrSelectedTab
import mozilla.components.browser.toolbar.display.DisplayToolbar
import mozilla.components.feature.app.links.AppLinksFeature
import mozilla.components.feature.downloads.DownloadsFeature
import mozilla.components.feature.downloads.manager.FetchDownloadManager
import mozilla.components.feature.privatemode.feature.SecureWindowFeature
@ -53,7 +52,6 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
private val toolbarFeature = ViewBoundFeatureWrapper<ToolbarFeature>()
private val contextMenuIntegration = ViewBoundFeatureWrapper<ContextMenuIntegration>()
private val downloadsFeature = ViewBoundFeatureWrapper<DownloadsFeature>()
private val appLinksFeature = ViewBoundFeatureWrapper<AppLinksFeature>()
private val promptFeature = ViewBoundFeatureWrapper<PromptFeature>()
private val findInPageIntegration = ViewBoundFeatureWrapper<FindInPageIntegration>()
private val sitePermissionsFeature = ViewBoundFeatureWrapper<SitePermissionsFeature>()
@ -167,19 +165,6 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
view = binding.root,
)
appLinksFeature.set(
feature = AppLinksFeature(
context = requireContext(),
store = components.store,
sessionId = sessionId,
fragmentManager = parentFragmentManager,
launchInApp = { components.preferences.getBoolean(DefaultComponents.PREF_LAUNCH_EXTERNAL_APP, false) },
loadUrlUseCase = components.sessionUseCases.loadUrl,
),
owner = this,
view = binding.root,
)
promptFeature.set(
feature = PromptFeature(
fragment = this,
@ -255,6 +240,10 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
customTabId = sessionId,
)
components.appLinksInterceptor.updateFragmentManger(
fragmentManager = parentFragmentManager,
)
// Observe the lifecycle for supported features
lifecycle.addObservers(
scrollFeature,
@ -303,6 +292,10 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
}
override fun onDestroyView() {
super.onDestroyView()
components.appLinksInterceptor.updateFragmentManger(
fragmentManager = null,
)
binding.engineView.setActivityContext(null)
_binding = null
}

View File

@ -254,6 +254,8 @@ open class DefaultComponents(private val applicationContext: Context) {
launchInApp = {
applicationContext.components.preferences.getBoolean(PREF_LAUNCH_EXTERNAL_APP, false)
},
launchFromInterceptor = true,
store = store,
)
}

View File

@ -86,7 +86,6 @@ import mozilla.components.concept.engine.permission.SitePermissions
import mozilla.components.concept.engine.prompt.ShareData
import mozilla.components.concept.storage.LoginEntry
import mozilla.components.feature.accounts.push.SendTabUseCases
import mozilla.components.feature.app.links.AppLinksFeature
import mozilla.components.feature.contextmenu.ContextMenuCandidate
import mozilla.components.feature.contextmenu.ContextMenuFeature
import mozilla.components.feature.downloads.DownloadsFeature
@ -293,7 +292,6 @@ abstract class BaseBrowserFragment :
private val downloadsFeature = ViewBoundFeatureWrapper<DownloadsFeature>()
private val shareDownloadsFeature = ViewBoundFeatureWrapper<ShareDownloadFeature>()
private val copyDownloadsFeature = ViewBoundFeatureWrapper<CopyDownloadFeature>()
private val appLinksFeature = ViewBoundFeatureWrapper<AppLinksFeature>()
private val promptsFeature = ViewBoundFeatureWrapper<PromptFeature>()
@VisibleForTesting
@ -889,29 +887,6 @@ abstract class BaseBrowserFragment :
tabId = customTabSessionId,
)
appLinksFeature.set(
feature = AppLinksFeature(
context,
store = store,
sessionId = customTabSessionId,
fragmentManager = parentFragmentManager,
launchInApp = { context.settings().shouldOpenLinksInApp(customTabSessionId != null) },
loadUrlUseCase = context.components.useCases.sessionUseCases.loadUrl,
shouldPrompt = { context.settings().shouldPromptOpenLinksInApp() },
failedToLaunchAction = { fallbackUrl ->
fallbackUrl?.let {
val appLinksUseCases = activity.components.useCases.appLinksUseCases
val getRedirect = appLinksUseCases.appLinkRedirect
val redirect = getRedirect.invoke(fallbackUrl)
redirect.appIntent?.flags = Intent.FLAG_ACTIVITY_NEW_TASK
appLinksUseCases.openAppLink.invoke(redirect.appIntent)
}
},
),
owner = this,
view = view,
)
biometricPromptFeature.set(
feature = BiometricPromptFeature(
context = context,
@ -1991,6 +1966,9 @@ abstract class BaseBrowserFragment :
}
hideToolbar()
components.services.appLinksInterceptor.updateFragmentManger(
fragmentManager = parentFragmentManager,
)
context?.settings()?.shouldOpenLinksInApp(customTabSessionId != null)
?.let { openLinksInExternalApp ->
components.services.appLinksInterceptor.updateLaunchInApp {
@ -2497,6 +2475,9 @@ abstract class BaseBrowserFragment :
binding.engineView.setActivityContext(null)
requireContext().accessibilityManager.removeAccessibilityStateChangeListener(this)
requireContext().components.services.appLinksInterceptor.updateFragmentManger(
fragmentManager = null,
)
_bottomToolbarContainerView = null
_browserToolbarView = null

View File

@ -77,7 +77,7 @@ class Components(private val context: Context) {
strictMode,
)
}
val services by lazyMonitored { Services(context, backgroundServices.accountManager) }
val services by lazyMonitored { Services(context, core.store, backgroundServices.accountManager) }
val core by lazyMonitored { Core(context, analytics.crashReporter, strictMode) }
@Suppress("Deprecation")

View File

@ -9,6 +9,7 @@ import android.net.Uri
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.feature.accounts.FirefoxAccountsAuthFeature
import mozilla.components.feature.app.links.AppLinksInterceptor
import mozilla.components.service.fxa.manager.FxaAccountManager
@ -21,6 +22,7 @@ import org.mozilla.fenix.settings.SupportUtils
*/
class Services(
private val context: Context,
private val store: BrowserStore,
private val accountManager: FxaAccountManager,
) {
val accountsAuthFeature by lazyMonitored {
@ -43,9 +45,12 @@ class Services(
val appLinksInterceptor by lazyMonitored {
AppLinksInterceptor(
context,
context = context,
interceptLinkClicks = true,
launchInApp = { context.settings().shouldOpenLinksInApp() },
shouldPrompt = { context.settings().shouldPromptOpenLinksInApp() },
launchFromInterceptor = true,
store = store,
)
}
}

View File

@ -271,7 +271,7 @@ class DefaultTabsTrayControllerTest {
every { browserStore.state } returns mockk()
try {
mockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
every { browserStore.state.findTab(any()) } returns tab
every { browserStore.state.findTab("testTabId") } returns tab
every { browserStore.state.getNormalOrPrivateTabs(any()) } returns listOf(tab)
createController().handleTabDeletion("testTabId", "unknown")
@ -303,7 +303,7 @@ class DefaultTabsTrayControllerTest {
)
try {
mockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
every { browserStore.state.findTab(any()) } returns tab
every { browserStore.state.findTab("testTabId") } returns tab
every { browserStore.state.getNormalOrPrivateTabs(any()) } returns listOf(tab)
every { browserStore.state.selectedTabId } returns "testTabId"
@ -419,7 +419,7 @@ class DefaultTabsTrayControllerTest {
every { browserStore.state } returns mockk()
try {
mockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
every { browserStore.state.findTab(any()) } returns tab
every { browserStore.state.findTab("22") } returns tab
every { browserStore.state.getNormalOrPrivateTabs(any()) } returns listOf(tab, mockk())
var showUndoSnackbarForTabInvoked = false
@ -449,7 +449,7 @@ class DefaultTabsTrayControllerTest {
try {
val testTabId = "33"
mockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
every { browserStore.state.findTab(any()) } returns tab
every { browserStore.state.findTab(testTabId) } returns tab
every { browserStore.state.getNormalOrPrivateTabs(any()) } returns listOf(tab)
every { browserStore.state.selectedTabId } returns testTabId

View File

@ -30,6 +30,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.anyString
import org.mozilla.fenix.GleanMetrics.Events
import org.mozilla.fenix.GleanMetrics.TabsTray
import org.mozilla.fenix.components.accounts.FenixFxAEntryPoint
@ -146,7 +147,7 @@ class NavigationInteractorTest {
)
try {
mockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
every { mockedStore.state.findTab(any()) } returns tab
every { mockedStore.state.findTab(anyString()) } returns tab
every { mockedStore.state.getNormalOrPrivateTabs(any()) } returns listOf(tab)
controller.onCloseAllTabsClicked(true)