1
0
Fork 0

Closes #3986 & Closes #3661: Migrate QuickActionSheet to LibState & add tests (#4058)

* Closes #3986: Migrate QuickActionSheet to LibState

* Closes #3661: Add tests for QuickActionSheet

Co-authored-by: boek <jeff@jeffboek.com>

* For #3986: Fix feedback
master
Sawyer Blatz 2019-07-22 10:31:31 -07:00 committed by GitHub
parent 9d9685625a
commit 7588251f8b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 729 additions and 420 deletions

View File

@ -25,6 +25,7 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.whenStarted
import androidx.navigation.fragment.NavHostFragment.findNavController
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.Snackbar
@ -34,8 +35,10 @@ import kotlinx.android.synthetic.main.fragment_browser.view.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import mozilla.appservices.places.BookmarkRoot
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager
@ -53,6 +56,7 @@ import mozilla.components.feature.session.ThumbnailsFeature
import mozilla.components.feature.sitepermissions.SitePermissions
import mozilla.components.feature.sitepermissions.SitePermissionsFeature
import mozilla.components.feature.sitepermissions.SitePermissionsRules
import mozilla.components.lib.state.ext.observe
import mozilla.components.support.base.feature.BackHandler
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
import mozilla.components.support.ktx.android.view.exitImmersiveModeIfNeeded
@ -63,11 +67,13 @@ import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.IntentReceiverActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.ThemeManager
import org.mozilla.fenix.browser.readermode.DefaultReaderModeController
import org.mozilla.fenix.collections.CreateCollectionViewModel
import org.mozilla.fenix.collections.SaveCollectionStep
import org.mozilla.fenix.collections.getStepForCollectionsSize
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.FindInPageIntegration
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.components.TabCollectionStorage
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.Event.BrowserMenuItemTapped.Item
@ -90,14 +96,13 @@ 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.quickactionsheet.QuickActionAction
import org.mozilla.fenix.quickactionsheet.QuickActionChange
import org.mozilla.fenix.quickactionsheet.QuickActionComponent
import org.mozilla.fenix.quickactionsheet.QuickActionInteractor
import org.mozilla.fenix.quickactionsheet.QuickActionSheetAction
import org.mozilla.fenix.quickactionsheet.QuickActionSheetBehavior
import org.mozilla.fenix.quickactionsheet.QuickActionState
import org.mozilla.fenix.quickactionsheet.QuickActionViewModel
import org.mozilla.fenix.quickactionsheet.QuickActionSheetState
import org.mozilla.fenix.quickactionsheet.QuickActionSheetStore
import org.mozilla.fenix.quickactionsheet.QuickActionView
import org.mozilla.fenix.settings.SupportUtils
import org.mozilla.fenix.utils.ItsNotBrokenSnack
import org.mozilla.fenix.utils.Settings
import java.net.MalformedURLException
import java.net.URL
@ -105,6 +110,7 @@ import java.net.URL
@SuppressWarnings("TooManyFunctions", "LargeClass")
class BrowserFragment : Fragment(), BackHandler {
private lateinit var toolbarComponent: ToolbarComponent
private lateinit var quickActionSheetStore: QuickActionSheetStore
private var tabCollectionObserver: Observer<List<TabCollection>>? = null
private var sessionObserver: Session.Observer? = null
@ -164,26 +170,6 @@ class BrowserFragment : Fragment(), BackHandler {
startPostponedEnterTransition()
QuickActionComponent(
view.nestedScrollQuickAction,
ActionBusFactory.get(this),
FenixViewModelProvider.create(
this,
QuickActionViewModel::class.java
) {
val appLink = requireComponents.useCases.appLinksUseCases.appLinkRedirect
QuickActionViewModel(
QuickActionState(
readable = getSessionById()?.readerable ?: false,
bookmarked = findBookmarkedURL(getSessionById()),
readerActive = getSessionById()?.readerMode ?: false,
bounceNeeded = false,
isAppLink = getSessionById()?.let { appLink.invoke(it.url).hasExternalApp() } ?: false
)
)
}
)
val activity = activity as HomeActivity
ThemeManager.applyStatusBarTheme(activity.window, activity.themeManager, activity)
@ -372,18 +358,14 @@ class BrowserFragment : Fragment(), BackHandler {
requireComponents.core.engine,
requireComponents.core.sessionManager,
view.readerViewControlsBar
) {
getManagedEmitter<QuickActionChange>().apply {
if (it) {
requireComponents.analytics.metrics.track(Event.ReaderModeAvailable)
}
) { available ->
if (available) { requireComponents.analytics.metrics.track(Event.ReaderModeAvailable) }
onNext(QuickActionChange.ReadableStateChange(it))
onNext(
QuickActionChange.ReaderActiveStateChange(
sessionManager.selectedSession?.readerMode ?: false
)
)
quickActionSheetStore.apply {
dispatch(QuickActionSheetAction.ReadableStateChange(available))
dispatch(QuickActionSheetAction.ReaderActiveStateChange(
sessionManager.selectedSession?.readerMode ?: false
))
}
},
owner = this,
@ -411,6 +393,42 @@ class BrowserFragment : Fragment(), BackHandler {
toolbarComponent.getView().setOnSiteSecurityClickedListener {
showQuickSettingsDialog()
}
val appLink = requireComponents.useCases.appLinksUseCases.appLinkRedirect
quickActionSheetStore = StoreProvider.get(this) {
QuickActionSheetStore(
QuickActionSheetState(
readable = getSessionById()?.readerable ?: false,
bookmarked = findBookmarkedURL(getSessionById()),
readerActive = getSessionById()?.readerMode ?: false,
bounceNeeded = false,
isAppLink = getSessionById()?.let { appLink.invoke(it.url).hasExternalApp() } ?: false
)
)
}
val quickActionSheetView = QuickActionView(
view.nestedScrollQuickAction,
QuickActionInteractor(
context!!,
DefaultReaderModeController(readerViewFeature),
quickActionSheetStore,
shareUrl = ::shareUrl,
bookmarkTapped = {
lifecycleScope.launch { bookmarkTapped(it) }
},
appLinksUseCases = requireComponents.useCases.appLinksUseCases
)
)
quickActionSheetStore.observe(view) {
viewLifecycleOwner.lifecycleScope.launch {
whenStarted {
quickActionSheetView.update(it)
}
}
}
}
private fun themeReaderViewControlsForPrivateMode(view: View) = with(view) {
@ -495,115 +513,46 @@ class BrowserFragment : Fragment(), BackHandler {
}
}
}
getAutoDisposeObservable<QuickActionAction>()
.subscribe {
when (it) {
is QuickActionAction.Opened -> {
requireComponents.analytics.metrics.track(Event.QuickActionSheetOpened)
}
is QuickActionAction.Closed -> {
requireComponents.analytics.metrics.track(Event.QuickActionSheetClosed)
}
is QuickActionAction.SharePressed -> {
requireComponents.analytics.metrics.track(Event.QuickActionSheetShareTapped)
getSessionById()?.let { session ->
shareUrl(session.url)
}
}
is QuickActionAction.DownloadsPressed -> {
requireComponents.analytics.metrics.track(Event.QuickActionSheetDownloadTapped)
ItsNotBrokenSnack(context!!).showSnackbar(issueNumber = "348")
}
is QuickActionAction.BookmarkPressed -> {
requireComponents.analytics.metrics.track(Event.QuickActionSheetBookmarkTapped)
bookmarkTapped()
}
is QuickActionAction.ReadPressed -> {
readerViewFeature.withFeature { feature ->
requireComponents.analytics.metrics.track(Event.QuickActionSheetReadTapped)
val actionEmitter = getManagedEmitter<QuickActionChange>()
val enabled = requireComponents.core.sessionManager.selectedSession?.readerMode ?: false
if (enabled) {
feature.hideReaderView()
actionEmitter.onNext(QuickActionChange.ReaderActiveStateChange(false))
} else {
feature.showReaderView()
actionEmitter.onNext(QuickActionChange.ReaderActiveStateChange(true))
requireComponents.analytics.metrics.track(Event.ReaderModeOpened)
}
}
}
is QuickActionAction.ReadAppearancePressed -> {
requireComponents.analytics.metrics.track(Event.ReaderModeAppearanceOpened)
readerViewFeature.withFeature { feature ->
feature.showControls()
}
}
is QuickActionAction.OpenAppLinkPressed -> {
appLinksFeature.withFeature { feature ->
val getRedirect = requireComponents.useCases.appLinksUseCases.appLinkRedirect
val redirect = getSessionById()?.let { session ->
getRedirect.invoke(session.url)
} ?: return@withFeature
redirect.appIntent?.flags = Intent.FLAG_ACTIVITY_NEW_TASK
val openAppLink = requireComponents.useCases.appLinksUseCases.openAppLink
openAppLink.invoke(redirect)
}
}
}
}
assignSitePermissionsRules()
}
private fun bookmarkTapped() {
getSessionById()?.let { session ->
lifecycleScope.launch(IO) {
val bookmarksStorage = requireComponents.core.bookmarksStorage
val existing = bookmarksStorage.getBookmarksWithUrl(session.url)
val found = existing.isNotEmpty() && existing[0].url == session.url
if (found) {
launch(Main) {
nav(
R.id.browserFragment,
BrowserFragmentDirections
.actionBrowserFragmentToBookmarkEditFragment(existing[0].guid)
)
}
} else {
val guid = bookmarksStorage.addItem(
BookmarkRoot.Mobile.id,
session.url,
session.title,
null
)
launch(Main) {
getManagedEmitter<QuickActionChange>()
.onNext(QuickActionChange.BookmarkedStateChange(true))
requireComponents.analytics.metrics.track(Event.AddBookmark)
view?.let {
FenixSnackbar.make(
it.rootView,
Snackbar.LENGTH_LONG
private suspend fun bookmarkTapped(session: Session) = withContext(IO) {
val bookmarksStorage = requireComponents.core.bookmarksStorage
val existing = bookmarksStorage.getBookmarksWithUrl(session.url).firstOrNull { it.url == session.url }
if (existing != null) {
// Bookmark exists, go to edit fragment
withContext(Main) {
nav(
R.id.browserFragment,
BrowserFragmentDirections.actionBrowserFragmentToBookmarkEditFragment(existing.guid)
)
}
} else {
// Save bookmark, then go to edit fragment
val guid = bookmarksStorage.addItem(
BookmarkRoot.Mobile.id,
url = session.url,
title = session.title,
position = null
)
withContext(Main) {
quickActionSheetStore.dispatch(
QuickActionSheetAction.BookmarkedStateChange(bookmarked = true)
)
requireComponents.analytics.metrics.track(Event.AddBookmark)
view?.let {
FenixSnackbar.make(it.rootView, Snackbar.LENGTH_LONG)
.setAnchorView(toolbarComponent.uiView.view)
.setAction(getString(R.string.edit_bookmark_snackbar_action)) {
nav(
R.id.browserFragment,
BrowserFragmentDirections.actionBrowserFragmentToBookmarkEditFragment(guid)
)
.setAnchorView(toolbarComponent.uiView.view)
.setAction(getString(R.string.edit_bookmark_snackbar_action)) {
nav(
R.id.browserFragment,
BrowserFragmentDirections
.actionBrowserFragmentToBookmarkEditFragment(
guid
)
)
}
.setText(getString(R.string.bookmark_saved_snackbar))
.show()
}
}
.setText(getString(R.string.bookmark_saved_snackbar))
.show()
}
}
}
@ -881,7 +830,7 @@ class BrowserFragment : Fragment(), BackHandler {
override fun onLoadingStateChanged(session: Session, loading: Boolean) {
if (!loading) {
updateBookmarkState(session)
getManagedEmitter<QuickActionChange>().onNext(QuickActionChange.BounceNeededChange)
quickActionSheetStore.dispatch(QuickActionSheetAction.BounceNeededChange)
}
super.onLoadingStateChanged(session, loading)
@ -923,12 +872,11 @@ class BrowserFragment : Fragment(), BackHandler {
}
private fun updateBookmarkState(session: Session) {
if (findBookmarkJob?.isActive == true) findBookmarkJob?.cancel()
findBookmarkJob?.cancel()
findBookmarkJob = lifecycleScope.launch(IO) {
val found = findBookmarkedURL(session)
launch(Main) {
getManagedEmitter<QuickActionChange>()
.onNext(QuickActionChange.BookmarkedStateChange(found))
withContext(Main) {
quickActionSheetStore.dispatch(QuickActionSheetAction.BookmarkedStateChange(found))
}
}
}
@ -936,8 +884,7 @@ class BrowserFragment : Fragment(), BackHandler {
private fun updateAppLinksState(session: Session) {
val url = session.url
val appLinks = requireComponents.useCases.appLinksUseCases.appLinkRedirect
getManagedEmitter<QuickActionChange>()
.onNext(QuickActionChange.AppLinkStateChange(appLinks.invoke(url).hasExternalApp()))
quickActionSheetStore.dispatch(QuickActionSheetAction.AppLinkStateChange(appLinks.invoke(url).hasExternalApp()))
}
private val collectionStorageObserver = object : TabCollectionStorage.Observer {

View File

@ -0,0 +1,33 @@
/* 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.browser.readermode
import mozilla.components.feature.readerview.ReaderViewFeature
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
/**
* An interface that exposes the hide and show reader view functions of a ReaderViewFeature
*/
interface ReaderModeController {
fun hideReaderView()
fun showReaderView()
fun showControls()
}
class DefaultReaderModeController(
private val readerViewFeature: ViewBoundFeatureWrapper<ReaderViewFeature>
) : ReaderModeController {
override fun hideReaderView() {
readerViewFeature.withFeature { it.hideReaderView() }
}
override fun showReaderView() {
readerViewFeature.withFeature { it.showReaderView() }
}
override fun showControls() {
readerViewFeature.withFeature { it.showControls() }
}
}

View File

@ -1,86 +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.quickactionsheet
import android.view.ViewGroup
import org.mozilla.fenix.mvi.Action
import org.mozilla.fenix.mvi.ActionBusFactory
import org.mozilla.fenix.mvi.UIComponentViewModelProvider
import org.mozilla.fenix.mvi.ViewState
import org.mozilla.fenix.mvi.Change
import org.mozilla.fenix.mvi.Reducer
import org.mozilla.fenix.mvi.UIComponent
import org.mozilla.fenix.mvi.UIComponentViewModelBase
import org.mozilla.fenix.mvi.UIView
class QuickActionComponent(
private val container: ViewGroup,
bus: ActionBusFactory,
viewModelProvider: UIComponentViewModelProvider<QuickActionState, QuickActionChange>
) : UIComponent<QuickActionState, QuickActionAction, QuickActionChange>(
bus.getManagedEmitter(QuickActionAction::class.java),
bus.getSafeManagedObservable(QuickActionChange::class.java),
viewModelProvider
) {
override fun initView(): UIView<QuickActionState, QuickActionAction, QuickActionChange> =
QuickActionUIView(container, actionEmitter, changesObservable)
init {
bind()
}
}
data class QuickActionState(
val readable: Boolean,
val bookmarked: Boolean,
val readerActive: Boolean,
val bounceNeeded: Boolean,
val isAppLink: Boolean
) : ViewState
sealed class QuickActionAction : Action {
object Opened : QuickActionAction()
object Closed : QuickActionAction()
object SharePressed : QuickActionAction()
object DownloadsPressed : QuickActionAction()
object BookmarkPressed : QuickActionAction()
object ReadPressed : QuickActionAction()
object ReadAppearancePressed : QuickActionAction()
object OpenAppLinkPressed : QuickActionAction()
}
sealed class QuickActionChange : Change {
data class BookmarkedStateChange(val bookmarked: Boolean) : QuickActionChange()
data class ReadableStateChange(val readable: Boolean) : QuickActionChange()
data class ReaderActiveStateChange(val active: Boolean) : QuickActionChange()
data class AppLinkStateChange(val isAppLink: Boolean) : QuickActionChange()
object BounceNeededChange : QuickActionChange()
}
class QuickActionViewModel(
initialState: QuickActionState
) : UIComponentViewModelBase<QuickActionState, QuickActionChange>(initialState, reducer) {
companion object {
val reducer: Reducer<QuickActionState, QuickActionChange> = { state, change ->
when (change) {
is QuickActionChange.BounceNeededChange -> {
state.copy(bounceNeeded = true)
}
is QuickActionChange.BookmarkedStateChange -> {
state.copy(bookmarked = change.bookmarked)
}
is QuickActionChange.ReadableStateChange -> {
state.copy(readable = change.readable)
}
is QuickActionChange.ReaderActiveStateChange -> {
state.copy(readerActive = change.active)
}
is QuickActionChange.AppLinkStateChange -> {
state.copy(isAppLink = change.isAppLink)
}
}
}
}
}

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.quickactionsheet
import android.content.Context
import android.content.Intent
import androidx.annotation.CallSuper
import mozilla.components.browser.session.Session
import mozilla.components.feature.app.links.AppLinksUseCases
import org.mozilla.fenix.browser.readermode.ReaderModeController
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.metrics
import org.mozilla.fenix.utils.ItsNotBrokenSnack
/**
* Interactor for the QuickActionSheet
*/
class QuickActionInteractor(
private val context: Context,
private val readerModeController: ReaderModeController,
private val quickActionStore: QuickActionSheetStore,
private val shareUrl: (String) -> Unit,
private val bookmarkTapped: (Session) -> Unit,
private val appLinksUseCases: AppLinksUseCases
) : QuickActionSheetInteractor {
private val selectedSession
inline get() = context.components.core.sessionManager.selectedSession
@CallSuper
override fun onOpened() {
context.metrics.track(Event.QuickActionSheetOpened)
}
@CallSuper
override fun onClosed() {
context.metrics.track(Event.QuickActionSheetClosed)
}
@CallSuper
override fun onSharedPressed() {
context.metrics.track(Event.QuickActionSheetShareTapped)
selectedSession?.url?.let(shareUrl)
}
@CallSuper
override fun onDownloadsPressed() {
context.metrics.track(Event.QuickActionSheetDownloadTapped)
ItsNotBrokenSnack(context).showSnackbar(issueNumber = "348")
}
@CallSuper
override fun onBookmarkPressed() {
context.metrics.track(Event.QuickActionSheetBookmarkTapped)
selectedSession?.let(bookmarkTapped)
}
@CallSuper
override fun onReadPressed() {
context.metrics.track(Event.QuickActionSheetReadTapped)
val enabled = selectedSession?.readerMode ?: false
if (enabled) {
readerModeController.hideReaderView()
} else {
readerModeController.showReaderView()
}
quickActionStore.dispatch(QuickActionSheetAction.ReaderActiveStateChange(!enabled))
}
@CallSuper
override fun onOpenAppLinkPressed() {
val getRedirect = appLinksUseCases.appLinkRedirect
val redirect = selectedSession?.let {
getRedirect.invoke(it.url)
} ?: return
redirect.appIntent?.flags = Intent.FLAG_ACTIVITY_NEW_TASK
appLinksUseCases.openAppLink.invoke(redirect)
}
@CallSuper
override fun onAppearancePressed() {
// TODO telemetry: https://github.com/mozilla-mobile/fenix/issues/2267
readerModeController.showControls()
}
}

View File

@ -5,26 +5,24 @@
package org.mozilla.fenix.quickactionsheet
import android.content.Context
import android.os.Bundle
import android.util.AttributeSet
import android.view.View
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import android.widget.LinearLayout
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.widget.NestedScrollView
import com.google.android.material.bottomsheet.BottomSheetBehavior
import mozilla.components.browser.toolbar.BrowserToolbar
import org.mozilla.fenix.R
import android.os.Bundle
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import kotlinx.android.synthetic.main.layout_quick_action_sheet.view.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import org.mozilla.fenix.utils.Settings
import kotlin.coroutines.CoroutineContext
const val POSITION_SNAP_BUFFER = 1f
@ -33,21 +31,18 @@ class QuickActionSheet @JvmOverloads constructor(
attrs: AttributeSet? = null,
defStyle: Int = 0,
defStyleRes: Int = 0
) : LinearLayout(context, attrs, defStyle, defStyleRes), CoroutineScope {
) : LinearLayout(context, attrs, defStyle, defStyleRes) {
private lateinit var job: Job
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
private val scope = MainScope()
private lateinit var quickActionSheetBehavior: QuickActionSheetBehavior
init {
inflate(getContext(), R.layout.layout_quick_action_sheet, this)
inflate(context, R.layout.layout_quick_action_sheet, this)
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
job = Job()
quickActionSheetBehavior = BottomSheetBehavior.from(quick_action_sheet.parent as View)
as QuickActionSheetBehavior
quickActionSheetBehavior.isHideable = false
@ -56,7 +51,7 @@ class QuickActionSheet @JvmOverloads constructor(
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
job.cancel()
scope.cancel()
}
private fun setupHandle() {
@ -71,13 +66,13 @@ class QuickActionSheet @JvmOverloads constructor(
}
fun bounceSheet() {
launch(Main) {
Settings.getInstance(context).incrementAutomaticBounceQuickActionSheetCount()
scope.launch(Dispatchers.Main) {
delay(BOUNCE_ANIMATION_DELAY_LENGTH)
quickActionSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
delay(BOUNCE_ANIMATION_PAUSE_LENGTH)
quickActionSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
Settings.getInstance(context).incrementAutomaticBounceQuickActionSheetCount()
}
class HandleAccessibilityDelegate(
@ -98,17 +93,16 @@ class QuickActionSheet @JvmOverloads constructor(
}
override fun performAccessibilityAction(host: View?, action: Int, args: Bundle?): Boolean {
when (action) {
AccessibilityNodeInfo.ACTION_CLICK -> {
finalState = when (quickActionSheetBehavior.state) {
finalState = when (action) {
AccessibilityNodeInfo.ACTION_CLICK ->
when (quickActionSheetBehavior.state) {
BottomSheetBehavior.STATE_EXPANDED -> BottomSheetBehavior.STATE_COLLAPSED
else -> BottomSheetBehavior.STATE_EXPANDED
}
}
AccessibilityNodeInfo.ACTION_COLLAPSE ->
finalState = BottomSheetBehavior.STATE_COLLAPSED
BottomSheetBehavior.STATE_COLLAPSED
AccessibilityNodeInfo.ACTION_EXPAND ->
finalState = BottomSheetBehavior.STATE_EXPANDED
BottomSheetBehavior.STATE_EXPANDED
else -> return super.performAccessibilityAction(host, action, args)
}
@ -133,7 +127,6 @@ class QuickActionSheet @JvmOverloads constructor(
}
}
@Suppress("unused") // Referenced from XML
class QuickActionSheetBehavior(
context: Context,
attrs: AttributeSet
@ -168,4 +161,9 @@ class QuickActionSheetBehavior(
}
quickActionSheetContainer.translationY = toolbar.translationY + toolbar.height * -1.0f
}
companion object {
fun from(view: NestedScrollView) =
BottomSheetBehavior.from(view) as QuickActionSheetBehavior
}
}

View File

@ -0,0 +1,63 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.quickactionsheet
import mozilla.components.lib.state.Action
import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
/**
* The [Store] for holding the [QuickActionSheetState] and applying [QuickActionSheetAction]s.
*/
class QuickActionSheetStore(initialState: QuickActionSheetState) :
Store<QuickActionSheetState, QuickActionSheetAction>(initialState, ::quickActionSheetStateReducer)
/**
* The state for the QuickActionSheet found in the Browser Fragment
* @property readable Whether or not the current session can display a reader view
* @property bookmarked Whether or not the current session is already bookmarked
* @property readerActive Whether or not the current session is in reader mode
* @property bounceNeeded Whether or not the quick action sheet should bounce
*/
data class QuickActionSheetState(
val readable: Boolean,
val bookmarked: Boolean,
val readerActive: Boolean,
val bounceNeeded: Boolean,
val isAppLink: Boolean
) : State
/**
* Actions to dispatch through the [QuickActionSheetStore] to modify [QuickActionSheetState] through the reducer.
*/
sealed class QuickActionSheetAction : Action {
data class BookmarkedStateChange(val bookmarked: Boolean) : QuickActionSheetAction()
data class ReadableStateChange(val readable: Boolean) : QuickActionSheetAction()
data class ReaderActiveStateChange(val active: Boolean) : QuickActionSheetAction()
data class AppLinkStateChange(val isAppLink: Boolean) : QuickActionSheetAction()
object BounceNeededChange : QuickActionSheetAction()
}
/**
* Reduces [QuickActionSheetAction]s to update [QuickActionSheetState].
*/
fun quickActionSheetStateReducer(
state: QuickActionSheetState,
action: QuickActionSheetAction
): QuickActionSheetState {
return when (action) {
is QuickActionSheetAction.BookmarkedStateChange ->
state.copy(bookmarked = action.bookmarked)
is QuickActionSheetAction.ReadableStateChange ->
state.copy(readable = action.readable)
is QuickActionSheetAction.ReaderActiveStateChange ->
state.copy(readerActive = action.active)
is QuickActionSheetAction.BounceNeededChange ->
state.copy(bounceNeeded = true)
is QuickActionSheetAction.AppLinkStateChange -> {
state.copy(isAppLink = action.isAppLink)
}
}
}

View File

@ -1,161 +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.quickactionsheet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.annotation.DrawableRes
import androidx.core.content.edit
import androidx.core.widget.NestedScrollView
import com.google.android.material.bottomsheet.BottomSheetBehavior
import io.reactivex.Observable
import io.reactivex.Observer
import io.reactivex.functions.Consumer
import kotlinx.android.synthetic.main.fragment_browser.*
import kotlinx.android.synthetic.main.layout_quick_action_sheet.*
import kotlinx.android.synthetic.main.layout_quick_action_sheet.view.*
import mozilla.components.support.ktx.android.view.putCompoundDrawablesRelativeWithIntrinsicBounds
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.mvi.UIView
import org.mozilla.fenix.utils.Settings
class QuickActionUIView(
container: ViewGroup,
actionEmitter: Observer<QuickActionAction>,
changesObservable: Observable<QuickActionChange>
) : UIView<QuickActionState, QuickActionAction, QuickActionChange>(container, actionEmitter, changesObservable) {
override val view: NestedScrollView = LayoutInflater.from(container.context)
.inflate(R.layout.component_quick_action_sheet, container, true)
.findViewById(R.id.nestedScrollQuickAction) as NestedScrollView
val quickActionSheet = view.quick_action_sheet as QuickActionSheet
init {
val quickActionSheetBehavior =
BottomSheetBehavior.from(nestedScrollQuickAction as View) as QuickActionSheetBehavior
quickActionSheetBehavior.setBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(v: View, state: Int) {
updateImportantForAccessibility(state)
if (state == BottomSheetBehavior.STATE_EXPANDED) {
actionEmitter.onNext(QuickActionAction.Opened)
} else if (state == BottomSheetBehavior.STATE_COLLAPSED) {
actionEmitter.onNext(QuickActionAction.Closed)
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
animateOverlay(slideOffset)
}
})
updateImportantForAccessibility(quickActionSheetBehavior.state)
view.quick_action_share.setOnClickListener {
actionEmitter.onNext(QuickActionAction.SharePressed)
quickActionSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
view.quick_action_downloads.setOnClickListener {
actionEmitter.onNext(QuickActionAction.DownloadsPressed)
quickActionSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
view.quick_action_bookmark.setOnClickListener {
actionEmitter.onNext(QuickActionAction.BookmarkPressed)
quickActionSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
view.quick_action_read.setOnClickListener {
actionEmitter.onNext(QuickActionAction.ReadPressed)
quickActionSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
view.quick_action_read_appearance.setOnClickListener {
actionEmitter.onNext(QuickActionAction.ReadAppearancePressed)
quickActionSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
view.quick_action_open_app_link.setOnClickListener {
actionEmitter.onNext(QuickActionAction.OpenAppLinkPressed)
quickActionSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
}
/**
* Changes alpha of overlay based on new offset of this sheet within [-1,1] range.
*/
private fun animateOverlay(offset: Float) {
overlay.alpha = (1 - offset)
}
private fun updateImportantForAccessibility(state: Int) {
view.findViewById<LinearLayout>(R.id.quick_action_buttons_layout).importantForAccessibility =
if (state == BottomSheetBehavior.STATE_COLLAPSED || state == BottomSheetBehavior.STATE_HIDDEN)
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
else
View.IMPORTANT_FOR_ACCESSIBILITY_AUTO
}
private fun sendTelemetryEvent(state: Int) {
when (state) {
BottomSheetBehavior.STATE_EXPANDED ->
view.context.components.analytics.metrics.track(Event.QuickActionSheetOpened)
BottomSheetBehavior.STATE_COLLAPSED ->
view.context.components.analytics.metrics.track(Event.QuickActionSheetClosed)
}
}
@Suppress("ComplexMethod")
override fun updateView() = Consumer<QuickActionState> {
view.quick_action_read.apply {
visibility = if (it.readable) View.VISIBLE else View.GONE
val shouldNotify = Settings.getInstance(context).preferences
.getBoolean(context.getString(R.string.pref_key_reader_mode_notification), true)
updateReaderModeButton(it.readable && shouldNotify)
isSelected = it.readerActive
text = if (it.readerActive) {
context.getString(R.string.quick_action_read_close)
} else {
context.getString(R.string.quick_action_read)
}
}
view.quick_action_read_appearance.visibility = if (it.readerActive) View.VISIBLE else View.GONE
view.quick_action_bookmark.isSelected = it.bookmarked
view.quick_action_bookmark.text = if (it.bookmarked) {
view.context.getString(R.string.quick_action_bookmark_edit)
} else {
view.context.getString(R.string.quick_action_bookmark)
}
if (it.bounceNeeded && Settings.getInstance(view.context).shouldAutoBounceQuickActionSheet) {
quickActionSheet.bounceSheet()
}
view.quick_action_open_app_link.apply {
visibility = if (it.isAppLink) View.VISIBLE else View.GONE
}
}
private fun updateReaderModeButton(withNotification: Boolean) {
@DrawableRes
val readerTwoStateDrawableId = if (withNotification) {
quickActionSheet.bounceSheet()
Settings.getInstance(view.context).preferences.edit {
putBoolean(view.context.getString(R.string.pref_key_reader_mode_notification), false)
}
R.drawable.reader_two_state_with_notification
} else {
R.drawable.reader_two_state
}
view.quick_action_read.putCompoundDrawablesRelativeWithIntrinsicBounds(
top = view.context.getDrawable(readerTwoStateDrawableId)
)
}
}

View File

@ -0,0 +1,152 @@
/* 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.quickactionsheet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.DrawableRes
import androidx.core.content.edit
import androidx.core.view.isVisible
import androidx.core.widget.NestedScrollView
import com.google.android.material.bottomsheet.BottomSheetBehavior
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.fragment_browser.*
import kotlinx.android.synthetic.main.layout_quick_action_sheet.*
import kotlinx.android.synthetic.main.layout_quick_action_sheet.view.*
import mozilla.components.support.ktx.android.view.putCompoundDrawablesRelativeWithIntrinsicBounds
import org.mozilla.fenix.R
import org.mozilla.fenix.utils.Settings
interface QuickActionSheetInteractor {
fun onOpened()
fun onClosed()
fun onSharedPressed()
fun onDownloadsPressed()
fun onBookmarkPressed()
fun onReadPressed()
fun onAppearancePressed()
fun onOpenAppLinkPressed()
}
/**
* View for the quick action sheet that slides out from the toolbar.
*/
class QuickActionView(
override val containerView: ViewGroup,
private val interactor: QuickActionSheetInteractor
) : LayoutContainer, View.OnClickListener {
val view: NestedScrollView = LayoutInflater.from(containerView.context)
.inflate(R.layout.component_quick_action_sheet, containerView, true)
.findViewById(R.id.nestedScrollQuickAction)
private val quickActionSheet = view.quick_action_sheet as QuickActionSheet
private val quickActionSheetBehavior = QuickActionSheetBehavior.from(nestedScrollQuickAction)
init {
quickActionSheetBehavior.setBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(v: View, state: Int) {
updateImportantForAccessibility(state)
if (state == BottomSheetBehavior.STATE_EXPANDED) {
interactor.onOpened()
} else if (state == BottomSheetBehavior.STATE_COLLAPSED) {
interactor.onClosed()
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
animateOverlay(slideOffset)
}
})
updateImportantForAccessibility(quickActionSheetBehavior.state)
view.quick_action_share.setOnClickListener(this)
view.quick_action_downloads.setOnClickListener(this)
view.quick_action_bookmark.setOnClickListener(this)
view.quick_action_read.setOnClickListener(this)
view.quick_action_appearance.setOnClickListener(this)
view.quick_action_open_app_link.setOnClickListener(this)
}
/**
* Handles clicks from quick action buttons
*/
override fun onClick(button: View) {
when (button.id) {
R.id.quick_action_share -> interactor.onSharedPressed()
R.id.quick_action_downloads -> interactor.onDownloadsPressed()
R.id.quick_action_bookmark -> interactor.onBookmarkPressed()
R.id.quick_action_read -> interactor.onReadPressed()
R.id.quick_action_appearance -> interactor.onAppearancePressed()
R.id.quick_action_open_app_link -> interactor.onOpenAppLinkPressed()
else -> return
}
quickActionSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
/**
* Changes alpha of overlay based on new offset of this sheet within [-1,1] range.
*/
private fun animateOverlay(offset: Float) {
overlay.alpha = (1 - offset)
}
/**
* Updates the important for accessibility flag on the buttons container,
* depending on if the sheet is opened or closed.
*/
private fun updateImportantForAccessibility(state: Int) {
view.quick_action_buttons_layout.importantForAccessibility = when (state) {
BottomSheetBehavior.STATE_COLLAPSED, BottomSheetBehavior.STATE_HIDDEN ->
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
else ->
View.IMPORTANT_FOR_ACCESSIBILITY_AUTO
}
}
fun update(state: QuickActionSheetState) {
view.quick_action_read.isVisible = state.readable
view.quick_action_read.isSelected = state.readerActive
view.quick_action_read.text = view.context.getString(
if (state.readerActive) R.string.quick_action_read_close else R.string.quick_action_read
)
notifyReaderModeButton(state.readable)
view.quick_action_appearance.isVisible = state.readerActive
view.quick_action_bookmark.isSelected = state.bookmarked
view.quick_action_bookmark.text = view.context.getString(
if (state.bookmarked) R.string.quick_action_bookmark_edit else R.string.quick_action_bookmark
)
if (state.bounceNeeded && Settings.getInstance(view.context).shouldAutoBounceQuickActionSheet) {
quickActionSheet.bounceSheet()
}
view.quick_action_open_app_link.apply {
visibility = if (state.isAppLink) View.VISIBLE else View.GONE
}
}
private fun notifyReaderModeButton(readable: Boolean) {
val settings = Settings.getInstance(view.context).preferences
val shouldNotifyKey = view.context.getString(R.string.pref_key_reader_mode_notification)
@DrawableRes
val readerTwoStateDrawableRes = if (readable && settings.getBoolean(shouldNotifyKey, true)) {
quickActionSheet.bounceSheet()
settings.edit { putBoolean(shouldNotifyKey, false) }
R.drawable.reader_two_state_with_notification
} else {
R.drawable.reader_two_state
}
view.quick_action_read.putCompoundDrawablesRelativeWithIntrinsicBounds(
top = view.context.getDrawable(readerTwoStateDrawableRes)
)
}
}

View File

@ -78,7 +78,7 @@
android:textSize="12sp" />
<Button
android:id="@+id/quick_action_read_appearance"
android:id="@+id/quick_action_appearance"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"

View File

@ -0,0 +1,274 @@
/* 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.quickactionsheet
import android.content.Context
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.verify
import junit.framework.Assert.assertEquals
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager
import mozilla.components.feature.app.links.AppLinkRedirect
import mozilla.components.feature.app.links.AppLinksUseCases
import org.junit.Test
import org.mozilla.fenix.browser.readermode.ReaderModeController
import org.mozilla.fenix.components.Analytics
import org.mozilla.fenix.components.Components
import org.mozilla.fenix.components.Core
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.metrics
class QuickActionInteractorTest {
@Test
fun onOpened() {
val context: Context = mockk()
val metrics: MetricController = mockk()
val interactor = QuickActionInteractor(
context,
mockk(),
mockk(),
mockk(),
mockk(),
mockk()
)
every { context.metrics } returns metrics
every { metrics.track(Event.QuickActionSheetOpened) } just Runs
interactor.onOpened()
verify { metrics.track(Event.QuickActionSheetOpened) }
}
@Test
fun onClosed() {
val context: Context = mockk()
val metrics: MetricController = mockk()
val interactor = QuickActionInteractor(
context,
mockk(),
mockk(),
mockk(),
mockk(),
mockk()
)
every { context.metrics } returns metrics
every { metrics.track(Event.QuickActionSheetClosed) } just Runs
interactor.onClosed()
verify { metrics.track(Event.QuickActionSheetClosed) }
}
@Test
fun onSharedPressed() {
val context: Context = mockk()
val session: Session = mockk()
var selectedSessionUrl = ""
val metrics: MetricController = mockk()
val interactor = QuickActionInteractor(
context,
mockk(),
mockk(),
{ selectedSessionUrl = it },
mockk(),
mockk()
)
val components: Components = mockk()
val core: Core = mockk()
val sessionManager: SessionManager = mockk()
val analytics: Analytics = mockk()
every { session.url } returns "mozilla.org"
every { context.components } returns components
every { components.analytics } returns analytics
every { metrics.track(Event.QuickActionSheetShareTapped) } just Runs
// Since we are mocking components, we must manually define metrics as `analytics.metrics`
every { analytics.metrics } returns metrics
every { components.core } returns core
every { core.sessionManager } returns sessionManager
every { sessionManager.selectedSession } returns session
interactor.onSharedPressed()
verify { metrics.track(Event.QuickActionSheetShareTapped) }
assertEquals("mozilla.org", selectedSessionUrl)
}
@Test
fun onDownloadsPressed() {
val context: Context = mockk()
val metrics: MetricController = mockk()
val interactor = QuickActionInteractor(
context,
mockk(),
mockk(),
mockk(),
mockk(),
mockk()
)
every { context.metrics } returns metrics
every { metrics.track(Event.QuickActionSheetDownloadTapped) } just Runs
interactor.onDownloadsPressed()
verify { metrics.track(Event.QuickActionSheetDownloadTapped) }
}
@Test
fun onBookmarkPressed() {
val context: Context = mockk()
val session: Session = mockk()
var bookmarkedSession: Session? = null
val metrics: MetricController = mockk()
val interactor = QuickActionInteractor(
context,
mockk(),
mockk(),
mockk(),
{ bookmarkedSession = it },
mockk()
)
val components: Components = mockk()
val core: Core = mockk()
val sessionManager: SessionManager = mockk()
val analytics: Analytics = mockk()
every { session.url } returns "mozilla.org"
every { context.components } returns components
every { components.analytics } returns analytics
every { metrics.track(Event.QuickActionSheetBookmarkTapped) } just Runs
// Since we are mocking components, we must manually define metrics as `analytics.metrics`
every { analytics.metrics } returns metrics
every { components.core } returns core
every { core.sessionManager } returns sessionManager
every { sessionManager.selectedSession } returns session
interactor.onBookmarkPressed()
verify { metrics.track(Event.QuickActionSheetBookmarkTapped) }
assertEquals("mozilla.org", bookmarkedSession?.url)
}
@Test
fun onReadPressed() {
val context: Context = mockk()
val metrics: MetricController = mockk()
val session: Session = mockk()
val readerModeController: ReaderModeController = mockk(relaxed = true)
val quickActionSheetStore: QuickActionSheetStore = mockk(relaxed = true)
val interactor = QuickActionInteractor(
context,
readerModeController,
quickActionSheetStore,
mockk(),
mockk(),
mockk()
)
every { context.metrics } returns metrics
every { context.components.core.sessionManager.selectedSession } returns session
every { session.readerMode } returns false
every { metrics.track(Event.QuickActionSheetReadTapped) } just Runs
interactor.onReadPressed()
verify { metrics.track(Event.QuickActionSheetReadTapped) }
verify { readerModeController.showReaderView() }
}
@Test
fun onReadPressedWithActiveReaderMode() {
val context: Context = mockk()
val metrics: MetricController = mockk()
val session: Session = mockk()
val readerModeController: ReaderModeController = mockk(relaxed = true)
val quickActionSheetStore: QuickActionSheetStore = mockk(relaxed = true)
val interactor = QuickActionInteractor(
context,
readerModeController,
quickActionSheetStore,
mockk(),
mockk(),
mockk()
)
every { context.metrics } returns metrics
every { context.components.core.sessionManager.selectedSession } returns session
every { session.readerMode } returns true
every { metrics.track(Event.QuickActionSheetReadTapped) } just Runs
interactor.onReadPressed()
verify { metrics.track(Event.QuickActionSheetReadTapped) }
verify { readerModeController.hideReaderView() }
}
@Test
fun onAppearancePressed() {
val context: Context = mockk()
val readerModeController: ReaderModeController = mockk(relaxed = true)
val interactor = QuickActionInteractor(
context,
readerModeController,
mockk(),
mockk(),
mockk(),
mockk()
)
interactor.onAppearancePressed()
verify { readerModeController.showControls() }
}
@Test
fun onOpenAppLink() {
val context: Context = mockk()
val session: Session = mockk()
val appLinksUseCases: AppLinksUseCases = mockk()
val interactor = QuickActionInteractor(
context,
mockk(),
mockk(),
mockk(),
mockk(),
appLinksUseCases
)
every { context.components.core.sessionManager.selectedSession } returns session
every { session.url } returns "mozilla.org"
val getAppLinkRedirect: AppLinksUseCases.GetAppLinkRedirect = mockk()
val appLinkRedirect: AppLinkRedirect = mockk()
val openAppLink: AppLinksUseCases.OpenAppLinkRedirect = mockk(relaxed = true)
every { appLinksUseCases.appLinkRedirect } returns getAppLinkRedirect
every { getAppLinkRedirect.invoke("mozilla.org") } returns appLinkRedirect
every { appLinksUseCases.openAppLink } returns openAppLink
every { appLinkRedirect.appIntent } returns mockk(relaxed = true)
interactor.onOpenAppLinkPressed()
verify { openAppLink.invoke(appLinkRedirect) }
}
}