Skip to content

Commit 1df4738

Browse files
MozLandoTitanNano
andcommitted
7367: Provide request interceptor to automatically navigate into PWAs r=NotWoods a=TitanNano For mozilla-mobile#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: Jovan Gerodetti <[email protected]>
2 parents 6eedf87 + 2f99e0a commit 1df4738

File tree

13 files changed

+390
-7
lines changed

13 files changed

+390
-7
lines changed

components/browser/engine-system/src/main/java/mozilla/components/browser/engine/system/SystemEngineView.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,8 @@ class SystemEngineView @JvmOverloads constructor(
269269

270270
super.shouldInterceptRequest(view, request)
271271
}
272+
273+
is InterceptionResponse.Deny -> super.shouldInterceptRequest(view, request)
272274
}
273275
}
274276
}

components/concept/engine/src/main/java/mozilla/components/concept/engine/request/RequestInterceptor.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ interface RequestInterceptor {
2626
data class Url(val url: String) : InterceptionResponse()
2727

2828
data class AppIntent(val appIntent: Intent, val url: String) : InterceptionResponse()
29+
30+
/**
31+
* Deny request without further action.
32+
*/
33+
object Deny : InterceptionResponse()
2934
}
3035

3136
/**

components/feature/pwa/src/main/java/mozilla/components/feature/pwa/ManifestStorage.kt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@ import mozilla.components.feature.pwa.db.ManifestEntity
1919
* @param activeThresholdMs a timeout in milliseconds after which the storage will consider a manifest
2020
* as unused. By default this is [ACTIVE_THRESHOLD_MS].
2121
*/
22+
@Suppress("TooManyFunctions")
2223
class ManifestStorage(context: Context, private val activeThresholdMs: Long = ACTIVE_THRESHOLD_MS) {
2324

2425
@VisibleForTesting
2526
internal var manifestDao = lazy { ManifestDatabase.get(context).manifestDao() }
27+
internal var installedScopes: MutableMap<String, String>? = null
2628

2729
/**
2830
* Load a Web App Manifest for the given URL from disk.
@@ -70,6 +72,34 @@ class ManifestStorage(context: Context, private val activeThresholdMs: Long = AC
7072
manifestDao.value.recentManifestsCount(thresholdMs = currentTimeMs - activeThresholdMs)
7173
}
7274

75+
/**
76+
* Returns the cached scope for an url if the url falls into a web app scope that has been installed by the user.
77+
*
78+
* @param url the url to match against installed web app scopes.
79+
*/
80+
fun getInstalledScope(url: String) = installedScopes?.keys?.sortedDescending()?.find { url.startsWith(it) }
81+
82+
/**
83+
* Returns a cached start url for an installed web app scope.
84+
*
85+
* @param scope the scope url to look up.
86+
*/
87+
fun getStartUrlForInstalledScope(scope: String) = installedScopes?.get(scope)
88+
89+
/**
90+
* Populates a cache of currently installed web app scopes and their start urls.
91+
*
92+
* @param currentTime the current time is used to determine which web apps are still installed.
93+
*/
94+
suspend fun warmUpScopes(currentTime: Long) = withContext(IO) {
95+
installedScopes = manifestDao.value
96+
.getInstalledScopes(currentTime - activeThresholdMs)
97+
.map { manifest -> manifest.scope?.let { scope -> Pair(scope, manifest.startUrl) } }
98+
.filterNotNull()
99+
.toMap()
100+
.toMutableMap()
101+
}
102+
73103
/**
74104
* Save a Web App Manifest to disk.
75105
*/
@@ -101,6 +131,12 @@ class ManifestStorage(context: Context, private val activeThresholdMs: Long = AC
101131
manifestDao.value.getManifest(manifest.startUrl)?.let { existing ->
102132
val update = existing.copy(usedAt = System.currentTimeMillis())
103133
manifestDao.value.updateManifest(update)
134+
135+
existing.scope?.let { scope ->
136+
installedScopes?.put(scope, existing.startUrl)
137+
}
138+
139+
return@let
104140
}
105141
}
106142

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
package mozilla.components.feature.pwa
6+
7+
import android.content.Context
8+
import android.content.Intent
9+
import android.net.Uri
10+
import mozilla.components.concept.engine.EngineSession
11+
import mozilla.components.concept.engine.request.RequestInterceptor
12+
import mozilla.components.feature.pwa.ext.putUrlOverride
13+
import mozilla.components.feature.pwa.intent.WebAppIntentProcessor
14+
15+
/**
16+
* This feature will intercept requests and reopen them in the corresponding installed PWA, if any.
17+
*
18+
* @param shortcutManager current shortcut manager instance to lookup web app install states
19+
*/
20+
class WebAppInterceptor(
21+
private val context: Context,
22+
private val manifestStorage: ManifestStorage,
23+
private val launchFromInterceptor: Boolean = true
24+
) : RequestInterceptor {
25+
26+
@Suppress("ReturnCount")
27+
override fun onLoadRequest(
28+
engineSession: EngineSession,
29+
uri: String,
30+
hasUserGesture: Boolean,
31+
isSameDomain: Boolean,
32+
isRedirect: Boolean,
33+
isDirectNavigation: Boolean
34+
): RequestInterceptor.InterceptionResponse? {
35+
val scope = manifestStorage.getInstalledScope(uri) ?: return null
36+
val startUrl = manifestStorage.getStartUrlForInstalledScope(scope) ?: return null
37+
val intent = createIntentFromUri(startUrl, uri)
38+
39+
if (!launchFromInterceptor) {
40+
return RequestInterceptor.InterceptionResponse.AppIntent(intent, uri)
41+
}
42+
43+
intent.flags = intent.flags or Intent.FLAG_ACTIVITY_NEW_TASK
44+
context.startActivity(intent)
45+
46+
return RequestInterceptor.InterceptionResponse.Deny
47+
}
48+
49+
/**
50+
* Creates a new VIEW_PWA intent for a URL.
51+
*
52+
* @param uri target URL for the new intent
53+
*/
54+
private fun createIntentFromUri(startUrl: String, urlOverride: String = startUrl): Intent {
55+
return Intent(WebAppIntentProcessor.ACTION_VIEW_PWA, Uri.parse(startUrl)).apply {
56+
this.addCategory(Intent.CATEGORY_DEFAULT)
57+
this.putUrlOverride(urlOverride)
58+
}
59+
}
60+
}

components/feature/pwa/src/main/java/mozilla/components/feature/pwa/db/ManifestDao.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,8 @@ internal interface ManifestDao {
4343
@WorkerThread
4444
@Query("DELETE FROM manifests WHERE start_url IN (:startUrls)")
4545
fun deleteManifests(startUrls: List<String>)
46+
47+
@WorkerThread
48+
@Query("SELECT * from manifests WHERE used_at > :expiresAt ORDER BY LENGTH(scope)")
49+
fun getInstalledScopes(expiresAt: Long): List<ManifestEntity>
4650
}

components/feature/pwa/src/main/java/mozilla/components/feature/pwa/ext/Intent.kt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import android.content.Intent
88
import mozilla.components.concept.engine.manifest.WebAppManifest
99
import mozilla.components.concept.engine.manifest.WebAppManifestParser
1010

11+
internal const val EXTRA_URL_OVERRIDE = "mozilla.components.feature.pwa.EXTRA_URL_OVERRIDE"
12+
1113
/**
1214
* Add extended [WebAppManifest] data to the intent.
1315
*/
@@ -22,3 +24,25 @@ fun Intent.putWebAppManifest(webAppManifest: WebAppManifest) {
2224
fun Intent.getWebAppManifest(): WebAppManifest? {
2325
return extras?.getWebAppManifest()
2426
}
27+
28+
/**
29+
* Add [String] URL override to the intent.
30+
*
31+
* @param url The URL override value.
32+
*
33+
* @return Returns the same Intent object, for chaining multiple calls
34+
* into a single statement.
35+
*
36+
* @see [getUrlOverride]
37+
*/
38+
fun Intent.putUrlOverride(url: String?): Intent {
39+
return putExtra(EXTRA_URL_OVERRIDE, url)
40+
}
41+
42+
/**
43+
* Retrieves [String] Url override from the intent.
44+
*
45+
* @return The URL override previously added with [putUrlOverride],
46+
* or null if no URL was found.
47+
*/
48+
fun Intent.getUrlOverride(): String? = getStringExtra(EXTRA_URL_OVERRIDE)

components/feature/pwa/src/main/java/mozilla/components/feature/pwa/intent/WebAppIntentProcessor.kt

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import kotlinx.coroutines.runBlocking
1010
import mozilla.components.browser.session.Session
1111
import mozilla.components.browser.session.Session.Source
1212
import mozilla.components.browser.session.SessionManager
13+
import mozilla.components.browser.state.state.ExternalAppType
1314
import mozilla.components.concept.engine.EngineSession
15+
import mozilla.components.concept.engine.manifest.WebAppManifest
16+
import mozilla.components.feature.pwa.ext.getUrlOverride
1417
import mozilla.components.feature.intent.ext.putSessionId
1518
import mozilla.components.feature.intent.processing.IntentProcessor
1619
import mozilla.components.feature.pwa.ManifestStorage
@@ -45,13 +48,14 @@ class WebAppIntentProcessor(
4548

4649
return if (!url.isNullOrEmpty() && matches(intent)) {
4750
val webAppManifest = runBlocking { storage.loadManifest(url) } ?: return false
51+
val targetUrl = intent.getUrlOverride() ?: url
4852

49-
val session = Session(url, private = false, source = Source.HOME_SCREEN)
50-
session.webAppManifest = webAppManifest
51-
session.customTabConfig = webAppManifest.toCustomTabConfig()
53+
val session = findExistingSession(webAppManifest) ?: createSession(webAppManifest, url)
54+
55+
if (targetUrl !== url) {
56+
loadUrlUseCase(targetUrl, session, EngineSession.LoadUrlFlags.external())
57+
}
5258

53-
sessionManager.add(session)
54-
loadUrlUseCase(url, session, EngineSession.LoadUrlFlags.external())
5559
intent.flags = FLAG_ACTIVITY_NEW_DOCUMENT
5660
intent.putSessionId(session.id)
5761
intent.putWebAppManifest(webAppManifest)
@@ -62,6 +66,30 @@ class WebAppIntentProcessor(
6266
}
6367
}
6468

69+
/**
70+
* Returns an existing web app session that matches the manifest.
71+
*/
72+
private fun findExistingSession(webAppManifest: WebAppManifest): Session? {
73+
return sessionManager.all.find {
74+
it.customTabConfig?.externalAppType == ExternalAppType.PROGRESSIVE_WEB_APP &&
75+
it.webAppManifest?.startUrl == webAppManifest.startUrl
76+
}
77+
}
78+
79+
/**
80+
* Returns a new web app session.
81+
*/
82+
private fun createSession(webAppManifest: WebAppManifest, url: String): Session {
83+
return Session(url, private = false, source = Source.HOME_SCREEN)
84+
.apply {
85+
this.webAppManifest = webAppManifest
86+
this.customTabConfig = webAppManifest.toCustomTabConfig()
87+
}.also {
88+
sessionManager.add(it)
89+
loadUrlUseCase(url, it, EngineSession.LoadUrlFlags.external())
90+
}
91+
}
92+
6593
companion object {
6694
const val ACTION_VIEW_PWA = "mozilla.components.feature.pwa.VIEW_PWA"
6795
}

components/feature/pwa/src/test/java/mozilla/components/feature/pwa/ManifestStorageTest.kt

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,18 @@ class ManifestStorageTest {
3737
scope = "/"
3838
)
3939

40+
private val googleMapsManifest = WebAppManifest(
41+
name = "Google Maps",
42+
startUrl = "https://google.com/maps",
43+
scope = "https://google.com/maps/"
44+
)
45+
46+
private val exampleWebAppManifest = WebAppManifest(
47+
name = "Example Web App",
48+
startUrl = "https://pwa.example.com/dashboard",
49+
scope = "https://pwa.example.com/"
50+
)
51+
4052
@Test
4153
fun `load returns null if entry does not exist`() = runBlocking {
4254
val storage = spy(ManifestStorage(testContext))
@@ -202,6 +214,65 @@ class ManifestStorageTest {
202214
))
203215
}
204216

217+
@Test
218+
fun `warmUpScopes populates cache of already installed web app scopes`() = runBlocking {
219+
val storage = spy(ManifestStorage(testContext))
220+
val dao = mockDatabase(storage)
221+
222+
val manifest1 = ManifestEntity(manifest = firefoxManifest, createdAt = 0, updatedAt = 0)
223+
val manifest2 = ManifestEntity(manifest = googleMapsManifest, createdAt = 0, updatedAt = 0)
224+
val manifest3 = ManifestEntity(manifest = exampleWebAppManifest, createdAt = 0, updatedAt = 0)
225+
226+
whenever(dao.getInstalledScopes(0)).thenReturn(listOf(manifest1, manifest2, manifest3))
227+
228+
storage.warmUpScopes(ManifestStorage.ACTIVE_THRESHOLD_MS)
229+
230+
assertEquals(
231+
mapOf(
232+
Pair("/", "https://firefox.com"),
233+
Pair("https://google.com/maps/", "https://google.com/maps"),
234+
Pair("https://pwa.example.com/", "https://pwa.example.com/dashboard")
235+
),
236+
storage.installedScopes
237+
)
238+
}
239+
240+
@Test
241+
fun `getInstalledScope returns cached scope for an url`() = runBlocking {
242+
val storage = spy(ManifestStorage(testContext))
243+
val dao = mockDatabase(storage)
244+
245+
val manifest1 = ManifestEntity(manifest = firefoxManifest, createdAt = 0, updatedAt = 0)
246+
val manifest2 = ManifestEntity(manifest = googleMapsManifest, createdAt = 0, updatedAt = 0)
247+
val manifest3 = ManifestEntity(manifest = exampleWebAppManifest, createdAt = 0, updatedAt = 0)
248+
249+
whenever(dao.getInstalledScopes(0)).thenReturn(listOf(manifest1, manifest2, manifest3))
250+
251+
storage.warmUpScopes(ManifestStorage.ACTIVE_THRESHOLD_MS)
252+
253+
val result = storage.getInstalledScope("https://pwa.example.com/profile/me")
254+
255+
assertEquals("https://pwa.example.com/", result)
256+
}
257+
258+
@Test
259+
fun `getStartUrlForInstalledScope returns cached start url for a currently installed scope`() = runBlocking {
260+
val storage = spy(ManifestStorage(testContext))
261+
val dao = mockDatabase(storage)
262+
263+
val manifest1 = ManifestEntity(manifest = firefoxManifest, createdAt = 0, updatedAt = 0)
264+
val manifest2 = ManifestEntity(manifest = googleMapsManifest, createdAt = 0, updatedAt = 0)
265+
val manifest3 = ManifestEntity(manifest = exampleWebAppManifest, createdAt = 0, updatedAt = 0)
266+
267+
whenever(dao.getInstalledScopes(0)).thenReturn(listOf(manifest1, manifest2, manifest3))
268+
269+
storage.warmUpScopes(ManifestStorage.ACTIVE_THRESHOLD_MS)
270+
271+
val result = storage.getStartUrlForInstalledScope("https://pwa.example.com/")
272+
273+
assertEquals("https://pwa.example.com/dashboard", result)
274+
}
275+
205276
private fun mockDatabase(storage: ManifestStorage): ManifestDao = mock<ManifestDao>().also {
206277
storage.manifestDao = lazy { it }
207278
}

0 commit comments

Comments
 (0)