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

View File

@@ -1517,9 +1517,9 @@ class EngineObserverTest {
val store: BrowserStore = mock() val store: BrowserStore = mock()
val observer = EngineObserver("test-id", store) val observer = EngineObserver("test-id", store)
val intent: Intent = mock() 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 @Test

View File

@@ -256,10 +256,14 @@ abstract class EngineSession(
* @param url The string url that was requested. * @param url The string url that was requested.
* @param appIntent The Android Intent that was requested. * @param appIntent The Android Intent that was requested.
* web content (as opposed to via the browser chrome). * 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( fun onLaunchIntentRequest(
url: String, url: String,
appIntent: Intent?, appIntent: Intent?,
fallbackUrl: String?,
appName: String?,
) = Unit ) = Unit
/** /**

View File

@@ -40,7 +40,12 @@ interface RequestInterceptor {
val additionalHeaders: Map<String, String>? = null, val additionalHeaders: Map<String, String>? = null,
) : InterceptionResponse() ) : 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. * Deny request without further action.

View File

@@ -79,7 +79,7 @@ class EngineSessionTest {
session.notifyInternalObservers { onMediaFullscreenChanged(true, mediaSessionElementMetadata) } session.notifyInternalObservers { onMediaFullscreenChanged(true, mediaSessionElementMetadata) }
session.notifyInternalObservers { onCrash() } session.notifyInternalObservers { onCrash() }
session.notifyInternalObservers { onLoadRequest("https://www.mozilla.org", true, true) } 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 { onProcessKilled() }
session.notifyInternalObservers { onShowDynamicToolbar() } session.notifyInternalObservers { onShowDynamicToolbar() }
@@ -114,7 +114,7 @@ class EngineSessionTest {
verify(observer).onMediaFullscreenChanged(true, mediaSessionElementMetadata) verify(observer).onMediaFullscreenChanged(true, mediaSessionElementMetadata)
verify(observer).onCrash() verify(observer).onCrash()
verify(observer).onLoadRequest("https://www.mozilla.org", true, true) 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).onProcessKilled()
verify(observer).onShowDynamicToolbar() verify(observer).onShowDynamicToolbar()
verifyNoMoreInteractions(observer) verifyNoMoreInteractions(observer)
@@ -152,7 +152,7 @@ class EngineSessionTest {
session.notifyInternalObservers { onWindowRequest(windowRequest) } session.notifyInternalObservers { onWindowRequest(windowRequest) }
session.notifyInternalObservers { onCrash() } session.notifyInternalObservers { onCrash() }
session.notifyInternalObservers { onLoadRequest("https://www.mozilla.org", true, true) } 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.notifyInternalObservers { onShowDynamicToolbar() }
session.unregister(observer) session.unregister(observer)
@@ -189,7 +189,7 @@ class EngineSessionTest {
session.notifyInternalObservers { onMediaFullscreenChanged(true, mediaSessionElementMetadata) } session.notifyInternalObservers { onMediaFullscreenChanged(true, mediaSessionElementMetadata) }
session.notifyInternalObservers { onCrash() } session.notifyInternalObservers { onCrash() }
session.notifyInternalObservers { onLoadRequest("https://www.mozilla.org", true, true) } 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() } session.notifyInternalObservers { onShowDynamicToolbar() }
verify(observer).onScrollChange(1234, 4321) verify(observer).onScrollChange(1234, 4321)
@@ -211,7 +211,7 @@ class EngineSessionTest {
verify(observer).onWindowRequest(windowRequest) verify(observer).onWindowRequest(windowRequest)
verify(observer).onCrash() verify(observer).onCrash()
verify(observer).onLoadRequest("https://www.mozilla.org", true, true) 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).onShowDynamicToolbar()
verify(observer, never()).onScrollChange(2345, 5432) verify(observer, never()).onScrollChange(2345, 5432)
verify(observer, never()).onLocationChange("https://www.firefox.com", false) verify(observer, never()).onLocationChange("https://www.firefox.com", false)
@@ -239,7 +239,7 @@ class EngineSessionTest {
verify(observer, never()).onMediaMuteChanged(true) verify(observer, never()).onMediaMuteChanged(true)
verify(observer, never()).onMediaFullscreenChanged(true, mediaSessionElementMetadata) verify(observer, never()).onMediaFullscreenChanged(true, mediaSessionElementMetadata)
verify(observer, never()).onLoadRequest("https://www.mozilla.org", false, true) 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) verifyNoMoreInteractions(observer)
} }
@@ -964,7 +964,7 @@ class EngineSessionTest {
observer.onWindowRequest(windowRequest) observer.onWindowRequest(windowRequest)
observer.onCrash() observer.onCrash()
observer.onLoadRequest("https://www.mozilla.org", true, true) 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.onProcessKilled()
observer.onShowDynamicToolbar() 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.base.feature.LifecycleAwareFeature
import mozilla.components.support.ktx.android.content.appName 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 * 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 * 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. * have registered to open.
* @param failedToLaunchAction Action to perform when failing to launch in third party app. * @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 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( class AppLinksFeature(
private val context: Context, private val context: Context,
@@ -58,6 +64,7 @@ class AppLinksFeature(
private val loadUrlUseCase: SessionUseCases.DefaultLoadUrlUseCase? = null, private val loadUrlUseCase: SessionUseCases.DefaultLoadUrlUseCase? = null,
private val engineSupportedSchemes: Set<String> = ENGINE_SUPPORTED_SCHEMES, private val engineSupportedSchemes: Set<String> = ENGINE_SUPPORTED_SCHEMES,
private val shouldPrompt: () -> Boolean = { true }, private val shouldPrompt: () -> Boolean = { true },
private val alwaysOpenCheckboxAction: (() -> Unit)? = null,
) : LifecycleAwareFeature { ) : LifecycleAwareFeature {
private var scope: CoroutineScope? = null private var scope: CoroutineScope? = null
@@ -71,10 +78,16 @@ class AppLinksFeature(
.distinctUntilChangedBy { .distinctUntilChangedBy {
it.content.appIntent it.content.appIntent
} }
.collect { tab -> .collect { sessionState ->
tab.content.appIntent?.let { sessionState.content.appIntent?.let {
handleAppIntent(tab, it.url, it.appIntent) handleAppIntent(
store.dispatch(ContentAction.ConsumeAppIntentAction(tab.id)) 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) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun handleAppIntent(tab: SessionState, url: String, appIntent: Intent?) { internal fun handleAppIntent(
if (appIntent == null) { 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 return
} }
val doNotOpenApp = { if (isADialogAlreadyCreated() || fragmentManager?.isStateSaved == true) {
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()
return return
} }
val dialog = getOrCreateDialog(tab.content.private, url) showRedirectDialog(
dialog.onConfirmRedirect = { doOpenApp() } sessionState = sessionState,
dialog.onCancelRedirect = doNotOpenApp url = url,
fallbackUrl = fallbackUrl,
if (!isAlreadyADialogCreated()) { appIntent = appIntent,
dialog.showNow(fragmentManager, FRAGMENT_TAG) appName = appName,
} isPrivate = isPrivate,
fragmentManager = fragmentManager,
)
} }
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @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) { if (dialog != null) {
return dialog return dialog
} }
val message = context.getString( val dialogTitle = when {
R.string.mozac_feature_applinks_normal_confirm_dialog_message, isPrivate && !targetAppName.isNullOrBlank() -> {
context.appName, 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( return SimpleRedirectDialogFragment.newInstance(
dialogTitleString = if (isPrivate) { dialogTitleString = dialogTitle,
context.getString(R.string.mozac_feature_applinks_confirm_dialog_title) dialogMessageString = dialogMessage,
} else { showCheckbox = if (isPrivate) false else alwaysOpenCheckboxAction != null,
context.getString(R.string.mozac_feature_applinks_normal_confirm_dialog_title) maxSuccessiveDialogMillisLimit = MAX_SUCCESSIVE_DIALOG_MILLIS_LIMIT,
},
dialogMessageString = if (isPrivate) {
url
} else {
message
},
) )
} }
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun loadUrlIfSchemeSupported(tab: SessionState, url: String) { internal fun isSchemeSupported(url: String): Boolean {
val schemeSupported = engineSupportedSchemes.contains(url.toUri().scheme) return 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 isAlreadyADialogCreated(): Boolean { private fun isADialogAlreadyCreated(): Boolean {
return findPreviousDialogFragment() != null return findPreviousDialogFragment() != null
} }
private fun findPreviousDialogFragment(): RedirectDialogFragment? { private fun findPreviousDialogFragment(): RedirectDialogFragment? {
return fragmentManager?.findFragmentByTag(FRAGMENT_TAG) as? 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 android.os.SystemClock
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.fragment.app.FragmentManager
import mozilla.components.browser.state.selector.findTabOrCustomTab import mozilla.components.browser.state.selector.findTabOrCustomTab
import mozilla.components.browser.state.state.SessionState import mozilla.components.browser.state.state.SessionState
import mozilla.components.browser.state.store.BrowserStore import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.EngineSession 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.concept.engine.request.RequestInterceptor
import mozilla.components.feature.app.links.AppLinksUseCases.Companion.ALWAYS_DENY_SCHEMES 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.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.android.net.isHttpOrHttps
import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl 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. * and https://github.com/mozilla-mobile/android-components/issues/2975.
* *
* @param context Context the feature is associated with. * @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 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 * @param alwaysDeniedSchemes List of schemes that will never be opened in a third-party app
* [interceptLinkClicks] is `true`.
* @param launchInApp If {true} then launch app links in third party app(s). Default to false because * @param launchInApp If {true} then launch app links in third party app(s). Default to false because
* of security concerns. * of security concerns.
* @param useCases These use cases allow for the detection of, and opening of links that other apps * @param useCases These use cases allow for the detection of, and opening of links that other apps
* have registered to open. * have registered to open.
* @param launchFromInterceptor If {true} then the interceptor will prompt and launch the link in * @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] * 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 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( class AppLinksInterceptor(
private val context: Context, private val context: Context,
private val interceptLinkClicks: Boolean = false,
private val engineSupportedSchemes: Set<String> = ENGINE_SUPPORTED_SCHEMES, private val engineSupportedSchemes: Set<String> = ENGINE_SUPPORTED_SCHEMES,
private val alwaysDeniedSchemes: Set<String> = ALWAYS_DENY_SCHEMES, private val alwaysDeniedSchemes: Set<String> = ALWAYS_DENY_SCHEMES,
private var launchInApp: () -> Boolean = { false }, private var launchInApp: () -> Boolean = { false },
@@ -82,32 +68,8 @@ class AppLinksInterceptor(
alwaysDeniedSchemes = alwaysDeniedSchemes, alwaysDeniedSchemes = alwaysDeniedSchemes,
), ),
private val launchFromInterceptor: Boolean = false, private val launchFromInterceptor: Boolean = false,
private val showCheckbox: Boolean = false,
private val store: BrowserStore? = null, 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 { ) : 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") @Suppress("ComplexMethod", "ReturnCount")
override fun onLoadRequest( override fun onLoadRequest(
@@ -137,8 +99,8 @@ class AppLinksInterceptor(
(!hasUserGesture && !isAllowedRedirect && !isDirectNavigation) || (!hasUserGesture && !isAllowedRedirect && !isDirectNavigation) ||
isSameDomain(lastUri, uri) isSameDomain(lastUri, uri)
) && engineSupportsScheme -> true ) && engineSupportsScheme -> true
// If scheme not in safelist then follow user preference // If scheme not in supported list then follow user preference
(!interceptLinkClicks || !launchInApp()) && engineSupportsScheme -> true !launchInApp() && !isPossibleAuthentication(tabSessionState) && engineSupportsScheme -> true
// Never go to an external app when scheme is in blocklist // Never go to an external app when scheme is in blocklist
alwaysDeniedSchemes.contains(uriScheme) -> true alwaysDeniedSchemes.contains(uriScheme) -> true
else -> false else -> false
@@ -151,10 +113,14 @@ class AppLinksInterceptor(
val tabId = tabSessionState?.id ?: "" val tabId = tabSessionState?.id ?: ""
val redirect = useCases.interceptedAppLinkRedirect(uri) val redirect = useCases.interceptedAppLinkRedirect(uri)
val result = handleRedirect(redirect, uri, tabId) 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()) { if (redirect.hasExternalApp()) {
val packageName = redirect.appIntent?.component?.packageName
if ( if (
lastApplinksPackageWithTimestamp.first == packageName && lastApplinksPackageWithTimestamp.second + lastApplinksPackageWithTimestamp.first == packageName && lastApplinksPackageWithTimestamp.second +
APP_LINKS_DO_NOT_INTERCEPT_INTERVAL > SystemClock.elapsedRealtime() APP_LINKS_DO_NOT_INTERCEPT_INTERVAL > SystemClock.elapsedRealtime()
@@ -166,20 +132,9 @@ class AppLinksInterceptor(
} }
if (redirect.isRedirect()) { if (redirect.isRedirect()) {
if ( if (launchFromInterceptor && result is RequestInterceptor.InterceptionResponse.AppIntent) {
launchFromInterceptor && result.appIntent.flags = result.appIntent.flags or Intent.FLAG_ACTIVITY_NEW_TASK
result is RequestInterceptor.InterceptionResponse.AppIntent useCases.openAppLink(result.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
} }
return result return result
@@ -211,19 +166,29 @@ class AppLinksInterceptor(
} }
if (!redirect.hasExternalApp()) { if (!redirect.hasExternalApp()) {
redirect.marketplaceIntent?.let { redirect.marketplaceIntent?.let { appIntent ->
return RequestInterceptor.InterceptionResponse.AppIntent(it, uri) return RequestInterceptor.InterceptionResponse.AppIntent(
appIntent = appIntent,
url = uri,
fallbackUrl = redirect.fallbackUrl,
appName = redirect.appName,
)
} }
redirect.fallbackUrl?.let { redirect.fallbackUrl?.let { fallbackUrl ->
return RequestInterceptor.InterceptionResponse.Url(it) return RequestInterceptor.InterceptionResponse.Url(url = fallbackUrl)
} }
return null return null
} }
redirect.appIntent?.let { redirect.appIntent?.let { appIntent ->
return RequestInterceptor.InterceptionResponse.AppIntent(it, uri) return RequestInterceptor.InterceptionResponse.AppIntent(
appIntent = appIntent,
url = uri,
fallbackUrl = redirect.fallbackUrl,
appName = redirect.appName,
)
} }
return null 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 { companion object {
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal var userDoNotInterceptCache: MutableMap<Int, Long> = mutableMapOf() internal var userDoNotInterceptCache: MutableMap<Int, Long> = mutableMapOf()
@@ -443,7 +261,43 @@ class AppLinksInterceptor(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal const val APP_LINKS_DO_NOT_INTERCEPT_INTERVAL = 2000L // 2 second 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? { private fun findDefaultActivity(intent: Intent): ResolveInfo? {
return context.packageManager.resolveActivityCompat(intent, PackageManager.MATCH_DEFAULT_ONLY) return context.packageManager.resolveActivityCompat(intent, PackageManager.MATCH_DEFAULT_ONLY)
} }

View File

@@ -111,35 +111,35 @@ class AppLinksFeatureTest {
} }
@Test @Test
fun `feature observes app intents when started`() { fun `WHEN feature started THEN feature observes app intents`() {
val tab = createTab(webUrl) val tab = createTab(webUrl)
store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking() 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 intent: Intent = mock()
val appIntent = AppIntentState(intentUrl, intent) val appIntent = AppIntentState(intentUrl, intent, null, null)
store.dispatch(ContentAction.UpdateAppIntentAction(tab.id, appIntent)).joinBlocking() store.dispatch(ContentAction.UpdateAppIntentAction(tab.id, appIntent)).joinBlocking()
store.waitUntilIdle() store.waitUntilIdle()
verify(feature).handleAppIntent(any(), any(), any()) verify(feature).handleAppIntent(any(), any(), any(), any(), any())
val tabWithConsumedAppIntent = store.state.findTab(tab.id)!! val tabWithConsumedAppIntent = store.state.findTab(tab.id)!!
assertNull(tabWithConsumedAppIntent.content.appIntent) assertNull(tabWithConsumedAppIntent.content.appIntent)
} }
@Test @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) val tab = createTab(webUrl)
store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking() store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
verify(feature, never()).handleAppIntent(any(), any(), any()) verify(feature, never()).handleAppIntent(any(), any(), any(), any(), any())
feature.stop() feature.stop()
val intent: Intent = mock() 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.dispatch(ContentAction.UpdateAppIntentAction(tab.id, appIntent)).joinBlocking()
verify(feature, never()).handleAppIntent(any(), any(), any()) verify(feature, never()).handleAppIntent(any(), any(), any(), any(), any())
} }
@Test @Test
@@ -159,7 +159,7 @@ class AppLinksFeatureTest {
} }
val tab = createTab(webUrl) val tab = createTab(webUrl)
feature.handleAppIntent(tab, intentUrl, mock()) feature.handleAppIntent(tab, intentUrl, mock(), null, null)
verify(mockDialog).showNow(eq(mockFragmentManager), anyString()) verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any()) verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
@@ -182,7 +182,7 @@ class AppLinksFeatureTest {
} }
val tab = createTab(webUrl) val tab = createTab(webUrl)
feature.handleAppIntent(tab, intentUrl, mock()) feature.handleAppIntent(tab, intentUrl, mock(), null, null)
verify(mockDialog, never()).showNow(eq(mockFragmentManager), anyString()) verify(mockDialog, never()).showNow(eq(mockFragmentManager), anyString())
} }
@@ -217,7 +217,7 @@ class AppLinksFeatureTest {
doReturn(componentName).`when`(appIntent).component doReturn(componentName).`when`(appIntent).component
doReturn("com.zxing.app").`when`(componentName).packageName 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()) verify(mockDialog, never()).showNow(eq(mockFragmentManager), anyString())
} }
@@ -252,7 +252,7 @@ class AppLinksFeatureTest {
doReturn(componentName).`when`(appIntent).component doReturn(componentName).`when`(appIntent).component
doReturn("com.zxing.app").`when`(componentName).packageName 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()) verify(mockDialog, never()).showNow(eq(mockFragmentManager), anyString())
} }
@@ -287,7 +287,7 @@ class AppLinksFeatureTest {
doReturn(componentName).`when`(appIntent).component doReturn(componentName).`when`(appIntent).component
doReturn("com.zxing.app").`when`(componentName).packageName 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(mockDialog).showNow(eq(mockFragmentManager), anyString())
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any()) verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
@@ -323,7 +323,7 @@ class AppLinksFeatureTest {
doReturn(componentName).`when`(appIntent).component doReturn(componentName).`when`(appIntent).component
doReturn("com.zxing.app").`when`(componentName).packageName 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(mockDialog).showNow(eq(mockFragmentManager), anyString())
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any()) verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
@@ -346,7 +346,7 @@ class AppLinksFeatureTest {
} }
val tab = createTab(webUrl, private = true) 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(mockDialog).showNow(eq(mockFragmentManager), anyString())
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any()) verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
@@ -369,7 +369,7 @@ class AppLinksFeatureTest {
} }
val tab = createTab(webUrl, private = true) 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(mockDialog).showNow(eq(mockFragmentManager), anyString())
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any()) verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
@@ -378,60 +378,41 @@ class AppLinksFeatureTest {
@Test @Test
fun `redirect dialog is only added once`() { fun `redirect dialog is only added once`() {
val tab = createTab(webUrl, private = true) 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(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) 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) verify(mockDialog, times(1)).showNow(mockFragmentManager, RedirectDialogFragment.FRAGMENT_TAG)
} }
@Test @Test
fun `only loads URL if scheme is supported`() { fun `WHEN url is not supported THEN isSchemeSupported returns false`() {
val tab = createTab(webUrl, private = true) assertFalse(feature.isSchemeSupported(intentUrl))
assertTrue(feature.isSchemeSupported(webUrl))
feature.loadUrlIfSchemeSupported(tab, intentUrl) assertTrue(feature.isSchemeSupported(aboutUrl))
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())
} }
@Test @Test
fun `WHEN caller and intent have the same package name THEN return true`() { fun `WHEN url or fallback url scheme is supported THEN cancel redirect will load it`() {
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))
val tab = createTab(webUrl, private = true) val tab = createTab(webUrl, private = true)
assertFalse(feature.isAuthentication(tab, appIntent)) val intent: Intent = mock()
val customTab2 = feature.cancelRedirect(tab, intentUrl, null, intent)
createCustomTab( verify(mockLoadUrlUseCase, never()).invoke(anyString(), anyString(), any(), any(), any())
id = "c",
url = webUrl,
source = SessionState.Source.External.CustomTab(
ExternalPackage("com.example.app", PackageCategory.PRODUCTIVITY),
),
)
assertFalse(feature.isAuthentication(customTab2, appIntent))
doReturn(null).`when`(componentName).packageName feature.cancelRedirect(tab, intentUrl, intentUrl, intent)
assertFalse(feature.isAuthentication(customTab, appIntent)) 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.PackageCategory
import mozilla.components.browser.state.state.SessionState import mozilla.components.browser.state.state.SessionState
import mozilla.components.browser.state.state.TabSessionState 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.browser.state.store.BrowserStore
import mozilla.components.concept.engine.EngineSession import mozilla.components.concept.engine.EngineSession
import mozilla.components.concept.engine.request.RequestInterceptor 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.app.links.AppLinksInterceptor.Companion.userDoNotInterceptCache
import mozilla.components.feature.session.SessionUseCases import mozilla.components.feature.session.SessionUseCases
import mozilla.components.support.test.any import mozilla.components.support.test.any
import mozilla.components.support.test.eq
import mozilla.components.support.test.mock import mozilla.components.support.test.mock
import mozilla.components.support.test.whenever import mozilla.components.support.test.whenever
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -39,9 +36,7 @@ import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.anyBoolean import org.mockito.ArgumentMatchers.anyBoolean
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito.doReturn import org.mockito.Mockito.doReturn
import org.mockito.Mockito.never
import org.mockito.Mockito.spy import org.mockito.Mockito.spy
import org.mockito.Mockito.times import org.mockito.Mockito.times
import org.mockito.Mockito.verify import org.mockito.Mockito.verify
@@ -65,7 +60,6 @@ class AppLinksInterceptorTest {
private val intentUrl = "zxing://scan;S.browser_fallback_url=example.com" private val intentUrl = "zxing://scan;S.browser_fallback_url=example.com"
private val fallbackUrl = "https://getpocket.com" private val fallbackUrl = "https://getpocket.com"
private val marketplaceUrl = "market://details?id=example.com" private val marketplaceUrl = "market://details?id=example.com"
private val aboutUrl = "about://scan"
@Before @Before
fun setup() { fun setup() {
@@ -98,7 +92,6 @@ class AppLinksInterceptorTest {
appLinksInterceptor = AppLinksInterceptor( appLinksInterceptor = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = true,
launchInApp = { true }, launchInApp = { true },
useCases = mockUseCases, useCases = mockUseCases,
) )
@@ -129,47 +122,27 @@ class AppLinksInterceptorTest {
} }
@Test @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( appLinksInterceptor = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = false,
launchInApp = { true }, launchInApp = { true },
useCases = mockUseCases, useCases = mockUseCases,
) )
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, webUrlWithAppLink, null, true, false, false, false, false) val response = appLinksInterceptor.onLoadRequest(mockEngineSession, webUrlWithAppLink, null, true, false, false, false, false)
assertEquals(null, response) assert(response is RequestInterceptor.InterceptionResponse.AppIntent)
}
@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)
} }
@Test @Test
@@ -229,7 +202,6 @@ class AppLinksInterceptorTest {
val blocklistedScheme = "blocklisted" val blocklistedScheme = "blocklisted"
val feature = AppLinksInterceptor( val feature = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = true,
alwaysDeniedSchemes = setOf(blocklistedScheme), alwaysDeniedSchemes = setOf(blocklistedScheme),
launchInApp = { true }, launchInApp = { true },
useCases = mockUseCases, useCases = mockUseCases,
@@ -248,7 +220,6 @@ class AppLinksInterceptorTest {
val supportedScheme = "supported" val supportedScheme = "supported"
val feature = AppLinksInterceptor( val feature = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = true,
engineSupportedSchemes = setOf(supportedScheme), engineSupportedSchemes = setOf(supportedScheme),
launchInApp = { false }, launchInApp = { false },
useCases = mockUseCases, useCases = mockUseCases,
@@ -261,32 +232,12 @@ class AppLinksInterceptorTest {
assertEquals(null, response) 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 @Test
fun `supported schemes request not launched if not triggered by user`() { fun `supported schemes request not launched if not triggered by user`() {
val engineSession: EngineSession = mock() val engineSession: EngineSession = mock()
val supportedScheme = "supported" val supportedScheme = "supported"
val feature = AppLinksInterceptor( val feature = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = true,
engineSupportedSchemes = setOf(supportedScheme), engineSupportedSchemes = setOf(supportedScheme),
launchInApp = { true }, launchInApp = { true },
useCases = mockUseCases, useCases = mockUseCases,
@@ -300,14 +251,13 @@ class AppLinksInterceptorTest {
} }
@Test @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 engineSession: EngineSession = mock()
val supportedScheme = "supported" val supportedScheme = "supported"
val notSupportedScheme = "not_supported" val notSupportedScheme = "not_supported"
val blocklistedScheme = "blocklisted" val blocklistedScheme = "blocklisted"
val feature = AppLinksInterceptor( val feature = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = false,
engineSupportedSchemes = setOf(supportedScheme), engineSupportedSchemes = setOf(supportedScheme),
alwaysDeniedSchemes = setOf(blocklistedScheme), alwaysDeniedSchemes = setOf(blocklistedScheme),
launchInApp = { false }, launchInApp = { false },
@@ -328,7 +278,6 @@ class AppLinksInterceptorTest {
val notSupportedScheme = "not_supported" val notSupportedScheme = "not_supported"
val feature = AppLinksInterceptor( val feature = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = false,
engineSupportedSchemes = setOf(supportedScheme), engineSupportedSchemes = setOf(supportedScheme),
alwaysDeniedSchemes = setOf(notSupportedScheme), alwaysDeniedSchemes = setOf(notSupportedScheme),
launchInApp = { false }, launchInApp = { false },
@@ -350,7 +299,6 @@ class AppLinksInterceptorTest {
val blocklistedScheme = "blocklisted" val blocklistedScheme = "blocklisted"
val feature = AppLinksInterceptor( val feature = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = false,
engineSupportedSchemes = setOf(supportedScheme), engineSupportedSchemes = setOf(supportedScheme),
alwaysDeniedSchemes = setOf(blocklistedScheme), alwaysDeniedSchemes = setOf(blocklistedScheme),
launchInApp = { true }, launchInApp = { true },
@@ -372,7 +320,6 @@ class AppLinksInterceptorTest {
val blocklistedScheme = "blocklisted" val blocklistedScheme = "blocklisted"
val feature = AppLinksInterceptor( val feature = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = true,
engineSupportedSchemes = setOf(supportedScheme), engineSupportedSchemes = setOf(supportedScheme),
alwaysDeniedSchemes = setOf(blocklistedScheme), alwaysDeniedSchemes = setOf(blocklistedScheme),
launchInApp = { false }, launchInApp = { false },
@@ -395,7 +342,6 @@ class AppLinksInterceptorTest {
val blocklistedScheme = "blocklisted" val blocklistedScheme = "blocklisted"
val feature = AppLinksInterceptor( val feature = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = true,
engineSupportedSchemes = setOf(supportedScheme), engineSupportedSchemes = setOf(supportedScheme),
alwaysDeniedSchemes = setOf(blocklistedScheme), alwaysDeniedSchemes = setOf(blocklistedScheme),
launchInApp = { false }, launchInApp = { false },
@@ -415,7 +361,6 @@ class AppLinksInterceptorTest {
val engineSession: EngineSession = mock() val engineSession: EngineSession = mock()
val feature = AppLinksInterceptor( val feature = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = false,
launchInApp = { false }, launchInApp = { false },
useCases = mockUseCases, useCases = mockUseCases,
) )
@@ -432,7 +377,6 @@ class AppLinksInterceptorTest {
val engineSession: EngineSession = mock() val engineSession: EngineSession = mock()
val feature = AppLinksInterceptor( val feature = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = false,
launchInApp = { false }, launchInApp = { false },
useCases = mockUseCases, 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`() { fun `external app is launched when launch in app is set to true and it is user triggered`() {
appLinksInterceptor = AppLinksInterceptor( appLinksInterceptor = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = true,
launchInApp = { true }, launchInApp = { true },
useCases = mockUseCases, useCases = mockUseCases,
launchFromInterceptor = true, launchFromInterceptor = true,
shouldPrompt = { false },
) )
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, webUrlWithAppLink, null, true, false, false, false, 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`() { fun `external app is launched when launchInApp settings is set to AlwaysAsk and it is user triggered`() {
appLinksInterceptor = AppLinksInterceptor( appLinksInterceptor = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = true,
launchInApp = { true }, launchInApp = { true },
useCases = mockUseCases, useCases = mockUseCases,
launchFromInterceptor = true, launchFromInterceptor = true,
shouldPrompt = { true },
) )
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, webUrlWithAppLink, null, true, false, false, false, false) 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()) 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`() { 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( appLinksInterceptor = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = true,
launchInApp = { true }, launchInApp = { true },
useCases = mockUseCases, useCases = mockUseCases,
launchFromInterceptor = false, launchFromInterceptor = false,
shouldPrompt = { false },
) )
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, webUrlWithAppLink, null, true, false, false, false, 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`() { fun `try to use fallback url if user preference is not to launch in third party app`() {
appLinksInterceptor = AppLinksInterceptor( appLinksInterceptor = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = true,
launchInApp = { false }, launchInApp = { false },
useCases = mockUseCases, useCases = mockUseCases,
launchFromInterceptor = true, launchFromInterceptor = true,
@@ -532,17 +469,16 @@ class AppLinksInterceptorTest {
} }
@Test @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( appLinksInterceptor = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = true,
launchInApp = { false }, launchInApp = { false },
useCases = mockUseCases, useCases = mockUseCases,
launchFromInterceptor = true, launchFromInterceptor = true,
) )
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, intentUrl, null, false, true, false, false, false) 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()) 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`() { fun `WHEN url scheme is not supported by the engine AND launch from interceptor is false THEN app intent is returned`() {
appLinksInterceptor = AppLinksInterceptor( appLinksInterceptor = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = true,
launchInApp = { false }, launchInApp = { false },
useCases = mockUseCases, useCases = mockUseCases,
launchFromInterceptor = false, 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`() { fun `do not use fallback url if trigger by user gesture and preference is to launch in app`() {
appLinksInterceptor = AppLinksInterceptor( appLinksInterceptor = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = true,
launchInApp = { true }, launchInApp = { true },
useCases = mockUseCases, useCases = mockUseCases,
launchFromInterceptor = true, launchFromInterceptor = true,
@@ -580,7 +514,6 @@ class AppLinksInterceptorTest {
fun `launch marketplace intent if available and no external app`() { fun `launch marketplace intent if available and no external app`() {
appLinksInterceptor = AppLinksInterceptor( appLinksInterceptor = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = true,
launchInApp = { true }, launchInApp = { true },
useCases = mockUseCases, useCases = mockUseCases,
launchFromInterceptor = true, launchFromInterceptor = true,
@@ -595,7 +528,6 @@ class AppLinksInterceptorTest {
fun `use fallback url if available and no external app`() { fun `use fallback url if available and no external app`() {
appLinksInterceptor = AppLinksInterceptor( appLinksInterceptor = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = true,
launchInApp = { true }, launchInApp = { true },
useCases = mockUseCases, useCases = mockUseCases,
launchFromInterceptor = true, launchFromInterceptor = true,
@@ -610,7 +542,6 @@ class AppLinksInterceptorTest {
fun `WHEN url have same domain THEN is same domain returns true ELSE false`() { fun `WHEN url have same domain THEN is same domain returns true ELSE false`() {
appLinksInterceptor = AppLinksInterceptor( appLinksInterceptor = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = true,
launchInApp = { true }, launchInApp = { true },
useCases = mockUseCases, useCases = mockUseCases,
launchFromInterceptor = true, launchFromInterceptor = true,
@@ -629,7 +560,6 @@ class AppLinksInterceptorTest {
fun `WHEN request is in user do not intercept cache THEN request is not intercepted`() { fun `WHEN request is in user do not intercept cache THEN request is not intercepted`() {
appLinksInterceptor = AppLinksInterceptor( appLinksInterceptor = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = true,
launchInApp = { true }, launchInApp = { true },
useCases = mockUseCases, 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`() { fun `WHEN request is in user do not intercept cache but there is a fallback THEN fallback is used`() {
appLinksInterceptor = AppLinksInterceptor( appLinksInterceptor = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = true,
launchInApp = { false }, launchInApp = { false },
useCases = mockUseCases, useCases = mockUseCases,
launchFromInterceptor = true, launchFromInterceptor = true,
@@ -664,7 +593,6 @@ class AppLinksInterceptorTest {
val blocklistedScheme = "blocklisted" val blocklistedScheme = "blocklisted"
val feature = AppLinksInterceptor( val feature = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = false,
engineSupportedSchemes = setOf(supportedScheme), engineSupportedSchemes = setOf(supportedScheme),
alwaysDeniedSchemes = setOf(blocklistedScheme), alwaysDeniedSchemes = setOf(blocklistedScheme),
launchInApp = { true }, launchInApp = { true },
@@ -687,7 +615,6 @@ class AppLinksInterceptorTest {
val blocklistedScheme = "blocklisted" val blocklistedScheme = "blocklisted"
val feature = AppLinksInterceptor( val feature = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = false,
engineSupportedSchemes = setOf(supportedScheme), engineSupportedSchemes = setOf(supportedScheme),
alwaysDeniedSchemes = setOf(blocklistedScheme), alwaysDeniedSchemes = setOf(blocklistedScheme),
launchInApp = { false }, launchInApp = { false },
@@ -746,7 +673,6 @@ class AppLinksInterceptorTest {
fun `WHEN request is redirecting to external app quickly THEN request is not intercepted`() { fun `WHEN request is redirecting to external app quickly THEN request is not intercepted`() {
appLinksInterceptor = AppLinksInterceptor( appLinksInterceptor = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = true,
launchInApp = { true }, launchInApp = { true },
useCases = mockUseCases, useCases = mockUseCases,
) )
@@ -762,7 +688,6 @@ class AppLinksInterceptorTest {
fun `WHEN request is redirecting to different app quickly THEN request is intercepted`() { fun `WHEN request is redirecting to different app quickly THEN request is intercepted`() {
appLinksInterceptor = AppLinksInterceptor( appLinksInterceptor = AppLinksInterceptor(
context = mockContext, context = mockContext,
interceptLinkClicks = true,
launchInApp = { true }, launchInApp = { true },
useCases = mockUseCases, useCases = mockUseCases,
) )
@@ -774,342 +699,17 @@ class AppLinksInterceptorTest {
assertTrue(response is RequestInterceptor.InterceptionResponse.AppIntent) 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 @Test
fun `WHEN caller and intent have the same package name THEN return true`() { fun `WHEN caller and intent have the same package name THEN return true`() {
appLinksInterceptor = spy( appLinksInterceptor = spy(
AppLinksInterceptor( AppLinksInterceptor(
context = mockContext, context = mockContext,
store = store, store = store,
interceptLinkClicks = true,
launchInApp = { true }, launchInApp = { true },
useCases = mockUseCases, useCases = mockUseCases,
shouldPrompt = { true },
), ),
) )
appLinksInterceptor.updateFragmentManger(mockFragmentManager)
val tabSessionState = TabSessionState( val tabSessionState = TabSessionState(
id = "tab1", id = "tab1",
content = ContentState( content = ContentState(
@@ -1120,11 +720,7 @@ class AppLinksInterceptorTest {
source = SessionState.Source.External.CustomTab(ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY)), source = SessionState.Source.External.CustomTab(ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY)),
) )
val appIntent: Intent = mock() assertTrue(AppLinksInterceptor.isAuthentication(tabSessionState, "com.zxing.app"))
val componentName: ComponentName = mock()
whenever(appIntent.component).thenReturn(componentName)
whenever(componentName.packageName).thenReturn("com.zxing.app")
assertTrue(appLinksInterceptor.isAuthentication(tabSessionState, appIntent))
val tabSessionState2 = TabSessionState( val tabSessionState2 = TabSessionState(
id = "tab1", id = "tab1",
@@ -1134,7 +730,7 @@ class AppLinksInterceptorTest {
isProductUrl = false, isProductUrl = false,
), ),
) )
assertFalse(appLinksInterceptor.isAuthentication(tabSessionState2, appIntent)) assertFalse(AppLinksInterceptor.isAuthentication(tabSessionState2, "com.zxing.app"))
val tabSessionState3 = TabSessionState( val tabSessionState3 = TabSessionState(
id = "tab1", id = "tab1",
@@ -1145,10 +741,8 @@ class AppLinksInterceptorTest {
), ),
source = SessionState.Source.External.CustomTab(ExternalPackage("com.example.app", PackageCategory.PRODUCTIVITY)), source = SessionState.Source.External.CustomTab(ExternalPackage("com.example.app", PackageCategory.PRODUCTIVITY)),
) )
assertFalse(appLinksInterceptor.isAuthentication(tabSessionState3, appIntent)) assertFalse(AppLinksInterceptor.isAuthentication(tabSessionState3, "com.zxing.app"))
assertFalse(AppLinksInterceptor.isAuthentication(tabSessionState, null))
doReturn(null).`when`(componentName).packageName
assertFalse(appLinksInterceptor.isAuthentication(tabSessionState, appIntent))
} }
@Test @Test
@@ -1157,19 +751,11 @@ class AppLinksInterceptorTest {
AppLinksInterceptor( AppLinksInterceptor(
context = mockContext, context = mockContext,
store = store, store = store,
interceptLinkClicks = true,
launchInApp = { true }, launchInApp = { true },
useCases = mockUseCases, 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( val tabSessionState = TabSessionState(
id = "tab1", id = "tab1",
content = ContentState( content = ContentState(
@@ -1180,7 +766,7 @@ class AppLinksInterceptorTest {
source = SessionState.Source.External.CustomTab(ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY)), 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( val tabSessionState2 = TabSessionState(
id = "tab1", id = "tab1",
@@ -1191,7 +777,7 @@ class AppLinksInterceptorTest {
), ),
source = SessionState.Source.External.ActionView(ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY)), 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( val tabSessionState3 = TabSessionState(
id = "tab1", id = "tab1",
@@ -1202,7 +788,7 @@ class AppLinksInterceptorTest {
), ),
source = SessionState.Source.External.ActionSend(ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY)), 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( val tabSessionState4 = TabSessionState(
id = "tab1", id = "tab1",
@@ -1213,7 +799,7 @@ class AppLinksInterceptorTest {
), ),
source = SessionState.Source.External.ActionSearch(ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY)), source = SessionState.Source.External.ActionSearch(ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY)),
) )
assertFalse(appLinksInterceptor.isAuthentication(tabSessionState4, appIntent)) assertFalse(AppLinksInterceptor.isAuthentication(tabSessionState4, "com.zxing.app"))
} }
@Test @Test
@@ -1224,30 +810,4 @@ class AppLinksInterceptorTest {
assertFalse(isSubframeAllowed("http")) // we should never allow http for subframes assertFalse(isSubframeAllowed("http")) // we should never allow http for subframes
assertFalse(isSubframeAllowed("https")) // we should never allow https 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") 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 @Test
fun `WHEN opening a app scheme uri WITH fallback URL WHERE the URL is Google PlayStore THEN ignore fallback URL`() { 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, "")) 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. * 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( class WebAppInterceptor(
private val context: Context, private val context: Context,
@@ -39,7 +43,7 @@ class WebAppInterceptor(
val intent = createIntentFromUri(startUrl, uri) val intent = createIntentFromUri(startUrl, uri)
if (!launchFromInterceptor) { 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 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. * 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 { private fun createIntentFromUri(startUrl: String, urlOverride: String = startUrl): Intent {
return Intent(WebAppIntentProcessor.ACTION_VIEW_PWA, startUrl.toUri()).apply { 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 `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**: 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). * ⚠️ **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 # 139.0
* **feature-downloads** * **feature-downloads**

View File

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

View File

@@ -243,8 +243,7 @@ open class DefaultComponents(private val applicationContext: Context) {
val appLinksInterceptor by lazy { val appLinksInterceptor by lazy {
AppLinksInterceptor( AppLinksInterceptor(
applicationContext, context = applicationContext,
interceptLinkClicks = true,
launchInApp = { launchInApp = {
applicationContext.components.preferences.getBoolean(PREF_LAUNCH_EXTERNAL_APP, false) 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.Login
import mozilla.components.concept.storage.LoginEntry import mozilla.components.concept.storage.LoginEntry
import mozilla.components.feature.accounts.push.SendTabUseCases 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.ContextMenuCandidate
import mozilla.components.feature.contextmenu.ContextMenuFeature import mozilla.components.feature.contextmenu.ContextMenuFeature
import mozilla.components.feature.downloads.DownloadsFeature import mozilla.components.feature.downloads.DownloadsFeature
@@ -302,6 +303,7 @@ abstract class BaseBrowserFragment :
private val lastTabFeature = ViewBoundFeatureWrapper<LastTabFeature>() private val lastTabFeature = ViewBoundFeatureWrapper<LastTabFeature>()
private val contextMenuFeature = ViewBoundFeatureWrapper<ContextMenuFeature>() private val contextMenuFeature = ViewBoundFeatureWrapper<ContextMenuFeature>()
private val downloadsFeature = ViewBoundFeatureWrapper<DownloadsFeature>() private val downloadsFeature = ViewBoundFeatureWrapper<DownloadsFeature>()
private val appLinksFeature = ViewBoundFeatureWrapper<AppLinksFeature>()
private val shareResourceFeature = ViewBoundFeatureWrapper<ShareResourceFeature>() private val shareResourceFeature = ViewBoundFeatureWrapper<ShareResourceFeature>()
private val copyDownloadsFeature = ViewBoundFeatureWrapper<CopyDownloadFeature>() private val copyDownloadsFeature = ViewBoundFeatureWrapper<CopyDownloadFeature>()
private val promptsFeature = ViewBoundFeatureWrapper<PromptFeature>() private val promptsFeature = ViewBoundFeatureWrapper<PromptFeature>()
@@ -934,6 +936,33 @@ abstract class BaseBrowserFragment :
onHide = ::onAutocompleteBarHide, 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( promptsFeature.set(
feature = PromptFeature( feature = PromptFeature(
activity = activity, activity = activity,
@@ -1854,16 +1883,6 @@ abstract class BaseBrowserFragment :
} }
hideToolbar() hideToolbar()
components.services.appLinksInterceptor.updateFragmentManger(
fragmentManager = parentFragmentManager,
)
context?.settings()?.shouldOpenLinksInApp(customTabSessionId != null)
?.let { openLinksInExternalApp ->
components.services.appLinksInterceptor.updateLaunchInApp {
openLinksInExternalApp
}
}
evaluateMessagesForMicrosurvey(components) evaluateMessagesForMicrosurvey(components)
BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt = BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt =
@@ -1881,10 +1900,6 @@ abstract class BaseBrowserFragment :
if (findNavController().currentDestination?.id != R.id.searchDialogFragment) { if (findNavController().currentDestination?.id != R.id.searchDialogFragment) {
view?.hideKeyboard() view?.hideKeyboard()
} }
requireComponents.services.appLinksInterceptor.updateFragmentManger(
fragmentManager = null,
)
} }
@CallSuper @CallSuper

View File

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

View File

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

View File

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