diff --git a/app/src/main/java/org/mozilla/fenix/BrowserDirection.kt b/app/src/main/java/org/mozilla/fenix/BrowserDirection.kt index e999ac2ee..90f90006b 100644 --- a/app/src/main/java/org/mozilla/fenix/BrowserDirection.kt +++ b/app/src/main/java/org/mozilla/fenix/BrowserDirection.kt @@ -19,6 +19,7 @@ enum class BrowserDirection(@IdRes val fragmentId: Int) { FromSearchDialog(R.id.searchDialogFragment), FromSettings(R.id.settingsFragment), FromBookmarks(R.id.bookmarkFragment), + FromBookmarkSearchDialog(R.id.bookmarkSearchDialogFragment), FromHistory(R.id.historyFragment), FromHistorySearchDialog(R.id.historySearchDialogFragment), FromHistoryMetadataGroup(R.id.historyMetadataGroupFragment), diff --git a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt index 90ed47739..f347e282c 100644 --- a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt +++ b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt @@ -788,6 +788,8 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { SettingsFragmentDirections.actionGlobalBrowser(customTabSessionId) BrowserDirection.FromBookmarks -> BookmarkFragmentDirections.actionGlobalBrowser(customTabSessionId) + BrowserDirection.FromBookmarkSearchDialog -> + SearchDialogFragmentDirections.actionGlobalBrowser(customTabSessionId) BrowserDirection.FromHistory -> HistoryFragmentDirections.actionGlobalBrowser(customTabSessionId) BrowserDirection.FromHistorySearchDialog -> diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkController.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkController.kt index 96846a326..47c150c14 100644 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkController.kt +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkController.kt @@ -25,6 +25,7 @@ import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.ext.bookmarkStorage import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.nav +import org.mozilla.fenix.ext.navigateSafe /** * [BookmarkFragment] controller. @@ -47,6 +48,7 @@ interface BookmarkController { fun handleBookmarkFolderDeletion(nodes: Set) fun handleRequestSync() fun handleBackPressed() + fun handleSearch() } @Suppress("TooManyFunctions") @@ -184,6 +186,12 @@ class DefaultBookmarkController( } } + override fun handleSearch() { + val directions = + BookmarkFragmentDirections.actionBookmarkFragmentToBookmarkSearchDialogFragment() + navController.navigateSafe(R.id.bookmarkFragment, directions) + } + private fun openInNewTabAndShow( searchTermOrURL: String, newTab: Boolean, diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragment.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragment.kt index 22e58747d..f7274b4aa 100644 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragment.kt @@ -35,6 +35,7 @@ import mozilla.components.concept.storage.BookmarkNode import mozilla.components.concept.storage.BookmarkNodeType import mozilla.components.lib.state.ext.consumeFrom import mozilla.components.support.base.feature.UserInteractionHandler +import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.NavHostActivity import org.mozilla.fenix.R @@ -178,6 +179,10 @@ class BookmarkFragment : LibraryPageFragment(), UserInteractionHan if (mode.showMenu) { inflater.inflate(R.menu.bookmarks_menu, menu) } + + if (!FeatureFlags.historyImprovementFeatures) { + menu.findItem(R.id.bookmark_search)?.isVisible = false + } } is BookmarkFragmentState.Mode.Selecting -> { if (mode.selectedItems.any { it.type != BookmarkNodeType.ITEM }) { @@ -196,6 +201,10 @@ class BookmarkFragment : LibraryPageFragment(), UserInteractionHan override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { + R.id.bookmark_search -> { + bookmarkInteractor.onSearch() + true + } R.id.close_bookmarks -> { invokePendingDeletion() close() diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractor.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractor.kt index 4f5e757fc..5ced1196e 100644 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractor.kt @@ -42,6 +42,10 @@ class BookmarkFragmentInteractor( bookmarksController.handleAllBookmarksDeselected() } + override fun onSearch() { + bookmarksController.handleSearch() + } + /** * Copies the URL of the given BookmarkNode into the copy and paste buffer. */ diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkSearchController.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkSearchController.kt new file mode 100644 index 000000000..c950c9da4 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkSearchController.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.bookmarks + +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 Bookmark Search, triggered by the Interactor + */ +interface BookmarkSearchController { + fun handleEditingCancelled() + fun handleTextChanged(text: String) + fun handleUrlTapped(url: String, flags: LoadUrlFlags = LoadUrlFlags.none()) +} + +class BookmarkSearchDialogController( + private val activity: HomeActivity, + private val fragmentStore: BookmarkSearchFragmentStore, + private val clearToolbarFocus: () -> Unit, +) : BookmarkSearchController { + + override fun handleEditingCancelled() { + clearToolbarFocus() + } + + override fun handleTextChanged(text: String) { + fragmentStore.dispatch(BookmarkSearchFragmentAction.UpdateQuery(text)) + } + + override fun handleUrlTapped(url: String, flags: LoadUrlFlags) { + clearToolbarFocus() + + activity.openToBrowserAndLoad( + searchTermOrURL = url, + newTab = true, + from = BrowserDirection.FromBookmarkSearchDialog, + flags = flags + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkSearchDialogFragment.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkSearchDialogFragment.kt new file mode 100644 index 000000000..69c0b1502 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkSearchDialogFragment.kt @@ -0,0 +1,314 @@ +/* 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.bookmarks + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.Dialog +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.speech.RecognizerIntent +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.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatDialogFragment +import androidx.appcompat.content.res.AppCompatResources +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.browser.toolbar.BrowserToolbar +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.FragmentBookmarkSearchDialogBinding +import org.mozilla.fenix.databinding.SearchSuggestionsHintBinding +import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.library.bookmarks.awesomebar.AwesomeBarView +import org.mozilla.fenix.library.bookmarks.toolbar.ToolbarView +import org.mozilla.fenix.settings.SupportUtils + +@Suppress("TooManyFunctions", "LargeClass") +class BookmarkSearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { + private var _binding: FragmentBookmarkSearchDialogBinding? = null + private val binding get() = _binding!! + + private lateinit var interactor: BookmarkSearchDialogInteractor + private lateinit var store: BookmarkSearchFragmentStore + private lateinit var toolbarView: ToolbarView + private lateinit var awesomeBarView: AwesomeBarView + + private val speechIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) + private var voiceSearchButtonAlreadyAdded = false + 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@BookmarkSearchDialogFragment.onBackPressed() + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentBookmarkSearchDialogBinding.inflate(inflater, container, false) + val activity = requireActivity() as HomeActivity + + store = BookmarkSearchFragmentStore( + createInitialBookmarkSearchFragmentState() + ) + + interactor = BookmarkSearchDialogInteractor( + BookmarkSearchDialogController( + 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.FromBookmarkSearchDialog + ) + } + + 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() + } + + addVoiceSearchButton() + 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) + } + } + } + + private val startVoiceSearchForResult = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> + if (result.resultCode == Activity.RESULT_OK) { + val intent = result.data + intent?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)?.first()?.also { + toolbarView.view.edit.updateUrl(url = it, shouldHighlight = true) + interactor.onTextChanged(it) + toolbarView.view.edit.focus() + } + } + } + + private fun addVoiceSearchButton() { + val shouldShowVoiceSearch = isSpeechAvailable() && + requireContext().settings().shouldShowVoiceSearch + + if (voiceSearchButtonAlreadyAdded || !shouldShowVoiceSearch) return + + toolbarView.view.addEditAction( + BrowserToolbar.Button( + imageDrawable = AppCompatResources.getDrawable(requireContext(), R.drawable.ic_microphone)!!, + contentDescription = requireContext().getString(R.string.voice_search_content_description), + visible = { true }, + listener = ::launchVoiceSearch + ) + ) + + voiceSearchButtonAlreadyAdded = true + } + + private fun launchVoiceSearch() { + // Note if a user disables speech while the app is on the search fragment + // the voice button will still be available and *will* cause a crash if tapped, + // since the `visible` call is only checked on create. In order to avoid extra complexity + // around such a small edge case, we make the button have no functionality in this case. + if (!isSpeechAvailable()) { return } + + speechIntent.apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_PROMPT, requireContext().getString(R.string.voice_search_explainer)) + } + + startVoiceSearchForResult.launch(speechIntent) + } + + private fun isSpeechAvailable(): Boolean = speechIntent.resolveActivity(requireContext().packageManager) != null +} diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkSearchDialogInteractor.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkSearchDialogInteractor.kt new file mode 100644 index 000000000..529dfcf1c --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkSearchDialogInteractor.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.bookmarks + +import mozilla.components.concept.engine.EngineSession.LoadUrlFlags +import org.mozilla.fenix.library.bookmarks.awesomebar.AwesomeBarInteractor +import org.mozilla.fenix.library.bookmarks.toolbar.ToolbarInteractor + +/** + * Interactor for the bookmark search + * Provides implementations for the AwesomeBarView and ToolbarView + */ +class BookmarkSearchDialogInteractor( + private val bookmarkSearchController: BookmarkSearchDialogController +) : AwesomeBarInteractor, ToolbarInteractor { + + override fun onEditingCanceled() { + bookmarkSearchController.handleEditingCancelled() + } + + override fun onTextChanged(text: String) { + bookmarkSearchController.handleTextChanged(text) + } + + override fun onUrlTapped(url: String, flags: LoadUrlFlags) { + bookmarkSearchController.handleUrlTapped(url, flags) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkSearchFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkSearchFragmentStore.kt new file mode 100644 index 000000000..3cb456dab --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkSearchFragmentStore.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.bookmarks + +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store + +/** + * The [Store] for holding the [BookmarkSearchFragmentState] and applying [BookmarkSearchFragmentAction]s. + */ +class BookmarkSearchFragmentStore( + initialState: BookmarkSearchFragmentState +) : Store( + initialState, + ::bookmarkSearchStateReducer +) + +/** + * The state for the Bookmark Search Screen + * + * @property query The current search query string + */ +data class BookmarkSearchFragmentState( + val query: String, +) : State + +fun createInitialBookmarkSearchFragmentState(): BookmarkSearchFragmentState { + return BookmarkSearchFragmentState(query = "") +} + +/** + * Actions to dispatch through the [BookmarkSearchFragmentStore] to modify [BookmarkSearchFragmentState] + * through the reducer. + */ +sealed class BookmarkSearchFragmentAction : Action { + data class UpdateQuery(val query: String) : BookmarkSearchFragmentAction() +} + +/** + * The [BookmarkSearchFragmentState] Reducer. + */ +private fun bookmarkSearchStateReducer( + state: BookmarkSearchFragmentState, + action: BookmarkSearchFragmentAction +): BookmarkSearchFragmentState { + return when (action) { + is BookmarkSearchFragmentAction.UpdateQuery -> + state.copy(query = action.query) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkView.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkView.kt index dcbf80948..f56f2cc71 100644 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkView.kt +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkView.kt @@ -97,6 +97,11 @@ interface BookmarkViewInteractor : SelectionInteractor { * */ fun onRequestSync() + + /** + * Handles when search is tapped + */ + fun onSearch() } class BookmarkView( diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/awesomebar/AwesomeBarInteractor.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/awesomebar/AwesomeBarInteractor.kt new file mode 100644 index 000000000..fef316b0c --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/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.bookmarks.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/bookmarks/awesomebar/AwesomeBarView.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/awesomebar/AwesomeBarView.kt new file mode 100644 index 000000000..4511e738e --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/awesomebar/AwesomeBarView.kt @@ -0,0 +1,60 @@ +/* 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.bookmarks.awesomebar + +import androidx.appcompat.content.res.AppCompatResources.getDrawable +import mozilla.components.concept.engine.EngineSession +import mozilla.components.feature.awesomebar.provider.BookmarksStorageSuggestionProvider +import mozilla.components.feature.session.SessionUseCases +import org.mozilla.fenix.HomeActivity +import org.mozilla.fenix.R +import org.mozilla.fenix.browser.browsingmode.BrowsingMode +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.library.bookmarks.BookmarkSearchFragmentState + +/** + * View that contains and configures the BrowserAwesomeBar + */ +class AwesomeBarView( + activity: HomeActivity, + val interactor: AwesomeBarInteractor, + val view: AwesomeBarWrapper, +) { + private val bookmarksStorageSuggestionProvider: BookmarksStorageSuggestionProvider + + 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 + } + + bookmarksStorageSuggestionProvider = + BookmarksStorageSuggestionProvider( + bookmarksStorage = components.core.bookmarksStorage, + loadUrlUseCase = loadUrlUseCase, + icons = components.core.icons, + indicatorIcon = getDrawable(activity, R.drawable.ic_search_results_bookmarks), + engine = engineForSpeculativeConnects + ) + + view.addProviders(bookmarksStorageSuggestionProvider) + } + + fun update(state: BookmarkSearchFragmentState) { + view.onInputChanged(state.query) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/awesomebar/AwesomeBarWrapper.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/awesomebar/AwesomeBarWrapper.kt new file mode 100644 index 000000000..5576d9fbc --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/awesomebar/AwesomeBarWrapper.kt @@ -0,0 +1,104 @@ +/* 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.bookmarks.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.ext.components +import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.theme.FirefoxTheme + +/** + * This wrapper wraps the `AwesomeBar()` composable and exposes it as a `View` and `concept-awesomebar` + * implementation to be integrated in the view hierarchy of [BookmarkSearchDialogFragment] 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 = FirefoxTheme.colors.textPrimary, + description = FirefoxTheme.colors.textSecondary, + autocompleteIcon = FirefoxTheme.colors.textSecondary + ), + 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/bookmarks/toolbar/ToolbarView.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/toolbar/ToolbarView.kt new file mode 100644 index 000000000..c1a013698 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/toolbar/ToolbarView.kt @@ -0,0 +1,117 @@ +/* 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.bookmarks.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 mozilla.components.support.ktx.android.view.hideKeyboard +import org.mozilla.fenix.R +import org.mozilla.fenix.library.bookmarks.BookmarkSearchFragmentState + +/** + * 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.layer1) + ) + + edit.hint = context.getString(R.string.bookmark_search) + + edit.colors = edit.colors.copy( + text = context.getColorFromAttr(R.attr.textPrimary), + hint = context.getColorFromAttr(R.attr.textSecondary), + suggestionBackground = ContextCompat.getColor( + context, + R.color.suggestion_highlight_color + ), + clear = context.getColorFromAttr(R.attr.textPrimary) + ) + + edit.setUrlBackground( + AppCompatResources.getDrawable(context, R.drawable.search_url_background) + ) + + private = isPrivate + + setOnUrlCommitListener { + hideKeyboard() + + // We need to return false to not show display mode + false + } + + setOnEditListener(object : mozilla.components.concept.toolbar.Toolbar.OnEditListener { + override fun onCancelEditing(): Boolean { + interactor.onEditingCanceled() + // We need to return false to not show display mode + return false + } + + override fun onTextChanged(text: String) { + url = text + interactor.onTextChanged(text) + } + }) + } + } + + fun update(state: BookmarkSearchFragmentState) { + 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 bookmarkSearchIcon = AppCompatResources.getDrawable(context, R.drawable.ic_bookmarks_menu) + + bookmarkSearchIcon?.let { + view.edit.setIcon(bookmarkSearchIcon, context.getString(R.string.bookmark_search)) + } + } +} diff --git a/app/src/main/res/layout/fragment_bookmark_search_dialog.xml b/app/src/main/res/layout/fragment_bookmark_search_dialog.xml new file mode 100644 index 000000000..e4e7b83ec --- /dev/null +++ b/app/src/main/res/layout/fragment_bookmark_search_dialog.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/bookmarks_menu.xml b/app/src/main/res/menu/bookmarks_menu.xml index 11fa00477..4714ee3d2 100644 --- a/app/src/main/res/menu/bookmarks_menu.xml +++ b/app/src/main/res/menu/bookmarks_menu.xml @@ -4,6 +4,13 @@ - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + + + + + + Deleting selected folders UNDO + + Enter search terms diff --git a/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkControllerTest.kt b/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkControllerTest.kt index 26a0055a7..663fffbe7 100644 --- a/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkControllerTest.kt +++ b/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkControllerTest.kt @@ -465,6 +465,18 @@ class BookmarkControllerTest { } } + @Test + fun `WHEN onSearch is called with BookmarkFragment THEN navigate to BookmarkSearchDialogFragment`() { + val controller = createController() + + controller.handleSearch() + verify { + navController.navigate( + BookmarkFragmentDirections.actionBookmarkFragmentToBookmarkSearchDialogFragment() + ) + } + } + @Suppress("LongParameterList") private fun createController( loadBookmarkNode: suspend (String) -> BookmarkNode? = { _ -> null }, diff --git a/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractorTest.kt index 581e79e8e..140a4b133 100644 --- a/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractorTest.kt +++ b/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractorTest.kt @@ -206,4 +206,13 @@ class BookmarkFragmentInteractorTest { bookmarkController.handleRequestSync() } } + + @Test + fun `WHEN onSearch is called THEN call controller handleSearch`() { + interactor.onSearch() + + verify { + bookmarkController.handleSearch() + } + } } diff --git a/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkSearchControllerTest.kt b/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkSearchControllerTest.kt new file mode 100644 index 000000000..a4763be0b --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkSearchControllerTest.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.bookmarks + +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 BookmarkSearchControllerTest { + + @MockK(relaxed = true) private lateinit var activity: HomeActivity + @MockK(relaxed = true) private lateinit var store: BookmarkSearchFragmentStore + + @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(BookmarkSearchFragmentAction.UpdateQuery(text)) } + } + + @Test + fun `WHEN text is changed to empty THEN update query action is dispatched`() { + val text = "" + + createController().handleTextChanged(text) + + verify { store.dispatch(BookmarkSearchFragmentAction.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.FromBookmarkSearchDialog, + flags = flags + ) + } + } + + private fun createController( + clearToolbarFocus: () -> Unit = { }, + ): BookmarkSearchDialogController { + return BookmarkSearchDialogController( + activity = activity, + fragmentStore = store, + clearToolbarFocus = clearToolbarFocus, + ) + } +} diff --git a/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkSearchDialogInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkSearchDialogInteractorTest.kt new file mode 100644 index 000000000..605eadf0b --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkSearchDialogInteractorTest.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.bookmarks + +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Before +import org.junit.Test + +class BookmarkSearchDialogInteractorTest { + + lateinit var searchController: BookmarkSearchDialogController + lateinit var interactor: BookmarkSearchDialogInteractor + + @Before + fun setup() { + searchController = mockk(relaxed = true) + interactor = BookmarkSearchDialogInteractor( + 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/bookmarks/BookmarkSearchFragmentStoreTest.kt b/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkSearchFragmentStoreTest.kt new file mode 100644 index 000000000..8ee1e4824 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkSearchFragmentStoreTest.kt @@ -0,0 +1,34 @@ +/* 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.bookmarks + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Test + +class BookmarkSearchFragmentStoreTest { + + @Test + fun `GIVEN createInitialBookmarkSearchFragmentState THEN query is empty`() { + val expected = BookmarkSearchFragmentState(query = "") + + assertEquals( + expected, + createInitialBookmarkSearchFragmentState() + ) + } + + @Test + fun updateQuery() = runBlocking { + val initialState = BookmarkSearchFragmentState(query = "") + val store = BookmarkSearchFragmentStore(initialState) + val query = "test query" + + store.dispatch(BookmarkSearchFragmentAction.UpdateQuery(query)).join() + assertNotSame(initialState, store.state) + assertEquals(query, store.state.query) + } +}