From 1c2cd2f18e8144b625fc0ea8b7b54e5335237baa 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. --- .../browser/session/tab/CustomTabConfig.kt | 5 +- .../engine/manifest/WebAppManifestParser.kt | 8 +- components/feature/pwa/build.gradle | 3 + .../pwa/AbstractWebAppShellActivity.kt | 87 --------------- .../feature/pwa/WebAppIntentProcessor.kt | 52 +++++++++ .../feature/pwa/WebAppLauncherActivity.kt | 9 +- .../feature/pwa/WebAppShortcutManager.kt | 10 +- .../components/feature/pwa/ext/Activity.kt | 3 +- .../components/feature/pwa/ext/Bundle.kt | 23 ++++ .../components/feature/pwa/ext/Intent.kt | 18 ++++ .../feature/pwa/ext/WebAppManifest.kt | 15 ++- .../pwa/feature/WebAppActivityFeature.kt | 63 +++++++++++ .../pwa/feature/WebAppToolbarFeature.kt | 56 ++++++++++ .../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 | 102 ++++++++++++++++++ .../pwa/feature/WebAppToolbarFeatureTest.kt | 52 +++++++++ samples/browser/src/main/AndroidManifest.xml | 6 ++ .../samples/browser/CustomTabActivity.kt | 18 +++- .../samples/browser/DefaultComponents.kt | 5 + .../samples/browser/IntentReceiverActivity.kt | 16 ++- .../samples/browser/WebAppBrowserFragment.kt | 34 +++++- 25 files changed, 487 insertions(+), 211 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/WebAppIntentProcessor.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/WebAppToolbarFeature.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/WebAppToolbarFeatureTest.kt diff --git a/components/browser/session/src/main/java/mozilla/components/browser/session/tab/CustomTabConfig.kt b/components/browser/session/src/main/java/mozilla/components/browser/session/tab/CustomTabConfig.kt index e7910a4c259..d39b231af2f 100644 --- a/components/browser/session/src/main/java/mozilla/components/browser/session/tab/CustomTabConfig.kt +++ b/components/browser/session/src/main/java/mozilla/components/browser/session/tab/CustomTabConfig.kt @@ -8,10 +8,9 @@ import android.app.PendingIntent import android.graphics.Bitmap import android.os.Bundle import android.os.Parcelable -import androidx.browser.customtabs.CustomTabsIntent import android.util.DisplayMetrics import androidx.annotation.ColorInt - +import androidx.browser.customtabs.CustomTabsIntent import mozilla.components.support.utils.SafeBundle import mozilla.components.support.utils.SafeIntent import java.util.ArrayList @@ -22,7 +21,7 @@ import java.util.UUID * Holds configuration data for a Custom Tab. Use [createFromIntent] to * create instances. */ -class CustomTabConfig internal constructor( +class CustomTabConfig( val id: String, @ColorInt val toolbarColor: Int?, val closeButtonIcon: Bitmap?, 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 a5b75f0228c..ccfea5c9466 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 @@ -90,7 +92,11 @@ class WebAppManifestParser { } } -private const val HEX_RADIX = 16 +fun WebAppManifestParser.Result.get(): WebAppManifest? = when (this) { + is WebAppManifestParser.Result.Success -> manifest + is WebAppManifestParser.Result.Failure -> null +} + private val whitespace = "\\s+".toRegex() private fun parseDisplayMode(json: JSONObject): WebAppManifest.DisplayMode { diff --git a/components/feature/pwa/build.gradle b/components/feature/pwa/build.gradle index 64111690192..646eba159d6 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 e44bee33fbe..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 { setStatusBarTheme(it) } - manifest?.backgroundColor?.let { 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/WebAppIntentProcessor.kt b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/WebAppIntentProcessor.kt new file mode 100644 index 00000000000..abdc18c4c9e --- /dev/null +++ b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/WebAppIntentProcessor.kt @@ -0,0 +1,52 @@ +/* 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.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.ext.toCustomTabConfig +import mozilla.components.feature.pwa.ext.putWebAppManifest +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/main/java/mozilla/components/feature/pwa/WebAppLauncherActivity.kt b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/WebAppLauncherActivity.kt index 43bce80cb64..1e127e58b7e 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.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 41f5b598e83..e04e454c2e1 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() @@ -89,9 +90,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) @@ -110,8 +110,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 } storage.saveManifest(manifest) 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..11c7f6c766d --- /dev/null +++ b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/ext/Bundle.kt @@ -0,0 +1,23 @@ +/* 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.get + +const val EXTRA_WEB_APP_MANIFEST = "mozilla.components.feature.pwa.EXTRA_WEB_APP_MANIFEST" + +fun Bundle.putWebAppManifest(webAppManifest: WebAppManifest) { + val json = WebAppManifestParser().serialize(webAppManifest) + putString(EXTRA_WEB_APP_MANIFEST, json.toString()) +} + +fun Bundle.getWebAppManifest(): WebAppManifest? { + return getString(EXTRA_WEB_APP_MANIFEST)?.let { json -> + WebAppManifestParser().parse(json).get() + } +} 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..415fb7feffb --- /dev/null +++ b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/ext/Intent.kt @@ -0,0 +1,18 @@ +/* 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 + +fun Intent.putWebAppManifest(webAppManifest: WebAppManifest) { + val json = WebAppManifestParser().serialize(webAppManifest) + putExtra(EXTRA_WEB_APP_MANIFEST, json.toString()) +} + +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..1c089e097dc 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,17 @@ 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, + disableUrlbarHiding = false, + actionButtonConfig = null, + showShareMenuItem = true, + menuItems = emptyList(), + options = 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..1b1f6398fd7 --- /dev/null +++ b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/feature/WebAppActivityFeature.kt @@ -0,0 +1,63 @@ +/* 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 +import mozilla.components.support.ktx.android.view.setNavigationBarTheme +import mozilla.components.support.ktx.android.view.setStatusBarTheme + +/** + * 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) + + manifest?.themeColor?.let { activity.setStatusBarTheme(it) } + manifest?.backgroundColor?.let { activity.setNavigationBarTheme(it) } + + scope.launch { + updateRecentEntry() + } + } + + @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) + fun onDestroy() { + scope.cancel() + } + + @VisibleForTesting + internal suspend fun updateRecentEntry() { + val manifest = this.manifest ?: return + 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/WebAppToolbarFeature.kt b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/feature/WebAppToolbarFeature.kt new file mode 100644 index 00000000000..a95b73cbb52 --- /dev/null +++ b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/feature/WebAppToolbarFeature.kt @@ -0,0 +1,56 @@ +/* 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 + +/** + * Initializes and resets the Toolbar for a Custom Tab based on the CustomTabConfig. + */ +class WebAppToolbarFeature( + private val sessionManager: SessionManager, + private val toolbar: View, + private val sessionId: String, + private val manifest: WebAppManifest? +) : Session.Observer, LifecycleAwareFeature { + + init { + toolbar.isGone = true + } + + override fun onUrlChanged(session: Session, url: String) { + toolbar.isGone = isInScope(url.toUri()) + } + + override fun start() { + 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 manifest = this.manifest ?: return false + val scope = (manifest.scope ?: manifest.startUrl).toUri() + + return sameOrigin(scope, target) && target.path.orEmpty().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/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..1065de0069d 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.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..4f46a5c65eb --- /dev/null +++ b/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/feature/WebAppActivityFeatureTest.kt @@ -0,0 +1,102 @@ +/* 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.graphics.Color +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 + } + + @Test + fun `sets status bar color`() { + val manifest = WebAppManifest( + name = "Test Manifest", + startUrl = "/", + themeColor = Color.RED + ) + WebAppActivityFeature(activity, icons, manifest).onResume() + + verify(window).statusBarColor = Color.RED + } + + @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/WebAppToolbarFeatureTest.kt b/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/feature/WebAppToolbarFeatureTest.kt new file mode 100644 index 00000000000..c368234d528 --- /dev/null +++ b/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/feature/WebAppToolbarFeatureTest.kt @@ -0,0 +1,52 @@ +/* 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 mozilla.components.browser.session.Session +import mozilla.components.browser.session.SessionManager +import mozilla.components.concept.engine.manifest.WebAppManifest +import mozilla.components.support.test.mock +import org.junit.Test +import org.mockito.Mockito.`when` +import org.mockito.Mockito.verify + +class WebAppToolbarFeatureTest { + + @Test + fun `hides toolbar immediately`() { + val toolbar: View = mock() + WebAppToolbarFeature(mock(), toolbar, "id", mock()) + + verify(toolbar).visibility = View.GONE + } + + @Test + fun `registers session observer`() { + val sessionManager: SessionManager = mock() + val session: Session = mock() + `when`(sessionManager.findSessionById("id")).thenReturn(session) + + val feature = WebAppToolbarFeature(sessionManager, mock(), "id", mock()) + + feature.start() + verify(session).register(feature) + + feature.stop() + verify(session).unregister(feature) + } + + @Test + fun `hides toolbar if URL is in scope`() { + val manifest = WebAppManifest( + name = "Test Manifest", + startUrl = "https://mozilla.com" + ) + val toolbar: View = mock() + val feature = WebAppToolbarFeature(mock(), toolbar, "id", manifest) + + feature.onUrlChanged(mock(), "https://") + } +} diff --git a/samples/browser/src/main/AndroidManifest.xml b/samples/browser/src/main/AndroidManifest.xml index 704fd173d8a..f3131176e4d 100644 --- a/samples/browser/src/main/AndroidManifest.xml +++ b/samples/browser/src/main/AndroidManifest.xml @@ -56,6 +56,12 @@ + + + + + + CustomTabActivity::class + else -> BrowserActivity::class + } + 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 index fc8e1e8e456..b9a1d0b13bd 100644 --- a/samples/browser/src/main/java/org/mozilla/samples/browser/WebAppBrowserFragment.kt +++ b/samples/browser/src/main/java/org/mozilla/samples/browser/WebAppBrowserFragment.kt @@ -9,17 +9,30 @@ 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.feature.WebAppActivityFeature +import mozilla.components.feature.pwa.feature.WebAppToolbarFeature +import mozilla.components.feature.pwa.ext.getWebAppManifest +import mozilla.components.feature.pwa.ext.putWebAppManifest 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() + private val webAppToolbarFeature = ViewBoundFeatureWrapper() + private lateinit var webAppActivityFeature: WebAppActivityFeature + private val manifest: WebAppManifest? + get() = arguments?.getWebAppManifest() + + @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, @@ -30,6 +43,24 @@ class WebAppBrowserFragment : BaseBrowserFragment(), BackHandler { owner = this, view = layout) + webAppToolbarFeature.set( + feature = WebAppToolbarFeature( + components.sessionManager, + layout.toolbar, + sessionId!!, + manifest + ), + owner = this, + view = layout.toolbar) + + webAppActivityFeature = WebAppActivityFeature( + activity!!, + components.icons, + manifest + ) + + activity?.lifecycle?.addObserver(webAppActivityFeature) + return layout } @@ -41,9 +72,10 @@ class WebAppBrowserFragment : BaseBrowserFragment(), BackHandler { super.onBackPressed() || customTabsToolbarFeature.onBackPressed() companion object { - fun create(sessionId: String) = WebAppBrowserFragment().apply { + fun create(sessionId: String, manifest: WebAppManifest) = WebAppBrowserFragment().apply { arguments = Bundle().apply { putSessionId(sessionId) + putWebAppManifest(manifest) } } }