forked from mirrors/gecko-dev
Bug 1891157 - Add a feature and a use case for closing synced tabs. r=jonalmeida,android-reviewers,007
This commit introduces plumbing for sending "close tab" commands to other devices that are signed in to the same Mozilla account, and for receiving "close tab" commands from other devices. Differential Revision: https://phabricator.services.mozilla.com/D208051
This commit is contained in:
parent
12f62a9cf4
commit
7bfef278ee
18 changed files with 658 additions and 34 deletions
|
|
@ -560,7 +560,12 @@ projects:
|
|||
path: components/feature/accounts-push
|
||||
publish: true
|
||||
upstream_dependencies:
|
||||
- browser-errorpages
|
||||
- browser-state
|
||||
- concept-awesomebar
|
||||
- concept-base
|
||||
- concept-engine
|
||||
- concept-fetch
|
||||
- concept-push
|
||||
- concept-storage
|
||||
- concept-sync
|
||||
|
|
@ -572,8 +577,10 @@ projects:
|
|||
- support-base
|
||||
- support-ktx
|
||||
- support-test
|
||||
- support-test-libstate
|
||||
- support-utils
|
||||
- tooling-lint
|
||||
- ui-icons
|
||||
feature-addons:
|
||||
description: A feature that provides for managing add-ons.
|
||||
path: components/feature/addons
|
||||
|
|
@ -587,6 +594,7 @@ projects:
|
|||
- concept-fetch
|
||||
- concept-menu
|
||||
- concept-storage
|
||||
- concept-toolbar
|
||||
- lib-publicsuffixlist
|
||||
- lib-state
|
||||
- support-base
|
||||
|
|
@ -634,6 +642,7 @@ projects:
|
|||
- concept-engine
|
||||
- concept-fetch
|
||||
- concept-storage
|
||||
- concept-toolbar
|
||||
- lib-fetch-okhttp
|
||||
- lib-publicsuffixlist
|
||||
- service-digitalassetlinks
|
||||
|
|
@ -800,6 +809,7 @@ projects:
|
|||
- concept-engine
|
||||
- concept-fetch
|
||||
- concept-storage
|
||||
- concept-toolbar
|
||||
- lib-publicsuffixlist
|
||||
- lib-state
|
||||
- support-android-test
|
||||
|
|
@ -2299,6 +2309,7 @@ projects:
|
|||
- concept-engine
|
||||
- concept-fetch
|
||||
- concept-storage
|
||||
- concept-toolbar
|
||||
- lib-publicsuffixlist
|
||||
- support-base
|
||||
- support-ktx
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ sealed class AccountEvent {
|
|||
sealed class DeviceCommandIncoming {
|
||||
/** A command to open a list of tabs on the current device */
|
||||
class TabReceived(val from: Device?, val entries: List<TabData>) : DeviceCommandIncoming()
|
||||
|
||||
/** A command to close one or more tabs that are open on the current device */
|
||||
class TabsClosed(val from: Device?, val urls: List<String>) : DeviceCommandIncoming()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -54,6 +57,9 @@ sealed class DeviceCommandIncoming {
|
|||
sealed class DeviceCommandOutgoing {
|
||||
/** A command to open a tab on another device */
|
||||
class SendTab(val title: String, val url: String) : DeviceCommandOutgoing()
|
||||
|
||||
/** A command to close one or more tabs that are open on another device */
|
||||
class CloseTab(val urls: List<String>) : DeviceCommandOutgoing()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ data class DeviceConfig(
|
|||
*/
|
||||
enum class DeviceCapability {
|
||||
SEND_TAB,
|
||||
CLOSE_TABS,
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ tasks.withType(KotlinCompile).configureEach {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':browser-state')
|
||||
implementation project(':service-firefox-accounts')
|
||||
implementation project(':support-ktx')
|
||||
implementation project(':support-base')
|
||||
|
|
@ -47,7 +48,9 @@ dependencies {
|
|||
implementation ComponentsDependencies.androidx_lifecycle_process
|
||||
implementation ComponentsDependencies.kotlin_coroutines
|
||||
|
||||
testImplementation project(':concept-engine')
|
||||
testImplementation project(':support-test')
|
||||
testImplementation project(':support-test-libstate')
|
||||
|
||||
testImplementation ComponentsDependencies.androidx_test_core
|
||||
testImplementation ComponentsDependencies.androidx_test_junit
|
||||
|
|
|
|||
|
|
@ -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.accounts.push
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import mozilla.components.browser.state.action.TabListAction
|
||||
import mozilla.components.browser.state.state.TabSessionState
|
||||
import mozilla.components.browser.state.store.BrowserStore
|
||||
import mozilla.components.concept.sync.AccountEvent
|
||||
import mozilla.components.concept.sync.AccountEventsObserver
|
||||
import mozilla.components.concept.sync.Device
|
||||
import mozilla.components.concept.sync.DeviceCommandIncoming
|
||||
import mozilla.components.concept.sync.DeviceConstellation
|
||||
import mozilla.components.service.fxa.manager.FxaAccountManager
|
||||
|
||||
/**
|
||||
* A feature for closing tabs on this device from other devices
|
||||
* in the [DeviceConstellation].
|
||||
*
|
||||
* This feature receives commands to close tabs using the [FxaAccountManager].
|
||||
*
|
||||
* See [CloseTabsUseCases] for the ability to close tabs that are open on
|
||||
* other devices from this device.
|
||||
*
|
||||
* @param browserStore The [BrowserStore] that holds the currently open tabs.
|
||||
* @param accountManager The account manager.
|
||||
* @param owner The Android lifecycle owner for the observers. Defaults to
|
||||
* the [ProcessLifecycleOwner].
|
||||
* @param autoPause Whether or not the observer should automatically be
|
||||
* paused/resumed with the bound lifecycle.
|
||||
* @param onTabsClosed The callback invoked when one or more tabs are closed.
|
||||
*/
|
||||
class CloseTabsFeature(
|
||||
private val browserStore: BrowserStore,
|
||||
private val accountManager: FxaAccountManager,
|
||||
private val owner: LifecycleOwner = ProcessLifecycleOwner.get(),
|
||||
private val autoPause: Boolean = false,
|
||||
onTabsClosed: (Device?, List<String>) -> Unit,
|
||||
) {
|
||||
@VisibleForTesting internal val observer = TabsClosedEventsObserver { device, urls ->
|
||||
val tabsToRemove = getTabsToRemove(urls)
|
||||
if (tabsToRemove.isNotEmpty()) {
|
||||
browserStore.dispatch(TabListAction.RemoveTabsAction(tabsToRemove.map { it.id }))
|
||||
onTabsClosed(device, tabsToRemove.map { it.content.url })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins observing the [accountManager] for "tabs closed" events.
|
||||
*/
|
||||
fun observe() {
|
||||
accountManager.registerForAccountEvents(observer, owner, autoPause)
|
||||
}
|
||||
|
||||
private fun getTabsToRemove(remotelyClosedUrls: List<String>): List<TabSessionState> {
|
||||
// The user might have the same URL open in multiple tabs on this device, and might want
|
||||
// to remotely close some or all of those tabs. Synced tabs don't carry enough
|
||||
// information to know which duplicates the user meant to close, so we use a heuristic:
|
||||
// if a URL appears N times in the remotely closed URLs list, we'll close up to
|
||||
// N instances of that URL.
|
||||
val countsByUrl = remotelyClosedUrls.groupingBy { it }.eachCount()
|
||||
return browserStore.state.tabs
|
||||
.groupBy { it.content.url }
|
||||
.asSequence()
|
||||
.mapNotNull { (url, tabs) ->
|
||||
countsByUrl[url]?.let { count -> tabs.take(count) }
|
||||
}
|
||||
.flatten()
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
internal class TabsClosedEventsObserver(
|
||||
internal val onTabsClosed: (Device?, List<String>) -> Unit,
|
||||
) : AccountEventsObserver {
|
||||
override fun onEvents(events: List<AccountEvent>) {
|
||||
// Group multiple commands from the same device, so that we can close
|
||||
// more tabs at once.
|
||||
events.asSequence()
|
||||
.filterIsInstance<AccountEvent.DeviceCommandIncoming>()
|
||||
.map { it.command }
|
||||
.filterIsInstance<DeviceCommandIncoming.TabsClosed>()
|
||||
.groupingBy { it.from }
|
||||
.fold(emptyList<String>()) { urls, command -> urls + command.urls }
|
||||
.forEach { (device, urls) ->
|
||||
onTabsClosed(device, urls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.accounts.push
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.annotation.WorkerThread
|
||||
import mozilla.components.concept.sync.Device
|
||||
import mozilla.components.concept.sync.DeviceCapability
|
||||
import mozilla.components.concept.sync.DeviceCommandOutgoing
|
||||
import mozilla.components.concept.sync.DeviceConstellation
|
||||
import mozilla.components.service.fxa.manager.FxaAccountManager
|
||||
|
||||
/**
|
||||
* Use cases for closing tabs that are open on other devices in the [DeviceConstellation].
|
||||
*
|
||||
* The use cases send commands to close tabs using the [FxaAccountManager].
|
||||
*
|
||||
* See [CloseTabsFeature] for the ability to close tabs on this device from
|
||||
* other devices.
|
||||
*
|
||||
* @param accountManager The account manager.
|
||||
*/
|
||||
class CloseTabsUseCases(private val accountManager: FxaAccountManager) {
|
||||
/**
|
||||
* Closes a tab that's currently open on another device.
|
||||
*
|
||||
* @param deviceId The ID of the device on which the tab is currently open.
|
||||
* @param url The URL of the tab to close.
|
||||
* @return Whether the command to close the tab was sent to the device.
|
||||
*/
|
||||
@WorkerThread
|
||||
suspend fun close(deviceId: String, url: String): Boolean {
|
||||
filterCloseTabsDevices(accountManager) { constellation, devices ->
|
||||
val device = devices.firstOrNull { it.id == deviceId }
|
||||
device?.let {
|
||||
return constellation.sendCommandToDevice(
|
||||
device.id,
|
||||
DeviceCommandOutgoing.CloseTab(listOf(url)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal inline fun filterCloseTabsDevices(
|
||||
accountManager: FxaAccountManager,
|
||||
block: (DeviceConstellation, Collection<Device>) -> Unit,
|
||||
) {
|
||||
val constellation = accountManager.authenticatedAccount()?.deviceConstellation() ?: return
|
||||
|
||||
constellation.state()?.let { state ->
|
||||
state.otherDevices.filter {
|
||||
it.capabilities.contains(DeviceCapability.CLOSE_TABS)
|
||||
}.let { devices ->
|
||||
block(constellation, devices)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,7 +24,7 @@ import mozilla.components.support.base.log.logger.Logger
|
|||
*
|
||||
* See [SendTabUseCases] for the ability to send tabs to other devices.
|
||||
*
|
||||
* @param accountManager Firefox account manager.
|
||||
* @param accountManager Account manager.
|
||||
* @param owner Android lifecycle owner for the observers. Defaults to the [ProcessLifecycleOwner]
|
||||
* so that we can always observe events throughout the application lifecycle.
|
||||
* @param autoPause whether or not the observer should automatically be
|
||||
|
|
@ -38,7 +38,7 @@ class SendTabFeature(
|
|||
onTabsReceived: (Device?, List<TabData>) -> Unit,
|
||||
) {
|
||||
init {
|
||||
val observer = EventsObserver(onTabsReceived)
|
||||
val observer = TabReceivedEventsObserver(onTabsReceived)
|
||||
|
||||
// Observe the account for all account events, although we'll ignore
|
||||
// non send-tab command events.
|
||||
|
|
@ -46,10 +46,10 @@ class SendTabFeature(
|
|||
}
|
||||
}
|
||||
|
||||
internal class EventsObserver(
|
||||
internal class TabReceivedEventsObserver(
|
||||
private val onTabsReceived: (Device?, List<TabData>) -> Unit,
|
||||
) : AccountEventsObserver {
|
||||
private val logger = Logger("EventsObserver")
|
||||
private val logger = Logger("TabReceivedEventsObserver")
|
||||
|
||||
override fun onEvents(events: List<AccountEvent>) {
|
||||
events.asSequence()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
/* 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.accounts.push
|
||||
|
||||
import mozilla.components.browser.state.state.BrowserState
|
||||
import mozilla.components.browser.state.state.createTab
|
||||
import mozilla.components.browser.state.store.BrowserStore
|
||||
import mozilla.components.concept.sync.Device
|
||||
import mozilla.components.concept.sync.DeviceCapability
|
||||
import mozilla.components.concept.sync.DeviceType
|
||||
import mozilla.components.support.test.any
|
||||
import mozilla.components.support.test.eq
|
||||
import mozilla.components.support.test.libstate.ext.waitUntilIdle
|
||||
import mozilla.components.support.test.mock
|
||||
import mozilla.components.support.test.rule.MainCoroutineRule
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.mockito.Mockito.never
|
||||
import org.mockito.Mockito.verify
|
||||
|
||||
class CloseTabsFeatureTest {
|
||||
@get:Rule
|
||||
val coroutinesTestRule = MainCoroutineRule()
|
||||
|
||||
private val device123 = Device(
|
||||
id = "123",
|
||||
displayName = "Charcoal",
|
||||
deviceType = DeviceType.DESKTOP,
|
||||
isCurrentDevice = false,
|
||||
lastAccessTime = null,
|
||||
capabilities = listOf(DeviceCapability.CLOSE_TABS),
|
||||
subscriptionExpired = true,
|
||||
subscription = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `GIVEN a notification to close multiple URLs WHEN all URLs are open in tabs THEN all tabs are closed and the callback is invoked`() {
|
||||
val urls = listOf(
|
||||
"https://mozilla.org",
|
||||
"https://getfirefox.com",
|
||||
"https://example.org",
|
||||
"https://getthunderbird.com",
|
||||
)
|
||||
val browserStore = BrowserStore(
|
||||
BrowserState(
|
||||
tabs = urls.map { createTab(it) },
|
||||
),
|
||||
)
|
||||
val callback: (Device?, List<String>) -> Unit = mock()
|
||||
val feature = CloseTabsFeature(
|
||||
browserStore,
|
||||
accountManager = mock(),
|
||||
owner = mock(),
|
||||
onTabsClosed = callback,
|
||||
)
|
||||
|
||||
feature.observer.onTabsClosed(device123, urls)
|
||||
|
||||
browserStore.waitUntilIdle()
|
||||
|
||||
assertTrue(browserStore.state.tabs.isEmpty())
|
||||
verify(callback).invoke(eq(device123), eq(urls))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a notification to close a URL WHEN the URL is not open in a tab THEN the callback is not invoked`() {
|
||||
val browserStore = BrowserStore()
|
||||
val callback: (Device?, List<String>) -> Unit = mock()
|
||||
val feature = CloseTabsFeature(
|
||||
browserStore,
|
||||
accountManager = mock(),
|
||||
owner = mock(),
|
||||
onTabsClosed = callback,
|
||||
)
|
||||
|
||||
feature.observer.onTabsClosed(device123, listOf("https://mozilla.org"))
|
||||
|
||||
browserStore.waitUntilIdle()
|
||||
|
||||
verify(callback, never()).invoke(any(), any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a notification to close duplicate URLs WHEN the duplicate URLs are open in tabs THEN the number of tabs closed matches the number of URLs and the callback is invoked`() {
|
||||
val browserStore = BrowserStore(
|
||||
BrowserState(
|
||||
tabs = listOf(
|
||||
createTab("https://mozilla.org", id = "1"),
|
||||
createTab("https://mozilla.org", id = "2"),
|
||||
createTab("https://getfirefox.com", id = "3"),
|
||||
createTab("https://getfirefox.com", id = "4"),
|
||||
createTab("https://getfirefox.com", id = "5"),
|
||||
createTab("https://getthunderbird.com", id = "6"),
|
||||
createTab("https://example.org", id = "7"),
|
||||
),
|
||||
),
|
||||
)
|
||||
val callback: (Device?, List<String>) -> Unit = mock()
|
||||
val feature = CloseTabsFeature(
|
||||
browserStore,
|
||||
accountManager = mock(),
|
||||
owner = mock(),
|
||||
onTabsClosed = callback,
|
||||
)
|
||||
|
||||
feature.observer.onTabsClosed(
|
||||
device123,
|
||||
listOf(
|
||||
"https://mozilla.org",
|
||||
"https://getfirefox.com",
|
||||
"https://getfirefox.com",
|
||||
"https://example.org",
|
||||
"https://example.org",
|
||||
),
|
||||
)
|
||||
|
||||
browserStore.waitUntilIdle()
|
||||
|
||||
assertEquals(listOf("2", "5", "6"), browserStore.state.tabs.map { it.id })
|
||||
verify(callback).invoke(
|
||||
eq(device123),
|
||||
eq(
|
||||
listOf(
|
||||
"https://mozilla.org",
|
||||
"https://getfirefox.com",
|
||||
"https://getfirefox.com",
|
||||
"https://example.org",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
/* 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.accounts.push
|
||||
|
||||
import mozilla.components.concept.sync.ConstellationState
|
||||
import mozilla.components.concept.sync.Device
|
||||
import mozilla.components.concept.sync.DeviceCapability
|
||||
import mozilla.components.concept.sync.DeviceConstellation
|
||||
import mozilla.components.concept.sync.DeviceType
|
||||
import mozilla.components.concept.sync.OAuthAccount
|
||||
import mozilla.components.service.fxa.manager.FxaAccountManager
|
||||
import mozilla.components.support.test.any
|
||||
import mozilla.components.support.test.mock
|
||||
import mozilla.components.support.test.rule.MainCoroutineRule
|
||||
import mozilla.components.support.test.rule.runTestOnMain
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.mockito.Mockito.never
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.Mockito.`when`
|
||||
|
||||
class CloseTabsUseCasesTest {
|
||||
@get:Rule
|
||||
val coroutinesTestRule = MainCoroutineRule()
|
||||
|
||||
private val device123 = Device(
|
||||
id = "123",
|
||||
displayName = "Charcoal",
|
||||
deviceType = DeviceType.DESKTOP,
|
||||
isCurrentDevice = false,
|
||||
lastAccessTime = null,
|
||||
capabilities = listOf(DeviceCapability.CLOSE_TABS),
|
||||
subscriptionExpired = true,
|
||||
subscription = null,
|
||||
)
|
||||
|
||||
private val device1234 = Device(
|
||||
id = "1234",
|
||||
displayName = "Ruby",
|
||||
deviceType = DeviceType.DESKTOP,
|
||||
isCurrentDevice = false,
|
||||
lastAccessTime = null,
|
||||
capabilities = emptyList(),
|
||||
subscriptionExpired = true,
|
||||
subscription = null,
|
||||
)
|
||||
|
||||
private val manager: FxaAccountManager = mock()
|
||||
private val account: OAuthAccount = mock()
|
||||
private val constellation: DeviceConstellation = mock()
|
||||
private val state: ConstellationState = mock()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
`when`(manager.authenticatedAccount()).thenReturn(account)
|
||||
`when`(account.deviceConstellation()).thenReturn(constellation)
|
||||
`when`(constellation.state()).thenReturn(state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a list of devices WHEN one device supports the close tabs command THEN filtering returns that device`() {
|
||||
val deviceIds = mutableListOf<String>()
|
||||
`when`(state.otherDevices).thenReturn(listOf(device123, device1234))
|
||||
filterCloseTabsDevices(manager) { _, devices ->
|
||||
deviceIds.addAll(devices.map { it.id })
|
||||
}
|
||||
|
||||
assertEquals(listOf("123"), deviceIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a constellation with one capable device WHEN sending a close tabs command to that device THEN the command is sent`() = runTestOnMain {
|
||||
val useCases = CloseTabsUseCases(manager)
|
||||
|
||||
`when`(state.otherDevices).thenReturn(listOf(device123))
|
||||
`when`(constellation.sendCommandToDevice(any(), any()))
|
||||
.thenReturn(true)
|
||||
|
||||
useCases.close("123", "http://example.com")
|
||||
|
||||
verify(constellation).sendCommandToDevice(any(), any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a constellation with one incapable device WHEN sending a close tabs command to that device THEN the command is not sent`() = runTestOnMain {
|
||||
val useCases = CloseTabsUseCases(manager)
|
||||
|
||||
`when`(state.otherDevices).thenReturn(listOf(device1234))
|
||||
`when`(constellation.sendCommandToDevice(any(), any()))
|
||||
.thenReturn(false)
|
||||
|
||||
useCases.close("1234", "http://example.com")
|
||||
|
||||
verify(constellation, never()).sendCommandToDevice(any(), any())
|
||||
}
|
||||
}
|
||||
|
|
@ -15,11 +15,11 @@ import org.junit.Test
|
|||
import org.mockito.Mockito.times
|
||||
import org.mockito.Mockito.verify
|
||||
|
||||
class EventsObserverTest {
|
||||
class TabReceivedEventsObserverTest {
|
||||
@Test
|
||||
fun `events are delivered successfully`() {
|
||||
val callback: (Device?, List<TabData>) -> Unit = mock()
|
||||
val observer = EventsObserver(callback)
|
||||
val observer = TabReceivedEventsObserver(callback)
|
||||
val events = listOf(AccountEvent.DeviceCommandIncoming(command = DeviceCommandIncoming.TabReceived(mock(), mock())))
|
||||
|
||||
observer.onEvents(events)
|
||||
|
|
@ -34,7 +34,7 @@ class EventsObserverTest {
|
|||
@Test
|
||||
fun `only TabReceived commands are delivered`() {
|
||||
val callback: (Device?, List<TabData>) -> Unit = mock()
|
||||
val observer = EventsObserver(callback)
|
||||
val observer = TabReceivedEventsObserver(callback)
|
||||
val events = listOf(
|
||||
AccountEvent.ProfileUpdated,
|
||||
AccountEvent.DeviceCommandIncoming(command = DeviceCommandIncoming.TabReceived(mock(), mock())),
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
/* 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.accounts.push
|
||||
|
||||
import mozilla.components.concept.sync.AccountEvent
|
||||
import mozilla.components.concept.sync.Device
|
||||
import mozilla.components.concept.sync.DeviceCapability
|
||||
import mozilla.components.concept.sync.DeviceCommandIncoming
|
||||
import mozilla.components.concept.sync.DeviceType
|
||||
import mozilla.components.support.test.any
|
||||
import mozilla.components.support.test.eq
|
||||
import mozilla.components.support.test.mock
|
||||
import org.junit.Test
|
||||
import org.mockito.Mockito.times
|
||||
import org.mockito.Mockito.verify
|
||||
|
||||
class TabsClosedEventsObserverTest {
|
||||
private val device123 = Device(
|
||||
id = "123",
|
||||
displayName = "Charcoal",
|
||||
deviceType = DeviceType.DESKTOP,
|
||||
isCurrentDevice = false,
|
||||
lastAccessTime = null,
|
||||
capabilities = listOf(DeviceCapability.CLOSE_TABS),
|
||||
subscriptionExpired = true,
|
||||
subscription = null,
|
||||
)
|
||||
|
||||
private val device1234 = Device(
|
||||
id = "1234",
|
||||
displayName = "Emerald",
|
||||
deviceType = DeviceType.MOBILE,
|
||||
isCurrentDevice = false,
|
||||
lastAccessTime = null,
|
||||
capabilities = listOf(DeviceCapability.CLOSE_TABS),
|
||||
subscriptionExpired = true,
|
||||
subscription = null,
|
||||
)
|
||||
|
||||
private val device12345 = Device(
|
||||
id = "12345",
|
||||
displayName = "Sapphire",
|
||||
deviceType = DeviceType.MOBILE,
|
||||
isCurrentDevice = false,
|
||||
lastAccessTime = null,
|
||||
capabilities = listOf(DeviceCapability.CLOSE_TABS),
|
||||
subscriptionExpired = true,
|
||||
subscription = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `GIVEN a tabs closed command WHEN the observer is notified THEN the callback is invoked`() {
|
||||
val callback: (Device?, List<String>) -> Unit = mock()
|
||||
val observer = TabsClosedEventsObserver(callback)
|
||||
val events = listOf(
|
||||
AccountEvent.DeviceCommandIncoming(
|
||||
command = DeviceCommandIncoming.TabsClosed(
|
||||
null,
|
||||
listOf("https://mozilla.org"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
observer.onEvents(events)
|
||||
|
||||
verify(callback).invoke(eq(null), eq(listOf("https://mozilla.org")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a tabs closed command from a device WHEN the observer is notified THEN the callback is invoked`() {
|
||||
val callback: (Device?, List<String>) -> Unit = mock()
|
||||
val observer = TabsClosedEventsObserver(callback)
|
||||
val events = listOf(
|
||||
AccountEvent.DeviceCommandIncoming(
|
||||
command = DeviceCommandIncoming.TabsClosed(
|
||||
device123,
|
||||
listOf("https://mozilla.org"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
observer.onEvents(events)
|
||||
|
||||
verify(callback).invoke(eq(device123), eq(listOf("https://mozilla.org")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN multiple commands WHEN the observer is notified THEN the callback is only invoked for the tabs closed commands`() {
|
||||
val callback: (Device?, List<String>) -> Unit = mock()
|
||||
val observer = TabsClosedEventsObserver(callback)
|
||||
val events = listOf(
|
||||
AccountEvent.ProfileUpdated,
|
||||
AccountEvent.DeviceCommandIncoming(
|
||||
command = DeviceCommandIncoming.TabsClosed(
|
||||
device123,
|
||||
listOf("https://mozilla.org"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
observer.onEvents(events)
|
||||
|
||||
verify(callback, times(1)).invoke(eq(device123), eq(listOf("https://mozilla.org")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN multiple tabs closed commands from the same device WHEN the observer is notified THEN the callback is invoked once`() {
|
||||
val callback: (Device?, List<String>) -> Unit = mock()
|
||||
val observer = TabsClosedEventsObserver(callback)
|
||||
val events = listOf(
|
||||
AccountEvent.DeviceCommandIncoming(
|
||||
command = DeviceCommandIncoming.TabsClosed(
|
||||
device123,
|
||||
listOf("https://mozilla.org", "https://getfirefox.com"),
|
||||
),
|
||||
),
|
||||
AccountEvent.DeviceCommandIncoming(
|
||||
command = DeviceCommandIncoming.TabsClosed(
|
||||
device123,
|
||||
listOf("https://example.org"),
|
||||
),
|
||||
),
|
||||
AccountEvent.DeviceCommandIncoming(
|
||||
command = DeviceCommandIncoming.TabsClosed(
|
||||
device123,
|
||||
listOf("https://getthunderbird.com"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
observer.onEvents(events)
|
||||
|
||||
verify(callback, times(1)).invoke(
|
||||
eq(device123),
|
||||
eq(
|
||||
listOf(
|
||||
"https://mozilla.org",
|
||||
"https://getfirefox.com",
|
||||
"https://example.org",
|
||||
"https://getthunderbird.com",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN multiple tabs closed commands from different devices WHEN the observer is notified THEN the callback is invoked once per device`() {
|
||||
val callback: (Device?, List<String>) -> Unit = mock()
|
||||
val observer = TabsClosedEventsObserver(callback)
|
||||
val events = listOf(
|
||||
AccountEvent.DeviceCommandIncoming(
|
||||
command = DeviceCommandIncoming.TabsClosed(
|
||||
null,
|
||||
listOf("https://mozilla.org"),
|
||||
),
|
||||
),
|
||||
AccountEvent.DeviceCommandIncoming(
|
||||
command = DeviceCommandIncoming.TabsClosed(
|
||||
device123,
|
||||
listOf("https://mozilla.org"),
|
||||
),
|
||||
),
|
||||
AccountEvent.DeviceCommandIncoming(
|
||||
command = DeviceCommandIncoming.TabsClosed(
|
||||
device1234,
|
||||
listOf("https://mozilla.org"),
|
||||
),
|
||||
),
|
||||
AccountEvent.DeviceCommandIncoming(
|
||||
command = DeviceCommandIncoming.TabsClosed(
|
||||
device12345,
|
||||
listOf("https://mozilla.org"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
observer.onEvents(events)
|
||||
|
||||
verify(callback, times(4)).invoke(any(), eq(listOf("https://mozilla.org")))
|
||||
}
|
||||
}
|
||||
|
|
@ -191,6 +191,9 @@ class FxaDeviceConstellation(
|
|||
crashReporter?.submitCaughtException(error)
|
||||
}
|
||||
}
|
||||
is DeviceCommandOutgoing.CloseTab -> {
|
||||
account.closeTabs(targetDeviceId, outgoingCommand.urls)
|
||||
}
|
||||
else -> logger.debug("Skipped sending unsupported command type: $outgoingCommand")
|
||||
}
|
||||
null
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ fun DeviceType.into(): RustDeviceType {
|
|||
fun DeviceCapability.into(): RustDeviceCapability {
|
||||
return when (this) {
|
||||
DeviceCapability.SEND_TAB -> RustDeviceCapability.SEND_TAB
|
||||
DeviceCapability.CLOSE_TABS -> RustDeviceCapability.CLOSE_TABS
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -164,6 +165,7 @@ fun DeviceCapability.into(): RustDeviceCapability {
|
|||
fun RustDeviceCapability.into(): DeviceCapability {
|
||||
return when (this) {
|
||||
RustDeviceCapability.SEND_TAB -> DeviceCapability.SEND_TAB
|
||||
RustDeviceCapability.CLOSE_TABS -> DeviceCapability.CLOSE_TABS
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -255,6 +257,7 @@ fun AccountEvent.into(): mozilla.components.concept.sync.AccountEvent {
|
|||
fun IncomingDeviceCommand.into(): mozilla.components.concept.sync.DeviceCommandIncoming {
|
||||
return when (this) {
|
||||
is IncomingDeviceCommand.TabReceived -> this.into()
|
||||
is IncomingDeviceCommand.TabsClosed -> this.into()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -264,3 +267,10 @@ fun IncomingDeviceCommand.TabReceived.into(): mozilla.components.concept.sync.De
|
|||
entries = this.payload.entries.map { it.into() },
|
||||
)
|
||||
}
|
||||
|
||||
fun IncomingDeviceCommand.TabsClosed.into(): mozilla.components.concept.sync.DeviceCommandIncoming.TabsClosed {
|
||||
return mozilla.components.concept.sync.DeviceCommandIncoming.TabsClosed(
|
||||
from = this.sender?.into(),
|
||||
urls = this.payload.urls,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -307,6 +307,7 @@
|
|||
<ID>UndocumentedPublicFunction:Types.kt$fun AccountEvent.into(): mozilla.components.concept.sync.AccountEvent</ID>
|
||||
<ID>UndocumentedPublicFunction:Types.kt$fun Device.into(): mozilla.components.concept.sync.Device</ID>
|
||||
<ID>UndocumentedPublicFunction:Types.kt$fun IncomingDeviceCommand.TabReceived.into(): mozilla.components.concept.sync.DeviceCommandIncoming.TabReceived</ID>
|
||||
<ID>UndocumentedPublicFunction:Types.kt$fun IncomingDeviceCommand.TabsClosed.into(): mozilla.components.concept.sync.DeviceCommandIncoming.TabsClosed</ID>
|
||||
<ID>UndocumentedPublicFunction:Types.kt$fun IncomingDeviceCommand.into(): mozilla.components.concept.sync.DeviceCommandIncoming</ID>
|
||||
<ID>UndocumentedPublicFunction:Types.kt$fun Profile.into(): mozilla.components.concept.sync.Profile</ID>
|
||||
<ID>UndocumentedPublicFunction:Types.kt$fun ScopedKey.into(): OAuthScopedKey</ID>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,15 @@ permalink: /changelog/
|
|||
* **browser-engine-gecko**
|
||||
* For screenshot capture, include exception in failure result rather than throwing.
|
||||
|
||||
* **feature-accounts-push**
|
||||
* 🆕 New `CloseTabsFeature` for closing tabs on this device from other devices that are signed to the same Mozilla account.
|
||||
* 🆕 New `CloseTabsUseCase` for closing tabs on other devices from this device.
|
||||
|
||||
* **concept-sync**
|
||||
* 🆕 New `DeviceCapability.CLOSE_TABS` variant to indicate that a device supports closing synced tabs.
|
||||
* 🆕 New `DeviceCommandIncoming.TabsClosed` variant to represent a "close synced tabs" command received from another device.
|
||||
* 🆕 New `DeviceCommandOutgoing.CloseTab` variant to represent a "close synced tabs" sent to another device.
|
||||
|
||||
# 126.0
|
||||
|
||||
* **browser-menu**
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
// These lines are generated by android-components/automation/application-services-nightly-bump.py
|
||||
val VERSION = "127.20240503050236"
|
||||
val VERSION = "127.20240504050247"
|
||||
val CHANNEL = ApplicationServicesChannel.NIGHTLY
|
||||
|
||||
object ApplicationServicesConfig {
|
||||
|
|
|
|||
|
|
@ -31,11 +31,11 @@ origin:
|
|||
|
||||
# Human-readable identifier for this version/release
|
||||
# Generally "version NNN", "tag SSS", "bookmark SSS"
|
||||
release: 5e198a9ab0001e91535e9dfc8908e56bdcfe1c6a (2024-05-03T05:02:36).
|
||||
release: f87a569e36804553dff78c58f9a12bb6079f6ec2 (2024-05-04T05:02:47).
|
||||
|
||||
# Revision to pull in
|
||||
# Must be a long or short commit SHA (long preferred)
|
||||
revision: 5e198a9ab0001e91535e9dfc8908e56bdcfe1c6a
|
||||
revision: f87a569e36804553dff78c58f9a12bb6079f6ec2
|
||||
|
||||
# The package's license, where possible using the mnemonic from
|
||||
# https://spdx.org/licenses/
|
||||
|
|
|
|||
|
|
@ -278,15 +278,20 @@ class MainActivity :
|
|||
events.forEach {
|
||||
when (it) {
|
||||
is AccountEvent.DeviceCommandIncoming -> {
|
||||
when (it.command) {
|
||||
val cmd = it.command
|
||||
when (cmd) {
|
||||
is DeviceCommandIncoming.TabReceived -> {
|
||||
val cmd = it.command as DeviceCommandIncoming.TabReceived
|
||||
var tabsStringified = "Tab(s) from: ${cmd.from?.displayName}\n"
|
||||
cmd.entries.forEach { tab ->
|
||||
tabsStringified += "${tab.title}: ${tab.url}\n"
|
||||
}
|
||||
txtView.text = tabsStringified
|
||||
}
|
||||
is DeviceCommandIncoming.TabsClosed -> {
|
||||
var urlsStringified = "Tabs closed from: ${cmd.from?.displayName}\n"
|
||||
cmd.urls.forEach { url -> urlsStringified += "${url}\n" }
|
||||
txtView.text = urlsStringified
|
||||
}
|
||||
}
|
||||
}
|
||||
is AccountEvent.ProfileUpdated -> {
|
||||
|
|
|
|||
Loading…
Reference in a new issue