Close #10046: Add history search

upstream-sync
Roger Yang 2 years ago committed by mergify[bot]
parent 23036680c2
commit 1e90235dcc

@ -20,6 +20,7 @@ enum class BrowserDirection(@IdRes val fragmentId: Int) {
FromSettings(R.id.settingsFragment),
FromBookmarks(R.id.bookmarkFragment),
FromHistory(R.id.historyFragment),
FromHistorySearchDialog(R.id.historySearchDialogFragment),
FromHistoryMetadataGroup(R.id.historyMetadataGroupFragment),
FromTrackingProtectionExceptions(R.id.trackingProtectionExceptionsFragment),
FromAbout(R.id.aboutFragment),

@ -769,6 +769,8 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity {
BookmarkFragmentDirections.actionGlobalBrowser(customTabSessionId)
BrowserDirection.FromHistory ->
HistoryFragmentDirections.actionGlobalBrowser(customTabSessionId)
BrowserDirection.FromHistorySearchDialog ->
SearchDialogFragmentDirections.actionGlobalBrowser(customTabSessionId)
BrowserDirection.FromHistoryMetadataGroup ->
HistoryMetadataGroupFragmentDirections.actionGlobalBrowser(customTabSessionId)
BrowserDirection.FromTrackingProtectionExceptions ->

@ -11,6 +11,7 @@ import kotlinx.coroutines.launch
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.ext.navigateSafe
@Suppress("TooManyFunctions")
interface HistoryController {
@ -19,6 +20,7 @@ interface HistoryController {
fun handleDeselect(item: History)
fun handleBackPressed(): Boolean
fun handleModeSwitched()
fun handleSearch()
fun handleDeleteAll()
fun handleDeleteSome(items: Set<History>)
fun handleRequestSync()
@ -80,6 +82,12 @@ class DefaultHistoryController(
invalidateOptionsMenu.invoke()
}
override fun handleSearch() {
val directions =
HistoryFragmentDirections.actionGlobalHistorySearchDialog()
navController.navigateSafe(R.id.historyFragment, directions)
}
override fun handleDeleteAll() {
displayDeleteAll.invoke()
}

@ -30,6 +30,7 @@ import mozilla.components.lib.state.ext.consumeFrom
import mozilla.components.service.fxa.sync.SyncReason
import mozilla.components.support.base.feature.UserInteractionHandler
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.NavHostActivity
import org.mozilla.fenix.R
@ -181,6 +182,10 @@ class HistoryFragment : LibraryPageFragment<History>(), UserInteractionHandler {
} else {
inflater.inflate(R.menu.history_menu, menu)
}
if (!FeatureFlags.historyImprovementFeatures) {
menu.findItem(R.id.history_search)?.isVisible = false
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
@ -238,6 +243,10 @@ class HistoryFragment : LibraryPageFragment<History>(), UserInteractionHandler {
showTabTray()
true
}
R.id.history_search -> {
historyInteractor.onSearch()
true
}
R.id.history_delete_all -> {
historyInteractor.onDeleteAll()
true

@ -22,6 +22,11 @@ interface HistoryInteractor : SelectionInteractor<History> {
*/
fun onModeSwitched()
/**
* Called when search is tapped
*/
fun onSearch()
/**
* Called when delete all is tapped
*/
@ -72,6 +77,10 @@ class DefaultHistoryInteractor(
historyController.handleModeSwitched()
}
override fun onSearch() {
historyController.handleSearch()
}
override fun onDeleteAll() {
historyController.handleDeleteAll()
}

@ -0,0 +1,44 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.library.history
import mozilla.components.concept.engine.EngineSession.LoadUrlFlags
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity
/**
* An interface that handles the view manipulation of the History Search, triggered by the Interactor
*/
interface HistorySearchController {
fun handleEditingCancelled()
fun handleTextChanged(text: String)
fun handleUrlTapped(url: String, flags: LoadUrlFlags = LoadUrlFlags.none())
}
class HistorySearchDialogController(
private val activity: HomeActivity,
private val fragmentStore: HistorySearchFragmentStore,
private val clearToolbarFocus: () -> Unit,
) : HistorySearchController {
override fun handleEditingCancelled() {
clearToolbarFocus()
}
override fun handleTextChanged(text: String) {
fragmentStore.dispatch(HistorySearchFragmentAction.UpdateQuery(text))
}
override fun handleUrlTapped(url: String, flags: LoadUrlFlags) {
clearToolbarFocus()
activity.openToBrowserAndLoad(
searchTermOrURL = url,
newTab = true,
from = BrowserDirection.FromHistorySearchDialog,
flags = flags
)
}
}

@ -0,0 +1,257 @@
/* 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.library.history
import android.annotation.SuppressLint
import android.app.Dialog
import android.content.Context
import android.content.DialogInterface
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewStub
import android.view.accessibility.AccessibilityEvent
import android.view.inputmethod.InputMethodManager
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.constraintlayout.widget.ConstraintProperties.BOTTOM
import androidx.constraintlayout.widget.ConstraintProperties.PARENT_ID
import androidx.constraintlayout.widget.ConstraintProperties.TOP
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import mozilla.components.lib.state.ext.consumeFlow
import mozilla.components.lib.state.ext.consumeFrom
import mozilla.components.support.base.feature.UserInteractionHandler
import mozilla.components.support.ktx.android.view.hideKeyboard
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.components.toolbar.ToolbarPosition
import org.mozilla.fenix.databinding.FragmentHistorySearchDialogBinding
import org.mozilla.fenix.databinding.SearchSuggestionsHintBinding
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.library.history.awesomebar.AwesomeBarView
import org.mozilla.fenix.library.history.toolbar.ToolbarView
import org.mozilla.fenix.settings.SupportUtils
@Suppress("TooManyFunctions")
class HistorySearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
private var _binding: FragmentHistorySearchDialogBinding? = null
private val binding get() = _binding!!
private lateinit var interactor: HistorySearchDialogInteractor
private lateinit var store: HistorySearchFragmentStore
private lateinit var toolbarView: ToolbarView
private lateinit var awesomeBarView: AwesomeBarView
private var dialogHandledAction = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_TITLE, R.style.SearchDialogStyle)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return object : Dialog(requireContext(), this.theme) {
override fun onBackPressed() {
this@HistorySearchDialogFragment.onBackPressed()
}
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentHistorySearchDialogBinding.inflate(inflater, container, false)
val activity = requireActivity() as HomeActivity
store = HistorySearchFragmentStore(
createInitialHistorySearchFragmentState()
)
interactor = HistorySearchDialogInteractor(
HistorySearchDialogController(
activity = activity,
fragmentStore = store,
clearToolbarFocus = {
dialogHandledAction = true
toolbarView.view.hideKeyboard()
toolbarView.view.clearFocus()
},
)
)
toolbarView = ToolbarView(
context = requireContext(),
interactor = interactor,
isPrivate = false,
view = binding.toolbar,
)
val awesomeBar = binding.awesomeBar
awesomeBarView = AwesomeBarView(
activity,
interactor,
awesomeBar,
)
awesomeBarView.view.setOnEditSuggestionListener(toolbarView.view::setSearchTerms)
return binding.root
}
@SuppressLint("ClickableViewAccessibility")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupConstraints(view)
binding.searchWrapper.setOnTouchListener { _, _ ->
dismissAllowingStateLoss()
true
}
val stubListener = ViewStub.OnInflateListener { _, inflated ->
val searchSuggestionHintBinding = SearchSuggestionsHintBinding.bind(inflated)
searchSuggestionHintBinding.learnMore.setOnClickListener {
(activity as HomeActivity)
.openToBrowserAndLoad(
searchTermOrURL = SupportUtils.getGenericSumoURLForTopic(
SupportUtils.SumoTopic.SEARCH_SUGGESTION
),
newTab = true,
from = BrowserDirection.FromHistorySearchDialog
)
}
searchSuggestionHintBinding.allow.setOnClickListener {
inflated.visibility = View.GONE
requireContext().settings().also {
it.shouldShowSearchSuggestionsInPrivate = true
it.showSearchSuggestionsInPrivateOnboardingFinished = true
}
}
searchSuggestionHintBinding.dismiss.setOnClickListener {
inflated.visibility = View.GONE
requireContext().settings().also {
it.shouldShowSearchSuggestionsInPrivate = false
it.showSearchSuggestionsInPrivateOnboardingFinished = true
}
}
searchSuggestionHintBinding.text.text =
getString(R.string.search_suggestions_onboarding_text, getString(R.string.app_name))
searchSuggestionHintBinding.title.text =
getString(R.string.search_suggestions_onboarding_title)
}
binding.searchSuggestionsHintDivider.isVisible = false
binding.searchSuggestionsHint.isVisible = false
binding.searchSuggestionsHint.setOnInflateListener((stubListener))
if (view.context.settings().accessibilityServicesEnabled) {
updateAccessibilityTraversalOrder()
}
observeAwesomeBarState()
consumeFrom(store) {
toolbarView.update(it)
awesomeBarView.update(it)
}
}
private fun observeAwesomeBarState() = consumeFlow(store) { flow ->
flow.map { state -> state.query.isNotBlank() }
.ifChanged()
.collect { shouldShowAwesomebar ->
binding.awesomeBar.visibility = if (shouldShowAwesomebar) {
View.VISIBLE
} else {
View.INVISIBLE
}
}
}
private fun updateAccessibilityTraversalOrder() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) {
viewLifecycleOwner.lifecycleScope.launch {
binding.searchWrapper.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED)
}
}
}
override fun onPause() {
super.onPause()
view?.hideKeyboard()
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
/*
* This way of dismissing the keyboard is needed to smoothly dismiss the keyboard while the dialog
* is also dismissing.
*/
private fun hideDeviceKeyboard() {
// If the interactor/controller has handled a search event itself, it will hide the keyboard.
if (!dialogHandledAction) {
val imm =
requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view?.windowToken, InputMethodManager.HIDE_IMPLICIT_ONLY)
}
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
hideDeviceKeyboard()
}
override fun onBackPressed(): Boolean {
view?.hideKeyboard()
dismissAllowingStateLoss()
return true
}
private fun setupConstraints(view: View) {
if (view.context.settings().toolbarPosition == ToolbarPosition.BOTTOM) {
ConstraintSet().apply {
clone(binding.searchWrapper)
clear(binding.toolbar.id, TOP)
connect(binding.toolbar.id, BOTTOM, PARENT_ID, BOTTOM)
clear(binding.pillWrapper.id, BOTTOM)
connect(binding.pillWrapper.id, BOTTOM, binding.toolbar.id, TOP)
clear(binding.awesomeBar.id, TOP)
clear(binding.awesomeBar.id, BOTTOM)
connect(binding.awesomeBar.id, TOP, binding.searchSuggestionsHint.id, BOTTOM)
connect(binding.awesomeBar.id, BOTTOM, binding.pillWrapper.id, TOP)
clear(binding.searchSuggestionsHint.id, TOP)
clear(binding.searchSuggestionsHint.id, BOTTOM)
connect(binding.searchSuggestionsHint.id, TOP, PARENT_ID, TOP)
connect(binding.searchSuggestionsHint.id, BOTTOM, binding.searchHintBottomBarrier.id, TOP)
applyTo(binding.searchWrapper)
}
}
}
}

@ -0,0 +1,30 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.library.history
import mozilla.components.concept.engine.EngineSession.LoadUrlFlags
import org.mozilla.fenix.library.history.awesomebar.AwesomeBarInteractor
import org.mozilla.fenix.library.history.toolbar.ToolbarInteractor
/**
* Interactor for the history search
* Provides implementations for the AwesomeBarView and ToolbarView
*/
class HistorySearchDialogInteractor(
private val historySearchController: HistorySearchDialogController
) : AwesomeBarInteractor, ToolbarInteractor {
override fun onEditingCanceled() {
historySearchController.handleEditingCancelled()
}
override fun onTextChanged(text: String) {
historySearchController.handleTextChanged(text)
}
override fun onUrlTapped(url: String, flags: LoadUrlFlags) {
historySearchController.handleUrlTapped(url, flags)
}
}

@ -0,0 +1,53 @@
/* 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.library.history
import mozilla.components.lib.state.Action
import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
/**
* The [Store] for holding the [HistorySearchFragmentState] and applying [HistorySearchFragmentAction]s.
*/
class HistorySearchFragmentStore(
initialState: HistorySearchFragmentState
) : Store<HistorySearchFragmentState, HistorySearchFragmentAction>(
initialState,
::historySearchStateReducer
)
/**
* The state for the History Search Screen
*
* @property query The current search query string
*/
data class HistorySearchFragmentState(
val query: String,
) : State
fun createInitialHistorySearchFragmentState(): HistorySearchFragmentState {
return HistorySearchFragmentState(query = "")
}
/**
* Actions to dispatch through the [HistorySearchFragmentStore] to modify [HistorySearchFragmentState]
* through the reducer.
*/
sealed class HistorySearchFragmentAction : Action {
data class UpdateQuery(val query: String) : HistorySearchFragmentAction()
}
/**
* The [HistorySearchFragmentState] Reducer.
*/
private fun historySearchStateReducer(
state: HistorySearchFragmentState,
action: HistorySearchFragmentAction
): HistorySearchFragmentState {
return when (action) {
is HistorySearchFragmentAction.UpdateQuery ->
state.copy(query = action.query)
}
}

@ -0,0 +1,20 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.library.history.awesomebar
import mozilla.components.concept.engine.EngineSession.LoadUrlFlags
/**
* Interface for the AwesomeBarView Interactor. This interface is implemented by objects that want
* to respond to user interaction on the AwesomebarView
*/
interface AwesomeBarInteractor {
/**
* Called whenever a suggestion containing a URL is tapped
* @param url the url the suggestion was providing
*/
fun onUrlTapped(url: String, flags: LoadUrlFlags = LoadUrlFlags.none())
}

@ -0,0 +1,64 @@
/* 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.library.history.awesomebar
import mozilla.components.concept.engine.EngineSession
import mozilla.components.feature.awesomebar.provider.CombinedHistorySuggestionProvider
import mozilla.components.feature.session.SessionUseCases
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.library.history.HistorySearchFragmentState
/**
* View that contains and configures the BrowserAwesomeBar
*/
class AwesomeBarView(
activity: HomeActivity,
val interactor: AwesomeBarInteractor,
val view: AwesomeBarWrapper,
) {
private val combinedHistoryProvider: CombinedHistorySuggestionProvider
private val loadUrlUseCase = object : SessionUseCases.LoadUrlUseCase {
override fun invoke(
url: String,
flags: EngineSession.LoadUrlFlags,
additionalHeaders: Map<String, String>?
) {
interactor.onUrlTapped(url, flags)
}
}
init {
val components = activity.components
val engineForSpeculativeConnects = when (activity.browsingModeManager.mode) {
BrowsingMode.Normal -> components.core.engine
BrowsingMode.Private -> null
}
combinedHistoryProvider =
CombinedHistorySuggestionProvider(
historyStorage = components.core.historyStorage,
historyMetadataStorage = components.core.historyStorage,
loadUrlUseCase = loadUrlUseCase,
icons = components.core.icons,
engine = engineForSpeculativeConnects,
maxNumberOfSuggestions = METADATA_SUGGESTION_LIMIT
)
view.addProviders(combinedHistoryProvider)
}
fun update(state: HistorySearchFragmentState) {
view.onInputChanged(state.query)
}
companion object {
// Maximum number of suggestions returned from the history metadata storage.
const val METADATA_SUGGESTION_LIMIT = 100
}
}

@ -0,0 +1,106 @@
/* 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.library.history.awesomebar
import android.content.Context
import android.util.AttributeSet
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.AbstractComposeView
import mozilla.components.compose.browser.awesomebar.AwesomeBar
import mozilla.components.compose.browser.awesomebar.AwesomeBarDefaults
import mozilla.components.compose.browser.awesomebar.AwesomeBarOrientation
import mozilla.components.concept.awesomebar.AwesomeBar
import mozilla.components.support.ktx.android.view.hideKeyboard
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.theme.FirefoxTheme
import org.mozilla.fenix.theme.ThemeManager
/**
* This wrapper wraps the `AwesomeBar()` composable and exposes it as a `View` and `concept-awesomebar`
* implementation to be integrated in the view hierarchy of [HistorySearchDialogFragment] until more parts
* of that screen have been refactored to use Jetpack Compose.
*/
class AwesomeBarWrapper @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AbstractComposeView(context, attrs, defStyleAttr), AwesomeBar {
private val providers = mutableStateOf(emptyList<AwesomeBar.SuggestionProvider>())
private val text = mutableStateOf("")
private var onEditSuggestionListener: ((String) -> Unit)? = null
private var onStopListener: (() -> Unit)? = null
@Composable
override fun Content() {
if (providers.value.isEmpty()) {
return
}
val orientation = if (context.settings().shouldUseBottomToolbar) {
AwesomeBarOrientation.BOTTOM
} else {
AwesomeBarOrientation.TOP
}
FirefoxTheme {
AwesomeBar(
text = text.value,
providers = providers.value,
orientation = orientation,
colors = AwesomeBarDefaults.colors(
background = Color.Transparent,
title = ThemeManager.resolveAttributeColor(R.attr.primaryText),
description = ThemeManager.resolveAttributeColor(R.attr.secondaryText),
autocompleteIcon = ThemeManager.resolveAttributeColor(R.attr.secondaryText)
),
onSuggestionClicked = { suggestion ->
suggestion.onSuggestionClicked?.invoke()
onStopListener?.invoke()
},
onAutoComplete = { suggestion ->
onEditSuggestionListener?.invoke(suggestion.editSuggestion!!)
},
onScroll = { hideKeyboard() },
profiler = context.components.core.engine.profiler,
)
}
}
override fun addProviders(vararg providers: AwesomeBar.SuggestionProvider) {
val newProviders = this.providers.value.toMutableList()
newProviders.addAll(providers)
this.providers.value = newProviders
}
override fun containsProvider(provider: AwesomeBar.SuggestionProvider): Boolean {
return providers.value.any { current -> current.id == provider.id }
}
override fun onInputChanged(text: String) {
this.text.value = text
}
override fun removeAllProviders() {
providers.value = emptyList()
}
override fun removeProviders(vararg providers: AwesomeBar.SuggestionProvider) {
val newProviders = this.providers.value.toMutableList()
newProviders.removeAll(providers)
this.providers.value = newProviders
}
override fun setOnEditSuggestionListener(listener: (String) -> Unit) {
onEditSuggestionListener = listener
}
override fun setOnStopListener(listener: () -> Unit) {
onStopListener = listener
}
}

@ -0,0 +1,109 @@
/* 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.library.history.toolbar
import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import mozilla.components.browser.toolbar.BrowserToolbar
import mozilla.components.support.ktx.android.content.getColorFromAttr
import mozilla.components.support.ktx.android.content.res.resolveAttribute
import org.mozilla.fenix.R
import org.mozilla.fenix.library.history.HistorySearchFragmentState
/**
* Interface for the Toolbar Interactor. This interface is implemented by objects that want
* to respond to user interaction on the [ToolbarView]
*/
interface ToolbarInteractor {
/**
* Called when a user removes focus from the [ToolbarView]
*/
fun onEditingCanceled()
/**
* Called whenever the text inside the [ToolbarView] changes
* @param text the current text displayed by [ToolbarView]
*/
fun onTextChanged(text: String)
}
/**
* View that contains and configures the BrowserToolbar to only be used in its editing mode.
*/
@Suppress("LongParameterList")
class ToolbarView(
private val context: Context,
private val interactor: ToolbarInteractor,
private val isPrivate: Boolean,
val view: BrowserToolbar,
) {
@VisibleForTesting
internal var isInitialized = false
init {
view.apply {
editMode()
background = AppCompatResources.getDrawable(
context, context.theme.resolveAttribute(R.attr.foundation)
)
edit.hint = context.getString(R.string.history_search)
edit.colors = edit.colors.copy(
text = context.getColorFromAttr(R.attr.primaryText),
hint = context.getColorFromAttr(R.attr.secondaryText),
suggestionBackground = ContextCompat.getColor(
context,
R.color.suggestion_highlight_color
),
clear = context.getColorFromAttr(R.attr.primaryText)
)
edit.setUrlBackground(
AppCompatResources.getDrawable(context, R.drawable.search_url_background)
)
private = isPrivate
setOnEditListener(object : mozilla.components.concept.toolbar.Toolbar.OnEditListener {
override fun onCancelEditing(): Boolean {
interactor.onEditingCanceled()
// We need to return false to notHistorySearchFragmentStore.kt show display mode
return false
}
override fun onTextChanged(text: String) {
url = text
interactor.onTextChanged(text)
}
})
}
}
fun update(state: HistorySearchFragmentState) {
if (!isInitialized) {
view.url = state.query
view.setSearchTerms(state.query)
// We must trigger an onTextChanged so when search terms are set when transitioning to `editMode`
// we have the most up to date text
interactor.onTextChanged(view.url.toString())
view.editMode()
isInitialized = true
}
val historySearchIcon = AppCompatResources.getDrawable(context, R.drawable.ic_history)
historySearchIcon?.let {
view.edit.setIcon(historySearchIcon, context.getString(R.string.history_search))
}
}
}

@ -0,0 +1,111 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/search_wrapper"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
android:background="?attr/scrimBackground">
<mozilla.components.browser.toolbar.BrowserToolbar
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="@dimen/browser_toolbar_height"
android:background="@drawable/toolbar_background_top"
android:clickable="true"
android:focusable="true"
android:focusableInTouchMode="true"
app:layout_scrollFlags="scroll|enterAlways|snap|exitUntilCollapsed"
app:browserToolbarClearColor="?primaryText"
app:browserToolbarInsecureColor="?primaryText"
app:browserToolbarMenuColor="?primaryText"
app:browserToolbarProgressBarGravity="bottom"
app:browserToolbarSecureColor="?primaryText"
app:browserToolbarTrackingProtectionAndSecurityIndicatorSeparatorColor="?toolbarDivider"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<ViewStub
android:id="@+id/search_suggestions_hint"
android:layout_width="0dp"
android:layout_height="0dp"
android:inflatedId="@id/search_suggestions_hint"
android:layout="@layout/search_suggestions_hint"
app:layout_constraintBottom_toTopOf="@id/search_hint_bottom_barrier"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintHeight_default="wrap"/>
<View
android:id="@+id/search_suggestions_hint_divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?neutralFaded"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/search_suggestions_hint"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/search_hint_bottom_barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="top"
app:constraint_referenced_ids="awesome_bar,pill_wrapper"/>
<org.mozilla.fenix.library.history.awesomebar.AwesomeBarWrapper
android:id="@+id/awesome_bar"
android:layout_width="0dp"
android:layout_height="0dp"
android:fadingEdge="horizontal"
android:fadingEdgeLength="40dp"
android:nestedScrollingEnabled="false"
android:requiresFadingEdge="vertical"
android:background="?attr/foundation"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="@+id/pill_wrapper"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/search_suggestions_hint" />
<ImageView
android:id="@+id/link_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/search_fragment_clipboard_item_horizontal_margin"
android:clickable="false"
android:focusable="false"
android:importantForAccessibility="no"
android:visibility="gone"
app:srcCompat="@drawable/ic_link"
tools:visibility="visible" />
<View
android:id="@+id/pill_wrapper_divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?neutralFaded"
app:layout_constraintBottom_toTopOf="@id/pill_wrapper" />
<View
android:id="@+id/pill_wrapper"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?foundation"
android:importantForAccessibility="no"
android:paddingStart="@dimen/search_fragment_pill_padding_start"
android:paddingTop="@dimen/search_fragment_pill_padding_vertical"
android:paddingEnd="@dimen/search_fragment_pill_padding_end"
android:paddingBottom="@dimen/search_fragment_pill_padding_vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

@ -4,6 +4,12 @@
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/history_search"
android:icon="@drawable/mozac_ic_search"
android:title="@string/history_search"
app:iconTint="?primaryText"
app:showAsAction="ifRoom" />
<item
android:id="@+id/history_delete_all"
android:icon="@drawable/ic_delete"

@ -260,7 +260,20 @@
android:id="@+id/historyFragment"
android:name="org.mozilla.fenix.library.history.HistoryFragment"
android:label="@string/library_history"
tools:layout="@layout/fragment_history" />
tools:layout="@layout/fragment_history">
<action
android:id="@+id/action_global_history_search_dialog"
app:destination="@id/historySearchDialogFragment"
app:popUpTo="@id/historySearchDialogFragment"
app:popUpToInclusive="true" />
</fragment>
<dialog
android:id="@+id/historySearchDialogFragment"
android:name="org.mozilla.fenix.library.history.HistorySearchDialogFragment"
tools:layout="@layout/fragment_history_search_dialog">
</dialog>
<fragment
android:id="@+id/historyMetadataGroupFragment"

@ -838,6 +838,8 @@
<string name="tab_tray_header_title_1">Other tabs</string>
<!-- History -->
<!-- Text for the button to search all history -->
<string name="history_search">Enter search term</string>
<!-- Text for the button to clear all history -->
<string name="history_delete_all">Delete history</string>
<!-- Text for the dialog to confirm clearing all history -->

@ -17,7 +17,9 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.ext.navigateSafe
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
@RunWith(FenixRobolectricTestRunner::class)
@ -114,6 +116,19 @@ class HistoryControllerTest {
assertTrue(invalidateOptionsMenuInvoked)
}
@Test
fun onSearch() {
val controller = createController()
controller.handleSearch()
verify {
navController.navigateSafe(
R.id.historyFragment,
HistoryFragmentDirections.actionGlobalHistorySearchDialog()
)
}
}
@Test
fun onDeleteAll() {
var displayDeleteAllInvoked = false

@ -65,6 +65,15 @@ class HistoryInteractorTest {
}
}
@Test
fun onSearch() {
interactor.onSearch()
verifyAll {
controller.handleSearch()
}
}
@Test
fun onDeleteAll() {
interactor.onDeleteAll()

@ -0,0 +1,85 @@
/* 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.library.history
import io.mockk.MockKAnnotations
import io.mockk.impl.annotations.MockK
import io.mockk.verify
import kotlinx.coroutines.test.runBlockingTest
import mozilla.components.concept.engine.EngineSession
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity
class HistorySearchControllerTest {
@MockK(relaxed = true) private lateinit var activity: HomeActivity
@MockK(relaxed = true) private lateinit var store: HistorySearchFragmentStore
@Before
fun setUp() {
MockKAnnotations.init(this)
}
@Test
fun `WHEN editing is cancelled THEN clearToolbarFocus is called`() = runBlockingTest {
var clearToolbarFocusInvoked = false
createController(
clearToolbarFocus = {
clearToolbarFocusInvoked = true
}
).handleEditingCancelled()
assertTrue(clearToolbarFocusInvoked)
}
@Test
fun `WHEN text changed THEN update query action is dispatched`() {
val text = "fenix"
createController().handleTextChanged(text)
verify { store.dispatch(HistorySearchFragmentAction.UpdateQuery(text)) }
}
@Test
fun `WHEN text is changed to empty THEN update query action is dispatched`() {
val text = ""
createController().handleTextChanged(text)
verify { store.dispatch(HistorySearchFragmentAction.UpdateQuery(text)) }
}
@Test
fun `WHEN url is tapped THEN openToBrowserAndLoad is called`() {
val url = "https://www.google.com/"
val flags = EngineSession.LoadUrlFlags.none()
createController().handleUrlTapped(url, flags)
createController().handleUrlTapped(url)
verify {
activity.openToBrowserAndLoad(
searchTermOrURL = url,
newTab = true,
from = BrowserDirection.FromHistorySearchDialog,
flags = flags
)
}
}
private fun createController(
clearToolbarFocus: () -> Unit = { },
): HistorySearchDialogController {
return HistorySearchDialogController(
activity = activity,
fragmentStore = store,
clearToolbarFocus = clearToolbarFocus,
)
}
}

@ -0,0 +1,50 @@
/* 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.library.history
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runBlockingTest
import org.junit.Before
import org.junit.Test
class HistorySearchDialogInteractorTest {
lateinit var searchController: HistorySearchDialogController
lateinit var interactor: HistorySearchDialogInteractor
@Before
fun setup() {
searchController = mockk(relaxed = true)
interactor = HistorySearchDialogInteractor(
searchController
)
}
@Test
fun onEditingCanceled() = runBlockingTest {
interactor.onEditingCanceled()
verify {
searchController.handleEditingCancelled()
}
}
@Test
fun onTextChanged() {
interactor.onTextChanged("test")
verify { searchController.handleTextChanged("test") }
}
@Test
fun onUrlTapped() {
interactor.onUrlTapped("test")
verify {
searchController.handleUrlTapped("test")
}
}
}

@ -0,0 +1,38 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.library.history
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotSame
import org.junit.Test
import org.mozilla.fenix.components.Components
class HistorySearchFragmentStoreTest {
@MockK(relaxed = true) private lateinit var components: Components
@Test
fun `GIVEN createInitialHistorySearchFragmentState THEN query is empty`() {
val expected = HistorySearchFragmentState(query = "")
assertEquals(
expected,
createInitialHistorySearchFragmentState()
)
}
@Test
fun updateQuery() = runBlocking {
val initialState = HistorySearchFragmentState(query = "")
val store = HistorySearchFragmentStore(initialState)
val query = "test query"
store.dispatch(HistorySearchFragmentAction.UpdateQuery(query)).join()
assertNotSame(initialState, store.state)
assertEquals(query, store.state.query)
}
}
Loading…
Cancel
Save