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:
committed by
royang@mozilla.com
parent
9e25ff9fd3
commit
5957e9d4df
@@ -1023,7 +1023,12 @@ class GeckoEngineSession(
|
||||
is InterceptionResponse.AppIntent -> {
|
||||
appRedirectUrl = lastLoadRequestUri
|
||||
notifyObservers {
|
||||
onLaunchIntentRequest(url = url, appIntent = appIntent)
|
||||
onLaunchIntentRequest(
|
||||
url = url,
|
||||
appIntent = appIntent,
|
||||
fallbackUrl = fallbackUrl,
|
||||
appName = appName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,8 +112,6 @@ typealias GeckoAntiTracking = ContentBlocking.AntiTracking
|
||||
typealias GeckoSafeBrowsing = ContentBlocking.SafeBrowsing
|
||||
typealias GeckoCookieBehavior = ContentBlocking.CookieBehavior
|
||||
|
||||
private const val AID = "AID"
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class GeckoEngineSessionTest {
|
||||
@@ -3380,6 +3378,8 @@ class GeckoEngineSessionTest {
|
||||
|
||||
var observedUrl: String? = null
|
||||
var observedIntent: Intent? = null
|
||||
var observedFallbackUrl: String? = null
|
||||
var observedAppName: String? = null
|
||||
|
||||
var observedLoadUrl: String? = null
|
||||
var observedTriggeredByRedirect: Boolean? = null
|
||||
@@ -3399,7 +3399,7 @@ class GeckoEngineSessionTest {
|
||||
isSubframeRequest: Boolean,
|
||||
): RequestInterceptor.InterceptionResponse? {
|
||||
return when (uri) {
|
||||
"sample:about" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result")
|
||||
"sample:about" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result", "fallback", "app")
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -3410,9 +3410,13 @@ class GeckoEngineSessionTest {
|
||||
override fun onLaunchIntentRequest(
|
||||
url: String,
|
||||
appIntent: Intent?,
|
||||
fallbackUrl: String?,
|
||||
appName: String?,
|
||||
) {
|
||||
observedUrl = url
|
||||
observedIntent = appIntent
|
||||
observedFallbackUrl = fallbackUrl
|
||||
observedAppName = appName
|
||||
}
|
||||
|
||||
override fun onLoadRequest(url: String, triggeredByRedirect: Boolean, triggeredByWebContent: Boolean) {
|
||||
@@ -3436,6 +3440,8 @@ class GeckoEngineSessionTest {
|
||||
assertEquals(result!!.poll(0), AllowOrDeny.DENY)
|
||||
assertNotNull(observedIntent)
|
||||
assertEquals("result", observedUrl)
|
||||
assertNotNull(observedFallbackUrl)
|
||||
assertNotNull(observedAppName)
|
||||
assertNull(observedLoadUrl)
|
||||
assertNull(observedTriggeredByRedirect)
|
||||
assertNull(observedTriggeredByWebContent)
|
||||
@@ -3469,6 +3475,8 @@ class GeckoEngineSessionTest {
|
||||
|
||||
var observedUrl: String? = null
|
||||
var observedIntent: Intent? = null
|
||||
var observedFallbackUrl: String? = null
|
||||
var observedAppName: String? = null
|
||||
|
||||
var observedLoadUrl: String? = null
|
||||
var observedTriggeredByRedirect: Boolean? = null
|
||||
@@ -3488,7 +3496,7 @@ class GeckoEngineSessionTest {
|
||||
isSubframeRequest: Boolean,
|
||||
): RequestInterceptor.InterceptionResponse? {
|
||||
return when (uri) {
|
||||
"sample:about" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result")
|
||||
"sample:about" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result", "fallback", "app")
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -3499,9 +3507,13 @@ class GeckoEngineSessionTest {
|
||||
override fun onLaunchIntentRequest(
|
||||
url: String,
|
||||
appIntent: Intent?,
|
||||
fallbackUrl: String?,
|
||||
appName: String?,
|
||||
) {
|
||||
observedUrl = url
|
||||
observedIntent = appIntent
|
||||
observedFallbackUrl = fallbackUrl
|
||||
observedAppName = appName
|
||||
}
|
||||
|
||||
override fun onLoadRequest(url: String, triggeredByRedirect: Boolean, triggeredByWebContent: Boolean) {
|
||||
@@ -3526,6 +3538,8 @@ class GeckoEngineSessionTest {
|
||||
assertNull(observedIntent)
|
||||
assertNull(observedUrl)
|
||||
assertNull(observedLoadUrl)
|
||||
assertNull(observedFallbackUrl)
|
||||
assertNull(observedAppName)
|
||||
assertNull(observedTriggeredByRedirect)
|
||||
assertNull(observedTriggeredByWebContent)
|
||||
|
||||
@@ -3543,6 +3557,8 @@ class GeckoEngineSessionTest {
|
||||
assertNull(observedIntent)
|
||||
assertNull(observedUrl)
|
||||
assertNull(observedLoadUrl)
|
||||
assertNull(observedFallbackUrl)
|
||||
assertNull(observedAppName)
|
||||
assertNull(observedTriggeredByRedirect)
|
||||
assertNull(observedTriggeredByWebContent)
|
||||
}
|
||||
@@ -3574,7 +3590,7 @@ class GeckoEngineSessionTest {
|
||||
isSubframeRequest: Boolean,
|
||||
): RequestInterceptor.InterceptionResponse? {
|
||||
return when (uri) {
|
||||
"sample:about" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result")
|
||||
"sample:about" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result", null, null)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -3724,29 +3740,37 @@ class GeckoEngineSessionTest {
|
||||
isSubframeRequest: Boolean,
|
||||
): RequestInterceptor.InterceptionResponse? {
|
||||
return when (uri) {
|
||||
"sample:triggeredByRedirect" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result1")
|
||||
"sample:NotTriggeredByRedirect" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result2")
|
||||
"sample:isDirectNavigation" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result3")
|
||||
"sample:triggeredByRedirect" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result1", "fallback1", "app1")
|
||||
"sample:NotTriggeredByRedirect" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result2", "fallback2", "app2")
|
||||
"sample:isDirectNavigation" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result3", "fallback3", "app3")
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val observer = object : EngineSession.Observer {
|
||||
var url: String? = null
|
||||
var intent: Intent? = null
|
||||
var observedUrl: String? = null
|
||||
var observedIntent: Intent? = null
|
||||
var observedFallbackUrl: String? = null
|
||||
var observedAppName: String? = null
|
||||
|
||||
override fun onLaunchIntentRequest(
|
||||
url: String,
|
||||
appIntent: Intent?,
|
||||
fallbackUrl: String?,
|
||||
appName: String?,
|
||||
) {
|
||||
this.url = url
|
||||
intent = appIntent
|
||||
observedUrl = url
|
||||
observedIntent = appIntent
|
||||
observedFallbackUrl = fallbackUrl
|
||||
observedAppName = appName
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
url = null
|
||||
intent = null
|
||||
observedUrl = null
|
||||
observedIntent = null
|
||||
observedFallbackUrl = null
|
||||
observedAppName = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3757,8 +3781,10 @@ class GeckoEngineSessionTest {
|
||||
mockLoadRequest("sample:triggeredByRedirect", triggeredByRedirect = true, isDirectNavigation = false),
|
||||
)
|
||||
|
||||
assertNotNull(observer.intent)
|
||||
assertEquals("result1", observer.url)
|
||||
assertNotNull(observer.observedIntent)
|
||||
assertEquals("result1", observer.observedUrl)
|
||||
assertEquals("fallback1", observer.observedFallbackUrl)
|
||||
assertEquals("app1", observer.observedAppName)
|
||||
|
||||
observer.reset()
|
||||
navigationDelegate.value.onLoadRequest(
|
||||
@@ -3766,8 +3792,10 @@ class GeckoEngineSessionTest {
|
||||
mockLoadRequest("sample:NotTriggeredByRedirect", triggeredByRedirect = false, isDirectNavigation = false),
|
||||
)
|
||||
|
||||
assertNotNull(observer.intent)
|
||||
assertEquals("result2", observer.url)
|
||||
assertNotNull(observer.observedIntent)
|
||||
assertEquals("result2", observer.observedUrl)
|
||||
assertEquals("fallback2", observer.observedFallbackUrl)
|
||||
assertEquals("app2", observer.observedAppName)
|
||||
|
||||
observer.reset()
|
||||
navigationDelegate.value.onLoadRequest(
|
||||
@@ -3775,8 +3803,10 @@ class GeckoEngineSessionTest {
|
||||
mockLoadRequest("sample:isDirectNavigation", triggeredByRedirect = false, isDirectNavigation = true),
|
||||
)
|
||||
|
||||
assertNull(observer.intent)
|
||||
assertNull(observer.url)
|
||||
assertNull(observer.observedIntent)
|
||||
assertNull(observer.observedUrl)
|
||||
assertNull(observer.observedFallbackUrl)
|
||||
assertNull(observer.observedAppName)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -3829,6 +3859,8 @@ class GeckoEngineSessionTest {
|
||||
|
||||
var observedUrl: String? = null
|
||||
var observedIntent: Intent? = null
|
||||
var observedFallbackUrl: String? = null
|
||||
var observedAppName: String? = null
|
||||
var observedIsSubframe = false
|
||||
|
||||
engineSession.settings.requestInterceptor = object : RequestInterceptor {
|
||||
@@ -3846,7 +3878,7 @@ class GeckoEngineSessionTest {
|
||||
): RequestInterceptor.InterceptionResponse? {
|
||||
observedIsSubframe = isSubframeRequest
|
||||
return when (uri) {
|
||||
"sample:about" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result")
|
||||
"sample:about" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result", "fallback", "app")
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -3857,9 +3889,13 @@ class GeckoEngineSessionTest {
|
||||
override fun onLaunchIntentRequest(
|
||||
url: String,
|
||||
appIntent: Intent?,
|
||||
fallbackUrl: String?,
|
||||
appName: String?,
|
||||
) {
|
||||
observedUrl = url
|
||||
observedIntent = appIntent
|
||||
observedFallbackUrl = fallbackUrl
|
||||
observedAppName = appName
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -3871,8 +3907,14 @@ class GeckoEngineSessionTest {
|
||||
|
||||
assertNotNull(observedIntent)
|
||||
assertEquals("result", observedUrl)
|
||||
assertEquals("fallback", observedFallbackUrl)
|
||||
assertEquals("app", observedAppName)
|
||||
assertEquals(true, observedIsSubframe)
|
||||
|
||||
observedUrl = null
|
||||
observedIntent = null
|
||||
observedFallbackUrl = null
|
||||
observedAppName = null
|
||||
navigationDelegate.value.onSubframeLoadRequest(
|
||||
mock(),
|
||||
mockLoadRequest("sample:about", triggeredByRedirect = false),
|
||||
@@ -3880,6 +3922,8 @@ class GeckoEngineSessionTest {
|
||||
|
||||
assertNotNull(observedIntent)
|
||||
assertEquals("result", observedUrl)
|
||||
assertEquals("fallback", observedFallbackUrl)
|
||||
assertEquals("app", observedAppName)
|
||||
assertEquals(true, observedIsSubframe)
|
||||
}
|
||||
|
||||
@@ -3892,8 +3936,10 @@ class GeckoEngineSessionTest {
|
||||
|
||||
captureDelegates()
|
||||
|
||||
var observedLaunchIntentUrl: String? = null
|
||||
var observedLaunchIntent: Intent? = null
|
||||
var observedUrl: String? = null
|
||||
var observedIntent: Intent? = null
|
||||
var observedFallbackUrl: String? = null
|
||||
var observedAppName: String? = null
|
||||
var observedOnLoadRequestUrl: String? = null
|
||||
var observedTriggeredByRedirect: Boolean? = null
|
||||
var observedTriggeredByWebContent: Boolean? = null
|
||||
@@ -3923,9 +3969,13 @@ class GeckoEngineSessionTest {
|
||||
override fun onLaunchIntentRequest(
|
||||
url: String,
|
||||
appIntent: Intent?,
|
||||
fallbackUrl: String?,
|
||||
appName: String?,
|
||||
) {
|
||||
observedLaunchIntentUrl = url
|
||||
observedLaunchIntent = appIntent
|
||||
observedUrl = url
|
||||
observedIntent = appIntent
|
||||
observedFallbackUrl = fallbackUrl
|
||||
observedAppName = appName
|
||||
}
|
||||
|
||||
override fun onLoadRequest(
|
||||
@@ -3945,8 +3995,10 @@ class GeckoEngineSessionTest {
|
||||
mockLoadRequest("sample:about", triggeredByRedirect = true),
|
||||
)
|
||||
|
||||
assertNull(observedLaunchIntentUrl)
|
||||
assertNull(observedLaunchIntent)
|
||||
assertNull(observedUrl)
|
||||
assertNull(observedIntent)
|
||||
assertNull(observedFallbackUrl)
|
||||
assertNull(observedAppName)
|
||||
assertNull(observedTriggeredByRedirect)
|
||||
assertNull(observedTriggeredByWebContent)
|
||||
assertNull(observedOnLoadRequestUrl)
|
||||
@@ -3956,8 +4008,10 @@ class GeckoEngineSessionTest {
|
||||
mockLoadRequest("sample:about", triggeredByRedirect = false),
|
||||
)
|
||||
|
||||
assertNull(observedLaunchIntentUrl)
|
||||
assertNull(observedLaunchIntent)
|
||||
assertNull(observedUrl)
|
||||
assertNull(observedIntent)
|
||||
assertNull(observedFallbackUrl)
|
||||
assertNull(observedAppName)
|
||||
assertNull(observedTriggeredByRedirect)
|
||||
assertNull(observedTriggeredByWebContent)
|
||||
assertNull(observedOnLoadRequestUrl)
|
||||
@@ -3972,8 +4026,10 @@ class GeckoEngineSessionTest {
|
||||
|
||||
captureDelegates()
|
||||
|
||||
var observedLaunchIntentUrl: String? = null
|
||||
var observedLaunchIntent: Intent? = null
|
||||
var observedUrl: String? = null
|
||||
var observedIntent: Intent? = null
|
||||
var observedFallbackUrl: String? = null
|
||||
var observedAppName: String? = null
|
||||
var observedOnLoadRequestUrl: String? = null
|
||||
var observedTriggeredByRedirect: Boolean? = null
|
||||
var observedTriggeredByWebContent: Boolean? = null
|
||||
@@ -3984,9 +4040,13 @@ class GeckoEngineSessionTest {
|
||||
override fun onLaunchIntentRequest(
|
||||
url: String,
|
||||
appIntent: Intent?,
|
||||
fallbackUrl: String?,
|
||||
appName: String?,
|
||||
) {
|
||||
observedLaunchIntentUrl = url
|
||||
observedLaunchIntent = appIntent
|
||||
observedUrl = url
|
||||
observedIntent = appIntent
|
||||
observedFallbackUrl = fallbackUrl
|
||||
observedAppName = appName
|
||||
}
|
||||
|
||||
override fun onLoadRequest(
|
||||
@@ -4006,8 +4066,10 @@ class GeckoEngineSessionTest {
|
||||
mockLoadRequest("sample:about", triggeredByRedirect = true),
|
||||
)
|
||||
|
||||
assertNull(observedLaunchIntentUrl)
|
||||
assertNull(observedLaunchIntent)
|
||||
assertNull(observedUrl)
|
||||
assertNull(observedIntent)
|
||||
assertNull(observedFallbackUrl)
|
||||
assertNull(observedAppName)
|
||||
assertNotNull(observedTriggeredByRedirect)
|
||||
assertTrue(observedTriggeredByRedirect!!)
|
||||
assertNotNull(observedTriggeredByWebContent)
|
||||
@@ -4019,8 +4081,10 @@ class GeckoEngineSessionTest {
|
||||
mockLoadRequest("sample:about", triggeredByRedirect = false),
|
||||
)
|
||||
|
||||
assertNull(observedLaunchIntentUrl)
|
||||
assertNull(observedLaunchIntent)
|
||||
assertNull(observedUrl)
|
||||
assertNull(observedIntent)
|
||||
assertNull(observedFallbackUrl)
|
||||
assertNull(observedAppName)
|
||||
assertNotNull(observedTriggeredByRedirect)
|
||||
assertFalse(observedTriggeredByRedirect!!)
|
||||
assertNotNull(observedTriggeredByWebContent)
|
||||
@@ -4054,7 +4118,7 @@ class GeckoEngineSessionTest {
|
||||
): RequestInterceptor.InterceptionResponse? {
|
||||
return when (uri) {
|
||||
fakeUrl -> null
|
||||
else -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), fakeUrl)
|
||||
else -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), fakeUrl, null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,7 +266,12 @@ class SystemEngineView @JvmOverloads constructor(
|
||||
is InterceptionResponse.AppIntent -> {
|
||||
if (request.isForMainFrame) {
|
||||
session.notifyObservers {
|
||||
onLaunchIntentRequest(url = url, appIntent = appIntent)
|
||||
onLaunchIntentRequest(
|
||||
url = url,
|
||||
appIntent = appIntent,
|
||||
fallbackUrl = fallbackUrl,
|
||||
appName = appName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,8 +104,18 @@ internal class EngineObserver(
|
||||
store.dispatch(ContentAction.UpdateLoadRequestAction(tabId, loadRequest))
|
||||
}
|
||||
|
||||
override fun onLaunchIntentRequest(url: String, appIntent: Intent?) {
|
||||
store.dispatch(ContentAction.UpdateAppIntentAction(tabId, AppIntentState(url, appIntent)))
|
||||
override fun onLaunchIntentRequest(
|
||||
url: String,
|
||||
appIntent: Intent?,
|
||||
fallbackUrl: String?,
|
||||
appName: String?,
|
||||
) {
|
||||
store.dispatch(
|
||||
ContentAction.UpdateAppIntentAction(
|
||||
tabId,
|
||||
AppIntentState(url, appIntent, fallbackUrl, appName),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onTitleChange(title: String) {
|
||||
|
||||
@@ -11,8 +11,12 @@ import android.content.Intent
|
||||
*
|
||||
* @param url the URL to launch in an external app.
|
||||
* @param appIntent the [Intent] to launch.
|
||||
* @param fallbackUrl the fallback URL if launch failed or denied by user.
|
||||
* @param appName the target application name.
|
||||
*/
|
||||
data class AppIntentState(
|
||||
val url: String,
|
||||
val appIntent: Intent?,
|
||||
val fallbackUrl: String?,
|
||||
val appName: String?,
|
||||
)
|
||||
|
||||
@@ -1517,9 +1517,9 @@ class EngineObserverTest {
|
||||
val store: BrowserStore = mock()
|
||||
val observer = EngineObserver("test-id", store)
|
||||
val intent: Intent = mock()
|
||||
observer.onLaunchIntentRequest(url = url, appIntent = intent)
|
||||
observer.onLaunchIntentRequest(url = url, appIntent = intent, fallbackUrl = null, appName = null)
|
||||
|
||||
verify(store).dispatch(ContentAction.UpdateAppIntentAction("test-id", AppIntentState(url, intent)))
|
||||
verify(store).dispatch(ContentAction.UpdateAppIntentAction("test-id", AppIntentState(url, intent, null, null)))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -256,10 +256,14 @@ abstract class EngineSession(
|
||||
* @param url The string url that was requested.
|
||||
* @param appIntent The Android Intent that was requested.
|
||||
* web content (as opposed to via the browser chrome).
|
||||
* @param fallbackUrl the fallback URL if launch failed or denied by user.
|
||||
* @param appName the target application name.
|
||||
*/
|
||||
fun onLaunchIntentRequest(
|
||||
url: String,
|
||||
appIntent: Intent?,
|
||||
fallbackUrl: String?,
|
||||
appName: String?,
|
||||
) = Unit
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,7 +40,12 @@ interface RequestInterceptor {
|
||||
val additionalHeaders: Map<String, String>? = null,
|
||||
) : InterceptionResponse()
|
||||
|
||||
data class AppIntent(val appIntent: Intent, val url: String) : InterceptionResponse()
|
||||
data class AppIntent(
|
||||
val appIntent: Intent,
|
||||
val url: String,
|
||||
val fallbackUrl: String?,
|
||||
val appName: String?,
|
||||
) : InterceptionResponse()
|
||||
|
||||
/**
|
||||
* Deny request without further action.
|
||||
|
||||
@@ -79,7 +79,7 @@ class EngineSessionTest {
|
||||
session.notifyInternalObservers { onMediaFullscreenChanged(true, mediaSessionElementMetadata) }
|
||||
session.notifyInternalObservers { onCrash() }
|
||||
session.notifyInternalObservers { onLoadRequest("https://www.mozilla.org", true, true) }
|
||||
session.notifyInternalObservers { onLaunchIntentRequest("https://www.mozilla.org", null) }
|
||||
session.notifyInternalObservers { onLaunchIntentRequest("https://www.mozilla.org", null, null, null) }
|
||||
session.notifyInternalObservers { onProcessKilled() }
|
||||
session.notifyInternalObservers { onShowDynamicToolbar() }
|
||||
|
||||
@@ -114,7 +114,7 @@ class EngineSessionTest {
|
||||
verify(observer).onMediaFullscreenChanged(true, mediaSessionElementMetadata)
|
||||
verify(observer).onCrash()
|
||||
verify(observer).onLoadRequest("https://www.mozilla.org", true, true)
|
||||
verify(observer).onLaunchIntentRequest("https://www.mozilla.org", null)
|
||||
verify(observer).onLaunchIntentRequest("https://www.mozilla.org", null, null, null)
|
||||
verify(observer).onProcessKilled()
|
||||
verify(observer).onShowDynamicToolbar()
|
||||
verifyNoMoreInteractions(observer)
|
||||
@@ -152,7 +152,7 @@ class EngineSessionTest {
|
||||
session.notifyInternalObservers { onWindowRequest(windowRequest) }
|
||||
session.notifyInternalObservers { onCrash() }
|
||||
session.notifyInternalObservers { onLoadRequest("https://www.mozilla.org", true, true) }
|
||||
session.notifyInternalObservers { onLaunchIntentRequest("https://www.mozilla.org", null) }
|
||||
session.notifyInternalObservers { onLaunchIntentRequest("https://www.mozilla.org", null, null, null) }
|
||||
session.notifyInternalObservers { onShowDynamicToolbar() }
|
||||
session.unregister(observer)
|
||||
|
||||
@@ -189,7 +189,7 @@ class EngineSessionTest {
|
||||
session.notifyInternalObservers { onMediaFullscreenChanged(true, mediaSessionElementMetadata) }
|
||||
session.notifyInternalObservers { onCrash() }
|
||||
session.notifyInternalObservers { onLoadRequest("https://www.mozilla.org", true, true) }
|
||||
session.notifyInternalObservers { onLaunchIntentRequest("https://www.firefox.com", null) }
|
||||
session.notifyInternalObservers { onLaunchIntentRequest("https://www.firefox.com", null, null, null) }
|
||||
session.notifyInternalObservers { onShowDynamicToolbar() }
|
||||
|
||||
verify(observer).onScrollChange(1234, 4321)
|
||||
@@ -211,7 +211,7 @@ class EngineSessionTest {
|
||||
verify(observer).onWindowRequest(windowRequest)
|
||||
verify(observer).onCrash()
|
||||
verify(observer).onLoadRequest("https://www.mozilla.org", true, true)
|
||||
verify(observer).onLaunchIntentRequest("https://www.mozilla.org", null)
|
||||
verify(observer).onLaunchIntentRequest("https://www.mozilla.org", null, null, null)
|
||||
verify(observer).onShowDynamicToolbar()
|
||||
verify(observer, never()).onScrollChange(2345, 5432)
|
||||
verify(observer, never()).onLocationChange("https://www.firefox.com", false)
|
||||
@@ -239,7 +239,7 @@ class EngineSessionTest {
|
||||
verify(observer, never()).onMediaMuteChanged(true)
|
||||
verify(observer, never()).onMediaFullscreenChanged(true, mediaSessionElementMetadata)
|
||||
verify(observer, never()).onLoadRequest("https://www.mozilla.org", false, true)
|
||||
verify(observer, never()).onLaunchIntentRequest("https://www.firefox.com", null)
|
||||
verify(observer, never()).onLaunchIntentRequest("https://www.firefox.com", null, null, null)
|
||||
verifyNoMoreInteractions(observer)
|
||||
}
|
||||
|
||||
@@ -964,7 +964,7 @@ class EngineSessionTest {
|
||||
observer.onWindowRequest(windowRequest)
|
||||
observer.onCrash()
|
||||
observer.onLoadRequest("https://www.mozilla.org", true, true)
|
||||
observer.onLaunchIntentRequest("https://www.mozilla.org", null)
|
||||
observer.onLaunchIntentRequest("https://www.mozilla.org", null, null, null)
|
||||
observer.onProcessKilled()
|
||||
observer.onShowDynamicToolbar()
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ import mozilla.components.lib.state.ext.flowScoped
|
||||
import mozilla.components.support.base.feature.LifecycleAwareFeature
|
||||
import mozilla.components.support.ktx.android.content.appName
|
||||
|
||||
// Minimum time for dialog to settle before accepting user interactions.
|
||||
internal const val MAX_SUCCESSIVE_DIALOG_MILLIS_LIMIT: Int = 500 // 0.5 seconds
|
||||
|
||||
/**
|
||||
* This feature implements observer for handling redirects to external apps. The users are asked to
|
||||
* confirm their intention before leaving the app if in private session. These include the Android
|
||||
@@ -45,6 +48,9 @@ import mozilla.components.support.ktx.android.content.appName
|
||||
* have registered to open.
|
||||
* @param failedToLaunchAction Action to perform when failing to launch in third party app.
|
||||
* @param loadUrlUseCase Used to load URL if user decides not to launch in third party app.
|
||||
* @param engineSupportedSchemes Set of URI schemes the engine supports.
|
||||
* @param shouldPrompt If {true} then user should be prompted before launching app links.
|
||||
* @param alwaysOpenCheckboxAction Action to perform when user checked the always open checkbox in the prompt.
|
||||
**/
|
||||
class AppLinksFeature(
|
||||
private val context: Context,
|
||||
@@ -58,6 +64,7 @@ class AppLinksFeature(
|
||||
private val loadUrlUseCase: SessionUseCases.DefaultLoadUrlUseCase? = null,
|
||||
private val engineSupportedSchemes: Set<String> = ENGINE_SUPPORTED_SCHEMES,
|
||||
private val shouldPrompt: () -> Boolean = { true },
|
||||
private val alwaysOpenCheckboxAction: (() -> Unit)? = null,
|
||||
) : LifecycleAwareFeature {
|
||||
|
||||
private var scope: CoroutineScope? = null
|
||||
@@ -71,10 +78,16 @@ class AppLinksFeature(
|
||||
.distinctUntilChangedBy {
|
||||
it.content.appIntent
|
||||
}
|
||||
.collect { tab ->
|
||||
tab.content.appIntent?.let {
|
||||
handleAppIntent(tab, it.url, it.appIntent)
|
||||
store.dispatch(ContentAction.ConsumeAppIntentAction(tab.id))
|
||||
.collect { sessionState ->
|
||||
sessionState.content.appIntent?.let {
|
||||
handleAppIntent(
|
||||
sessionState = sessionState,
|
||||
url = it.url,
|
||||
appIntent = it.appIntent,
|
||||
fallbackUrl = it.fallbackUrl,
|
||||
appName = it.appName,
|
||||
)
|
||||
store.dispatch(ContentAction.ConsumeAppIntentAction(sessionState.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,105 +102,160 @@ class AppLinksFeature(
|
||||
}
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal fun handleAppIntent(tab: SessionState, url: String, appIntent: Intent?) {
|
||||
if (appIntent == null) {
|
||||
internal fun handleAppIntent(
|
||||
sessionState: SessionState,
|
||||
url: String,
|
||||
appIntent: Intent?,
|
||||
fallbackUrl: String?,
|
||||
appName: String?,
|
||||
) {
|
||||
if (appIntent == null) return
|
||||
|
||||
val isPrivate = sessionState.content.private
|
||||
val isAuthenticationFlow =
|
||||
AppLinksInterceptor.isAuthentication(sessionState, appIntent.component?.packageName)
|
||||
|
||||
if (shouldBypassPrompt(isPrivate, isAuthenticationFlow, fragmentManager)) {
|
||||
openApp(appIntent)
|
||||
return
|
||||
}
|
||||
|
||||
val doNotOpenApp = {
|
||||
AppLinksInterceptor.addUserDoNotIntercept(url, appIntent, tab.id)
|
||||
|
||||
loadUrlIfSchemeSupported(tab, url)
|
||||
}
|
||||
|
||||
val doOpenApp = {
|
||||
useCases.openAppLink(
|
||||
appIntent,
|
||||
failedToLaunchAction = failedToLaunchAction,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("ComplexCondition")
|
||||
if (isAuthentication(tab, appIntent) || (!tab.content.private && !shouldPrompt()) ||
|
||||
fragmentManager == null
|
||||
) {
|
||||
doOpenApp()
|
||||
if (isADialogAlreadyCreated() || fragmentManager?.isStateSaved == true) {
|
||||
return
|
||||
}
|
||||
|
||||
val dialog = getOrCreateDialog(tab.content.private, url)
|
||||
dialog.onConfirmRedirect = { doOpenApp() }
|
||||
dialog.onCancelRedirect = doNotOpenApp
|
||||
|
||||
if (!isAlreadyADialogCreated()) {
|
||||
dialog.showNow(fragmentManager, FRAGMENT_TAG)
|
||||
}
|
||||
showRedirectDialog(
|
||||
sessionState = sessionState,
|
||||
url = url,
|
||||
fallbackUrl = fallbackUrl,
|
||||
appIntent = appIntent,
|
||||
appName = appName,
|
||||
isPrivate = isPrivate,
|
||||
fragmentManager = fragmentManager,
|
||||
)
|
||||
}
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal fun getOrCreateDialog(isPrivate: Boolean, url: String): RedirectDialogFragment {
|
||||
internal fun shouldBypassPrompt(
|
||||
isPrivate: Boolean,
|
||||
isAuthenticationFlow: Boolean,
|
||||
fragmentManager: FragmentManager?,
|
||||
): Boolean {
|
||||
val shouldShowPrompt = isPrivate || shouldPrompt()
|
||||
return fragmentManager == null || !shouldShowPrompt || isAuthenticationFlow
|
||||
}
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal fun openApp(appIntent: Intent) {
|
||||
useCases.openAppLink(
|
||||
appIntent,
|
||||
failedToLaunchAction = failedToLaunchAction,
|
||||
)
|
||||
}
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal fun cancelRedirect(
|
||||
sessionState: SessionState,
|
||||
url: String,
|
||||
fallbackUrl: String?,
|
||||
appIntent: Intent,
|
||||
) {
|
||||
AppLinksInterceptor.addUserDoNotIntercept(url, appIntent, sessionState.id)
|
||||
|
||||
val urlToLoad = when {
|
||||
isSchemeSupported(url) -> url
|
||||
fallbackUrl != null && isSchemeSupported(fallbackUrl) -> fallbackUrl
|
||||
else -> return // No supported URL to load.
|
||||
}
|
||||
|
||||
loadUrlUseCase?.invoke(
|
||||
url = urlToLoad,
|
||||
sessionId = sessionState.id,
|
||||
flags = EngineSession.LoadUrlFlags.select(EXTERNAL, LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE),
|
||||
)
|
||||
}
|
||||
|
||||
private fun showRedirectDialog(
|
||||
sessionState: SessionState,
|
||||
url: String,
|
||||
fallbackUrl: String?,
|
||||
appIntent: Intent,
|
||||
appName: String?,
|
||||
isPrivate: Boolean,
|
||||
fragmentManager: FragmentManager?,
|
||||
) {
|
||||
if (fragmentManager == null) {
|
||||
return
|
||||
}
|
||||
|
||||
getOrCreateDialog(isPrivate, url, appName).apply {
|
||||
onConfirmRedirect = { isCheckboxTicked ->
|
||||
if (isCheckboxTicked) {
|
||||
alwaysOpenCheckboxAction?.invoke()
|
||||
}
|
||||
openApp(appIntent)
|
||||
}
|
||||
onCancelRedirect = {
|
||||
cancelRedirect(sessionState, url, fallbackUrl, appIntent)
|
||||
}
|
||||
}.showNow(fragmentManager, FRAGMENT_TAG)
|
||||
}
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal fun getOrCreateDialog(
|
||||
isPrivate: Boolean,
|
||||
url: String,
|
||||
targetAppName: String?,
|
||||
): RedirectDialogFragment {
|
||||
if (dialog != null) {
|
||||
return dialog
|
||||
}
|
||||
|
||||
val message = context.getString(
|
||||
R.string.mozac_feature_applinks_normal_confirm_dialog_message,
|
||||
context.appName,
|
||||
)
|
||||
val dialogTitle = when {
|
||||
isPrivate && !targetAppName.isNullOrBlank() -> {
|
||||
context.getString(R.string.mozac_feature_applinks_confirm_dialog_title_with_app_name, targetAppName)
|
||||
}
|
||||
isPrivate -> {
|
||||
context.getString(R.string.mozac_feature_applinks_confirm_dialog_title)
|
||||
}
|
||||
!targetAppName.isNullOrBlank() -> {
|
||||
context.getString(
|
||||
R.string.mozac_feature_applinks_normal_confirm_dialog_title_with_app_name,
|
||||
targetAppName,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
context.getString(R.string.mozac_feature_applinks_normal_confirm_dialog_title)
|
||||
}
|
||||
}
|
||||
|
||||
val dialogMessage = if (isPrivate) {
|
||||
url
|
||||
} else {
|
||||
context.getString(
|
||||
R.string.mozac_feature_applinks_normal_confirm_dialog_message,
|
||||
context.appName,
|
||||
)
|
||||
}
|
||||
|
||||
return SimpleRedirectDialogFragment.newInstance(
|
||||
dialogTitleString = if (isPrivate) {
|
||||
context.getString(R.string.mozac_feature_applinks_confirm_dialog_title)
|
||||
} else {
|
||||
context.getString(R.string.mozac_feature_applinks_normal_confirm_dialog_title)
|
||||
},
|
||||
dialogMessageString = if (isPrivate) {
|
||||
url
|
||||
} else {
|
||||
message
|
||||
},
|
||||
dialogTitleString = dialogTitle,
|
||||
dialogMessageString = dialogMessage,
|
||||
showCheckbox = if (isPrivate) false else alwaysOpenCheckboxAction != null,
|
||||
maxSuccessiveDialogMillisLimit = MAX_SUCCESSIVE_DIALOG_MILLIS_LIMIT,
|
||||
)
|
||||
}
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal fun loadUrlIfSchemeSupported(tab: SessionState, url: String) {
|
||||
val schemeSupported = engineSupportedSchemes.contains(url.toUri().scheme)
|
||||
if (schemeSupported) {
|
||||
loadUrlUseCase?.invoke(
|
||||
url = url,
|
||||
sessionId = tab.id,
|
||||
flags = EngineSession.LoadUrlFlags.select(EXTERNAL, LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE),
|
||||
)
|
||||
}
|
||||
internal fun isSchemeSupported(url: String): Boolean {
|
||||
return engineSupportedSchemes.contains(url.toUri().scheme)
|
||||
}
|
||||
|
||||
private fun isAlreadyADialogCreated(): Boolean {
|
||||
private fun isADialogAlreadyCreated(): Boolean {
|
||||
return findPreviousDialogFragment() != null
|
||||
}
|
||||
|
||||
private fun findPreviousDialogFragment(): RedirectDialogFragment? {
|
||||
return fragmentManager?.findFragmentByTag(FRAGMENT_TAG) as? RedirectDialogFragment
|
||||
}
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal fun isAuthentication(tab: SessionState, appIntent: Intent): Boolean {
|
||||
return when (tab.source) {
|
||||
is SessionState.Source.External.ActionSend,
|
||||
is SessionState.Source.External.ActionSearch,
|
||||
-> false
|
||||
// CustomTab and ActionView can be used for authentication
|
||||
is SessionState.Source.External.CustomTab,
|
||||
is SessionState.Source.External.ActionView,
|
||||
-> {
|
||||
(tab.source as? SessionState.Source.External)?.let { externalSource ->
|
||||
when (externalSource.caller?.packageId) {
|
||||
null -> false
|
||||
appIntent.component?.packageName -> true
|
||||
else -> false
|
||||
}
|
||||
} ?: false
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,19 +10,13 @@ import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.core.net.toUri
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import mozilla.components.browser.state.selector.findTabOrCustomTab
|
||||
import mozilla.components.browser.state.state.SessionState
|
||||
import mozilla.components.browser.state.store.BrowserStore
|
||||
import mozilla.components.concept.engine.EngineSession
|
||||
import mozilla.components.concept.engine.EngineSession.LoadUrlFlags.Companion.EXTERNAL
|
||||
import mozilla.components.concept.engine.EngineSession.LoadUrlFlags.Companion.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE
|
||||
import mozilla.components.concept.engine.request.RequestInterceptor
|
||||
import mozilla.components.feature.app.links.AppLinksUseCases.Companion.ALWAYS_DENY_SCHEMES
|
||||
import mozilla.components.feature.app.links.AppLinksUseCases.Companion.ENGINE_SUPPORTED_SCHEMES
|
||||
import mozilla.components.feature.app.links.RedirectDialogFragment.Companion.FRAGMENT_TAG
|
||||
import mozilla.components.feature.session.SessionUseCases
|
||||
import mozilla.components.support.ktx.android.content.appName
|
||||
import mozilla.components.support.ktx.android.net.isHttpOrHttps
|
||||
import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl
|
||||
|
||||
@@ -53,26 +47,18 @@ private val ALLOWED_SCHEMES_IN_SUBFRAME: List<String> = listOf(
|
||||
* and https://github.com/mozilla-mobile/android-components/issues/2975.
|
||||
*
|
||||
* @param context Context the feature is associated with.
|
||||
* @param interceptLinkClicks If {true} then intercept link clicks.
|
||||
* @param engineSupportedSchemes List of schemes that the engine supports.
|
||||
* @param alwaysDeniedSchemes List of schemes that will never be opened in a third-party app even if
|
||||
* [interceptLinkClicks] is `true`.
|
||||
* @param alwaysDeniedSchemes List of schemes that will never be opened in a third-party app
|
||||
* @param launchInApp If {true} then launch app links in third party app(s). Default to false because
|
||||
* of security concerns.
|
||||
* @param useCases These use cases allow for the detection of, and opening of links that other apps
|
||||
* have registered to open.
|
||||
* @param launchFromInterceptor If {true} then the interceptor will prompt and launch the link in
|
||||
* third-party apps if available. Do not use this in conjunction with [AppLinksFeature]
|
||||
* @param showCheckbox if {true} then the checkbox will be visible on normal tabs within [SimpleRedirectDialogFragment]
|
||||
* @param store [BrowserStore] containing the information about the currently open tabs.
|
||||
* @param shouldPrompt If {true} then we should prompt the user before redirect.
|
||||
* @param failedToLaunchAction Action to perform when failing to launch in third party app.
|
||||
* @param checkboxCheckedAction Action to perform when checkbox is ticked and positive button is clicked
|
||||
* on redirect prompt.
|
||||
*/
|
||||
class AppLinksInterceptor(
|
||||
private val context: Context,
|
||||
private val interceptLinkClicks: Boolean = false,
|
||||
private val engineSupportedSchemes: Set<String> = ENGINE_SUPPORTED_SCHEMES,
|
||||
private val alwaysDeniedSchemes: Set<String> = ALWAYS_DENY_SCHEMES,
|
||||
private var launchInApp: () -> Boolean = { false },
|
||||
@@ -82,32 +68,8 @@ class AppLinksInterceptor(
|
||||
alwaysDeniedSchemes = alwaysDeniedSchemes,
|
||||
),
|
||||
private val launchFromInterceptor: Boolean = false,
|
||||
private val showCheckbox: Boolean = false,
|
||||
private val store: BrowserStore? = null,
|
||||
private val shouldPrompt: () -> Boolean = { true },
|
||||
private val failedToLaunchAction: (fallbackUrl: String?) -> Unit = {},
|
||||
private val loadUrlUseCase: SessionUseCases.DefaultLoadUrlUseCase? = null,
|
||||
private val checkboxCheckedAction: () -> Unit = {},
|
||||
) : RequestInterceptor {
|
||||
private var fragmentManager: FragmentManager? = null
|
||||
private val dialog: RedirectDialogFragment? = null
|
||||
|
||||
/**
|
||||
* Update [FragmentManager] for this instance of AppLinksInterceptor
|
||||
* @param fragmentManager the new value of [FragmentManager]
|
||||
*/
|
||||
fun updateFragmentManger(fragmentManager: FragmentManager?) {
|
||||
this.fragmentManager = fragmentManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Update launchInApp for this instance of AppLinksInterceptor
|
||||
* @param launchInApp the new value of launchInApp
|
||||
*/
|
||||
fun updateLaunchInApp(launchInApp: () -> Boolean) {
|
||||
this.launchInApp = launchInApp
|
||||
useCases.updateLaunchInApp(launchInApp)
|
||||
}
|
||||
|
||||
@Suppress("ComplexMethod", "ReturnCount")
|
||||
override fun onLoadRequest(
|
||||
@@ -137,8 +99,8 @@ class AppLinksInterceptor(
|
||||
(!hasUserGesture && !isAllowedRedirect && !isDirectNavigation) ||
|
||||
isSameDomain(lastUri, uri)
|
||||
) && engineSupportsScheme -> true
|
||||
// If scheme not in safelist then follow user preference
|
||||
(!interceptLinkClicks || !launchInApp()) && engineSupportsScheme -> true
|
||||
// If scheme not in supported list then follow user preference
|
||||
!launchInApp() && !isPossibleAuthentication(tabSessionState) && engineSupportsScheme -> true
|
||||
// Never go to an external app when scheme is in blocklist
|
||||
alwaysDeniedSchemes.contains(uriScheme) -> true
|
||||
else -> false
|
||||
@@ -151,10 +113,14 @@ class AppLinksInterceptor(
|
||||
val tabId = tabSessionState?.id ?: ""
|
||||
val redirect = useCases.interceptedAppLinkRedirect(uri)
|
||||
val result = handleRedirect(redirect, uri, tabId)
|
||||
val packageName = redirect.appIntent?.component?.packageName
|
||||
|
||||
// Now that we have the package name, check again if this is not authentication.
|
||||
if (!launchInApp() && !isAuthentication(tabSessionState, packageName) && engineSupportsScheme) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (redirect.hasExternalApp()) {
|
||||
val packageName = redirect.appIntent?.component?.packageName
|
||||
|
||||
if (
|
||||
lastApplinksPackageWithTimestamp.first == packageName && lastApplinksPackageWithTimestamp.second +
|
||||
APP_LINKS_DO_NOT_INTERCEPT_INTERVAL > SystemClock.elapsedRealtime()
|
||||
@@ -166,20 +132,9 @@ class AppLinksInterceptor(
|
||||
}
|
||||
|
||||
if (redirect.isRedirect()) {
|
||||
if (
|
||||
launchFromInterceptor &&
|
||||
result is RequestInterceptor.InterceptionResponse.AppIntent
|
||||
) {
|
||||
handleIntent(
|
||||
sessionState = tabSessionState,
|
||||
url = uri,
|
||||
appIntent = redirect.appIntent,
|
||||
fallbackUrl = redirect.fallbackUrl,
|
||||
marketingIntent = redirect.marketplaceIntent,
|
||||
appName = redirect.appName,
|
||||
)
|
||||
// We can avoid loading the page only if openInApp settings is set to Always
|
||||
return if (shouldPrompt()) null else result
|
||||
if (launchFromInterceptor && result is RequestInterceptor.InterceptionResponse.AppIntent) {
|
||||
result.appIntent.flags = result.appIntent.flags or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
useCases.openAppLink(result.appIntent)
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -211,19 +166,29 @@ class AppLinksInterceptor(
|
||||
}
|
||||
|
||||
if (!redirect.hasExternalApp()) {
|
||||
redirect.marketplaceIntent?.let {
|
||||
return RequestInterceptor.InterceptionResponse.AppIntent(it, uri)
|
||||
redirect.marketplaceIntent?.let { appIntent ->
|
||||
return RequestInterceptor.InterceptionResponse.AppIntent(
|
||||
appIntent = appIntent,
|
||||
url = uri,
|
||||
fallbackUrl = redirect.fallbackUrl,
|
||||
appName = redirect.appName,
|
||||
)
|
||||
}
|
||||
|
||||
redirect.fallbackUrl?.let {
|
||||
return RequestInterceptor.InterceptionResponse.Url(it)
|
||||
redirect.fallbackUrl?.let { fallbackUrl ->
|
||||
return RequestInterceptor.InterceptionResponse.Url(url = fallbackUrl)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
redirect.appIntent?.let {
|
||||
return RequestInterceptor.InterceptionResponse.AppIntent(it, uri)
|
||||
redirect.appIntent?.let { appIntent ->
|
||||
return RequestInterceptor.InterceptionResponse.AppIntent(
|
||||
appIntent = appIntent,
|
||||
url = uri,
|
||||
fallbackUrl = redirect.fallbackUrl,
|
||||
appName = redirect.appName,
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -248,153 +213,6 @@ class AppLinksInterceptor(
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal fun handleIntent(
|
||||
sessionState: SessionState?,
|
||||
url: String,
|
||||
appIntent: Intent?,
|
||||
fallbackUrl: String?,
|
||||
marketingIntent: Intent?,
|
||||
appName: String,
|
||||
) {
|
||||
var isAuthenticationFlow = false
|
||||
|
||||
val targetIntent = when {
|
||||
appIntent != null -> {
|
||||
// Without fragment manager we are unable to prompt
|
||||
// Only non private tabs can be redirected to external app without prompt
|
||||
// Authentication flow should not prompt
|
||||
isAuthenticationFlow =
|
||||
sessionState?.let { isAuthentication(sessionState, appIntent) } == true
|
||||
appIntent
|
||||
}
|
||||
marketingIntent != null -> marketingIntent
|
||||
else -> return
|
||||
}
|
||||
|
||||
val fragmentManager = fragmentManager
|
||||
|
||||
val isPrivate = sessionState?.content?.private == true
|
||||
val doNotOpenApp = {
|
||||
addUserDoNotIntercept(url, targetIntent, sessionState?.id)
|
||||
if (sessionState != null && fallbackUrl != null) {
|
||||
loadUrlIfSchemeSupported(sessionState, fallbackUrl)
|
||||
}
|
||||
}
|
||||
|
||||
val doOpenApp = {
|
||||
useCases.openAppLink(
|
||||
targetIntent,
|
||||
failedToLaunchAction = failedToLaunchAction,
|
||||
)
|
||||
}
|
||||
|
||||
val shouldShowPrompt = isPrivate || shouldPrompt()
|
||||
if (fragmentManager == null || !shouldShowPrompt || isAuthenticationFlow) {
|
||||
doOpenApp()
|
||||
return
|
||||
}
|
||||
|
||||
if (isADialogAlreadyCreated()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!fragmentManager.isStateSaved) {
|
||||
getOrCreateDialog(isPrivate, url, appName).apply {
|
||||
onConfirmRedirect = { isCheckboxTicked ->
|
||||
if (isCheckboxTicked) {
|
||||
checkboxCheckedAction()
|
||||
}
|
||||
doOpenApp()
|
||||
}
|
||||
onCancelRedirect = doNotOpenApp
|
||||
}.showNow(fragmentManager, FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal fun getOrCreateDialog(
|
||||
isPrivate: Boolean,
|
||||
url: String,
|
||||
targetAppName: String,
|
||||
): RedirectDialogFragment {
|
||||
if (dialog != null) {
|
||||
return dialog
|
||||
}
|
||||
|
||||
val dialogTitle = when {
|
||||
isPrivate && targetAppName.isNotBlank() -> {
|
||||
context.getString(R.string.mozac_feature_applinks_confirm_dialog_title_with_app_name, targetAppName)
|
||||
}
|
||||
isPrivate -> {
|
||||
context.getString(R.string.mozac_feature_applinks_confirm_dialog_title)
|
||||
}
|
||||
targetAppName.isNotBlank() -> {
|
||||
context.getString(
|
||||
R.string.mozac_feature_applinks_normal_confirm_dialog_title_with_app_name,
|
||||
targetAppName,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
context.getString(R.string.mozac_feature_applinks_normal_confirm_dialog_title)
|
||||
}
|
||||
}
|
||||
|
||||
val dialogMessage = if (isPrivate) {
|
||||
url
|
||||
} else {
|
||||
context.getString(
|
||||
R.string.mozac_feature_applinks_normal_confirm_dialog_message,
|
||||
context.appName,
|
||||
)
|
||||
}
|
||||
|
||||
return SimpleRedirectDialogFragment.newInstance(
|
||||
dialogTitleString = dialogTitle,
|
||||
dialogMessageString = dialogMessage,
|
||||
showCheckbox = if (isPrivate) false else showCheckbox,
|
||||
maxSuccessiveDialogMillisLimit = MAX_SUCCESSIVE_DIALOG_MILLIS_LIMIT,
|
||||
)
|
||||
}
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal fun loadUrlIfSchemeSupported(tab: SessionState, url: String) {
|
||||
val schemeSupported = engineSupportedSchemes.contains(url.toUri().scheme)
|
||||
if (schemeSupported) {
|
||||
loadUrlUseCase?.invoke(
|
||||
url = url,
|
||||
sessionId = tab.id,
|
||||
flags = EngineSession.LoadUrlFlags.select(EXTERNAL, LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isADialogAlreadyCreated(): Boolean {
|
||||
return fragmentManager?.findFragmentByTag(FRAGMENT_TAG) as? RedirectDialogFragment != null
|
||||
}
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal fun isAuthentication(sessionState: SessionState, appIntent: Intent): Boolean {
|
||||
return when (sessionState.source) {
|
||||
is SessionState.Source.External.ActionSend,
|
||||
is SessionState.Source.External.ActionSearch,
|
||||
-> false
|
||||
// CustomTab and ActionView can be used for authentication
|
||||
is SessionState.Source.External.CustomTab,
|
||||
is SessionState.Source.External.ActionView,
|
||||
-> {
|
||||
(sessionState.source as? SessionState.Source.External)?.let { externalSource ->
|
||||
when (externalSource.caller?.packageId) {
|
||||
null -> false
|
||||
appIntent.component?.packageName -> true
|
||||
else -> false
|
||||
}
|
||||
} ?: false
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal var userDoNotInterceptCache: MutableMap<Int, Long> = mutableMapOf()
|
||||
@@ -443,7 +261,43 @@ class AppLinksInterceptor(
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal const val APP_LINKS_DO_NOT_INTERCEPT_INTERVAL = 2000L // 2 second
|
||||
|
||||
// Minimum time for dialog to settle before accepting user interactions.
|
||||
internal const val MAX_SUCCESSIVE_DIALOG_MILLIS_LIMIT: Int = 500 // 0.5 seconds
|
||||
/**
|
||||
* Determines whether a given tab session is being used for authentication purposes.
|
||||
*
|
||||
* @param sessionState The current [SessionState], representing the tab session to inspect.
|
||||
*
|
||||
* @return `true` if the tab session is possible authentication flow, `false` otherwise.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal fun isPossibleAuthentication(sessionState: SessionState?): Boolean {
|
||||
return when (sessionState?.source) {
|
||||
// CustomTab and ActionView can be used for authentication
|
||||
is SessionState.Source.External.CustomTab,
|
||||
is SessionState.Source.External.ActionView,
|
||||
-> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a given tab session is being used for authentication purposes.
|
||||
*
|
||||
* @param sessionState The current [SessionState], representing the tab session to inspect.
|
||||
* @param packageName The target package name used to match with the caller's package name.
|
||||
*
|
||||
* @return `true` if the tab session is an authentication flow from the same app, `false` otherwise.
|
||||
*/
|
||||
fun isAuthentication(sessionState: SessionState?, packageName: String?): Boolean {
|
||||
if (packageName != null && isPossibleAuthentication(sessionState)) {
|
||||
val callerPackageId =
|
||||
(sessionState?.source as? SessionState.Source.External)?.caller?.packageId
|
||||
|
||||
if (callerPackageId == packageName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,14 +73,6 @@ class AppLinksUseCases(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update launchInApp for this instance of AppLinksUseCases
|
||||
* @param launchInApp the new value of launchInApp
|
||||
*/
|
||||
fun updateLaunchInApp(launchInApp: () -> Boolean) {
|
||||
this.launchInApp = launchInApp
|
||||
}
|
||||
|
||||
private fun findDefaultActivity(intent: Intent): ResolveInfo? {
|
||||
return context.packageManager.resolveActivityCompat(intent, PackageManager.MATCH_DEFAULT_ONLY)
|
||||
}
|
||||
|
||||
@@ -111,35 +111,35 @@ class AppLinksFeatureTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `feature observes app intents when started`() {
|
||||
fun `WHEN feature started THEN feature observes app intents`() {
|
||||
val tab = createTab(webUrl)
|
||||
store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
|
||||
verify(feature, never()).handleAppIntent(any(), any(), any())
|
||||
verify(feature, never()).handleAppIntent(any(), any(), any(), any(), any())
|
||||
|
||||
val intent: Intent = mock()
|
||||
val appIntent = AppIntentState(intentUrl, intent)
|
||||
val appIntent = AppIntentState(intentUrl, intent, null, null)
|
||||
store.dispatch(ContentAction.UpdateAppIntentAction(tab.id, appIntent)).joinBlocking()
|
||||
|
||||
store.waitUntilIdle()
|
||||
verify(feature).handleAppIntent(any(), any(), any())
|
||||
verify(feature).handleAppIntent(any(), any(), any(), any(), any())
|
||||
|
||||
val tabWithConsumedAppIntent = store.state.findTab(tab.id)!!
|
||||
assertNull(tabWithConsumedAppIntent.content.appIntent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `feature doesn't observes app intents when stopped`() {
|
||||
fun `WHEN feature is stopped THEN feature doesn't observes app intents`() {
|
||||
val tab = createTab(webUrl)
|
||||
store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
|
||||
verify(feature, never()).handleAppIntent(any(), any(), any())
|
||||
verify(feature, never()).handleAppIntent(any(), any(), any(), any(), any())
|
||||
|
||||
feature.stop()
|
||||
|
||||
val intent: Intent = mock()
|
||||
val appIntent = AppIntentState(intentUrl, intent)
|
||||
val appIntent = AppIntentState(intentUrl, intent, null, null)
|
||||
store.dispatch(ContentAction.UpdateAppIntentAction(tab.id, appIntent)).joinBlocking()
|
||||
|
||||
verify(feature, never()).handleAppIntent(any(), any(), any())
|
||||
verify(feature, never()).handleAppIntent(any(), any(), any(), any(), any())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -159,7 +159,7 @@ class AppLinksFeatureTest {
|
||||
}
|
||||
|
||||
val tab = createTab(webUrl)
|
||||
feature.handleAppIntent(tab, intentUrl, mock())
|
||||
feature.handleAppIntent(tab, intentUrl, mock(), null, null)
|
||||
|
||||
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
|
||||
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
|
||||
@@ -182,7 +182,7 @@ class AppLinksFeatureTest {
|
||||
}
|
||||
|
||||
val tab = createTab(webUrl)
|
||||
feature.handleAppIntent(tab, intentUrl, mock())
|
||||
feature.handleAppIntent(tab, intentUrl, mock(), null, null)
|
||||
|
||||
verify(mockDialog, never()).showNow(eq(mockFragmentManager), anyString())
|
||||
}
|
||||
@@ -217,7 +217,7 @@ class AppLinksFeatureTest {
|
||||
doReturn(componentName).`when`(appIntent).component
|
||||
doReturn("com.zxing.app").`when`(componentName).packageName
|
||||
|
||||
feature.handleAppIntent(tab, intentUrl, appIntent)
|
||||
feature.handleAppIntent(tab, intentUrl, appIntent, null, null)
|
||||
|
||||
verify(mockDialog, never()).showNow(eq(mockFragmentManager), anyString())
|
||||
}
|
||||
@@ -252,7 +252,7 @@ class AppLinksFeatureTest {
|
||||
doReturn(componentName).`when`(appIntent).component
|
||||
doReturn("com.zxing.app").`when`(componentName).packageName
|
||||
|
||||
feature.handleAppIntent(tab, intentUrl, appIntent)
|
||||
feature.handleAppIntent(tab, intentUrl, appIntent, null, null)
|
||||
|
||||
verify(mockDialog, never()).showNow(eq(mockFragmentManager), anyString())
|
||||
}
|
||||
@@ -287,7 +287,7 @@ class AppLinksFeatureTest {
|
||||
doReturn(componentName).`when`(appIntent).component
|
||||
doReturn("com.zxing.app").`when`(componentName).packageName
|
||||
|
||||
feature.handleAppIntent(tab, intentUrl, appIntent)
|
||||
feature.handleAppIntent(tab, intentUrl, appIntent, null, null)
|
||||
|
||||
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
|
||||
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
|
||||
@@ -323,7 +323,7 @@ class AppLinksFeatureTest {
|
||||
doReturn(componentName).`when`(appIntent).component
|
||||
doReturn("com.zxing.app").`when`(componentName).packageName
|
||||
|
||||
feature.handleAppIntent(tab, intentUrl, appIntent)
|
||||
feature.handleAppIntent(tab, intentUrl, appIntent, null, null)
|
||||
|
||||
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
|
||||
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
|
||||
@@ -346,7 +346,7 @@ class AppLinksFeatureTest {
|
||||
}
|
||||
|
||||
val tab = createTab(webUrl, private = true)
|
||||
feature.handleAppIntent(tab, intentUrl, mock())
|
||||
feature.handleAppIntent(tab, intentUrl, mock(), null, null)
|
||||
|
||||
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
|
||||
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
|
||||
@@ -369,7 +369,7 @@ class AppLinksFeatureTest {
|
||||
}
|
||||
|
||||
val tab = createTab(webUrl, private = true)
|
||||
feature.handleAppIntent(tab, intentUrl, mock())
|
||||
feature.handleAppIntent(tab, intentUrl, mock(), null, null)
|
||||
|
||||
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
|
||||
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
|
||||
@@ -378,60 +378,41 @@ class AppLinksFeatureTest {
|
||||
@Test
|
||||
fun `redirect dialog is only added once`() {
|
||||
val tab = createTab(webUrl, private = true)
|
||||
feature.handleAppIntent(tab, intentUrl, mock())
|
||||
feature.handleAppIntent(tab, intentUrl, mock(), null, null)
|
||||
|
||||
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
|
||||
|
||||
doReturn(mockDialog).`when`(feature).getOrCreateDialog(false, "")
|
||||
doReturn(mockDialog).`when`(feature).getOrCreateDialog(false, "", null)
|
||||
doReturn(mockDialog).`when`(mockFragmentManager).findFragmentByTag(RedirectDialogFragment.FRAGMENT_TAG)
|
||||
feature.handleAppIntent(tab, intentUrl, mock())
|
||||
feature.handleAppIntent(tab, intentUrl, mock(), null, null)
|
||||
verify(mockDialog, times(1)).showNow(mockFragmentManager, RedirectDialogFragment.FRAGMENT_TAG)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only loads URL if scheme is supported`() {
|
||||
val tab = createTab(webUrl, private = true)
|
||||
|
||||
feature.loadUrlIfSchemeSupported(tab, intentUrl)
|
||||
verify(mockLoadUrlUseCase, never()).invoke(anyString(), anyString(), any(), any(), any())
|
||||
|
||||
feature.loadUrlIfSchemeSupported(tab, webUrl)
|
||||
verify(mockLoadUrlUseCase, times(1)).invoke(anyString(), anyString(), any(), any(), any())
|
||||
|
||||
feature.loadUrlIfSchemeSupported(tab, aboutUrl)
|
||||
verify(mockLoadUrlUseCase, times(2)).invoke(anyString(), anyString(), any(), any(), any())
|
||||
fun `WHEN url is not supported THEN isSchemeSupported returns false`() {
|
||||
assertFalse(feature.isSchemeSupported(intentUrl))
|
||||
assertTrue(feature.isSchemeSupported(webUrl))
|
||||
assertTrue(feature.isSchemeSupported(aboutUrl))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN caller and intent have the same package name THEN return true`() {
|
||||
val customTab =
|
||||
createCustomTab(
|
||||
id = "c",
|
||||
url = webUrl,
|
||||
source = SessionState.Source.External.CustomTab(
|
||||
ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY),
|
||||
),
|
||||
)
|
||||
val appIntent: Intent = mock()
|
||||
val componentName: ComponentName = mock()
|
||||
doReturn(componentName).`when`(appIntent).component
|
||||
doReturn("com.zxing.app").`when`(componentName).packageName
|
||||
assertTrue(feature.isAuthentication(customTab, appIntent))
|
||||
|
||||
fun `WHEN url or fallback url scheme is supported THEN cancel redirect will load it`() {
|
||||
val tab = createTab(webUrl, private = true)
|
||||
assertFalse(feature.isAuthentication(tab, appIntent))
|
||||
val intent: Intent = mock()
|
||||
|
||||
val customTab2 =
|
||||
createCustomTab(
|
||||
id = "c",
|
||||
url = webUrl,
|
||||
source = SessionState.Source.External.CustomTab(
|
||||
ExternalPackage("com.example.app", PackageCategory.PRODUCTIVITY),
|
||||
),
|
||||
)
|
||||
assertFalse(feature.isAuthentication(customTab2, appIntent))
|
||||
feature.cancelRedirect(tab, intentUrl, null, intent)
|
||||
verify(mockLoadUrlUseCase, never()).invoke(anyString(), anyString(), any(), any(), any())
|
||||
|
||||
doReturn(null).`when`(componentName).packageName
|
||||
assertFalse(feature.isAuthentication(customTab, appIntent))
|
||||
feature.cancelRedirect(tab, intentUrl, intentUrl, intent)
|
||||
verify(mockLoadUrlUseCase, never()).invoke(anyString(), anyString(), any(), any(), any())
|
||||
|
||||
feature.cancelRedirect(tab, webUrl, null, intent)
|
||||
verify(mockLoadUrlUseCase, times(1)).invoke(anyString(), anyString(), any(), any(), any())
|
||||
|
||||
feature.cancelRedirect(tab, aboutUrl, null, intent)
|
||||
verify(mockLoadUrlUseCase, times(2)).invoke(anyString(), anyString(), any(), any(), any())
|
||||
|
||||
feature.cancelRedirect(tab, intentUrl, aboutUrl, intent)
|
||||
verify(mockLoadUrlUseCase, times(3)).invoke(anyString(), anyString(), any(), any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,6 @@ import mozilla.components.browser.state.state.ExternalPackage
|
||||
import mozilla.components.browser.state.state.PackageCategory
|
||||
import mozilla.components.browser.state.state.SessionState
|
||||
import mozilla.components.browser.state.state.TabSessionState
|
||||
import mozilla.components.browser.state.state.createCustomTab
|
||||
import mozilla.components.browser.state.state.createTab
|
||||
import mozilla.components.browser.state.store.BrowserStore
|
||||
import mozilla.components.concept.engine.EngineSession
|
||||
import mozilla.components.concept.engine.request.RequestInterceptor
|
||||
@@ -28,7 +26,6 @@ import mozilla.components.feature.app.links.AppLinksInterceptor.Companion.lastAp
|
||||
import mozilla.components.feature.app.links.AppLinksInterceptor.Companion.userDoNotInterceptCache
|
||||
import mozilla.components.feature.session.SessionUseCases
|
||||
import mozilla.components.support.test.any
|
||||
import mozilla.components.support.test.eq
|
||||
import mozilla.components.support.test.mock
|
||||
import mozilla.components.support.test.whenever
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -39,9 +36,7 @@ import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.ArgumentMatchers.anyBoolean
|
||||
import org.mockito.ArgumentMatchers.anyString
|
||||
import org.mockito.Mockito.doReturn
|
||||
import org.mockito.Mockito.never
|
||||
import org.mockito.Mockito.spy
|
||||
import org.mockito.Mockito.times
|
||||
import org.mockito.Mockito.verify
|
||||
@@ -65,7 +60,6 @@ class AppLinksInterceptorTest {
|
||||
private val intentUrl = "zxing://scan;S.browser_fallback_url=example.com"
|
||||
private val fallbackUrl = "https://getpocket.com"
|
||||
private val marketplaceUrl = "market://details?id=example.com"
|
||||
private val aboutUrl = "about://scan"
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
@@ -98,7 +92,6 @@ class AppLinksInterceptorTest {
|
||||
|
||||
appLinksInterceptor = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
)
|
||||
@@ -129,47 +122,27 @@ class AppLinksInterceptorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `request is not intercepted when interceptLinkClicks is false`() {
|
||||
fun `WHEN launchInApp preference is false THEN request is not intercepted`() {
|
||||
appLinksInterceptor = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
launchInApp = { false },
|
||||
useCases = mockUseCases,
|
||||
)
|
||||
|
||||
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, webUrlWithAppLink, null, true, false, false, false, false)
|
||||
assertEquals(null, response)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN launchInApp preference is true THEN request is intercepted`() {
|
||||
appLinksInterceptor = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = false,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
)
|
||||
|
||||
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, webUrlWithAppLink, null, true, false, false, false, false)
|
||||
assertEquals(null, response)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `request is not intercepted when launchInApp preference is false`() {
|
||||
appLinksInterceptor = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { false },
|
||||
useCases = mockUseCases,
|
||||
)
|
||||
|
||||
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, webUrlWithAppLink, null, true, false, false, false, false)
|
||||
assertEquals(null, response)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `request is not intercepted when launchInApp preference is updated to false`() {
|
||||
appLinksInterceptor = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { false },
|
||||
useCases = mockUseCases,
|
||||
)
|
||||
|
||||
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, webUrlWithAppLink, null, true, false, false, false, false)
|
||||
assertEquals(null, response)
|
||||
|
||||
appLinksInterceptor.updateLaunchInApp { true }
|
||||
verify(mockUseCases).updateLaunchInApp(any())
|
||||
val response2 = appLinksInterceptor.onLoadRequest(mockEngineSession, webUrlWithAppLink, null, true, false, false, false, false)
|
||||
assert(response2 is RequestInterceptor.InterceptionResponse.AppIntent)
|
||||
assert(response is RequestInterceptor.InterceptionResponse.AppIntent)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -229,7 +202,6 @@ class AppLinksInterceptorTest {
|
||||
val blocklistedScheme = "blocklisted"
|
||||
val feature = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = true,
|
||||
alwaysDeniedSchemes = setOf(blocklistedScheme),
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
@@ -248,7 +220,6 @@ class AppLinksInterceptorTest {
|
||||
val supportedScheme = "supported"
|
||||
val feature = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = true,
|
||||
engineSupportedSchemes = setOf(supportedScheme),
|
||||
launchInApp = { false },
|
||||
useCases = mockUseCases,
|
||||
@@ -261,32 +232,12 @@ class AppLinksInterceptorTest {
|
||||
assertEquals(null, response)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `supported schemes request not launched if interceptLinkClicks is false`() {
|
||||
val engineSession: EngineSession = mock()
|
||||
val supportedScheme = "supported"
|
||||
val feature = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = false,
|
||||
engineSupportedSchemes = setOf(supportedScheme),
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
)
|
||||
|
||||
val supportedUrl = "$supportedScheme://example.com"
|
||||
val supportedRedirect = AppLinkRedirect(Intent.parseUri(supportedUrl, 0), "", null, null)
|
||||
whenever(mockGetRedirect.invoke(supportedUrl)).thenReturn(supportedRedirect)
|
||||
val response = feature.onLoadRequest(engineSession, supportedUrl, null, true, false, false, false, false)
|
||||
assertEquals(null, response)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `supported schemes request not launched if not triggered by user`() {
|
||||
val engineSession: EngineSession = mock()
|
||||
val supportedScheme = "supported"
|
||||
val feature = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = true,
|
||||
engineSupportedSchemes = setOf(supportedScheme),
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
@@ -300,14 +251,13 @@ class AppLinksInterceptorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `not supported schemes request always intercepted regardless of hasUserGesture, interceptLinkClicks or launchInApp`() {
|
||||
fun `WHEN request is in not supported schemes THEN it is always intercepted regardless of hasUserGesture or launchInApp`() {
|
||||
val engineSession: EngineSession = mock()
|
||||
val supportedScheme = "supported"
|
||||
val notSupportedScheme = "not_supported"
|
||||
val blocklistedScheme = "blocklisted"
|
||||
val feature = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = false,
|
||||
engineSupportedSchemes = setOf(supportedScheme),
|
||||
alwaysDeniedSchemes = setOf(blocklistedScheme),
|
||||
launchInApp = { false },
|
||||
@@ -328,7 +278,6 @@ class AppLinksInterceptorTest {
|
||||
val notSupportedScheme = "not_supported"
|
||||
val feature = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = false,
|
||||
engineSupportedSchemes = setOf(supportedScheme),
|
||||
alwaysDeniedSchemes = setOf(notSupportedScheme),
|
||||
launchInApp = { false },
|
||||
@@ -350,7 +299,6 @@ class AppLinksInterceptorTest {
|
||||
val blocklistedScheme = "blocklisted"
|
||||
val feature = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = false,
|
||||
engineSupportedSchemes = setOf(supportedScheme),
|
||||
alwaysDeniedSchemes = setOf(blocklistedScheme),
|
||||
launchInApp = { true },
|
||||
@@ -372,7 +320,6 @@ class AppLinksInterceptorTest {
|
||||
val blocklistedScheme = "blocklisted"
|
||||
val feature = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = true,
|
||||
engineSupportedSchemes = setOf(supportedScheme),
|
||||
alwaysDeniedSchemes = setOf(blocklistedScheme),
|
||||
launchInApp = { false },
|
||||
@@ -395,7 +342,6 @@ class AppLinksInterceptorTest {
|
||||
val blocklistedScheme = "blocklisted"
|
||||
val feature = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = true,
|
||||
engineSupportedSchemes = setOf(supportedScheme),
|
||||
alwaysDeniedSchemes = setOf(blocklistedScheme),
|
||||
launchInApp = { false },
|
||||
@@ -415,7 +361,6 @@ class AppLinksInterceptorTest {
|
||||
val engineSession: EngineSession = mock()
|
||||
val feature = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = false,
|
||||
launchInApp = { false },
|
||||
useCases = mockUseCases,
|
||||
)
|
||||
@@ -432,7 +377,6 @@ class AppLinksInterceptorTest {
|
||||
val engineSession: EngineSession = mock()
|
||||
val feature = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = false,
|
||||
launchInApp = { false },
|
||||
useCases = mockUseCases,
|
||||
)
|
||||
@@ -472,11 +416,9 @@ class AppLinksInterceptorTest {
|
||||
fun `external app is launched when launch in app is set to true and it is user triggered`() {
|
||||
appLinksInterceptor = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
launchFromInterceptor = true,
|
||||
shouldPrompt = { false },
|
||||
)
|
||||
|
||||
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, webUrlWithAppLink, null, true, false, false, false, false)
|
||||
@@ -488,15 +430,13 @@ class AppLinksInterceptorTest {
|
||||
fun `external app is launched when launchInApp settings is set to AlwaysAsk and it is user triggered`() {
|
||||
appLinksInterceptor = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
launchFromInterceptor = true,
|
||||
shouldPrompt = { true },
|
||||
)
|
||||
|
||||
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, webUrlWithAppLink, null, true, false, false, false, false)
|
||||
assertNull(response)
|
||||
assert(response is RequestInterceptor.InterceptionResponse.AppIntent)
|
||||
verify(mockOpenRedirect).invoke(any(), anyBoolean(), any())
|
||||
}
|
||||
|
||||
@@ -504,11 +444,9 @@ class AppLinksInterceptorTest {
|
||||
fun `WHEN launch from intercept is false AND launch in app is set to true and it is user triggered THEN app intent is returned`() {
|
||||
appLinksInterceptor = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
launchFromInterceptor = false,
|
||||
shouldPrompt = { false },
|
||||
)
|
||||
|
||||
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, webUrlWithAppLink, null, true, false, false, false, false)
|
||||
@@ -520,7 +458,6 @@ class AppLinksInterceptorTest {
|
||||
fun `try to use fallback url if user preference is not to launch in third party app`() {
|
||||
appLinksInterceptor = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { false },
|
||||
useCases = mockUseCases,
|
||||
launchFromInterceptor = true,
|
||||
@@ -532,17 +469,16 @@ class AppLinksInterceptorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `external app is launched when url scheme is not supported by the engine`() {
|
||||
fun `WHEN url scheme is not supported by the engine THEN external app is launched`() {
|
||||
appLinksInterceptor = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { false },
|
||||
useCases = mockUseCases,
|
||||
launchFromInterceptor = true,
|
||||
)
|
||||
|
||||
val response = appLinksInterceptor.onLoadRequest(mockEngineSession, intentUrl, null, false, true, false, false, false)
|
||||
assertNull(response)
|
||||
assert(response is RequestInterceptor.InterceptionResponse.AppIntent)
|
||||
verify(mockOpenRedirect).invoke(any(), anyBoolean(), any())
|
||||
}
|
||||
|
||||
@@ -550,7 +486,6 @@ class AppLinksInterceptorTest {
|
||||
fun `WHEN url scheme is not supported by the engine AND launch from interceptor is false THEN app intent is returned`() {
|
||||
appLinksInterceptor = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { false },
|
||||
useCases = mockUseCases,
|
||||
launchFromInterceptor = false,
|
||||
@@ -565,7 +500,6 @@ class AppLinksInterceptorTest {
|
||||
fun `do not use fallback url if trigger by user gesture and preference is to launch in app`() {
|
||||
appLinksInterceptor = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
launchFromInterceptor = true,
|
||||
@@ -580,7 +514,6 @@ class AppLinksInterceptorTest {
|
||||
fun `launch marketplace intent if available and no external app`() {
|
||||
appLinksInterceptor = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
launchFromInterceptor = true,
|
||||
@@ -595,7 +528,6 @@ class AppLinksInterceptorTest {
|
||||
fun `use fallback url if available and no external app`() {
|
||||
appLinksInterceptor = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
launchFromInterceptor = true,
|
||||
@@ -610,7 +542,6 @@ class AppLinksInterceptorTest {
|
||||
fun `WHEN url have same domain THEN is same domain returns true ELSE false`() {
|
||||
appLinksInterceptor = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
launchFromInterceptor = true,
|
||||
@@ -629,7 +560,6 @@ class AppLinksInterceptorTest {
|
||||
fun `WHEN request is in user do not intercept cache THEN request is not intercepted`() {
|
||||
appLinksInterceptor = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
)
|
||||
@@ -644,7 +574,6 @@ class AppLinksInterceptorTest {
|
||||
fun `WHEN request is in user do not intercept cache but there is a fallback THEN fallback is used`() {
|
||||
appLinksInterceptor = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { false },
|
||||
useCases = mockUseCases,
|
||||
launchFromInterceptor = true,
|
||||
@@ -664,7 +593,6 @@ class AppLinksInterceptorTest {
|
||||
val blocklistedScheme = "blocklisted"
|
||||
val feature = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = false,
|
||||
engineSupportedSchemes = setOf(supportedScheme),
|
||||
alwaysDeniedSchemes = setOf(blocklistedScheme),
|
||||
launchInApp = { true },
|
||||
@@ -687,7 +615,6 @@ class AppLinksInterceptorTest {
|
||||
val blocklistedScheme = "blocklisted"
|
||||
val feature = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = false,
|
||||
engineSupportedSchemes = setOf(supportedScheme),
|
||||
alwaysDeniedSchemes = setOf(blocklistedScheme),
|
||||
launchInApp = { false },
|
||||
@@ -746,7 +673,6 @@ class AppLinksInterceptorTest {
|
||||
fun `WHEN request is redirecting to external app quickly THEN request is not intercepted`() {
|
||||
appLinksInterceptor = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
)
|
||||
@@ -762,7 +688,6 @@ class AppLinksInterceptorTest {
|
||||
fun `WHEN request is redirecting to different app quickly THEN request is intercepted`() {
|
||||
appLinksInterceptor = AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
)
|
||||
@@ -774,342 +699,17 @@ class AppLinksInterceptorTest {
|
||||
assertTrue(response is RequestInterceptor.InterceptionResponse.AppIntent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN should prompt AND in non-private mode THEN an external app dialog is shown`() {
|
||||
appLinksInterceptor = spy(
|
||||
AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
store = store,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
shouldPrompt = { true },
|
||||
),
|
||||
)
|
||||
|
||||
appLinksInterceptor.updateFragmentManger(mockFragmentManager)
|
||||
|
||||
val tab = createTab(webUrl)
|
||||
val mockAppIntent: Intent = mock()
|
||||
val mockComponentName: ComponentName = mock()
|
||||
whenever(mockAppIntent.component).thenReturn(mockComponentName)
|
||||
whenever(mockComponentName.packageName).thenReturn("com.zxing.app")
|
||||
doReturn(mockDialog).`when`(appLinksInterceptor).getOrCreateDialog(anyBoolean(), any(), any())
|
||||
|
||||
appLinksInterceptor.handleIntent(tab, intentUrl, mockAppIntent, null, mock(), "")
|
||||
|
||||
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
|
||||
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN should prompt AND only have marketing intent THEN an external app dialog is shown`() {
|
||||
appLinksInterceptor = spy(
|
||||
AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
store = store,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
shouldPrompt = { true },
|
||||
),
|
||||
)
|
||||
|
||||
appLinksInterceptor.updateFragmentManger(mockFragmentManager)
|
||||
|
||||
val tab = createTab(webUrl)
|
||||
val mockMarketIntent: Intent = mock()
|
||||
val mockComponentName: ComponentName = mock()
|
||||
whenever(mockComponentName.packageName).thenReturn("com.zxing.app")
|
||||
doReturn(mockDialog).`when`(appLinksInterceptor).getOrCreateDialog(anyBoolean(), any(), any())
|
||||
|
||||
appLinksInterceptor.handleIntent(tab, intentUrl, null, null, mockMarketIntent, "")
|
||||
|
||||
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
|
||||
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN should not prompt AND in non-private mode THEN an external app dialog is not shown`() {
|
||||
appLinksInterceptor = spy(
|
||||
AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
store = store,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
shouldPrompt = { false },
|
||||
),
|
||||
)
|
||||
|
||||
appLinksInterceptor.updateFragmentManger(mockFragmentManager)
|
||||
|
||||
val tab = createTab(webUrl)
|
||||
appLinksInterceptor.handleIntent(tab, intentUrl, mock(), null, mock(), "")
|
||||
|
||||
verify(mockDialog, never()).showNow(eq(mockFragmentManager), anyString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN custom tab and caller is the same as external app THEN an external app dialog is not shown`() {
|
||||
appLinksInterceptor = spy(
|
||||
AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
store = store,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
shouldPrompt = { true },
|
||||
),
|
||||
)
|
||||
|
||||
appLinksInterceptor.updateFragmentManger(mockFragmentManager)
|
||||
|
||||
val tab = createCustomTab(
|
||||
id = "c",
|
||||
url = webUrl,
|
||||
source = SessionState.Source.External.CustomTab(
|
||||
ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY),
|
||||
),
|
||||
)
|
||||
|
||||
val appIntent: Intent = mock()
|
||||
val componentName: ComponentName = mock()
|
||||
whenever(appIntent.component).thenReturn(componentName)
|
||||
whenever(componentName.packageName).thenReturn("com.zxing.app")
|
||||
|
||||
appLinksInterceptor.handleIntent(tab, intentUrl, appIntent, null, null, "")
|
||||
|
||||
verify(mockDialog, never()).showNow(eq(mockFragmentManager), anyString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN tab have action view and caller is the same as external app THEN an external app dialog is not shown`() {
|
||||
appLinksInterceptor = spy(
|
||||
AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
store = store,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
shouldPrompt = { true },
|
||||
),
|
||||
)
|
||||
|
||||
appLinksInterceptor.updateFragmentManger(mockFragmentManager)
|
||||
|
||||
val tab = createCustomTab(
|
||||
id = "d",
|
||||
url = webUrl,
|
||||
source = SessionState.Source.External.ActionView(
|
||||
ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY),
|
||||
),
|
||||
)
|
||||
|
||||
val appIntent: Intent = mock()
|
||||
val componentName: ComponentName = mock()
|
||||
whenever(appIntent.component).thenReturn(componentName)
|
||||
whenever(componentName.packageName).thenReturn("com.zxing.app")
|
||||
|
||||
appLinksInterceptor.handleIntent(tab, intentUrl, appIntent, null, null, "")
|
||||
|
||||
verify(mockDialog, never()).showNow(eq(mockFragmentManager), anyString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN tab have action send and caller is the same as external app THEN an external app dialog is shown`() {
|
||||
appLinksInterceptor = spy(
|
||||
AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
store = store,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
shouldPrompt = { true },
|
||||
),
|
||||
)
|
||||
|
||||
appLinksInterceptor.updateFragmentManger(mockFragmentManager)
|
||||
|
||||
val tab = createCustomTab(
|
||||
id = "d",
|
||||
url = webUrl,
|
||||
source = SessionState.Source.External.ActionSend(
|
||||
ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY),
|
||||
),
|
||||
)
|
||||
|
||||
val appIntent: Intent = mock()
|
||||
val componentName: ComponentName = mock()
|
||||
whenever(appIntent.component).thenReturn(componentName)
|
||||
whenever(componentName.packageName).thenReturn("com.zxing.app")
|
||||
doReturn(mockDialog).`when`(appLinksInterceptor).getOrCreateDialog(anyBoolean(), any(), any())
|
||||
|
||||
appLinksInterceptor.handleIntent(tab, intentUrl, appIntent, null, null, "")
|
||||
|
||||
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
|
||||
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN tab have action search and caller is the same as external app THEN an external app dialog is shown`() {
|
||||
appLinksInterceptor = spy(
|
||||
AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
store = store,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
shouldPrompt = { true },
|
||||
),
|
||||
)
|
||||
|
||||
appLinksInterceptor.updateFragmentManger(mockFragmentManager)
|
||||
|
||||
val tab = createCustomTab(
|
||||
id = "d",
|
||||
url = webUrl,
|
||||
source = SessionState.Source.External.ActionSearch(
|
||||
ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY),
|
||||
),
|
||||
)
|
||||
|
||||
val appIntent: Intent = mock()
|
||||
val componentName: ComponentName = mock()
|
||||
whenever(appIntent.component).thenReturn(componentName)
|
||||
whenever(componentName.packageName).thenReturn("com.zxing.app")
|
||||
doReturn(mockDialog).`when`(appLinksInterceptor).getOrCreateDialog(anyBoolean(), any(), any())
|
||||
|
||||
appLinksInterceptor.handleIntent(tab, intentUrl, appIntent, null, null, "")
|
||||
|
||||
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
|
||||
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN should prompt and in private mode THEN an external app dialog is shown`() {
|
||||
appLinksInterceptor = spy(
|
||||
AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
store = store,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
shouldPrompt = { true },
|
||||
),
|
||||
)
|
||||
|
||||
appLinksInterceptor.updateFragmentManger(mockFragmentManager)
|
||||
|
||||
val tabSessionState = TabSessionState(
|
||||
id = "tab1",
|
||||
content = ContentState(
|
||||
url = "https://mozilla.org",
|
||||
private = true,
|
||||
isProductUrl = false,
|
||||
),
|
||||
)
|
||||
val mockAppIntent: Intent = mock()
|
||||
val mockComponentName: ComponentName = mock()
|
||||
whenever(mockAppIntent.component).thenReturn(mockComponentName)
|
||||
whenever(mockComponentName.packageName).thenReturn("com.zxing.app")
|
||||
doReturn(mockDialog).`when`(appLinksInterceptor).getOrCreateDialog(anyBoolean(), any(), any())
|
||||
|
||||
appLinksInterceptor.handleIntent(tabSessionState, intentUrl, mockAppIntent, null, mock(), "")
|
||||
|
||||
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
|
||||
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN should not prompt and in private mode THEN an external app dialog is shown`() {
|
||||
appLinksInterceptor = spy(
|
||||
AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
store = store,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
shouldPrompt = { false },
|
||||
),
|
||||
)
|
||||
|
||||
appLinksInterceptor.updateFragmentManger(mockFragmentManager)
|
||||
|
||||
val tabSessionState = TabSessionState(
|
||||
id = "tab1",
|
||||
content = ContentState(
|
||||
url = "https://mozilla.org",
|
||||
private = true,
|
||||
isProductUrl = false,
|
||||
),
|
||||
)
|
||||
val mockAppIntent: Intent = mock()
|
||||
val mockComponentName: ComponentName = mock()
|
||||
whenever(mockAppIntent.component).thenReturn(mockComponentName)
|
||||
whenever(mockComponentName.packageName).thenReturn("com.zxing.app")
|
||||
doReturn(mockDialog).`when`(appLinksInterceptor).getOrCreateDialog(anyBoolean(), any(), any())
|
||||
|
||||
appLinksInterceptor.handleIntent(tabSessionState, intentUrl, mockAppIntent, null, mock(), "")
|
||||
|
||||
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
|
||||
verify(mockOpenRedirect, never()).invoke(any(), anyBoolean(), any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `redirect dialog is only added once`() {
|
||||
appLinksInterceptor = spy(
|
||||
AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
store = store,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
shouldPrompt = { true },
|
||||
),
|
||||
)
|
||||
|
||||
appLinksInterceptor.updateFragmentManger(mockFragmentManager)
|
||||
val tabSessionState = TabSessionState(
|
||||
id = "tab1",
|
||||
content = ContentState(
|
||||
url = "https://mozilla.org",
|
||||
private = false,
|
||||
isProductUrl = false,
|
||||
),
|
||||
)
|
||||
|
||||
val appIntent: Intent = mock()
|
||||
val componentName: ComponentName = mock()
|
||||
whenever(appIntent.component).thenReturn(componentName)
|
||||
whenever(componentName.packageName).thenReturn("com.zxing.app")
|
||||
doReturn(mockDialog).`when`(appLinksInterceptor).getOrCreateDialog(anyBoolean(), any(), any())
|
||||
|
||||
appLinksInterceptor.handleIntent(tabSessionState, intentUrl, appIntent, null, null, "")
|
||||
|
||||
verify(mockDialog).showNow(eq(mockFragmentManager), anyString())
|
||||
|
||||
doReturn(mockDialog).`when`(appLinksInterceptor).getOrCreateDialog(false, "", "")
|
||||
doReturn(mockDialog).`when`(mockFragmentManager).findFragmentByTag(RedirectDialogFragment.FRAGMENT_TAG)
|
||||
appLinksInterceptor.handleIntent(tabSessionState, intentUrl, mock(), null, mock(), "")
|
||||
verify(mockDialog, times(1)).showNow(mockFragmentManager, RedirectDialogFragment.FRAGMENT_TAG)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN caller and intent have the same package name THEN return true`() {
|
||||
appLinksInterceptor = spy(
|
||||
AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
store = store,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
shouldPrompt = { true },
|
||||
),
|
||||
)
|
||||
|
||||
appLinksInterceptor.updateFragmentManger(mockFragmentManager)
|
||||
|
||||
val tabSessionState = TabSessionState(
|
||||
id = "tab1",
|
||||
content = ContentState(
|
||||
@@ -1120,11 +720,7 @@ class AppLinksInterceptorTest {
|
||||
source = SessionState.Source.External.CustomTab(ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY)),
|
||||
)
|
||||
|
||||
val appIntent: Intent = mock()
|
||||
val componentName: ComponentName = mock()
|
||||
whenever(appIntent.component).thenReturn(componentName)
|
||||
whenever(componentName.packageName).thenReturn("com.zxing.app")
|
||||
assertTrue(appLinksInterceptor.isAuthentication(tabSessionState, appIntent))
|
||||
assertTrue(AppLinksInterceptor.isAuthentication(tabSessionState, "com.zxing.app"))
|
||||
|
||||
val tabSessionState2 = TabSessionState(
|
||||
id = "tab1",
|
||||
@@ -1134,7 +730,7 @@ class AppLinksInterceptorTest {
|
||||
isProductUrl = false,
|
||||
),
|
||||
)
|
||||
assertFalse(appLinksInterceptor.isAuthentication(tabSessionState2, appIntent))
|
||||
assertFalse(AppLinksInterceptor.isAuthentication(tabSessionState2, "com.zxing.app"))
|
||||
|
||||
val tabSessionState3 = TabSessionState(
|
||||
id = "tab1",
|
||||
@@ -1145,10 +741,8 @@ class AppLinksInterceptorTest {
|
||||
),
|
||||
source = SessionState.Source.External.CustomTab(ExternalPackage("com.example.app", PackageCategory.PRODUCTIVITY)),
|
||||
)
|
||||
assertFalse(appLinksInterceptor.isAuthentication(tabSessionState3, appIntent))
|
||||
|
||||
doReturn(null).`when`(componentName).packageName
|
||||
assertFalse(appLinksInterceptor.isAuthentication(tabSessionState, appIntent))
|
||||
assertFalse(AppLinksInterceptor.isAuthentication(tabSessionState3, "com.zxing.app"))
|
||||
assertFalse(AppLinksInterceptor.isAuthentication(tabSessionState, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1157,19 +751,11 @@ class AppLinksInterceptorTest {
|
||||
AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
store = store,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
shouldPrompt = { true },
|
||||
),
|
||||
)
|
||||
|
||||
val appIntent: Intent = mock()
|
||||
val componentName: ComponentName = mock()
|
||||
whenever(appIntent.component).thenReturn(componentName)
|
||||
whenever(componentName.packageName).thenReturn("com.zxing.app")
|
||||
appLinksInterceptor.updateFragmentManger(mockFragmentManager)
|
||||
|
||||
val tabSessionState = TabSessionState(
|
||||
id = "tab1",
|
||||
content = ContentState(
|
||||
@@ -1180,7 +766,7 @@ class AppLinksInterceptorTest {
|
||||
source = SessionState.Source.External.CustomTab(ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY)),
|
||||
)
|
||||
|
||||
assertTrue(appLinksInterceptor.isAuthentication(tabSessionState, appIntent))
|
||||
assertTrue(AppLinksInterceptor.isAuthentication(tabSessionState, "com.zxing.app"))
|
||||
|
||||
val tabSessionState2 = TabSessionState(
|
||||
id = "tab1",
|
||||
@@ -1191,7 +777,7 @@ class AppLinksInterceptorTest {
|
||||
),
|
||||
source = SessionState.Source.External.ActionView(ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY)),
|
||||
)
|
||||
assertTrue(appLinksInterceptor.isAuthentication(tabSessionState2, appIntent))
|
||||
assertTrue(AppLinksInterceptor.isAuthentication(tabSessionState2, "com.zxing.app"))
|
||||
|
||||
val tabSessionState3 = TabSessionState(
|
||||
id = "tab1",
|
||||
@@ -1202,7 +788,7 @@ class AppLinksInterceptorTest {
|
||||
),
|
||||
source = SessionState.Source.External.ActionSend(ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY)),
|
||||
)
|
||||
assertFalse(appLinksInterceptor.isAuthentication(tabSessionState3, appIntent))
|
||||
assertFalse(AppLinksInterceptor.isAuthentication(tabSessionState3, "com.zxing.app"))
|
||||
|
||||
val tabSessionState4 = TabSessionState(
|
||||
id = "tab1",
|
||||
@@ -1213,7 +799,7 @@ class AppLinksInterceptorTest {
|
||||
),
|
||||
source = SessionState.Source.External.ActionSearch(ExternalPackage("com.zxing.app", PackageCategory.PRODUCTIVITY)),
|
||||
)
|
||||
assertFalse(appLinksInterceptor.isAuthentication(tabSessionState4, appIntent))
|
||||
assertFalse(AppLinksInterceptor.isAuthentication(tabSessionState4, "com.zxing.app"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1224,30 +810,4 @@ class AppLinksInterceptorTest {
|
||||
assertFalse(isSubframeAllowed("http")) // we should never allow http for subframes
|
||||
assertFalse(isSubframeAllowed("https")) // we should never allow https for subframes
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN scheme is supported THEN loads URL`() {
|
||||
val tab = createTab(webUrl, private = true)
|
||||
|
||||
appLinksInterceptor = spy(
|
||||
AppLinksInterceptor(
|
||||
context = mockContext,
|
||||
store = store,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = { true },
|
||||
useCases = mockUseCases,
|
||||
shouldPrompt = { true },
|
||||
loadUrlUseCase = mockLoadUrlUseCase,
|
||||
),
|
||||
)
|
||||
|
||||
appLinksInterceptor.loadUrlIfSchemeSupported(tab, intentUrl)
|
||||
verify(mockLoadUrlUseCase, never()).invoke(anyString(), anyString(), any(), any(), any())
|
||||
|
||||
appLinksInterceptor.loadUrlIfSchemeSupported(tab, webUrl)
|
||||
verify(mockLoadUrlUseCase, times(1)).invoke(anyString(), anyString(), any(), any(), any())
|
||||
|
||||
appLinksInterceptor.loadUrlIfSchemeSupported(tab, aboutUrl)
|
||||
verify(mockLoadUrlUseCase, times(2)).invoke(anyString(), anyString(), any(), any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -676,20 +676,6 @@ class AppLinksUseCasesTest {
|
||||
assertEquals(result?.`package`, "org.mozilla.test")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN launch in app is updated to true THEN should redirect`() {
|
||||
val context = createContext(Triple(appUrl, appPackage, ""))
|
||||
val subject = AppLinksUseCases(context, { false })
|
||||
|
||||
var redirect = subject.interceptedAppLinkRedirect(appUrl)
|
||||
assertFalse(redirect.isRedirect())
|
||||
|
||||
AppLinksUseCases.clearRedirectCache()
|
||||
subject.updateLaunchInApp { true }
|
||||
redirect = subject.interceptedAppLinkRedirect(appUrl)
|
||||
assertTrue(redirect.isRedirect())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN opening a app scheme uri WITH fallback URL WHERE the URL is Google PlayStore THEN ignore fallback URL`() {
|
||||
val context = createContext(Triple(appIntentWithPackageAndPlayStoreFallback, appPackage, ""))
|
||||
|
||||
@@ -15,7 +15,11 @@ import mozilla.components.feature.pwa.intent.WebAppIntentProcessor
|
||||
/**
|
||||
* This feature will intercept requests and reopen them in the corresponding installed PWA, if any.
|
||||
*
|
||||
* @param shortcutManager current shortcut manager instance to lookup web app install states
|
||||
* @param context application context used for launching activities or accessing system services
|
||||
* @param manifestStorage Disk storage for [WebAppManifest]. Other components use this class to
|
||||
* reload a saved manifest.
|
||||
* @param launchFromInterceptor flag to determine whether intercepted requests should directly launch
|
||||
* the PWA
|
||||
*/
|
||||
class WebAppInterceptor(
|
||||
private val context: Context,
|
||||
@@ -39,7 +43,7 @@ class WebAppInterceptor(
|
||||
val intent = createIntentFromUri(startUrl, uri)
|
||||
|
||||
if (!launchFromInterceptor) {
|
||||
return RequestInterceptor.InterceptionResponse.AppIntent(intent, uri)
|
||||
return RequestInterceptor.InterceptionResponse.AppIntent(intent, uri, null, null)
|
||||
}
|
||||
|
||||
intent.flags = intent.flags or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
@@ -51,7 +55,10 @@ class WebAppInterceptor(
|
||||
/**
|
||||
* Creates a new VIEW_PWA intent for a URL.
|
||||
*
|
||||
* @param uri target URL for the new intent
|
||||
* @param startUrl the original start URL associated with the PWA
|
||||
* @param urlOverride an optional override URL to open instead of the start URL; defaults to [startUrl]
|
||||
*
|
||||
* @return an [Intent] configured to launch the PWA with the given URL
|
||||
*/
|
||||
private fun createIntentFromUri(startUrl: String, urlOverride: String = startUrl): Intent {
|
||||
return Intent(WebAppIntentProcessor.ACTION_VIEW_PWA, startUrl.toUri()).apply {
|
||||
|
||||
@@ -12,6 +12,10 @@ permalink: /changelog/
|
||||
* ⚠️ **Breaking change**: Added `downloadEstimator` property to `DownloadJobState`. [Bug 1956577](https://bugzilla.mozilla.org/show_bug.cgi?id=1956577).
|
||||
* ⚠️ **Breaking change**: Added new 'dateTimeProvider' abstract val to `AbstractFetchDownloadService`. [Bug 1956577](https://bugzilla.mozilla.org/show_bug.cgi?id=1956577).
|
||||
* ⚠️ **Breaking change**: Removed Deprecated `Long.toMegabyteOrKilobyteString` in v140. [Bug 1955689](https://bugzilla.mozilla.org/show_bug.cgi?id=1955689).
|
||||
* **feature-app-links**
|
||||
* Added `alwaysOpenCheckboxAction` parameter to `AppLinksFeature`, this was moved from `AppLinksInterceptor`.
|
||||
* ⚠️ **Breaking change**: Moved prompt functionality back to `AppLinksFeature`.
|
||||
* ⚠️ **Breaking change**: Removed `interceptLinkClicks` parameter from `AppLinksInterceptor` since no usage sets this to false.
|
||||
|
||||
# 139.0
|
||||
* **feature-downloads**
|
||||
|
||||
@@ -240,10 +240,6 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
|
||||
customTabId = sessionId,
|
||||
)
|
||||
|
||||
components.appLinksInterceptor.updateFragmentManger(
|
||||
fragmentManager = parentFragmentManager,
|
||||
)
|
||||
|
||||
// Observe the lifecycle for supported features
|
||||
lifecycle.addObservers(
|
||||
scrollFeature,
|
||||
@@ -292,9 +288,6 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
|
||||
}
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
components.appLinksInterceptor.updateFragmentManger(
|
||||
fragmentManager = null,
|
||||
)
|
||||
|
||||
binding.engineView.setActivityContext(null)
|
||||
_binding = null
|
||||
|
||||
@@ -243,8 +243,7 @@ open class DefaultComponents(private val applicationContext: Context) {
|
||||
|
||||
val appLinksInterceptor by lazy {
|
||||
AppLinksInterceptor(
|
||||
applicationContext,
|
||||
interceptLinkClicks = true,
|
||||
context = applicationContext,
|
||||
launchInApp = {
|
||||
applicationContext.components.preferences.getBoolean(PREF_LAUNCH_EXTERNAL_APP, false)
|
||||
},
|
||||
|
||||
@@ -77,6 +77,7 @@ import mozilla.components.concept.storage.CreditCardEntry
|
||||
import mozilla.components.concept.storage.Login
|
||||
import mozilla.components.concept.storage.LoginEntry
|
||||
import mozilla.components.feature.accounts.push.SendTabUseCases
|
||||
import mozilla.components.feature.app.links.AppLinksFeature
|
||||
import mozilla.components.feature.contextmenu.ContextMenuCandidate
|
||||
import mozilla.components.feature.contextmenu.ContextMenuFeature
|
||||
import mozilla.components.feature.downloads.DownloadsFeature
|
||||
@@ -302,6 +303,7 @@ abstract class BaseBrowserFragment :
|
||||
private val lastTabFeature = ViewBoundFeatureWrapper<LastTabFeature>()
|
||||
private val contextMenuFeature = ViewBoundFeatureWrapper<ContextMenuFeature>()
|
||||
private val downloadsFeature = ViewBoundFeatureWrapper<DownloadsFeature>()
|
||||
private val appLinksFeature = ViewBoundFeatureWrapper<AppLinksFeature>()
|
||||
private val shareResourceFeature = ViewBoundFeatureWrapper<ShareResourceFeature>()
|
||||
private val copyDownloadsFeature = ViewBoundFeatureWrapper<CopyDownloadFeature>()
|
||||
private val promptsFeature = ViewBoundFeatureWrapper<PromptFeature>()
|
||||
@@ -934,6 +936,33 @@ abstract class BaseBrowserFragment :
|
||||
onHide = ::onAutocompleteBarHide,
|
||||
)
|
||||
|
||||
appLinksFeature.set(
|
||||
feature = AppLinksFeature(
|
||||
context = requireContext(),
|
||||
store = store,
|
||||
sessionId = tab.id,
|
||||
fragmentManager = parentFragmentManager,
|
||||
launchInApp = { context.settings().shouldOpenLinksInApp(customTabSessionId != null) },
|
||||
loadUrlUseCase = requireComponents.useCases.sessionUseCases.loadUrl,
|
||||
shouldPrompt = { context.settings().shouldPromptOpenLinksInApp() },
|
||||
alwaysOpenCheckboxAction = {
|
||||
context.settings().openLinksInExternalApp =
|
||||
context.getString(R.string.pref_key_open_links_in_apps_always)
|
||||
},
|
||||
failedToLaunchAction = { fallbackUrl ->
|
||||
fallbackUrl?.let {
|
||||
val appLinksUseCases = requireComponents.useCases.appLinksUseCases
|
||||
val getRedirect = appLinksUseCases.appLinkRedirect
|
||||
val redirect = getRedirect.invoke(fallbackUrl)
|
||||
redirect.appIntent?.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
appLinksUseCases.openAppLink.invoke(redirect.appIntent)
|
||||
}
|
||||
},
|
||||
),
|
||||
owner = this,
|
||||
view = binding.root,
|
||||
)
|
||||
|
||||
promptsFeature.set(
|
||||
feature = PromptFeature(
|
||||
activity = activity,
|
||||
@@ -1854,16 +1883,6 @@ abstract class BaseBrowserFragment :
|
||||
}
|
||||
hideToolbar()
|
||||
|
||||
components.services.appLinksInterceptor.updateFragmentManger(
|
||||
fragmentManager = parentFragmentManager,
|
||||
)
|
||||
context?.settings()?.shouldOpenLinksInApp(customTabSessionId != null)
|
||||
?.let { openLinksInExternalApp ->
|
||||
components.services.appLinksInterceptor.updateLaunchInApp {
|
||||
openLinksInExternalApp
|
||||
}
|
||||
}
|
||||
|
||||
evaluateMessagesForMicrosurvey(components)
|
||||
|
||||
BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt =
|
||||
@@ -1881,10 +1900,6 @@ abstract class BaseBrowserFragment :
|
||||
if (findNavController().currentDestination?.id != R.id.searchDialogFragment) {
|
||||
view?.hideKeyboard()
|
||||
}
|
||||
|
||||
requireComponents.services.appLinksInterceptor.updateFragmentManger(
|
||||
fragmentManager = null,
|
||||
)
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
|
||||
@@ -13,8 +13,6 @@ import mozilla.components.browser.state.store.BrowserStore
|
||||
import mozilla.components.feature.accounts.FirefoxAccountsAuthFeature
|
||||
import mozilla.components.feature.app.links.AppLinksInterceptor
|
||||
import mozilla.components.service.fxa.manager.FxaAccountManager
|
||||
import org.mozilla.fenix.R
|
||||
import org.mozilla.fenix.ext.components
|
||||
import org.mozilla.fenix.ext.settings
|
||||
import org.mozilla.fenix.perf.lazyMonitored
|
||||
import org.mozilla.fenix.settings.SupportUtils
|
||||
@@ -48,17 +46,9 @@ class Services(
|
||||
val appLinksInterceptor by lazyMonitored {
|
||||
AppLinksInterceptor(
|
||||
context = context,
|
||||
interceptLinkClicks = true,
|
||||
showCheckbox = true,
|
||||
launchInApp = { context.settings().shouldOpenLinksInApp() },
|
||||
shouldPrompt = { context.settings().shouldPromptOpenLinksInApp() },
|
||||
checkboxCheckedAction = {
|
||||
context.settings().openLinksInExternalApp =
|
||||
context.getString(R.string.pref_key_open_links_in_apps_always)
|
||||
},
|
||||
launchFromInterceptor = true,
|
||||
launchFromInterceptor = false,
|
||||
store = store,
|
||||
loadUrlUseCase = context.components.useCases.sessionUseCases.loadUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +249,6 @@ class Components(
|
||||
val appLinksInterceptor by lazy {
|
||||
AppLinksInterceptor(
|
||||
context,
|
||||
interceptLinkClicks = true,
|
||||
launchInApp = {
|
||||
context.settings.openLinksInExternalApp
|
||||
},
|
||||
|
||||
@@ -821,13 +821,6 @@ class BrowserFragment :
|
||||
if (tab.isCustomTab()) {
|
||||
view?.isVisible = true
|
||||
}
|
||||
|
||||
context?.settings?.openLinksInExternalApp?.let { openLinksInExternalApp ->
|
||||
val isCustomTab = tab.isCustomTab()
|
||||
components?.appLinksInterceptor?.updateLaunchInApp {
|
||||
openLinksInExternalApp || isCustomTab
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateEngineColorScheme() {
|
||||
|
||||
Reference in New Issue
Block a user