Commit eef0c4bd authored by MozLando's avatar MozLando
Browse files

Merge #7251



7251: Move PIP to BrowserState, use for toolbar visibility r=grigoryk a=NotWoods

Moves everything that affects toolbar visibility into the stores.
Co-authored-by: default avatarTiger Oakes <toakes@mozilla.com>
parents e5388850 54c83b00
......@@ -31,6 +31,7 @@ import mozilla.components.concept.engine.permission.PermissionRequest
import mozilla.components.concept.engine.prompt.PromptRequest
import mozilla.components.concept.engine.window.WindowRequest
import mozilla.components.support.base.observer.Consumable
import mozilla.components.support.ktx.android.net.isInScope
/**
* [EngineSession.Observer] implementation responsible to update the state of a [Session] from the events coming out of
......@@ -83,13 +84,6 @@ internal class EngineObserver(
uri.query == originalUri.query
}
private fun isHostEquals(sessionUrl: String, newUrl: String): Boolean {
val sessionUri = sessionUrl.toUri()
val newUri = newUrl.toUri()
return sessionUri.scheme == newUri.scheme && sessionUri.host == newUri.host
}
/**
* Checks that the [newUrl] is in scope of the web app manifest.
*
......@@ -100,9 +94,7 @@ internal class EngineObserver(
val scopeUri = scope.toUri()
val newUri = newUrl.toUri()
return isHostEquals(scope, newUrl) &&
scopeUri.port == newUri.port &&
newUri.path.orEmpty().startsWith(scopeUri.path.orEmpty())
return newUri.isInScope(listOf(scopeUri))
}
override fun onLoadRequest(
......
......@@ -250,10 +250,15 @@ sealed class ContentAction : BrowserAction() {
data class ConsumeSearchRequestAction(val sessionId: String) : ContentAction()
/**
* Updates the [fullScreenEnabled] with the given [sessionId].
* Updates [fullScreenEnabled] with the given [sessionId].
*/
data class FullScreenChangedAction(val sessionId: String, val fullScreenEnabled: Boolean) : ContentAction()
/**
* Updates [pipEnabled] with the given [sessionId].
*/
data class PictureInPictureChangedAction(val sessionId: String, val pipEnabled: Boolean) : ContentAction()
/**
* Updates the [layoutInDisplayCutoutMode] with the given [sessionId].
*
......
......@@ -10,6 +10,7 @@ import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.ContentState
import mozilla.components.browser.state.state.SessionState
import mozilla.components.browser.state.state.content.HistoryState
import mozilla.components.support.ktx.android.net.sameSchemeAndHostAs
internal object ContentStateReducer {
/**
......@@ -103,6 +104,9 @@ internal object ContentStateReducer {
is ContentAction.FullScreenChangedAction -> updateContentState(state, action.sessionId) {
it.copy(fullScreen = action.fullScreenEnabled)
}
is ContentAction.PictureInPictureChangedAction -> updateContentState(state, action.sessionId) {
it.copy(pictureInPictureEnabled = action.pipEnabled)
}
is ContentAction.ViewportFitChangedAction -> updateContentState(state, action.sessionId) {
it.copy(layoutInDisplayCutoutMode = action.layoutInDisplayCutoutMode)
}
......@@ -142,5 +146,5 @@ private fun isHostEquals(sessionUrl: String, newUrl: String): Boolean {
val sessionUri = Uri.parse(sessionUrl)
val newUri = Uri.parse(newUrl)
return sessionUri.scheme == newUri.scheme && sessionUri.host == newUri.host
return sessionUri.sameSchemeAndHostAs(newUri)
}
......@@ -41,6 +41,7 @@ import mozilla.components.concept.engine.window.WindowRequest
* @property canGoForward whether or not there's an history item to navigate forward to.
* @property webAppManifest the Web App Manifest for the currently visited page (or null).
* @property firstContentfulPaint whether or not the first contentful paint has happened.
* @property pictureInPictureEnabled True if the session is being displayed in PIP mode.
*/
data class ContentState(
val url: String,
......@@ -64,5 +65,6 @@ data class ContentState(
val canGoForward: Boolean = false,
val webAppManifest: WebAppManifest? = null,
val firstContentfulPaint: Boolean = false,
val history: HistoryState = HistoryState()
val history: HistoryState = HistoryState(),
val pictureInPictureEnabled: Boolean = false
)
......@@ -4,94 +4,120 @@
package mozilla.components.feature.pwa.feature
import android.net.Uri
import android.view.View
import androidx.core.net.toUri
import androidx.core.view.isVisible
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager
import mozilla.components.browser.state.selector.findTabOrCustomTabOrSelectedTab
import mozilla.components.browser.state.state.CustomTabSessionState
import mozilla.components.browser.state.state.SessionState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.manifest.WebAppManifest
import mozilla.components.feature.customtabs.store.CustomTabState
import mozilla.components.feature.customtabs.store.CustomTabsServiceState
import mozilla.components.feature.customtabs.store.CustomTabsServiceStore
import mozilla.components.feature.pwa.ext.getTrustedScope
import mozilla.components.feature.pwa.ext.trustedOrigins
import mozilla.components.lib.state.ext.flow
import mozilla.components.support.base.feature.LifecycleAwareFeature
import mozilla.components.support.ktx.android.net.isInScope
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
/**
* 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.
* The toolbar is also hidden in fullscreen mode or picture in picture mode.
*
* In standard custom tabs, no scopes are trusted.
* As a result the URL has no impact on toolbar visibility.
*
* @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).
* @param onToolbarVisibilityChange Called when the toolbar is changed to be visible or hidden.
* @param store Reference to the browser store where tab state is located.
* @param customTabsStore Reference to the store that communicates with the custom tabs service.
* @param tabId ID of the tab session, or null if the selected session should be used.
* @param manifest Reference to the cached [WebAppManifest] for the current PWA.
* Null if this feature is not used in a PWA context.
*/
class WebAppHideToolbarFeature(
private val sessionManager: SessionManager,
private val toolbar: View,
private val sessionId: String,
private var trustedScopes: List<Uri>,
private val onToolbarVisibilityChange: (visible: Boolean) -> Unit = {}
) : Session.Observer, LifecycleAwareFeature {
private val store: BrowserStore,
private val customTabsStore: CustomTabsServiceStore,
private val tabId: String? = null,
manifest: WebAppManifest? = null
) : LifecycleAwareFeature {
private val manifestScope = listOfNotNull(manifest?.getTrustedScope())
private var scope: CoroutineScope? = null
init {
// Hide the toolbar by default to prevent a flash.
// If trusted scopes is empty, we're probably a normal custom tab so don't hide the toolbar.
setToolbarVisible(trustedScopes.isEmpty())
val tab = store.state.findTabOrCustomTabOrSelectedTab(tabId)
val customTabState = customTabsStore.state.getCustomTabStateForTab(tab)
toolbar.isVisible = shouldToolbarBeVisible(tab, customTabState)
}
private fun setToolbarVisible(visible: Boolean) {
toolbar.isVisible = visible
onToolbarVisibilityChange(visible)
@ExperimentalCoroutinesApi
override fun start() {
scope = MainScope().apply {
launch {
// Since we subscribe to both store and customTabsStore,
// we don't extend another non-external-apps feature for hiding the toolbar
// as very little code would be shared.
val sessionFlow = store.flow()
.map { state -> state.findTabOrCustomTabOrSelectedTab(tabId) }
.ifChanged()
val customTabServiceMapFlow = customTabsStore.flow()
sessionFlow.combine(customTabServiceMapFlow) { tab, customTabServiceState ->
tab to customTabServiceState.getCustomTabStateForTab(tab)
}
.map { (tab, customTabState) -> shouldToolbarBeVisible(tab, customTabState) }
.ifChanged()
.collect { toolbarVisible ->
toolbar.isVisible = toolbarVisible
}
}
}
}
/**
* Hides or reveals the toolbar when the session navigates to a new URL.
*/
override fun onUrlChanged(session: Session, url: String) {
setToolbarVisible(!isInScope(url.toUri(), trustedScopes))
override fun stop() {
scope?.cancel()
}
/**
* Hides or reveals the toolbar when the list of trusted scopes is changed.
* Reports if the toolbar should be shown for the given external app session.
* If the URL is in the same scope as the [WebAppManifest]
*/
fun onTrustedScopesChange(trustedScopes: List<Uri>) {
val session = sessionManager.findSessionById(sessionId)
this.trustedScopes = trustedScopes
private fun shouldToolbarBeVisible(
session: SessionState?,
customTabState: CustomTabState?
): Boolean {
val url = session?.content?.url?.toUri() ?: return true
if (session != null) {
setToolbarVisible(!isInScope(session.url.toUri(), trustedScopes))
} else {
setToolbarVisible(true)
}
}
val trustedOrigins = customTabState?.trustedOrigins.orEmpty()
val inScope = url.isInScope(manifestScope + trustedOrigins)
override fun start() {
sessionManager.findSessionById(sessionId)?.register(this)
}
override fun stop() {
sessionManager.findSessionById(sessionId)?.unregister(this)
return !inScope && !session.content.fullScreen && !session.content.pictureInPictureEnabled
}
companion object {
/**
* 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, trustedScopes: Iterable<Uri>): Boolean {
val path = target.path.orEmpty()
return trustedScopes.any { scope ->
sameOrigin(scope, target) && path.startsWith(scope.path.orEmpty())
}
/**
* Find corresponding custom tab state, if any.
*/
private fun CustomTabsServiceState.getCustomTabStateForTab(
tab: SessionState?
): CustomTabState? {
return (tab as? CustomTabSessionState)?.config?.sessionToken?.let { sessionToken ->
tabs[sessionToken]
}
/**
* Checks that [a] and [b] have the same origin.
*
* https://html.spec.whatwg.org/multipage/origin.html#same-origin
*/
private fun sameOrigin(a: Uri, b: Uri) =
a.scheme == b.scheme && a.host == b.host && a.port == b.port
}
}
......@@ -5,158 +5,309 @@
package mozilla.components.feature.pwa.feature
import android.view.View
import androidx.browser.customtabs.CustomTabsService.RELATION_HANDLE_ALL_URLS
import androidx.browser.customtabs.CustomTabsSessionToken
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 kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestCoroutineDispatcher
import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.ContentState
import mozilla.components.browser.state.state.CustomTabConfig
import mozilla.components.browser.state.state.CustomTabSessionState
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.state.state.createCustomTab
import mozilla.components.browser.state.state.createTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.manifest.WebAppManifest
import mozilla.components.feature.customtabs.store.CustomTabState
import mozilla.components.feature.customtabs.store.CustomTabsServiceState
import mozilla.components.feature.customtabs.store.CustomTabsServiceStore
import mozilla.components.feature.customtabs.store.OriginRelationPair
import mozilla.components.feature.customtabs.store.ValidateRelationshipAction
import mozilla.components.feature.customtabs.store.VerificationStatus
import mozilla.components.support.test.ext.joinBlocking
import mozilla.components.support.test.mock
import mozilla.components.support.test.robolectric.testContext
import mozilla.components.support.test.rule.MainCoroutineRule
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.`when`
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.verify
@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
class WebAppHideToolbarFeatureTest {
private val customTabId = "custom-id"
private val testDispatcher = TestCoroutineDispatcher()
private val toolbar = View(testContext)
@get:Rule
val coroutinesTestRule = MainCoroutineRule(testDispatcher)
@Test
fun `hides toolbar immediately`() {
val toolbar: View = mock()
var changeResult = true
fun `hides toolbar immediately based on PWA manifest`() {
val tab = CustomTabSessionState(
id = customTabId,
content = ContentState("https://mozilla.org"),
config = CustomTabConfig()
)
val store = BrowserStore(BrowserState(customTabs = listOf(tab)))
val feature = WebAppHideToolbarFeature(
toolbar,
store,
CustomTabsServiceStore(),
tabId = tab.id,
manifest = mockManifest("https://mozilla.org")
)
feature.start()
assertEquals(View.GONE, toolbar.visibility)
}
WebAppHideToolbarFeature(mock(), toolbar, "id", listOf(mock())) { changeResult = it }
verify(toolbar).visibility = View.GONE
assertFalse(changeResult)
@Test
fun `hides toolbar immediately based on trusted origins`() {
val token = mock<CustomTabsSessionToken>()
val tab = CustomTabSessionState(
id = customTabId,
content = ContentState("https://mozilla.org"),
config = CustomTabConfig(sessionToken = token)
)
val store = BrowserStore(BrowserState(customTabs = listOf(tab)))
val customTabsStore = CustomTabsServiceStore(CustomTabsServiceState(
tabs = mapOf(token to mockCustomTabState("https://firefox.com", "https://mozilla.org"))
))
val feature = WebAppHideToolbarFeature(
toolbar,
store,
customTabsStore,
tabId = tab.id
)
feature.start()
assertEquals(View.GONE, toolbar.visibility)
}
@Test
fun `does not hide toolbar for a normal tab`() {
val tab = createTab("https://mozilla.org")
val store = BrowserStore(BrowserState(tabs = listOf(tab)))
WebAppHideToolbarFeature(mock(), toolbar, "id", emptyList()) { changeResult = it }
verify(toolbar).visibility = View.VISIBLE
assertTrue(changeResult)
val feature = WebAppHideToolbarFeature(toolbar, store, CustomTabsServiceStore(), tabId = tab.id)
feature.start()
assertEquals(View.VISIBLE, toolbar.visibility)
}
@Test
fun `registers session observer`() {
val sessionManager: SessionManager = mock()
val session: Session = mock()
`when`(sessionManager.findSessionById("id")).thenReturn(session)
fun `does not hide toolbar for an invalid tab`() {
val store = BrowserStore()
val feature = WebAppHideToolbarFeature(sessionManager, mock(), "id", listOf(mock()))
val feature = WebAppHideToolbarFeature(toolbar, store, CustomTabsServiceStore())
feature.start()
assertEquals(View.VISIBLE, toolbar.visibility)
}
@Test
fun `does hide toolbar for a normal tab in fullscreen`() {
val tab = TabSessionState(
content = ContentState(
url = "https://mozilla.org",
fullScreen = true
)
)
val store = BrowserStore(BrowserState(tabs = listOf(tab)))
val feature = WebAppHideToolbarFeature(toolbar, store, CustomTabsServiceStore(), tabId = tab.id)
feature.start()
verify(session).register(feature)
assertEquals(View.GONE, toolbar.visibility)
}
feature.stop()
verify(session).unregister(feature)
@Test
fun `does hide toolbar for a normal tab in PIP`() {
val tab = TabSessionState(
content = ContentState(
url = "https://mozilla.org",
pictureInPictureEnabled = true
)
)
val store = BrowserStore(BrowserState(tabs = listOf(tab)))
val feature = WebAppHideToolbarFeature(toolbar, store, CustomTabsServiceStore(), tabId = tab.id)
feature.start()
assertEquals(View.GONE, toolbar.visibility)
}
@Test
fun `does not hide toolbar if origin is not trusted`() {
val token = mock<CustomTabsSessionToken>()
val tab = createCustomTab(
id = customTabId,
url = "https://firefox.com",
config = CustomTabConfig(sessionToken = token)
)
val store = BrowserStore(BrowserState(customTabs = listOf(tab)))
val customTabsStore = CustomTabsServiceStore(CustomTabsServiceState(
tabs = mapOf(token to mockCustomTabState("https://mozilla.org"))
))
val feature = WebAppHideToolbarFeature(
toolbar,
store,
customTabsStore,
tabId = tab.id
)
feature.start()
assertEquals(View.VISIBLE, toolbar.visibility)
}
@Test
fun `onUrlChanged 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)
val token = mock<CustomTabsSessionToken>()
val tab = createCustomTab(
id = customTabId,
url = "https://mozilla.org",
config = CustomTabConfig(sessionToken = token)
)
val store = BrowserStore(BrowserState(customTabs = listOf(tab)))
val customTabsStore = CustomTabsServiceStore(CustomTabsServiceState(
tabs = mapOf(token to mockCustomTabState("https://mozilla.com", "https://m.mozilla.com"))
))
val feature = WebAppHideToolbarFeature(
toolbar,
store,
customTabsStore,
tabId = customTabId
)
feature.start()
feature.onUrlChanged(mock(), "https://mozilla.com/example-page")
store.dispatch(
ContentAction.UpdateUrlAction(customTabId, "https://mozilla.com/example-page")
).joinBlocking()
assertEquals(View.GONE, toolbar.visibility)
feature.onUrlChanged(mock(), "https://firefox.com/out-of-scope")
store.dispatch(
ContentAction.UpdateUrlAction(customTabId, "https://firefox.com/out-of-scope")
).joinBlocking()
assertEquals(View.VISIBLE, toolbar.visibility)
feature.onUrlChanged(mock(), "https://mozilla.com/back-in-scope")
store.dispatch(
ContentAction.UpdateUrlAction(customTabId, "https://mozilla.com/back-in-scope")
).joinBlocking()
assertEquals(View.GONE, toolbar.visibility)
feature.onUrlChanged(mock(), "https://m.mozilla.com/second-origin")
store.dispatch(
ContentAction.UpdateUrlAction(customTabId, "https://m.mozilla.com/second-origin")
).joinBlocking()
assertEquals(View.GONE, toolbar.visibility)
}
@Test
fun `onUrlChanged 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)
val tab = createCustomTab(id = customTabId, url = "https://mozilla.org")
val store = BrowserStore(BrowserState(customTabs = listOf(tab)))
val feature = WebAppHideToolbarFeature(
toolbar,
store,
CustomTabsServiceStore(),
tabId = customTabId,
manifest = mockManifest("https://mozilla.github.io/my-app/")
)
feature.start()
feature.onUrlChanged(mock(), "https://mozilla.github.io/my-app/")
store.dispatch(
ContentAction.UpdateUrlAction(customTabId, "https://mozilla.github.io/my-app/")
).joinBlocking()
assertEquals(View.GONE, toolbar.visibility)
feature.onUrlChanged(mock(), "https://firefox.com/out-of-scope")
store.dispatch(
ContentAction.UpdateUrlAction(customTabId, "https://firefox.com/out-of-scope")
).joinBlocking()
assertEquals(View.VISIBLE, toolbar.visibility)
feature.onUrlChanged(mock(), "https://mozilla.github.io/my-app-almost-in-scope")
store.dispatch(
ContentAction.UpdateUrlAction(customTabId, "https://mozilla.github.io/my-app-almost-in-scope")
).joinBlocking()
assertEquals(View.VISIBLE, toolbar.visibility)
feature.onUrlChanged(mock(), "https://mozilla.github.io/my-app/sub-page")
store.dispatch(
ContentAction.UpdateUrlAction(customTabId, "https://mozilla.github.io/my-app/sub-page")
).joinBlocking()
assertEquals(View.GONE, toolbar.visibility)
}
@Test
fun `onUrlChanged 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)
val tab = createCustomTab(id = customTabId, url = "https://mozilla.org")
val store = BrowserStore(BrowserState(customTabs = listOf(tab)))
val feature = WebAppHideToolbarFeature(