1
0
Fork 0

For #5574 - Migrate SessionControl to LibState (#6651)

* For #5574 - Part 1: Port TabAction.SaveTabGroup to TabSessionInteractor and SessionControlController. (#6651)

- Introduces the TabSessionInteractor, SessionControlInteractor and SessionControlController classes.
- Removes the TabAction.SaveTabGroup.

* For #5574 - Part 2: Port TabAction.PrivateBrowsingLearnMore to TabSessionInteractor and SessionControlController (#6651)

* For #5574 - Part 3: Port TabAction.ShareTabs to TabSessionInteractor and SessionControlController (#6651)

* For #5574 - Part 4: Remove unused TabAction.Share and TabItemMenu (#6651)

In #2205, the tab overflow button was removed which would have shown the
TabItemMenu when clicked. So, we can remove TabItemMenu since it is not
used and as a result, we can also remove TabAction.Share since there are
no consumers.

* For #5574 - Part 5: Port TabAction.PlayMedia and TabAction.PauseMedia to TabSessionInteractor and SessionControlController (#6651)

* For #5574 - Part 6: Port TabAction.Select to TabSessionInteractor and SessionControlController (#6651)

* For #5574 - Part 7: Port Onboarding.Finish to OnboardingInteractor and SessionControlController (#6651)

* For #5574 - Part 8: Port TabAction.Close and TabAction.CloseAll to TabSessionInteractor and SessionControlController (#6651)

- Removes TabAction

* For #5574 - Part 9: Port CollectionAction.Delete to CollectionInteractor and SessionControlController (#6651)

* For #5574 - Part 10: Port CollectionAction.ShareTabs to CollectionInteractor and SessionControlController (#6651)

* For #5574 - Part 11: Port CollectionAction.AddTab and CollectionAction.Rename to CollectionInteractor and SessionControlController (#6651)

* For #5574 - Part 12: Port CollectionAction.RemoveTab to CollectionInteractor and SessionControlController (#6651)

* For #5574 - Part 13: Port CollectionAction.OpenTab to CollectionInteractor and SessionControlController (#6651)

* For #5574 - Part 14: Port CollectionAction.CloseTabs to CollectionInteractor and SessionControlController (#6651)

* For #5574 - Part 15: Introduce a HomeFragmentStore (#6651)

- We will hook up the HomeFragmentStore in later parts.
- Removes List<Tab>.toSessionBundle(context: Context) since it is unused.

* For #5574 - Part 16: Port CollectionAction.Collapse and CollectionAction.Expand to CollectionInteractor and SessionControlController (#6651)

- We assume the store is hooked up to the SessionControlController in this part,
but this work will be done in a later part.
- Removes CollectionAction.

* For #5574 - Part 20: Remove the architecture module. (#6651)

* For #5574 - Part 17:  Remove duplicate subscribeToTabCollections in BrowserFragment.kt (#6651)

There is a duplicate call of subscribeToTabCollections() in both HomeFragment and BrowserFragment.
In this patch, we remove the call in BrowserFragment to avoid passing the HomeFragmentStore to
BrowserFragment in order to dispatch the CollectionsChange event.

* For #5574 - Part 18: Delete SessionControlComponent and fix TabCollection and Tab imports (#6651)

* For #5574 - Part 19: Use the new HomeFragmentStore in the HomeFragment (#6651)

- Renames SessionControlUIView to SessionControlView

* For #5574 - Part 21: Fix white screen on home fragment (#6651)

* For #5574 - Part 22: Fix formatting in SessionControlInteractor and replace See with @see in SessionControlController (#6651)

* For #5574 - Part 23: Move to metrics.track call to the beginning of handleCollectionRemoveTab (#6651)

This ensures that the metrics.track will be called immediately before the tab is removed from the collection.

* For #5574 - Part 24: Use the sessionManager getter in SessionControlController (#6651)

* For #5574 - Part 25: Use mapNotNull in List<Tab>.toSessionBundle (#6651)

* For #5574 - Part 26: Simplify closeTab and closeAllTabs functions by assigning a deletionJob constant (#6651)

* For #5574 - Part 27: Replace listOf() with emptyList() in removeAllTabsWithUndo (#6651)

* For #5574 - Part 28: Replace the Context parameter with the HomeActivity in SessionControlController (#6651)

* For #5574 - Part 29: Add test for HomeFragmentStore, DefaultSessionControlController and SessionControlInteractor (#6651)

* For #5574 - Removes running CI against the architecture debug build varient
master
Gabriel 2019-12-04 22:06:05 -05:00 committed by GitHub
parent a07a77c01a
commit 56b21426eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 1384 additions and 1142 deletions

View File

@ -363,8 +363,6 @@ androidExtensions {
}
dependencies {
implementation project(':architecture')
geckoNightlyImplementation Deps.mozilla_browser_engine_gecko_nightly
geckoBetaImplementation Deps.mozilla_browser_engine_gecko_beta
@ -437,6 +435,7 @@ dependencies {
implementation Deps.mozilla_service_glean
implementation Deps.mozilla_service_experiments
implementation Deps.mozilla_support_base
implementation Deps.mozilla_support_ktx
implementation Deps.mozilla_support_rustlog
implementation Deps.mozilla_support_utils

View File

@ -1,34 +0,0 @@
/* 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
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.mozilla.fenix.mvi.Change
import org.mozilla.fenix.mvi.UIComponentViewModelBase
import org.mozilla.fenix.mvi.UIComponentViewModelProvider
import org.mozilla.fenix.mvi.ViewState
object FenixViewModelProvider {
fun <S : ViewState, C : Change, T : UIComponentViewModelBase<S, C>> create(
fragment: Fragment,
modelClass: Class<T>,
viewModelCreator: () -> T
): UIComponentViewModelProvider<S, C> {
val factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return viewModelCreator() as T
}
}
return object : UIComponentViewModelProvider<S, C> {
override fun fetchViewModel(): T {
return ViewModelProvider(fragment, factory).get(modelClass)
}
}
}
}

View File

@ -14,7 +14,6 @@ import android.widget.Button
import android.widget.RadioButton
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat
import androidx.lifecycle.Observer
import androidx.transition.TransitionInflater
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.fragment_browser.*
@ -25,6 +24,7 @@ import mozilla.components.feature.contextmenu.ContextMenuCandidate
import mozilla.components.feature.readerview.ReaderViewFeature
import mozilla.components.feature.session.TrackingProtectionUseCases
import mozilla.components.feature.sitepermissions.SitePermissions
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.tabs.WindowFeature
import mozilla.components.lib.state.ext.consumeFrom
import mozilla.components.support.base.feature.UserInteractionHandler
@ -39,9 +39,6 @@ import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.home.sessioncontrol.SessionControlChange
import org.mozilla.fenix.home.sessioncontrol.TabCollection
import org.mozilla.fenix.mvi.getManagedEmitter
import org.mozilla.fenix.trackingprotection.TrackingProtectionOverlay
/**
@ -119,7 +116,6 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler {
override fun onStart() {
super.onStart()
subscribeToTabCollections()
val toolbarSessionObserver = TrackingProtectionOverlay(
context = requireContext(),
settings = requireContext().settings()
@ -232,17 +228,6 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler {
}
}
private fun subscribeToTabCollections() {
requireComponents.core.tabCollectionStorage.getCollections().observe(this, Observer {
requireComponents.core.tabCollectionStorage.cachedTabCollections = it
getManagedEmitter<SessionControlChange>().onNext(
SessionControlChange.CollectionsChange(
it
)
)
})
}
private val collectionStorageObserver = object : TabCollectionStorage.Observer {
override fun onCollectionCreated(title: String, sessions: List<Session>) {
showTabSavedToCollectionSnackbar()

View File

@ -17,8 +17,8 @@ import org.mozilla.fenix.R
import org.mozilla.fenix.components.Analytics
import org.mozilla.fenix.components.TabCollectionStorage
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.home.sessioncontrol.Tab
import org.mozilla.fenix.home.sessioncontrol.toSessionBundle
import org.mozilla.fenix.home.Tab
import org.mozilla.fenix.home.toSessionBundle
interface CollectionCreationController {

View File

@ -22,7 +22,7 @@ import org.mozilla.fenix.R
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.toTab
import org.mozilla.fenix.home.sessioncontrol.Tab
import org.mozilla.fenix.home.Tab
@ExperimentalCoroutinesApi
class CollectionCreationFragment : DialogFragment() {

View File

@ -6,8 +6,8 @@
package org.mozilla.fenix.collections
import org.mozilla.fenix.home.sessioncontrol.Tab
import org.mozilla.fenix.home.sessioncontrol.TabCollection
import mozilla.components.feature.tab.collections.TabCollection
import org.mozilla.fenix.home.Tab
interface CollectionCreationInteractor {

View File

@ -9,7 +9,7 @@ import mozilla.components.lib.state.Action
import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
import org.mozilla.fenix.collections.CollectionCreationAction.StepChanged
import org.mozilla.fenix.home.sessioncontrol.Tab
import org.mozilla.fenix.home.Tab
class CollectionCreationStore(
initialState: CollectionCreationState

View File

@ -15,7 +15,7 @@ import kotlinx.android.synthetic.main.collection_tab_list_row.view.*
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.loadIntoView
import org.mozilla.fenix.home.sessioncontrol.Tab
import org.mozilla.fenix.home.Tab
class CollectionCreationTabListAdapter(
private val interactor: CollectionCreationInteractor

View File

@ -21,6 +21,7 @@ import androidx.transition.TransitionManager
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.component_collection_creation.*
import kotlinx.android.synthetic.main.component_collection_creation.view.*
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.support.ktx.android.view.hideKeyboard
import mozilla.components.support.ktx.android.view.showKeyboard
import org.mozilla.fenix.R
@ -28,8 +29,7 @@ import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.increaseTapArea
import org.mozilla.fenix.ext.toShortUrl
import org.mozilla.fenix.home.sessioncontrol.Tab
import org.mozilla.fenix.home.sessioncontrol.TabCollection
import org.mozilla.fenix.home.Tab
@SuppressWarnings("LargeClass")
class CollectionCreationView(

View File

@ -10,11 +10,11 @@ import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.collections_list_item.view.*
import mozilla.components.feature.tab.collections.TabCollection
import org.mozilla.fenix.R
import org.mozilla.fenix.components.description
import org.mozilla.fenix.ext.getIconColor
import org.mozilla.fenix.home.sessioncontrol.Tab
import org.mozilla.fenix.home.sessioncontrol.TabCollection
import org.mozilla.fenix.home.Tab
class SaveCollectionListAdapter(
private val interactor: CollectionCreationInteractor

View File

@ -8,7 +8,7 @@ import android.content.Context
import mozilla.components.browser.session.Session
import mozilla.components.feature.media.state.MediaState
import mozilla.components.lib.publicsuffixlist.PublicSuffixList
import org.mozilla.fenix.home.sessioncontrol.Tab
import org.mozilla.fenix.home.Tab
fun Session.toTab(context: Context, selected: Boolean? = null, mediaState: MediaState? = null): Tab =
this.toTab(context.components.publicSuffixList, selected, mediaState)

View File

@ -7,8 +7,8 @@ package org.mozilla.fenix.ext
import android.content.Context
import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat
import mozilla.components.feature.tab.collections.TabCollection
import org.mozilla.fenix.R
import org.mozilla.fenix.home.sessioncontrol.TabCollection
import kotlin.math.abs
/**

View File

@ -36,22 +36,20 @@ import androidx.transition.TransitionInflater
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.fragment_home.*
import kotlinx.android.synthetic.main.fragment_home.view.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.delay
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import mozilla.appservices.places.BookmarkRoot
import mozilla.components.browser.menu.BrowserMenu
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager
import mozilla.components.concept.engine.prompt.ShareData
import mozilla.components.concept.sync.AccountObserver
import mozilla.components.concept.sync.AuthType
import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.feature.media.ext.getSession
import mozilla.components.feature.media.ext.pauseIfPlaying
import mozilla.components.feature.media.ext.playIfPaused
import mozilla.components.feature.media.state.MediaState
import mozilla.components.feature.media.state.MediaStateMachine
import mozilla.components.feature.tab.collections.TabCollection
@ -61,13 +59,12 @@ import org.jetbrains.anko.constraint.layout.ConstraintSetBuilder.Side.START
import org.jetbrains.anko.constraint.layout.ConstraintSetBuilder.Side.TOP
import org.jetbrains.anko.constraint.layout.applyConstraintSet
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.FenixViewModelProvider
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.collections.SaveCollectionStep
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.PrivateShortcutCreateManager
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.components.TabCollectionStorage
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
@ -78,20 +75,10 @@ import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.sessionsOfType
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.toTab
import org.mozilla.fenix.home.sessioncontrol.CollectionAction
import org.mozilla.fenix.home.sessioncontrol.OnboardingAction
import org.mozilla.fenix.home.sessioncontrol.SessionControlAction
import org.mozilla.fenix.home.sessioncontrol.SessionControlChange
import org.mozilla.fenix.home.sessioncontrol.SessionControlComponent
import org.mozilla.fenix.home.sessioncontrol.SessionControlState
import org.mozilla.fenix.home.sessioncontrol.SessionControlViewModel
import org.mozilla.fenix.home.sessioncontrol.Tab
import org.mozilla.fenix.home.sessioncontrol.TabAction
import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController
import org.mozilla.fenix.home.sessioncontrol.SessionControlInteractor
import org.mozilla.fenix.home.sessioncontrol.SessionControlView
import org.mozilla.fenix.home.sessioncontrol.viewholders.CollectionViewHolder
import org.mozilla.fenix.lib.Do
import org.mozilla.fenix.mvi.ActionBusFactory
import org.mozilla.fenix.mvi.getAutoDisposeObservable
import org.mozilla.fenix.mvi.getManagedEmitter
import org.mozilla.fenix.onboarding.FenixOnboarding
import org.mozilla.fenix.settings.SupportUtils
import org.mozilla.fenix.settings.deletebrowsingdata.deleteAndQuit
@ -100,11 +87,9 @@ import org.mozilla.fenix.utils.allowUndo
import org.mozilla.fenix.whatsnew.WhatsNew
import kotlin.math.min
@ExperimentalCoroutinesApi
@SuppressWarnings("TooManyFunctions", "LargeClass")
class HomeFragment : Fragment() {
private val bus = ActionBusFactory.get(this)
private val browsingModeManager get() = (activity as HomeActivity).browsingModeManager
private val singleSessionObserver = object : Session.Observer {
@ -142,7 +127,9 @@ class HomeFragment : Fragment() {
data class PendingSessionDeletion(val deletionJob: (suspend () -> Unit), val sessionId: String)
private val onboarding by lazy { FenixOnboarding(requireContext()) }
private lateinit var sessionControlComponent: SessionControlComponent
private lateinit var homeFragmentStore: HomeFragmentStore
private lateinit var sessionControlInteractor: SessionControlInteractor
private lateinit var sessionControlView: SessionControlView
private lateinit var currentMode: CurrentMode
override fun onCreate(savedInstanceState: Bundle?) {
@ -169,34 +156,49 @@ class HomeFragment : Fragment() {
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_home, container, false)
val activity = activity as HomeActivity
currentMode = CurrentMode(
view.context,
onboarding,
browsingModeManager,
getManagedEmitter()
::dispatchModeChanges
)
sessionControlComponent = SessionControlComponent(
view.homeLayout,
bus,
FenixViewModelProvider.create(
this,
SessionControlViewModel::class.java
) {
SessionControlViewModel(
SessionControlState(
emptyList(),
emptySet(),
requireComponents.core.tabCollectionStorage.cachedTabCollections,
currentMode.getCurrentMode()
)
homeFragmentStore = StoreProvider.get(this) {
HomeFragmentStore(
HomeFragmentState(
collections = requireComponents.core.tabCollectionStorage.cachedTabCollections,
expandedCollections = emptySet(),
mode = currentMode.getCurrentMode(),
tabs = emptyList()
)
}
)
}
sessionControlInteractor = SessionControlInteractor(
DefaultSessionControlController(
activity = activity,
store = homeFragmentStore,
navController = findNavController(),
homeLayout = view.homeLayout,
browsingModeManager = browsingModeManager,
lifecycleScope = viewLifecycleOwner.lifecycleScope,
closeTab = ::closeTab,
closeAllTabs = ::closeAllTabs,
getListOfTabs = ::getListOfTabs,
hideOnboarding = ::hideOnboarding,
invokePendingDeleteJobs = ::invokePendingDeleteJobs,
registerCollectionStorageObserver = ::registerCollectionStorageObserver,
scrollToTheTop = ::scrollToTheTop,
showDeleteCollectionPrompt = ::showDeleteCollectionPrompt
)
)
sessionControlView = SessionControlView(homeFragmentStore, view.homeLayout, sessionControlInteractor)
view.homeLayout.applyConstraintSet {
sessionControlComponent.view {
sessionControlView.view {
connect(
TOP to BOTTOM of view.wordmark_spacer,
START to START of PARENT_ID,
@ -206,13 +208,12 @@ class HomeFragment : Fragment() {
}
}
ActionBusFactory.get(this).logMergedObservables()
val activity = activity as HomeActivity
activity.themeManager.applyStatusBarTheme(activity)
return view
}
@ExperimentalCoroutinesApi
@SuppressWarnings("LongMethod")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@ -222,7 +223,7 @@ class HomeFragment : Fragment() {
ViewModelProvider.NewInstanceFactory() // this is a workaround for #4652
}
homeViewModel.layoutManagerState?.also { parcelable ->
sessionControlComponent.view.layoutManager?.onRestoreInstanceState(parcelable)
sessionControlView.view.layoutManager?.onRestoreInstanceState(parcelable)
}
homeLayout?.progress = homeViewModel.motionLayoutProgress
homeViewModel.layoutManagerState = null
@ -292,9 +293,8 @@ class HomeFragment : Fragment() {
}
if (onboarding.userHasBeenOnboarded()) {
getManagedEmitter<SessionControlChange>().onNext(
SessionControlChange.ModeChange(Mode.fromBrowsingMode(newMode))
)
homeFragmentStore.dispatch(
HomeFragmentAction.ModeChange(Mode.fromBrowsingMode(newMode)))
}
}
@ -311,25 +311,14 @@ class HomeFragment : Fragment() {
super.onStart()
subscribeToTabCollections()
getAutoDisposeObservable<SessionControlAction>()
.subscribe {
when (it) {
is SessionControlAction.Tab -> handleTabAction(it.action)
is SessionControlAction.Collection -> handleCollectionAction(it.action)
is SessionControlAction.Onboarding -> handleOnboardingAction(it.action)
}
}
val context = requireContext()
val components = context.components
getManagedEmitter<SessionControlChange>().onNext(
SessionControlChange.Change(
tabs = getListOfSessions().toTabs(),
mode = currentMode.getCurrentMode(),
collections = components.core.tabCollectionStorage.cachedTabCollections
)
)
homeFragmentStore.dispatch(HomeFragmentAction.Change(
collections = components.core.tabCollectionStorage.cachedTabCollections,
mode = currentMode.getCurrentMode(),
tabs = getListOfSessions().toTabs()
))
hideToolbar()
@ -357,103 +346,46 @@ class HomeFragment : Fragment() {
requireComponents.core.tabCollectionStorage.unregister(collectionStorageObserver)
}
private fun handleOnboardingAction(action: OnboardingAction) {
Do exhaustive when (action) {
is OnboardingAction.Finish -> {
homeLayout?.progress = 0F
hideOnboarding()
private fun closeTab(sessionId: String) {
val deletionJob = pendingSessionDeletion?.deletionJob
if (deletionJob == null) {
removeTabWithUndo(sessionId, browsingModeManager.mode.isPrivate)
} else {
viewLifecycleOwner.lifecycleScope.launch {
deletionJob.invoke()
}.invokeOnCompletion {
pendingSessionDeletion = null
removeTabWithUndo(sessionId, browsingModeManager.mode.isPrivate)
}
}
}
@SuppressWarnings("ComplexMethod", "LongMethod")
private fun handleTabAction(action: TabAction) {
Do exhaustive when (action) {
is TabAction.SaveTabGroup -> {
if (browsingModeManager.mode.isPrivate) return
invokePendingDeleteJobs()
saveTabToCollection(action.selectedTabSessionId)
}
is TabAction.Select -> {
invokePendingDeleteJobs()
val session = sessionManager.findSessionById(action.sessionId)
sessionManager.select(session!!)
val directions = HomeFragmentDirections.actionHomeFragmentToBrowserFragment(null)
val extras =
FragmentNavigator.Extras.Builder()
.addSharedElement(
action.tabView,
"$TAB_ITEM_TRANSITION_NAME${action.sessionId}"
)
.build()
nav(R.id.homeFragment, directions, extras)
}
is TabAction.Close -> {
if (pendingSessionDeletion?.deletionJob == null) {
removeTabWithUndo(action.sessionId, browsingModeManager.mode.isPrivate)
} else {
pendingSessionDeletion?.deletionJob?.let {
viewLifecycleOwner.lifecycleScope.launch {
it.invoke()
}.invokeOnCompletion {
pendingSessionDeletion = null
removeTabWithUndo(action.sessionId, browsingModeManager.mode.isPrivate)
}
}
}
}
is TabAction.Share -> {
invokePendingDeleteJobs()
sessionManager.findSessionById(action.sessionId)?.let { session ->
share(listOf(ShareData(url = session.url)))
}
}
is TabAction.PauseMedia -> {
MediaStateMachine.state.pauseIfPlaying()
}
is TabAction.PlayMedia -> {
MediaStateMachine.state.playIfPaused()
}
is TabAction.CloseAll -> {
if (pendingSessionDeletion?.deletionJob == null) {
removeAllTabsWithUndo(
sessionManager.sessionsOfType(private = action.private),
action.private
)
} else {
pendingSessionDeletion?.deletionJob?.let {
viewLifecycleOwner.lifecycleScope.launch {
it.invoke()
}.invokeOnCompletion {
pendingSessionDeletion = null
removeAllTabsWithUndo(
sessionManager.sessionsOfType(private = action.private),
action.private
)
}
}
}
}
is TabAction.PrivateBrowsingLearnMore -> {
(activity as HomeActivity).openToBrowserAndLoad(
searchTermOrURL = SupportUtils.getGenericSumoURLForTopic
(SupportUtils.SumoTopic.PRIVATE_BROWSING_MYTHS),
newTab = true,
from = BrowserDirection.FromHome
private fun closeAllTabs(isPrivateMode: Boolean) {
val deletionJob = pendingSessionDeletion?.deletionJob
if (deletionJob == null) {
removeAllTabsWithUndo(
sessionManager.sessionsOfType(private = isPrivateMode),
isPrivateMode
)
} else {
viewLifecycleOwner.lifecycleScope.launch {
deletionJob.invoke()
}.invokeOnCompletion {
pendingSessionDeletion = null
removeAllTabsWithUndo(
sessionManager.sessionsOfType(private = isPrivateMode),
isPrivateMode
)
}
is TabAction.ShareTabs -> {
invokePendingDeleteJobs()
val shareData = sessionManager
.sessionsOfType(private = browsingModeManager.mode.isPrivate)
.map { ShareData(url = it.url, title = it.title) }
.toList()
share(shareData)
}
}
}
private fun dispatchModeChanges(mode: Mode) {
homeFragmentStore.dispatch(HomeFragmentAction.ModeChange(mode))
}
private fun invokePendingDeleteJobs() {
pendingSessionDeletion?.deletionJob?.let {
viewLifecycleOwner.lifecycleScope.launch {
@ -472,7 +404,7 @@ class HomeFragment : Fragment() {
}
}
private fun createDeleteCollectionPrompt(tabCollection: TabCollection) {
private fun showDeleteCollectionPrompt(tabCollection: TabCollection) {
val context = context ?: return
AlertDialog.Builder(context).apply {
val message =
@ -493,107 +425,6 @@ class HomeFragment : Fragment() {
}.show()
}
@SuppressWarnings("LongMethod")
private fun handleCollectionAction(action: CollectionAction) {
when (action) {
is CollectionAction.Expand -> {
getManagedEmitter<SessionControlChange>()
.onNext(SessionControlChange.ExpansionChange(action.collection, true))
}
is CollectionAction.Collapse -> {
getManagedEmitter<SessionControlChange>()
.onNext(SessionControlChange.ExpansionChange(action.collection, false))
}
is CollectionAction.Delete -> {
createDeleteCollectionPrompt(action.collection)
}
is CollectionAction.AddTab -> {
requireComponents.analytics.metrics.track(Event.CollectionAddTabPressed)
showCollectionCreationFragment(
step = SaveCollectionStep.SelectTabs,
selectedTabCollectionId = action.collection.id
)
}
is CollectionAction.Rename -> {
showCollectionCreationFragment(
step = SaveCollectionStep.RenameCollection,
selectedTabCollectionId = action.collection.id
)
requireComponents.analytics.metrics.track(Event.CollectionRenamePressed)
}
is CollectionAction.OpenTab -> {
invokePendingDeleteJobs()
val context = requireContext()
val components = context.components
val session = action.tab.restore(
context = context,
engine = components.core.engine,
tab = action.tab,
restoreSessionId = false
)
if (session == null) {
// We were unable to create a snapshot, so just load the tab instead
(activity as HomeActivity).openToBrowserAndLoad(
searchTermOrURL = action.tab.url,
newTab = true,
from = BrowserDirection.FromHome
)
} else {
components.core.sessionManager.add(
session,
true
)
(activity as HomeActivity).openToBrowser(BrowserDirection.FromHome)
}
components.analytics.metrics.track(Event.CollectionTabRestored)
}
is CollectionAction.OpenTabs -> {
invokePendingDeleteJobs()
val context = requireContext()
val components = context.components
action.collection.tabs.reversed().forEach {
val session = it.restore(
context = context,
engine = components.core.engine,
tab = it,
restoreSessionId = false
)
if (session == null) {
// We were unable to create a snapshot, so just load the tab instead
components.useCases.tabsUseCases.addTab.invoke(it.url)
} else {
components.core.sessionManager.add(
session,
context.components.core.sessionManager.selectedSession == null
)
}
}
viewLifecycleOwner.lifecycleScope.launch(Main) {
delay(ANIM_SCROLL_DELAY)
sessionControlComponent.view.smoothScrollToPosition(0)
}
components.analytics.metrics.track(Event.CollectionAllTabsRestored)
}
is CollectionAction.ShareTabs -> {
share(action.collection.tabs.map { ShareData(url = it.url, title = it.title) })
requireComponents.analytics.metrics.track(Event.CollectionShared)
}
is CollectionAction.RemoveTab -> {
viewLifecycleOwner.lifecycleScope.launch(IO) {
requireComponents.core.tabCollectionStorage.removeTabFromCollection(
action.collection,
action.tab
)
}
requireComponents.analytics.metrics.track(Event.CollectionTabRemoved)
}
}
}
override fun onStop() {
invokePendingDeleteJobs()
super.onStop()
@ -601,7 +432,7 @@ class HomeFragment : Fragment() {
ViewModelProvider.NewInstanceFactory() // this is a workaround for #4652
}
homeViewModel.layoutManagerState =
sessionControlComponent.view.layoutManager?.onSaveInstanceState()
sessionControlView.view.layoutManager?.onSaveInstanceState()
homeViewModel.motionLayoutProgress = homeLayout?.progress ?: 0F
}
@ -725,20 +556,14 @@ class HomeFragment : Fragment() {
private fun subscribeToTabCollections(): Observer<List<TabCollection>> {
return Observer<List<TabCollection>> {
requireComponents.core.tabCollectionStorage.cachedTabCollections = it
getManagedEmitter<SessionControlChange>().onNext(
SessionControlChange.CollectionsChange(
it
)
)
homeFragmentStore.dispatch(HomeFragmentAction.CollectionsChange(it))
}.also { observer ->
requireComponents.core.tabCollectionStorage.getCollections().observe(this, observer)
}
}
private fun removeAllTabsWithUndo(listOfSessionsToDelete: Sequence<Session>, private: Boolean) {
val sessionManager = requireComponents.core.sessionManager
getManagedEmitter<SessionControlChange>().onNext(SessionControlChange.TabsChange(listOf()))
homeFragmentStore.dispatch(HomeFragmentAction.TabsChange(emptyList()))
val deleteOperation: (suspend () -> Unit) = {
listOfSessionsToDelete.forEach {
@ -802,11 +627,7 @@ class HomeFragment : Fragment() {
}
private fun emitSessionChanges() {
getManagedEmitter<SessionControlChange>().onNext(
SessionControlChange.TabsChange(
getListOfSessions().toTabs()
)
)
homeFragmentStore.dispatch(HomeFragmentAction.TabsChange(getListOfTabs()))
}
private fun getListOfSessions(): List<Session> {
@ -815,48 +636,19 @@ class HomeFragment : Fragment() {
.toList()
}
private fun showCollectionCreationFragment(
step: SaveCollectionStep,
selectedTabIds: Array<String>? = null,
selectedTabCollectionId: Long? = null
) {
if (findNavController().currentDestination?.id == R.id.collectionCreationFragment) return
val storage = requireComponents.core.tabCollectionStorage
// Only register the observer right before moving to collection creation
storage.register(collectionStorageObserver, this)
val tabIds = getListOfSessions().toTabs().map { it.sessionId }.toTypedArray()
view?.let {
val directions = HomeFragmentDirections.actionHomeFragmentToCreateCollectionFragment(
tabIds = tabIds,
previousFragmentId = R.id.homeFragment,
saveCollectionStep = step,
selectedTabIds = selectedTabIds,
selectedTabCollectionId = selectedTabCollectionId ?: -1
)
nav(R.id.homeFragment, directions)
}
private fun getListOfTabs(): List<Tab> {
return getListOfSessions().toTabs()
}
private fun saveTabToCollection(selectedTabId: String?) {
val tabs = getListOfSessions().toTabs()
val storage = requireComponents.core.tabCollectionStorage
val step = when {
tabs.size > 1 -> SaveCollectionStep.SelectTabs
storage.cachedTabCollections.isNotEmpty() -> SaveCollectionStep.SelectCollection
else -> SaveCollectionStep.NameCollection
}
showCollectionCreationFragment(step, selectedTabId?.let { arrayOf(it) })
private fun registerCollectionStorageObserver() {
requireComponents.core.tabCollectionStorage.register(collectionStorageObserver, this)
}
private fun share(data: List<ShareData>) {
val directions = HomeFragmentDirections.actionHomeFragmentToShareFragment(
data = data.toTypedArray()
)
nav(R.id.homeFragment, directions)
private fun scrollToTheTop() {
lifecycleScope.launch(Main) {
delay(ANIM_SCROLL_DELAY)
sessionControlView.view.smoothScrollToPosition(0)
}
}
private fun scrollAndAnimateCollection(
@ -865,7 +657,7 @@ class HomeFragment : Fragment() {
) {
if (view != null) {
viewLifecycleOwner.lifecycleScope.launch {
val recyclerView = sessionControlComponent.view
val recyclerView = sessionControlView.view
delay(ANIM_SCROLL_DELAY)
val tabsSize = getListOfSessions().size
@ -908,7 +700,7 @@ class HomeFragment : Fragment() {
private fun animateCollection(addedTabsSize: Int, indexOfCollection: Int) {
viewLifecycleOwner.lifecycleScope.launch {
val viewHolder =
sessionControlComponent.view.findViewHolderForAdapterPosition(indexOfCollection)
sessionControlView.view.findViewHolderForAdapterPosition(indexOfCollection)
val border =
(viewHolder as? CollectionViewHolder)?.view?.findViewById<View>(R.id.selected_border)
val listener = object : Animator.AnimatorListener {
@ -982,7 +774,6 @@ class HomeFragment : Fragment() {
private const val FADE_ANIM_DURATION = 150L
private const val ANIM_SNACKBAR_DELAY = 100L
private const val SHARED_TRANSITION_MS = 200L
private const val TAB_ITEM_TRANSITION_NAME = "tab_item"
private const val CFR_WIDTH_DIVIDER = 1.7
private const val CFR_Y_OFFSET = -20
}

View File

@ -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 org.mozilla.fenix.home
import android.graphics.Bitmap
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager
import mozilla.components.feature.media.state.MediaState
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.lib.state.Action
import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
/**
* The [Store] for holding the [HomeFragmentState] and applying [HomeFragmentAction]s.
*/
class HomeFragmentStore(
initialState: HomeFragmentState
) : Store<HomeFragmentState, HomeFragmentAction>(
initialState, ::homeFragmentStateReducer
)
data class Tab(
val sessionId: String,
val url: String,
val hostname: String,
val title: String,
val selected: Boolean? = null,
var mediaState: MediaState? = null,
val icon: Bitmap? = null
)
fun List<Tab>.toSessionBundle(sessionManager: SessionManager): List<Session> {
return this.mapNotNull { sessionManager.findSessionById(it.sessionId) }
}
/**
* The state for the [HomeFragment].
*
* @property collections The list of [TabCollection] to display in the [HomeFragment].
* @property expandedCollections A set containing the ids of the [TabCollection] that are expanded
* in the [HomeFragment].
* @property mode The state of the [HomeFragment] UI.
* @property tabs The list of opened [Tab] in the [HomeFragment].
*/
data class HomeFragmentState(
val collections: List<TabCollection>,
val expandedCollections: Set<Long>,
val mode: Mode,
val tabs: List<Tab>
) : State
sealed class HomeFragmentAction : Action {
data class Change(val tabs: List<Tab>, val mode: Mode, val collections: List<TabCollection>) :
HomeFragmentAction()
data class CollectionExpanded(val collection: TabCollection, val expand: Boolean) : HomeFragmentAction()
data class CollectionsChange(val collections: List<TabCollection>) : HomeFragmentAction()
data class ModeChange(val mode: Mode) : HomeFragmentAction()
data class TabsChange(val tabs: List<Tab>) : HomeFragmentAction()
}
private fun homeFragmentStateReducer(
state: HomeFragmentState,
action: HomeFragmentAction
): HomeFragmentState {
return when (action) {
is HomeFragmentAction.Change -> state.copy(
collections = action.collections,
mode = action.mode,
tabs = action.tabs
)
is HomeFragmentAction.CollectionExpanded -> {
val newExpandedCollection = state.expandedCollections.toMutableSet()
if (action.expand) {
newExpandedCollection.add(action.collection.id)
} else {
newExpandedCollection.remove(action.collection.id)
}
state.copy(expandedCollections = newExpandedCollection)
}
is HomeFragmentAction.CollectionsChange -> state.copy(collections = action.collections)
is HomeFragmentAction.ModeChange -> state.copy(mode = action.mode, tabs = emptyList())
is HomeFragmentAction.TabsChange -> state.copy(tabs = action.tabs)
}
}

View File

@ -5,7 +5,6 @@
package org.mozilla.fenix.home
import android.content.Context
import io.reactivex.Observer
import mozilla.components.concept.sync.AccountObserver
import mozilla.components.concept.sync.AuthType
import mozilla.components.concept.sync.OAuthAccount
@ -14,7 +13,6 @@ import mozilla.components.service.fxa.sharing.ShareableAccount
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.home.sessioncontrol.SessionControlChange
import org.mozilla.fenix.onboarding.FenixOnboarding
/**
@ -49,7 +47,7 @@ class CurrentMode(
private val context: Context,
private val onboarding: FenixOnboarding,
private val browsingModeManager: BrowsingModeManager,
private val emitter: Observer<SessionControlChange>
private val dispatchModeChanges: (mode: Mode) -> Unit
) : AccountObserver {
private val accountManager = context.components.backgroundServices.accountManager
@ -71,7 +69,7 @@ class CurrentMode(
}
fun emitModeChanges() {
emitter.onNext(SessionControlChange.ModeChange(getCurrentMode()))
dispatchModeChanges(getCurrentMode())
}
override fun onAuthenticated(account: OAuthAccount, authType: AuthType) = emitModeChanges()

View File

@ -14,10 +14,11 @@ import androidx.annotation.StringRes
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.synthetic.main.tab_list_row.*
import mozilla.components.feature.media.state.MediaState
import mozilla.components.feature.tab.collections.TabCollection
import org.mozilla.fenix.home.OnboardingState
import org.mozilla.fenix.home.Tab
import org.mozilla.fenix.home.sessioncontrol.viewholders.CollectionHeaderViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.CollectionViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.NoContentMessageViewHolder
@ -139,7 +140,7 @@ class AdapterItemDiffCallback : DiffUtil.ItemCallback<AdapterItem>() {
}
class SessionControlAdapter(
private val actionEmitter: Observer<SessionControlAction>
private val interactor: SessionControlInteractor
) : ListAdapter<AdapterItem, RecyclerView.ViewHolder>(AdapterItemDiffCallback()) {
// This method triggers the ComplexMethod lint error when in fact it's quite simple.
@ -147,14 +148,14 @@ class SessionControlAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
return when (viewType) {
TabHeaderViewHolder.LAYOUT_ID -> TabHeaderViewHolder(view, actionEmitter)
TabViewHolder.LAYOUT_ID -> TabViewHolder(view, actionEmitter)
SaveTabGroupViewHolder.LAYOUT_ID -> SaveTabGroupViewHolder(view, actionEmitter)
PrivateBrowsingDescriptionViewHolder.LAYOUT_ID -> PrivateBrowsingDescriptionViewHolder(view, actionEmitter)
TabHeaderViewHolder.LAYOUT_ID -> TabHeaderViewHolder(view, interactor)
TabViewHolder.LAYOUT_ID -> TabViewHolder(view, interactor)
SaveTabGroupViewHolder.LAYOUT_ID -> SaveTabGroupViewHolder(view, interactor)
PrivateBrowsingDescriptionViewHolder.LAYOUT_ID -> PrivateBrowsingDescriptionViewHolder(view, interactor)
NoContentMessageViewHolder.LAYOUT_ID -> NoContentMessageViewHolder(view)
CollectionHeaderViewHolder.LAYOUT_ID -> CollectionHeaderViewHolder(view)
CollectionViewHolder.LAYOUT_ID -> CollectionViewHolder(view, actionEmitter)
TabInCollectionViewHolder.LAYOUT_ID -> TabInCollectionViewHolder(view, actionEmitter)
CollectionViewHolder.LAYOUT_ID -> CollectionViewHolder(view, interactor)
TabInCollectionViewHolder.LAYOUT_ID -> TabInCollectionViewHolder(view, interactor)
OnboardingHeaderViewHolder.LAYOUT_ID -> OnboardingHeaderViewHolder(view)
OnboardingSectionHeaderViewHolder.LAYOUT_ID -> OnboardingSectionHeaderViewHolder(view)
OnboardingAutomaticSignInViewHolder.LAYOUT_ID -> OnboardingAutomaticSignInViewHolder(view)
@ -163,7 +164,7 @@ class SessionControlAdapter(
OnboardingTrackingProtectionViewHolder.LAYOUT_ID -> OnboardingTrackingProtectionViewHolder(view)
OnboardingPrivateBrowsingViewHolder.LAYOUT_ID -> OnboardingPrivateBrowsingViewHolder(view)
OnboardingPrivacyNoticeViewHolder.LAYOUT_ID -> OnboardingPrivacyNoticeViewHolder(view)
OnboardingFinishViewHolder.LAYOUT_ID -> OnboardingFinishViewHolder(view, actionEmitter)
OnboardingFinishViewHolder.LAYOUT_ID -> OnboardingFinishViewHolder(view, interactor)
else -> throw IllegalStateException()
}
}

View File

@ -1,164 +0,0 @@
/* 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.home.sessioncontrol
import android.content.Context
import android.graphics.Bitmap
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager
import mozilla.components.feature.media.state.MediaState
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.home.Mode
import org.mozilla.fenix.mvi.Action
import org.mozilla.fenix.mvi.ActionBusFactory
import org.mozilla.fenix.mvi.Change
import org.mozilla.fenix.mvi.UIComponent
import org.mozilla.fenix.mvi.UIComponentViewModelBase
import org.mozilla.fenix.mvi.UIComponentViewModelProvider
import org.mozilla.fenix.mvi.ViewState
import mozilla.components.feature.tab.collections.Tab as ComponentTab
import mozilla.components.feature.tab.collections.TabCollection as ACTabCollection
class SessionControlComponent(
private val container: ViewGroup,
bus: ActionBusFactory,
viewModelProvider: UIComponentViewModelProvider<SessionControlState, SessionControlChange>
) :
UIComponent<SessionControlState, SessionControlAction, SessionControlChange>(
bus.getManagedEmitter(SessionControlAction::class.java),
bus.getSafeManagedObservable(SessionControlChange::class.java),
viewModelProvider
) {
override fun initView() = SessionControlUIView(container, actionEmitter, changesObservable)
val view: RecyclerView
get() = uiView.view as RecyclerView
init {
bind()
}
}
data class Tab(
val sessionId: String,
val url: String,
val hostname: String,
val title: String,
val selected: Boolean? = null,
var mediaState: MediaState? = null,
val icon: Bitmap? = null
)
fun List<Tab>.toSessionBundle(context: Context): MutableList<Session> =
this.toSessionBundle(context.components.core.sessionManager)
fun List<Tab>.toSessionBundle(sessionManager: SessionManager): MutableList<Session> {
val sessionBundle = mutableListOf<Session>()
this.forEach {
sessionManager.findSessionById(it.sessionId)?.let { session ->
sessionBundle.add(session)
}
}
return sessionBundle
}
data class SessionControlState(
val tabs: List<Tab>,
val expandedCollections: Set<Long>,
val collections: List<TabCollection>,
val mode: Mode
) : ViewState
typealias TabCollection = ACTabCollection
sealed class TabAction : Action {
data class SaveTabGroup(val selectedTabSessionId: String?) : TabAction()
object ShareTabs : TabAction()
data class CloseAll(val private: Boolean) : TabAction()
data class Select(val tabView: View, val sessionId: String) : TabAction()
data class Close(val sessionId: String) : TabAction()
data class Share(val sessionId: String) : TabAction()
data class PauseMedia(val sessionId: String) : TabAction()
data class PlayMedia(val sessionId: String) : TabAction()
object PrivateBrowsingLearnMore : TabAction()
}
sealed class CollectionAction : Action {
data class Expand(val collection: TabCollection) : CollectionAction()
data class Collapse(val collection: TabCollection) : CollectionAction()
data class Delete(val collection: TabCollection) : CollectionAction()
data class AddTab(val collection: TabCollection) : CollectionAction()
data class Rename(val collection: TabCollection) : CollectionAction()
data class OpenTab(val tab: ComponentTab) : CollectionAction()
data class OpenTabs(val collection: TabCollection) : CollectionAction()
data class ShareTabs(val collection: TabCollection) : CollectionAction()
data class RemoveTab(val collection: TabCollection, val tab: ComponentTab) : CollectionAction()
}
sealed class OnboardingAction : Action {
object Finish : OnboardingAction()
}
sealed class SessionControlAction : Action {
data class Tab(val action: TabAction) : SessionControlAction()
data class Collection(val action: CollectionAction) : SessionControlAction()
data class Onboarding(val action: OnboardingAction) : SessionControlAction()
}
fun Observer<SessionControlAction>.onNext(tabAction: TabAction) {
onNext(SessionControlAction.Tab(tabAction))
}
fun Observer<SessionControlAction>.onNext(collectionAction: CollectionAction) {
onNext(SessionControlAction.Collection(collectionAction))
}
fun Observer<SessionControlAction>.onNext(onboardingAction: OnboardingAction) {
onNext(SessionControlAction.Onboarding(onboardingAction))
}
sealed class SessionControlChange : Change {
data class Change(val tabs: List<Tab>, val mode: Mode, val collections: List<TabCollection>) :
SessionControlChange()
data class TabsChange(val tabs: List<Tab>) : SessionControlChange()
data class ModeChange(val mode: Mode) : SessionControlChange()
data class CollectionsChange(val collections: List<TabCollection>) : SessionControlChange()
data class ExpansionChange(val collection: TabCollection, val expand: Boolean) : SessionControlChange()
}
class SessionControlViewModel(
initialState: SessionControlState
) : UIComponentViewModelBase<SessionControlState, SessionControlChange>(initialState, reducer) {
companion object {
val reducer: (SessionControlState, SessionControlChange) -> SessionControlState = { state, change ->
when (change) {
is SessionControlChange.CollectionsChange -> state.copy(collections = change.collections)
is SessionControlChange.TabsChange -> state.copy(tabs = change.tabs)
is SessionControlChange.ModeChange -> state.copy(mode = change.mode, tabs = emptyList())
is SessionControlChange.ExpansionChange -> {
val newExpandedCollection = state.expandedCollections.toMutableSet()
if (change.expand) {
newExpandedCollection.add(change.collection.id)
} else {
newExpandedCollection.remove(change.collection.id)
}
state.copy(expandedCollections = newExpandedCollection)
}
is SessionControlChange.Change -> state.copy(
tabs = change.tabs,
mode = change.mode,
collections = change.collections
)
}
}
}
}

View File

@ -0,0 +1,350 @@
/* 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.home.sessioncontrol
import android.view.View
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.navigation.NavController
import androidx.navigation.fragment.FragmentNavigator
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import mozilla.components.browser.session.SessionManager
import mozilla.components.concept.engine.prompt.ShareData
import mozilla.components.feature.media.ext.pauseIfPlaying
import mozilla.components.feature.media.ext.playIfPaused
import mozilla.components.feature.media.state.MediaStateMachine
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.tab.collections.Tab as ComponentTab
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
import org.mozilla.fenix.collections.SaveCollectionStep
import org.mozilla.fenix.components.TabCollectionStorage
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.ext.sessionsOfType
import org.mozilla.fenix.home.HomeFragment
import org.mozilla.fenix.home.HomeFragmentAction
import org.mozilla.fenix.home.HomeFragmentDirections
import org.mozilla.fenix.home.HomeFragmentStore
import org.mozilla.fenix.home.Tab
import org.mozilla.fenix.settings.SupportUtils
/**
* [HomeFragment] controller. An interface that handles the view manipulation of the Tabs triggered
* by the Interactor.
*/
@SuppressWarnings("TooManyFunctions")
interface SessionControlController {
/**
* @see [TabSessionInteractor.onCloseTab]
*/
fun handleCloseTab(sessionId: String)
/**
* @see [TabSessionInteractor.onCloseAllTabs]
*/
fun handleCloseAllTabs(isPrivateMode: Boolean)
/**
* @see [CollectionInteractor.onCollectionAddTabTapped]
*/
fun handleCollectionAddTabTapped(collection: TabCollection)
/**
* @see [CollectionInteractor.onCollectionOpenTabClicked]
*/
fun handleCollectionOpenTabClicked(tab: ComponentTab)
/**
* @see [CollectionInteractor.onCollectionOpenTabsTapped]
*/
fun handleCollectionOpenTabsTapped(collection: TabCollection)
/**
* @see [CollectionInteractor.onCollectionRemoveTab]
*/
fun handleCollectionRemoveTab(collection: TabCollection, tab: ComponentTab)
/**
* @see [CollectionInteractor.onCollectionShareTabsClicked]
*/
fun handleCollectionShareTabsClicked(collection: TabCollection)
/**
* @see [CollectionInteractor.onDeleteCollectionTapped]
*/
fun handleDeleteCollectionTapped(collection: TabCollection)
/**
* @see [TabSessionInteractor.onPauseMediaClicked]
*/
fun handlePauseMediaClicked()
/**
* @see [TabSessionInteractor.onPlayMediaClicked]
*/
fun handlePlayMediaClicked()
/**
* @see [TabSessionInteractor.onPrivateBrowsingLearnMoreClicked]
*/
fun handlePrivateBrowsingLearnMoreClicked()
/**
* @see [CollectionInteractor.onRenameCollectionTapped]
*/
fun handleRenameCollectionTapped(collection: TabCollection)
/**
* @see [TabSessionInteractor.onSaveToCollection]
*/
fun handleSaveTabToCollection(selectedTabId: String?)
/**
* @see [TabSessionInteractor.onSelectTab]
*/
fun handleSelectTab(tabView: View, sessionId: String)
/**
* @see [TabSessionInteractor.onShareTabs]
*/
fun handleShareTabs()
/**
* @see [OnboardingInteractor.onStartBrowsingClicked]
*/
fun handleStartBrowsingClicked()
/**
* @see [CollectionInteractor.onToggleCollectionExpanded]
*/
fun handleToggleCollectionExpanded(collection: TabCollection, expand: Boolean)
}
@SuppressWarnings("TooManyFunctions")
class DefaultSessionControlController(
private val activity: HomeActivity,
private val store: HomeFragmentStore,
private val navController: NavController,
private val homeLayout: MotionLayout,
private val browsingModeManager: BrowsingModeManager,
private val lifecycleScope: CoroutineScope,
private val closeTab: (sessionId: String) -> Unit,
private val closeAllTabs: (isPrivateMode: Boolean) -> Unit,
private val getListOfTabs: () -> List<Tab>,
private val hideOnboarding: () -> Unit,
private val invokePendingDeleteJobs: () -> Unit,
private val registerCollectionStorageObserver: () -> Unit,
private val scrollToTheTop: () -> Unit,
private val showDeleteCollectionPrompt: (tabCollection: TabCollection) -> Unit
) : SessionControlController {
private val metrics: MetricController
get() = activity.components.analytics.metrics
private val sessionManager: SessionManager
get() = activity.components.core.sessionManager
private val tabCollectionStorage: TabCollectionStorage
get() = activity.components.core.tabCollectionStorage
override fun handleCloseTab(sessionId: String) {
closeTab.invoke(sessionId)
}
override fun handleCloseAllTabs(isPrivateMode: Boolean) {
closeAllTabs.invoke(isPrivateMode)
}
override fun handleCollectionAddTabTapped(collection: TabCollection) {
metrics.track(Event.CollectionAddTabPressed)
showCollectionCreationFragment(
step = SaveCollectionStep.SelectTabs,
selectedTabCollectionId = collection.id
)
}
override fun handleCollectionOpenTabClicked(tab: ComponentTab) {
invokePendingDeleteJobs()
val session = tab.restore(
context = activity,
engine = activity.components.core.engine,
tab = tab,
restoreSessionId = false
)
if (session == null) {
// We were unable to create a snapshot, so just load the tab instead
activity.openToBrowserAndLoad(
searchTermOrURL = tab.url,
newTab = true,
from = BrowserDirection.FromHome
)
} else {
sessionManager.add(
session,
true
)
activity.openToBrowser(BrowserDirection.FromHome)
}
metrics.track(Event.CollectionTabRestored)
}
override fun handleCollectionOpenTabsTapped(collection: TabCollection) {
invokePendingDeleteJobs()
collection.tabs.reversed().forEach {
val session = it.restore(
context = activity,
engine = activity.components.core.engine,
tab = it,
restoreSessionId = false
)
if (session == null) {
// We were unable to create a snapshot, so just load the tab instead
activity.components.useCases.tabsUseCases.addTab.invoke(it.url)
} else {
sessionManager.add(
session,
activity.components.core.sessionManager.selectedSession == null
)
}
}
scrollToTheTop()
metrics.track(Event.CollectionAllTabsRestored)
}
override fun handleCollectionRemoveTab(collection: TabCollection, tab: ComponentTab) {
metrics.track(Event.CollectionTabRemoved)
lifecycleScope.launch(Dispatchers.IO) {
tabCollectionStorage.removeTabFromCollection(collection, tab)
}
}
override fun handleCollectionShareTabsClicked(collection: TabCollection) {
showShareFragment(collection.tabs.map { ShareData(url = it.url, title = it.title) })
metrics.track(Event.CollectionShared)
}
override fun handleDeleteCollectionTapped(collection: TabCollection) {
showDeleteCollectionPrompt(collection)
}
override fun handlePauseMediaClicked() {
MediaStateMachine.state.pauseIfPlaying()
}
override fun handlePlayMediaClicked() {
MediaStateMachine.state.playIfPaused()
}
override fun handlePrivateBrowsingLearnMoreClicked() {
activity.openToBrowserAndLoad(
searchTermOrURL = SupportUtils.getGenericSumoURLForTopic
(SupportUtils.SumoTopic.PRIVATE_BROWSING_MYTHS),
newTab = true,
from = BrowserDirection.FromHome
)
}
override fun handleRenameCollectionTapped(collection: TabCollection) {
showCollectionCreationFragment(
step = SaveCollectionStep.RenameCollection,
selectedTabCollectionId = collection.id
)
metrics.track(Event.CollectionRenamePressed)
}
override fun handleSaveTabToCollection(selectedTabId: String?) {
if (browsingModeManager.mode.isPrivate) return
invokePendingDeleteJobs()
val tabs = getListOfTabs()
val step = when {
// Show the SelectTabs fragment if there are multiple opened tabs to select which tabs
// you want to save to a collection.
tabs.size > 1 -> SaveCollectionStep.SelectTabs
// If there is an existing tab collection, show the SelectCollection fragment to save
// the selected tab to a collection of your choice.
tabCollectionStorage.cachedTabCollections.isNotEmpty() -> SaveCollectionStep.SelectCollection
// Show the NameCollection fragment to create a new collection for the selected tab.
else -> SaveCollectionStep.NameCollection
}
showCollectionCreationFragment(step, selectedTabId?.let { arrayOf(it) })
}
override fun handleSelectTab(tabView: View, sessionId: String) {
invokePendingDeleteJobs()
val session = sessionManager.findSessionById(sessionId)
sessionManager.select(session!!)
val directions = HomeFragmentDirections.actionHomeFragmentToBrowserFragment(null)
val extras =
FragmentNavigator.Extras.Builder()
.addSharedElement(tabView,
"$TAB_ITEM_TRANSITION_NAME$sessionId"
)
.build()
navController.nav(R.id.homeFragment, directions, extras)
}
override fun handleShareTabs() {
invokePendingDeleteJobs()
val shareData = sessionManager
.sessionsOfType(private = browsingModeManager.mode.isPrivate)
.map { ShareData(url = it.url, title = it.title) }
.toList()
showShareFragment(shareData)
}
override fun handleStartBrowsingClicked() {
homeLayout.progress = 0F
hideOnboarding()
}
override fun handleToggleCollectionExpanded(collection: TabCollection, expand: Boolean) {
store.dispatch(HomeFragmentAction.CollectionExpanded(collection, expand))
}
private fun showCollectionCreationFragment(
step: SaveCollectionStep,
selectedTabIds: Array<String>? = null,
selectedTabCollectionId: Long? = null
) {
if (navController.currentDestination?.id == R.id.collectionCreationFragment) return
// Only register the observer right before moving to collection creation
registerCollectionStorageObserver()
val tabIds = getListOfTabs().map { it.sessionId }.toTypedArray()
val directions = HomeFragmentDirections.actionHomeFragmentToCreateCollectionFragment(
tabIds = tabIds,
previousFragmentId = R.id.homeFragment,
saveCollectionStep = step,
selectedTabIds = selectedTabIds,
selectedTabCollectionId = selectedTabCollectionId ?: -1
)
navController.nav(R.id.homeFragment, directions)
}
private fun showShareFragment(data: List<ShareData>) {
val directions = HomeFragmentDirections.actionHomeFragmentToShareFragment(
data = data.toTypedArray()
)
navController.nav(R.id.homeFragment, directions)
}
companion object {
private const val TAB_ITEM_TRANSITION_NAME = "tab_item"
}
}

View File

@ -0,0 +1,228 @@
/* 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.home.sessioncontrol
import android.view.View
import mozilla.components.feature.tab.collections.Tab
import mozilla.components.feature.tab.collections.TabCollection
/**
* Interface for collection related actions in the [SessionControlInteractor].
*/
interface CollectionInteractor {
/**
* Shows the Collection Creation fragment for selecting the tabs to add to the given tab
* collection. Called when a user taps on the "Add tab" collection menu item.
*
* @param collection The collection of tabs that will be modified.
*/
fun onCollectionAddTabTapped(collection: TabCollection)
/**
* Opens the given tab. Called when a user clicks on a tab in the tab collection.
*
* @param tab The tab to open from the tab collection.
*/
fun onCollectionOpenTabClicked(tab: Tab)
/**
* Opens all the tabs in a given tab collection. Called when a user taps on the "Open tabs"
* collection menu item.
*
* @param collection The collection of tabs to open.
*/
fun onCollectionOpenTabsTapped(collection: TabCollection)
/**
* Removes the given tab from the given tab collection. Called when a user swipes to remove a
* tab or clicks on the tab close button.
*
* @param collection The collection of tabs that will be modified.
* @param tab The tab to remove from the tab collection.
*/
fun onCollectionRemoveTab(collection: TabCollection, tab: Tab)
/**
* Shares the tabs in the given tab collection. Called when a user clicks on the Collection
* Share button.
*
* @param collection The collection of tabs to share.
*/
fun onCollectionShareTabsClicked(collection: TabCollection)
/**
* Shows a prompt for deleting the given tab collection. Called when a user taps on the
* "Delete collection" collection menu item.
*
* @param collection The collection of tabs to delete.
*/
fun onDeleteCollectionTapped(collection: TabCollection)
/**
* Shows the Collection Creation fragment for renaming the given tab collection. Called when a
* user taps on the "Rename collection" collection menu item.
*
* @param collection The collection of tabs to rename.
*/
fun onRenameCollectionTapped(collection: TabCollection)
/**
* Toggles expanding or collapsing the given tab collection. Called when a user clicks on a
* [CollectionViewHolder].
*
* @param collection The collection of tabs that will be collapsed.
* @param expand True if the given tab collection should be expanded or collapse if false.
*/
fun onToggleCollectionExpanded(collection: TabCollection, expand: Boolean)
}
/**
* Interface for onboarding related actions in the [SessionControlInteractor].
*/
interface OnboardingInteractor {
/**
* Hides the onboarding. Called when a user clicks on the "Start Browsing" button.
*/
fun onStartBrowsingClicked()
}
/**
* Interface for tab related actions in the [SessionControlInteractor].
*/
interface TabSessionInteractor {
/**
* Closes the given tab. Called when a user swipes to close a tab or clicks on the Close Tab
* button in the tab view.
*
* @param sessionId The selected tab session id to close.
*/
fun onCloseTab(sessionId: String)
/**
* Closes all the tabs. Called when a user clicks on the Close Tabs button or "Close all tabs"
* tab header menu item.
*
* @param isPrivateMode True if the [BrowsingMode] is [Private] and false otherwise.
*/
fun onCloseAllTabs(isPrivateMode: Boolean)
/**
* Pauses all playing [Media]. Called when a user clicks on the Pause button in the tab view.
*/
fun onPauseMediaClicked()
/**
* Resumes playing all paused [Media]. Called when a user clicks on the Play button in the tab
* view.
*/
fun onPlayMediaClicked()
/**
* Shows the Private Browsing Learn More page in a new tab. Called when a user clicks on the
* "Common myths about private browsing" link in private mode.
*/
fun onPrivateBrowsingLearnMoreClicked()
/**
* Saves the given tab to collection. Called when a user clicks on the "Save to collection"
* button or tab header menu item, and on long click of an open tab.
*
* @param sessionId The selected tab session id to save.
*/
fun onSaveToCollection(sessionId: String?)
/**
* Selects the given tab. Called when a user clicks on a tab.
*
* @param tabView [View] of the current Fragment to match with a View in the Fragment being
* navigated to.
* @param sessionId The tab session id to select.
*/
fun onSelectTab(tabView: View, sessionId: String)
/**
* Shares the current opened tabs. Called when a user clicks on the Share Tabs button in private
* mode or tab header menu item.
*/
fun onShareTabs()
}
/**
* Interactor for the Home screen.
* Provides implementations for the CollectionInteractor, OnboardingInteractor and
* TabSessionInteractor.
*/
@SuppressWarnings("TooManyFunctions")
class SessionControlInteractor(
private val controller: SessionControlController
) : CollectionInteractor, OnboardingInteractor, TabSessionInteractor {
override fun onCloseTab(sessionId: String) {
controller.handleCloseTab(sessionId)
}
override fun onCloseAllTabs(isPrivateMode: Boolean) {
controller.handleCloseAllTabs(isPrivateMode)
}
override fun onCollectionAddTabTapped(collection: TabCollection) {
controller.handleCollectionAddTabTapped(collection)
}
override fun onCollectionOpenTabClicked(tab: Tab) {
controller.handleCollectionOpenTabClicked(tab)
}
override fun onCollectionOpenTabsTapped(collection: TabCollection) {
controller.handleCollectionOpenTabsTapped(collection)
}
override fun onCollectionRemoveTab(collection: TabCollection, tab: Tab) {
controller.handleCollectionRemoveTab(collection, tab)
}
override fun onCollectionShareTabsClicked(collection: TabCollection) {
controller.handleCollectionShareTabsClicked(collection)
}
override fun onDeleteCollectionTapped(collection: TabCollection) {
controller.handleDeleteCollectionTapped(collection)
}
override fun onPauseMediaClicked() {
controller.handlePauseMediaClicked()
}
override fun onPlayMediaClicked() {
controller.handlePlayMediaClicked()
}
override fun onPrivateBrowsingLearnMoreClicked() {
controller.handlePrivateBrowsingLearnMoreClicked()
}
override fun onRenameCollectionTapped(collection: TabCollection) {
controller.handleRenameCollectionTapped(collection)
}
override fun onSaveToCollection(sessionId: String?) {
controller.handleSaveTabToCollection(sessionId)
}
override fun onSelectTab(tabView: View, sessionId: String) {
controller.handleSelectTab(tabView, sessionId)
}
override fun onShareTabs() {
controller.handleShareTabs()
}
override fun onStartBrowsingClicked() {
controller.handleStartBrowsingClicked()
}
override fun onToggleCollectionExpanded(collection: TabCollection, expand: Boolean) {
controller.handleToggleCollectionExpanded(collection, expand)
}
}

View File

@ -6,17 +6,22 @@ package org.mozilla.fenix.home.sessioncontrol
import android.os.Build
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observable
import io.reactivex.Observer
import io.reactivex.functions.Consumer
import kotlinx.android.extensions.LayoutContainer
import kotlinx.coroutines.ExperimentalCoroutinesApi
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.lib.state.ext.consumeFrom
import org.mozilla.fenix.R
import org.mozilla.fenix.home.HomeFragmentState
import org.mozilla.fenix.home.HomeFragmentStore
import org.mozilla.fenix.home.Mode
import org.mozilla.fenix.home.OnboardingState
import org.mozilla.fenix.mvi.UIView
import org.mozilla.fenix.home.Tab
val noTabMessage = AdapterItem.NoContentMessage(
R.drawable.ic_tabs,
@ -110,7 +115,7 @@ private fun onboardingAdapterItems(onboardingState: OnboardingState): List<Adapt
return items
}
private fun SessionControlState.toAdapterList(): List<AdapterItem> = when (mode) {
private fun HomeFragmentState.toAdapterList(): List<AdapterItem> = when (mode) {
is Mode.Normal -> normalModeAdapterItems(tabs, collections, expandedCollections)
is Mode.Private -> privateModeAdapterItems(tabs)
is Mode.Onboarding -> onboardingAdapterItems(mode.state)
@ -120,22 +125,20 @@ private fun collectionTabItems(collection: TabCollection) = collection.tabs.mapI
AdapterItem.TabInCollectionItem(collection, tab, index == collection.tabs.lastIndex)
}
class SessionControlUIView(
container: ViewGroup,
actionEmitter: Observer<SessionControlAction>,
changesObservable: Observable<SessionControlChange>
) :
UIView<SessionControlState, SessionControlAction, SessionControlChange>(
container,
actionEmitter,
changesObservable
) {
@ExperimentalCoroutinesApi
class SessionControlView(
private val homeFragmentStore: HomeFragmentStore,
private val container: ViewGroup,
interactor: SessionControlInteractor
) : LayoutContainer {
override val containerView: View?
get() = container
override val view: RecyclerView = LayoutInflater.from(container.context)
val view: RecyclerView = LayoutInflater.from(container.context)
.inflate(R.layout.component_session_control, container, true)
.findViewById(R.id.home_component)
private val sessionControlAdapter = SessionControlAdapter(actionEmitter)
private val sessionControlAdapter = SessionControlAdapter(interactor)
init {
view.apply {
@ -144,18 +147,22 @@ class SessionControlUIView(
val itemTouchHelper =
ItemTouchHelper(
SwipeToDeleteCallback(
actionEmitter
interactor
)
)
itemTouchHelper.attachToRecyclerView(this)
view.consumeFrom(homeFragmentStore, ProcessLifecycleOwner.get()) {
update(it)
}
}
}
override fun updateView() = Consumer<SessionControlState> {
fun update(state: HomeFragmentState) {
// Workaround for list not updating until scroll on Android 5 + 6
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
sessionControlAdapter.submitList(null)
}
sessionControlAdapter.submitList(it.toAdapterList())
sessionControlAdapter.submitList(state.toAdapterList())
}
}

View File

@ -10,14 +10,13 @@ import android.graphics.drawable.Drawable
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.getColorFromAttr
import org.mozilla.fenix.home.sessioncontrol.viewholders.TabInCollectionViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.TabViewHolder
class SwipeToDeleteCallback(
val actionEmitter: Observer<SessionControlAction>
val interactor: SessionControlInteractor
) : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) {
override fun onMove(
recyclerView: RecyclerView,
@ -30,9 +29,9 @@ class SwipeToDeleteCallback(
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
when (viewHolder) {
is TabViewHolder -> actionEmitter.onNext(TabAction.Close(viewHolder.tab?.sessionId!!))
is TabViewHolder -> interactor.onCloseTab(viewHolder.tab?.sessionId!!)
is TabInCollectionViewHolder -> {
actionEmitter.onNext(CollectionAction.RemoveTab(viewHolder.collection, viewHolder.tab))
interactor.onCollectionRemoveTab(viewHolder.collection, viewHolder.tab)
}
}
}

View File

@ -9,25 +9,22 @@ import android.graphics.PorterDuff.Mode.SRC_IN
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.collection_home_list_row.*
import kotlinx.android.synthetic.main.collection_home_list_row.view.*
import mozilla.components.browser.menu.BrowserMenuBuilder
import mozilla.components.browser.menu.item.SimpleBrowserMenuItem
import mozilla.components.feature.tab.collections.TabCollection
import org.mozilla.fenix.R
import org.mozilla.fenix.theme.ThemeManager
import org.mozilla.fenix.components.description
import org.mozilla.fenix.ext.getIconColor
import org.mozilla.fenix.ext.increaseTapArea
import org.mozilla.fenix.home.sessioncontrol.CollectionAction
import org.mozilla.fenix.home.sessioncontrol.SessionControlAction
import org.mozilla.fenix.home.sessioncontrol.TabCollection
import org.mozilla.fenix.home.sessioncontrol.onNext
import org.mozilla.fenix.home.sessioncontrol.CollectionInteractor
class CollectionViewHolder(
val view: View,
val actionEmitter: Observer<SessionControlAction>,
val interactor: CollectionInteractor,
override val containerView: View? = view
) :
RecyclerView.ViewHolder(view), LayoutContainer {
@ -40,10 +37,10 @@ class CollectionViewHolder(
init {
collectionMenu = CollectionItemMenu(view.context, sessionHasOpenTabs) {
when (it) {
is CollectionItemMenu.Item.DeleteCollection -> actionEmitter.onNext(CollectionAction.Delete(collection))
is CollectionItemMenu.Item.AddTab -> actionEmitter.onNext(CollectionAction.AddTab(collection))
is CollectionItemMenu.Item.RenameCollection -> actionEmitter.onNext(CollectionAction.Rename(collection))
is CollectionItemMenu.Item.OpenTabs -> actionEmitter.onNext(CollectionAction.OpenTabs(collection))
is CollectionItemMenu.Item.DeleteCollection -> interactor.onDeleteCollectionTapped(collection)
is CollectionItemMenu.Item.AddTab -> interactor.onCollectionAddTabTapped(collection)
is CollectionItemMenu.Item.RenameCollection -> interactor.onRenameCollectionTapped(collection)
is CollectionItemMenu.Item.OpenTabs -> interactor.onCollectionOpenTabsTapped(collection)
}
}
@ -59,13 +56,13 @@ class CollectionViewHolder(
collection_share_button.run {
increaseTapArea(buttonIncreaseDps)
setOnClickListener {
actionEmitter.onNext(CollectionAction.ShareTabs(collection))
interactor.onCollectionShareTabsClicked(collection)
}
}
view.clipToOutline = true
view.setOnClickListener {
handleExpansion(expanded)
interactor.onToggleCollectionExpanded(collection, !expanded)
}
}
@ -98,14 +95,6 @@ class CollectionViewHolder(
)
}
private fun handleExpansion(isExpanded: Boolean) {
if (isExpanded) {
actionEmitter.onNext(CollectionAction.Collapse(collection))
} else {
actionEmitter.onNext(CollectionAction.Expand(collection))
}
}
companion object {
const val buttonIncreaseDps = 16
const val EXPANDED_PADDING = 60

View File

@ -9,16 +9,13 @@ import android.text.method.LinkMovementMethod
import android.text.style.UnderlineSpan
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.synthetic.main.private_browsing_description.view.*
import org.mozilla.fenix.R
import org.mozilla.fenix.home.sessioncontrol.SessionControlAction
import org.mozilla.fenix.home.sessioncontrol.TabAction
import org.mozilla.fenix.home.sessioncontrol.onNext
import org.mozilla.fenix.home.sessioncontrol.TabSessionInteractor
class PrivateBrowsingDescriptionViewHolder(
view: View,
private val actionEmitter: Observer<SessionControlAction>
private val interactor: TabSessionInteractor
) : RecyclerView.ViewHolder(view) {
init {
@ -35,7 +32,7 @@ class PrivateBrowsingDescriptionViewHolder(
movementMethod = LinkMovementMethod.getInstance()
text = textWithLink
setOnClickListener {
actionEmitter.onNext(TabAction.PrivateBrowsingLearnMore)
interactor.onPrivateBrowsingLearnMoreClicked()
}
}
}

View File

@ -6,26 +6,22 @@ package org.mozilla.fenix.home.sessioncontrol.viewholders
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.synthetic.main.save_tab_group_button.view.*
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.home.sessioncontrol.SessionControlAction
import org.mozilla.fenix.home.sessioncontrol.TabAction
import org.mozilla.fenix.home.sessioncontrol.onNext
import org.mozilla.fenix.home.sessioncontrol.TabSessionInteractor
class SaveTabGroupViewHolder(
view: View,
private val actionEmitter: Observer<SessionControlAction>
private val interactor: TabSessionInteractor
) : RecyclerView.ViewHolder(view) {
init {
view.save_tab_group_button.setOnClickListener {
view.context.components.analytics.metrics
.track(Event.CollectionSaveButtonPressed(TELEMETRY_HOME_IDENTIFIER))
actionEmitter.onNext(TabAction.SaveTabGroup(selectedTabSessionId = null))
interactor.onSaveToCollection(sessionId = null)
}
}

View File

@ -10,7 +10,6 @@ import android.widget.PopupWindow
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.synthetic.main.tab_header.view.*
import mozilla.components.browser.menu.BrowserMenu
import mozilla.components.browser.menu.BrowserMenuBuilder
@ -18,13 +17,11 @@ import mozilla.components.browser.menu.item.SimpleBrowserMenuItem
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.home.sessioncontrol.SessionControlAction
import org.mozilla.fenix.home.sessioncontrol.TabAction
import org.mozilla.fenix.home.sessioncontrol.onNext
import org.mozilla.fenix.home.sessioncontrol.SessionControlInteractor
class TabHeaderViewHolder(
private val view: View,
private val actionEmitter: Observer<SessionControlAction>
private val interactor: SessionControlInteractor
) : RecyclerView.ViewHolder(view) {
private var isPrivate = false
private var tabsMenu: TabHeaderMenu
@ -32,10 +29,10 @@ class TabHeaderViewHolder(
init {
tabsMenu = TabHeaderMenu(view.context, isPrivate) {
when (it) {
is TabHeaderMenu.Item.Share -> actionEmitter.onNext(TabAction.ShareTabs)
is TabHeaderMenu.Item.CloseAll -> actionEmitter.onNext(TabAction.CloseAll(isPrivate))
is TabHeaderMenu.Item.Share -> interactor.onShareTabs()
is TabHeaderMenu.Item.CloseAll -> interactor.onCloseAllTabs(isPrivate)
is TabHeaderMenu.Item.SaveToCollection -> {
actionEmitter.onNext(TabAction.SaveTabGroup(null))
interactor.onSaveToCollection(null)
view.context.components.analytics.metrics
.track(Event.CollectionSaveButtonPressed(TELEMETRY_HOME_MENU_IDENITIFIER))
}
@ -45,14 +42,14 @@ class TabHeaderViewHolder(
view.apply {
share_tabs_button.run {
setOnClickListener {
actionEmitter.onNext(TabAction.ShareTabs)
interactor.onShareTabs()
}
}
close_tabs_button.run {
setOnClickListener {
view.context.components.analytics.metrics.track(Event.PrivateBrowsingGarbageIconTapped)
actionEmitter.onNext(TabAction.CloseAll(true))
interactor.onCloseAllTabs(true)
}
}

View File

@ -9,9 +9,9 @@ import android.view.View
import android.view.ViewOutlineProvider
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.tab_in_collection.*
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.support.ktx.android.util.dpToFloat
import org.jetbrains.anko.backgroundColor
import org.mozilla.fenix.R
@ -20,15 +20,12 @@ import org.mozilla.fenix.ext.getColorFromAttr
import org.mozilla.fenix.ext.increaseTapArea
import org.mozilla.fenix.ext.loadIntoView
import org.mozilla.fenix.ext.toShortUrl
import org.mozilla.fenix.home.sessioncontrol.CollectionAction
import org.mozilla.fenix.home.sessioncontrol.SessionControlAction
import org.mozilla.fenix.home.sessioncontrol.TabCollection
import org.mozilla.fenix.home.sessioncontrol.onNext
import org.mozilla.fenix.home.sessioncontrol.CollectionInteractor
import mozilla.components.feature.tab.collections.Tab as ComponentTab
class TabInCollectionViewHolder(
val view: View,
val actionEmitter: Observer<SessionControlAction>,
val interactor: CollectionInteractor,
override val containerView: View? = view
) : RecyclerView.ViewHolder(view), LayoutContainer {
@ -53,12 +50,12 @@ class TabInCollectionViewHolder(
}
view.setOnClickListener {
actionEmitter.onNext(CollectionAction.OpenTab(tab))
interactor.onCollectionOpenTabClicked(tab)
}
collection_tab_close_button.increaseTapArea(buttonIncreaseDps)
collection_tab_close_button.setOnClickListener {
actionEmitter.onNext(CollectionAction.RemoveTab(collection, tab))
interactor.onCollectionRemoveTab(collection, tab)
}
}

View File

@ -4,17 +4,13 @@
package org.mozilla.fenix.home.sessioncontrol.viewholders
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Outline
import android.view.View
import android.view.ViewOutlineProvider
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.tab_list_row.*
import mozilla.components.browser.menu.BrowserMenuBuilder
import mozilla.components.browser.menu.item.SimpleBrowserMenuItem
import mozilla.components.feature.media.state.MediaState
import mozilla.components.support.ktx.android.util.dpToFloat
import org.jetbrains.anko.imageBitmap
@ -23,41 +19,31 @@ import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.increaseTapArea
import org.mozilla.fenix.ext.loadIntoView
import org.mozilla.fenix.home.sessioncontrol.SessionControlAction
import org.mozilla.fenix.home.sessioncontrol.Tab
import org.mozilla.fenix.home.sessioncontrol.TabAction
import org.mozilla.fenix.home.sessioncontrol.onNext
import org.mozilla.fenix.home.Tab
import org.mozilla.fenix.home.sessioncontrol.TabSessionInteractor
class TabViewHolder(
view: View,
actionEmitter: Observer<SessionControlAction>,
interactor: TabSessionInteractor,
override val containerView: View? = view
) :
RecyclerView.ViewHolder(view), LayoutContainer {
internal var tab: Tab? = null
private var tabMenu: TabItemMenu
init {
tabMenu = TabItemMenu(view.context) {
when (it) {
is TabItemMenu.Item.Share ->
actionEmitter.onNext(TabAction.Share(tab?.sessionId!!))
}
}
item_tab.setOnClickListener {
actionEmitter.onNext(TabAction.Select(it, tab?.sessionId!!))
interactor.onSelectTab(it, tab?.sessionId!!)
}
item_tab.setOnLongClickListener {
view.context.components.analytics.metrics.track(Event.CollectionTabLongPressed)
actionEmitter.onNext(TabAction.SaveTabGroup(tab?.sessionId!!))
true
interactor.onSaveToCollection(tab?.sessionId!!)
return@setOnLongClickListener true
}
close_tab_button.setOnClickListener {
actionEmitter.onNext(TabAction.Close(tab?.sessionId!!))
interactor.onCloseTab(tab?.sessionId!!)
}
play_pause_button.increaseTapArea(PLAY_PAUSE_BUTTON_EXTRA_DPS)
@ -66,12 +52,12 @@ class TabViewHolder(
when (tab?.mediaState) {
is MediaState.Playing -> {
it.context.components.analytics.metrics.track(Event.TabMediaPlay)
actionEmitter.onNext(TabAction.PauseMedia(tab?.sessionId!!))
interactor.onPauseMediaClicked()
}
is MediaState.Paused -> {
it.context.components.analytics.metrics.track(Event.TabMediaPause)
actionEmitter.onNext(TabAction.PlayMedia(tab?.sessionId!!))
interactor.onPlayMediaClicked()
}
}
}
@ -155,24 +141,3 @@ class TabViewHolder(
const val favIconBorderRadiusInPx = 4
}
}
class TabItemMenu(
private val context: Context,
private val onItemTapped: (Item) -> Unit = {}
) {
sealed class Item {
object Share : Item()
}
val menuBuilder by lazy { BrowserMenuBuilder(menuItems) }
private val menuItems by lazy {
listOf(
SimpleBrowserMenuItem(
context.getString(R.string.tab_share)
) {
onItemTapped.invoke(Item.Share)
}
)
}
}

View File

@ -6,21 +6,18 @@ package org.mozilla.fenix.home.sessioncontrol.viewholders.onboarding
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.synthetic.main.onboarding_finish.view.*
import org.mozilla.fenix.R
import org.mozilla.fenix.home.sessioncontrol.OnboardingAction
import org.mozilla.fenix.home.sessioncontrol.SessionControlAction
import org.mozilla.fenix.home.sessioncontrol.onNext
import org.mozilla.fenix.home.sessioncontrol.OnboardingInteractor
class OnboardingFinishViewHolder(
view: View,
private val actionEmitter: Observer<SessionControlAction>
private val interactor: OnboardingInteractor
) : RecyclerView.ViewHolder(view) {
init {
view.finish_button.setOnClickListener {
actionEmitter.onNext(OnboardingAction.Finish)
interactor.onStartBrowsingClicked()
}
}

View File

@ -1,28 +0,0 @@
/* 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
import androidx.lifecycle.LifecycleOwner
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.reactivex.android.plugins.RxAndroidPlugins
import io.reactivex.plugins.RxJavaPlugins
import io.reactivex.schedulers.Schedulers
import org.mozilla.fenix.mvi.ActionBusFactory
object TestUtils {
fun setRxSchedulers() {
RxJavaPlugins.setIoSchedulerHandler { Schedulers.trampoline() }
RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() }
}
val owner = mockk<LifecycleOwner> {
every { lifecycle } returns mockk()
every { lifecycle.addObserver(any()) } just Runs
}
val bus: ActionBusFactory = ActionBusFactory.get(owner)
}

View File

@ -29,6 +29,7 @@ import mozilla.components.browser.session.SessionManager
import mozilla.components.concept.engine.EngineView
import mozilla.components.feature.search.SearchUseCases
import mozilla.components.feature.session.SessionUseCases
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.tabs.TabsUseCases
import org.junit.After
import org.junit.Before
@ -48,8 +49,7 @@ import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.ext.toTab
import org.mozilla.fenix.home.sessioncontrol.Tab
import org.mozilla.fenix.home.sessioncontrol.TabCollection
import org.mozilla.fenix.home.Tab
import org.mozilla.fenix.settings.deletebrowsingdata.deleteAndQuit
@ExperimentalCoroutinesApi

View File

@ -0,0 +1,209 @@
/* 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.home
import android.view.View
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.navigation.NavController
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.ObsoleteCoroutinesApi
import kotlinx.coroutines.newSingleThreadContext
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import mozilla.components.feature.tab.collections.TabCollection
import org.junit.After
import mozilla.components.feature.tab.collections.Tab as ComponentTab
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.BrowsingModeManager
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController
import org.mozilla.fenix.settings.SupportUtils
@ExperimentalCoroutinesApi
@UseExperimental(ObsoleteCoroutinesApi::class)
class DefaultSessionControlControllerTest {
private val mainThreadSurrogate = newSingleThreadContext("UI thread")
private val activity: HomeActivity = mockk(relaxed = true)
private val store: HomeFragmentStore = mockk(relaxed = true)
private val navController: NavController = mockk(relaxed = true)
private val homeLayout: MotionLayout = mockk(relaxed = true)
private val browsingModeManager: BrowsingModeManager = mockk(relaxed = true)
private val closeTab: (sessionId: String) -> Unit = mockk(relaxed = true)
private val closeAllTabs: (isPrivateMode: Boolean) -> Unit = mockk(relaxed = true)
private val getListOfTabs: () -> List<Tab> = { emptyList() }
private val hideOnboarding: () -> Unit = mockk(relaxed = true)
private val invokePendingDeleteJobs: () -> Unit = mockk(relaxed = true)
private val registerCollectionStorageObserver: () -> Unit = mockk(relaxed = true)
private val scrollToTheTop: () -> Unit = mockk(relaxed = true)
private val showDeleteCollectionPrompt: (tabCollection: TabCollection) -> Unit =
mockk(relaxed = true)
private val metrics: MetricController = mockk(relaxed = true)
private val state: HomeFragmentState = mockk(relaxed = true)
private lateinit var controller: DefaultSessionControlController
@Before
fun setup() {
Dispatchers.setMain(mainThreadSurrogate)
every { store.state } returns state
every { state.collections } returns emptyList()
every { state.expandedCollections } returns emptySet()
every { state.mode } returns Mode.Normal
every { state.tabs } returns emptyList()
every { activity.components.analytics.metrics } returns metrics
controller = DefaultSessionControlController(
activity = activity,
store = store,
navController = navController,
homeLayout = homeLayout,
browsingModeManager = browsingModeManager,
lifecycleScope = MainScope(),
closeTab = closeTab,
closeAllTabs = closeAllTabs,
getListOfTabs = getListOfTabs,
hideOnboarding = hideOnboarding,
invokePendingDeleteJobs = invokePendingDeleteJobs,
registerCollectionStorageObserver = registerCollectionStorageObserver,
scrollToTheTop = scrollToTheTop,
showDeleteCollectionPrompt = showDeleteCollectionPrompt
)
}
@After
fun tearDown() {
Dispatchers.resetMain() // reset main dispatcher to the original Main dispatcher
mainThreadSurrogate.close()
}
@Test
fun handleCloseTab() {
val sessionId = "hello"
controller.handleCloseTab(sessionId)
verify { closeTab(sessionId) }
}
@Test
fun handleCloseAllTabs() {
val isPrivateMode = true
controller.handleCloseAllTabs(isPrivateMode)
verify { closeAllTabs(isPrivateMode) }
}
@Test
fun handleCollectionAddTabTapped() {
val collection: TabCollection = mockk(relaxed = true)
controller.handleCollectionAddTabTapped(collection)
verify { metrics.track(Event.CollectionAddTabPressed) }
}
@Test
fun handleCollectionOpenTabClicked() {
val tab: ComponentTab = mockk(relaxed = true)
controller.handleCollectionOpenTabClicked(tab)
verify { invokePendingDeleteJobs() }
verify { metrics.track(Event.CollectionTabRestored) }
}
@Test
fun handleCollectionOpenTabsTapped() {
val collection: TabCollection = mockk(relaxed = true)
controller.handleCollectionOpenTabsTapped(collection)
verify { invokePendingDeleteJobs() }
verify { scrollToTheTop() }
verify { metrics.track(Event.CollectionAllTabsRestored) }
}
@Test
fun handleCollectionRemoveTab() {
val collection: TabCollection = mockk(relaxed = true)
val tab: ComponentTab = mockk(relaxed = true)
controller.handleCollectionRemoveTab(collection, tab)
verify { metrics.track(Event.CollectionTabRemoved) }
}
@Test
fun handleCollectionShareTabsClicked() {
val collection: TabCollection = mockk(relaxed = true)
controller.handleCollectionShareTabsClicked(collection)
verify { metrics.track(Event.CollectionShared) }
}
@Test
fun handleDeleteCollectionTapped() {
val collection: TabCollection = mockk(relaxed = true)
controller.handleDeleteCollectionTapped(collection)
verify { showDeleteCollectionPrompt(collection) }
}
@Test
fun handlePrivateBrowsingLearnMoreClicked() {
controller.handlePrivateBrowsingLearnMoreClicked()
verify {
activity.openToBrowserAndLoad(
searchTermOrURL = SupportUtils.getGenericSumoURLForTopic
(SupportUtils.SumoTopic.PRIVATE_BROWSING_MYTHS),
newTab = true,
from = BrowserDirection.FromHome
)
}
}
@Test
fun handleRenameCollectionTapped() {
val collection: TabCollection = mockk(relaxed = true)
controller.handleRenameCollectionTapped(collection)
verify { metrics.track(Event.CollectionRenamePressed) }
}
@Test
fun handleSaveTabToCollection() {
controller.handleSaveTabToCollection(selectedTabId = null)
verify { invokePendingDeleteJobs() }
}
@Test
fun handleSelectTab() {
val tabView: View = mockk(relaxed = true)
val sessionId = "hello"
val directions = HomeFragmentDirections.actionHomeFragmentToBrowserFragment(null)
controller.handleSelectTab(tabView, sessionId)
verify { invokePendingDeleteJobs() }
verify { navController.nav(R.id.homeFragment, directions) }
}
@Test
fun handleShareTabs() {
controller.handleShareTabs()
verify { invokePendingDeleteJobs() }
}
@Test
fun handleStartBrowsingClicked() {
controller.handleStartBrowsingClicked()
verify { hideOnboarding() }
}
@Test
fun handleToggleCollectionExpanded() {
val collection: TabCollection = mockk(relaxed = true)
controller.handleToggleCollectionExpanded(collection, true)
verify { store.dispatch(HomeFragmentAction.CollectionExpanded(collection, true)) }
}
}

View File

@ -0,0 +1,161 @@
/* 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.home
import android.content.Context
import assertk.assertThat
import assertk.assertions.isEqualTo
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.service.fxa.manager.FxaAccountManager
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.onboarding.FenixOnboarding
class HomeFragmentStoreTest {
private lateinit var context: Context
private lateinit var accountManager: FxaAccountManager
private lateinit var onboarding: FenixOnboarding
private lateinit var browsingModeManager: BrowsingModeManager
private lateinit var dispatchModeChanges: (mode: Mode) -> Unit
private lateinit var currentMode: CurrentMode
private lateinit var homeFragmentState: HomeFragmentState
private lateinit var homeFragmentStore: HomeFragmentStore
@Before
fun setup() {
context = mockk(relaxed = true)
accountManager = mockk(relaxed = true)
onboarding = mockk(relaxed = true)
browsingModeManager = mockk(relaxed = true)
dispatchModeChanges = mockk(relaxed = true)
every { context.components.backgroundServices.accountManager } returns accountManager
every { onboarding.userHasBeenOnboarded() } returns true
every { browsingModeManager.mode } returns BrowsingMode.Normal
currentMode = CurrentMode(
context,
onboarding,
browsingModeManager,
dispatchModeChanges
)
homeFragmentState = HomeFragmentState(
collections = emptyList(),
expandedCollections = emptySet(),
mode = currentMode.getCurrentMode(),
tabs = emptyList()
)
homeFragmentStore = HomeFragmentStore(homeFragmentState)
}
@Test
fun `Test toggling the mode in HomeFragmentStore`() = runBlocking {
// Verify that the default mode and tab states of the HomeFragment are correct.
assertThat(homeFragmentStore.state.mode).isEqualTo(Mode.Normal)
assertEquals(0, homeFragmentStore.state.tabs.size)
// Change the HomeFragmentStore to Private mode.
homeFragmentStore.dispatch(HomeFragmentAction.ModeChange(Mode.Private)).join()
assertThat(homeFragmentStore.state.mode).isEqualTo(Mode.Private)
assertEquals(0, homeFragmentStore.state.tabs.size)
// Change the HomeFragmentStore back to Normal mode.
homeFragmentStore.dispatch(HomeFragmentAction.ModeChange(Mode.Normal)).join()
assertThat(homeFragmentStore.state.mode).isEqualTo(Mode.Normal)
assertEquals(0, homeFragmentStore.state.tabs.size)
}
@Test
fun `Test toggling the mode with tabs in HomeFragmentStore`() = runBlocking {
// Verify that the default mode and tab states of the HomeFragment are correct.
assertThat(homeFragmentStore.state.mode).isEqualTo(Mode.Normal)
assertEquals(0, homeFragmentStore.state.tabs.size)
// Add 2 Tabs to the HomeFragmentStore.
val tabs: List<Tab> = listOf(mockk(), mockk())
homeFragmentStore.dispatch(HomeFragmentAction.TabsChange(tabs)).join()
assertEquals(2, homeFragmentStore.state.tabs.size)
// Change the HomeFragmentStore to Private mode.
homeFragmentStore.dispatch(HomeFragmentAction.ModeChange(Mode.Private)).join()
assertThat(homeFragmentStore.state.mode).isEqualTo(Mode.Private)
assertEquals(0, homeFragmentStore.state.tabs.size)
}
@Test
fun `Test changing the collections in HomeFragmentStore`() = runBlocking {
assertEquals(0, homeFragmentStore.state.collections.size)
// Add 2 TabCollections to the HomeFragmentStore.
val tabCollections: List<TabCollection> = listOf(mockk(), mockk())
homeFragmentStore.dispatch(HomeFragmentAction.CollectionsChange(tabCollections)).join()
assertThat(homeFragmentStore.state.collections).isEqualTo(tabCollections)
}
@Test
fun `Test changing the tab in HomeFragmentStore`() = runBlocking {
assertEquals(0, homeFragmentStore.state.tabs.size)
val tab: Tab = mockk()
homeFragmentStore.dispatch(HomeFragmentAction.TabsChange(listOf(tab))).join()
assertTrue(homeFragmentStore.state.tabs.contains(tab))
assertEquals(1, homeFragmentStore.state.tabs.size)
}
@Test
fun `Test changing the expanded collections in HomeFragmentStore`() = runBlocking {
val collection: TabCollection = mockk<TabCollection>().apply {
every { id } returns 0
}
// Expand the given collection.
homeFragmentStore.dispatch(HomeFragmentAction.CollectionsChange(listOf(collection))).join()
homeFragmentStore.dispatch(HomeFragmentAction.CollectionExpanded(collection, true)).join()
assertTrue(homeFragmentStore.state.expandedCollections.contains(collection.id))
assertEquals(1, homeFragmentStore.state.expandedCollections.size)
}
@Test
fun `Test changing the collections, mode and tabs in the HomeFragmentStore`() = runBlocking {
// Verify that the default state of the HomeFragment is correct.
assertEquals(0, homeFragmentStore.state.collections.size)
assertEquals(0, homeFragmentStore.state.tabs.size)
assertThat(homeFragmentStore.state.mode).isEqualTo(Mode.Normal)
val collections: List<TabCollection> = listOf(mockk())
val tabs: List<Tab> = listOf(mockk(), mockk())
homeFragmentStore.dispatch(
HomeFragmentAction.Change(
collections = collections,
mode = Mode.Private,
tabs = tabs
)
).join()
assertEquals(1, homeFragmentStore.state.collections.size)
assertThat(homeFragmentStore.state.mode).isEqualTo(Mode.Private)
assertEquals(2, homeFragmentStore.state.tabs.size)
}
}

View File

@ -9,7 +9,6 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.spyk
import io.mockk.verify
import io.reactivex.Observer
import mozilla.components.service.fxa.manager.FxaAccountManager
import mozilla.components.service.fxa.sharing.ShareableAccount
import org.junit.Assert.assertEquals
@ -18,7 +17,6 @@ import org.junit.Test
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.home.sessioncontrol.SessionControlChange
import org.mozilla.fenix.onboarding.FenixOnboarding
class ModeTest {
@ -27,8 +25,8 @@ class ModeTest {
private lateinit var accountManager: FxaAccountManager
private lateinit var onboarding: FenixOnboarding
private lateinit var browsingModeManager: BrowsingModeManager
private lateinit var emitter: Observer<SessionControlChange>
private lateinit var currentMode: CurrentMode
private lateinit var dispatchModeChanges: (mode: Mode) -> Unit
@Before
fun setup() {
@ -36,7 +34,7 @@ class ModeTest {
accountManager = mockk(relaxed = true)
onboarding = mockk(relaxed = true)
browsingModeManager = mockk(relaxed = true)
emitter = mockk(relaxed = true)
dispatchModeChanges = mockk(relaxed = true)
every { context.components.backgroundServices.accountManager } returns accountManager
@ -44,7 +42,7 @@ class ModeTest {
context,
onboarding,
browsingModeManager,
emitter
dispatchModeChanges
)
}
@ -101,7 +99,7 @@ class ModeTest {
currentMode.emitModeChanges()
verify { emitter.onNext(SessionControlChange.ModeChange(Mode.Normal)) }
verify { dispatchModeChanges(Mode.Normal) }
}
@Test

View File

@ -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 org.mozilla.fenix.home
import android.view.View
import io.mockk.mockk
import io.mockk.verify
import mozilla.components.feature.tab.collections.Tab
import mozilla.components.feature.tab.collections.TabCollection
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController
import org.mozilla.fenix.home.sessioncontrol.SessionControlInteractor
class SessionControlInteractorTest {
private val controller: DefaultSessionControlController = mockk(relaxed = true)
private lateinit var interactor: SessionControlInteractor
@Before
fun setup() {
interactor = SessionControlInteractor(controller)
}
@Test
fun onCloseTab() {
val sessionId = "hello"
interactor.onCloseTab(sessionId)
verify { controller.handleCloseTab(sessionId) }
}
@Test
fun onCloseAllTabs() {
val isPrivateMode = true
interactor.onCloseAllTabs(isPrivateMode)
verify { controller.handleCloseAllTabs(isPrivateMode) }
}
@Test
fun onCollectionAddTabTapped() {
val collection: TabCollection = mockk(relaxed = true)
interactor.onCollectionAddTabTapped(collection)
verify { controller.handleCollectionAddTabTapped(collection) }
}
@Test
fun onCollectionOpenTabClicked() {
val tab: Tab = mockk(relaxed = true)
interactor.onCollectionOpenTabClicked(tab)
verify { controller.handleCollectionOpenTabClicked(tab) }
}
@Test
fun onCollectionOpenTabsTapped() {
val collection: TabCollection = mockk(relaxed = true)
interactor.onCollectionOpenTabsTapped(collection)
verify { controller.handleCollectionOpenTabsTapped(collection) }
}
@Test
fun onCollectionRemoveTab() {
val collection: TabCollection = mockk(relaxed = true)
val tab: Tab = mockk(relaxed = true)
interactor.onCollectionRemoveTab(collection, tab)
verify { controller.handleCollectionRemoveTab(collection, tab) }
}
@Test
fun onCollectionShareTabsClicked() {
val collection: TabCollection = mockk(relaxed = true)
interactor.onCollectionShareTabsClicked(collection)
verify { controller.handleCollectionShareTabsClicked(collection) }
}
@Test
fun onDeleteCollectionTapped() {
val collection: TabCollection = mockk(relaxed = true)
interactor.onDeleteCollectionTapped(collection)
verify { controller.handleDeleteCollectionTapped(collection) }
}
@Test
fun onPauseMediaClicked() {
interactor.onPauseMediaClicked()
verify { controller.handlePauseMediaClicked() }
}
@Test
fun onPlayMediaClicked() {
interactor.onPlayMediaClicked()
verify { controller.handlePlayMediaClicked() }
}
@Test
fun onPrivateBrowsingLearnMoreClicked() {
interactor.onPrivateBrowsingLearnMoreClicked()
verify { controller.handlePrivateBrowsingLearnMoreClicked() }
}
@Test
fun onRenameCollectionTapped() {
val collection: TabCollection = mockk(relaxed = true)
interactor.onRenameCollectionTapped(collection)
verify { controller.handleRenameCollectionTapped(collection) }
}
@Test
fun onSaveToCollection() {
interactor.onSaveToCollection(null)
verify { controller.handleSaveTabToCollection(null) }
}
@Test
fun onSelectTab() {
val tabView: View = mockk(relaxed = true)
val sessionId = "hello"
interactor.onSelectTab(tabView, sessionId)
verify { controller.handleSelectTab(tabView, sessionId) }
}
@Test
fun onShareTabs() {
interactor.onShareTabs()
verify { controller.handleShareTabs() }
}
@Test
fun onStartBrowsingClicked() {
interactor.onStartBrowsingClicked()
verify { controller.handleStartBrowsingClicked() }
}
@Test
fun onToggleCollectionExpanded() {
val collection: TabCollection = mockk(relaxed = true)
interactor.onToggleCollectionExpanded(collection, true)
verify { controller.handleToggleCollectionExpanded(collection, true) }
}
}

View File

@ -1,49 +0,0 @@
/* 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'
apply plugin: 'kotlin-android-extensions'
android {
compileSdkVersion 28
defaultConfig {
minSdkVersion 21
targetSdkVersion 28
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
consumerProguardFiles 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
androidExtensions {
experimental = true
}
dependencies {
implementation Deps.kotlin_stdlib
implementation Deps.androidx_annotation
implementation Deps.androidx_lifecycle_livedata
implementation Deps.androidx_lifecycle_viewmodel
implementation Deps.mozilla_support_base
implementation Deps.rxAndroid
implementation Deps.rxKotlin
implementation Deps.autodispose
implementation Deps.autodispose_android
implementation Deps.autodispose_android_aac
}

View File

@ -1,22 +0,0 @@
# 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
-keep class org.mozilla.fenix.mvi.** { *; }

View File

@ -1,5 +0,0 @@
<!-- 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 xmlns:android="http://schemas.android.com/apk/res/android"
package="org.mozilla.fenix.mvi"/>

View File

@ -1,176 +0,0 @@
/* 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/. */
/*
* Copyright (C) 2018 Netflix, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* Created by Juliano Moraes, Rohan Dhruva, Emmanuel Boudrant.
*/
package org.mozilla.fenix.mvi
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.OnLifecycleEvent
import com.uber.autodispose.AutoDispose.autoDisposable
import com.uber.autodispose.ObservableSubscribeProxy
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider
import io.reactivex.Observable
import io.reactivex.Observer
import io.reactivex.rxkotlin.merge
import io.reactivex.subjects.PublishSubject
import io.reactivex.subjects.Subject
/**
* It implements a Factory pattern generating Rx Subjects based on Event Types.
* It maintain a map of Rx Subjects, one per type per instance of ActionBusFactory.
*
* @param owner is a LifecycleOwner used to auto dispose based on destroy observable
*/
class ActionBusFactory private constructor(val owner: LifecycleOwner) {
companion object {
private val buses = mutableMapOf<LifecycleOwner, ActionBusFactory>()
/**
* Return the [ActionBusFactory] associated to the [LifecycleOwner]. It there is no bus it will create one.
* If the [LifecycleOwner] used is a fragment it use [Fragment#getViewLifecycleOwner()]
*/
@JvmStatic
fun get(lifecycleOwner: LifecycleOwner): ActionBusFactory {
return with(lifecycleOwner) {
var bus = buses[lifecycleOwner]
if (bus == null) {
bus = ActionBusFactory(lifecycleOwner)
buses[lifecycleOwner] = bus
// LifecycleOwner
lifecycleOwner.lifecycle.addObserver(bus.observer)
}
bus
}
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
val map = HashMap<Class<*>, Subject<*>>()
internal val observer = object : LifecycleObserver {
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun onDestroy() {
map.forEach { entry -> entry.value.onComplete() }
buses.remove(owner)
}
}
private fun <T> create(clazz: Class<T>): Subject<T> {
val subject = PublishSubject.create<T>().toSerialized()
map[clazz] = subject
return subject
}
/**
* emit will create (if needed) or use the existing Rx Subject to send events.
*
* @param clazz is the Event Class
* @param event is the instance of the Event to be sent
*/
@Suppress("UNCHECKED_CAST")
fun <T : Action> emit(clazz: Class<T>, event: T) {
val subject = if (map[clazz] != null) map[clazz] else create(clazz)
(subject as Subject<T>).onNext(event)
}
/**x
* getSafeManagedObservable returns an Rx Observable which is
* *Safe* against reentrant events as it is serialized and
* *Managed* since it disposes itself based on the lifecycle
*
* @param clazz is the class of the event type used by this observable
*/
@Suppress("UNCHECKED_CAST")
fun <T : Action> getSafeManagedObservable(clazz: Class<T>): Observable<T> {
return if (map[clazz] != null) map[clazz] as Observable<T> else create(clazz)
}
fun <T : Action> getAutoDisposeObservable(clazz: Class<T>): ObservableSubscribeProxy<T> {
return getSafeManagedObservable(clazz)
.`as`(autoDisposable(AndroidLifecycleScopeProvider.from(owner)))
}
@Suppress("UNCHECKED_CAST")
fun <T : Action> getManagedEmitter(clazz: Class<T>): Observer<T> {
return if (map[clazz] != null) map[clazz] as Observer<T> else create(clazz)
}
fun logMergedObservables() {
// TODO make this observe new items in the map and combine them
map.values.merge().compose(logState()).subscribe()
}
/**
* getDestroyObservable observes to Lifecycle owner and fires during ON_DESTROY
*/
fun getDestroyObservable(): Observable<Unit> {
return owner.createDestroyObservable()
}
}
/**
* Extension on [LifecycleOwner] used to emit an event.
*/
inline fun <reified T : Action> LifecycleOwner.emit(event: T) =
with(ActionBusFactory.get(this)) {
getSafeManagedObservable(T::class.java)
emit(T::class.java, event)
}
/**
* Extension on [LifecycleOwner] used used to get the state observable.
*/
inline fun <reified T : Action> LifecycleOwner.getSafeManagedObservable(): Observable<T> =
ActionBusFactory.get(this).getSafeManagedObservable(T::class.java)
inline fun <reified T : Action> LifecycleOwner.getAutoDisposeObservable(): ObservableSubscribeProxy<T> =
ActionBusFactory.get(this).getAutoDisposeObservable(T::class.java)
inline fun <reified T : Action> LifecycleOwner.getManagedEmitter(): Observer<T> =
ActionBusFactory.get(this).getManagedEmitter(T::class.java)
/**
* This method returns a destroy observable that can be passed to [org.mozilla.fenix.mvi.UIView]s as needed.
*/
fun LifecycleOwner?.createDestroyObservable(): Observable<Unit> {
return Observable.create { emitter ->
if (this == null || this.lifecycle.currentState == Lifecycle.State.DESTROYED) {
emitter.onNext(Unit)
emitter.onComplete()
return@create
}
this.lifecycle.addObserver(object : LifecycleObserver {
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun emitDestroy() {
if (emitter.isDisposed) {
emitter.onNext(Unit)
emitter.onComplete()
}
}
})
}
}

View File

@ -1,49 +0,0 @@
/* 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.mvi
import io.reactivex.ObservableTransformer
import io.reactivex.subjects.Subject
import mozilla.components.support.base.log.logger.Logger
/**
* An action is a command or intent the user performed
*/
interface Action
/**
* A Change is a change to the view coming from the model
* (Extending action so we can reuse the ActionBusFactory)
*/
interface Change : Action
/**
* A ViewState is a model reflecting the current state of the view
*/
interface ViewState
/**
* A Reducer applies changes to the ViewState
*/
typealias Reducer<S, C> = (S, C) -> S
/**
* Simple logger for tracking ViewState changes
*/
fun <S> logState(): ObservableTransformer<S, S> = ObservableTransformer { observable ->
observable.doOnNext {
if (BuildConfig.DEBUG) Logger("State").debug(it.toString())
}
}
/**
* For capturing state to a Subject for testing
*/
fun <S> captureState(subject: Subject<S>):
ObservableTransformer<S, S> = ObservableTransformer { observable ->
observable.doOnNext {
subject.onNext(it)
}
}

View File

@ -1,72 +0,0 @@
/* 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.mvi
import androidx.lifecycle.ViewModel
import io.reactivex.Observable
import io.reactivex.Observer
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.rxkotlin.withLatestFrom
import io.reactivex.schedulers.Schedulers
import io.reactivex.subjects.BehaviorSubject
import io.reactivex.subjects.PublishSubject
interface UIComponentViewModel<S : ViewState, C : Change> {
val changes: Observer<C>
val state: Observable<S>
}
interface UIComponentViewModelProvider<S : ViewState, C : Change> {
fun fetchViewModel(): UIComponentViewModel<S, C>
}
abstract class UIComponent<S : ViewState, A : Action, C : Change>(
protected val actionEmitter: Observer<A>,
protected val changesObservable: Observable<C>,
private val viewModelProvider: UIComponentViewModelProvider<S, C>
) {
open val uiView: UIView<S, A, C> by lazy { initView() }
abstract fun initView(): UIView<S, A, C>
open fun getContainerId() = uiView.containerId
fun bind(): CompositeDisposable {
val compositeDisposable = CompositeDisposable()
val viewModel = viewModelProvider.fetchViewModel()
compositeDisposable.add(changesObservable.subscribe(viewModel.changes::onNext))
compositeDisposable.add(
viewModel
.state
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(uiView.updateView())
)
return compositeDisposable
}
}
abstract class UIComponentViewModelBase<S : ViewState, C : Change>(
initialState: S,
reducer: Reducer<S, C>
) : ViewModel(), UIComponentViewModel<S, C> {
final override val changes: Observer<C>
private var _state: BehaviorSubject<S> = BehaviorSubject.createDefault(initialState)
override val state: Observable<S>
get() = _state
init {
changes = PublishSubject.create<C>()
changes
.withLatestFrom(_state)
.map { reducer(it.second, it.first) }
.distinctUntilChanged()
.subscribe(_state)
}
}

View File

@ -1,54 +0,0 @@
/* 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.mvi
import android.view.View
import android.view.ViewGroup
import androidx.annotation.IdRes
import io.reactivex.Observable
import io.reactivex.Observer
import io.reactivex.functions.Consumer
import kotlinx.android.extensions.LayoutContainer
abstract class UIView<S : ViewState, A : Action, C : Change>(
private val container: ViewGroup,
protected val actionEmitter: Observer<A>,
protected val changesObservable: Observable<C>
) : LayoutContainer {
abstract val view: View
/**
* Get the XML id for the UIView
*/
@get:IdRes
val containerId: Int
get() = container.id
/**
* Provides container to empower Kotlin Android Extensions
*/
override val containerView: View?
get() = container
/**
* Show the UIView
*/
open fun show() {
view.visibility = View.VISIBLE
}
/**
* Hide the UIView
*/
open fun hide() {
view.visibility = View.GONE
}
/**
* Update the view from the ViewState
*/
abstract fun updateView(): Consumer<S>
}

View File

@ -53,7 +53,7 @@ task clean(type: Delete) {
detekt {
// The version number is duplicated, please refer to plugins block for more details
version = "1.0.0-RC16"
input = files("$projectDir/app/src", "$projectDir/architecture/src")
input = files("$projectDir/app/src")
config = files("$projectDir/config/detekt.yml")
filters = ".*test.*,.*/resources/.*,.*/tmp/.*"
@ -80,5 +80,5 @@ task ktlint(type: JavaExec, group: "verification") {
description = "Check Kotlin code style."
classpath = configurations.ktlint
main = "com.pinterest.ktlint.Main"
args "app/src/**/*.kt", "architecture/src/**/*.kt"
args "app/src/**/*.kt"
}

View File

@ -1,4 +1,4 @@
include ':app', ':architecture'
include ':app'
def log(message) {
logger.lifecycle("[settings] ${message}")

View File

@ -52,6 +52,6 @@ jobs:
description: 'Running lint over all modules'
run:
using: gradlew
gradlew: ['lintDebug', 'app:lintGeckoNightlyDebug']
gradlew: ['lintGeckoNightlyDebug']
treeherder:
symbol: lint