diff --git a/components/feature/pwa/README.md b/components/feature/pwa/README.md
index 6b8bbf86a82..f6aa0912e2c 100644
--- a/components/feature/pwa/README.md
+++ b/components/feature/pwa/README.md
@@ -12,6 +12,30 @@ Use Gradle to download the library from [maven.mozilla.org](https://maven.mozill
implementation "org.mozilla.components:feature-pwa:{latest-version}"
```
+### WebAppShellActivity
+
+Standalone and fullscreen web apps are launched in a `WebAppShellActivity` instance. Since this instance requires access to some components and app using this component needs to create a new class and register it in the manifest:
+
+```Kotlin
+class WebAppActivity : AbstractWebAppShellActivity() {
+ override val engine: Engine by lazy {
+ /* Get Engine instance */
+ }
+ override val sessionManager: SessionManager by lazy {
+ /* Get SessionManager instance */
+ }
+}
+```
+
+```xml
+
+
+
+
+
+
+```
+
## License
This Source Code Form is subject to the terms of the Mozilla Public
diff --git a/components/feature/pwa/build.gradle b/components/feature/pwa/build.gradle
index 4e2e1777aaf..78a9618c0b2 100644
--- a/components/feature/pwa/build.gradle
+++ b/components/feature/pwa/build.gradle
@@ -24,6 +24,7 @@ android {
dependencies {
implementation project(':browser-session')
implementation project(':concept-engine')
+ implementation project(':support-base')
implementation project(':support-ktx')
implementation Dependencies.kotlin_stdlib
diff --git a/components/feature/pwa/src/main/AndroidManifest.xml b/components/feature/pwa/src/main/AndroidManifest.xml
index 2ef375d9834..cd0512e69ee 100644
--- a/components/feature/pwa/src/main/AndroidManifest.xml
+++ b/components/feature/pwa/src/main/AndroidManifest.xml
@@ -2,4 +2,14 @@
- 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">
+
+
+
+
+
+
+
+
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
new file mode 100644
index 00000000000..74246712492
--- /dev/null
+++ b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/AbstractWebAppShellActivity.kt
@@ -0,0 +1,70 @@
+/* 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 android.support.annotation.VisibleForTesting
+import android.support.v7.app.AppCompatActivity
+import mozilla.components.browser.session.Session
+import mozilla.components.browser.session.SessionManager
+import mozilla.components.browser.session.manifest.WebAppManifest
+import mozilla.components.concept.engine.Engine
+import mozilla.components.concept.engine.EngineView
+import mozilla.components.feature.pwa.ext.applyOrientation
+import mozilla.components.support.ktx.android.view.enterToImmersiveMode
+
+/**
+ * Activity for "standalone" and "fullscreen" web applications.
+ */
+abstract class AbstractWebAppShellActivity : AppCompatActivity() {
+ abstract val engine: Engine
+ abstract val sessionManager: SessionManager
+
+ lateinit var session: Session
+ lateinit var engineView: EngineView
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // We do not "install" web apps yet. So there's no place we can load a manifest from yet.
+ // https://github.com/mozilla-mobile/android-components/issues/2382
+ val manifest = createTestManifest()
+
+ applyConfiguration(manifest)
+ renderSession(manifest)
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+
+ sessionManager
+ .getOrCreateEngineSession(session)
+ .close()
+ }
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal fun applyConfiguration(manifest: WebAppManifest) {
+ if (manifest.display == WebAppManifest.DisplayMode.FULLSCREEN) {
+ enterToImmersiveMode()
+ }
+
+ applyOrientation(manifest)
+ }
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal fun renderSession(manifest: WebAppManifest) {
+ setContentView(engine
+ .createView(this)
+ .also { engineView = it }
+ .asView())
+
+ session = Session(manifest.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
new file mode 100644
index 00000000000..c956bfb22e4
--- /dev/null
+++ b/components/feature/pwa/src/main/java/mozilla/components/feature/pwa/WebAppLauncherActivity.kt
@@ -0,0 +1,92 @@
+/* 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.ActivityNotFoundException
+import android.content.Intent
+import android.os.Bundle
+import android.support.annotation.VisibleForTesting
+import android.support.annotation.VisibleForTesting.PRIVATE
+import android.support.v7.app.AppCompatActivity
+import mozilla.components.browser.session.manifest.WebAppManifest
+import mozilla.components.support.base.log.logger.Logger
+import mozilla.components.support.ktx.kotlin.toUri
+
+/**
+ * This activity is launched by Web App shortcuts on the home screen.
+ *
+ * Based on the Web App Manifest (display) it will decide whether the app is launched in the browser or in a
+ * standalone activity.
+ */
+class WebAppLauncherActivity : AppCompatActivity() {
+ private val logger = Logger("WebAppLauncherActivity")
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val manifest = loadManifest()
+
+ routeManifest(manifest)
+
+ finish()
+ }
+
+ @VisibleForTesting(otherwise = PRIVATE)
+ internal fun routeManifest(manifest: WebAppManifest) {
+ when (manifest.display) {
+ WebAppManifest.DisplayMode.FULLSCREEN, WebAppManifest.DisplayMode.STANDALONE -> launchWebAppShell()
+
+ // We do not implement "minimal-ui" mode. Following the Web App Manifest spec we fallback to
+ // using "browser" in this case.
+ WebAppManifest.DisplayMode.MINIMAL_UI -> launchBrowser(manifest)
+
+ WebAppManifest.DisplayMode.BROWSER -> launchBrowser(manifest)
+ }
+ }
+
+ @VisibleForTesting(otherwise = PRIVATE)
+ internal fun launchBrowser(manifest: WebAppManifest) {
+ val intent = Intent(Intent.ACTION_VIEW, manifest.startUrl.toUri())
+ intent.`package` = packageName
+
+ try {
+ startActivity(intent)
+ } catch (e: ActivityNotFoundException) {
+ logger.error("Package does not handle VIEW intent. Can't launch browser.")
+ }
+ }
+
+ @VisibleForTesting(otherwise = PRIVATE)
+ internal fun launchWebAppShell() {
+ val intent = Intent()
+ intent.action = AbstractWebAppShellActivity.INTENT_ACTION
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ intent.`package` = packageName
+
+ try {
+ startActivity(intent)
+ } catch (e: ActivityNotFoundException) {
+ logger.error("Packages does not handle AbstractWebAppShellActivity intent. Can't launch web app.", e)
+ }
+ }
+
+ @VisibleForTesting(otherwise = PRIVATE)
+ internal fun loadManifest(): WebAppManifest {
+ // We do not "install" web apps yet. So there's no place we can load a manifest from yet.
+ // https://github.com/mozilla-mobile/android-components/issues/2382
+ return createTestManifest()
+ }
+}
+
+/**
+ * Just a test manifest we use for testing until we save and load the actual manifests.
+ * https://github.com/mozilla-mobile/android-components/issues/2382
+ */
+internal fun createTestManifest(): WebAppManifest {
+ return WebAppManifest(
+ name = "Demo",
+ startUrl = "https://www.mozilla.org",
+ display = WebAppManifest.DisplayMode.FULLSCREEN)
+}
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
new file mode 100644
index 00000000000..b718f040c46
--- /dev/null
+++ b/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/AbstractWebAppShellActivityTest.kt
@@ -0,0 +1,93 @@
+/* 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 mozilla.components.browser.session.SessionManager
+import mozilla.components.browser.session.manifest.WebAppManifest
+import mozilla.components.concept.engine.Engine
+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
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::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
new file mode 100644
index 00000000000..2796d155a54
--- /dev/null
+++ b/components/feature/pwa/src/test/java/mozilla/components/feature/pwa/WebAppLauncherActivityTest.kt
@@ -0,0 +1,123 @@
+/* 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.manifest.WebAppManifest
+import mozilla.components.support.test.any
+import mozilla.components.support.test.argumentCaptor
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.doNothing
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class WebAppLauncherActivityTest {
+ @Test
+ fun `DisplayMode-Browser launches browser`() {
+ val activity = spy(WebAppLauncherActivity())
+ doNothing().`when`(activity).launchBrowser(any())
+
+ val manifest = WebAppManifest(
+ name = "Test",
+ startUrl = "https://www.mozilla.org",
+ display = WebAppManifest.DisplayMode.BROWSER
+ )
+
+ activity.routeManifest(manifest)
+
+ verify(activity).launchBrowser(manifest)
+ }
+
+ @Test
+ fun `DisplayMode-minimalui launches browser`() {
+ val activity = spy(WebAppLauncherActivity())
+ doNothing().`when`(activity).launchBrowser(any())
+
+ val manifest = WebAppManifest(
+ name = "Test",
+ startUrl = "https://www.mozilla.org",
+ display = WebAppManifest.DisplayMode.MINIMAL_UI
+ )
+
+ activity.routeManifest(manifest)
+
+ verify(activity).launchBrowser(manifest)
+ }
+
+ @Test
+ fun `DisplayMode-fullscreen launches web app shell`() {
+ val activity = spy(WebAppLauncherActivity())
+ doNothing().`when`(activity).launchWebAppShell()
+
+ val manifest = WebAppManifest(
+ name = "Test",
+ startUrl = "https://www.mozilla.org",
+ display = WebAppManifest.DisplayMode.FULLSCREEN
+ )
+
+ activity.routeManifest(manifest)
+
+ verify(activity).launchWebAppShell()
+ }
+
+ @Test
+ fun `DisplayMode-standalone launches web app shell`() {
+ val activity = spy(WebAppLauncherActivity())
+ doNothing().`when`(activity).launchWebAppShell()
+
+ val manifest = WebAppManifest(
+ name = "Test",
+ startUrl = "https://www.mozilla.org",
+ display = WebAppManifest.DisplayMode.STANDALONE
+ )
+
+ activity.routeManifest(manifest)
+
+ verify(activity).launchWebAppShell()
+ }
+
+ @Test
+ fun `launchBrowser starts activity with VIEW intent`() {
+ val activity = spy(WebAppLauncherActivity())
+ doReturn("test").`when`(activity).packageName
+ doNothing().`when`(activity).startActivity(any())
+
+ val manifest = WebAppManifest(
+ name = "Test",
+ startUrl = "https://www.mozilla.org",
+ display = WebAppManifest.DisplayMode.BROWSER
+ )
+
+ activity.launchBrowser(manifest)
+
+ val captor = argumentCaptor()
+ verify(activity).startActivity(captor.capture())
+
+ assertEquals(Intent.ACTION_VIEW, captor.value.action)
+ assertEquals("https://www.mozilla.org", captor.value.data!!.toString())
+ assertEquals("test", captor.value.`package`)
+ }
+
+ @Test
+ fun `launchWebAppShell starts activity with SHELL intent`() {
+ val activity = spy(WebAppLauncherActivity())
+ doReturn("test").`when`(activity).packageName
+ doNothing().`when`(activity).startActivity(any())
+
+ activity.launchWebAppShell()
+
+ val captor = argumentCaptor()
+ verify(activity).startActivity(captor.capture())
+
+ assertEquals(AbstractWebAppShellActivity.INTENT_ACTION, captor.value.action)
+ assertEquals(Intent.FLAG_ACTIVITY_NEW_TASK, captor.value.flags)
+ assertEquals("test", captor.value.`package`)
+ }
+}