diff options
Diffstat (limited to 'mobile/android/android-components/components/feature/syncedtabs')
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 |