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:
Lina Butler 2024-05-05 22:07:11 +00:00
parent 12f62a9cf4
commit 7bfef278ee
18 changed files with 658 additions and 34 deletions

View file

@ -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

View file

@ -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()
}
/**

View file

@ -158,6 +158,7 @@ data class DeviceConfig(
*/
enum class DeviceCapability {
SEND_TAB,
CLOSE_TABS,
}
/**

View file

@ -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

View file

@ -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)
}
}
}

View file

@ -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)
}
}
}

View file

@ -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()

View file

@ -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",
),
),
)
}
}

View file

@ -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())
}
}

View file

@ -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())),

View file

@ -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")))
}
}

View file

@ -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

View file

@ -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,
)
}

View file

@ -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>

View file

@ -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**

View file

@ -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 {

View file

@ -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/

View file

@ -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 -> {