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`) + } +}