Skip to content
This repository was archived by the owner on Nov 1, 2022. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,8 @@ class SystemEngineView @JvmOverloads constructor(

super.shouldInterceptRequest(view, request)
}

is InterceptionResponse.Deny -> super.shouldInterceptRequest(view, request)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/* 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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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>
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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 }
}
Expand Down
Loading