Bug 1929028 - Use AppLinksFeature to prompt the user. r=android-reviewers,tthibaud

Differential Revision: https://phabricator.services.mozilla.com/D247410
This commit is contained in:
Roger Yang
2025-05-13 14:45:56 +00:00
committed by royang@mozilla.com
parent 9e25ff9fd3
commit 5957e9d4df
23 changed files with 470 additions and 932 deletions

View File

@@ -1023,7 +1023,12 @@ class GeckoEngineSession(
is InterceptionResponse.AppIntent -> {
appRedirectUrl = lastLoadRequestUri
notifyObservers {
onLaunchIntentRequest(url = url, appIntent = appIntent)
onLaunchIntentRequest(
url = url,
appIntent = appIntent,
fallbackUrl = fallbackUrl,
appName = appName,
)
}
}

View File

@@ -112,8 +112,6 @@ typealias GeckoAntiTracking = ContentBlocking.AntiTracking
typealias GeckoSafeBrowsing = ContentBlocking.SafeBrowsing
typealias GeckoCookieBehavior = ContentBlocking.CookieBehavior
private const val AID = "AID"
@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
class GeckoEngineSessionTest {
@@ -3380,6 +3378,8 @@ class GeckoEngineSessionTest {
var observedUrl: String? = null
var observedIntent: Intent? = null
var observedFallbackUrl: String? = null
var observedAppName: String? = null
var observedLoadUrl: String? = null
var observedTriggeredByRedirect: Boolean? = null
@@ -3399,7 +3399,7 @@ class GeckoEngineSessionTest {
isSubframeRequest: Boolean,
): RequestInterceptor.InterceptionResponse? {
return when (uri) {
"sample:about" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result")
"sample:about" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result", "fallback", "app")
else -> null
}
}
@@ -3410,9 +3410,13 @@ class GeckoEngineSessionTest {
override fun onLaunchIntentRequest(
url: String,
appIntent: Intent?,
fallbackUrl: String?,
appName: String?,
) {
observedUrl = url
observedIntent = appIntent
observedFallbackUrl = fallbackUrl
observedAppName = appName
}
override fun onLoadRequest(url: String, triggeredByRedirect: Boolean, triggeredByWebContent: Boolean) {
@@ -3436,6 +3440,8 @@ class GeckoEngineSessionTest {
assertEquals(result!!.poll(0), AllowOrDeny.DENY)
assertNotNull(observedIntent)
assertEquals("result", observedUrl)
assertNotNull(observedFallbackUrl)
assertNotNull(observedAppName)
assertNull(observedLoadUrl)
assertNull(observedTriggeredByRedirect)
assertNull(observedTriggeredByWebContent)
@@ -3469,6 +3475,8 @@ class GeckoEngineSessionTest {
var observedUrl: String? = null
var observedIntent: Intent? = null
var observedFallbackUrl: String? = null
var observedAppName: String? = null
var observedLoadUrl: String? = null
var observedTriggeredByRedirect: Boolean? = null
@@ -3488,7 +3496,7 @@ class GeckoEngineSessionTest {
isSubframeRequest: Boolean,
): RequestInterceptor.InterceptionResponse? {
return when (uri) {
"sample:about" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result")
"sample:about" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result", "fallback", "app")
else -> null
}
}
@@ -3499,9 +3507,13 @@ class GeckoEngineSessionTest {
override fun onLaunchIntentRequest(
url: String,
appIntent: Intent?,
fallbackUrl: String?,
appName: String?,
) {
observedUrl = url
observedIntent = appIntent
observedFallbackUrl = fallbackUrl
observedAppName = appName
}
override fun onLoadRequest(url: String, triggeredByRedirect: Boolean, triggeredByWebContent: Boolean) {
@@ -3526,6 +3538,8 @@ class GeckoEngineSessionTest {
assertNull(observedIntent)
assertNull(observedUrl)
assertNull(observedLoadUrl)
assertNull(observedFallbackUrl)
assertNull(observedAppName)
assertNull(observedTriggeredByRedirect)
assertNull(observedTriggeredByWebContent)
@@ -3543,6 +3557,8 @@ class GeckoEngineSessionTest {
assertNull(observedIntent)
assertNull(observedUrl)
assertNull(observedLoadUrl)
assertNull(observedFallbackUrl)
assertNull(observedAppName)
assertNull(observedTriggeredByRedirect)
assertNull(observedTriggeredByWebContent)
}
@@ -3574,7 +3590,7 @@ class GeckoEngineSessionTest {
isSubframeRequest: Boolean,
): RequestInterceptor.InterceptionResponse? {
return when (uri) {
"sample:about" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result")
"sample:about" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result", null, null)
else -> null
}
}
@@ -3724,29 +3740,37 @@ class GeckoEngineSessionTest {
isSubframeRequest: Boolean,
): RequestInterceptor.InterceptionResponse? {
return when (uri) {
"sample:triggeredByRedirect" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result1")
"sample:NotTriggeredByRedirect" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result2")
"sample:isDirectNavigation" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result3")
"sample:triggeredByRedirect" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result1", "fallback1", "app1")
"sample:NotTriggeredByRedirect" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result2", "fallback2", "app2")
"sample:isDirectNavigation" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result3", "fallback3", "app3")
else -> null
}
}
}
val observer = object : EngineSession.Observer {
var url: String? = null
var intent: Intent? = null
var observedUrl: String? = null
var observedIntent: Intent? = null
var observedFallbackUrl: String? = null
var observedAppName: String? = null
override fun onLaunchIntentRequest(
url: String,
appIntent: Intent?,
fallbackUrl: String?,
appName: String?,
) {
this.url = url
intent = appIntent
observedUrl = url
observedIntent = appIntent
observedFallbackUrl = fallbackUrl
observedAppName = appName
}
fun reset() {
url = null
intent = null
observedUrl = null
observedIntent = null
observedFallbackUrl = null
observedAppName = null
}
}
@@ -3757,8 +3781,10 @@ class GeckoEngineSessionTest {
mockLoadRequest("sample:triggeredByRedirect", triggeredByRedirect = true, isDirectNavigation = false),
)
assertNotNull(observer.intent)
assertEquals("result1", observer.url)
assertNotNull(observer.observedIntent)
assertEquals("result1", observer.observedUrl)
assertEquals("fallback1", observer.observedFallbackUrl)
assertEquals("app1", observer.observedAppName)
observer.reset()
navigationDelegate.value.onLoadRequest(
@@ -3766,8 +3792,10 @@ class GeckoEngineSessionTest {
mockLoadRequest("sample:NotTriggeredByRedirect", triggeredByRedirect = false, isDirectNavigation = false),
)
assertNotNull(observer.intent)
assertEquals("result2", observer.url)
assertNotNull(observer.observedIntent)
assertEquals("result2", observer.observedUrl)
assertEquals("fallback2", observer.observedFallbackUrl)
assertEquals("app2", observer.observedAppName)
observer.reset()
navigationDelegate.value.onLoadRequest(
@@ -3775,8 +3803,10 @@ class GeckoEngineSessionTest {
mockLoadRequest("sample:isDirectNavigation", triggeredByRedirect = false, isDirectNavigation = true),
)
assertNull(observer.intent)
assertNull(observer.url)
assertNull(observer.observedIntent)
assertNull(observer.observedUrl)
assertNull(observer.observedFallbackUrl)
assertNull(observer.observedAppName)
}
@Test
@@ -3829,6 +3859,8 @@ class GeckoEngineSessionTest {
var observedUrl: String? = null
var observedIntent: Intent? = null
var observedFallbackUrl: String? = null
var observedAppName: String? = null
var observedIsSubframe = false
engineSession.settings.requestInterceptor = object : RequestInterceptor {
@@ -3846,7 +3878,7 @@ class GeckoEngineSessionTest {
): RequestInterceptor.InterceptionResponse? {
observedIsSubframe = isSubframeRequest
return when (uri) {
"sample:about" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result")
"sample:about" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result", "fallback", "app")
else -> null
}
}
@@ -3857,9 +3889,13 @@ class GeckoEngineSessionTest {
override fun onLaunchIntentRequest(
url: String,
appIntent: Intent?,
fallbackUrl: String?,
appName: String?,
) {
observedUrl = url
observedIntent = appIntent
observedFallbackUrl = fallbackUrl
observedAppName = appName
}
},
)
@@ -3871,8 +3907,14 @@ class GeckoEngineSessionTest {
assertNotNull(observedIntent)
assertEquals("result", observedUrl)
assertEquals("fallback", observedFallbackUrl)
assertEquals("app", observedAppName)
assertEquals(true, observedIsSubframe)
observedUrl = null
observedIntent = null
observedFallbackUrl = null
observedAppName = null
navigationDelegate.value.onSubframeLoadRequest(
mock(),
mockLoadRequest("sample:about", triggeredByRedirect = false),
@@ -3880,6 +3922,8 @@ class GeckoEngineSessionTest {
assertNotNull(observedIntent)
assertEquals("result", observedUrl)
assertEquals("fallback", observedFallbackUrl)
assertEquals("app", observedAppName)
assertEquals(true, observedIsSubframe)
}
@@ -3892,8 +3936,10 @@ class GeckoEngineSessionTest {
captureDelegates()
var observedLaunchIntentUrl: String? = null
var observedLaunchIntent: Intent? = null
var observedUrl: String? = null
var observedIntent: Intent? = null
var observedFallbackUrl: String? = null
var observedAppName: String? = null
var observedOnLoadRequestUrl: String? = null
var observedTriggeredByRedirect: Boolean? = null
var observedTriggeredByWebContent: Boolean? = null
@@ -3923,9 +3969,13 @@ class GeckoEngineSessionTest {
override fun onLaunchIntentRequest(
url: String,
appIntent: Intent?,
fallbackUrl: String?,
appName: String?,
) {
observedLaunchIntentUrl = url
observedLaunchIntent = appIntent
observedUrl = url
observedIntent = appIntent
observedFallbackUrl = fallbackUrl
observedAppName = appName
}
override fun onLoadRequest(
@@ -3945,8 +3995,10 @@ class GeckoEngineSessionTest {
mockLoadRequest("sample:about", triggeredByRedirect = true),
)
assertNull(observedLaunchIntentUrl)
assertNull(observedLaunchIntent)
assertNull(observedUrl)
assertNull(observedIntent)
assertNull(observedFallbackUrl)
assertNull(observedAppName)
assertNull(observedTriggeredByRedirect)
assertNull(observedTriggeredByWebContent)
assertNull(observedOnLoadRequestUrl)
@@ -3956,8 +4008,10 @@ class GeckoEngineSessionTest {
mockLoadRequest("sample:about", triggeredByRedirect = false),
)
assertNull(observedLaunchIntentUrl)
assertNull(observedLaunchIntent)
assertNull(observedUrl)
assertNull(observedIntent)
assertNull(observedFallbackUrl)
assertNull(observedAppName)
assertNull(observedTriggeredByRedirect)
assertNull(observedTriggeredByWebContent)
assertNull(observedOnLoadRequestUrl)
@@ -3972,8 +4026,10 @@ class GeckoEngineSessionTest {
captureDelegates()
var observedLaunchIntentUrl: String? = null
var observedLaunchIntent: Intent? = null
var observedUrl: String? = null
var observedIntent: Intent? = null
var observedFallbackUrl: String? = null
var observedAppName: String? = null
var observedOnLoadRequestUrl: String? = null
var observedTriggeredByRedirect: Boolean? = null
var observedTriggeredByWebContent: Boolean? = null
@@ -3984,9 +4040,13 @@ class GeckoEngineSessionTest {
override fun onLaunchIntentRequest(
url: String,
appIntent: Intent?,
fallbackUrl: String?,
appName: String?,
) {
observedLaunchIntentUrl = url
observedLaunchIntent = appIntent
observedUrl = url
observedIntent = appIntent
observedFallbackUrl = fallbackUrl
observedAppName = appName
}
override fun onLoadRequest(
@@ -4006,8 +4066,10 @@ class GeckoEngineSessionTest {
mockLoadRequest("sample:about", triggeredByRedirect = true),
)
assertNull(observedLaunchIntentUrl)
assertNull(observedLaunchIntent)
assertNull(observedUrl)
assertNull(observedIntent)
assertNull(observedFallbackUrl)
assertNull(observedAppName)
assertNotNull(observedTriggeredByRedirect)
assertTrue(observedTriggeredByRedirect!!)
assertNotNull(observedTriggeredByWebContent)
@@ -4019,8 +4081,10 @@ class GeckoEngineSessionTest {
mockLoadRequest("sample:about", triggeredByRedirect = false),
)
assertNull(observedLaunchIntentUrl)
assertNull(observedLaunchIntent)
assertNull(observedUrl)
assertNull(observedIntent)
assertNull(observedFallbackUrl)
assertNull(observedAppName)
assertNotNull(observedTriggeredByRedirect)
assertFalse(observedTriggeredByRedirect!!)
assertNotNull(observedTriggeredByWebContent)
@@ -4054,7 +4118,7 @@ class GeckoEngineSessionTest {
): RequestInterceptor.InterceptionResponse? {
return when (uri) {
fakeUrl -> null
else -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), fakeUrl)
else -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), fakeUrl, null, null)
}
}
}

View File

@@ -266,7 +266,12 @@ class SystemEngineView @JvmOverloads constructor(
is InterceptionResponse.AppIntent -> {
if (request.isForMainFrame) {
session.notifyObservers {
onLaunchIntentRequest(url = url, appIntent = appIntent)
onLaunchIntentRequest(
url = url,
appIntent = appIntent,
fallbackUrl = fallbackUrl,
appName = appName,
)
}
}

View File

@@ -104,8 +104,18 @@ internal class EngineObserver(
store.dispatch(ContentAction.UpdateLoadRequestAction(tabId, loadRequest))
}
override fun onLaunchIntentRequest(url: String, appIntent: Intent?) {
store.dispatch(ContentAction.UpdateAppIntentAction(tabId, AppIntentState(url, appIntent)))
override fun onLaunchIntentRequest(
url: String,
appIntent: Intent?,
fallbackUrl: String?,
appName: String?,
) {
store.dispatch(
ContentAction.UpdateAppIntentAction(
tabId,
AppIntentState(url, appIntent, fallbackUrl, appName),
),
)
}
override fun onTitleChange(title: String) {

View File

@@ -11,8 +11,12 @@ import android.content.Intent
*
* @param url the URL to launch in an external app.
* @param appIntent the [Intent] to launch.
* @param fallbackUrl the fallback URL if launch failed or denied by user.
* @param appName the target application name.
*/
data class AppIntentState(
val url: String,
val appIntent: Intent?,
val fallbackUrl: String?,
val appName: String?,
)

View File

@@ -1517,9 +1517,9 @@ class EngineObserverTest {
val store: BrowserStore = mock()
val observer = EngineObserver("test-id", store)
val intent: Intent = mock()
observer.onLaunchIntentRequest(url = url, appIntent = intent)
observer.onLaunchIntentRequest(url = url, appIntent = intent, fallbackUrl = null, appName = null)
verify(store).dispatch(ContentAction.UpdateAppIntentAction("test-id", AppIntentState(url, intent)))
verify(store).dispatch(ContentAction.UpdateAppIntentAction("test-id", AppIntentState(url, intent, null, null)))
}
@Test

View File

@@ -256,10 +256,14 @@ abstract class EngineSession(
* @param url The string url that was requested.
* @param appIntent The Android Intent that was requested.
* web content (as opposed to via the browser chrome).
* @param fallbackUrl the fallback URL if launch failed or denied by user.
* @param appName the target application name.
*/
fun onLaunchIntentRequest(
url: String,
appIntent: Intent?,
fallbackUrl: String?,
appName: String?,
) = Unit
/**

View File

@@ -40,7 +40,12 @@ interface RequestInterceptor {
val additionalHeaders: Map<String, String>? = null,
) : InterceptionResponse()
data class AppIntent(val appIntent: Intent, val url: String) : InterceptionResponse()
data class AppIntent(
val appIntent: Intent,
val url: String,
val fallbackUrl: String?,
val appName: String?,
) : InterceptionResponse()
/**
* Deny request without further action.

View File

@@ -79,7 +79,7 @@ class EngineSessionTest {
session.notifyInternalObservers { onMediaFullscreenChanged(true, mediaSessionElementMetadata) }
session.notifyInternalObservers { onCrash() }
session.notifyInternalObservers { onLoadRequest("https://www.mozilla.org", true, true) }
session.notifyInternalObservers { onLaunchIntentRequest("https://www.mozilla.org", null) }
session.notifyInternalObservers { onLaunchIntentRequest("https://www.mozilla.org", null, null, null) }
session.notifyInternalObservers { onProcessKilled() }
session.notifyInternalObservers { onShowDynamicToolbar() }
@@ -114,7 +114,7 @@ class EngineSessionTest {
verify(observer).onMediaFullscreenChanged(true, mediaSessionElementMetadata)
verify(observer).onCrash()
verify(observer).onLoadRequest("https://www.mozilla.org", true, true)
verify(observer).onLaunchIntentRequest("https://www.mozilla.org", null)
verify(observer).onLaunchIntentRequest("https://www.mozilla.org", null, null, null)
verify(observer).onProcessKilled()
verify(observer).onShowDynamicToolbar()
verifyNoMoreInteractions(observer)
@@ -152,7 +152,7 @@ class EngineSessionTest {
session.notifyInternalObservers { onWindowRequest(windowRequest) }
session.notifyInternalObservers { onCrash() }
session.notifyInternalObservers { onLoadRequest("https://www.mozilla.org", true, true) }
session.notifyInternalObservers { onLaunchIntentRequest("https://www.mozilla.org", null) }
session.notifyInternalObservers { onLaunchIntentRequest("https://www.mozilla.org", null, null, null) }
session.notifyInternalObservers { onShowDynamicToolbar() }
session.unregister(observer)
@@ -189,7 +189,7 @@ class EngineSessionTest {
session.notifyInternalObservers { onMediaFullscreenChanged(true, mediaSessionElementMetadata) }
session.notifyInternalObservers { onCrash() }
session.notifyInternalObservers { onLoadRequest("https://www.mozilla.org", true, true) }
session.notifyInternalObservers { onLaunchIntentRequest("https://www.firefox.com", null) }
session.notifyInternalObservers { onLaunchIntentRequest("https://www.firefox.com", null, null, null) }
session.notifyInternalObservers { onShowDynamicToolbar() }
verify(observer).onScrollChange(1234, 4321)
@@ -211,7 +211,7 @@ class EngineSessionTest {
verify(observer).onWindowRequest(windowRequest)
verify(observer).onCrash()
verify(observer).onLoadRequest("https://www.mozilla.org", true, true)
verify(observer).onLaunchIntentRequest("https://www.mozilla.org", null)
verify(observer).onLaunchIntentRequest("https://www.mozilla.org", null, null, null)
verify(observer).onShowDynamicToolbar()
verify(observer, never()).onScrollChange(2345, 5432)
verify(observer, never()).onLocationChange("https://www.firefox.com", false)
@@ -239,7 +239,7 @@ class EngineSessionTest {
verify(observer, never()).onMediaMuteChanged(true)
verify(observer, never()).onMediaFullscreenChanged(true, mediaSessionElementMetadata)
verify(observer, never()).onLoadRequest("https://www.mozilla.org", false, true)
verify(observer, never()).onLaunchIntentRequest("https://www.firefox.com", null)
verify(observer, never()).onLaunchIntentRequest("https://www.firefox.com", null, null, null)
verifyNoMoreInteractions(observer)
}
@@ -964,7 +964,7 @@ class EngineSessionTest {
observer.onWindowRequest(windowRequest)
observer.onCrash()
observer.onLoadRequest("https://www.mozilla.org", true, true)
observer.onLaunchIntentRequest("https://www.mozilla.org", null)
observer.onLaunchIntentRequest("https://www.mozilla.org", null, null, null)
observer.onProcessKilled()
observer.onShowDynamicToolbar()
}

View File

@@ -27,6 +27,9 @@ import mozilla.components.lib.state.ext.flowScoped
import mozilla.components.support.base.feature.LifecycleAwareFeature
import mozilla.components.support.ktx.android.content.appName
// Minimum time for dialog to settle before accepting user interactions.
internal const val MAX_SUCCESSIVE_DIALOG_MILLIS_LIMIT: Int = 500 // 0.5 seconds
/**
* This feature implements observer for handling redirects to external apps. The users are asked to
* confirm their intention before leaving the app if in private session. These include the Android
@@ -45,6 +48,9 @@ import mozilla.components.support.ktx.android.content.appName
* have registered to open.
* @param failedToLaunchAction Action to perform when failing to launch in third party app.
* @param loadUrlUseCase Used to load URL if user decides not to launch in third party app.
* @param engineSupportedSchemes Set of URI schemes the engine supports.
* @param shouldPrompt If {true} then user should be prompted before launching app links.
* @param alwaysOpenCheckboxAction Action to perform when user checked the always open checkbox in the prompt.
**/
class AppLinksFeature(
private val context: Context,
@@ -58,6 +64,7 @@ class AppLinksFeature(
private val loadUrlUseCase: SessionUseCases.DefaultLoadUrlUseCase? = null,
private val engineSupportedSchemes: Set<String> = ENGINE_SUPPORTED_SCHEMES,
private val shouldPrompt: () -> Boolean = { true },
private val alwaysOpenCheckboxAction: (() -> Unit)? = null,
) : LifecycleAwareFeature {
private var scope: CoroutineScope? = null
@@ -71,10 +78,16 @@ class AppLinksFeature(
.distinctUntilChangedBy {
it.content.appIntent
}
.collect { tab ->
tab.content.appIntent?.let {
handleAppIntent(tab, it.url, it.appIntent)
store.dispatch(ContentAction.ConsumeAppIntentAction(tab.id))
.collect { sessionState ->
sessionState.content.appIntent?.let {
handleAppIntent(
sessionState = sessionState,
url = it.url,
appIntent = it.appIntent,
fallbackUrl = it.fallbackUrl,
appName = it.appName,
)
store.dispatch(ContentAction.ConsumeAppIntentAction(sessionState.id))
}
}
}
@@ -89,105 +102,160 @@ class AppLinksFeature(
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun handleAppIntent(tab: SessionState, url: String, appIntent: Intent?) {
if (appIntent == null) {
internal fun handleAppIntent(
sessionState: SessionState,
url: String,
appIntent: Intent?,
fallbackUrl: String?,
appName: String?,
) {
if (appIntent == null) return
val isPrivate = sessionState.content.private
val isAuthenticationFlow =
AppLinksInterceptor.isAuthentication(sessionState, appIntent.component?.packageName)
if (shouldBypassPrompt(isPrivate, isAuthenticationFlow, fragmentManager)) {
openApp(appIntent)
return
}
val doNotOpenApp = {
AppLinksInterceptor.addUserDoNotIntercept(url, appIntent, tab.id)
loadUrlIfSchemeSupported(tab, url)
}
val doOpenApp = {
useCases.openAppLink(
appIntent,
failedToLaunchAction = failedToLaunchAction,
)
}
@Suppress("ComplexCondition")
if (isAuthentication(tab, appIntent) || (!tab.content.private && !shouldPrompt()) ||
fragmentManager == null
) {
doOpenApp()
if (isADialogAlreadyCreated() || fragmentManager?.isStateSaved == true) {
return
}
val dialog = getOrCreateDialog(tab.content.private, url)
dialog.onConfirmRedirect = { doOpenApp() }
dialog.onCancelRedirect = doNotOpenApp
if (!isAlreadyADialogCreated()) {
dialog.showNow(fragmentManager, FRAGMENT_TAG)
}
showRedirectDialog(
sessionState = sessionState,
url = url,
fallbackUrl = fallbackUrl,
appIntent = appIntent,
appName = appName,
isPrivate = isPrivate,
fragmentManager = fragmentManager,
)
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun getOrCreateDialog(isPrivate: Boolean, url: String): RedirectDialogFragment {
internal fun shouldBypassPrompt(
isPrivate: Boolean,
isAuthenticationFlow: Boolean,
fragmentManager: FragmentManager?,
): Boolean {
val shouldShowPrompt = isPrivate || shouldPrompt()
return fragmentManager == null || !shouldShowPrompt || isAuthenticationFlow
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun openApp(appIntent: Intent) {
useCases.openAppLink(
appIntent,
failedToLaunchAction = failedToLaunchAction,
)
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun cancelRedirect(
sessionState: SessionState,
url: String,
fallbackUrl: String?,
appIntent: Intent,
) {
AppLinksInterceptor.addUserDoNotIntercept(url, appIntent, sessionState.id)
val urlToLoad = when {
isSchemeSupported(url) -> url
fallbackUrl != null && isSchemeSupported(fallbackUrl) -> fallbackUrl
else -> return // No supported URL to load.
}
loadUrlUseCase?.invoke(
url = urlToLoad,
sessionId = sessionState.id,
flags = EngineSession.LoadUrlFlags.select(EXTERNAL, LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE),
)
}
private fun showRedirectDialog(
sessionState: SessionState,
url: String,
fallbackUrl: String?,
appIntent: Intent,
appName: String?,
isPrivate: Boolean,
fragmentManager: FragmentManager?,
) {
if (fragmentManager == null) {
return
}
getOrCreateDialog(isPrivate, url, appName).apply {
onConfirmRedirect = { isCheckboxTicked ->
if (isCheckboxTicked) {
alwaysOpenCheckboxAction?.invoke()
}
openApp(appIntent)
}
onCancelRedirect = {
cancelRedirect(sessionState, url, fallbackUrl, appIntent)
}
}.showNow(fragmentManager, FRAGMENT_TAG)
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun getOrCreateDialog(
isPrivate: Boolean,
url: String,
targetAppName: String?,
): RedirectDialogFragment {
if (dialog != null) {
return dialog
}
val message = context.getString(
R.string.mozac_feature_applinks_normal_confirm_dialog_message,
context.appName,
)
val dialogTitle = when {
isPrivate && !targetAppName.isNullOrBlank() -> {
context.getString(R.string.mozac_feature_applinks_confirm_dialog_title_with_app_name, targetAppName)
}
isPrivate -> {
context.getString(R.string.mozac_feature_applinks_confirm_dialog_title)
}
!targetAppName.isNullOrBlank() -> {
context.getString(
R.string.mozac_feature_applinks_normal_confirm_dialog_title_with_app_name,
targetAppName,
)
}
else -> {
context.getString(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(
dialogTitleString = if (isPrivate) {
context.getString(R.string.mozac_feature_applinks_confirm_dialog_title)
} else {
context.getString(R.string.mozac_feature_applinks_normal_confirm_dialog_title)
},
dialogMessageString = if (isPrivate) {
url
} else {
message
},
dialogTitleString = dialogTitle,
dialogMessageString = dialogMessage,
showCheckbox = if (isPrivate) false else alwaysOpenCheckboxAction != null,
maxSuccessiveDialogMillisLimit = MAX_SUCCESSIVE_DIALOG_MILLIS_LIMIT,
)
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun loadUrlIfSchemeSupported(tab: SessionState, url: String) {
val schemeSupported = engineSupportedSchemes.contains(url.toUri().scheme)
if (schemeSupported) {
loadUrlUseCase?.invoke(
url = url,
sessionId = tab.id,
flags = EngineSession.LoadUrlFlags.select(EXTERNAL, LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE),
)
}
internal fun isSchemeSupported(url: String): Boolean {
return engineSupportedSchemes.contains(url.toUri().scheme)
}
private fun isAlreadyADialogCreated(): Boolean {
private fun isADialogAlreadyCreated(): Boolean {
return findPreviousDialogFragment() != null
}
private fun findPreviousDialogFragment(): RedirectDialogFragment? {
return fragmentManager?.findFragmentByTag(FRAGMENT_TAG) as? RedirectDialogFragment
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun isAuthentication(tab: SessionState, appIntent: Intent): Boolean {
return when (tab.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,
-> {
(tab.source as? SessionState.Source.External)?.let { externalSource ->
when (externalSource.caller?.packageId) {
null -> false
appIntent.component?.packageName -> true
else -> false
}
} ?: false
}
else -> false
}
}
}

View File

@@ -10,19 +10,13 @@ import android.content.Intent
import android.os.SystemClock
import androidx.annotation.VisibleForTesting
import androidx.core.net.toUri
import androidx.fragment.app.FragmentManager
import mozilla.components.browser.state.selector.findTabOrCustomTab
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.EngineSession.LoadUrlFlags.Companion.EXTERNAL
import mozilla.components.concept.engine.EngineSession.LoadUrlFlags.Companion.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE
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.feature.session.SessionUseCases
import mozilla.components.support.ktx.android.content.appName
import mozilla.components.support.ktx.android.net.isHttpOrHttps
import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl
@@ -53,26 +47,18 @@ private val ALLOWED_SCHEMES_IN_SUBFRAME: List<String> = listOf(
* and https://github.com/mozilla-mobile/android-components/issues/2975.
*
* @param context Context the feature is associated with.
* @param interceptLinkClicks If {true} then intercept link clicks.
* @param engineSupportedSchemes List of schemes that the engine supports.
* @param alwaysDeniedSchemes List of schemes that will never be opened in a third-party app even if
* [interceptLinkClicks] is `true`.
* @param alwaysDeniedSchemes List of schemes that will never be opened in a third-party app
* @param launchInApp If {true} then launch app links in third party app(s). Default to false because
* 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 prompt and launch the link in
* third-party apps if available. Do not use this in conjunction with [AppLinksFeature]
* @param showCheckbox if {true} then the checkbox will be visible on normal tabs within [SimpleRedirectDialogFragment]
* @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.
* @param checkboxCheckedAction Action to perform when checkbox is ticked and positive button is clicked
* on redirect prompt.
*/
class AppLinksInterceptor(
private val context: Context,
private val interceptLinkClicks: Boolean = false,
private val engineSupportedSchemes: Set<String> = ENGINE_SUPPORTED_SCHEMES,
private val alwaysDeniedSchemes: Set<String> = ALWAYS_DENY_SCHEMES,
private var launchInApp: () -> Boolean = { false },
@@ -82,32 +68,8 @@ class AppLinksInterceptor(
alwaysDeniedSchemes = alwaysDeniedSchemes,
),
private val launchFromInterceptor: Boolean = false,
private val showCheckbox: Boolean = false,
private val store: BrowserStore? = null,
private val shouldPrompt: () -> Boolean = { true },
private val failedToLaunchAction: (fallbackUrl: String?) -> Unit = {},
private val loadUrlUseCase: SessionUseCases.DefaultLoadUrlUseCase? = null,
private val checkboxCheckedAction: () -> 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
* @param launchInApp the new value of launchInApp
*/
fun updateLaunchInApp(launchInApp: () -> Boolean) {
this.launchInApp = launchInApp
useCases.updateLaunchInApp(launchInApp)
}
@Suppress("ComplexMethod", "ReturnCount")
override fun onLoadRequest(
@@ -137,8 +99,8 @@ class AppLinksInterceptor(
(!hasUserGesture && !isAllowedRedirect && !isDirectNavigation) ||
isSameDomain(lastUri, uri)
) && engineSupportsScheme -> true
// If scheme not in safelist then follow user preference
(!interceptLinkClicks || !launchInApp()) && engineSupportsScheme -> true
// If scheme not in supported list then follow user preference
!launchInApp() && !isPossibleAuthentication(tabSessionState) && engineSupportsScheme -> true
// Never go to an external app when scheme is in blocklist
alwaysDeniedSchemes.contains(uriScheme) -> true
else -> false
@@ -151,10 +113,14 @@ class AppLinksInterceptor(
val tabId = tabSessionState?.id ?: ""
val redirect = useCases.interceptedAppLinkRedirect(uri)
val result = handleRedirect(redirect, uri, tabId)
val packageName = redirect.appIntent?.component?.packageName
// Now that we have the package name, check again if this is not authentication.
if (!launchInApp() && !isAuthentication(tabSessionState, packageName) && engineSupportsScheme) {
return null
}
if (redirect.hasExternalApp()) {
val packageName = redirect.appIntent?.component?.packageName
if (
lastApplinksPackageWithTimestamp.first == packageName && lastApplinksPackageWithTimestamp.second +
APP_LINKS_DO_NOT_INTERCEPT_INTERVAL > SystemClock.elapsedRealtime()
@@ -166,20 +132,9 @@ class AppLinksInterceptor(
}
if (redirect.isRedirect()) {
if (
launchFromInterceptor &&
result is RequestInterceptor.InterceptionResponse.AppIntent
) {
handleIntent(
sessionState = tabSessionState,
url = uri,
appIntent = redirect.appIntent,
fallbackUrl = redirect.fallbackUrl,
marketingIntent = redirect.marketplaceIntent,
appName = redirect.appName,
)
// We can avoid loading the page only if openInApp settings is set to Always
return if (shouldPrompt()) null else result
if (launchFromInterceptor && result is RequestInterceptor.InterceptionResponse.AppIntent) {
result.appIntent.flags = result.appIntent.flags or Intent.FLAG_ACTIVITY_NEW_TASK
useCases.openAppLink(result.appIntent)
}
return result
@@ -211,19 +166,29 @@ class AppLinksInterceptor(
}
if (!redirect.hasExternalApp()) {
redirect.marketplaceIntent?.let {
return RequestInterceptor.InterceptionResponse.AppIntent(it, uri)
redirect.marketplaceIntent?.let { appIntent ->
return RequestInterceptor.InterceptionResponse.AppIntent(
appIntent = appIntent,
url = uri,
fallbackUrl = redirect.fallbackUrl,
appName = redirect.appName,
)
}
redirect.fallbackUrl?.let {
return RequestInterceptor.InterceptionResponse.Url(it)
redirect.fallbackUrl?.let { fallbackUrl ->
return RequestInterceptor.InterceptionResponse.Url(url = fallbackUrl)
}
return null
}
redirect.appIntent?.let {
return RequestInterceptor.InterceptionResponse.AppIntent(it, uri)
redirect.appIntent?.let { appIntent ->
return RequestInterceptor.InterceptionResponse.AppIntent(
appIntent = appIntent,
url = uri,
fallbackUrl = redirect.fallbackUrl,
appName = redirect.appName,
)
}
return null
@@ -248,153 +213,6 @@ class AppLinksInterceptor(
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun handleIntent(
sessionState: SessionState?,
url: String,
appIntent: Intent?,
fallbackUrl: String?,
marketingIntent: Intent?,
appName: String,
) {
var isAuthenticationFlow = false
val targetIntent = when {
appIntent != null -> {
// Without fragment manager we are unable to prompt
// Only non private tabs can be redirected to external app without prompt
// Authentication flow should not prompt
isAuthenticationFlow =
sessionState?.let { isAuthentication(sessionState, appIntent) } == true
appIntent
}
marketingIntent != null -> marketingIntent
else -> return
}
val fragmentManager = fragmentManager
val isPrivate = sessionState?.content?.private == true
val doNotOpenApp = {
addUserDoNotIntercept(url, targetIntent, sessionState?.id)
if (sessionState != null && fallbackUrl != null) {
loadUrlIfSchemeSupported(sessionState, fallbackUrl)
}
}
val doOpenApp = {
useCases.openAppLink(
targetIntent,
failedToLaunchAction = failedToLaunchAction,
)
}
val shouldShowPrompt = isPrivate || shouldPrompt()
if (fragmentManager == null || !shouldShowPrompt || isAuthenticationFlow) {
doOpenApp()
return
}
if (isADialogAlreadyCreated()) {
return
}
if (!fragmentManager.isStateSaved) {
getOrCreateDialog(isPrivate, url, appName).apply {
onConfirmRedirect = { isCheckboxTicked ->
if (isCheckboxTicked) {
checkboxCheckedAction()
}
doOpenApp()
}
onCancelRedirect = doNotOpenApp
}.showNow(fragmentManager, FRAGMENT_TAG)
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun getOrCreateDialog(
isPrivate: Boolean,
url: String,
targetAppName: String,
): RedirectDialogFragment {
if (dialog != null) {
return dialog
}
val dialogTitle = when {
isPrivate && targetAppName.isNotBlank() -> {
context.getString(R.string.mozac_feature_applinks_confirm_dialog_title_with_app_name, targetAppName)
}
isPrivate -> {
context.getString(R.string.mozac_feature_applinks_confirm_dialog_title)
}
targetAppName.isNotBlank() -> {
context.getString(
R.string.mozac_feature_applinks_normal_confirm_dialog_title_with_app_name,
targetAppName,
)
}
else -> {
context.getString(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(
dialogTitleString = dialogTitle,
dialogMessageString = dialogMessage,
showCheckbox = if (isPrivate) false else showCheckbox,
maxSuccessiveDialogMillisLimit = MAX_SUCCESSIVE_DIALOG_MILLIS_LIMIT,
)
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun loadUrlIfSchemeSupported(tab: SessionState, url: String) {
val schemeSupported = engineSupportedSchemes.contains(url.toUri().scheme)
if (schemeSupported) {
loadUrlUseCase?.invoke(
url = url,
sessionId = tab.id,
flags = EngineSession.LoadUrlFlags.select(EXTERNAL, LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE),
)
}
}
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()
@@ -443,7 +261,43 @@ class AppLinksInterceptor(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal const val APP_LINKS_DO_NOT_INTERCEPT_INTERVAL = 2000L // 2 second
// Minimum time for dialog to settle before accepting user interactions.
internal const val MAX_SUCCESSIVE_DIALOG_MILLIS_LIMIT: Int = 500 // 0.5 seconds
/**
* Determines whether a given tab session is being used for authentication purposes.
*
* @param sessionState The current [SessionState], representing the tab session to inspect.
*
* @return `true` if the tab session is possible authentication flow, `false` otherwise.
*/
@VisibleForTesting
internal fun isPossibleAuthentication(sessionState: SessionState?): Boolean {
return when (sessionState?.source) {
// CustomTab and ActionView can be used for authentication
is SessionState.Source.External.CustomTab,
is SessionState.Source.External.ActionView,
-> true
else -> false
}
}
/**
* Determines whether a given tab session is being used for authentication purposes.
*
* @param sessionState The current [SessionState], representing the tab session to inspect.
* @param packageName The target package name used to match with the caller's package name.
*
* @return `true` if the tab session is an authentication flow from the same app, `false` otherwise.
*/
fun isAuthentication(sessionState: SessionState?, packageName: String?): Boolean {
if (packageName != null && isPossibleAuthentication(sessionState)) {
val callerPackageId =
(sessionState?.source as? SessionState.Source.External)?.caller?.packageId
if (callerPackageId == packageName) {
return true
}
}
return false
}
}
}

View File

@@ -73,14 +73,6 @@ class AppLinksUseCases(
}
}
/**
* Update launchInApp for this instance of AppLinksUseCases
* @param launchInApp the new value of launchInApp
*/
fun updateLaunchInApp(launchInApp: () -> Boolean) {
this.launchInApp = launchInApp
}
private fun findDefaultActivity(intent: Intent): ResolveInfo? {
return context.packageManager.resolveActivityCompat(intent, PackageManager.MATCH_DEFAULT_ONLY)
}

View File

@@ -111,35 +111,35 @@ class AppLinksFeatureTest {
}
@Test
fun `feature observes app intents when started`() {
fun `WHEN feature started THEN feature observes app intents`() {
val tab = createTab(webUrl)
store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
verify(feature, never()).handleAppIntent(any(), any(), any())
verify(feature, never()).handleAppIntent(any(), any(), any(), any(), any())
val intent: Intent = mock()
val appIntent = AppIntentState(intentUrl, intent)
val appIntent = AppIntentState(intentUrl, intent, null, null)
store.dispatch(ContentAction.UpdateAppIntentAction(tab.id, appIntent)).joinBlocking()
store.waitUntilIdle()
verify(feature).handleAppIntent(any(), any(), any())
verify(feature).handleAppIntent(any(), any(), any(), any(), any())
val tabWithConsumedAppIntent = store.state.findTab(tab.id)!!
assertNull(tabWithConsumedAppIntent.content.appIntent)
}
@Test
fun `feature doesn't observes app intents when stopped`() {
fun `WHEN feature is stopped THEN feature doesn't observes app intents`() {
val tab = createTab(webUrl)
store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
verify(feature, never()).handleAppIntent(any(), any(), any())
verify(feature, never()).handleAppIntent(any(), any(), any(), any(), any())
feature.stop()
val intent: Intent = mock()
val appIntent = AppIntentState(intentUrl, intent)
val appIntent = AppIntentState(intentUrl, intent, null, null)
store.dispatch(ContentAction.UpdateAppIntentAction(tab.id, appIntent)).joinBlocking()
verify(feature, never()).handleAppIntent(any(), any(), any())
verify(feature, never()).handleAppIntent(any(), any(), any(), any(), any())
}
@Test
@@ -159,7 +159,7 @@ class AppLinksFeatureTest {
}
val tab = createTab(webUrl)
feature.handleAppIntent(tab, intentUrl, mock())
feature.handleAppIntent(tab, intentUrl, mock(), null, null)
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
@@ -182,7 +182,7 @@ class AppLinksFeatureTest {
}
val tab = createTab(webUrl)
feature.handleAppIntent(tab, intentUrl, mock())
feature.handleAppIntent(tab, intentUrl, mock(), null, null)
verify(mockDialog, never()).showNow(eq(mockFragmentManager), anyString())
}
@@ -217,7 +217,7 @@ class AppLinksFeatureTest {
doReturn(componentName).`when`(appIntent).component
doReturn("com.zxing.app").`when`(componentName).packageName
feature.handleAppIntent(tab, intentUrl, appIntent)
feature.handleAppIntent(tab, intentUrl, appIntent, null, null)
verify(mockDialog, never()).showNow(eq(mockFragmentManager), anyString())
}
@@ -252,7 +252,7 @@ class AppLinksFeatureTest {
doReturn(componentName).`when`(appIntent).component
doReturn("com.zxing.app").`when`(componentName).packageName
feature.handleAppIntent(tab, intentUrl, appIntent)
feature.handleAppIntent(tab, intentUrl, appIntent, null, null)
verify(mockDialog, never()).showNow(eq(mockFragmentManager), anyString())
}
@@ -287,7 +287,7 @@ class AppLinksFeatureTest {
doReturn(componentName).`when`(appIntent).component
doReturn("com.zxing.app").`when`(componentName).packageName
feature.handleAppIntent(tab, intentUrl, appIntent)
feature.handleAppIntent(tab, intentUrl, appIntent, null, null)
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
@@ -323,7 +323,7 @@ class AppLinksFeatureTest {
doReturn(componentName).`when`(appIntent).component
doReturn("com.zxing.app").`when`(componentName).packageName
feature.handleAppIntent(tab, intentUrl, appIntent)
feature.handleAppIntent(tab, intentUrl, appIntent, null, null)
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
@@ -346,7 +346,7 @@ class AppLinksFeatureTest {
}
val tab = createTab(webUrl, private = true)
feature.handleAppIntent(tab, intentUrl, mock())
feature.handleAppIntent(tab, intentUrl, mock(), null, null)
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
@@ -369,7 +369,7 @@ class AppLinksFeatureTest {
}
val tab = createTab(webUrl, private = true)
feature.handleAppIntent(tab, intentUrl, mock())
feature.handleAppIntent(tab, intentUrl, mock(), null, null)
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
@@ -378,60 +378,41 @@ class AppLinksFeatureTest {
@Test
fun `redirect dialog is only added once`() {
val tab = createTab(webUrl, private = true)
feature.handleAppIntent(tab, intentUrl, mock())
feature.handleAppIntent(tab, intentUrl, mock(), null, null)
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
doReturn(mockDialog).`when`(feature).getOrCreateDialog(false, "")
doReturn(mockDialog).`when`(feature).getOrCreateDialog(false, "", null)
doReturn(mockDialog).`when`(mockFragmentManager).findFragmentByTag(RedirectDialogFragment.FRAGMENT_TAG)
feature.handleAppIntent(tab, intentUrl, mock())
feature.handleAppIntent(tab, intentUrl, mock(), null, null)
verify(mockDialog, times(1)).showNow(mockFragmentManager, RedirectDialogFragment.FRAGMENT_TAG)
}
@Test
fun `only loads URL if scheme is supported`() {
val tab = createTab(webUrl, private = true)
feature.loadUrlIfSchemeSupported(tab, intentUrl)
verify(mockLoadUrlUseCase, never()).invoke(anyString(), anyString(), any(), any(), any())
feature.loadUrlIfSchemeSupported(tab, webUrl)
verify(mockLoadUrlUseCase, times(1)).invoke(anyString(), anyString(), any(), any(), any())
feature.loadUrlIfSchemeSupported(tab, aboutUrl)
verify(mockLoadUrlUseCase, times(2)).invoke(anyString(), anyString(), any(), any(), any())
fun `WHEN url is not supported THEN isSchemeSupported returns false`() {
assertFalse(feature.isSchemeSupported(intentUrl))
assertTrue(feature.isSchemeSupported(webUrl))
assertTrue(feature.isSchemeSupported(aboutUrl))
}
@Test
fun `WHEN caller and intent have the same package name THEN return true`() {
val customTab =
createCustomTab(
id = "c",
url = webUrl,
source = SessionState.Source.External.CustomTab(
ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY),
),
)
val appIntent: Intent = mock()
val componentName: ComponentName = mock()
doReturn(componentName).`when`(appIntent).component
doReturn("com.zxing.app").`when`(componentName).packageName
assertTrue(feature.isAuthentication(customTab, appIntent))
fun `WHEN url or fallback url scheme is supported THEN cancel redirect will load it`() {
val tab = createTab(webUrl, private = true)
assertFalse(feature.isAuthentication(tab, appIntent))
val intent: Intent = mock()
val customTab2 =
createCustomTab(
id = "c",
url = webUrl,
source = SessionState.Source.External.CustomTab(
ExternalPackage("com.example.app", PackageCategory.PRODUCTIVITY),
),
)
assertFalse(feature.isAuthentication(customTab2, appIntent))
feature.cancelRedirect(tab, intentUrl, null, intent)
verify(mockLoadUrlUseCase, never()).invoke(anyString(), anyString(), any(), any(), any())
doReturn(null).`when`(componentName).packageName
assertFalse(feature.isAuthentication(customTab, appIntent))
feature.cancelRedirect(tab, intentUrl, intentUrl, intent)
verify(mockLoadUrlUseCase, never()).invoke(anyString(), anyString(), any(), any(), any())
feature.cancelRedirect(tab, webUrl, null, intent)
verify(mockLoadUrlUseCase, times(1)).invoke(anyString(), anyString(), any(), any(), any())
feature.cancelRedirect(tab, aboutUrl, null, intent)
verify(mockLoadUrlUseCase, times(2)).invoke(anyString(), anyString(), any(), any(), any())
feature.cancelRedirect(tab, intentUrl, aboutUrl, intent)
verify(mockLoadUrlUseCase, times(3)).invoke(anyString(), anyString(), any(), any(), any())
}
}

View File

@@ -14,8 +14,6 @@ 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
@@ -28,7 +26,6 @@ import mozilla.components.feature.app.links.AppLinksInterceptor.Companion.lastAp
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
@@ -39,9 +36,7 @@ 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
@@ -65,7 +60,6 @@ class AppLinksInterceptorTest {
private val intentUrl = "zxing://scan;S.browser_fallback_url=example.com"
private val fallbackUrl = "https://getpocket.com"
private val marketplaceUrl = "market://details?id=example.com"
private val aboutUrl = "about://scan"
@Before
fun setup() {
@@ -98,7 +92,6 @@ class AppLinksInterceptorTest {
appLinksInterceptor = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = true,
launchInApp = { true },
useCases = mockUseCases,
)
@@ -129,47 +122,27 @@ class AppLinksInterceptorTest {
}
@Test
fun `request is not intercepted when interceptLinkClicks is false`() {
fun `WHEN launchInApp preference is false THEN request is not intercepted`() {
appLinksInterceptor = AppLinksInterceptor(
context = mockContext,
launchInApp = { false },
useCases = mockUseCases,
)
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, webUrlWithAppLink, null, true, false, false, false, false)
assertEquals(null, response)
}
@Test
fun `WHEN launchInApp preference is true THEN request is intercepted`() {
appLinksInterceptor = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = false,
launchInApp = { true },
useCases = mockUseCases,
)
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, webUrlWithAppLink, null, true, false, false, false, false)
assertEquals(null, response)
}
@Test
fun `request is not intercepted when launchInApp preference is false`() {
appLinksInterceptor = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = true,
launchInApp = { false },
useCases = mockUseCases,
)
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, webUrlWithAppLink, null, true, false, false, false, false)
assertEquals(null, response)
}
@Test
fun `request is not intercepted when launchInApp preference is updated to false`() {
appLinksInterceptor = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = true,
launchInApp = { false },
useCases = mockUseCases,
)
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, webUrlWithAppLink, null, true, false, false, false, false)
assertEquals(null, response)
appLinksInterceptor.updateLaunchInApp { true }
verify(mockUseCases).updateLaunchInApp(any())
val response2 = appLinksInterceptor.onLoadRequest(mockEngineSession, webUrlWithAppLink, null, true, false, false, false, false)
assert(response2 is RequestInterceptor.InterceptionResponse.AppIntent)
assert(response is RequestInterceptor.InterceptionResponse.AppIntent)
}
@Test
@@ -229,7 +202,6 @@ class AppLinksInterceptorTest {
val blocklistedScheme = "blocklisted"
val feature = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = true,
alwaysDeniedSchemes = setOf(blocklistedScheme),
launchInApp = { true },
useCases = mockUseCases,
@@ -248,7 +220,6 @@ class AppLinksInterceptorTest {
val supportedScheme = "supported"
val feature = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = true,
engineSupportedSchemes = setOf(supportedScheme),
launchInApp = { false },
useCases = mockUseCases,
@@ -261,32 +232,12 @@ class AppLinksInterceptorTest {
assertEquals(null, response)
}
@Test
fun `supported schemes request not launched if interceptLinkClicks is false`() {
val engineSession: EngineSession = mock()
val supportedScheme = "supported"
val feature = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = false,
engineSupportedSchemes = setOf(supportedScheme),
launchInApp = { true },
useCases = mockUseCases,
)
val supportedUrl = "$supportedScheme://example.com"
val supportedRedirect = AppLinkRedirect(Intent.parseUri(supportedUrl, 0), "", null, null)
whenever(mockGetRedirect.invoke(supportedUrl)).thenReturn(supportedRedirect)
val response = feature.onLoadRequest(engineSession, supportedUrl, null, true, false, false, false, false)
assertEquals(null, response)
}
@Test
fun `supported schemes request not launched if not triggered by user`() {
val engineSession: EngineSession = mock()
val supportedScheme = "supported"
val feature = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = true,
engineSupportedSchemes = setOf(supportedScheme),
launchInApp = { true },
useCases = mockUseCases,
@@ -300,14 +251,13 @@ class AppLinksInterceptorTest {
}
@Test
fun `not supported schemes request always intercepted regardless of hasUserGesture, interceptLinkClicks or launchInApp`() {
fun `WHEN request is in not supported schemes THEN it is always intercepted regardless of hasUserGesture or launchInApp`() {
val engineSession: EngineSession = mock()
val supportedScheme = "supported"
val notSupportedScheme = "not_supported"
val blocklistedScheme = "blocklisted"
val feature = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = false,
engineSupportedSchemes = setOf(supportedScheme),
alwaysDeniedSchemes = setOf(blocklistedScheme),
launchInApp = { false },
@@ -328,7 +278,6 @@ class AppLinksInterceptorTest {
val notSupportedScheme = "not_supported"
val feature = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = false,
engineSupportedSchemes = setOf(supportedScheme),
alwaysDeniedSchemes = setOf(notSupportedScheme),
launchInApp = { false },
@@ -350,7 +299,6 @@ class AppLinksInterceptorTest {
val blocklistedScheme = "blocklisted"
val feature = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = false,
engineSupportedSchemes = setOf(supportedScheme),
alwaysDeniedSchemes = setOf(blocklistedScheme),
launchInApp = { true },
@@ -372,7 +320,6 @@ class AppLinksInterceptorTest {
val blocklistedScheme = "blocklisted"
val feature = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = true,
engineSupportedSchemes = setOf(supportedScheme),
alwaysDeniedSchemes = setOf(blocklistedScheme),
launchInApp = { false },
@@ -395,7 +342,6 @@ class AppLinksInterceptorTest {
val blocklistedScheme = "blocklisted"
val feature = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = true,
engineSupportedSchemes = setOf(supportedScheme),
alwaysDeniedSchemes = setOf(blocklistedScheme),
launchInApp = { false },
@@ -415,7 +361,6 @@ class AppLinksInterceptorTest {
val engineSession: EngineSession = mock()
val feature = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = false,
launchInApp = { false },
useCases = mockUseCases,
)
@@ -432,7 +377,6 @@ class AppLinksInterceptorTest {
val engineSession: EngineSession = mock()
val feature = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = false,
launchInApp = { false },
useCases = mockUseCases,
)
@@ -472,11 +416,9 @@ class AppLinksInterceptorTest {
fun `external app is launched when launch in app is set to true and it is user triggered`() {
appLinksInterceptor = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = true,
launchInApp = { true },
useCases = mockUseCases,
launchFromInterceptor = true,
shouldPrompt = { false },
)
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, webUrlWithAppLink, null, true, false, false, false, false)
@@ -488,15 +430,13 @@ class AppLinksInterceptorTest {
fun `external app is launched when launchInApp settings is set to AlwaysAsk and it is user triggered`() {
appLinksInterceptor = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = true,
launchInApp = { true },
useCases = mockUseCases,
launchFromInterceptor = true,
shouldPrompt = { true },
)
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, webUrlWithAppLink, null, true, false, false, false, false)
assertNull(response)
assert(response is RequestInterceptor.InterceptionResponse.AppIntent)
verify(mockOpenRedirect).invoke(any(), anyBoolean(), any())
}
@@ -504,11 +444,9 @@ class AppLinksInterceptorTest {
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)
@@ -520,7 +458,6 @@ class AppLinksInterceptorTest {
fun `try to use fallback url if user preference is not to launch in third party app`() {
appLinksInterceptor = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = true,
launchInApp = { false },
useCases = mockUseCases,
launchFromInterceptor = true,
@@ -532,17 +469,16 @@ class AppLinksInterceptorTest {
}
@Test
fun `external app is launched when url scheme is not supported by the engine`() {
fun `WHEN url scheme is not supported by the engine THEN external app is launched`() {
appLinksInterceptor = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = true,
launchInApp = { false },
useCases = mockUseCases,
launchFromInterceptor = true,
)
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, intentUrl, null, false, true, false, false, false)
assertNull(response)
assert(response is RequestInterceptor.InterceptionResponse.AppIntent)
verify(mockOpenRedirect).invoke(any(), anyBoolean(), any())
}
@@ -550,7 +486,6 @@ class AppLinksInterceptorTest {
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,
@@ -565,7 +500,6 @@ class AppLinksInterceptorTest {
fun `do not use fallback url if trigger by user gesture and preference is to launch in app`() {
appLinksInterceptor = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = true,
launchInApp = { true },
useCases = mockUseCases,
launchFromInterceptor = true,
@@ -580,7 +514,6 @@ class AppLinksInterceptorTest {
fun `launch marketplace intent if available and no external app`() {
appLinksInterceptor = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = true,
launchInApp = { true },
useCases = mockUseCases,
launchFromInterceptor = true,
@@ -595,7 +528,6 @@ class AppLinksInterceptorTest {
fun `use fallback url if available and no external app`() {
appLinksInterceptor = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = true,
launchInApp = { true },
useCases = mockUseCases,
launchFromInterceptor = true,
@@ -610,7 +542,6 @@ class AppLinksInterceptorTest {
fun `WHEN url have same domain THEN is same domain returns true ELSE false`() {
appLinksInterceptor = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = true,
launchInApp = { true },
useCases = mockUseCases,
launchFromInterceptor = true,
@@ -629,7 +560,6 @@ class AppLinksInterceptorTest {
fun `WHEN request is in user do not intercept cache THEN request is not intercepted`() {
appLinksInterceptor = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = true,
launchInApp = { true },
useCases = mockUseCases,
)
@@ -644,7 +574,6 @@ class AppLinksInterceptorTest {
fun `WHEN request is in user do not intercept cache but there is a fallback THEN fallback is used`() {
appLinksInterceptor = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = true,
launchInApp = { false },
useCases = mockUseCases,
launchFromInterceptor = true,
@@ -664,7 +593,6 @@ class AppLinksInterceptorTest {
val blocklistedScheme = "blocklisted"
val feature = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = false,
engineSupportedSchemes = setOf(supportedScheme),
alwaysDeniedSchemes = setOf(blocklistedScheme),
launchInApp = { true },
@@ -687,7 +615,6 @@ class AppLinksInterceptorTest {
val blocklistedScheme = "blocklisted"
val feature = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = false,
engineSupportedSchemes = setOf(supportedScheme),
alwaysDeniedSchemes = setOf(blocklistedScheme),
launchInApp = { false },
@@ -746,7 +673,6 @@ class AppLinksInterceptorTest {
fun `WHEN request is redirecting to external app quickly THEN request is not intercepted`() {
appLinksInterceptor = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = true,
launchInApp = { true },
useCases = mockUseCases,
)
@@ -762,7 +688,6 @@ class AppLinksInterceptorTest {
fun `WHEN request is redirecting to different app quickly THEN request is intercepted`() {
appLinksInterceptor = AppLinksInterceptor(
context = mockContext,
interceptLinkClicks = true,
launchInApp = { true },
useCases = mockUseCases,
)
@@ -774,342 +699,17 @@ class AppLinksInterceptorTest {
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(), any())
appLinksInterceptor.handleIntent(tab, intentUrl, mockAppIntent, null, mock(), "")
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
}
@Test
fun `WHEN should prompt AND only have marketing intent 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 mockMarketIntent: Intent = mock()
val mockComponentName: ComponentName = mock()
whenever(mockComponentName.packageName).thenReturn("com.zxing.app")
doReturn(mockDialog).`when`(appLinksInterceptor).getOrCreateDialog(anyBoolean(), any(), any())
appLinksInterceptor.handleIntent(tab, intentUrl, null, null, mockMarketIntent, "")
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.handleIntent(tab, intentUrl, mock(), null, 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.handleIntent(tab, intentUrl, appIntent, null, null, "")
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.handleIntent(tab, intentUrl, appIntent, null, null, "")
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(), any())
appLinksInterceptor.handleIntent(tab, intentUrl, appIntent, null, null, "")
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(), any())
appLinksInterceptor.handleIntent(tab, intentUrl, appIntent, null, null, "")
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(), any())
appLinksInterceptor.handleIntent(tabSessionState, intentUrl, mockAppIntent, null, mock(), "")
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(), any())
appLinksInterceptor.handleIntent(tabSessionState, intentUrl, mockAppIntent, null, mock(), "")
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(), any())
appLinksInterceptor.handleIntent(tabSessionState, intentUrl, appIntent, null, null, "")
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
doReturn(mockDialog).`when`(appLinksInterceptor).getOrCreateDialog(false, "", "")
doReturn(mockDialog).`when`(mockFragmentManager).findFragmentByTag(RedirectDialogFragment.FRAGMENT_TAG)
appLinksInterceptor.handleIntent(tabSessionState, intentUrl, mock(), null, 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(
@@ -1120,11 +720,7 @@ class AppLinksInterceptorTest {
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))
assertTrue(AppLinksInterceptor.isAuthentication(tabSessionState, "com.zxing.app"))
val tabSessionState2 = TabSessionState(
id = "tab1",
@@ -1134,7 +730,7 @@ class AppLinksInterceptorTest {
isProductUrl = false,
),
)
assertFalse(appLinksInterceptor.isAuthentication(tabSessionState2, appIntent))
assertFalse(AppLinksInterceptor.isAuthentication(tabSessionState2, "com.zxing.app"))
val tabSessionState3 = TabSessionState(
id = "tab1",
@@ -1145,10 +741,8 @@ class AppLinksInterceptorTest {
),
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))
assertFalse(AppLinksInterceptor.isAuthentication(tabSessionState3, "com.zxing.app"))
assertFalse(AppLinksInterceptor.isAuthentication(tabSessionState, null))
}
@Test
@@ -1157,19 +751,11 @@ class AppLinksInterceptorTest {
AppLinksInterceptor(
context = mockContext,
store = store,
interceptLinkClicks = true,
launchInApp = { true },
useCases = mockUseCases,
shouldPrompt = { true },
),
)
val appIntent: Intent = mock()
val componentName: ComponentName = mock()
whenever(appIntent.component).thenReturn(componentName)
whenever(componentName.packageName).thenReturn("com.zxing.app")
appLinksInterceptor.updateFragmentManger(mockFragmentManager)
val tabSessionState = TabSessionState(
id = "tab1",
content = ContentState(
@@ -1180,7 +766,7 @@ class AppLinksInterceptorTest {
source = SessionState.Source.External.CustomTab(ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY)),
)
assertTrue(appLinksInterceptor.isAuthentication(tabSessionState, appIntent))
assertTrue(AppLinksInterceptor.isAuthentication(tabSessionState, "com.zxing.app"))
val tabSessionState2 = TabSessionState(
id = "tab1",
@@ -1191,7 +777,7 @@ class AppLinksInterceptorTest {
),
source = SessionState.Source.External.ActionView(ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY)),
)
assertTrue(appLinksInterceptor.isAuthentication(tabSessionState2, appIntent))
assertTrue(AppLinksInterceptor.isAuthentication(tabSessionState2, "com.zxing.app"))
val tabSessionState3 = TabSessionState(
id = "tab1",
@@ -1202,7 +788,7 @@ class AppLinksInterceptorTest {
),
source = SessionState.Source.External.ActionSend(ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY)),
)
assertFalse(appLinksInterceptor.isAuthentication(tabSessionState3, appIntent))
assertFalse(AppLinksInterceptor.isAuthentication(tabSessionState3, "com.zxing.app"))
val tabSessionState4 = TabSessionState(
id = "tab1",
@@ -1213,7 +799,7 @@ class AppLinksInterceptorTest {
),
source = SessionState.Source.External.ActionSearch(ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY)),
)
assertFalse(appLinksInterceptor.isAuthentication(tabSessionState4, appIntent))
assertFalse(AppLinksInterceptor.isAuthentication(tabSessionState4, "com.zxing.app"))
}
@Test
@@ -1224,30 +810,4 @@ class AppLinksInterceptorTest {
assertFalse(isSubframeAllowed("http")) // we should never allow http for subframes
assertFalse(isSubframeAllowed("https")) // we should never allow https for subframes
}
@Test
fun `WHEN scheme is supported THEN loads URL`() {
val tab = createTab(webUrl, private = true)
appLinksInterceptor = spy(
AppLinksInterceptor(
context = mockContext,
store = store,
interceptLinkClicks = true,
launchInApp = { true },
useCases = mockUseCases,
shouldPrompt = { true },
loadUrlUseCase = mockLoadUrlUseCase,
),
)
appLinksInterceptor.loadUrlIfSchemeSupported(tab, intentUrl)
verify(mockLoadUrlUseCase, never()).invoke(anyString(), anyString(), any(), any(), any())
appLinksInterceptor.loadUrlIfSchemeSupported(tab, webUrl)
verify(mockLoadUrlUseCase, times(1)).invoke(anyString(), anyString(), any(), any(), any())
appLinksInterceptor.loadUrlIfSchemeSupported(tab, aboutUrl)
verify(mockLoadUrlUseCase, times(2)).invoke(anyString(), anyString(), any(), any(), any())
}
}

View File

@@ -676,20 +676,6 @@ class AppLinksUseCasesTest {
assertEquals(result?.`package`, "org.mozilla.test")
}
@Test
fun `WHEN launch in app is updated to true THEN should redirect`() {
val context = createContext(Triple(appUrl, appPackage, ""))
val subject = AppLinksUseCases(context, { false })
var redirect = subject.interceptedAppLinkRedirect(appUrl)
assertFalse(redirect.isRedirect())
AppLinksUseCases.clearRedirectCache()
subject.updateLaunchInApp { true }
redirect = subject.interceptedAppLinkRedirect(appUrl)
assertTrue(redirect.isRedirect())
}
@Test
fun `WHEN opening a app scheme uri WITH fallback URL WHERE the URL is Google PlayStore THEN ignore fallback URL`() {
val context = createContext(Triple(appIntentWithPackageAndPlayStoreFallback, appPackage, ""))

View File

@@ -15,7 +15,11 @@ import mozilla.components.feature.pwa.intent.WebAppIntentProcessor
/**
* This feature will intercept requests and reopen them in the corresponding installed PWA, if any.
*
* @param shortcutManager current shortcut manager instance to lookup web app install states
* @param context application context used for launching activities or accessing system services
* @param manifestStorage Disk storage for [WebAppManifest]. Other components use this class to
* reload a saved manifest.
* @param launchFromInterceptor flag to determine whether intercepted requests should directly launch
* the PWA
*/
class WebAppInterceptor(
private val context: Context,
@@ -39,7 +43,7 @@ class WebAppInterceptor(
val intent = createIntentFromUri(startUrl, uri)
if (!launchFromInterceptor) {
return RequestInterceptor.InterceptionResponse.AppIntent(intent, uri)
return RequestInterceptor.InterceptionResponse.AppIntent(intent, uri, null, null)
}
intent.flags = intent.flags or Intent.FLAG_ACTIVITY_NEW_TASK
@@ -51,7 +55,10 @@ class WebAppInterceptor(
/**
* Creates a new VIEW_PWA intent for a URL.
*
* @param uri target URL for the new intent
* @param startUrl the original start URL associated with the PWA
* @param urlOverride an optional override URL to open instead of the start URL; defaults to [startUrl]
*
* @return an [Intent] configured to launch the PWA with the given URL
*/
private fun createIntentFromUri(startUrl: String, urlOverride: String = startUrl): Intent {
return Intent(WebAppIntentProcessor.ACTION_VIEW_PWA, startUrl.toUri()).apply {

View File

@@ -12,6 +12,10 @@ permalink: /changelog/
* ⚠️ **Breaking change**: Added `downloadEstimator` property to `DownloadJobState`. [Bug 1956577](https://bugzilla.mozilla.org/show_bug.cgi?id=1956577).
* ⚠️ **Breaking change**: Added new 'dateTimeProvider' abstract val to `AbstractFetchDownloadService`. [Bug 1956577](https://bugzilla.mozilla.org/show_bug.cgi?id=1956577).
* ⚠️ **Breaking change**: Removed Deprecated `Long.toMegabyteOrKilobyteString` in v140. [Bug 1955689](https://bugzilla.mozilla.org/show_bug.cgi?id=1955689).
* **feature-app-links**
* Added `alwaysOpenCheckboxAction` parameter to `AppLinksFeature`, this was moved from `AppLinksInterceptor`.
* ⚠️ **Breaking change**: Moved prompt functionality back to `AppLinksFeature`.
* ⚠️ **Breaking change**: Removed `interceptLinkClicks` parameter from `AppLinksInterceptor` since no usage sets this to false.
# 139.0
* **feature-downloads**

View File

@@ -240,10 +240,6 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
customTabId = sessionId,
)
components.appLinksInterceptor.updateFragmentManger(
fragmentManager = parentFragmentManager,
)
// Observe the lifecycle for supported features
lifecycle.addObservers(
scrollFeature,
@@ -292,9 +288,6 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
}
override fun onDestroyView() {
super.onDestroyView()
components.appLinksInterceptor.updateFragmentManger(
fragmentManager = null,
)
binding.engineView.setActivityContext(null)
_binding = null

View File

@@ -243,8 +243,7 @@ open class DefaultComponents(private val applicationContext: Context) {
val appLinksInterceptor by lazy {
AppLinksInterceptor(
applicationContext,
interceptLinkClicks = true,
context = applicationContext,
launchInApp = {
applicationContext.components.preferences.getBoolean(PREF_LAUNCH_EXTERNAL_APP, false)
},

View File

@@ -77,6 +77,7 @@ import mozilla.components.concept.storage.CreditCardEntry
import mozilla.components.concept.storage.Login
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
@@ -302,6 +303,7 @@ abstract class BaseBrowserFragment :
private val lastTabFeature = ViewBoundFeatureWrapper<LastTabFeature>()
private val contextMenuFeature = ViewBoundFeatureWrapper<ContextMenuFeature>()
private val downloadsFeature = ViewBoundFeatureWrapper<DownloadsFeature>()
private val appLinksFeature = ViewBoundFeatureWrapper<AppLinksFeature>()
private val shareResourceFeature = ViewBoundFeatureWrapper<ShareResourceFeature>()
private val copyDownloadsFeature = ViewBoundFeatureWrapper<CopyDownloadFeature>()
private val promptsFeature = ViewBoundFeatureWrapper<PromptFeature>()
@@ -934,6 +936,33 @@ abstract class BaseBrowserFragment :
onHide = ::onAutocompleteBarHide,
)
appLinksFeature.set(
feature = AppLinksFeature(
context = requireContext(),
store = store,
sessionId = tab.id,
fragmentManager = parentFragmentManager,
launchInApp = { context.settings().shouldOpenLinksInApp(customTabSessionId != null) },
loadUrlUseCase = requireComponents.useCases.sessionUseCases.loadUrl,
shouldPrompt = { context.settings().shouldPromptOpenLinksInApp() },
alwaysOpenCheckboxAction = {
context.settings().openLinksInExternalApp =
context.getString(R.string.pref_key_open_links_in_apps_always)
},
failedToLaunchAction = { fallbackUrl ->
fallbackUrl?.let {
val appLinksUseCases = requireComponents.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 = binding.root,
)
promptsFeature.set(
feature = PromptFeature(
activity = activity,
@@ -1854,16 +1883,6 @@ abstract class BaseBrowserFragment :
}
hideToolbar()
components.services.appLinksInterceptor.updateFragmentManger(
fragmentManager = parentFragmentManager,
)
context?.settings()?.shouldOpenLinksInApp(customTabSessionId != null)
?.let { openLinksInExternalApp ->
components.services.appLinksInterceptor.updateLaunchInApp {
openLinksInExternalApp
}
}
evaluateMessagesForMicrosurvey(components)
BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt =
@@ -1881,10 +1900,6 @@ abstract class BaseBrowserFragment :
if (findNavController().currentDestination?.id != R.id.searchDialogFragment) {
view?.hideKeyboard()
}
requireComponents.services.appLinksInterceptor.updateFragmentManger(
fragmentManager = null,
)
}
@CallSuper

View File

@@ -13,8 +13,6 @@ 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
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.perf.lazyMonitored
import org.mozilla.fenix.settings.SupportUtils
@@ -48,17 +46,9 @@ class Services(
val appLinksInterceptor by lazyMonitored {
AppLinksInterceptor(
context = context,
interceptLinkClicks = true,
showCheckbox = true,
launchInApp = { context.settings().shouldOpenLinksInApp() },
shouldPrompt = { context.settings().shouldPromptOpenLinksInApp() },
checkboxCheckedAction = {
context.settings().openLinksInExternalApp =
context.getString(R.string.pref_key_open_links_in_apps_always)
},
launchFromInterceptor = true,
launchFromInterceptor = false,
store = store,
loadUrlUseCase = context.components.useCases.sessionUseCases.loadUrl,
)
}
}

View File

@@ -249,7 +249,6 @@ class Components(
val appLinksInterceptor by lazy {
AppLinksInterceptor(
context,
interceptLinkClicks = true,
launchInApp = {
context.settings.openLinksInExternalApp
},

View File

@@ -821,13 +821,6 @@ class BrowserFragment :
if (tab.isCustomTab()) {
view?.isVisible = true
}
context?.settings?.openLinksInExternalApp?.let { openLinksInExternalApp ->
val isCustomTab = tab.isCustomTab()
components?.appLinksInterceptor?.updateLaunchInApp {
openLinksInExternalApp || isCustomTab
}
}
}
private fun updateEngineColorScheme() {