Commit a8e7990d authored by Arturo Mejia's avatar Arturo Mejia Committed by Sebastian Kaspari
Browse files

Closes #4469: Add API for supporting WebExtension browserActions

parent dedb09a1
......@@ -13,6 +13,7 @@ import mozilla.components.browser.session.engine.request.LoadRequestOption
import mozilla.components.browser.session.ext.syncDispatch
import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.action.TrackingProtectionAction
import mozilla.components.browser.state.action.WebExtensionAction
import mozilla.components.browser.state.state.content.DownloadState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.EngineSession
......@@ -23,6 +24,7 @@ import mozilla.components.concept.engine.media.Media
import mozilla.components.concept.engine.media.RecordingDevice
import mozilla.components.concept.engine.permission.PermissionRequest
import mozilla.components.concept.engine.prompt.PromptRequest
import mozilla.components.concept.engine.webextension.BrowserAction
import mozilla.components.concept.engine.window.WindowRequest
import mozilla.components.support.base.observer.Consumable
......@@ -211,6 +213,16 @@ internal class EngineObserver(
media.unregisterObservers()
}
override fun onBrowserActionChange(webExtensionId: String, action: BrowserAction) {
store?.dispatch(
WebExtensionAction.UpdateTabBrowserAction(
session.id,
webExtensionId,
action
)
)
}
override fun onWebAppManifestLoaded(manifest: WebAppManifest) {
session.webAppManifest = manifest
}
......
......@@ -10,6 +10,7 @@ import mozilla.components.browser.session.Session
import mozilla.components.browser.session.engine.request.LoadRequestOption
import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.action.TrackingProtectionAction
import mozilla.components.browser.state.action.WebExtensionAction
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.EngineSession
import mozilla.components.concept.engine.EngineSessionState
......@@ -20,6 +21,7 @@ import mozilla.components.concept.engine.manifest.WebAppManifest
import mozilla.components.concept.engine.media.Media
import mozilla.components.concept.engine.permission.PermissionRequest
import mozilla.components.concept.engine.prompt.PromptRequest
import mozilla.components.concept.engine.webextension.BrowserAction
import mozilla.components.concept.engine.window.WindowRequest
import mozilla.components.support.base.observer.Consumable
import mozilla.components.support.test.any
......@@ -193,6 +195,25 @@ class EngineObserverTest {
)
}
@Test
fun engineSessionObserverOnBrowserActionChange() {
val session = Session("")
val store = mock(BrowserStore::class.java)
val browserAction = BrowserAction("", true, mock(), "", "", 0, 0) {}
val observer = EngineObserver(session, store)
whenever(store.dispatch(any())).thenReturn(mock())
observer.onBrowserActionChange("extensionId", browserAction)
verify(store).dispatch(
WebExtensionAction.UpdateTabBrowserAction(
session.id,
"extensionId",
browserAction
)
)
}
@Test
fun engineObserverClearsWebsiteTitleIfNewPageStartsLoading() {
val session = Session("https://www.mozilla.org")
......
......@@ -13,6 +13,7 @@ import mozilla.components.browser.state.state.SecurityInfoState
import mozilla.components.browser.state.state.SessionState
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.state.state.TrackingProtectionState
import mozilla.components.browser.state.state.WebExtensionState
import mozilla.components.browser.state.state.content.DownloadState
import mozilla.components.browser.state.state.content.FindResultState
import mozilla.components.concept.engine.EngineSession
......@@ -22,6 +23,7 @@ import mozilla.components.concept.engine.content.blocking.Tracker
import mozilla.components.concept.engine.prompt.PromptRequest
import mozilla.components.lib.state.Action
typealias WebExtensionBrowserAction = mozilla.components.concept.engine.webextension.BrowserAction
/**
* [Action] implementation related to [BrowserState].
*/
......@@ -246,6 +248,36 @@ sealed class TrackingProtectionAction : BrowserAction() {
data class ClearTrackersAction(val tabId: String) : TrackingProtectionAction()
}
/**
* [BrowserAction] implementations related to updating [BrowserState.extensions] and
* [TabSessionState.extensionState].
*/
sealed class WebExtensionAction : BrowserAction() {
/**
* Installs the given [extension] and adds it to the [BrowserState.extensions].
*/
data class InstallWebExtension(val extension: WebExtensionState) :
WebExtensionAction()
/**
* Updates a browser action of a given [extensionId].
*/
data class UpdateBrowserAction(
val extensionId: String,
val browserAction: WebExtensionBrowserAction
) : WebExtensionAction()
/**
* Updates a browser action that belongs to the given [sessionId] and [extensionId] on the
* [TabSessionState.extensionState].
*/
data class UpdateTabBrowserAction(
val sessionId: String,
val extensionId: String,
val browserAction: WebExtensionBrowserAction
) : WebExtensionAction()
}
/**
* [BrowserAction] implementations related to updating the [EngineState] of a single [SessionState] inside
* [BrowserState].
......
......@@ -11,6 +11,7 @@ import mozilla.components.browser.state.action.EngineAction
import mozilla.components.browser.state.action.SystemAction
import mozilla.components.browser.state.action.TabListAction
import mozilla.components.browser.state.action.TrackingProtectionAction
import mozilla.components.browser.state.action.WebExtensionAction
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.lib.state.Action
......@@ -30,6 +31,7 @@ internal object BrowserStateReducer {
is TabListAction -> TabListReducer.reduce(state, action)
is TrackingProtectionAction -> TrackingProtectionStateReducer.reduce(state, action)
is EngineAction -> EngineStateReducer.reduce(state, action)
is WebExtensionAction -> WebExtensionReducer.reduce(state, action)
}
}
}
/* 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.browser.state.reducer
import mozilla.components.browser.state.action.WebExtensionAction
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.SessionState
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.state.state.WebExtensionState
internal object WebExtensionReducer {
/**
* [WebExtensionAction] Reducer function for modifying a specific [WebExtensionState] in
* both [SessionState.extensionState] or [BrowserState.extensions].
*/
fun reduce(state: BrowserState, action: WebExtensionAction): BrowserState {
return when (action) {
is WebExtensionAction.InstallWebExtension -> {
state.copy(
extensions = state.extensions + (action.extension.id to action.extension)
)
}
is WebExtensionAction.UpdateBrowserAction -> {
val newExtension = action.extensionId to WebExtensionState(
id = action.extensionId, browserAction = action.browserAction
)
val updatedExtensions = state.extensions - action.extensionId
state.copy(
extensions = updatedExtensions + newExtension
)
}
is WebExtensionAction.UpdateTabBrowserAction -> {
state.updateTabState(action.sessionId) { tab ->
val existingExtension = tab.extensionState[action.extensionId]
val newExtension = action.extensionId to WebExtensionState(
id = action.extensionId, browserAction = action.browserAction
)
val updatedExtensions = if (existingExtension == null) {
tab.extensionState + newExtension
} else {
val newExtensions = tab.extensionState - action.extensionId
newExtensions + newExtension
}
tab.copy(
extensionState = updatedExtensions
)
}
}
}
}
private fun BrowserState.updateTabState(
tabId: String,
update: (TabSessionState) -> TabSessionState
): BrowserState {
return copy(
tabs = tabs.map { current ->
if (current.id == tabId) {
update(current)
} else {
current
}
}
)
}
}
......@@ -12,10 +12,13 @@ import mozilla.components.lib.state.State
* @property tabs the list of open tabs, defaults to an empty list.
* @property selectedTabId the ID of the currently selected (active) tab.
* @property customTabs the list of custom tabs, defaults to an empty list.
* @property extensions A map of extension ids and web extensions of all installed web extensions.
* The extensions here represent the default values for all [BrowserState.extensions] and can
* be overridden per [SessionState].
*/
data class BrowserState(
val tabs: List<TabSessionState> = emptyList(),
val selectedTabId: String? = null,
val customTabs: List<CustomTabSessionState> = emptyList()
val customTabs: List<CustomTabSessionState> = emptyList(),
val extensions: Map<String, WebExtensionState> = emptyMap()
) : State
......@@ -13,13 +13,16 @@ import java.util.UUID
* @property content the [ContentState] of this custom tab.
* @property trackingProtection the [TrackingProtectionState] of this custom tab.
* @property config the [CustomTabConfig] used to create this custom tab.
* @property extensionState a map of web extension ids and extensions, that contains the overridden
* values for this tab.
*/
data class CustomTabSessionState(
override val id: String = UUID.randomUUID().toString(),
override val content: ContentState,
override val trackingProtection: TrackingProtectionState = TrackingProtectionState(),
val config: CustomTabConfig,
override val engineState: EngineState = EngineState()
override val engineState: EngineState = EngineState(),
override val extensionState: Map<String, WebExtensionState> = emptyMap()
) : SessionState
fun createCustomTab(
......
......@@ -11,10 +11,13 @@ package mozilla.components.browser.state.state
* @property content the [ContentState] of this session.
* @property trackingProtection the [TrackingProtectionState] of this session.
* @property engineState the [EngineState] of this session.
* @property extensionState a map of extension id and web extension states
* specific to this [SessionState].
*/
interface SessionState {
val id: String
val content: ContentState
val trackingProtection: TrackingProtectionState
val engineState: EngineState
val extensionState: Map<String, WebExtensionState>
}
......@@ -16,24 +16,32 @@ import java.util.UUID
* parent. The parent tab is usually the tab that initiated opening this
* tab (e.g. the user clicked a link with target="_blank" or selected
* "open in new tab" or a "window.open" was triggered).
* @property extensionState a map of web extension ids and extensions, that contains the overridden
* values for this tab.
*/
data class TabSessionState(
override val id: String = UUID.randomUUID().toString(),
override val content: ContentState,
override val trackingProtection: TrackingProtectionState = TrackingProtectionState(),
override val engineState: EngineState = EngineState(),
val parentId: String? = null
val parentId: String? = null,
override val extensionState: Map<String, WebExtensionState> = emptyMap()
) : SessionState
/**
* Convenient function for creating a tab.
*/
fun createTab(
url: String,
private: Boolean = false,
id: String = UUID.randomUUID().toString(),
parent: TabSessionState? = null
parent: TabSessionState? = null,
extensions: Map<String, WebExtensionState> = emptyMap()
): TabSessionState {
return TabSessionState(
id = id,
content = ContentState(url, private),
parentId = parent?.id
parentId = parent?.id,
extensionState = extensions
)
}
/* 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.browser.state.state
typealias WebExtensionBrowserAction = mozilla.components.concept.engine.webextension.BrowserAction
/**
* Value type that represents the state of a web extension.
*
* @property id The unique identifier for this web extension.
* @property url The url pointing to a resources path for locating the extension
* within the APK file e.g. resource://android/assets/extensions/my_web_ext.
* @property browserAction A list browser action that this web extension has.
*/
data class WebExtensionState(
val id: String,
val url: String? = null,
val browserAction: WebExtensionBrowserAction? = null
)
/* 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.browser.state.action
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.WebExtensionState
import mozilla.components.browser.state.state.createTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.support.test.ext.joinBlocking
import mozilla.components.support.test.mock
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
typealias WebExtensionBrowserAction = mozilla.components.concept.engine.webextension.BrowserAction
class WebExtensionActionTest {
@Test
fun `InstallWebExtension - Adds an extension to the BrowserState extensions`() {
val store = BrowserStore()
assertTrue(store.state.extensions.isEmpty())
val extension = WebExtensionState("id", "url")
store.dispatch(WebExtensionAction.InstallWebExtension(extension)).joinBlocking()
assertFalse(store.state.extensions.isEmpty())
assertEquals(extension, store.state.extensions.values.first())
}
@Test
fun `UpdateGlobalBrowserAction - Updates a browser action of an existing WebExtensionState on the BrowserState`() {
val store = BrowserStore()
val mockedBrowserAction = mock<WebExtensionBrowserAction>()
assertTrue(store.state.extensions.isEmpty())
val extension = WebExtensionState("id", "url")
store.dispatch(WebExtensionAction.InstallWebExtension(extension)).joinBlocking()
assertFalse(store.state.extensions.isEmpty())
assertEquals(extension, store.state.extensions.values.first())
store.dispatch(WebExtensionAction.UpdateBrowserAction("id", mockedBrowserAction))
.joinBlocking()
assertEquals(mockedBrowserAction, store.state.extensions.values.first().browserAction)
}
@Test
fun `UpdateTabBrowserAction - Updates the browser action of an existing WebExtensionState on a given tab`() {
val tab = createTab("url")
val store = BrowserStore(
initialState = BrowserState(
tabs = listOf(tab)
)
)
val mockedBrowserAction = mock<WebExtensionBrowserAction>()
assertTrue(tab.extensionState.isEmpty())
val extension = WebExtensionState("id", "url")
store.dispatch(
WebExtensionAction.UpdateTabBrowserAction(
tab.id,
extension.id,
mockedBrowserAction
)
).joinBlocking()
val extensions = store.state.tabs.first().extensionState
assertEquals(mockedBrowserAction, extensions.values.first().browserAction)
}
@Test
fun `UpdateTabBrowserAction - Updates an existing browser action`() {
val mockedBrowserAction1 = mock<WebExtensionBrowserAction>()
val mockedBrowserAction2 = mock<WebExtensionBrowserAction>()
val tab = createTab(
"url",
extensions = mapOf(
"extensionId" to WebExtensionState(
"extensionId",
"url",
mockedBrowserAction1
)
)
)
val store = BrowserStore(
initialState = BrowserState(
tabs = listOf(tab)
)
)
store.dispatch(
WebExtensionAction.UpdateTabBrowserAction(
tab.id,
"extensionId",
mockedBrowserAction2
)
).joinBlocking()
val extensions = store.state.tabs.first().extensionState
assertEquals(mockedBrowserAction2, extensions.values.first().browserAction)
}
}
......@@ -580,7 +580,7 @@ class BrowserToolbar @JvmOverloads constructor(
* view for this action should be added or removed. Additionally <code>bind</code> will be
* called on every visible action to update its view.
*/
fun invalidateActions() {
override fun invalidateActions() {
displayToolbar.invalidateActions()
editToolbar.invalidateActions()
}
......
......@@ -15,6 +15,7 @@ import mozilla.components.concept.engine.media.Media
import mozilla.components.concept.engine.media.RecordingDevice
import mozilla.components.concept.engine.permission.PermissionRequest
import mozilla.components.concept.engine.prompt.PromptRequest
import mozilla.components.concept.engine.webextension.BrowserAction
import mozilla.components.concept.engine.window.WindowRequest
import mozilla.components.support.base.observer.Observable
import mozilla.components.support.base.observer.ObserverRegistry
......@@ -59,6 +60,10 @@ abstract class EngineSession(
fun onCloseWindowRequest(windowRequest: WindowRequest) = Unit
fun onMediaAdded(media: Media) = Unit
fun onMediaRemoved(media: Media) = Unit
/**
* Event to notify that a web extension browser action has changed.
*/
fun onBrowserActionChange(webExtensionId: String, action: BrowserAction) = Unit
fun onWebAppManifestLoaded(manifest: WebAppManifest) = Unit
fun onCrash() = Unit
fun onProcessKilled() = Unit
......
/* 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.concept.engine.webextension
import android.graphics.drawable.Drawable
/**
* Value type that represents the state of a browser action within a [WebExtension].
*
* @property title The title of the browser action to be visible in the user interface.
* @property enabled Indicates if the browser action should be enabled or disabled.
* @property icon The image for this browser icon.
* @property uri The url to get the HTML document, representing the internal user interface of the extension.
* @property badgeText The browser action's badge text.
* @property badgeTextColor The browser action's badge text color.
* @property badgeBackgroundColor The browser action's badge background color.
* @property onClick A callback to be executed when this browser action is clicked.
*/
data class BrowserAction(
val title: String,
val enabled: Boolean,
val icon: Drawable,
val uri: String,
val badgeText: String,
val badgeTextColor: Int,
val badgeBackgroundColor: Int,
val onClick: () -> Unit
)
......@@ -25,6 +25,7 @@ import org.mockito.Mockito.verifyNoMoreInteractions
import org.mockito.Mockito.verifyZeroInteractions
import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy.TrackingCategory
import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy.CookiePolicy
import mozilla.components.concept.engine.webextension.BrowserAction
class EngineSessionTest {
private val unknownHitResult = HitResult.UNKNOWN("file://foobar")
......@@ -36,6 +37,7 @@ class EngineSessionTest {
val observer = mock(EngineSession.Observer::class.java)
val emptyBitmap = spy(Bitmap::class.java)
val permissionRequest = mock(PermissionRequest::class.java)
val browserAction = mock(BrowserAction::class.java)
val windowRequest = mock(WindowRequest::class.java)
session.register(observer)
......@@ -68,6 +70,7 @@ class EngineSessionTest {
session.notifyInternalObservers { onCrash() }
session.notifyInternalObservers { onLoadRequest("https://www.mozilla.org", true, true) }
session.notifyInternalObservers { onProcessKilled() }
session.notifyInternalObservers { onBrowserActionChange("extensionId", browserAction) }
verify(observer).onLocationChange("https://www.mozilla.org")
verify(observer).onLocationChange("https://www.firefox.com")
......@@ -94,6 +97,7 @@ class EngineSessionTest {
verify(observer).onCrash()
verify(observer).onLoadRequest("https://www.mozilla.org", true, true)
verify(observer).onProcessKilled()
verify(observer).onBrowserActionChange("extensionId", browserAction)
verifyNoMoreInteractions(observer)
}
......@@ -669,6 +673,7 @@ class EngineSessionTest {
defaultObserver.onMediaAdded(mock())
defaultObserver.onMediaRemoved(mock())
defaultObserver.onCrash()
defaultObserver.onBrowserActionChange("", mock())
}
@Test
......
......@@ -99,6 +99,12 @@ interface Toolbar {
*/
fun addBrowserAction(action: Action)
/**
* Declare that the actions (navigation actions, browser actions, page actions) have changed and
* should be updated if needed.
*/
fun invalidateActions()
/**
* Adds an action to be displayed on the right side of the URL in display mode.
*
......