diff options
Diffstat (limited to 'mobile/android/android-components/components/feature/accounts-push')
8 files changed, 585 insertions, 7 deletions
diff --git a/mobile/android/android-components/components/feature/accounts-push/build.gradle b/mobile/android/android-components/components/feature/accounts-push/build.gradle index c87fa7582a..17bc5d8313 100644 --- a/mobile/android/android-components/components/feature/accounts-push/build.gradle +++ b/mobile/android/android-components/components/feature/accounts-push/build.gradle @@ -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 diff --git a/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/CloseTabsFeature.kt b/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/CloseTabsFeature.kt new file mode 100644 index 0000000000..8767064867 --- /dev/null +++ b/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/CloseTabsFeature.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.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) + } + } +} diff --git a/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/CloseTabsUseCases.kt b/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/CloseTabsUseCases.kt new file mode 100644 index 0000000000..845ae27a98 --- /dev/null +++ b/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/CloseTabsUseCases.kt @@ -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) + } + } +} diff --git a/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/SendTabFeature.kt b/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/SendTabFeature.kt index 4f049a790b..931dcf59a6 100644 --- a/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/SendTabFeature.kt +++ b/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/SendTabFeature.kt @@ -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() diff --git a/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/CloseTabsFeatureTest.kt b/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/CloseTabsFeatureTest.kt new file mode 100644 index 0000000000..7b18681dce --- /dev/null +++ b/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/CloseTabsFeatureTest.kt @@ -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", + ), + ), + ) + } +} diff --git a/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/CloseTabsUseCasesTest.kt b/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/CloseTabsUseCasesTest.kt new file mode 100644 index 0000000000..4aae8c84f6 --- /dev/null +++ b/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/CloseTabsUseCasesTest.kt @@ -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()) + } +} diff --git a/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/EventsObserverTest.kt b/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/TabReceivedEventsObserverTest.kt index 6de8ff42f6..1b8128d4ba 100644 --- a/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/EventsObserverTest.kt +++ b/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/TabReceivedEventsObserverTest.kt @@ -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())), diff --git a/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/TabsClosedEventsObserverTest.kt b/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/TabsClosedEventsObserverTest.kt new file mode 100644 index 0000000000..8d51d0b812 --- /dev/null +++ b/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/TabsClosedEventsObserverTest.kt @@ -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"))) + } +} |