summaryrefslogtreecommitdiffstats
path: root/mobile/android/android-components/components/feature/syncedtabs
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/android-components/components/feature/syncedtabs')
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/README.md36
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/build.gradle64
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/ClientTabPair.kt17
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/SyncedTabsAutocompleteProvider.kt51
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/SyncedTabsFeature.kt74
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/SyncedTabsStorageSuggestionProvider.kt92
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/controller/DefaultController.kt68
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/controller/SyncedTabsController.kt30
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/ext/SyncedTabsStorage.kt43
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/facts/SyncedTabsFacts.kt44
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/interactor/DefaultInteractor.kt32
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/interactor/SyncedTabsInteractor.kt19
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/presenter/DefaultPresenter.kt142
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/presenter/SyncedTabsPresenter.kt20
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/storage/SyncedTabsProvider.kt18
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/storage/SyncedTabsStorage.kt119
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/view/SyncedTabsView.kt89
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/SyncedTabsAutocompleteProviderKtTest.kt68
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/SyncedTabsFeatureTest.kt55
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/SyncedTabsStorageSuggestionProviderTest.kt88
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/controller/DefaultControllerTest.kt149
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/ext/SyncedTabsStorageKtTest.kt65
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/facts/SyncedTabsFactsTest.kt29
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/helper/SyncedTabsProvider.kt83
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/interactor/DefaultInteractorTest.kt107
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/presenter/DefaultPresenterTest.kt256
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/storage/SyncedTabsStorageTest.kt392
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker2
-rw-r--r--mobile/android/android-components/components/feature/syncedtabs/src/test/resources/robolectric.properties1
31 files changed, 2278 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/feature/syncedtabs/README.md b/mobile/android/android-components/components/feature/syncedtabs/README.md
new file mode 100644
index 0000000000..433b7a96de
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/README.md
@@ -0,0 +1,36 @@
+# [Android Components](../../../README.md) > Feature > Synced Tabs
+
+Feature component for viewing tabs from other devices with a registered Fx Account.
+
+## Usage
+
+### Setting up the dependency
+
+Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)):
+
+```Groovy
+implementation "org.mozilla.components:feature-syncedtabs:{latest-version}"
+```
+
+## Usage
+
+In order to make use of the synced tab feature here, it's required to have an an FxA Account setup and Sync enabled.
+See the [service-firefox-accounts](../../service/firefox-accounts/README.md) for more information how to set this up.
+
+```kotlin
+ // The feature will start listening to local tabs changes.
+ val syncedTabsFeature = SyncedTabsFeature(
+ accountManager = accountManager,
+ store = browserStore,
+ tabsStorage = tabsStorage
+ )
+ // Grab the list of opened tabs on other devices.
+ val otherDevicesTabs: Map<Device, List<Tab>> = syncedTabsFeature.getSyncedTabs()
+
+```
+
+## License
+
+ 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/
diff --git a/mobile/android/android-components/components/feature/syncedtabs/build.gradle b/mobile/android/android-components/components/feature/syncedtabs/build.gradle
new file mode 100644
index 0000000000..7934e6270a
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/build.gradle
@@ -0,0 +1,64 @@
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+
+/* 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/.
+ */
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ defaultConfig {
+ minSdkVersion config.minSdkVersion
+ compileSdk config.compileSdkVersion
+ targetSdkVersion config.targetSdkVersion
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ packagingOptions {
+ exclude 'META-INF/proguard/androidx-annotations.pro'
+ }
+
+ namespace 'mozilla.components.feature.sendtab'
+}
+
+tasks.withType(KotlinCompile).configureEach {
+ kotlinOptions.freeCompilerArgs += "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi"
+}
+
+dependencies {
+ implementation project(':service-firefox-accounts')
+ implementation project(':browser-icons')
+ implementation project(':browser-state')
+ implementation project(':browser-storage-sync')
+ implementation project(':concept-awesomebar')
+ implementation project(':concept-engine')
+ implementation project(':concept-toolbar')
+ implementation project(':feature-session')
+ implementation project(':support-utils')
+ implementation project(':support-ktx')
+ implementation project(':support-base')
+
+ implementation ComponentsDependencies.androidx_work_runtime
+ implementation ComponentsDependencies.androidx_lifecycle_runtime
+
+ implementation ComponentsDependencies.kotlin_coroutines
+
+ testImplementation project(':support-test')
+
+ testImplementation ComponentsDependencies.androidx_test_junit
+ testImplementation ComponentsDependencies.testing_coroutines
+ testImplementation ComponentsDependencies.testing_robolectric
+}
+
+apply from: '../../../android-lint.gradle'
+apply from: '../../../publish.gradle'
+ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
diff --git a/mobile/android/android-components/components/feature/syncedtabs/proguard-rules.pro b/mobile/android/android-components/components/feature/syncedtabs/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/main/AndroidManifest.xml b/mobile/android/android-components/components/feature/syncedtabs/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..e16cda1d34
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+<!-- 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/. -->
+<manifest />
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/ClientTabPair.kt b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/ClientTabPair.kt
new file mode 100644
index 0000000000..afcc2c570b
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/ClientTabPair.kt
@@ -0,0 +1,17 @@
+/* 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.syncedtabs
+
+import mozilla.components.browser.storage.sync.TabEntry
+import mozilla.components.concept.sync.DeviceType
+
+/**
+ * Mapping of a device and the active [TabEntry] for each synced tab.
+ */
+internal data class ClientTabPair(
+ val clientName: String,
+ val tab: TabEntry,
+ val lastUsed: Long,
+ val deviceType: DeviceType,
+)
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/SyncedTabsAutocompleteProvider.kt b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/SyncedTabsAutocompleteProvider.kt
new file mode 100644
index 0000000000..71ee683f59
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/SyncedTabsAutocompleteProvider.kt
@@ -0,0 +1,51 @@
+/* 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.syncedtabs
+
+import androidx.annotation.VisibleForTesting
+import mozilla.components.concept.toolbar.AutocompleteProvider
+import mozilla.components.concept.toolbar.AutocompleteResult
+import mozilla.components.feature.syncedtabs.ext.getActiveDeviceTabs
+import mozilla.components.feature.syncedtabs.storage.SyncedTabsStorage
+import mozilla.components.support.utils.doesUrlStartsWithText
+import mozilla.components.support.utils.segmentAwareDomainMatch
+
+@VisibleForTesting
+internal const val SYNCED_TABS_AUTOCOMPLETE_SOURCE_NAME = "syncedTabs"
+
+/**
+ * Provide autocomplete suggestions from synced tabs.
+ *
+ * @param syncedTabs [SyncedTabsStorage] containing the information about the available synced tabs.
+ * @param autocompletePriority Order in which this provider will be queried for autocomplete suggestions
+ * in relation ot others.
+ * - a lower priority means that this provider must be called before others with a higher priority.
+ * - an equal priority offers no ordering guarantees.
+ *
+ * Defaults to `0`.
+ */
+class SyncedTabsAutocompleteProvider(
+ private val syncedTabs: SyncedTabsStorage,
+ override val autocompletePriority: Int = 0,
+) : AutocompleteProvider {
+ override suspend fun getAutocompleteSuggestion(query: String): AutocompleteResult? {
+ val tabUrl = syncedTabs
+ .getActiveDeviceTabs { doesUrlStartsWithText(it.url, query) }
+ .firstOrNull()
+ ?.tab?.url
+ ?: return null
+
+ val resultText = segmentAwareDomainMatch(query, arrayListOf(tabUrl))
+ return resultText?.let {
+ AutocompleteResult(
+ input = query,
+ text = it.matchedSegment,
+ url = it.url,
+ source = SYNCED_TABS_AUTOCOMPLETE_SOURCE_NAME,
+ totalItems = 1,
+ )
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/SyncedTabsFeature.kt b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/SyncedTabsFeature.kt
new file mode 100644
index 0000000000..02354bc51c
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/SyncedTabsFeature.kt
@@ -0,0 +1,74 @@
+/* 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.syncedtabs
+
+import android.content.Context
+import androidx.lifecycle.LifecycleOwner
+import kotlinx.coroutines.Dispatchers
+import mozilla.components.browser.storage.sync.Tab
+import mozilla.components.feature.syncedtabs.controller.DefaultController
+import mozilla.components.feature.syncedtabs.controller.SyncedTabsController
+import mozilla.components.feature.syncedtabs.interactor.DefaultInteractor
+import mozilla.components.feature.syncedtabs.interactor.SyncedTabsInteractor
+import mozilla.components.feature.syncedtabs.presenter.DefaultPresenter
+import mozilla.components.feature.syncedtabs.presenter.SyncedTabsPresenter
+import mozilla.components.feature.syncedtabs.storage.SyncedTabsStorage
+import mozilla.components.feature.syncedtabs.view.SyncedTabsView
+import mozilla.components.service.fxa.manager.FxaAccountManager
+import mozilla.components.support.base.feature.LifecycleAwareFeature
+import kotlin.coroutines.CoroutineContext
+
+/**
+ * Feature implementation that will keep a [SyncedTabsView] notified with other synced device tabs for
+ * the Firefox Sync account.
+ *
+ * @param storage The synced tabs storage that stores the current device's and remote device tabs.
+ * @param accountManager Firefox Account Manager that holds a Firefox Sync account.
+ * @param view An implementor of [SyncedTabsView] that will be notified of changes.
+ * @param lifecycleOwner Android Lifecycle Owner to bind observers onto.
+ * @param coroutineContext A coroutine context that can be used to perform work off the main thread.
+ * @param onTabClicked Invoked when a tab is selected by the user on the [SyncedTabsView].
+ * @param controller See [SyncedTabsController].
+ * @param presenter See [SyncedTabsPresenter].
+ * @param interactor See [SyncedTabsInteractor].
+ */
+class SyncedTabsFeature(
+ context: Context,
+ storage: SyncedTabsStorage,
+ accountManager: FxaAccountManager,
+ view: SyncedTabsView,
+ lifecycleOwner: LifecycleOwner,
+ coroutineContext: CoroutineContext = Dispatchers.IO,
+ onTabClicked: (Tab) -> Unit,
+ private val controller: SyncedTabsController = DefaultController(
+ storage,
+ accountManager,
+ view,
+ coroutineContext,
+ ),
+ private val presenter: SyncedTabsPresenter = DefaultPresenter(
+ context,
+ controller,
+ accountManager,
+ view,
+ lifecycleOwner,
+ ),
+ private val interactor: SyncedTabsInteractor = DefaultInteractor(
+ controller,
+ view,
+ onTabClicked,
+ ),
+) : LifecycleAwareFeature {
+
+ override fun start() {
+ presenter.start()
+ interactor.start()
+ }
+
+ override fun stop() {
+ presenter.stop()
+ interactor.stop()
+ }
+}
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/SyncedTabsStorageSuggestionProvider.kt b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/SyncedTabsStorageSuggestionProvider.kt
new file mode 100644
index 0000000000..b4fffdfdc2
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/SyncedTabsStorageSuggestionProvider.kt
@@ -0,0 +1,92 @@
+/* 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.syncedtabs
+
+import android.graphics.drawable.Drawable
+import androidx.annotation.VisibleForTesting
+import mozilla.components.browser.icons.BrowserIcons
+import mozilla.components.browser.icons.IconRequest
+import mozilla.components.concept.awesomebar.AwesomeBar
+import mozilla.components.concept.awesomebar.AwesomeBar.Suggestion.Flag
+import mozilla.components.concept.sync.DeviceType
+import mozilla.components.feature.session.SessionUseCases
+import mozilla.components.feature.syncedtabs.ext.getActiveDeviceTabs
+import mozilla.components.feature.syncedtabs.facts.emitSyncedTabSuggestionClickedFact
+import mozilla.components.feature.syncedtabs.storage.SyncedTabsStorage
+import java.util.UUID
+
+/**
+ * A [AwesomeBar.SuggestionProvider] implementation that provides suggestions for remote tabs
+ * based on [SyncedTabsStorage].
+ */
+class SyncedTabsStorageSuggestionProvider(
+ private val syncedTabs: SyncedTabsStorage,
+ private val loadUrlUseCase: SessionUseCases.LoadUrlUseCase,
+ private val icons: BrowserIcons? = null,
+ private val deviceIndicators: DeviceIndicators = DeviceIndicators(),
+ private val suggestionsHeader: String? = null,
+ @get:VisibleForTesting val resultsUrlFilter: ((String) -> Boolean)? = null,
+) : AwesomeBar.SuggestionProvider {
+ override val id: String = UUID.randomUUID().toString()
+
+ override fun groupTitle(): String? {
+ return suggestionsHeader
+ }
+
+ override suspend fun onInputChanged(text: String): List<AwesomeBar.Suggestion> {
+ if (text.isEmpty()) {
+ return emptyList()
+ }
+
+ val results = syncedTabs.getActiveDeviceTabs { tab ->
+ // This is a fairly naive match implementation, but this is what we do on Desktop 🤷.
+ (tab.url.contains(text, ignoreCase = true) || tab.title.contains(text, ignoreCase = true)) &&
+ resultsUrlFilter?.invoke(tab.url) != false
+ }
+
+ return results.sortedByDescending { it.lastUsed }.into()
+ }
+
+ /**
+ * Expects list of BookmarkNode to be specifically of bookmarks (e.g. nodes with a url).
+ */
+ private suspend fun List<ClientTabPair>.into(): List<AwesomeBar.Suggestion> {
+ val iconRequests = this.map { client ->
+ client.tab.iconUrl?.let { iconUrl ->
+ icons?.loadIcon(
+ IconRequest(url = iconUrl, waitOnNetworkLoad = false),
+ )
+ }
+ }
+
+ return this.zip(iconRequests) { result, icon ->
+ AwesomeBar.Suggestion(
+ provider = this@SyncedTabsStorageSuggestionProvider,
+ icon = icon?.await()?.bitmap,
+ indicatorIcon = when (result.deviceType) {
+ DeviceType.DESKTOP -> deviceIndicators.desktop
+ DeviceType.MOBILE -> deviceIndicators.mobile
+ DeviceType.TABLET -> deviceIndicators.tablet
+ else -> null
+ },
+ flags = setOf(Flag.SYNC_TAB),
+ title = result.tab.title,
+ description = result.clientName,
+ onSuggestionClicked = {
+ loadUrlUseCase.invoke(result.tab.url)
+ emitSyncedTabSuggestionClickedFact()
+ },
+ )
+ }
+ }
+}
+
+/**
+ * AwesomeBar suggestion indicators data class for desktop, mobile, tablet device types.
+ */
+data class DeviceIndicators(
+ val desktop: Drawable? = null,
+ val mobile: Drawable? = null,
+ val tablet: Drawable? = null,
+)
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/controller/DefaultController.kt b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/controller/DefaultController.kt
new file mode 100644
index 0000000000..c59fece6e2
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/controller/DefaultController.kt
@@ -0,0 +1,68 @@
+/* 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.syncedtabs.controller
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import mozilla.components.feature.syncedtabs.storage.SyncedTabsProvider
+import mozilla.components.feature.syncedtabs.view.SyncedTabsView
+import mozilla.components.feature.syncedtabs.view.SyncedTabsView.ErrorType
+import mozilla.components.service.fxa.SyncEngine
+import mozilla.components.service.fxa.manager.FxaAccountManager
+import mozilla.components.service.fxa.manager.ext.withConstellation
+import mozilla.components.service.fxa.sync.SyncReason
+import kotlin.coroutines.CoroutineContext
+
+internal class DefaultController(
+ override val provider: SyncedTabsProvider,
+ override val accountManager: FxaAccountManager,
+ override val view: SyncedTabsView,
+ coroutineContext: CoroutineContext,
+) : SyncedTabsController {
+
+ private val scope = CoroutineScope(coroutineContext)
+
+ /**
+ * See [SyncedTabsController.refreshSyncedTabs]
+ */
+ override fun refreshSyncedTabs() {
+ scope.launch {
+ accountManager.withConstellation {
+ val syncedDeviceTabs = provider.getSyncedDeviceTabs()
+ val otherDevices = state()?.otherDevices
+
+ scope.launch(Dispatchers.Main) {
+ if (syncedDeviceTabs.isEmpty() && otherDevices?.isEmpty() == true) {
+ view.onError(ErrorType.MULTIPLE_DEVICES_UNAVAILABLE)
+ } else if (syncedDeviceTabs.all { it.tabs.isEmpty() }) {
+ view.onError(ErrorType.NO_TABS_AVAILABLE)
+ } else {
+ view.displaySyncedTabs(syncedDeviceTabs)
+ }
+ }
+ }
+
+ scope.launch(Dispatchers.Main) {
+ view.stopLoading()
+ }
+ }
+ }
+
+ /**
+ * See [SyncedTabsController.syncAccount]
+ */
+ override fun syncAccount() {
+ view.startLoading()
+ scope.launch {
+ accountManager.withConstellation { refreshDevices() }
+ accountManager.syncNow(
+ SyncReason.User,
+ customEngineSubset = listOf(SyncEngine.Tabs),
+ debounce = true,
+ )
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/controller/SyncedTabsController.kt b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/controller/SyncedTabsController.kt
new file mode 100644
index 0000000000..cc10d36fa4
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/controller/SyncedTabsController.kt
@@ -0,0 +1,30 @@
+/* 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.syncedtabs.controller
+
+import mozilla.components.feature.syncedtabs.storage.SyncedTabsProvider
+import mozilla.components.feature.syncedtabs.view.SyncedTabsView
+import mozilla.components.service.fxa.manager.FxaAccountManager
+
+/**
+ * A controller for making the appropriate request for remote tabs from [SyncedTabsProvider] when the
+ * [FxaAccountManager] account is in the appropriate state. The [SyncedTabsView] can then be notified.
+ */
+interface SyncedTabsController {
+ val provider: SyncedTabsProvider
+ val accountManager: FxaAccountManager
+ val view: SyncedTabsView
+
+ /**
+ * Requests for remote tabs and notifies the [SyncedTabsView] when available with [SyncedTabsView.displaySyncedTabs]
+ * otherwise notifies the appropriate error to [SyncedTabsView.onError].
+ */
+ fun refreshSyncedTabs()
+
+ /**
+ * Requests for the account on the [FxaAccountManager] to perform a sync.
+ */
+ fun syncAccount()
+}
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/ext/SyncedTabsStorage.kt b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/ext/SyncedTabsStorage.kt
new file mode 100644
index 0000000000..0b8247365a
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/ext/SyncedTabsStorage.kt
@@ -0,0 +1,43 @@
+/* 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.syncedtabs.ext
+
+import mozilla.components.browser.storage.sync.TabEntry
+import mozilla.components.feature.syncedtabs.ClientTabPair
+import mozilla.components.feature.syncedtabs.storage.SyncedTabsStorage
+
+/**
+ * Get all the synced tabs that match the optional filter.
+ *
+ * @param limit How many synced tabs to query. A negative value will query all tabs. Defaults to `-1`.
+ * @param filter Optional filter for the active [TabEntry] of each tab.
+ */
+internal suspend fun SyncedTabsStorage.getActiveDeviceTabs(
+ limit: Int = -1,
+ filter: (TabEntry) -> Boolean = { true },
+): List<ClientTabPair> {
+ if (limit == 0) return emptyList()
+
+ return getSyncedDeviceTabs().fold(mutableListOf()) { result, (client, tabs) ->
+ tabs.forEach { tab ->
+ val activeTabEntry = tab.active()
+ if (filter(activeTabEntry)) {
+ result.add(
+ ClientTabPair(
+ clientName = client.displayName,
+ tab = activeTabEntry,
+ lastUsed = tab.lastUsed,
+ deviceType = client.deviceType,
+ ),
+ )
+
+ if (result.size == limit) {
+ return result
+ }
+ }
+ }
+ result
+ }
+}
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/facts/SyncedTabsFacts.kt b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/facts/SyncedTabsFacts.kt
new file mode 100644
index 0000000000..5ccede45b1
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/facts/SyncedTabsFacts.kt
@@ -0,0 +1,44 @@
+/* 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.syncedtabs.facts
+
+import mozilla.components.support.base.Component
+import mozilla.components.support.base.facts.Action
+import mozilla.components.support.base.facts.Fact
+import mozilla.components.support.base.facts.collect
+
+/**
+ * Facts emitted for telemetry related to the Synced Tabs feature.
+ */
+class SyncedTabsFacts {
+ /**
+ * Specific types of telemetry items.
+ */
+ object Items {
+ const val SYNCED_TABS_SUGGESTION_CLICKED = "synced_tabs_suggestion_clicked"
+ }
+}
+
+private fun emitSyncedTabsFact(
+ action: Action,
+ item: String,
+ value: String? = null,
+ metadata: Map<String, Any>? = null,
+) {
+ Fact(
+ Component.FEATURE_SYNCEDTABS,
+ action,
+ item,
+ value,
+ metadata,
+ ).collect()
+}
+
+internal fun emitSyncedTabSuggestionClickedFact() {
+ emitSyncedTabsFact(
+ Action.INTERACTION,
+ SyncedTabsFacts.Items.SYNCED_TABS_SUGGESTION_CLICKED,
+ )
+}
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/interactor/DefaultInteractor.kt b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/interactor/DefaultInteractor.kt
new file mode 100644
index 0000000000..a7826ff559
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/interactor/DefaultInteractor.kt
@@ -0,0 +1,32 @@
+/* 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.syncedtabs.interactor
+
+import mozilla.components.browser.storage.sync.Tab
+import mozilla.components.feature.syncedtabs.controller.SyncedTabsController
+import mozilla.components.feature.syncedtabs.view.SyncedTabsView
+
+internal class DefaultInteractor(
+ override val controller: SyncedTabsController,
+ override val view: SyncedTabsView,
+ override val tabClicked: (Tab) -> Unit,
+) : SyncedTabsInteractor {
+
+ override fun start() {
+ view.listener = this
+ }
+
+ override fun stop() {
+ view.listener = null
+ }
+
+ override fun onTabClicked(tab: Tab) {
+ tabClicked(tab)
+ }
+
+ override fun onRefresh() {
+ controller.syncAccount()
+ }
+}
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/interactor/SyncedTabsInteractor.kt b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/interactor/SyncedTabsInteractor.kt
new file mode 100644
index 0000000000..55016e89ae
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/interactor/SyncedTabsInteractor.kt
@@ -0,0 +1,19 @@
+/* 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.syncedtabs.interactor
+
+import mozilla.components.browser.storage.sync.Tab
+import mozilla.components.feature.syncedtabs.controller.SyncedTabsController
+import mozilla.components.feature.syncedtabs.view.SyncedTabsView
+import mozilla.components.support.base.feature.LifecycleAwareFeature
+
+/**
+ * An interactor that handles events from [SyncedTabsView.Listener].
+ */
+interface SyncedTabsInteractor : SyncedTabsView.Listener, LifecycleAwareFeature {
+ val controller: SyncedTabsController
+ val view: SyncedTabsView
+ val tabClicked: (Tab) -> Unit
+}
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/presenter/DefaultPresenter.kt b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/presenter/DefaultPresenter.kt
new file mode 100644
index 0000000000..21172bb0a5
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/presenter/DefaultPresenter.kt
@@ -0,0 +1,142 @@
+/* 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.syncedtabs.presenter
+
+import android.content.Context
+import androidx.annotation.VisibleForTesting
+import androidx.lifecycle.LifecycleOwner
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import mozilla.components.concept.sync.AccountObserver
+import mozilla.components.concept.sync.AuthType
+import mozilla.components.concept.sync.OAuthAccount
+import mozilla.components.feature.syncedtabs.controller.SyncedTabsController
+import mozilla.components.feature.syncedtabs.view.SyncedTabsView
+import mozilla.components.feature.syncedtabs.view.SyncedTabsView.ErrorType
+import mozilla.components.service.fxa.SyncEngine
+import mozilla.components.service.fxa.manager.FxaAccountManager
+import mozilla.components.service.fxa.manager.SyncEnginesStorage
+import mozilla.components.service.fxa.sync.SyncStatusObserver
+
+/**
+ * The tricky part in this class is to handle all possible Sync+FxA states:
+ *
+ * - No Sync account
+ * - Connected to FxA but not Sync (impossible state on mobile at the moment).
+ * - Connected to Sync, but needs reconnection.
+ * - Connected to Sync, but tabs syncing disabled.
+ * - Connected to Sync, but tabs haven't been synced yet (they stay in memory after the first sync).
+ * - Connected to Sync, but only one device in the account (us), so no other tab to show.
+ * - Connected to Sync.
+ *
+ */
+internal class DefaultPresenter(
+ private val context: Context,
+ override val controller: SyncedTabsController,
+ override val accountManager: FxaAccountManager,
+ override val view: SyncedTabsView,
+ private val lifecycleOwner: LifecycleOwner,
+) : SyncedTabsPresenter {
+
+ @VisibleForTesting
+ internal val eventObserver = SyncedTabsSyncObserver(context, view, controller)
+
+ @VisibleForTesting
+ internal val accountObserver = SyncedTabsAccountObserver(view, controller)
+
+ override fun start() {
+ accountManager.registerForSyncEvents(
+ observer = eventObserver,
+ owner = lifecycleOwner,
+ autoPause = true,
+ )
+
+ accountManager.register(
+ observer = accountObserver,
+ owner = lifecycleOwner,
+ autoPause = true,
+ )
+
+ // No authenticated account present at all.
+ if (accountManager.authenticatedAccount() == null) {
+ view.onError(ErrorType.SYNC_UNAVAILABLE)
+ return
+ }
+
+ // Have an account, but it ran into auth issues.
+ if (accountManager.accountNeedsReauth()) {
+ view.onError(ErrorType.SYNC_NEEDS_REAUTHENTICATION)
+ return
+ }
+
+ // Synced tabs not enabled.
+ if (!isSyncedTabsEngineEnabled(context)) {
+ view.onError(ErrorType.SYNC_ENGINE_UNAVAILABLE)
+ return
+ }
+
+ controller.syncAccount()
+ }
+
+ override fun stop() {
+ accountManager.unregisterForSyncEvents(eventObserver)
+ accountManager.unregister(accountObserver)
+ }
+
+ companion object {
+ // This status isn't always set before it's inspected. This causes erroneous reports of the
+ // sync engine being unavailable. Tabs are included in sync by default, so it's safe to
+ // default to true until they are deliberately disabled.
+ private fun isSyncedTabsEngineEnabled(context: Context): Boolean {
+ return SyncEnginesStorage(context).getStatus()[SyncEngine.Tabs] ?: true
+ }
+ }
+
+ internal class SyncedTabsAccountObserver(
+ private val view: SyncedTabsView,
+ private val controller: SyncedTabsController,
+ ) : AccountObserver {
+
+ override fun onLoggedOut() {
+ CoroutineScope(Dispatchers.Main).launch { view.onError(ErrorType.SYNC_UNAVAILABLE) }
+ }
+
+ override fun onAuthenticated(account: OAuthAccount, authType: AuthType) {
+ CoroutineScope(Dispatchers.Main).launch {
+ controller.syncAccount()
+ }
+ }
+
+ override fun onAuthenticationProblems() {
+ CoroutineScope(Dispatchers.Main).launch {
+ view.onError(ErrorType.SYNC_NEEDS_REAUTHENTICATION)
+ }
+ }
+ }
+
+ internal class SyncedTabsSyncObserver(
+ private val context: Context,
+ private val view: SyncedTabsView,
+ private val controller: SyncedTabsController,
+ ) : SyncStatusObserver {
+
+ override fun onIdle() {
+ if (isSyncedTabsEngineEnabled(context)) {
+ controller.refreshSyncedTabs()
+ } else {
+ view.onError(ErrorType.SYNC_ENGINE_UNAVAILABLE)
+ }
+ }
+
+ override fun onError(error: Exception?) {
+ view.onError(ErrorType.SYNC_ENGINE_UNAVAILABLE)
+ }
+
+ override fun onStarted() {
+ view.startLoading()
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/presenter/SyncedTabsPresenter.kt b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/presenter/SyncedTabsPresenter.kt
new file mode 100644
index 0000000000..3f714651e6
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/presenter/SyncedTabsPresenter.kt
@@ -0,0 +1,20 @@
+/* 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.syncedtabs.presenter
+
+import mozilla.components.feature.syncedtabs.controller.SyncedTabsController
+import mozilla.components.feature.syncedtabs.view.SyncedTabsView
+import mozilla.components.service.fxa.manager.FxaAccountManager
+import mozilla.components.support.base.feature.LifecycleAwareFeature
+
+/**
+ * A presenter that orchestrates the [FxaAccountManager] being in the correct state to request remote tabs from the
+ * [SyncedTabsController] or notifies [SyncedTabsView.onError] otherwise.
+ */
+interface SyncedTabsPresenter : LifecycleAwareFeature {
+ val controller: SyncedTabsController
+ val accountManager: FxaAccountManager
+ val view: SyncedTabsView
+}
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/storage/SyncedTabsProvider.kt b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/storage/SyncedTabsProvider.kt
new file mode 100644
index 0000000000..573ac9bdeb
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/storage/SyncedTabsProvider.kt
@@ -0,0 +1,18 @@
+/* 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.syncedtabs.storage
+
+import mozilla.components.browser.storage.sync.SyncedDeviceTabs
+
+/**
+ * Provides tabs from remote Firefox Sync devices.
+ */
+interface SyncedTabsProvider {
+
+ /**
+ * A list of [SyncedDeviceTabs], each containing a synced device and its current tabs.
+ */
+ suspend fun getSyncedDeviceTabs(): List<SyncedDeviceTabs>
+}
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/storage/SyncedTabsStorage.kt b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/storage/SyncedTabsStorage.kt
new file mode 100644
index 0000000000..9c599e7230
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/storage/SyncedTabsStorage.kt
@@ -0,0 +1,119 @@
+/* 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.syncedtabs.storage
+
+import androidx.annotation.VisibleForTesting
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChangedBy
+import kotlinx.coroutines.flow.map
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.isActive
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.browser.storage.sync.RemoteTabsStorage
+import mozilla.components.browser.storage.sync.SyncedDeviceTabs
+import mozilla.components.browser.storage.sync.Tab
+import mozilla.components.browser.storage.sync.TabEntry
+import mozilla.components.concept.sync.Device
+import mozilla.components.lib.state.ext.flowScoped
+import mozilla.components.service.fxa.SyncEngine
+import mozilla.components.service.fxa.manager.FxaAccountManager
+import mozilla.components.service.fxa.manager.ext.withConstellation
+import mozilla.components.service.fxa.sync.SyncReason
+
+/**
+ * A storage that listens to the [BrowserStore] changes to synchronize the local tabs state
+ * with [RemoteTabsStorage] and then synchronize with [accountManager].
+ *
+ * @param accountManager Account manager used to retrieve synced tabs.
+ * @param store Browser store to observe for state changes.
+ * @param tabsStorage Storage layer for tabs to sync.
+ * @param debounceMillis Length to debounce rapid changes for storing and syncing.
+ */
+class SyncedTabsStorage(
+ private val accountManager: FxaAccountManager,
+ private val store: BrowserStore,
+ private val tabsStorage: RemoteTabsStorage,
+ private val maxActiveTime: Long,
+ private val debounceMillis: Long = 1000L,
+) : SyncedTabsProvider {
+ private var scope: CoroutineScope? = null
+
+ /**
+ * Start listening to browser store changes.
+ */
+ @OptIn(FlowPreview::class)
+ fun start() {
+ scope = store.flowScoped { flow ->
+ flow.distinctUntilChangedBy { it.toSyncTabState() }
+ .map { state ->
+ // TO-DO: https://github.com/mozilla-mobile/android-components/issues/5179
+ val iconUrl = null
+ state.tabs.filter { !it.content.private && !it.content.loading }.map { tab ->
+ val history = listOf(TabEntry(tab.content.title, tab.content.url, iconUrl))
+ Tab(history, 0, tab.lastAccess, !tab.isActive(maxActiveTime))
+ }
+ }
+ .debounce(debounceMillis)
+ .collect { tabs ->
+ tabsStorage.store(tabs)
+ accountManager.syncNow(
+ reason = SyncReason.User,
+ customEngineSubset = listOf(SyncEngine.Tabs),
+ debounce = true,
+ )
+ }
+ }
+ }
+
+ /**
+ * Stop listening to browser store changes.
+ */
+ fun stop() {
+ scope?.cancel()
+ }
+
+ /**
+ * See [SyncedTabsProvider.getSyncedDeviceTabs].
+ */
+ override suspend fun getSyncedDeviceTabs(): List<SyncedDeviceTabs> {
+ val otherDevices = syncClients() ?: return emptyList()
+ return tabsStorage.getAll()
+ .mapNotNull { (client, tabs) ->
+ val fxaDevice = otherDevices.find { it.id == client.id }
+
+ fxaDevice?.let { SyncedDeviceTabs(fxaDevice, tabs.sortedByDescending { it.lastUsed }) }
+ }
+ .sortedByDescending {
+ it.tabs.firstOrNull()?.lastUsed
+ }
+ }
+
+ /**
+ * List of synced devices.
+ */
+ @VisibleForTesting
+ internal fun syncClients(): List<Device>? {
+ accountManager.withConstellation {
+ return state()?.otherDevices
+ }
+ return null
+ }
+
+ private data class SyncComponents(
+ val selectedId: String?,
+ val lastAccessed: List<Long>,
+ val loadedTabs: List<TabSessionState>,
+ )
+
+ private fun BrowserState.toSyncTabState() = SyncComponents(
+ selectedId = selectedTabId,
+ lastAccessed = tabs.map { it.lastAccess },
+ loadedTabs = tabs.filter { it.content.loading },
+ )
+}
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/view/SyncedTabsView.kt b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/view/SyncedTabsView.kt
new file mode 100644
index 0000000000..2165c1d739
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/view/SyncedTabsView.kt
@@ -0,0 +1,89 @@
+/* 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.syncedtabs.view
+
+import android.view.View
+import mozilla.components.browser.storage.sync.SyncedDeviceTabs
+import mozilla.components.browser.storage.sync.Tab
+
+/**
+ * An interface for views that can display Firefox Sync "synced tabs" and related UI controls.
+ */
+interface SyncedTabsView {
+ var listener: Listener?
+
+ /**
+ * When tab syncing has started.
+ */
+ fun startLoading() = Unit
+
+ /**
+ * When tab syncing has completed.
+ */
+ fun stopLoading() = Unit
+
+ /**
+ * New tabs have been received to display.
+ */
+ fun displaySyncedTabs(syncedTabs: List<SyncedDeviceTabs>)
+
+ /**
+ * An error has occurred that may require various user-interactions based on the [ErrorType].
+ */
+ fun onError(error: ErrorType)
+
+ /**
+ * Casts this [SyncedTabsView] interface to an actual Android [View] object.
+ */
+ fun asView(): View = (this as View)
+
+ /**
+ * An interface for notifying the listener of the [SyncedTabsView].
+ */
+ interface Listener {
+
+ /**
+ * Invoked when a tab has been selected.
+ */
+ fun onTabClicked(tab: Tab)
+
+ /**
+ * Invoked when receiving a request to refresh the synced tabs.
+ */
+ fun onRefresh()
+ }
+
+ /**
+ * The various types of errors that can occur from syncing tabs.
+ */
+ enum class ErrorType {
+
+ /**
+ * Other devices found but there are no tabs to sync.
+ * */
+ NO_TABS_AVAILABLE,
+
+ /**
+ * There are no other devices found with this account and therefore no tabs to sync.
+ */
+ MULTIPLE_DEVICES_UNAVAILABLE,
+
+ /**
+ * The engine for syncing tabs is unavailable. This is mostly due to a user turning off the feature on the
+ * Firefox Sync account.
+ */
+ SYNC_ENGINE_UNAVAILABLE,
+
+ /**
+ * There is no Firefox Sync account available. A user needs to sign-in before this feature.
+ */
+ SYNC_UNAVAILABLE,
+
+ /**
+ * The Firefox Sync account requires user-intervention to re-authenticate the account.
+ */
+ SYNC_NEEDS_REAUTHENTICATION,
+ }
+}
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/SyncedTabsAutocompleteProviderKtTest.kt b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/SyncedTabsAutocompleteProviderKtTest.kt
new file mode 100644
index 0000000000..6f939958a6
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/SyncedTabsAutocompleteProviderKtTest.kt
@@ -0,0 +1,68 @@
+/* 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.syncedtabs
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.storage.sync.SyncedDeviceTabs
+import mozilla.components.feature.syncedtabs.helper.getDevice1Tabs
+import mozilla.components.feature.syncedtabs.helper.getDevice2Tabs
+import mozilla.components.feature.syncedtabs.storage.SyncedTabsStorage
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.doReturn
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(AndroidJUnit4::class)
+class SyncedTabsAutocompleteProviderKtTest {
+ private val syncedTabs: SyncedTabsStorage = mock()
+
+ @Test
+ fun `GIVEN synced tabs exist WHEN asked for autocomplete suggestions THEN return the first matching tab`() = runTest {
+ val deviceTabs1 = getDevice1Tabs()
+ val deviceTabs2 = getDevice2Tabs()
+ doReturn(listOf(deviceTabs1, deviceTabs2)).`when`(syncedTabs).getSyncedDeviceTabs()
+ val provider = SyncedTabsAutocompleteProvider(syncedTabs)
+
+ var suggestion = provider.getAutocompleteSuggestion("mozilla")
+ assertNull(suggestion)
+
+ suggestion = provider.getAutocompleteSuggestion("foo")
+ assertNotNull(suggestion)
+ assertEquals("foo", suggestion?.input)
+ assertEquals("foo.bar", suggestion?.text)
+ assertEquals("https://foo.bar", suggestion?.url)
+ assertEquals(SYNCED_TABS_AUTOCOMPLETE_SOURCE_NAME, suggestion?.source)
+ assertEquals(1, suggestion?.totalItems)
+
+ suggestion = provider.getAutocompleteSuggestion("obob")
+ assertNotNull(suggestion)
+ assertEquals("obob", suggestion?.input)
+ assertEquals("obob.bar", suggestion?.text)
+ assertEquals("https://obob.bar", suggestion?.url)
+ assertEquals(SYNCED_TABS_AUTOCOMPLETE_SOURCE_NAME, suggestion?.source)
+ assertEquals(1, suggestion?.totalItems)
+ }
+
+ @Test
+ fun `GIVEN open tabs exist WHEN asked for autocomplete suggestions and only private tabs match THEN return null`() = runTest {
+ doReturn(emptyList<SyncedDeviceTabs>()).`when`(syncedTabs).getSyncedDeviceTabs()
+ val provider = SyncedTabsAutocompleteProvider(syncedTabs)
+
+ var suggestion = provider.getAutocompleteSuggestion("mozilla")
+ assertNull(suggestion)
+
+ suggestion = provider.getAutocompleteSuggestion("foo")
+ assertNull(suggestion)
+
+ suggestion = provider.getAutocompleteSuggestion("bar")
+ assertNull(suggestion)
+ }
+}
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/SyncedTabsFeatureTest.kt b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/SyncedTabsFeatureTest.kt
new file mode 100644
index 0000000000..c0c66fc7ec
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/SyncedTabsFeatureTest.kt
@@ -0,0 +1,55 @@
+/* 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.syncedtabs
+
+import android.content.Context
+import androidx.lifecycle.LifecycleOwner
+import mozilla.components.feature.syncedtabs.interactor.SyncedTabsInteractor
+import mozilla.components.feature.syncedtabs.presenter.SyncedTabsPresenter
+import mozilla.components.feature.syncedtabs.storage.SyncedTabsStorage
+import mozilla.components.feature.syncedtabs.view.SyncedTabsView
+import mozilla.components.service.fxa.manager.FxaAccountManager
+import mozilla.components.support.test.mock
+import org.junit.Test
+import org.mockito.Mockito.verify
+
+class SyncedTabsFeatureTest {
+
+ private val context: Context = mock()
+ private val storage: SyncedTabsStorage = mock()
+ private val accountManager: FxaAccountManager = mock()
+ private val view: SyncedTabsView = mock()
+ private val lifecycleOwner: LifecycleOwner = mock()
+
+ private val presenter: SyncedTabsPresenter = mock()
+ private val interactor: SyncedTabsInteractor = mock()
+ private val feature: SyncedTabsFeature =
+ SyncedTabsFeature(
+ context,
+ storage,
+ accountManager,
+ view,
+ lifecycleOwner,
+ onTabClicked = {},
+ presenter = presenter,
+ interactor = interactor,
+ )
+
+ @Test
+ fun start() {
+ feature.start()
+
+ verify(presenter).start()
+ verify(interactor).start()
+ }
+
+ @Test
+ fun stop() {
+ feature.stop()
+
+ verify(presenter).stop()
+ verify(interactor).stop()
+ }
+}
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/SyncedTabsStorageSuggestionProviderTest.kt b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/SyncedTabsStorageSuggestionProviderTest.kt
new file mode 100644
index 0000000000..25a81fa050
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/SyncedTabsStorageSuggestionProviderTest.kt
@@ -0,0 +1,88 @@
+/* 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.syncedtabs
+
+import android.graphics.drawable.Drawable
+import kotlinx.coroutines.test.runTest
+import mozilla.components.concept.awesomebar.AwesomeBar.Suggestion.Flag
+import mozilla.components.feature.syncedtabs.helper.getDevice1Tabs
+import mozilla.components.feature.syncedtabs.helper.getDevice2Tabs
+import mozilla.components.feature.syncedtabs.storage.SyncedTabsStorage
+import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.whenever
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Before
+import org.junit.Test
+
+class SyncedTabsStorageSuggestionProviderTest {
+ private lateinit var syncedTabs: SyncedTabsStorage
+ private lateinit var indicatorIcon: DeviceIndicators
+ private lateinit var indicatorIconDesktop: Drawable
+ private lateinit var indicatorIconMobile: Drawable
+
+ @Before
+ fun setup() {
+ syncedTabs = mock()
+ indicatorIcon = mock()
+ indicatorIconDesktop = mock()
+ indicatorIconMobile = mock()
+ }
+
+ @Test
+ fun `matches remote tabs`() = runTest {
+ val provider = SyncedTabsStorageSuggestionProvider(syncedTabs, mock(), mock(), indicatorIcon)
+ val deviceTabs1 = getDevice1Tabs()
+ val deviceTabs2 = getDevice2Tabs()
+ whenever(syncedTabs.getSyncedDeviceTabs()).thenReturn(listOf(deviceTabs1, deviceTabs2))
+ whenever(indicatorIcon.desktop).thenReturn(indicatorIconDesktop)
+ whenever(indicatorIcon.mobile).thenReturn(indicatorIconMobile)
+
+ val suggestions = provider.onInputChanged("bobo")
+ assertEquals(3, suggestions.size)
+ assertEquals("Hello Bobo", suggestions[0].title)
+ assertEquals("Foo Client", suggestions[0].description)
+ assertEquals("In URL", suggestions[1].title)
+ assertEquals("Foo Client", suggestions[1].description)
+ assertEquals("BOBO in CAPS", suggestions[2].title)
+ assertEquals("Bar Client", suggestions[2].description)
+ assertEquals(setOf(Flag.SYNC_TAB), suggestions[0].flags)
+ assertEquals(setOf(Flag.SYNC_TAB), suggestions[1].flags)
+ assertEquals(setOf(Flag.SYNC_TAB), suggestions[2].flags)
+ assertEquals(indicatorIconDesktop, suggestions[0].indicatorIcon)
+ assertEquals(indicatorIconDesktop, suggestions[1].indicatorIcon)
+ assertEquals(indicatorIconMobile, suggestions[2].indicatorIcon)
+ assertNotNull(suggestions[0].indicatorIcon)
+ assertNotNull(suggestions[1].indicatorIcon)
+ assertNotNull(suggestions[2].indicatorIcon)
+ }
+
+ @Test
+ fun `GIVEN an external filter WHEN querying tabs THEN return only the results that pass through the filter`() = runTest {
+ val deviceTabs1 = getDevice1Tabs()
+ val deviceTabs2 = getDevice2Tabs()
+ whenever(syncedTabs.getSyncedDeviceTabs()).thenReturn(listOf(deviceTabs1, deviceTabs2))
+ whenever(indicatorIcon.desktop).thenReturn(indicatorIconDesktop)
+ whenever(indicatorIcon.mobile).thenReturn(indicatorIconMobile)
+
+ val provider = SyncedTabsStorageSuggestionProvider(
+ syncedTabs = syncedTabs,
+ loadUrlUseCase = mock(),
+ icons = mock(),
+ deviceIndicators = indicatorIcon,
+ resultsUrlFilter = {
+ it.tryGetHostFromUrl() == "https://foo.bar".tryGetHostFromUrl()
+ },
+ )
+
+ val suggestions = provider.onInputChanged("foo")
+
+ assertEquals(2, suggestions.size)
+ // The url is behind the "onSuggestionClicked" lambda.
+ // Check the descriptions of the only two tabs that have the "foo.bar" host.
+ assertEquals(2, suggestions.map { it.description }.filter { it == "Foo Client" }.size)
+ }
+}
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/controller/DefaultControllerTest.kt b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/controller/DefaultControllerTest.kt
new file mode 100644
index 0000000000..0241a0d0a9
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/controller/DefaultControllerTest.kt
@@ -0,0 +1,149 @@
+/* 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.syncedtabs.controller
+
+import mozilla.components.browser.storage.sync.SyncedDeviceTabs
+import mozilla.components.concept.sync.ConstellationState
+import mozilla.components.concept.sync.DeviceConstellation
+import mozilla.components.concept.sync.OAuthAccount
+import mozilla.components.feature.syncedtabs.storage.SyncedTabsStorage
+import mozilla.components.feature.syncedtabs.view.SyncedTabsView
+import mozilla.components.feature.syncedtabs.view.SyncedTabsView.ErrorType
+import mozilla.components.service.fxa.SyncEngine
+import mozilla.components.service.fxa.manager.FxaAccountManager
+import mozilla.components.service.fxa.sync.SyncReason
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoMoreInteractions
+import org.mockito.Mockito.`when`
+
+class DefaultControllerTest {
+ private val storage: SyncedTabsStorage = mock()
+ private val accountManager: FxaAccountManager = mock()
+ private val view: SyncedTabsView = mock()
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Test
+ fun `update view only when no account available`() = runTestOnMain {
+ val controller = DefaultController(
+ storage,
+ accountManager,
+ view,
+ coroutineContext,
+ )
+
+ controller.refreshSyncedTabs()
+
+ verify(view).stopLoading()
+
+ verifyNoMoreInteractions(view)
+ }
+
+ @Test
+ fun `notify if there are no other devices synced`() = runTestOnMain {
+ val controller = DefaultController(
+ storage,
+ accountManager,
+ view,
+ coroutineContext,
+ )
+ val account: OAuthAccount = mock()
+ val constellation: DeviceConstellation = mock()
+ val state: ConstellationState = mock()
+
+ `when`(accountManager.authenticatedAccount()).thenReturn(account)
+ `when`(account.deviceConstellation()).thenReturn(constellation)
+ `when`(constellation.state()).thenReturn(state)
+ `when`(state.otherDevices).thenReturn(emptyList())
+
+ `when`(storage.getSyncedDeviceTabs()).thenReturn(emptyList())
+
+ controller.refreshSyncedTabs()
+
+ verify(view).onError(ErrorType.MULTIPLE_DEVICES_UNAVAILABLE)
+ }
+
+ @Test
+ fun `notify if there are no tabs from other devices to sync`() = runTestOnMain {
+ val controller = DefaultController(
+ storage,
+ accountManager,
+ view,
+ coroutineContext,
+ )
+ val account: OAuthAccount = mock()
+ val constellation: DeviceConstellation = mock()
+ val state: ConstellationState = mock()
+
+ `when`(accountManager.authenticatedAccount()).thenReturn(account)
+ `when`(account.deviceConstellation()).thenReturn(constellation)
+ `when`(constellation.state()).thenReturn(state)
+ `when`(state.otherDevices).thenReturn(listOf(mock()))
+
+ `when`(storage.getSyncedDeviceTabs()).thenReturn(emptyList())
+
+ controller.refreshSyncedTabs()
+
+ verify(view, never()).onError(ErrorType.MULTIPLE_DEVICES_UNAVAILABLE)
+ verify(view).onError(ErrorType.NO_TABS_AVAILABLE)
+ }
+
+ @Test
+ fun `display synced tabs`() = runTestOnMain {
+ val controller = DefaultController(
+ storage,
+ accountManager,
+ view,
+ coroutineContext,
+ )
+ val account: OAuthAccount = mock()
+ val constellation: DeviceConstellation = mock()
+ val state: ConstellationState = mock()
+ val syncedDeviceTabs = SyncedDeviceTabs(mock(), listOf(mock()))
+ val listOfSyncedDeviceTabs = listOf(syncedDeviceTabs)
+
+ `when`(accountManager.authenticatedAccount()).thenReturn(account)
+ `when`(account.deviceConstellation()).thenReturn(constellation)
+ `when`(constellation.state()).thenReturn(state)
+ `when`(state.otherDevices).thenReturn(listOf(mock()))
+
+ `when`(storage.getSyncedDeviceTabs()).thenReturn(listOfSyncedDeviceTabs)
+
+ controller.refreshSyncedTabs()
+
+ verify(view, never()).onError(ErrorType.MULTIPLE_DEVICES_UNAVAILABLE)
+ verify(view, never()).onError(ErrorType.NO_TABS_AVAILABLE)
+ verify(view).displaySyncedTabs(listOfSyncedDeviceTabs)
+ }
+
+ @Test
+ fun `WHEN syncAccount is called THEN view is loading, devices are refreshed, and sync started`() = runTestOnMain {
+ val controller = DefaultController(
+ storage,
+ accountManager,
+ view,
+ coroutineContext,
+ )
+ val account: OAuthAccount = mock()
+ val constellation: DeviceConstellation = mock()
+
+ `when`(accountManager.authenticatedAccount()).thenReturn(account)
+ `when`(account.deviceConstellation()).thenReturn(constellation)
+ `when`(constellation.refreshDevices()).thenReturn(true)
+
+ controller.syncAccount()
+
+ verify(view).startLoading()
+ verify(constellation).refreshDevices()
+ verify(accountManager).syncNow(SyncReason.User, true, listOf(SyncEngine.Tabs))
+ }
+}
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/ext/SyncedTabsStorageKtTest.kt b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/ext/SyncedTabsStorageKtTest.kt
new file mode 100644
index 0000000000..def4a3479d
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/ext/SyncedTabsStorageKtTest.kt
@@ -0,0 +1,65 @@
+/* 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.syncedtabs.ext
+
+import kotlinx.coroutines.test.runTest
+import mozilla.components.feature.syncedtabs.helper.getDevice1Tabs
+import mozilla.components.feature.syncedtabs.helper.getDevice2Tabs
+import mozilla.components.feature.syncedtabs.storage.SyncedTabsStorage
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Test
+import org.mockito.Mockito.doReturn
+
+class SyncedTabsStorageKtTest {
+ private val syncedTabs: SyncedTabsStorage = mock()
+
+ @Test
+ fun `GIVEN synced tabs exist WHEN asked for active device tabs THEN return all tabs`() = runTest {
+ val device1Tabs = getDevice1Tabs()
+ val device2Tabs = getDevice2Tabs()
+ doReturn(listOf(device1Tabs, device2Tabs)).`when`(syncedTabs).getSyncedDeviceTabs()
+
+ val result = syncedTabs.getActiveDeviceTabs()
+ assertNotNull(result)
+ assertEquals(4, result.size)
+ assertEquals(3, result.filter { it.clientName == device1Tabs.device.displayName }.size)
+ assertEquals(1, result.filter { it.clientName == device2Tabs.device.displayName }.size)
+ }
+
+ @Test
+ fun `GIVEN synced tabs exist WHEN asked for a lower number of active device tabs THEN return tabs up to that number`() = runTest {
+ val device1Tabs = getDevice1Tabs()
+ val device2Tabs = getDevice2Tabs()
+ doReturn(listOf(device1Tabs, device2Tabs)).`when`(syncedTabs).getSyncedDeviceTabs()
+
+ var result = syncedTabs.getActiveDeviceTabs(2)
+ assertNotNull(result)
+ assertEquals(2, result.size)
+ assertEquals(2, result.filter { it.clientName == device1Tabs.device.displayName }.size)
+
+ result = syncedTabs.getActiveDeviceTabs(7)
+ assertNotNull(result)
+ assertEquals(4, result.size)
+ assertEquals(3, result.filter { it.clientName == device1Tabs.device.displayName }.size)
+ assertEquals(1, result.filter { it.clientName == device2Tabs.device.displayName }.size)
+ }
+
+ @Test
+ fun `GIVEN synced tabs exist WHEN asked for active device tabs and a filter is passed THEN return all tabs matching the filter`() = runTest {
+ val device1Tabs = getDevice1Tabs()
+ val device2Tabs = getDevice2Tabs()
+ doReturn(listOf(device1Tabs, device2Tabs)).`when`(syncedTabs).getSyncedDeviceTabs()
+ val filteredTitle = device1Tabs.tabs[0].active().title
+
+ val result = syncedTabs.getActiveDeviceTabs {
+ it.title == filteredTitle
+ }
+ assertNotNull(result)
+ assertEquals(1, result.size)
+ assertEquals(filteredTitle, result[0].tab.title)
+ }
+}
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/facts/SyncedTabsFactsTest.kt b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/facts/SyncedTabsFactsTest.kt
new file mode 100644
index 0000000000..b33abd336c
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/facts/SyncedTabsFactsTest.kt
@@ -0,0 +1,29 @@
+/* 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.syncedtabs.facts
+
+import mozilla.components.support.base.Component
+import mozilla.components.support.base.facts.Action
+import mozilla.components.support.base.facts.processor.CollectionProcessor
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class SyncedTabsFactsTest {
+
+ @Test
+ fun `Emits facts for current state`() {
+ CollectionProcessor.withFactCollection { facts ->
+
+ emitSyncedTabSuggestionClickedFact()
+
+ assertEquals(1, facts.size)
+ facts[0].apply {
+ assertEquals(Component.FEATURE_SYNCEDTABS, component)
+ assertEquals(Action.INTERACTION, action)
+ assertEquals(SyncedTabsFacts.Items.SYNCED_TABS_SUGGESTION_CLICKED, item)
+ }
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/helper/SyncedTabsProvider.kt b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/helper/SyncedTabsProvider.kt
new file mode 100644
index 0000000000..c39befa40f
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/helper/SyncedTabsProvider.kt
@@ -0,0 +1,83 @@
+/* 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.syncedtabs.helper
+
+import mozilla.components.browser.storage.sync.SyncedDeviceTabs
+import mozilla.components.browser.storage.sync.Tab
+import mozilla.components.browser.storage.sync.TabEntry
+import mozilla.components.concept.sync.Device
+import mozilla.components.concept.sync.DeviceType.DESKTOP
+import mozilla.components.concept.sync.DeviceType.MOBILE
+
+/**
+ * Get fake tabs from a fake desktop device.
+ */
+internal fun getDevice1Tabs() = SyncedDeviceTabs(
+ Device(
+ id = "client1",
+ displayName = "Foo Client",
+ deviceType = DESKTOP,
+ isCurrentDevice = false,
+ lastAccessTime = null,
+ capabilities = listOf(),
+ subscriptionExpired = false,
+ subscription = null,
+ ),
+ listOf(
+ Tab(
+ listOf(
+ TabEntry("Foo", "https://foo.bar", null), // active tab
+ TabEntry("Bobo", "https://foo.bar", null),
+ TabEntry("Foo", "https://bobo.bar", null),
+ ),
+ 0,
+ 1,
+ false,
+ ),
+ Tab(
+ listOf(
+ TabEntry("Hello Bobo", "https://foo.bar", null), // active tab
+ ),
+ 0,
+ 5,
+ false,
+ ),
+ Tab(
+ listOf(
+ TabEntry("In URL", "https://bobo.bar", null), // active tab
+ ),
+ 0,
+ 2,
+ false,
+ ),
+ ),
+)
+
+/**
+ * Get fake tabs from a fake mobile device.
+ */
+internal fun getDevice2Tabs() = SyncedDeviceTabs(
+ Device(
+ id = "client2",
+ displayName = "Bar Client",
+ deviceType = MOBILE,
+ isCurrentDevice = false,
+ lastAccessTime = null,
+ capabilities = listOf(),
+ subscriptionExpired = false,
+ subscription = null,
+ ),
+ listOf(
+ Tab(
+ listOf(
+ TabEntry("Bar", "https://bar.bar", null),
+ TabEntry("BOBO in CAPS", "https://obob.bar", null), // active tab
+ ),
+ 1,
+ 1,
+ false,
+ ),
+ ),
+)
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/interactor/DefaultInteractorTest.kt b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/interactor/DefaultInteractorTest.kt
new file mode 100644
index 0000000000..d003a87301
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/interactor/DefaultInteractorTest.kt
@@ -0,0 +1,107 @@
+/* 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.syncedtabs.interactor
+
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.storage.sync.SyncedDeviceTabs
+import mozilla.components.feature.syncedtabs.controller.SyncedTabsController
+import mozilla.components.feature.syncedtabs.view.SyncedTabsView
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.mockito.Mockito.verify
+
+class DefaultInteractorTest {
+
+ private val view: SyncedTabsView = mock()
+ private val controller: SyncedTabsController = mock()
+
+ @Test
+ fun start() = runTest {
+ val view =
+ TestSyncedTabsView()
+ val feature = DefaultInteractor(
+ controller,
+ view,
+ ) {}
+
+ assertNull(view.listener)
+
+ feature.start()
+
+ assertNotNull(view.listener)
+ }
+
+ @Test
+ fun stop() = runTest {
+ val view =
+ TestSyncedTabsView()
+ val feature = DefaultInteractor(
+ controller,
+ view,
+ ) {}
+
+ assertNull(view.listener)
+
+ feature.start()
+
+ assertNotNull(view.listener)
+
+ feature.stop()
+
+ assertNull(view.listener)
+ }
+
+ @Test
+ fun `onTabClicked invokes callback`() = runTest {
+ var invoked = false
+ val feature = DefaultInteractor(
+ controller,
+ view,
+ ) {
+ invoked = true
+ }
+
+ feature.onTabClicked(mock())
+
+ assertTrue(invoked)
+ }
+
+ @Test
+ fun `onRefresh does not update devices when there is no constellation`() = runTest {
+ val feature = DefaultInteractor(
+ controller,
+ view,
+ ) {}
+
+ feature.onRefresh()
+
+ verify(controller).syncAccount()
+ }
+
+ @Test
+ fun `onRefresh updates devices when there is a constellation`() = runTest {
+ val feature = DefaultInteractor(
+ controller,
+ view,
+ ) {}
+
+ feature.onRefresh()
+
+ verify(controller).syncAccount()
+ }
+
+ private class TestSyncedTabsView : SyncedTabsView {
+ override var listener: SyncedTabsView.Listener? = null
+
+ override fun onError(error: SyncedTabsView.ErrorType) {
+ }
+
+ override fun displaySyncedTabs(syncedTabs: List<SyncedDeviceTabs>) {
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/presenter/DefaultPresenterTest.kt b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/presenter/DefaultPresenterTest.kt
new file mode 100644
index 0000000000..4e057d7e42
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/presenter/DefaultPresenterTest.kt
@@ -0,0 +1,256 @@
+/* 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.syncedtabs.presenter
+
+import android.content.Context
+import android.os.Looper.getMainLooper
+import androidx.lifecycle.LifecycleOwner
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.test.runTest
+import mozilla.components.concept.sync.AuthType
+import mozilla.components.feature.syncedtabs.controller.SyncedTabsController
+import mozilla.components.feature.syncedtabs.view.SyncedTabsView
+import mozilla.components.feature.syncedtabs.view.SyncedTabsView.ErrorType
+import mozilla.components.service.fxa.manager.FxaAccountManager
+import mozilla.components.service.fxa.manager.SyncEnginesStorage.Companion.SYNC_ENGINES_KEY
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoInteractions
+import org.mockito.Mockito.`when`
+import org.robolectric.Shadows.shadowOf
+
+@RunWith(AndroidJUnit4::class)
+class DefaultPresenterTest {
+
+ private val context: Context = testContext
+ private val controller: SyncedTabsController = mock()
+ private val accountManager: FxaAccountManager = mock()
+ private val view: SyncedTabsView = mock()
+ private val lifecycleOwner: LifecycleOwner = mock()
+
+ private val prefs = testContext.getSharedPreferences(SYNC_ENGINES_KEY, Context.MODE_PRIVATE)
+
+ @Test
+ fun `start returns when there is no profile`() = runTest {
+ val presenter = DefaultPresenter(
+ context,
+ controller,
+ accountManager,
+ view,
+ lifecycleOwner,
+ )
+
+ presenter.start()
+
+ verify(view).onError(ErrorType.SYNC_UNAVAILABLE)
+ }
+
+ @Test
+ fun `start returns if sync engine is not enabled`() = runTest {
+ val presenter = DefaultPresenter(
+ context,
+ controller,
+ accountManager,
+ view,
+ lifecycleOwner,
+ )
+
+ // disable sync storage
+ prefs.edit().putBoolean("tabs", false).apply()
+ `when`(accountManager.authenticatedAccount()).thenReturn(mock())
+
+ presenter.start()
+
+ verify(view).onError(ErrorType.SYNC_ENGINE_UNAVAILABLE)
+ }
+
+ @Test
+ fun `start returns if sync needs reauthentication`() {
+ val presenter = DefaultPresenter(
+ context,
+ controller,
+ accountManager,
+ view,
+ lifecycleOwner,
+ )
+
+ `when`(accountManager.authenticatedAccount()).thenReturn(mock())
+ `when`(accountManager.accountNeedsReauth()).thenReturn(true)
+
+ presenter.start()
+
+ verify(view).onError(ErrorType.SYNC_NEEDS_REAUTHENTICATION)
+ }
+
+ @Test
+ fun `start invokes syncTabs - account profile is absent`() = runTest {
+ val presenter = DefaultPresenter(
+ context,
+ controller,
+ accountManager,
+ view,
+ lifecycleOwner,
+ )
+
+ prefs.edit().putBoolean("tabs", true).apply()
+ `when`(accountManager.authenticatedAccount()).thenReturn(mock())
+ `when`(accountManager.accountProfile()).thenReturn(null)
+ `when`(accountManager.accountNeedsReauth()).thenReturn(false)
+
+ presenter.start()
+
+ verify(controller).syncAccount()
+ }
+
+ @Test
+ fun `start invokes syncTabs - account profile is present`() = runTest {
+ val presenter = DefaultPresenter(
+ context,
+ controller,
+ accountManager,
+ view,
+ lifecycleOwner,
+ )
+
+ prefs.edit().putBoolean("tabs", true).apply()
+ `when`(accountManager.authenticatedAccount()).thenReturn(mock())
+ `when`(accountManager.accountProfile()).thenReturn(mock())
+ `when`(accountManager.accountNeedsReauth()).thenReturn(false)
+
+ presenter.start()
+
+ verify(controller).syncAccount()
+ }
+
+ @Test
+ fun `notify on logout`() = runTest {
+ val presenter = DefaultPresenter(
+ context,
+ controller,
+ accountManager,
+ view,
+ lifecycleOwner,
+ )
+
+ presenter.accountObserver.onLoggedOut()
+ shadowOf(getMainLooper()).idle()
+
+ verify(view).onError(ErrorType.SYNC_UNAVAILABLE)
+ }
+
+ @Test
+ fun `notify on authenticated`() = runTest {
+ val presenter = DefaultPresenter(
+ context,
+ controller,
+ accountManager,
+ view,
+ lifecycleOwner,
+ )
+
+ presenter.accountObserver.onAuthenticated(mock(), mock<AuthType.Existing>())
+ shadowOf(getMainLooper()).idle()
+
+ verify(controller).syncAccount()
+ }
+
+ @Test
+ fun `notify on authentication problems`() = runTest {
+ val presenter = DefaultPresenter(
+ context,
+ controller,
+ accountManager,
+ view,
+ lifecycleOwner,
+ )
+
+ presenter.accountObserver.onAuthenticationProblems()
+ shadowOf(getMainLooper()).idle()
+
+ verify(view).onError(ErrorType.SYNC_NEEDS_REAUTHENTICATION)
+ }
+
+ @Test
+ fun `sync tabs on idle status - tabs sync enabled`() = runTest {
+ val presenter = DefaultPresenter(
+ context,
+ controller,
+ accountManager,
+ view,
+ lifecycleOwner,
+ )
+
+ prefs.edit().putBoolean("tabs", true).apply()
+ presenter.eventObserver.onIdle()
+
+ verify(controller).refreshSyncedTabs()
+ }
+
+ @Test
+ fun `sync tabs on idle status - tabs sync disabled`() = runTest {
+ val presenter = DefaultPresenter(
+ context,
+ controller,
+ accountManager,
+ view,
+ lifecycleOwner,
+ )
+
+ prefs.edit().putBoolean("tabs", false).apply()
+ presenter.eventObserver.onIdle()
+
+ verifyNoInteractions(controller)
+ verify(view).onError(ErrorType.SYNC_ENGINE_UNAVAILABLE)
+ }
+
+ @Test
+ fun `show loading state on started status`() = runTest {
+ val presenter = DefaultPresenter(
+ context,
+ controller,
+ accountManager,
+ view,
+ lifecycleOwner,
+ )
+
+ presenter.eventObserver.onStarted()
+
+ verify(view).startLoading()
+ }
+
+ @Test
+ fun `notify on error`() = runTest {
+ val presenter = DefaultPresenter(
+ context,
+ controller,
+ accountManager,
+ view,
+ lifecycleOwner,
+ )
+
+ presenter.eventObserver.onError(mock())
+
+ verify(view).onError(ErrorType.SYNC_ENGINE_UNAVAILABLE)
+ }
+
+ @Test
+ fun `GIVEN the presenter is started WHEN it is stopped THEN unregister the account and sync events observers`() {
+ val presenter = DefaultPresenter(
+ context,
+ controller,
+ accountManager,
+ view,
+ lifecycleOwner,
+ )
+
+ presenter.stop()
+
+ verify(accountManager).unregisterForSyncEvents(presenter.eventObserver)
+ verify(accountManager).unregister(presenter.accountObserver)
+ }
+}
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/storage/SyncedTabsStorageTest.kt b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/storage/SyncedTabsStorageTest.kt
new file mode 100644
index 0000000000..e0e5c081f3
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/storage/SyncedTabsStorageTest.kt
@@ -0,0 +1,392 @@
+/* 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.syncedtabs.storage
+
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.action.LastAccessAction
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.browser.storage.sync.RemoteTabsStorage
+import mozilla.components.browser.storage.sync.SyncClient
+import mozilla.components.browser.storage.sync.SyncedDeviceTabs
+import mozilla.components.browser.storage.sync.Tab
+import mozilla.components.browser.storage.sync.TabEntry
+import mozilla.components.concept.sync.ConstellationState
+import mozilla.components.concept.sync.Device
+import mozilla.components.concept.sync.DeviceConstellation
+import mozilla.components.concept.sync.DeviceType
+import mozilla.components.concept.sync.OAuthAccount
+import mozilla.components.service.fxa.SyncEngine
+import mozilla.components.service.fxa.manager.FxaAccountManager
+import mozilla.components.service.fxa.sync.SyncReason
+import mozilla.components.support.test.any
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import mozilla.components.support.test.whenever
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.never
+import org.mockito.Mockito.reset
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+
+class SyncedTabsStorageTest {
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ private lateinit var store: BrowserStore
+ private lateinit var tabsStorage: RemoteTabsStorage
+ private lateinit var accountManager: FxaAccountManager
+
+ @Before
+ fun setup() {
+ store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab(id = "tab1", url = "https://www.mozilla.org", lastAccess = 123L),
+ createTab(id = "tab2", url = "https://www.foo.bar", lastAccess = 124L),
+ createTab(id = "private", url = "https://private.tab", private = true, lastAccess = 125L),
+ ),
+ selectedTabId = "tab1",
+ ),
+ ),
+ )
+ tabsStorage = mock()
+ accountManager = mock()
+ }
+
+ @Test
+ fun `listens to browser store changes, stores state changes, and calls onStoreComplete`() = runTestOnMain {
+ val feature = SyncedTabsStorage(
+ accountManager,
+ store,
+ tabsStorage,
+ 0,
+ debounceMillis = 0,
+ )
+ feature.start()
+
+ // This action will change the state due to lastUsed timestamp, but will run the flow.
+ store.dispatch(TabListAction.RemoveAllPrivateTabsAction).joinBlocking()
+
+ verify(tabsStorage, times(2)).store(
+ listOf(
+ Tab(history = listOf(TabEntry(title = "", url = "https://www.mozilla.org", iconUrl = null)), active = 0, lastUsed = 123L, true),
+ Tab(history = listOf(TabEntry(title = "", url = "https://www.foo.bar", iconUrl = null)), active = 0, lastUsed = 124L, true),
+ // Private tab is absent.
+ ),
+ )
+ verify(accountManager, times(2)).syncNow(
+ SyncReason.User,
+ true,
+ listOf(SyncEngine.Tabs),
+ )
+ }
+
+ @Test
+ fun `stops listening to browser store changes on stop()`() = runTestOnMain {
+ val feature = SyncedTabsStorage(
+ accountManager,
+ store,
+ tabsStorage,
+ 0,
+ debounceMillis = 0,
+ )
+ feature.start()
+ // Run the flow.
+ store.dispatch(TabListAction.RemoveAllPrivateTabsAction).joinBlocking()
+
+ verify(tabsStorage, times(2)).store(
+ listOf(
+ Tab(history = listOf(TabEntry(title = "", url = "https://www.mozilla.org", iconUrl = null)), active = 0, lastUsed = 123L, true),
+ Tab(history = listOf(TabEntry(title = "", url = "https://www.foo.bar", iconUrl = null)), active = 0, lastUsed = 124L, true),
+ ),
+ )
+
+ feature.stop()
+ // Run the flow.
+ store.dispatch(TabListAction.RemoveAllPrivateTabsAction).joinBlocking()
+
+ verify(tabsStorage, never()).store(listOf()) // any() is not working so we send garbage
+ }
+
+ @Test
+ fun `getSyncedTabs matches tabs with FxA devices`() = runTestOnMain {
+ val feature = spy(
+ SyncedTabsStorage(
+ accountManager,
+ store,
+ tabsStorage,
+ 0,
+ ),
+ )
+ val device1 = Device(
+ id = "client1",
+ displayName = "Foo Client",
+ deviceType = DeviceType.DESKTOP,
+ isCurrentDevice = false,
+ lastAccessTime = null,
+ capabilities = listOf(),
+ subscriptionExpired = false,
+ subscription = null,
+ )
+ val device2 = Device(
+ id = "client2",
+ displayName = "Bar Client",
+ deviceType = DeviceType.MOBILE,
+ isCurrentDevice = false,
+ lastAccessTime = null,
+ capabilities = listOf(),
+ subscriptionExpired = false,
+ subscription = null,
+ )
+ doReturn(listOf(device1, device2)).`when`(feature).syncClients()
+ val tabsClient1 = listOf(Tab(listOf(TabEntry("Foo", "https://foo.bar", null)), 0, 0, true))
+ val tabsClient2 = listOf(Tab(listOf(TabEntry("Foo", "https://foo.bar", null)), 0, 0, true))
+ whenever(tabsStorage.getAll()).thenReturn(
+ mapOf(
+ SyncClient("client1") to tabsClient1,
+ SyncClient("client2") to tabsClient2,
+ SyncClient("client-unknown") to listOf(Tab(listOf(TabEntry("Foo", "https://foo.bar", null)), 0, 0, true)),
+ ),
+ )
+
+ val result = feature.getSyncedDeviceTabs()
+ assertEquals(device1, result[0].device)
+ assertEquals(device2, result[1].device)
+ assertEquals(tabsClient1, result[0].tabs)
+ assertEquals(tabsClient2, result[1].tabs)
+ }
+
+ @Test
+ fun `getSyncedTabs returns empty list if syncClients() is null`() = runTestOnMain {
+ val feature = spy(
+ SyncedTabsStorage(
+ accountManager,
+ store,
+ tabsStorage,
+ 0,
+ ),
+ )
+ doReturn(null).`when`(feature).syncClients()
+ assertEquals(emptyList<SyncedDeviceTabs>(), feature.getSyncedDeviceTabs())
+ }
+
+ @Test
+ fun `syncClients returns clients if the account is set and constellation state is set too`() {
+ val feature = spy(
+ SyncedTabsStorage(
+ accountManager,
+ store,
+ tabsStorage,
+ 0,
+ ),
+ )
+ val account: OAuthAccount = mock()
+ val constellation: DeviceConstellation = mock()
+ val state: ConstellationState = mock()
+ whenever(accountManager.authenticatedAccount()).thenReturn(account)
+ whenever(account.deviceConstellation()).thenReturn(constellation)
+ whenever(constellation.state()).thenReturn(state)
+ val otherDevices = listOf(
+ Device(
+ id = "client2",
+ displayName = "Bar Client",
+ deviceType = DeviceType.MOBILE,
+ isCurrentDevice = false,
+ lastAccessTime = null,
+ capabilities = listOf(),
+ subscriptionExpired = false,
+ subscription = null,
+ ),
+ )
+ whenever(state.otherDevices).thenReturn(otherDevices)
+ assertEquals(otherDevices, feature.syncClients())
+ }
+
+ @Test
+ fun `syncClients returns null if the account is set but constellation state is null`() {
+ val feature = spy(
+ SyncedTabsStorage(
+ accountManager,
+ store,
+ tabsStorage,
+ 0,
+ ),
+ )
+ val account: OAuthAccount = mock()
+ val constellation: DeviceConstellation = mock()
+ whenever(accountManager.authenticatedAccount()).thenReturn(account)
+ whenever(account.deviceConstellation()).thenReturn(constellation)
+ whenever(constellation.state()).thenReturn(null)
+ assertEquals(null, feature.syncClients())
+ }
+
+ @Test
+ fun `syncClients returns null if the account is null`() {
+ val feature = spy(
+ SyncedTabsStorage(
+ accountManager,
+ store,
+ tabsStorage,
+ 0,
+ ),
+ )
+ whenever(accountManager.authenticatedAccount()).thenReturn(null)
+ assertEquals(null, feature.syncClients())
+ }
+
+ @Test
+ fun `tabs are stored when loaded`() = runTestOnMain {
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab(id = "tab1", url = "https://www.mozilla.org", lastAccess = 123L),
+ createTab(id = "tab2", url = "https://www.foo.bar", lastAccess = 124L),
+ ),
+ selectedTabId = "tab1",
+ ),
+ )
+ val feature = spy(
+ SyncedTabsStorage(
+ accountManager,
+ store,
+ tabsStorage,
+ 0,
+ debounceMillis = 0,
+ ),
+ )
+ feature.start()
+
+ // Tabs are only stored when initial state is collected, since they are already loaded
+ verify(tabsStorage, times(1)).store(
+ listOf(
+ Tab(history = listOf(TabEntry(title = "", url = "https://www.mozilla.org", iconUrl = null)), active = 0, lastUsed = 123L, true),
+ Tab(history = listOf(TabEntry(title = "", url = "https://www.foo.bar", iconUrl = null)), active = 0, lastUsed = 124L, true),
+ ),
+ )
+
+ // Change a tab besides loading it
+ store.dispatch(ContentAction.UpdateProgressAction("tab1", 50)).joinBlocking()
+
+ reset(tabsStorage)
+
+ verify(tabsStorage, never()).store(any())
+ }
+
+ @Test
+ fun `only loaded tabs are stored on load`() = runTestOnMain {
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createUnloadedTab(id = "tab1", url = "https://www.mozilla.org", lastAccess = 123L),
+ createUnloadedTab(id = "tab2", url = "https://www.foo.bar", lastAccess = 124L),
+ ),
+ selectedTabId = "tab1",
+ ),
+ )
+ val feature = spy(
+ SyncedTabsStorage(
+ accountManager,
+ store,
+ tabsStorage,
+ 0,
+ debounceMillis = 0,
+ ),
+ )
+ feature.start()
+
+ store.dispatch(ContentAction.UpdateLoadingStateAction("tab1", false)).joinBlocking()
+
+ verify(tabsStorage).store(
+ listOf(
+ Tab(history = listOf(TabEntry(title = "", url = "https://www.mozilla.org", iconUrl = null)), active = 0, lastUsed = 123L, true),
+ ),
+ )
+ }
+
+ @Test
+ fun `tabs are stored when selected tab changes`() = runTestOnMain {
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab(id = "tab1", url = "https://www.mozilla.org", lastAccess = 123L),
+ createTab(id = "tab2", url = "https://www.foo.bar", lastAccess = 124L),
+ ),
+ selectedTabId = "tab1",
+ ),
+ )
+ val feature = spy(
+ SyncedTabsStorage(
+ accountManager,
+ store,
+ tabsStorage,
+ System.currentTimeMillis() * 2,
+ debounceMillis = 0,
+ ),
+ )
+ feature.start()
+
+ store.dispatch(TabListAction.SelectTabAction("tab2")).joinBlocking()
+
+ verify(tabsStorage, times(2)).store(
+ listOf(
+ Tab(history = listOf(TabEntry(title = "", url = "https://www.mozilla.org", iconUrl = null)), active = 0, lastUsed = 123L, false),
+ Tab(history = listOf(TabEntry(title = "", url = "https://www.foo.bar", iconUrl = null)), active = 0, lastUsed = 124L, false),
+ ),
+ )
+ }
+
+ @Test
+ fun `tabs are stored when lastAccessed is changed for any tab`() = runTestOnMain {
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab(id = "tab1", url = "https://www.mozilla.org", lastAccess = 123L),
+ createTab(id = "tab2", url = "https://www.foo.bar", lastAccess = 124L),
+ ),
+ selectedTabId = "tab1",
+ ),
+ )
+ val feature = spy(
+ SyncedTabsStorage(
+ accountManager,
+ store,
+ tabsStorage,
+ 0,
+ debounceMillis = 0,
+ ),
+ )
+ feature.start()
+
+ store.dispatch(LastAccessAction.UpdateLastAccessAction("tab1", 300L)).joinBlocking()
+
+ verify(tabsStorage, times(1)).store(
+ listOf(
+ Tab(history = listOf(TabEntry(title = "", url = "https://www.mozilla.org", iconUrl = null)), active = 0, lastUsed = 123L, true),
+ Tab(history = listOf(TabEntry(title = "", url = "https://www.foo.bar", iconUrl = null)), active = 0, lastUsed = 124L, true),
+ ),
+ )
+ verify(tabsStorage, times(1)).store(
+ listOf(
+ Tab(history = listOf(TabEntry(title = "", url = "https://www.mozilla.org", iconUrl = null)), active = 0, lastUsed = 300L, true),
+ Tab(history = listOf(TabEntry(title = "", url = "https://www.foo.bar", iconUrl = null)), active = 0, lastUsed = 124L, true),
+ ),
+ )
+ }
+
+ private fun createUnloadedTab(id: String, url: String, lastAccess: Long) = createTab(id = id, url = url, lastAccess = lastAccess).run {
+ copy(content = this.content.copy(loading = true))
+ }
+}
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/feature/syncedtabs/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000000..cf1c399ea8
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1,2 @@
+mock-maker-inline
+// This allows mocking final classes (classes are final by default in Kotlin)
diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/test/resources/robolectric.properties b/mobile/android/android-components/components/feature/syncedtabs/src/test/resources/robolectric.properties
new file mode 100644
index 0000000000..932b01b9eb
--- /dev/null
+++ b/mobile/android/android-components/components/feature/syncedtabs/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=28