From 36b33033a74bf5049624992472ec450086208ebd Mon Sep 17 00:00:00 2001 From: Tiger Oakes Date: Tue, 16 Jul 2019 12:40:21 -0400 Subject: [PATCH] Closes #2293 - Open custom tabs when leaving scope Refactors PWA code so that it uses a BrowserFragment subclass. --- .../engine/manifest/WebAppManifestParser.kt | 10 ++ .../manifest/WebAppManifestParserTest.kt | 9 ++ components/feature/pwa/build.gradle | 3 + .../pwa/AbstractWebAppShellActivity.kt | 87 --------------- .../feature/pwa/WebAppLauncherActivity.kt | 9 +- .../feature/pwa/WebAppShortcutManager.kt | 10 +- .../components/feature/pwa/ext/Activity.kt | 3 +- .../components/feature/pwa/ext/Bundle.kt | 31 ++++++ .../components/feature/pwa/ext/Intent.kt | 24 ++++ .../feature/pwa/ext/WebAppManifest.kt | 14 ++- .../pwa/feature/WebAppActivityFeature.kt | 57 ++++++++++ .../pwa/feature/WebAppHideToolbarFeature.kt | 68 ++++++++++++ .../pwa/intent/WebAppIntentProcessor.kt | 53 +++++++++ .../pwa/AbstractWebAppShellActivityTest.kt | 94 ---------------- .../feature/pwa/WebAppLauncherActivityTest.kt | 6 +- .../feature/pwa/WebAppShortcutManagerTest.kt | 3 +- .../feature/pwa/ext/ActivityKtTest.kt | 6 + .../feature/pwa/ext/WebAppManifestKtTest.kt | 4 +- .../pwa/feature/WebAppActivityFeatureTest.kt | 89 +++++++++++++++ .../feature/WebAppHideToolbarFeatureTest.kt | 104 ++++++++++++++++++ .../pwa/intent/WebAppIntentProcessorTest.kt | 74 +++++++++++++ samples/browser/build.gradle | 1 + samples/browser/src/main/AndroidManifest.xml | 8 +- .../samples/browser/BaseBrowserFragment.kt | 5 + .../samples/browser/BrowserFragment.kt | 3 + .../samples/browser/CustomTabActivity.kt | 12 -- .../browser/CustomTabBrowserFragment.kt | 51 --------- .../samples/browser/DefaultComponents.kt | 5 + .../browser/ExternalAppBrowserActivity.kt | 30 +++++ .../browser/ExternalAppBrowserFragment.kt | 97 ++++++++++++++++ .../samples/browser/IntentReceiverActivity.kt | 25 ++++- .../samples/browser/WebAppBrowserFragment.kt | 51 --------- 32 files changed, 727 insertions(+), 319 deletions(-) delete mode 100644 components/feature/pwa/src/main/java/mozilla/components/feature/pwa/AbstractWebAppShellActivity.kt create mode 100644 components/feature/pwa/src/main/java/mozilla/components/feature/pwa/ext/Bundle.kt create mode 100644 components/feature/pwa/src/main/java/mozilla/components/feature/pwa/ext/Intent.kt create mode 100644 components/feature/pwa/src/main/java/mozilla/components/feature/pwa/feature/WebAppActivityFeature.kt create mode 100644 components/feature/pwa/src/main/java/mozilla/components/feature/pwa/feature/WebAppHideToolbarFeature.kt create mode 100644 components/feature/pwa/src/main/java/mozilla/components/feature/pwa/intent/WebAppIntentProcessor.kt delete mode 100644 components/feature/pwa/src/test/java/mozilla/components/feature/pwa/AbstractWebAppShellActivityTest.kt create mode 100644 components/feature/pwa/src/test/java/mozilla/components/feature/pwa/feature/WebAppActivityFeatureTest.kt create mode 100644 components/feature/pwa/src/test/java/mozilla/components/feature/pwa/feature/WebAppHideToolbarFeatureTest.kt create mode 100644 components/feature/pwa/src/test/java/mozilla/components/feature/pwa/intent/WebAppIntentProcessorTest.kt delete mode 100644 samples/browser/src/main/java/org/mozilla/samples/browser/CustomTabActivity.kt delete mode 100644 samples/browser/src/main/java/org/mozilla/samples/browser/CustomTabBrowserFragment.kt create mode 100644 samples/browser/src/main/java/org/mozilla/samples/browser/ExternalAppBrowserActivity.kt create mode 100644 samples/browser/src/main/java/org/mozilla/samples/browser/ExternalAppBrowserFragment.kt delete mode 100644 samples/browser/src/main/java/org/mozilla/samples/browser/WebAppBrowserFragment.kt diff --git a/components/concept/engine/src/main/java/mozilla/components/concept/engine/manifest/WebAppManifestParser.kt b/components/concept/engine/src/main/java/mozilla/components/concept/engine/manifest/WebAppManifestParser.kt index a3f33c43e5d..9068bbf7800 100644 --- a/components/concept/engine/src/main/java/mozilla/components/concept/engine/manifest/WebAppManifestParser.kt +++ b/components/concept/engine/src/main/java/mozilla/components/concept/engine/manifest/WebAppManifestParser.kt @@ -2,6 +2,8 @@ * 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/. */ +@file:Suppress("TooManyFunctions") + package mozilla.components.concept.engine.manifest import android.graphics.Color @@ -95,6 +97,14 @@ class WebAppManifestParser { } } +/** + * Returns the encapsulated value if this instance represents success or `null` if it is failure. + */ +fun WebAppManifestParser.Result.getOrNull(): WebAppManifest? = when (this) { + is WebAppManifestParser.Result.Success -> manifest + is WebAppManifestParser.Result.Failure -> null +} + private fun parseDisplayMode(json: JSONObject): WebAppManifest.DisplayMode { return when (json.optString("display")) { "standalone" -> WebAppManifest.DisplayMode.STANDALONE diff --git a/components/concept/engine/src/test/java/mozilla/components/concept/engine/manifest/WebAppManifestParserTest.kt b/components/concept/engine/src/test/java/mozilla/components/concept/engine/manifest/WebAppManifestParserTest.kt index b4dd1590a04..d88d3a0351f 100644 --- a/components/concept/engine/src/test/java/mozilla/components/concept/engine/manifest/WebAppManifestParserTest.kt +++ b/components/concept/engine/src/test/java/mozilla/components/concept/engine/manifest/WebAppManifestParserTest.kt @@ -19,6 +19,15 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class WebAppManifestParserTest { + @Test + fun `getOrNull returns parsed manifest`() { + val sucessfulResult = WebAppManifestParser().parse(loadManifest("example_mdn.json")) + assertNotNull(sucessfulResult.getOrNull()) + + val failedResult = WebAppManifestParser().parse(loadManifest("invalid_json.json")) + assertNull(failedResult.getOrNull()) + } + @Test fun `Parsing example manifest from MDN`() { val json = loadManifest("example_mdn.json") diff --git a/components/feature/pwa/build.gradle b/components/feature/pwa/build.gradle index 7f1021b0c84..bc9f0332ea6 100644 --- a/components/feature/pwa/build.gradle +++ b/components/feature/pwa/build.gradle @@ -33,9 +33,12 @@ dependencies { implementation project(':browser-session') implementation project(':concept-engine') implementation project(':concept-fetch') + implementation project(':feature-session') implementation project(':support-base') implementation project(':support-ktx') + implementation project(':support-utils') + implementation Dependencies.androidx_browser implementation Dependencies.androidx_core_ktx implementation Dependencies.kotlin_stdlib implementation Dependencies.kotlin_coroutines diff --git a/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/AbstractWebAppShellActivity.kt b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/AbstractWebAppShellActivity.kt deleted file mode 100644 index 1f69be2d1d2..00000000000 --- a/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/AbstractWebAppShellActivity.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* 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.os.Bundle -import androidx.annotation.VisibleForTesting -import androidx.appcompat.app.AppCompatActivity -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import mozilla.components.browser.session.Session -import mozilla.components.browser.session.SessionManager -import mozilla.components.concept.engine.Engine -import mozilla.components.concept.engine.EngineView -import mozilla.components.concept.engine.manifest.WebAppManifest -import mozilla.components.feature.pwa.ext.applyOrientation -import mozilla.components.feature.pwa.ext.asTaskDescription -import mozilla.components.support.ktx.android.view.enterToImmersiveMode -import mozilla.components.support.ktx.android.view.setNavigationBarTheme -import mozilla.components.support.ktx.android.view.setStatusBarTheme - -/** - * Activity for "standalone" and "fullscreen" web applications. - */ -abstract class AbstractWebAppShellActivity : AppCompatActivity(), CoroutineScope by MainScope() { - abstract val engine: Engine - abstract val sessionManager: SessionManager - - lateinit var session: Session - lateinit var engineView: EngineView - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val storage = ManifestStorage(this) - - val startUrl = intent.data?.toString() ?: return finish() - launch { - val manifest = storage.loadManifest(startUrl) - - applyConfiguration(manifest) - renderSession(startUrl) - - if (manifest != null) { - setTaskDescription(manifest.asTaskDescription(null)) - } - } - } - - override fun onDestroy() { - super.onDestroy() - - coroutineContext.cancel() - sessionManager - .getOrCreateEngineSession(session) - .close() - } - - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal fun applyConfiguration(manifest: WebAppManifest?) { - if (manifest?.display == WebAppManifest.DisplayMode.FULLSCREEN) { - enterToImmersiveMode() - } - - applyOrientation(manifest) - - manifest?.themeColor?.let { window.setStatusBarTheme(it) } - manifest?.backgroundColor?.let { window.setNavigationBarTheme(it) } - } - - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal fun renderSession(startUrl: String) { - setContentView(engine - .createView(this) - .also { engineView = it } - .asView()) - - session = Session(startUrl) - engineView.render(sessionManager.getOrCreateEngineSession(session)) - } - - companion object { - const val INTENT_ACTION = "mozilla.components.feature.pwa.SHELL" - } -} diff --git a/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/WebAppLauncherActivity.kt b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/WebAppLauncherActivity.kt index 43bce80cb64..29ba2caa34e 100644 --- a/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/WebAppLauncherActivity.kt +++ b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/WebAppLauncherActivity.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import mozilla.components.concept.engine.manifest.WebAppManifest +import mozilla.components.feature.pwa.intent.WebAppIntentProcessor.Companion.ACTION_VIEW_PWA import mozilla.components.support.base.log.logger.Logger /** @@ -76,16 +77,14 @@ class WebAppLauncherActivity : AppCompatActivity(), CoroutineScope by MainScope( @VisibleForTesting(otherwise = PRIVATE) internal fun launchWebAppShell(startUrl: Uri) { - val intent = Intent(AbstractWebAppShellActivity.INTENT_ACTION).apply { - data = startUrl - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK + val intent = Intent(ACTION_VIEW_PWA, startUrl).apply { `package` = packageName } try { startActivity(intent) } catch (e: ActivityNotFoundException) { - logger.error("Packages does not handle AbstractWebAppShellActivity intent. Can't launch web app.", e) + logger.error("Packages does not handle ACTION_VIEW_PWA intent. Can't launch as web app.", e) // Fall back to normal browser launchBrowser(startUrl) } @@ -97,6 +96,6 @@ class WebAppLauncherActivity : AppCompatActivity(), CoroutineScope by MainScope( } companion object { - const val INTENT_ACTION = "mozilla.components.feature.pwa.PWA_LAUNCHER" + internal const val ACTION_PWA_LAUNCHER = "mozilla.components.feature.pwa.PWA_LAUNCHER" } } diff --git a/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/WebAppShortcutManager.kt b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/WebAppShortcutManager.kt index 03d7636b4ea..222deaf92fe 100644 --- a/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/WebAppShortcutManager.kt +++ b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/WebAppShortcutManager.kt @@ -37,6 +37,7 @@ import mozilla.components.browser.icons.utils.IconMemoryCache import mozilla.components.browser.session.Session import mozilla.components.concept.engine.manifest.WebAppManifest import mozilla.components.concept.fetch.Client +import mozilla.components.feature.pwa.WebAppLauncherActivity.Companion.ACTION_PWA_LAUNCHER import mozilla.components.feature.pwa.ext.installableManifest private val pwaIconMemoryCache = IconMemoryCache() @@ -93,9 +94,8 @@ class WebAppShortcutManager( * Create a new basic pinned website shortcut using info from the session. */ fun buildBasicShortcut(context: Context, session: Session): ShortcutInfoCompat? { - val shortcutIntent = Intent(context, WebAppLauncherActivity::class.java).apply { - action = WebAppLauncherActivity.INTENT_ACTION - data = session.url.toUri() + val shortcutIntent = Intent(Intent.ACTION_VIEW, session.url.toUri()).apply { + `package` = context.packageName } val builder = ShortcutInfoCompat.Builder(context, session.url) @@ -114,8 +114,10 @@ class WebAppShortcutManager( */ suspend fun buildWebAppShortcut(context: Context, manifest: WebAppManifest): ShortcutInfoCompat? { val shortcutIntent = Intent(context, WebAppLauncherActivity::class.java).apply { - action = WebAppLauncherActivity.INTENT_ACTION + action = ACTION_PWA_LAUNCHER data = manifest.startUrl.toUri() + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK + `package` = context.packageName } val shortLabel = manifest.shortName ?: manifest.name diff --git a/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/ext/Activity.kt b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/ext/Activity.kt index c1088d8b9e3..a64d4c4c868 100644 --- a/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/ext/Activity.kt +++ b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/ext/Activity.kt @@ -15,13 +15,12 @@ import mozilla.components.concept.engine.manifest.WebAppManifest.Orientation */ fun Activity.applyOrientation(manifest: WebAppManifest?) { requestedOrientation = when (manifest?.orientation) { - Orientation.NATURAL, Orientation.ANY -> ActivityInfo.SCREEN_ORIENTATION_USER + Orientation.NATURAL, Orientation.ANY, null -> ActivityInfo.SCREEN_ORIENTATION_USER Orientation.LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE Orientation.LANDSCAPE_PRIMARY -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE Orientation.LANDSCAPE_SECONDARY -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE Orientation.PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT Orientation.PORTRAIT_PRIMARY -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT Orientation.PORTRAIT_SECONDARY -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT - else -> ActivityInfo.SCREEN_ORIENTATION_USER } } diff --git a/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/ext/Bundle.kt b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/ext/Bundle.kt new file mode 100644 index 00000000000..44670d4b10c --- /dev/null +++ b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/ext/Bundle.kt @@ -0,0 +1,31 @@ +/* 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.ext + +import android.os.Bundle +import mozilla.components.concept.engine.manifest.WebAppManifest +import mozilla.components.concept.engine.manifest.WebAppManifestParser +import mozilla.components.concept.engine.manifest.getOrNull + +internal const val EXTRA_WEB_APP_MANIFEST = "mozilla.components.feature.pwa.EXTRA_WEB_APP_MANIFEST" + +/** + * Serializes and inserts a [WebAppManifest] value into the mapping of this [Bundle], + * replacing any existing web app manifest. + */ +fun Bundle.putWebAppManifest(webAppManifest: WebAppManifest?) { + val json = webAppManifest?.let { WebAppManifestParser().serialize(it).toString() } + putString(EXTRA_WEB_APP_MANIFEST, json) +} + +/** + * Parses and returns the [WebAppManifest] associated with this [Bundle], + * or null if no mapping of the desired type exists. + */ +fun Bundle.getWebAppManifest(): WebAppManifest? { + return getString(EXTRA_WEB_APP_MANIFEST)?.let { json -> + WebAppManifestParser().parse(json).getOrNull() + } +} diff --git a/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/ext/Intent.kt b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/ext/Intent.kt new file mode 100644 index 00000000000..0432596d863 --- /dev/null +++ b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/ext/Intent.kt @@ -0,0 +1,24 @@ +/* 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.ext + +import android.content.Intent +import mozilla.components.concept.engine.manifest.WebAppManifest +import mozilla.components.concept.engine.manifest.WebAppManifestParser + +/** + * Add extended [WebAppManifest] data to the intent. + */ +fun Intent.putWebAppManifest(webAppManifest: WebAppManifest) { + val json = WebAppManifestParser().serialize(webAppManifest) + putExtra(EXTRA_WEB_APP_MANIFEST, json.toString()) +} + +/** + * Retrieve extended [WebAppManifest] data from the intent. + */ +fun Intent.getWebAppManifest(): WebAppManifest? { + return extras?.getWebAppManifest() +} diff --git a/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/ext/WebAppManifest.kt b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/ext/WebAppManifest.kt index c9a90e3066c..ef41ea8245d 100644 --- a/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/ext/WebAppManifest.kt +++ b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/ext/WebAppManifest.kt @@ -6,6 +6,7 @@ package mozilla.components.feature.pwa.ext import android.app.ActivityManager.TaskDescription import android.graphics.Bitmap +import mozilla.components.browser.session.tab.CustomTabConfig import mozilla.components.concept.engine.manifest.WebAppManifest /** @@ -15,5 +16,16 @@ import mozilla.components.concept.engine.manifest.WebAppManifest * Instead we use the deprecated constructor. */ @Suppress("Deprecation") -fun WebAppManifest.asTaskDescription(icon: Bitmap?) = +fun WebAppManifest.toTaskDescription(icon: Bitmap?) = TaskDescription(name, icon, themeColor ?: 0) + +fun WebAppManifest.toCustomTabConfig() = + CustomTabConfig( + id = startUrl, + toolbarColor = themeColor, + closeButtonIcon = null, + enableUrlbarHiding = true, + actionButtonConfig = null, + showShareMenuItem = true, + menuItems = emptyList() + ) diff --git a/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/feature/WebAppActivityFeature.kt b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/feature/WebAppActivityFeature.kt new file mode 100644 index 00000000000..6ba887c6c97 --- /dev/null +++ b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/feature/WebAppActivityFeature.kt @@ -0,0 +1,57 @@ +/* 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.feature + +import android.app.Activity +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.OnLifecycleEvent +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import mozilla.components.browser.icons.BrowserIcons +import mozilla.components.browser.icons.extension.toIconRequest +import mozilla.components.concept.engine.manifest.WebAppManifest +import mozilla.components.feature.pwa.ext.applyOrientation +import mozilla.components.feature.pwa.ext.toTaskDescription +import mozilla.components.support.ktx.android.view.enterToImmersiveMode + +/** + * Feature used to handle window effects for "standalone" and "fullscreen" web apps. + */ +class WebAppActivityFeature( + private val activity: Activity, + private val icons: BrowserIcons, + private val manifest: WebAppManifest +) : LifecycleObserver { + + private val scope = MainScope() + + @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) + fun onResume() { + if (manifest.display == WebAppManifest.DisplayMode.FULLSCREEN) { + activity.enterToImmersiveMode() + } + + activity.applyOrientation(manifest) + + scope.launch { + updateRecentEntry() + } + } + + @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) + fun onDestroy() { + scope.cancel() + } + + @VisibleForTesting + internal suspend fun updateRecentEntry() { + val icon = icons.loadIcon(manifest.toIconRequest()).await() + + activity.setTaskDescription(manifest.toTaskDescription(icon.bitmap)) + } +} diff --git a/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/feature/WebAppHideToolbarFeature.kt b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/feature/WebAppHideToolbarFeature.kt new file mode 100644 index 00000000000..d8329784301 --- /dev/null +++ b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/feature/WebAppHideToolbarFeature.kt @@ -0,0 +1,68 @@ +/* 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.feature + +import android.net.Uri +import android.view.View +import androidx.core.net.toUri +import androidx.core.view.isGone +import mozilla.components.browser.session.Session +import mozilla.components.browser.session.SessionManager +import mozilla.components.concept.engine.manifest.WebAppManifest +import mozilla.components.support.base.feature.LifecycleAwareFeature + +/** + * Hides a custom tab toolbar for Progressive Web Apps and Trusted Web Activities. + * + * When the [Session] is inside a trusted scope, the toolbar will be hidden. + * Once the [Session] navigates to another scope, the toolbar will be revealed. + * + * @param toolbar Toolbar to show or hide. + * @param sessionId ID of the custom tab session. + * @param trustedScopes Scopes to hide the toolbar at. + * Scopes correspond to [WebAppManifest.scope]. They can be a path (PWA) or just an origin (TWA). + */ +class WebAppHideToolbarFeature( + private val sessionManager: SessionManager, + private val toolbar: View, + private val sessionId: String, + private val trustedScopes: List +) : Session.Observer, LifecycleAwareFeature { + + init { + // Hide the toolbar by default to prevent a flash. + // If we don't trust any scopes don't bother with hiding it. + toolbar.isGone = trustedScopes.isNotEmpty() + } + + override fun onUrlChanged(session: Session, url: String) { + toolbar.isGone = isInScope(url.toUri()) + } + + override fun start() { + if (trustedScopes.isNotEmpty()) { + sessionManager.findSessionById(sessionId)?.register(this) + } + } + + override fun stop() { + sessionManager.findSessionById(sessionId)?.unregister(this) + } + + /** + * Checks that the [target] URL is in scope of the web app. + * + * https://www.w3.org/TR/appmanifest/#dfn-within-scope + */ + private fun isInScope(target: Uri): Boolean { + val path = target.path.orEmpty() + return trustedScopes.any { scope -> + sameOrigin(scope, target) && path.startsWith(scope.path.orEmpty()) + } + } + + private fun sameOrigin(a: Uri, b: Uri) = + a.scheme == b.scheme && a.host == b.host && a.port == b.port +} diff --git a/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/intent/WebAppIntentProcessor.kt b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/intent/WebAppIntentProcessor.kt new file mode 100644 index 00000000000..b395091306e --- /dev/null +++ b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/intent/WebAppIntentProcessor.kt @@ -0,0 +1,53 @@ +/* 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.intent + +import android.content.Intent +import mozilla.components.browser.session.Session +import mozilla.components.browser.session.Session.Source +import mozilla.components.browser.session.SessionManager +import mozilla.components.browser.session.intent.IntentProcessor +import mozilla.components.browser.session.intent.putSessionId +import mozilla.components.concept.engine.EngineSession +import mozilla.components.feature.pwa.ManifestStorage +import mozilla.components.feature.pwa.ext.putWebAppManifest +import mozilla.components.feature.pwa.ext.toCustomTabConfig +import mozilla.components.feature.session.SessionUseCases +import mozilla.components.support.utils.toSafeIntent + +class WebAppIntentProcessor( + private val sessionManager: SessionManager, + private val loadUrlUseCase: SessionUseCases.DefaultLoadUrlUseCase, + private val storage: ManifestStorage +) : IntentProcessor { + + override fun matches(intent: Intent) = + intent.toSafeIntent().action == ACTION_VIEW_PWA + + override suspend fun process(intent: Intent): Boolean { + val url = intent.toSafeIntent().dataString + + return if (!url.isNullOrEmpty() && matches(intent)) { + val webAppManifest = storage.loadManifest(url) ?: return false + + val session = Session(url, private = false, source = Source.HOME_SCREEN) + session.webAppManifest = webAppManifest + session.customTabConfig = webAppManifest.toCustomTabConfig() + + sessionManager.add(session) + loadUrlUseCase(url, session, EngineSession.LoadUrlFlags.external()) + intent.putSessionId(session.id) + intent.putWebAppManifest(webAppManifest) + + true + } else { + false + } + } + + companion object { + const val ACTION_VIEW_PWA = "mozilla.components.feature.pwa.VIEW_PWA" + } +} diff --git a/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/AbstractWebAppShellActivityTest.kt b/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/AbstractWebAppShellActivityTest.kt deleted file mode 100644 index a833a200f43..00000000000 --- a/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/AbstractWebAppShellActivityTest.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* 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.pm.ActivityInfo -import android.view.View -import android.view.Window -import android.view.WindowManager -import androidx.test.ext.junit.runners.AndroidJUnit4 -import mozilla.components.browser.session.SessionManager -import mozilla.components.concept.engine.Engine -import mozilla.components.concept.engine.manifest.WebAppManifest -import mozilla.components.support.test.mock -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.ArgumentMatchers -import org.mockito.Mockito.doReturn -import org.mockito.Mockito.never -import org.mockito.Mockito.spy -import org.mockito.Mockito.verify - -@RunWith(AndroidJUnit4::class) -class AbstractWebAppShellActivityTest { - - @Test - fun `applyConfiguration applies orientation`() { - val activity = spy(TestWebAppShellActivity()) - - val manifest = WebAppManifest( - name = "Test Manifest", - startUrl = "/", - orientation = WebAppManifest.Orientation.LANDSCAPE) - - activity.applyConfiguration(manifest) - - verify(activity).requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE - } - - @Test - fun `applyConfiguration switches to immersive mode (fullscreen display mode)`() { - val decorView: View = mock() - - val window: Window = mock() - doReturn(decorView).`when`(window).decorView - - val activity = spy(TestWebAppShellActivity()) - doReturn(window).`when`(activity).window - - val manifest = WebAppManifest( - name = "Test Manifest", - startUrl = "/", - orientation = WebAppManifest.Orientation.LANDSCAPE, - display = WebAppManifest.DisplayMode.FULLSCREEN) - - activity.applyConfiguration(manifest) - - verify(window).addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - verify(decorView).systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE - or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION - or View.SYSTEM_UI_FLAG_FULLSCREEN - or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY) - } - - @Test - fun `applyConfiguration does NOT switch to immersive mode (standalone display mode)`() { - val decorView: View = mock() - - val window: Window = mock() - doReturn(decorView).`when`(window).decorView - - val activity = spy(TestWebAppShellActivity()) - doReturn(window).`when`(activity).window - - val manifest = WebAppManifest( - name = "Test Manifest", - startUrl = "/", - orientation = WebAppManifest.Orientation.LANDSCAPE, - display = WebAppManifest.DisplayMode.STANDALONE) - - activity.applyConfiguration(manifest) - - verify(window, never()).addFlags(ArgumentMatchers.anyInt()) - verify(decorView, never()).systemUiVisibility = ArgumentMatchers.anyInt() - } -} - -private class TestWebAppShellActivity( - override val engine: Engine = mock(), - override val sessionManager: SessionManager = mock() -) : AbstractWebAppShellActivity() diff --git a/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/WebAppLauncherActivityTest.kt b/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/WebAppLauncherActivityTest.kt index e4a6532cfcb..f48f43a0d1c 100644 --- a/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/WebAppLauncherActivityTest.kt +++ b/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/WebAppLauncherActivityTest.kt @@ -5,9 +5,10 @@ package mozilla.components.feature.pwa import android.content.Intent -import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.core.net.toUri +import androidx.test.ext.junit.runners.AndroidJUnit4 import mozilla.components.concept.engine.manifest.WebAppManifest +import mozilla.components.feature.pwa.intent.WebAppIntentProcessor.Companion.ACTION_VIEW_PWA import mozilla.components.support.test.any import mozilla.components.support.test.argumentCaptor import org.junit.Assert.assertEquals @@ -120,9 +121,8 @@ class WebAppLauncherActivityTest { val captor = argumentCaptor() verify(activity).startActivity(captor.capture()) - assertEquals(AbstractWebAppShellActivity.INTENT_ACTION, captor.value.action) + assertEquals(ACTION_VIEW_PWA, captor.value.action) assertEquals(url, captor.value.data) - assertEquals(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK, captor.value.flags) assertEquals("test", captor.value.`package`) } } diff --git a/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/WebAppShortcutManagerTest.kt b/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/WebAppShortcutManagerTest.kt index 50ca3a67002..38da036c101 100644 --- a/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/WebAppShortcutManagerTest.kt +++ b/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/WebAppShortcutManagerTest.kt @@ -20,6 +20,7 @@ import mozilla.components.browser.session.Session import mozilla.components.concept.engine.manifest.Size import mozilla.components.concept.engine.manifest.WebAppManifest import mozilla.components.concept.fetch.Client +import mozilla.components.feature.pwa.WebAppLauncherActivity.Companion.ACTION_PWA_LAUNCHER import mozilla.components.support.test.any import mozilla.components.support.test.mock import mozilla.components.support.test.robolectric.testContext @@ -205,7 +206,7 @@ class WebAppShortcutManagerTest { assertEquals("https://example.com", shortcut.id) assertEquals("Demo", shortcut.longLabel) assertEquals("Demo", shortcut.shortLabel) - assertEquals(WebAppLauncherActivity.INTENT_ACTION, intent.action) + assertEquals(ACTION_PWA_LAUNCHER, intent.action) assertEquals("https://example.com".toUri(), intent.data) } diff --git a/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/ext/ActivityKtTest.kt b/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/ext/ActivityKtTest.kt index 98dd3dab195..30850e6100c 100644 --- a/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/ext/ActivityKtTest.kt +++ b/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/ext/ActivityKtTest.kt @@ -56,5 +56,11 @@ class ActivityKtTest { orientation = WebAppManifest.Orientation.LANDSCAPE)) verify(activity).requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE } + + run { + val activity: Activity = mock() + activity.applyOrientation(null) + verify(activity).requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER + } } } diff --git a/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/ext/WebAppManifestKtTest.kt b/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/ext/WebAppManifestKtTest.kt index 662d9047549..b13665749af 100644 --- a/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/ext/WebAppManifestKtTest.kt +++ b/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/ext/WebAppManifestKtTest.kt @@ -20,7 +20,7 @@ class WebAppManifestKtTest { val taskDescription = WebAppManifest( name = "Demo", startUrl = "https://example.com" - ).asTaskDescription(null) + ).toTaskDescription(null) assertEquals("Demo", taskDescription.label) assertNull(taskDescription.icon) assertEquals(0, taskDescription.primaryColor) @@ -32,7 +32,7 @@ class WebAppManifestKtTest { name = "My App", startUrl = "https://example.com", themeColor = rgb(255, 0, 255) - ).asTaskDescription(null) + ).toTaskDescription(null) assertEquals("My App", taskDescription.label) assertNull(taskDescription.icon) assertEquals(rgb(255, 0, 255), taskDescription.primaryColor) diff --git a/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/feature/WebAppActivityFeatureTest.kt b/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/feature/WebAppActivityFeatureTest.kt new file mode 100644 index 00000000000..61ea271ad7d --- /dev/null +++ b/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/feature/WebAppActivityFeatureTest.kt @@ -0,0 +1,89 @@ +/* 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.feature + +import android.app.Activity +import android.content.pm.ActivityInfo +import android.view.View +import android.view.Window +import android.view.WindowManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.CompletableDeferred +import mozilla.components.browser.icons.BrowserIcons +import mozilla.components.browser.icons.Icon +import mozilla.components.concept.engine.manifest.WebAppManifest +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations.initMocks + +@RunWith(AndroidJUnit4::class) +class WebAppActivityFeatureTest { + + @Mock private lateinit var activity: Activity + @Mock private lateinit var window: Window + @Mock private lateinit var decorView: View + @Mock private lateinit var icons: BrowserIcons + + @Before + fun setup() { + initMocks(this) + + `when`(activity.window).thenReturn(window) + `when`(window.decorView).thenReturn(decorView) + `when`(icons.loadIcon(any())).thenReturn(CompletableDeferred(mock())) + } + + @Test + fun `enters immersive mode only when display mode is fullscreen`() { + val basicManifest = WebAppManifest( + name = "Demo", + startUrl = "https://mozilla.com", + display = WebAppManifest.DisplayMode.STANDALONE + ) + WebAppActivityFeature(activity, icons, basicManifest).onResume() + verify(window, never()).addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + val fullscreenManifest = basicManifest.copy( + display = WebAppManifest.DisplayMode.FULLSCREEN + ) + WebAppActivityFeature(activity, icons, fullscreenManifest).onResume() + verify(window).addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + + @Test + fun `applies orientation`() { + val manifest = WebAppManifest( + name = "Test Manifest", + startUrl = "/", + orientation = WebAppManifest.Orientation.LANDSCAPE + ) + + WebAppActivityFeature(activity, icons, manifest).onResume() + + verify(activity).requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE + } + + @Suppress("Deprecation") + @Test + fun `sets task description`() { + val manifest = WebAppManifest( + name = "Test Manifest", + startUrl = "/" + ) + val icon = Icon(mock(), source = Icon.Source.GENERATOR) + `when`(icons.loadIcon(any())).thenReturn(CompletableDeferred(icon)) + + WebAppActivityFeature(activity, icons, manifest).onResume() + + verify(activity).setTaskDescription(any()) + } +} diff --git a/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/feature/WebAppHideToolbarFeatureTest.kt b/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/feature/WebAppHideToolbarFeatureTest.kt new file mode 100644 index 00000000000..28298203c37 --- /dev/null +++ b/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/feature/WebAppHideToolbarFeatureTest.kt @@ -0,0 +1,104 @@ +/* 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.feature + +import android.view.View +import androidx.core.net.toUri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.browser.session.Session +import mozilla.components.browser.session.SessionManager +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.`when` +import org.mockito.Mockito.never +import org.mockito.Mockito.verify + +@RunWith(AndroidJUnit4::class) +class WebAppHideToolbarFeatureTest { + + @Test + fun `hides toolbar immediately`() { + val toolbar: View = mock() + + WebAppHideToolbarFeature(mock(), toolbar, "id", listOf(mock())) + verify(toolbar).visibility = View.GONE + + WebAppHideToolbarFeature(mock(), toolbar, "id", emptyList()) + verify(toolbar).visibility = View.VISIBLE + } + + @Test + fun `registers session observer`() { + val sessionManager: SessionManager = mock() + val session: Session = mock() + `when`(sessionManager.findSessionById("id")).thenReturn(session) + + WebAppHideToolbarFeature(sessionManager, mock(), "id", emptyList()).start() + verify(session, never()).register(any()) + + val feature = WebAppHideToolbarFeature(sessionManager, mock(), "id", listOf(mock())) + + feature.start() + verify(session).register(feature) + + feature.stop() + verify(session).unregister(feature) + } + + @Test + fun `hides toolbar if URL is in origin`() { + val trusted = listOf("https://mozilla.com".toUri(), "https://m.mozilla.com".toUri()) + val toolbar = View(testContext) + val feature = WebAppHideToolbarFeature(mock(), toolbar, "id", trusted) + + feature.onUrlChanged(mock(), "https://mozilla.com/example-page") + assertEquals(View.GONE, toolbar.visibility) + + feature.onUrlChanged(mock(), "https://firefox.com/out-of-scope") + assertEquals(View.VISIBLE, toolbar.visibility) + + feature.onUrlChanged(mock(), "https://mozilla.com/back-in-scope") + assertEquals(View.GONE, toolbar.visibility) + + feature.onUrlChanged(mock(), "https://m.mozilla.com/second-origin") + assertEquals(View.GONE, toolbar.visibility) + } + + @Test + fun `hides toolbar if URL is in scope`() { + val trusted = listOf("https://mozilla.github.io/my-app/".toUri()) + val toolbar = View(testContext) + val feature = WebAppHideToolbarFeature(mock(), toolbar, "id", trusted) + + feature.onUrlChanged(mock(), "https://mozilla.github.io/my-app/") + assertEquals(View.GONE, toolbar.visibility) + + feature.onUrlChanged(mock(), "https://firefox.com/out-of-scope") + assertEquals(View.VISIBLE, toolbar.visibility) + + feature.onUrlChanged(mock(), "https://mozilla.github.io/my-app-almost-in-scope") + assertEquals(View.VISIBLE, toolbar.visibility) + + feature.onUrlChanged(mock(), "https://mozilla.github.io/my-app/sub-page") + assertEquals(View.GONE, toolbar.visibility) + } + + @Test + fun `hides toolbar if URL is in ambiguous scope`() { + val trusted = listOf("https://mozilla.github.io/prefix".toUri()) + val toolbar = View(testContext) + val feature = WebAppHideToolbarFeature(mock(), toolbar, "id", trusted) + + feature.onUrlChanged(mock(), "https://mozilla.github.io/prefix/") + assertEquals(View.GONE, toolbar.visibility) + + feature.onUrlChanged(mock(), "https://mozilla.github.io/prefix-of/resource.html") + assertEquals(View.GONE, toolbar.visibility) + } +} diff --git a/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/intent/WebAppIntentProcessorTest.kt b/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/intent/WebAppIntentProcessorTest.kt new file mode 100644 index 00000000000..81107a30963 --- /dev/null +++ b/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/intent/WebAppIntentProcessorTest.kt @@ -0,0 +1,74 @@ +/* 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.intent + +import android.content.Intent +import android.content.Intent.ACTION_VIEW +import androidx.core.net.toUri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runBlockingTest +import mozilla.components.browser.session.intent.getSessionId +import mozilla.components.concept.engine.manifest.WebAppManifest +import mozilla.components.feature.pwa.ManifestStorage +import mozilla.components.feature.pwa.ext.getWebAppManifest +import mozilla.components.feature.pwa.intent.WebAppIntentProcessor.Companion.ACTION_VIEW_PWA +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.`when` + +@RunWith(AndroidJUnit4::class) +@ExperimentalCoroutinesApi +class WebAppIntentProcessorTest { + + @Test + fun `matches checks if intent action is ACTION_VIEW_PWA`() { + val processor = WebAppIntentProcessor(mock(), mock(), mock()) + + assertTrue(processor.matches(Intent(ACTION_VIEW_PWA))) + assertFalse(processor.matches(Intent(ACTION_VIEW))) + } + + @Test + fun `process checks if intent action is not valid`() = runBlockingTest { + val processor = WebAppIntentProcessor(mock(), mock(), mock()) + + assertFalse(processor.process(Intent(ACTION_VIEW))) + assertFalse(processor.process(Intent(ACTION_VIEW_PWA, null))) + assertFalse(processor.process(Intent(ACTION_VIEW_PWA, "".toUri()))) + } + + @Test + fun `process returns false if no manifest is in storage`() = runBlockingTest { + val storage: ManifestStorage = mock() + val processor = WebAppIntentProcessor(mock(), mock(), storage) + + `when`(storage.loadManifest("https://mozilla.com")).thenReturn(null) + + assertFalse(processor.process(Intent(ACTION_VIEW_PWA, "https://mozilla.com".toUri()))) + } + + @Test + fun `process adds session ID and manifest to intent`() = runBlockingTest { + val storage: ManifestStorage = mock() + val processor = WebAppIntentProcessor(mock(), mock(), storage) + + 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()) + assertTrue(processor.process(intent)) + assertNotNull(intent.getSessionId()) + assertEquals(manifest, intent.getWebAppManifest()) + } +} diff --git a/samples/browser/build.gradle b/samples/browser/build.gradle index 3e1a7a6ae81..d12096e5eed 100644 --- a/samples/browser/build.gradle +++ b/samples/browser/build.gradle @@ -170,6 +170,7 @@ dependencies { implementation Dependencies.kotlin_stdlib implementation Dependencies.androidx_appcompat + implementation Dependencies.androidx_core_ktx implementation Dependencies.androidx_constraintlayout androidTestImplementation project(':support-android-test') diff --git a/samples/browser/src/main/AndroidManifest.xml b/samples/browser/src/main/AndroidManifest.xml index 704fd173d8a..04385f4a4ee 100644 --- a/samples/browser/src/main/AndroidManifest.xml +++ b/samples/browser/src/main/AndroidManifest.xml @@ -33,7 +33,7 @@ - + + + + + + () private val toolbarFeature = ViewBoundFeatureWrapper() diff --git a/samples/browser/src/main/java/org/mozilla/samples/browser/BrowserFragment.kt b/samples/browser/src/main/java/org/mozilla/samples/browser/BrowserFragment.kt index 5943bebf72b..9350a45ccb5 100644 --- a/samples/browser/src/main/java/org/mozilla/samples/browser/BrowserFragment.kt +++ b/samples/browser/src/main/java/org/mozilla/samples/browser/BrowserFragment.kt @@ -19,6 +19,9 @@ import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import org.mozilla.samples.browser.ext.components import org.mozilla.samples.browser.integration.ReaderViewIntegration +/** + * Fragment used for browsing the web within the main app. + */ class BrowserFragment : BaseBrowserFragment(), BackHandler { private val thumbnailsFeature = ViewBoundFeatureWrapper() private val readerViewFeature = ViewBoundFeatureWrapper() diff --git a/samples/browser/src/main/java/org/mozilla/samples/browser/CustomTabActivity.kt b/samples/browser/src/main/java/org/mozilla/samples/browser/CustomTabActivity.kt deleted file mode 100644 index cf10ec312e6..00000000000 --- a/samples/browser/src/main/java/org/mozilla/samples/browser/CustomTabActivity.kt +++ /dev/null @@ -1,12 +0,0 @@ -/* 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 org.mozilla.samples.browser - -import androidx.fragment.app.Fragment - -class CustomTabActivity : BrowserActivity() { - override fun createBrowserFragment(sessionId: String?): Fragment = - CustomTabBrowserFragment.create(sessionId!!) -} diff --git a/samples/browser/src/main/java/org/mozilla/samples/browser/CustomTabBrowserFragment.kt b/samples/browser/src/main/java/org/mozilla/samples/browser/CustomTabBrowserFragment.kt deleted file mode 100644 index 2c1edc23e24..00000000000 --- a/samples/browser/src/main/java/org/mozilla/samples/browser/CustomTabBrowserFragment.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* 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 org.mozilla.samples.browser - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import kotlinx.android.synthetic.main.fragment_browser.view.* -import mozilla.components.feature.customtabs.CustomTabsToolbarFeature -import mozilla.components.support.base.feature.BackHandler -import mozilla.components.support.base.feature.ViewBoundFeatureWrapper -import org.mozilla.samples.browser.ext.components - -class CustomTabBrowserFragment : BaseBrowserFragment(), BackHandler { - private val customTabsToolbarFeature = ViewBoundFeatureWrapper() - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val layout = super.onCreateView(inflater, container, savedInstanceState) - - customTabsToolbarFeature.set( - feature = CustomTabsToolbarFeature( - components.sessionManager, - layout.toolbar, - sessionId, - components.menuBuilder, - window = activity?.window, - closeListener = { activity?.finish() }), - owner = this, - view = layout) - - return layout - } - - /** - * Calls [onBackPressed] for features in the base class first, - * before trying to call the custom tab [BackHandler]. - */ - override fun onBackPressed(): Boolean = - super.onBackPressed() || customTabsToolbarFeature.onBackPressed() - - companion object { - fun create(sessionId: String) = CustomTabBrowserFragment().apply { - arguments = Bundle().apply { - putSessionId(sessionId) - } - } - } -} diff --git a/samples/browser/src/main/java/org/mozilla/samples/browser/DefaultComponents.kt b/samples/browser/src/main/java/org/mozilla/samples/browser/DefaultComponents.kt index 84340709ec3..e75273afc1b 100644 --- a/samples/browser/src/main/java/org/mozilla/samples/browser/DefaultComponents.kt +++ b/samples/browser/src/main/java/org/mozilla/samples/browser/DefaultComponents.kt @@ -32,6 +32,8 @@ import mozilla.components.feature.intent.TabIntentProcessor import mozilla.components.feature.media.RecordingDevicesNotificationFeature import mozilla.components.feature.media.notification.MediaNotificationFeature import mozilla.components.feature.media.state.MediaStateMachine +import mozilla.components.feature.pwa.ManifestStorage +import mozilla.components.feature.pwa.intent.WebAppIntentProcessor import mozilla.components.feature.pwa.WebAppUseCases import mozilla.components.feature.search.SearchUseCases import mozilla.components.feature.session.HistoryDelegate @@ -119,6 +121,9 @@ open class DefaultComponents(private val applicationContext: Context) { val customTabIntentProcessor by lazy { CustomTabIntentProcessor(sessionManager, sessionUseCases.loadUrl, applicationContext.resources) } + val webAppIntentProcessor by lazy { + WebAppIntentProcessor(sessionManager, sessionUseCases.loadUrl, ManifestStorage(applicationContext)) + } // Menu val menuBuilder by lazy { BrowserMenuBuilder(menuItems) } diff --git a/samples/browser/src/main/java/org/mozilla/samples/browser/ExternalAppBrowserActivity.kt b/samples/browser/src/main/java/org/mozilla/samples/browser/ExternalAppBrowserActivity.kt new file mode 100644 index 00000000000..1dacd088e4c --- /dev/null +++ b/samples/browser/src/main/java/org/mozilla/samples/browser/ExternalAppBrowserActivity.kt @@ -0,0 +1,30 @@ +/* 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 org.mozilla.samples.browser + +import androidx.core.net.toUri +import androidx.fragment.app.Fragment +import mozilla.components.concept.engine.manifest.WebAppManifest +import mozilla.components.feature.pwa.ext.getWebAppManifest + +class ExternalAppBrowserActivity : BrowserActivity() { + + override fun createBrowserFragment(sessionId: String?): Fragment { + return if (sessionId != null) { + val manifest = intent.getWebAppManifest() + + ExternalAppBrowserFragment.create( + sessionId, + manifest = manifest, + trustedScopes = listOfNotNull(manifest?.getScope()) + ) + } else { + // Fall back to browser fragment + super.createBrowserFragment(sessionId) + } + } + + private fun WebAppManifest.getScope() = (scope ?: startUrl).toUri() +} diff --git a/samples/browser/src/main/java/org/mozilla/samples/browser/ExternalAppBrowserFragment.kt b/samples/browser/src/main/java/org/mozilla/samples/browser/ExternalAppBrowserFragment.kt new file mode 100644 index 00000000000..06e99e9779c --- /dev/null +++ b/samples/browser/src/main/java/org/mozilla/samples/browser/ExternalAppBrowserFragment.kt @@ -0,0 +1,97 @@ +/* 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 org.mozilla.samples.browser + +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import kotlinx.android.synthetic.main.fragment_browser.view.* +import mozilla.components.concept.engine.manifest.WebAppManifest +import mozilla.components.feature.customtabs.CustomTabsToolbarFeature +import mozilla.components.feature.pwa.ext.getWebAppManifest +import mozilla.components.feature.pwa.ext.putWebAppManifest +import mozilla.components.feature.pwa.feature.WebAppActivityFeature +import mozilla.components.feature.pwa.feature.WebAppHideToolbarFeature +import mozilla.components.support.base.feature.BackHandler +import mozilla.components.support.base.feature.ViewBoundFeatureWrapper +import org.mozilla.samples.browser.ext.components + +/** + * Fragment used for browsing within an external app, such as for custom tabs and PWAs. + */ +class ExternalAppBrowserFragment : BaseBrowserFragment(), BackHandler { + private val customTabsToolbarFeature = ViewBoundFeatureWrapper() + private val hideToolbarFeature = ViewBoundFeatureWrapper() + + private val manifest: WebAppManifest? + get() = arguments?.getWebAppManifest() + private val trustedScopes: List + get() = arguments?.getParcelableArrayList(ARG_TRUSTED_SCOPES).orEmpty() + + @Suppress("LongMethod") + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val layout = super.onCreateView(inflater, container, savedInstanceState) + + val manifest = this.manifest + + customTabsToolbarFeature.set( + feature = CustomTabsToolbarFeature( + components.sessionManager, + layout.toolbar, + sessionId, + components.menuBuilder, + window = activity?.window, + closeListener = { activity?.finish() }), + owner = this, + view = layout) + + hideToolbarFeature.set( + feature = WebAppHideToolbarFeature( + components.sessionManager, + layout.toolbar, + sessionId!!, + trustedScopes + ), + owner = this, + view = layout.toolbar) + + if (manifest != null) { + activity?.lifecycle?.addObserver( + WebAppActivityFeature( + activity!!, + components.icons, + manifest + ) + ) + } + + return layout + } + + /** + * Calls [onBackPressed] for features in the base class first, + * before trying to call the external app [BackHandler]. + */ + override fun onBackPressed(): Boolean = + super.onBackPressed() || customTabsToolbarFeature.onBackPressed() + + companion object { + private const val ARG_TRUSTED_SCOPES = "org.mozilla.samples.browser.TRUSTED_SCOPES" + + fun create( + sessionId: String, + manifest: WebAppManifest?, + trustedScopes: List + ) = ExternalAppBrowserFragment().apply { + arguments = Bundle().apply { + putSessionId(sessionId) + putWebAppManifest(manifest) + putParcelableArrayList(ARG_TRUSTED_SCOPES, ArrayList(trustedScopes)) + } + } + } +} diff --git a/samples/browser/src/main/java/org/mozilla/samples/browser/IntentReceiverActivity.kt b/samples/browser/src/main/java/org/mozilla/samples/browser/IntentReceiverActivity.kt index 2cd09872d60..4b9000be85b 100644 --- a/samples/browser/src/main/java/org/mozilla/samples/browser/IntentReceiverActivity.kt +++ b/samples/browser/src/main/java/org/mozilla/samples/browser/IntentReceiverActivity.kt @@ -18,20 +18,35 @@ class IntentReceiverActivity : Activity() { MainScope().launch { val intent = intent?.let { Intent(it) } ?: Intent() val intentProcessors = listOf( + components.webAppIntentProcessor, components.customTabIntentProcessor, components.tabIntentProcessor ) intentProcessors.any { it.process(intent) } - if (components.customTabIntentProcessor.matches(intent)) { - intent.setClassName(applicationContext, CustomTabActivity::class.java.name) - } else { - intent.setClassName(applicationContext, BrowserActivity::class.java.name) - } + setBrowserActivity(intent) startActivity(intent) finish() } } + + /** + * Sets the activity that this [intent] will launch. + */ + private fun setBrowserActivity(intent: Intent) { + val externalAppIntentProcessors = listOf( + components.webAppIntentProcessor, + components.customTabIntentProcessor + ) + + val className = if (externalAppIntentProcessors.any { it.matches(intent) }) { + ExternalAppBrowserActivity::class + } else { + BrowserActivity::class + } + + intent.setClassName(applicationContext, className.java.name) + } } diff --git a/samples/browser/src/main/java/org/mozilla/samples/browser/WebAppBrowserFragment.kt b/samples/browser/src/main/java/org/mozilla/samples/browser/WebAppBrowserFragment.kt deleted file mode 100644 index 4864cfcf7e0..00000000000 --- a/samples/browser/src/main/java/org/mozilla/samples/browser/WebAppBrowserFragment.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* 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 org.mozilla.samples.browser - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import kotlinx.android.synthetic.main.fragment_browser.view.* -import mozilla.components.feature.customtabs.CustomTabsToolbarFeature -import mozilla.components.support.base.feature.BackHandler -import mozilla.components.support.base.feature.ViewBoundFeatureWrapper -import org.mozilla.samples.browser.ext.components - -class WebAppBrowserFragment : BaseBrowserFragment(), BackHandler { - private val customTabsToolbarFeature = ViewBoundFeatureWrapper() - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val layout = super.onCreateView(inflater, container, savedInstanceState) - - customTabsToolbarFeature.set( - feature = CustomTabsToolbarFeature( - components.sessionManager, - layout.toolbar, - sessionId, - components.menuBuilder, - window = activity?.window, - closeListener = { activity?.finish() }), - owner = this, - view = layout) - - return layout - } - - /** - * Calls [onBackPressed] for features in the base class first, - * before trying to call the progressive web app [BackHandler]. - */ - override fun onBackPressed(): Boolean = - super.onBackPressed() || customTabsToolbarFeature.onBackPressed() - - companion object { - fun create(sessionId: String) = WebAppBrowserFragment().apply { - arguments = Bundle().apply { - putSessionId(sessionId) - } - } - } -}