diff --git a/app/src/main/java/org/mozilla/fenix/BrowserDirection.kt b/app/src/main/java/org/mozilla/fenix/BrowserDirection.kt index bad10a707..e999ac2ee 100644 --- a/app/src/main/java/org/mozilla/fenix/BrowserDirection.kt +++ b/app/src/main/java/org/mozilla/fenix/BrowserDirection.kt @@ -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), diff --git a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt index b19757dc6..4b96e8e7b 100644 --- a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt +++ b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt @@ -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 -> diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryController.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryController.kt index 42e1cdc27..e3ac797e3 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryController.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryController.kt @@ -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) 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() } diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt index fa24a045b..39862bc63 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt @@ -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(), 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(), UserInteractionHandler { showTabTray() true } + R.id.history_search -> { + historyInteractor.onSearch() + true + } R.id.history_delete_all -> { historyInteractor.onDeleteAll() true diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryInteractor.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryInteractor.kt index 21066f49a..a1b0c437d 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryInteractor.kt @@ -22,6 +22,11 @@ interface HistoryInteractor : SelectionInteractor { */ 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() } diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistorySearchController.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistorySearchController.kt new file mode 100644 index 000000000..9f423a297 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistorySearchController.kt @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package 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 + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistorySearchDialogFragment.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistorySearchDialogFragment.kt new file mode 100644 index 000000000..1e31005d8 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistorySearchDialogFragment.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistorySearchDialogInteractor.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistorySearchDialogInteractor.kt new file mode 100644 index 000000000..5f52c4da3 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistorySearchDialogInteractor.kt @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package 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) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistorySearchFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistorySearchFragmentStore.kt new file mode 100644 index 000000000..ce33c1624 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistorySearchFragmentStore.kt @@ -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( + 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) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/history/awesomebar/AwesomeBarInteractor.kt b/app/src/main/java/org/mozilla/fenix/library/history/awesomebar/AwesomeBarInteractor.kt new file mode 100644 index 000000000..1aa1819d5 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/history/awesomebar/AwesomeBarInteractor.kt @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package 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()) +} diff --git a/app/src/main/java/org/mozilla/fenix/library/history/awesomebar/AwesomeBarView.kt b/app/src/main/java/org/mozilla/fenix/library/history/awesomebar/AwesomeBarView.kt new file mode 100644 index 000000000..3b107befc --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/history/awesomebar/AwesomeBarView.kt @@ -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? + ) { + 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 + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/history/awesomebar/AwesomeBarWrapper.kt b/app/src/main/java/org/mozilla/fenix/library/history/awesomebar/AwesomeBarWrapper.kt new file mode 100644 index 000000000..d50b05ae1 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/history/awesomebar/AwesomeBarWrapper.kt @@ -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()) + 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 + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/history/toolbar/ToolbarView.kt b/app/src/main/java/org/mozilla/fenix/library/history/toolbar/ToolbarView.kt new file mode 100644 index 000000000..c52c6f9be --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/history/toolbar/ToolbarView.kt @@ -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)) + } + } +} diff --git a/app/src/main/res/layout/fragment_history_search_dialog.xml b/app/src/main/res/layout/fragment_history_search_dialog.xml new file mode 100644 index 000000000..9635b4a47 --- /dev/null +++ b/app/src/main/res/layout/fragment_history_search_dialog.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/history_menu.xml b/app/src/main/res/menu/history_menu.xml index 7d8badf26..69cb931f0 100644 --- a/app/src/main/res/menu/history_menu.xml +++ b/app/src/main/res/menu/history_menu.xml @@ -4,6 +4,12 @@ - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + + tools:layout="@layout/fragment_history"> + + + + + + Other tabs + + Enter search term Delete history diff --git a/app/src/test/java/org/mozilla/fenix/library/history/HistoryControllerTest.kt b/app/src/test/java/org/mozilla/fenix/library/history/HistoryControllerTest.kt index 7a13c9835..776ad305f 100644 --- a/app/src/test/java/org/mozilla/fenix/library/history/HistoryControllerTest.kt +++ b/app/src/test/java/org/mozilla/fenix/library/history/HistoryControllerTest.kt @@ -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 diff --git a/app/src/test/java/org/mozilla/fenix/library/history/HistoryInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/library/history/HistoryInteractorTest.kt index 7fee97707..7b6d6bd1b 100644 --- a/app/src/test/java/org/mozilla/fenix/library/history/HistoryInteractorTest.kt +++ b/app/src/test/java/org/mozilla/fenix/library/history/HistoryInteractorTest.kt @@ -65,6 +65,15 @@ class HistoryInteractorTest { } } + @Test + fun onSearch() { + interactor.onSearch() + + verifyAll { + controller.handleSearch() + } + } + @Test fun onDeleteAll() { interactor.onDeleteAll() diff --git a/app/src/test/java/org/mozilla/fenix/library/history/HistorySearchControllerTest.kt b/app/src/test/java/org/mozilla/fenix/library/history/HistorySearchControllerTest.kt new file mode 100644 index 000000000..b8089b38a --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/library/history/HistorySearchControllerTest.kt @@ -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, + ) + } +} diff --git a/app/src/test/java/org/mozilla/fenix/library/history/HistorySearchDialogInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/library/history/HistorySearchDialogInteractorTest.kt new file mode 100644 index 000000000..e0e7afcc6 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/library/history/HistorySearchDialogInteractorTest.kt @@ -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") + } + } +} diff --git a/app/src/test/java/org/mozilla/fenix/library/history/HistorySearchFragmentStoreTest.kt b/app/src/test/java/org/mozilla/fenix/library/history/HistorySearchFragmentStoreTest.kt new file mode 100644 index 000000000..787f3ac7d --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/library/history/HistorySearchFragmentStoreTest.kt @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.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) + } +}