From f614c0b18d19e3b92ad2089edff97f2e1c1f29aa Mon Sep 17 00:00:00 2001 From: Jonathan Almeida Date: Tue, 18 Aug 2020 00:09:27 -0400 Subject: [PATCH] For #12287: Add Synced Tabs to Tabs Tray --- .../org/mozilla/fenix/BrowserDirection.kt | 3 +- .../java/org/mozilla/fenix/HomeActivity.kt | 3 + .../mozilla/fenix/sync/SyncedTabsAdapter.kt | 54 ++++++++--- .../mozilla/fenix/sync/SyncedTabsLayout.kt | 48 +++++----- .../fenix/sync/SyncedTabsViewHolder.kt | 49 ++++++++-- .../org/mozilla/fenix/sync/ext/ErrorType.kt | 38 ++++++++ .../fenix/sync/ext/SyncedTabsAdapter.kt | 22 +++++ .../fenix/tabtray/SyncedTabsController.kt | 55 +++++++++++ .../fenix/tabtray/TabTrayController.kt | 12 +++ .../fenix/tabtray/TabTrayDialogFragment.kt | 3 +- .../tabtray/TabTrayFragmentInteractor.kt | 10 ++ .../org/mozilla/fenix/tabtray/TabTrayView.kt | 58 +++++++++--- app/src/main/res/anim/full_rotation.xml | 9 ++ .../main/res/layout/sync_tabs_error_row.xml | 1 + .../res/layout/view_synced_tabs_no_item.xml | 9 ++ .../res/layout/view_synced_tabs_title.xml | 29 ++++++ .../fenix/sync/ListenerDelegateTest.kt | 26 ++++++ .../fenix/sync/SyncedTabsAdapterTest.kt | 13 ++- .../fenix/sync/SyncedTabsLayoutTest.kt | 75 --------------- .../fenix/sync/SyncedTabsViewHolderTest.kt | 41 ++++++++- .../mozilla/fenix/sync/ext/ErrorTypeKtTest.kt | 72 +++++++++++++++ .../fenix/sync/ext/SyncedTabsAdapterKtTest.kt | 92 +++++++++++++++++++ .../tabtray/DefaultTabTrayControllerTest.kt | 13 +++ .../fenix/tabtray/SyncedTabsControllerTest.kt | 78 ++++++++++++++++ .../tabtray/TabTrayFragmentInteractorTest.kt | 6 ++ 25 files changed, 676 insertions(+), 143 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/sync/ext/ErrorType.kt create mode 100644 app/src/main/java/org/mozilla/fenix/sync/ext/SyncedTabsAdapter.kt create mode 100644 app/src/main/java/org/mozilla/fenix/tabtray/SyncedTabsController.kt create mode 100644 app/src/main/res/anim/full_rotation.xml create mode 100644 app/src/main/res/layout/view_synced_tabs_no_item.xml create mode 100644 app/src/main/res/layout/view_synced_tabs_title.xml create mode 100644 app/src/test/java/org/mozilla/fenix/sync/ListenerDelegateTest.kt create mode 100644 app/src/test/java/org/mozilla/fenix/sync/ext/ErrorTypeKtTest.kt create mode 100644 app/src/test/java/org/mozilla/fenix/sync/ext/SyncedTabsAdapterKtTest.kt create mode 100644 app/src/test/java/org/mozilla/fenix/tabtray/SyncedTabsControllerTest.kt diff --git a/app/src/main/java/org/mozilla/fenix/BrowserDirection.kt b/app/src/main/java/org/mozilla/fenix/BrowserDirection.kt index f23c51c31..841305b36 100644 --- a/app/src/main/java/org/mozilla/fenix/BrowserDirection.kt +++ b/app/src/main/java/org/mozilla/fenix/BrowserDirection.kt @@ -31,5 +31,6 @@ enum class BrowserDirection(@IdRes val fragmentId: Int) { FromEditCustomSearchEngineFragment(R.id.editCustomSearchEngineFragment), FromAddonDetailsFragment(R.id.addonDetailsFragment), FromAddonPermissionsDetailsFragment(R.id.addonPermissionsDetailFragment), - FromLoginDetailFragment(R.id.loginDetailFragment) + FromLoginDetailFragment(R.id.loginDetailFragment), + FromTabTray(R.id.tabTrayDialogFragment) } diff --git a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt index 02496dca7..86f26ed9e 100644 --- a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt +++ b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt @@ -100,6 +100,7 @@ import org.mozilla.fenix.settings.search.EditCustomSearchEngineFragmentDirection import org.mozilla.fenix.share.AddNewDeviceFragmentDirections import org.mozilla.fenix.sync.SyncedTabsFragmentDirections import org.mozilla.fenix.tabtray.TabTrayDialogFragment +import org.mozilla.fenix.tabtray.TabTrayDialogFragmentDirections import org.mozilla.fenix.theme.DefaultThemeManager import org.mozilla.fenix.theme.ThemeManager import org.mozilla.fenix.utils.BrowsersCache @@ -597,6 +598,8 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { AddonPermissionsDetailsFragmentDirections.actionGlobalBrowser(customTabSessionId) BrowserDirection.FromLoginDetailFragment -> LoginDetailFragmentDirections.actionGlobalBrowser(customTabSessionId) + BrowserDirection.FromTabTray -> + TabTrayDialogFragmentDirections.actionGlobalBrowser(customTabSessionId) } /** diff --git a/app/src/main/java/org/mozilla/fenix/sync/SyncedTabsAdapter.kt b/app/src/main/java/org/mozilla/fenix/sync/SyncedTabsAdapter.kt index e51419df4..6e30a4711 100644 --- a/app/src/main/java/org/mozilla/fenix/sync/SyncedTabsAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/sync/SyncedTabsAdapter.kt @@ -10,14 +10,18 @@ import androidx.navigation.NavController import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import mozilla.components.browser.storage.sync.SyncedDeviceTabs +import mozilla.components.feature.syncedtabs.view.SyncedTabsView import org.mozilla.fenix.sync.SyncedTabsViewHolder.DeviceViewHolder import org.mozilla.fenix.sync.SyncedTabsViewHolder.ErrorViewHolder +import org.mozilla.fenix.sync.SyncedTabsViewHolder.NoTabsViewHolder import org.mozilla.fenix.sync.SyncedTabsViewHolder.TabViewHolder +import org.mozilla.fenix.sync.SyncedTabsViewHolder.TitleViewHolder +import org.mozilla.fenix.sync.ext.toAdapterList import mozilla.components.browser.storage.sync.Tab as SyncTab import mozilla.components.concept.sync.Device as SyncDevice class SyncedTabsAdapter( - private val listener: (SyncTab) -> Unit + private val newListener: SyncedTabsView.Listener ) : ListAdapter(DiffCallback) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SyncedTabsViewHolder { @@ -27,30 +31,26 @@ class SyncedTabsAdapter( DeviceViewHolder.LAYOUT_ID -> DeviceViewHolder(itemView) TabViewHolder.LAYOUT_ID -> TabViewHolder(itemView) ErrorViewHolder.LAYOUT_ID -> ErrorViewHolder(itemView) + TitleViewHolder.LAYOUT_ID -> TitleViewHolder(itemView) + NoTabsViewHolder.LAYOUT_ID -> NoTabsViewHolder(itemView) else -> throw IllegalStateException() } } override fun onBindViewHolder(holder: SyncedTabsViewHolder, position: Int) { - holder.bind(getItem(position), listener) + holder.bind(getItem(position), newListener) } override fun getItemViewType(position: Int) = when (getItem(position)) { is AdapterItem.Device -> DeviceViewHolder.LAYOUT_ID is AdapterItem.Tab -> TabViewHolder.LAYOUT_ID is AdapterItem.Error -> ErrorViewHolder.LAYOUT_ID + is AdapterItem.Title -> TitleViewHolder.LAYOUT_ID + is AdapterItem.NoTabs -> NoTabsViewHolder.LAYOUT_ID } fun updateData(syncedTabs: List) { - val allDeviceTabs = mutableListOf() - - syncedTabs.forEach { (device, tabs) -> - if (tabs.isNotEmpty()) { - allDeviceTabs.add(AdapterItem.Device(device)) - tabs.mapTo(allDeviceTabs) { AdapterItem.Tab(it) } - } - } - + val allDeviceTabs = syncedTabs.toAdapterList() submitList(allDeviceTabs) } @@ -59,7 +59,11 @@ class SyncedTabsAdapter( when (oldItem) { is AdapterItem.Device -> newItem is AdapterItem.Device && oldItem.device.id == newItem.device.id - is AdapterItem.Tab, is AdapterItem.Error -> + is AdapterItem.NoTabs -> + newItem is AdapterItem.NoTabs && oldItem.device.id == newItem.device.id + is AdapterItem.Tab, + is AdapterItem.Error, + is AdapterItem.Title -> oldItem == newItem } @@ -68,9 +72,35 @@ class SyncedTabsAdapter( oldItem == newItem } + /** + * The various types of adapter items that can be found in a [SyncedTabsAdapter]. + */ sealed class AdapterItem { + + /** + * A title header of the Synced Tabs UI that has a refresh button in it. This may be seen + * only in some views depending on where the Synced Tabs UI is displayed. + */ + object Title : AdapterItem() + + /** + * A device header for displaying a synced device. + */ data class Device(val device: SyncDevice) : AdapterItem() + + /** + * A tab that was synced. + */ data class Tab(val tab: SyncTab) : AdapterItem() + + /** + * A placeholder for a device that has no tabs synced. + */ + data class NoTabs(val device: SyncDevice) : AdapterItem() + + /** + * A message displayed if an error was encountered. + */ data class Error( val descriptionResId: Int, val navController: NavController? = null diff --git a/app/src/main/java/org/mozilla/fenix/sync/SyncedTabsLayout.kt b/app/src/main/java/org/mozilla/fenix/sync/SyncedTabsLayout.kt index 8308692ba..1d160cfe5 100644 --- a/app/src/main/java/org/mozilla/fenix/sync/SyncedTabsLayout.kt +++ b/app/src/main/java/org/mozilla/fenix/sync/SyncedTabsLayout.kt @@ -7,7 +7,6 @@ package org.mozilla.fenix.sync import android.content.Context import android.util.AttributeSet import android.widget.FrameLayout -import androidx.annotation.StringRes import androidx.fragment.app.findFragment import androidx.navigation.NavController import androidx.navigation.fragment.findNavController @@ -18,8 +17,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import mozilla.components.browser.storage.sync.SyncedDeviceTabs +import mozilla.components.browser.storage.sync.Tab import mozilla.components.feature.syncedtabs.view.SyncedTabsView import org.mozilla.fenix.R +import org.mozilla.fenix.sync.ext.toAdapterItem +import org.mozilla.fenix.sync.ext.toStringRes import java.lang.IllegalStateException class SyncedTabsLayout @JvmOverloads constructor( @@ -30,7 +32,7 @@ class SyncedTabsLayout @JvmOverloads constructor( override var listener: SyncedTabsView.Listener? = null - private val adapter = SyncedTabsAdapter { listener?.onTabClicked(it) } + private val adapter = SyncedTabsAdapter(ListenerDelegate { listener }) private val coroutineScope = CoroutineScope(Dispatchers.Main) init { @@ -53,8 +55,8 @@ class SyncedTabsLayout @JvmOverloads constructor( null } - val descriptionResId = stringResourceForError(error) - val errorItem = getErrorItem(navController, error, descriptionResId) + val descriptionResId = error.toStringRes() + val errorItem = error.toAdapterItem(descriptionResId, navController) val errorList: List = listOf(errorItem) adapter.submitList(errorList) @@ -96,27 +98,21 @@ class SyncedTabsLayout @JvmOverloads constructor( SyncedTabsView.ErrorType.MULTIPLE_DEVICES_UNAVAILABLE, SyncedTabsView.ErrorType.NO_TABS_AVAILABLE -> true } - - internal fun stringResourceForError(error: SyncedTabsView.ErrorType) = when (error) { - SyncedTabsView.ErrorType.MULTIPLE_DEVICES_UNAVAILABLE -> R.string.synced_tabs_connect_another_device - SyncedTabsView.ErrorType.SYNC_ENGINE_UNAVAILABLE -> R.string.synced_tabs_enable_tab_syncing - SyncedTabsView.ErrorType.SYNC_UNAVAILABLE -> R.string.synced_tabs_sign_in_message - SyncedTabsView.ErrorType.SYNC_NEEDS_REAUTHENTICATION -> R.string.synced_tabs_reauth - SyncedTabsView.ErrorType.NO_TABS_AVAILABLE -> R.string.synced_tabs_no_tabs - } - - internal fun getErrorItem( - navController: NavController?, - error: SyncedTabsView.ErrorType, - @StringRes stringResId: Int - ): SyncedTabsAdapter.AdapterItem = when (error) { - SyncedTabsView.ErrorType.MULTIPLE_DEVICES_UNAVAILABLE, - SyncedTabsView.ErrorType.SYNC_ENGINE_UNAVAILABLE, - SyncedTabsView.ErrorType.SYNC_NEEDS_REAUTHENTICATION, - SyncedTabsView.ErrorType.NO_TABS_AVAILABLE -> SyncedTabsAdapter.AdapterItem - .Error(descriptionResId = stringResId) - SyncedTabsView.ErrorType.SYNC_UNAVAILABLE -> SyncedTabsAdapter.AdapterItem - .Error(descriptionResId = stringResId, navController = navController) - } + } +} + +/** + * We have to do this weird daisy-chaining of callbacks because the listener is nullable and + * when we get a null reference, we never get a new binding to the non-null listener. + */ +class ListenerDelegate( + private val listener: (() -> SyncedTabsView.Listener?) +) : SyncedTabsView.Listener { + override fun onRefresh() { + listener.invoke()?.onRefresh() + } + + override fun onTabClicked(tab: Tab) { + listener.invoke()?.onTabClicked(tab) } } diff --git a/app/src/main/java/org/mozilla/fenix/sync/SyncedTabsViewHolder.kt b/app/src/main/java/org/mozilla/fenix/sync/SyncedTabsViewHolder.kt index 95abada88..4549f570e 100644 --- a/app/src/main/java/org/mozilla/fenix/sync/SyncedTabsViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/sync/SyncedTabsViewHolder.kt @@ -7,29 +7,36 @@ package org.mozilla.fenix.sync import android.view.View import android.view.View.GONE import android.view.View.VISIBLE +import android.view.animation.Animation +import android.view.animation.AnimationUtils import android.widget.LinearLayout import androidx.recyclerview.widget.RecyclerView import kotlinx.android.synthetic.main.sync_tabs_error_row.view.* import kotlinx.android.synthetic.main.sync_tabs_list_item.view.* import kotlinx.android.synthetic.main.view_synced_tabs_group.view.* -import mozilla.components.browser.storage.sync.Tab +import kotlinx.android.synthetic.main.view_synced_tabs_title.view.* import mozilla.components.concept.sync.DeviceType +import mozilla.components.feature.syncedtabs.view.SyncedTabsView import mozilla.components.support.ktx.android.util.dpToPx import org.mozilla.fenix.NavGraphDirections import org.mozilla.fenix.R import org.mozilla.fenix.sync.SyncedTabsAdapter.AdapterItem +/** + * The various view-holders that can be found in a [SyncedTabsAdapter]. For more + * descriptive information on the different types, see the docs for [AdapterItem]. + */ sealed class SyncedTabsViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - abstract fun bind(item: T, interactor: (Tab) -> Unit) + abstract fun bind(item: T, interactor: SyncedTabsView.Listener) class TabViewHolder(itemView: View) : SyncedTabsViewHolder(itemView) { - override fun bind(item: T, interactor: (Tab) -> Unit) { + override fun bind(item: T, interactor: SyncedTabsView.Listener) { bindTab(item as AdapterItem.Tab) itemView.setOnClickListener { - interactor(item.tab) + interactor.onTabClicked(item.tab) } } @@ -46,7 +53,7 @@ sealed class SyncedTabsViewHolder(itemView: View) : RecyclerView.ViewHolder(item class ErrorViewHolder(itemView: View) : SyncedTabsViewHolder(itemView) { - override fun bind(item: T, interactor: (Tab) -> Unit) { + override fun bind(item: T, interactor: SyncedTabsView.Listener) { val errorItem = item as AdapterItem.Error setErrorMargins() @@ -69,7 +76,7 @@ sealed class SyncedTabsViewHolder(itemView: View) : RecyclerView.ViewHolder(item class DeviceViewHolder(itemView: View) : SyncedTabsViewHolder(itemView) { - override fun bind(item: T, interactor: (Tab) -> Unit) { + override fun bind(item: T, interactor: SyncedTabsView.Listener) { bindHeader(item as AdapterItem.Device) } @@ -93,6 +100,36 @@ sealed class SyncedTabsViewHolder(itemView: View) : RecyclerView.ViewHolder(item } } + class NoTabsViewHolder(itemView: View) : SyncedTabsViewHolder(itemView) { + override fun bind(item: T, interactor: SyncedTabsView.Listener) = Unit + + companion object { + const val LAYOUT_ID = R.layout.view_synced_tabs_no_item + } + } + + class TitleViewHolder(itemView: View) : SyncedTabsViewHolder(itemView) { + + override fun bind(item: T, interactor: SyncedTabsView.Listener) { + itemView.refresh_icon.setOnClickListener { v -> + val rotation = AnimationUtils.loadAnimation( + itemView.context, + R.anim.full_rotation + ).apply { + repeatCount = Animation.ABSOLUTE + } + + v.startAnimation(rotation) + + interactor.onRefresh() + } + } + + companion object { + const val LAYOUT_ID = R.layout.view_synced_tabs_title + } + } + internal fun setErrorMargins() { val lp = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, diff --git a/app/src/main/java/org/mozilla/fenix/sync/ext/ErrorType.kt b/app/src/main/java/org/mozilla/fenix/sync/ext/ErrorType.kt new file mode 100644 index 000000000..1a24ba455 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/sync/ext/ErrorType.kt @@ -0,0 +1,38 @@ +/* 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 org.mozilla.fenix.sync.ext + +import androidx.annotation.StringRes +import androidx.navigation.NavController +import mozilla.components.feature.syncedtabs.view.SyncedTabsView.ErrorType +import org.mozilla.fenix.R +import org.mozilla.fenix.sync.SyncedTabsAdapter + +/** + * Converts the error type to the appropriate matching string resource for displaying to the user. + */ +fun ErrorType.toStringRes() = when (this) { + ErrorType.MULTIPLE_DEVICES_UNAVAILABLE -> R.string.synced_tabs_connect_another_device + ErrorType.SYNC_ENGINE_UNAVAILABLE -> R.string.synced_tabs_enable_tab_syncing + ErrorType.SYNC_UNAVAILABLE -> R.string.synced_tabs_sign_in_message + ErrorType.SYNC_NEEDS_REAUTHENTICATION -> R.string.synced_tabs_reauth + ErrorType.NO_TABS_AVAILABLE -> R.string.synced_tabs_no_tabs +} + +/** + * Converts an error type to an [SyncedTabsAdapter.AdapterItem.Error]. + */ +fun ErrorType.toAdapterItem( + @StringRes stringResId: Int, + navController: NavController? = null +) = when (this) { + ErrorType.MULTIPLE_DEVICES_UNAVAILABLE, + ErrorType.SYNC_ENGINE_UNAVAILABLE, + ErrorType.SYNC_NEEDS_REAUTHENTICATION, + ErrorType.NO_TABS_AVAILABLE -> SyncedTabsAdapter.AdapterItem + .Error(descriptionResId = stringResId) + ErrorType.SYNC_UNAVAILABLE -> SyncedTabsAdapter.AdapterItem + .Error(descriptionResId = stringResId, navController = navController) +} diff --git a/app/src/main/java/org/mozilla/fenix/sync/ext/SyncedTabsAdapter.kt b/app/src/main/java/org/mozilla/fenix/sync/ext/SyncedTabsAdapter.kt new file mode 100644 index 000000000..6f1e982b5 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/sync/ext/SyncedTabsAdapter.kt @@ -0,0 +1,22 @@ +/* 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 org.mozilla.fenix.sync.ext + +import mozilla.components.browser.storage.sync.SyncedDeviceTabs +import org.mozilla.fenix.sync.SyncedTabsAdapter.AdapterItem + +fun List.toAdapterList( +): MutableList { + val allDeviceTabs = mutableListOf() + + forEach { (device, tabs) -> + if (tabs.isNotEmpty()) { + allDeviceTabs.add(AdapterItem.Device(device)) + tabs.mapTo(allDeviceTabs) { AdapterItem.Tab(it) } + } + } + + return allDeviceTabs +} \ No newline at end of file diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/SyncedTabsController.kt b/app/src/main/java/org/mozilla/fenix/tabtray/SyncedTabsController.kt new file mode 100644 index 000000000..2e47fff15 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabtray/SyncedTabsController.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 org.mozilla.fenix.tabtray + +import android.view.View +import androidx.fragment.app.FragmentManager.findFragment +import androidx.navigation.NavController +import androidx.navigation.fragment.findNavController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import mozilla.components.browser.storage.sync.SyncedDeviceTabs +import mozilla.components.feature.syncedtabs.view.SyncedTabsView +import org.mozilla.fenix.sync.ListenerDelegate +import org.mozilla.fenix.sync.SyncedTabsAdapter +import org.mozilla.fenix.sync.ext.toAdapterList +import org.mozilla.fenix.sync.ext.toAdapterItem +import org.mozilla.fenix.sync.ext.toStringRes +import kotlin.coroutines.CoroutineContext + +class SyncedTabsController( + private val view: View, + coroutineContext: CoroutineContext = Dispatchers.Main +) : SyncedTabsView { + override var listener: SyncedTabsView.Listener? = null + + val adapter = SyncedTabsAdapter(ListenerDelegate { listener }) + + private val scope: CoroutineScope = CoroutineScope(coroutineContext) + + override fun displaySyncedTabs(syncedTabs: List) { + scope.launch { + val tabsList = listOf(SyncedTabsAdapter.AdapterItem.Title) + syncedTabs.toAdapterList() + // Reverse layout for TabTrayView which does things backwards. + adapter.submitList(tabsList.reversed()) + } + } + + override fun onError(error: SyncedTabsView.ErrorType) { + scope.launch { + val navController: NavController? = try { + findFragment(view).findNavController() + } catch (exception: IllegalStateException) { + null + } + + val descriptionResId = error.toStringRes() + val errorItem = error.toAdapterItem(descriptionResId, navController) + + adapter.submitList(listOf(errorItem)) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayController.kt b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayController.kt index ad71ced1c..7a3a03eee 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayController.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayController.kt @@ -9,10 +9,12 @@ import androidx.navigation.NavController import kotlinx.coroutines.ExperimentalCoroutinesApi import mozilla.components.browser.session.Session import mozilla.components.browser.session.SessionManager +import mozilla.components.browser.storage.sync.Tab as SyncTab import mozilla.components.concept.engine.profiler.Profiler import mozilla.components.concept.engine.prompt.ShareData import mozilla.components.concept.tabstray.Tab import mozilla.components.feature.tabs.TabsUseCases +import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager @@ -30,6 +32,7 @@ interface TabTrayController { fun onNewTabTapped(private: Boolean) fun onTabTrayDismissed() fun onShareTabsClicked(private: Boolean) + fun onSyncedTabClicked(syncTab: SyncTab) fun onSaveToCollectionClicked(selectedTabs: Set) fun onCloseAllTabsClicked(private: Boolean) fun handleBackPressed(): Boolean @@ -59,6 +62,7 @@ interface TabTrayController { */ @Suppress("TooManyFunctions") class DefaultTabTrayController( + private val activity: HomeActivity, private val profiler: Profiler?, private val sessionManager: SessionManager, private val browsingModeManager: BrowsingModeManager, @@ -117,6 +121,14 @@ class DefaultTabTrayController( navController.navigate(directions) } + override fun onSyncedTabClicked(syncTab: SyncTab) { + activity.openToBrowserAndLoad( + searchTermOrURL = syncTab.active().url, + newTab = true, + from = BrowserDirection.FromTabTray + ) + } + @OptIn(ExperimentalCoroutinesApi::class) override fun onCloseAllTabsClicked(private: Boolean) { val sessionsToClose = if (private) { diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayDialogFragment.kt b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayDialogFragment.kt index cfe8b0f1c..be5ec1818 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayDialogFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayDialogFragment.kt @@ -177,6 +177,7 @@ class TabTrayDialogFragment : AppCompatDialogFragment(), UserInteractionHandler adapter, interactor = TabTrayFragmentInteractor( DefaultTabTrayController( + activity = activity, profiler = activity.components.core.engine.profiler, sessionManager = activity.components.core.sessionManager, browsingModeManager = activity.browsingModeManager, @@ -194,7 +195,7 @@ class TabTrayDialogFragment : AppCompatDialogFragment(), UserInteractionHandler isPrivate = isPrivate, startingInLandscape = requireContext().resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE, - lifecycleScope = viewLifecycleOwner.lifecycleScope + lifecycleOwner = viewLifecycleOwner ) { private -> val filter: (TabSessionState) -> Boolean = { state -> private == state.content.private } diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayFragmentInteractor.kt b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayFragmentInteractor.kt index 33bef403a..b6a65dd77 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayFragmentInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayFragmentInteractor.kt @@ -5,6 +5,7 @@ package org.mozilla.fenix.tabtray import mozilla.components.concept.tabstray.Tab +import mozilla.components.browser.storage.sync.Tab as SyncTab @Suppress("TooManyFunctions") interface TabTrayInteractor { @@ -33,6 +34,11 @@ interface TabTrayInteractor { */ fun onCloseAllTabsClicked(private: Boolean) + /** + * Called when the user clicks on a synced tab entry. + */ + fun onSyncedTabClicked(syncTab: SyncTab) + /** * Called when the physical back button is clicked. */ @@ -89,6 +95,10 @@ class TabTrayFragmentInteractor(private val controller: TabTrayController) : Tab controller.onCloseAllTabsClicked(private) } + override fun onSyncedTabClicked(syncTab: SyncTab) { + controller.onSyncedTabClicked(syncTab) + } + override fun onBackPressed(): Boolean { return controller.handleBackPressed() } diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt index 9b78836ff..977cee49a 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt @@ -16,7 +16,8 @@ import androidx.constraintlayout.widget.ConstraintSet import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams -import androidx.lifecycle.LifecycleCoroutineScope +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.bottomsheet.BottomSheetBehavior @@ -35,7 +36,10 @@ import mozilla.components.browser.state.selector.getNormalOrPrivateTabs import mozilla.components.browser.state.selector.normalTabs import mozilla.components.browser.state.selector.privateTabs import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.storage.sync.Tab as SyncTab import mozilla.components.browser.tabstray.TabViewHolder +import mozilla.components.feature.syncedtabs.SyncedTabsFeature +import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import mozilla.components.support.ktx.android.util.dpToPx import org.mozilla.fenix.R import org.mozilla.fenix.components.metrics.Event @@ -58,9 +62,10 @@ class TabTrayView( private val interactor: TabTrayInteractor, isPrivate: Boolean, startingInLandscape: Boolean, - lifecycleScope: LifecycleCoroutineScope, + lifecycleOwner: LifecycleOwner, private val filterTabs: (Boolean) -> Unit ) : LayoutContainer, TabLayout.OnTabSelectedListener { + val lifecycleScope = lifecycleOwner.lifecycleScope val fabView = LayoutInflater.from(container.context) .inflate(R.layout.component_tabstray_fab, container, true) @@ -79,13 +84,18 @@ class TabTrayView( private var tabsTouchHelper: TabsTouchHelper private val collectionsButtonAdapter = SaveToCollectionsButtonAdapter(interactor, isPrivate) + private val syncedTabsController = SyncedTabsController(view) + private val syncedTabsFeature = ViewBoundFeatureWrapper() + private var hasLoaded = false override val containerView: View? get() = container + private val components = container.context.components + init { - container.context.components.analytics.metrics.track(Event.TabsTrayOpened) + components.analytics.metrics.track(Event.TabsTrayOpened) toggleFabText(isPrivate) @@ -102,7 +112,7 @@ class TabTrayView( override fun onStateChanged(bottomSheet: View, newState: Int) { if (newState == BottomSheetBehavior.STATE_HIDDEN) { - container.context.components.analytics.metrics.track(Event.TabsTrayClosed) + components.analytics.metrics.track(Event.TabsTrayClosed) interactor.onTabTrayDismissed() } } @@ -135,8 +145,20 @@ class TabTrayView( setTopOffset(startingInLandscape) - val concatAdapter = ConcatAdapter(tabsAdapter) + syncedTabsFeature.set( + feature = SyncedTabsFeature( + context = container.context, + storage = components.backgroundServices.syncedTabsStorage, + accountManager = components.backgroundServices.accountManager, + view = syncedTabsController, + lifecycleOwner = lifecycleOwner, + onTabClicked = ::handleTabClicked + ), + owner = lifecycleOwner, + view = view + ) + val concatAdapter = ConcatAdapter(tabsAdapter) view.tabsTray.apply { layoutManager = LinearLayoutManager(container.context).apply { reverseLayout = true @@ -156,6 +178,9 @@ class TabTrayView( // Put the 'Add to collections' button after the tabs have loaded. concatAdapter.addAdapter(0, collectionsButtonAdapter) + // Put the Synced Tabs adapter at the end. + concatAdapter.addAdapter(0, syncedTabsController.adapter) + if (hasAccessibilityEnabled) { tabsAdapter.notifyDataSetChanged() } @@ -193,7 +218,7 @@ class TabTrayView( } view.tab_tray_overflow.setOnClickListener { - container.context.components.analytics.metrics.track(Event.TabsTrayMenuOpened) + components.analytics.metrics.track(Event.TabsTrayMenuOpened) menu = tabTrayItemMenu.menuBuilder.build(container.context) menu?.show(it) ?.also { pu -> @@ -209,6 +234,10 @@ class TabTrayView( adjustNewTabButtonsForNormalMode() } + private fun handleTabClicked(tab: SyncTab) { + interactor.onSyncedTabClicked(tab) + } + private fun adjustNewTabButtonsForNormalMode() { view.tab_tray_new_tab.apply { isVisible = hasAccessibilityEnabled @@ -234,7 +263,7 @@ class TabTrayView( Event.NewTabTapped } - container.context.components.analytics.metrics.track(eventToSend) + components.analytics.metrics.track(eventToSend) } fun expand() { @@ -261,17 +290,14 @@ class TabTrayView( scrollToTab(view.context.components.core.store.state.selectedTabId) if (isPrivateModeSelected) { - container.context.components.analytics.metrics.track(Event.TabsTrayPrivateModeTapped) + components.analytics.metrics.track(Event.TabsTrayPrivateModeTapped) } else { - container.context.components.analytics.metrics.track(Event.TabsTrayNormalModeTapped) + components.analytics.metrics.track(Event.TabsTrayNormalModeTapped) } } - override fun onTabReselected(tab: TabLayout.Tab?) { /*noop*/ - } - - override fun onTabUnselected(tab: TabLayout.Tab?) { /*noop*/ - } + override fun onTabReselected(tab: TabLayout.Tab?) = Unit + override fun onTabUnselected(tab: TabLayout.Tab?) = Unit var mode: Mode = Mode.Normal private set @@ -513,7 +539,9 @@ class TabTrayView( // We offset the tab index by the number of items in the other adapters. // We add the offset, because the layoutManager is initialized with `reverseLayout`. - val recyclerViewIndex = selectedBrowserTabIndex + collectionsButtonAdapter.itemCount + val recyclerViewIndex = selectedBrowserTabIndex + + collectionsButtonAdapter.itemCount + + syncedTabsController.adapter.itemCount layoutManager?.scrollToPosition(recyclerViewIndex) } diff --git a/app/src/main/res/anim/full_rotation.xml b/app/src/main/res/anim/full_rotation.xml new file mode 100644 index 000000000..357a54041 --- /dev/null +++ b/app/src/main/res/anim/full_rotation.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/sync_tabs_error_row.xml b/app/src/main/res/layout/sync_tabs_error_row.xml index f9005af30..1e104150e 100644 --- a/app/src/main/res/layout/sync_tabs_error_row.xml +++ b/app/src/main/res/layout/sync_tabs_error_row.xml @@ -19,6 +19,7 @@ android:layout_marginTop="4dp" android:textSize="14sp" android:textAlignment="viewStart" + android:textColor="@color/tab_tray_item_text_normal_theme" tools:text="@string/synced_tabs_no_tabs"/> + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_synced_tabs_title.xml b/app/src/main/res/layout/view_synced_tabs_title.xml new file mode 100644 index 000000000..eedbe9415 --- /dev/null +++ b/app/src/main/res/layout/view_synced_tabs_title.xml @@ -0,0 +1,29 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/org/mozilla/fenix/sync/ListenerDelegateTest.kt b/app/src/test/java/org/mozilla/fenix/sync/ListenerDelegateTest.kt new file mode 100644 index 000000000..f1863bd60 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/sync/ListenerDelegateTest.kt @@ -0,0 +1,26 @@ +/* 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 org.mozilla.fenix.sync + +import io.mockk.mockk +import io.mockk.verify +import mozilla.components.feature.syncedtabs.view.SyncedTabsView +import org.junit.Test + +class ListenerDelegateTest { + @Test + fun `delegate invokes nullable listener`() { + val listener: SyncedTabsView.Listener? = mockk(relaxed = true) + val delegate = ListenerDelegate { listener } + + delegate.onRefresh() + + verify { listener?.onRefresh() } + + delegate.onTabClicked(mockk()) + + verify { listener?.onTabClicked(any()) } + } +} diff --git a/app/src/test/java/org/mozilla/fenix/sync/SyncedTabsAdapterTest.kt b/app/src/test/java/org/mozilla/fenix/sync/SyncedTabsAdapterTest.kt index e22bbb181..f1f7f3fea 100644 --- a/app/src/test/java/org/mozilla/fenix/sync/SyncedTabsAdapterTest.kt +++ b/app/src/test/java/org/mozilla/fenix/sync/SyncedTabsAdapterTest.kt @@ -11,6 +11,7 @@ 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.DeviceType +import mozilla.components.feature.syncedtabs.view.SyncedTabsView import mozilla.components.support.test.robolectric.testContext import org.junit.Assert.assertEquals import org.junit.Before @@ -21,7 +22,7 @@ import org.mozilla.fenix.helpers.FenixRobolectricTestRunner @RunWith(FenixRobolectricTestRunner::class) class SyncedTabsAdapterTest { - private lateinit var listener: (Tab) -> Unit + private lateinit var listener: SyncedTabsView.Listener private lateinit var adapter: SyncedTabsAdapter private val oneTabDevice = SyncedDeviceTabs( @@ -77,10 +78,12 @@ class SyncedTabsAdapterTest { fun `updateData() adds items for each device and tab`() { assertEquals(0, adapter.itemCount) - adapter.updateData(listOf( - oneTabDevice, - threeTabDevice - )) + adapter.updateData( + listOf( + oneTabDevice, + threeTabDevice + ) + ) assertEquals(5, adapter.itemCount) assertEquals(SyncedTabsViewHolder.DeviceViewHolder.LAYOUT_ID, adapter.getItemViewType(0)) diff --git a/app/src/test/java/org/mozilla/fenix/sync/SyncedTabsLayoutTest.kt b/app/src/test/java/org/mozilla/fenix/sync/SyncedTabsLayoutTest.kt index a04bfbb51..8c1f6514a 100644 --- a/app/src/test/java/org/mozilla/fenix/sync/SyncedTabsLayoutTest.kt +++ b/app/src/test/java/org/mozilla/fenix/sync/SyncedTabsLayoutTest.kt @@ -4,16 +4,10 @@ package org.mozilla.fenix.sync -import androidx.navigation.NavController -import io.mockk.mockk import mozilla.components.feature.syncedtabs.view.SyncedTabsView.ErrorType -import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test -import org.mozilla.fenix.R class SyncedTabsLayoutTest { @@ -25,73 +19,4 @@ class SyncedTabsLayoutTest { assertFalse(SyncedTabsLayout.pullToRefreshEnableState(ErrorType.SYNC_NEEDS_REAUTHENTICATION)) assertFalse(SyncedTabsLayout.pullToRefreshEnableState(ErrorType.SYNC_UNAVAILABLE)) } - - @Test - fun `string resource for error`() { - assertEquals( - R.string.synced_tabs_connect_another_device, - SyncedTabsLayout.stringResourceForError(ErrorType.MULTIPLE_DEVICES_UNAVAILABLE) - ) - assertEquals( - R.string.synced_tabs_enable_tab_syncing, - SyncedTabsLayout.stringResourceForError(ErrorType.SYNC_ENGINE_UNAVAILABLE) - ) - assertEquals( - R.string.synced_tabs_sign_in_message, - SyncedTabsLayout.stringResourceForError(ErrorType.SYNC_UNAVAILABLE) - ) - assertEquals( - R.string.synced_tabs_reauth, - SyncedTabsLayout.stringResourceForError(ErrorType.SYNC_NEEDS_REAUTHENTICATION) - ) - assertEquals( - R.string.synced_tabs_no_tabs, - SyncedTabsLayout.stringResourceForError(ErrorType.NO_TABS_AVAILABLE) - ) - } - - @Test - fun `get error item`() { - val navController = mockk() - - var errorItem = SyncedTabsLayout.getErrorItem( - navController, - ErrorType.MULTIPLE_DEVICES_UNAVAILABLE, - R.string.synced_tabs_connect_another_device - ) - assertNull((errorItem as SyncedTabsAdapter.AdapterItem.Error).navController) - assertEquals(R.string.synced_tabs_connect_another_device, errorItem.descriptionResId) - - errorItem = SyncedTabsLayout.getErrorItem( - navController, - ErrorType.SYNC_ENGINE_UNAVAILABLE, - R.string.synced_tabs_enable_tab_syncing - ) - assertNull((errorItem as SyncedTabsAdapter.AdapterItem.Error).navController) - assertEquals(R.string.synced_tabs_enable_tab_syncing, errorItem.descriptionResId) - - errorItem = SyncedTabsLayout.getErrorItem( - navController, - ErrorType.SYNC_NEEDS_REAUTHENTICATION, - R.string.synced_tabs_reauth - ) - assertNull((errorItem as SyncedTabsAdapter.AdapterItem.Error).navController) - assertEquals(R.string.synced_tabs_reauth, errorItem.descriptionResId) - - errorItem = SyncedTabsLayout.getErrorItem( - navController, - ErrorType.NO_TABS_AVAILABLE, - R.string.synced_tabs_no_tabs - ) - assertNull((errorItem as SyncedTabsAdapter.AdapterItem.Error).navController) - assertEquals(R.string.synced_tabs_no_tabs, errorItem.descriptionResId) - - errorItem = SyncedTabsLayout.getErrorItem( - navController, - ErrorType.SYNC_UNAVAILABLE, - R.string.synced_tabs_sign_in_message - ) - assertNotNull((errorItem as SyncedTabsAdapter.AdapterItem.Error).navController) - assertEquals(R.string.synced_tabs_sign_in_message, errorItem.descriptionResId) - } } diff --git a/app/src/test/java/org/mozilla/fenix/sync/SyncedTabsViewHolderTest.kt b/app/src/test/java/org/mozilla/fenix/sync/SyncedTabsViewHolderTest.kt index b81a0fc3b..3f8b06fd2 100644 --- a/app/src/test/java/org/mozilla/fenix/sync/SyncedTabsViewHolderTest.kt +++ b/app/src/test/java/org/mozilla/fenix/sync/SyncedTabsViewHolderTest.kt @@ -7,15 +7,18 @@ package org.mozilla.fenix.sync import android.view.LayoutInflater import android.view.View import android.widget.TextView +import io.mockk.Called import io.mockk.every import io.mockk.mockk import io.mockk.verify import kotlinx.android.synthetic.main.sync_tabs_list_item.view.* import kotlinx.android.synthetic.main.view_synced_tabs_group.view.* +import kotlinx.android.synthetic.main.view_synced_tabs_title.view.* 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 +import mozilla.components.feature.syncedtabs.view.SyncedTabsView import mozilla.components.support.test.robolectric.testContext import org.junit.Assert.assertEquals import org.junit.Before @@ -32,6 +35,10 @@ class SyncedTabsViewHolderTest { private lateinit var deviceViewHolder: SyncedTabsViewHolder.DeviceViewHolder private lateinit var deviceView: View private lateinit var deviceViewGroupName: TextView + private lateinit var titleView: View + private lateinit var titleViewHolder: SyncedTabsViewHolder.TitleViewHolder + private lateinit var noTabsView: View + private lateinit var noTabsViewHolder: SyncedTabsViewHolder.NoTabsViewHolder private val tab = Tab( history = listOf( @@ -59,6 +66,12 @@ class SyncedTabsViewHolderTest { every { synced_tabs_group_name } returns deviceViewGroupName } deviceViewHolder = SyncedTabsViewHolder.DeviceViewHolder(deviceView) + + titleView = inflater.inflate(SyncedTabsViewHolder.TitleViewHolder.LAYOUT_ID, null) + titleViewHolder = SyncedTabsViewHolder.TitleViewHolder(titleView) + + noTabsView = inflater.inflate(SyncedTabsViewHolder.NoTabsViewHolder.LAYOUT_ID, null) + noTabsViewHolder = SyncedTabsViewHolder.NoTabsViewHolder(noTabsView) } @Test @@ -71,11 +84,11 @@ class SyncedTabsViewHolderTest { @Test fun `TabViewHolder calls interactor on click`() { - val interactor = mockk<(Tab) -> Unit>(relaxed = true) + val interactor = mockk(relaxed = true) tabViewHolder.bind(SyncedTabsAdapter.AdapterItem.Tab(tab), interactor) tabView.performClick() - verify { interactor(tab) } + verify { interactor.onTabClicked(tab) } } @Test @@ -109,4 +122,28 @@ class SyncedTabsViewHolderTest { ) } } + + @Test + fun `TitleViewHolder calls interactor refresh`() { + val interactor = mockk(relaxed = true) + titleViewHolder.bind(SyncedTabsAdapter.AdapterItem.Title, interactor) + + titleView.findViewById(R.id.refresh_icon).performClick() + + verify { interactor.onRefresh() } + } + + @Test + fun `NoTabsViewHolder does nothing`() { + val device = mockk { + every { displayName } returns "Charcoal" + every { deviceType } returns DeviceType.DESKTOP + } + val interactor = mockk(relaxed = true) + noTabsViewHolder.bind(SyncedTabsAdapter.AdapterItem.NoTabs(device), interactor) + + titleView.performClick() + + verify { interactor wasNot Called } + } } diff --git a/app/src/test/java/org/mozilla/fenix/sync/ext/ErrorTypeKtTest.kt b/app/src/test/java/org/mozilla/fenix/sync/ext/ErrorTypeKtTest.kt new file mode 100644 index 000000000..180ed0878 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/sync/ext/ErrorTypeKtTest.kt @@ -0,0 +1,72 @@ +package org.mozilla.fenix.sync.ext + +import org.junit.Test +import androidx.navigation.NavController +import io.mockk.mockk +import mozilla.components.feature.syncedtabs.view.SyncedTabsView.ErrorType +import org.mozilla.fenix.R +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertEquals + +class ErrorTypeKtTest { + + @Test + fun `string resource for error`() { + assertEquals( + R.string.synced_tabs_connect_another_device, + ErrorType.MULTIPLE_DEVICES_UNAVAILABLE.toStringRes() + ) + assertEquals( + R.string.synced_tabs_enable_tab_syncing, + ErrorType.SYNC_ENGINE_UNAVAILABLE.toStringRes() + ) + assertEquals( + R.string.synced_tabs_sign_in_message, + ErrorType.SYNC_UNAVAILABLE.toStringRes() + ) + assertEquals( + R.string.synced_tabs_reauth, + ErrorType.SYNC_NEEDS_REAUTHENTICATION.toStringRes() + ) + assertEquals( + R.string.synced_tabs_no_tabs, + ErrorType.NO_TABS_AVAILABLE.toStringRes() + ) + } + + @Test + fun `get error item`() { + val navController = mockk() + + var errorItem = ErrorType.MULTIPLE_DEVICES_UNAVAILABLE.toAdapterItem( + R.string.synced_tabs_connect_another_device, navController + ) + assertNull(errorItem.navController) + assertEquals(R.string.synced_tabs_connect_another_device, errorItem.descriptionResId) + + errorItem = ErrorType.SYNC_ENGINE_UNAVAILABLE.toAdapterItem( + R.string.synced_tabs_enable_tab_syncing, navController + ) + assertNull(errorItem.navController) + assertEquals(R.string.synced_tabs_enable_tab_syncing, errorItem.descriptionResId) + + errorItem = ErrorType.SYNC_NEEDS_REAUTHENTICATION.toAdapterItem( + R.string.synced_tabs_reauth, navController + ) + assertNull(errorItem.navController) + assertEquals(R.string.synced_tabs_reauth, errorItem.descriptionResId) + + errorItem = ErrorType.NO_TABS_AVAILABLE.toAdapterItem( + R.string.synced_tabs_no_tabs, navController + ) + assertNull(errorItem.navController) + assertEquals(R.string.synced_tabs_no_tabs, errorItem.descriptionResId) + + errorItem = ErrorType.SYNC_UNAVAILABLE.toAdapterItem( + R.string.synced_tabs_sign_in_message, navController + ) + assertNotNull(errorItem.navController) + assertEquals(R.string.synced_tabs_sign_in_message, errorItem.descriptionResId) + } +} diff --git a/app/src/test/java/org/mozilla/fenix/sync/ext/SyncedTabsAdapterKtTest.kt b/app/src/test/java/org/mozilla/fenix/sync/ext/SyncedTabsAdapterKtTest.kt new file mode 100644 index 000000000..bc04fc8f7 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/sync/ext/SyncedTabsAdapterKtTest.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 org.mozilla.fenix.sync.ext + +import io.mockk.every +import io.mockk.mockk +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.DeviceType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mozilla.fenix.sync.SyncedTabsAdapter + +class SyncedTabsAdapterKtTest { + private val noTabDevice = SyncedDeviceTabs( + device = mockk { + every { displayName } returns "Charcoal" + every { deviceType } returns DeviceType.DESKTOP + }, + tabs = emptyList() + ) + + private val oneTabDevice = SyncedDeviceTabs( + device = mockk { + every { displayName } returns "Charcoal" + every { deviceType } returns DeviceType.DESKTOP + }, + tabs = listOf(Tab( + history = listOf(TabEntry( + title = "Mozilla", + url = "https://mozilla.org", + iconUrl = null + )), + active = 0, + lastUsed = 0L + )) + ) + + private val twoTabDevice = SyncedDeviceTabs( + device = mockk { + every { displayName } returns "Emerald" + every { deviceType } returns DeviceType.MOBILE + }, + tabs = listOf( + Tab( + history = listOf(TabEntry( + title = "Mozilla", + url = "https://mozilla.org", + iconUrl = null + )), + active = 0, + lastUsed = 0L + ), + Tab( + history = listOf( + TabEntry( + title = "Firefox", + url = "https://firefox.com", + iconUrl = null + ) + ), + active = 0, + lastUsed = 0L + ) + ) + ) + + @Test + fun `verify ordering of adapter items`() { + val syncedDeviceList = listOf(oneTabDevice, twoTabDevice) + val adapterData = syncedDeviceList.toAdapterList() + + assertEquals(5, adapterData.count()) + assertTrue(adapterData[0] is SyncedTabsAdapter.AdapterItem.Device) + assertTrue(adapterData[1] is SyncedTabsAdapter.AdapterItem.Tab) + assertTrue(adapterData[2] is SyncedTabsAdapter.AdapterItem.Device) + assertTrue(adapterData[3] is SyncedTabsAdapter.AdapterItem.Tab) + assertTrue(adapterData[4] is SyncedTabsAdapter.AdapterItem.Tab) + } + + @Test + fun `verify no tabs displayed`() { + val syncedDeviceList = listOf(noTabDevice) + val adapterData = syncedDeviceList.toAdapterList() + + assertEquals(0, adapterData.count()) + } +} diff --git a/app/src/test/java/org/mozilla/fenix/tabtray/DefaultTabTrayControllerTest.kt b/app/src/test/java/org/mozilla/fenix/tabtray/DefaultTabTrayControllerTest.kt index 57ce54da4..c95c8bef4 100644 --- a/app/src/test/java/org/mozilla/fenix/tabtray/DefaultTabTrayControllerTest.kt +++ b/app/src/test/java/org/mozilla/fenix/tabtray/DefaultTabTrayControllerTest.kt @@ -26,6 +26,8 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import org.mozilla.fenix.BrowserDirection +import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager @@ -34,6 +36,7 @@ import org.mozilla.fenix.ext.sessionsOfType @OptIn(ExperimentalCoroutinesApi::class) class DefaultTabTrayControllerTest { + private val activity: HomeActivity = mockk(relaxed = true) private val profiler: Profiler? = mockk(relaxed = true) private val navController: NavController = mockk() private val sessionManager: SessionManager = mockk(relaxed = true) @@ -81,6 +84,7 @@ class DefaultTabTrayControllerTest { every { tabCollection.title } returns "Collection title" controller = DefaultTabTrayController( + activity = activity, profiler = profiler, sessionManager = sessionManager, browsingModeManager = browsingModeManager, @@ -156,6 +160,15 @@ class DefaultTabTrayControllerTest { } } + @Test + fun onSyncedTabClicked() { + controller.onSyncedTabClicked(mockk(relaxed = true)) + + verify { + activity.openToBrowserAndLoad(any(), true, BrowserDirection.FromTabTray) + } + } + @Test fun handleBackPressed() { every { tabTrayFragmentStore.state.mode } returns TabTrayDialogFragmentState.Mode.MultiSelect( diff --git a/app/src/test/java/org/mozilla/fenix/tabtray/SyncedTabsControllerTest.kt b/app/src/test/java/org/mozilla/fenix/tabtray/SyncedTabsControllerTest.kt new file mode 100644 index 000000000..c3d7b33de --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/tabtray/SyncedTabsControllerTest.kt @@ -0,0 +1,78 @@ +package org.mozilla.fenix.tabtray + +import android.view.LayoutInflater +import android.view.View +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runBlockingTest +import mozilla.components.browser.storage.sync.SyncedDeviceTabs +import mozilla.components.feature.syncedtabs.view.SyncedTabsView.ErrorType +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.fenix.R +import org.mozilla.fenix.helpers.FenixRobolectricTestRunner +import org.mozilla.fenix.sync.SyncedTabsViewHolder + +@ExperimentalCoroutinesApi +@RunWith(FenixRobolectricTestRunner::class) +class SyncedTabsControllerTest { + + private lateinit var view: View + private lateinit var controller: SyncedTabsController + + @Before + fun setup() = runBlockingTest { + view = LayoutInflater.from(testContext).inflate(R.layout.about_list_item, null) + controller = SyncedTabsController(view, coroutineContext) + } + + @Test + fun `display synced tabs in reverse`() { + val tabs = listOf( + SyncedDeviceTabs( + device = mockk(relaxed = true), + tabs = listOf( + mockk(relaxed = true), + mockk(relaxed = true) + ) + ) + ) + + controller.displaySyncedTabs(tabs) + + val itemCount = controller.adapter.itemCount + + // title + device name + 2 tabs + assertEquals(4, itemCount) + assertEquals( + SyncedTabsViewHolder.TitleViewHolder.LAYOUT_ID, + controller.adapter.getItemViewType(itemCount - 1) + ) + assertEquals( + SyncedTabsViewHolder.DeviceViewHolder.LAYOUT_ID, + controller.adapter.getItemViewType(itemCount - 2) + ) + assertEquals( + SyncedTabsViewHolder.TabViewHolder.LAYOUT_ID, + controller.adapter.getItemViewType(itemCount - 3) + ) + assertEquals( + SyncedTabsViewHolder.TabViewHolder.LAYOUT_ID, + controller.adapter.getItemViewType(itemCount - 4) + ) + } + + @Test + fun `show error when we go kaput`() { + controller.onError(ErrorType.SYNC_NEEDS_REAUTHENTICATION) + + assertEquals(1, controller.adapter.itemCount) + assertEquals( + SyncedTabsViewHolder.ErrorViewHolder.LAYOUT_ID, + controller.adapter.getItemViewType(0) + ) + } +} diff --git a/app/src/test/java/org/mozilla/fenix/tabtray/TabTrayFragmentInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/tabtray/TabTrayFragmentInteractorTest.kt index 8a459f73f..575b3a4ec 100644 --- a/app/src/test/java/org/mozilla/fenix/tabtray/TabTrayFragmentInteractorTest.kt +++ b/app/src/test/java/org/mozilla/fenix/tabtray/TabTrayFragmentInteractorTest.kt @@ -53,6 +53,12 @@ class TabTrayFragmentInteractorTest { verify { controller.onCloseAllTabsClicked(true) } } + @Test + fun onSyncedTabClicked() { + interactor.onSyncedTabClicked(mockk(relaxed = true)) + verify { controller.onSyncedTabClicked(any()) } + } + @Test fun onBackPressed() { interactor.onBackPressed()