Commit 1df47382 authored by MozLando's avatar MozLando
Browse files

Merge #7367



7367: Provide request interceptor to automatically navigate into PWAs r=NotWoods a=TitanNano

For #7366 and mozilla-mobile/fenix#5772

- a WebAppInterceptor is needed to redirect to a separate WebApp
  activity
- the manifest dao currently reports a web app as installed when it was
  last used before the deadline / expiration time. This is the oposite
  of what it's supposed to do.
- The intent extension should also hold an override URL so a WebApp
  intent can be launched with a deeplink.
- The WebAppIntentProcessor currently launches new sessions for each
  intent. This will break user expectations when they start to be
  switched to a already running WebApp activity but loose their session.
Co-authored-by: default avatarJovan Gerodetti <jovan.gerodetti@titannano.de>
parents 6eedf876 2f99e0ae
......@@ -269,6 +269,8 @@ class SystemEngineView @JvmOverloads constructor(
super.shouldInterceptRequest(view, request)
}
is InterceptionResponse.Deny -> super.shouldInterceptRequest(view, request)
}
}
}
......
......@@ -26,6 +26,11 @@ interface RequestInterceptor {
data class Url(val url: String) : InterceptionResponse()
data class AppIntent(val appIntent: Intent, val url: String) : InterceptionResponse()
/**
* Deny request without further action.
*/
object Deny : InterceptionResponse()
}
/**
......
......@@ -19,10 +19,12 @@ import mozilla.components.feature.pwa.db.ManifestEntity
* @param activeThresholdMs a timeout in milliseconds after which the storage will consider a manifest
* as unused. By default this is [ACTIVE_THRESHOLD_MS].
*/
@Suppress("TooManyFunctions")
class ManifestStorage(context: Context, private val activeThresholdMs: Long = ACTIVE_THRESHOLD_MS) {
@VisibleForTesting
internal var manifestDao = lazy { ManifestDatabase.get(context).manifestDao() }
internal var installedScopes: MutableMap<String, String>? = null
/**
* Load a Web App Manifest for the given URL from disk.
......@@ -70,6 +72,34 @@ class ManifestStorage(context: Context, private val activeThresholdMs: Long = AC
manifestDao.value.recentManifestsCount(thresholdMs = currentTimeMs - activeThresholdMs)
}
/**
* Returns the cached scope for an url if the url falls into a web app scope that has been installed by the user.
*
* @param url the url to match against installed web app scopes.
*/
fun getInstalledScope(url: String) = installedScopes?.keys?.sortedDescending()?.find { url.startsWith(it) }
/**
* Returns a cached start url for an installed web app scope.
*
* @param scope the scope url to look up.
*/
fun getStartUrlForInstalledScope(scope: String) = installedScopes?.get(scope)
/**
* Populates a cache of currently installed web app scopes and their start urls.
*
* @param currentTime the current time is used to determine which web apps are still installed.
*/
suspend fun warmUpScopes(currentTime: Long) = withContext(IO) {
installedScopes = manifestDao.value
.getInstalledScopes(currentTime - activeThresholdMs)
.map { manifest -> manifest.scope?.let { scope -> Pair(scope, manifest.startUrl) } }
.filterNotNull()
.toMap()
.toMutableMap()
}
/**
* Save a Web App Manifest to disk.
*/
......@@ -101,6 +131,12 @@ class ManifestStorage(context: Context, private val activeThresholdMs: Long = AC
manifestDao.value.getManifest(manifest.startUrl)?.let { existing ->
val update = existing.copy(usedAt = System.currentTimeMillis())
manifestDao.value.updateManifest(update)
existing.scope?.let { scope ->
installedScopes?.put(scope, existing.startUrl)
}
return@let
}
}
......
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package mozilla.components.feature.pwa
import android.content.Context
import android.content.Intent
import android.net.Uri
import mozilla.components.concept.engine.EngineSession
import mozilla.components.concept.engine.request.RequestInterceptor
import mozilla.components.feature.pwa.ext.putUrlOverride
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
*/
class WebAppInterceptor(
private val context: Context,
private val manifestStorage: ManifestStorage,
private val launchFromInterceptor: Boolean = true
) : RequestInterceptor {
@Suppress("ReturnCount")
override fun onLoadRequest(
engineSession: EngineSession,
uri: String,
hasUserGesture: Boolean,
isSameDomain: Boolean,
isRedirect: Boolean,
isDirectNavigation: Boolean
): RequestInterceptor.InterceptionResponse? {
val scope = manifestStorage.getInstalledScope(uri) ?: return null
val startUrl = manifestStorage.getStartUrlForInstalledScope(scope) ?: return null
val intent = createIntentFromUri(startUrl, uri)
if (!launchFromInterceptor) {
return RequestInterceptor.InterceptionResponse.AppIntent(intent, uri)
}
intent.flags = intent.flags or Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)
return RequestInterceptor.InterceptionResponse.Deny
}
/**
* Creates a new VIEW_PWA intent for a URL.
*
* @param uri target URL for the new intent
*/
private fun createIntentFromUri(startUrl: String, urlOverride: String = startUrl): Intent {
return Intent(WebAppIntentProcessor.ACTION_VIEW_PWA, Uri.parse(startUrl)).apply {
this.addCategory(Intent.CATEGORY_DEFAULT)
this.putUrlOverride(urlOverride)
}
}
}
......@@ -43,4 +43,8 @@ internal interface ManifestDao {
@WorkerThread
@Query("DELETE FROM manifests WHERE start_url IN (:startUrls)")
fun deleteManifests(startUrls: List<String>)
@WorkerThread
@Query("SELECT * from manifests WHERE used_at > :expiresAt ORDER BY LENGTH(scope)")
fun getInstalledScopes(expiresAt: Long): List<ManifestEntity>
}
......@@ -8,6 +8,8 @@ import android.content.Intent
import mozilla.components.concept.engine.manifest.WebAppManifest
import mozilla.components.concept.engine.manifest.WebAppManifestParser
internal const val EXTRA_URL_OVERRIDE = "mozilla.components.feature.pwa.EXTRA_URL_OVERRIDE"
/**
* Add extended [WebAppManifest] data to the intent.
*/
......@@ -22,3 +24,25 @@ fun Intent.putWebAppManifest(webAppManifest: WebAppManifest) {
fun Intent.getWebAppManifest(): WebAppManifest? {
return extras?.getWebAppManifest()
}
/**
* Add [String] URL override to the intent.
*
* @param url The URL override value.
*
* @return Returns the same Intent object, for chaining multiple calls
* into a single statement.
*
* @see [getUrlOverride]
*/
fun Intent.putUrlOverride(url: String?): Intent {
return putExtra(EXTRA_URL_OVERRIDE, url)
}
/**
* Retrieves [String] Url override from the intent.
*
* @return The URL override previously added with [putUrlOverride],
* or null if no URL was found.
*/
fun Intent.getUrlOverride(): String? = getStringExtra(EXTRA_URL_OVERRIDE)
......@@ -10,7 +10,10 @@ import kotlinx.coroutines.runBlocking
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.Session.Source
import mozilla.components.browser.session.SessionManager
import mozilla.components.browser.state.state.ExternalAppType
import mozilla.components.concept.engine.EngineSession
import mozilla.components.concept.engine.manifest.WebAppManifest
import mozilla.components.feature.pwa.ext.getUrlOverride
import mozilla.components.feature.intent.ext.putSessionId
import mozilla.components.feature.intent.processing.IntentProcessor
import mozilla.components.feature.pwa.ManifestStorage
......@@ -45,13 +48,14 @@ class WebAppIntentProcessor(
return if (!url.isNullOrEmpty() && matches(intent)) {
val webAppManifest = runBlocking { storage.loadManifest(url) } ?: return false
val targetUrl = intent.getUrlOverride() ?: url
val session = Session(url, private = false, source = Source.HOME_SCREEN)
session.webAppManifest = webAppManifest
session.customTabConfig = webAppManifest.toCustomTabConfig()
val session = findExistingSession(webAppManifest) ?: createSession(webAppManifest, url)
if (targetUrl !== url) {
loadUrlUseCase(targetUrl, session, EngineSession.LoadUrlFlags.external())
}
sessionManager.add(session)
loadUrlUseCase(url, session, EngineSession.LoadUrlFlags.external())
intent.flags = FLAG_ACTIVITY_NEW_DOCUMENT
intent.putSessionId(session.id)
intent.putWebAppManifest(webAppManifest)
......@@ -62,6 +66,30 @@ class WebAppIntentProcessor(
}
}
/**
* Returns an existing web app session that matches the manifest.
*/
private fun findExistingSession(webAppManifest: WebAppManifest): Session? {
return sessionManager.all.find {
it.customTabConfig?.externalAppType == ExternalAppType.PROGRESSIVE_WEB_APP &&
it.webAppManifest?.startUrl == webAppManifest.startUrl
}
}
/**
* Returns a new web app session.
*/
private fun createSession(webAppManifest: WebAppManifest, url: String): Session {
return Session(url, private = false, source = Source.HOME_SCREEN)
.apply {
this.webAppManifest = webAppManifest
this.customTabConfig = webAppManifest.toCustomTabConfig()
}.also {
sessionManager.add(it)
loadUrlUseCase(url, it, EngineSession.LoadUrlFlags.external())
}
}
companion object {
const val ACTION_VIEW_PWA = "mozilla.components.feature.pwa.VIEW_PWA"
}
......
......@@ -37,6 +37,18 @@ class ManifestStorageTest {
scope = "/"
)
private val googleMapsManifest = WebAppManifest(
name = "Google Maps",
startUrl = "https://google.com/maps",
scope = "https://google.com/maps/"
)
private val exampleWebAppManifest = WebAppManifest(
name = "Example Web App",
startUrl = "https://pwa.example.com/dashboard",
scope = "https://pwa.example.com/"
)
@Test
fun `load returns null if entry does not exist`() = runBlocking {
val storage = spy(ManifestStorage(testContext))
......@@ -202,6 +214,65 @@ class ManifestStorageTest {
))
}
@Test
fun `warmUpScopes populates cache of already installed web app scopes`() = runBlocking {
val storage = spy(ManifestStorage(testContext))
val dao = mockDatabase(storage)
val manifest1 = ManifestEntity(manifest = firefoxManifest, createdAt = 0, updatedAt = 0)
val manifest2 = ManifestEntity(manifest = googleMapsManifest, createdAt = 0, updatedAt = 0)
val manifest3 = ManifestEntity(manifest = exampleWebAppManifest, createdAt = 0, updatedAt = 0)
whenever(dao.getInstalledScopes(0)).thenReturn(listOf(manifest1, manifest2, manifest3))
storage.warmUpScopes(ManifestStorage.ACTIVE_THRESHOLD_MS)
assertEquals(
mapOf(
Pair("/", "https://firefox.com"),
Pair("https://google.com/maps/", "https://google.com/maps"),
Pair("https://pwa.example.com/", "https://pwa.example.com/dashboard")
),
storage.installedScopes
)
}
@Test
fun `getInstalledScope returns cached scope for an url`() = runBlocking {
val storage = spy(ManifestStorage(testContext))
val dao = mockDatabase(storage)
val manifest1 = ManifestEntity(manifest = firefoxManifest, createdAt = 0, updatedAt = 0)
val manifest2 = ManifestEntity(manifest = googleMapsManifest, createdAt = 0, updatedAt = 0)
val manifest3 = ManifestEntity(manifest = exampleWebAppManifest, createdAt = 0, updatedAt = 0)
whenever(dao.getInstalledScopes(0)).thenReturn(listOf(manifest1, manifest2, manifest3))
storage.warmUpScopes(ManifestStorage.ACTIVE_THRESHOLD_MS)
val result = storage.getInstalledScope("https://pwa.example.com/profile/me")
assertEquals("https://pwa.example.com/", result)
}
@Test
fun `getStartUrlForInstalledScope returns cached start url for a currently installed scope`() = runBlocking {
val storage = spy(ManifestStorage(testContext))
val dao = mockDatabase(storage)
val manifest1 = ManifestEntity(manifest = firefoxManifest, createdAt = 0, updatedAt = 0)
val manifest2 = ManifestEntity(manifest = googleMapsManifest, createdAt = 0, updatedAt = 0)
val manifest3 = ManifestEntity(manifest = exampleWebAppManifest, createdAt = 0, updatedAt = 0)
whenever(dao.getInstalledScopes(0)).thenReturn(listOf(manifest1, manifest2, manifest3))
storage.warmUpScopes(ManifestStorage.ACTIVE_THRESHOLD_MS)
val result = storage.getStartUrlForInstalledScope("https://pwa.example.com/")
assertEquals("https://pwa.example.com/dashboard", result)
}
private fun mockDatabase(storage: ManifestStorage): ManifestDao = mock<ManifestDao>().also {
storage.manifestDao = lazy { it }
}
......
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package mozilla.components.feature.pwa
import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4
import mozilla.components.concept.engine.EngineSession
import mozilla.components.concept.engine.request.RequestInterceptor
import mozilla.components.support.test.mock
import mozilla.components.support.test.whenever
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class WebAppInterceptorTest {
private lateinit var mockContext: Context
private lateinit var mockEngineSession: EngineSession
private lateinit var mockManifestStorage: ManifestStorage
private lateinit var webAppInterceptor: WebAppInterceptor
private val webUrl = "https://example.com"
private val webUrlWithWebApp = "https://google.com/maps/"
private val webUrlOutOfScope = "https://google.com/search/"
@Before
fun setup() {
mockContext = mock()
mockEngineSession = mock()
mockManifestStorage = mock()
webAppInterceptor = WebAppInterceptor(
context = mockContext,
manifestStorage = mockManifestStorage,
launchFromInterceptor = true
)
}
@Test
fun `request is intercepted when navigating to an installed web app`() {
whenever(mockManifestStorage.getInstalledScope(webUrlWithWebApp)).thenReturn(webUrlWithWebApp)
whenever(mockManifestStorage.getStartUrlForInstalledScope(webUrlWithWebApp)).thenReturn(webUrlWithWebApp)
val response = webAppInterceptor.onLoadRequest(mockEngineSession, webUrlWithWebApp, true, false, false, false)
assert(response is RequestInterceptor.InterceptionResponse.Deny)
}
@Test
fun `request is not intercepted when url is out of scope`() {
whenever(mockManifestStorage.getInstalledScope(webUrlOutOfScope)).thenReturn(null)
whenever(mockManifestStorage.getStartUrlForInstalledScope(webUrlOutOfScope)).thenReturn(null)
val response = webAppInterceptor.onLoadRequest(mockEngineSession, webUrlOutOfScope, true, false, false, false)
assertNull(response)
}
@Test
fun `request is not intercepted when url is not part of a web app`() {
whenever(mockManifestStorage.getInstalledScope(webUrl)).thenReturn(null)
whenever(mockManifestStorage.getStartUrlForInstalledScope(webUrl)).thenReturn(null)
val response = webAppInterceptor.onLoadRequest(mockEngineSession, webUrl, true, false, false, false)
assertNull(response)
}
@Test
fun `request is intercepted with app intent if not launchFromInterceptor`() {
webAppInterceptor = WebAppInterceptor(
context = mockContext,
manifestStorage = mockManifestStorage,
launchFromInterceptor = false
)
whenever(mockManifestStorage.getInstalledScope(webUrlWithWebApp)).thenReturn(webUrlWithWebApp)
whenever(mockManifestStorage.getStartUrlForInstalledScope(webUrlWithWebApp)).thenReturn(webUrlWithWebApp)
val response = webAppInterceptor.onLoadRequest(mockEngineSession, webUrlWithWebApp, true, false, false, false)
assert(response is RequestInterceptor.InterceptionResponse.AppIntent)
}
@Test
fun `launchFromInterceptor is enabled by default`() {
webAppInterceptor = WebAppInterceptor(
context = mockContext,
manifestStorage = mockManifestStorage
)
whenever(mockManifestStorage.getInstalledScope(webUrlWithWebApp)).thenReturn(webUrlWithWebApp)
whenever(mockManifestStorage.getStartUrlForInstalledScope(webUrlWithWebApp)).thenReturn(webUrlWithWebApp)
val response = webAppInterceptor.onLoadRequest(mockEngineSession, webUrlWithWebApp, true, false, false, false)
assert(response is RequestInterceptor.InterceptionResponse.Deny)
}
}
......@@ -17,7 +17,11 @@ import mozilla.components.concept.engine.manifest.WebAppManifest
import mozilla.components.feature.intent.ext.getSessionId
import mozilla.components.feature.pwa.ManifestStorage
import mozilla.components.feature.pwa.ext.getWebAppManifest
import mozilla.components.feature.pwa.ext.putUrlOverride
import mozilla.components.feature.pwa.intent.WebAppIntentProcessor.Companion.ACTION_VIEW_PWA
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 org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
......@@ -26,6 +30,7 @@ import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.`when`
import org.mockito.Mockito.verify
@RunWith(AndroidJUnit4::class)
@ExperimentalCoroutinesApi
......@@ -87,4 +92,26 @@ class WebAppIntentProcessorTest {
assertNotNull(sessionState.config)
assertEquals(ExternalAppType.PROGRESSIVE_WEB_APP, sessionState.config.externalAppType)
}
@Test
fun `url override is applied to session if present`() = runBlockingTest {
val storage: ManifestStorage = mock()
val loadUrlUseCase: SessionUseCases.DefaultLoadUrlUseCase = mock()
val processor = WebAppIntentProcessor(mock(), loadUrlUseCase, storage)
val urlOverride = "https://mozilla.com/deep/link/index.html"
val manifest = WebAppManifest(
name = "Test Manifest",
startUrl = "https://mozilla.com"
)
`when`(storage.loadManifest("https://mozilla.com")).thenReturn(manifest)
val intent = Intent(ACTION_VIEW_PWA, "https://mozilla.com".toUri())
intent.putUrlOverride(urlOverride)
assertTrue(processor.process(intent))
verify(loadUrlUseCase).invoke(eq(urlOverride), any(), any(), any())
}
}
......@@ -53,6 +53,7 @@ import mozilla.components.feature.intent.processing.TabIntentProcessor
import mozilla.components.feature.media.RecordingDevicesNotificationFeature
import mozilla.components.feature.media.middleware.MediaMiddleware
import mozilla.components.feature.pwa.ManifestStorage
import mozilla.components.feature.pwa.WebAppInterceptor
import mozilla.components.feature.pwa.WebAppShortcutManager
import mozilla.components.feature.pwa.WebAppUseCases
import mozilla.components.feature.pwa.intent.TrustedWebActivityIntentProcessor
......@@ -209,6 +210,13 @@ open class DefaultComponents(private val applicationContext: Context) {
)
}
val webAppInterceptor by lazy {
WebAppInterceptor(
applicationContext,
webAppManifestStorage
)
}
val webAppManifestStorage by lazy { ManifestStorage(applicationContext) }
val webAppShortcutManager by lazy { WebAppShortcutManager(applicationContext, client, webAppManifestStorage) }
val webAppUseCases by lazy { WebAppUseCases(applicationContext, sessionManager, webAppShortcutManager) }
......
......@@ -5,6 +5,9 @@
package org.mozilla.samples.browser
import android.app.Application
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import mozilla.appservices.Megazord
import mozilla.components.browser.session.Session
import mozilla.components.concept.fetch.Client
......@@ -51,6 +54,10 @@ class SampleApplication : Application() {
components.engine.warmUp()
GlobalScope.launch(Dispatchers.IO) {
components.webAppManifestStorage.warmUpScopes(System.currentTimeMillis())
}
try {
GlobalAddonDependencyProvider.initialize(
components.addonManager,
......
......@@ -27,8 +27,17 @@ class SampleRequestInterceptor(val context: Context) : RequestInterceptor {
): InterceptionResponse? {
return when (uri) {
"sample:about" -> InterceptionResponse.Content("<h1>I am the sample browser</h1>")
else -> context.components.appLinksInterceptor.onLoadRequest(
engineSession, uri, hasUserGesture, isSameDomain, isRedirect, isDirectNavigation)
else -> {
var response = context.components.appLinksInterceptor.onLoadRequest(
engineSession, uri, hasUserGesture, isSameDomain, isRedirect, isDirectNavigation)
if (response == null && !isDirectNavigation) {
response = context.components.webAppInterceptor.onLoadRequest(
engineSession, uri, hasUserGesture, isSameDomain, isRedirect, isDirectNavigation)
}
response
}
}
}
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment