SearchDialogFragment.kt 21.5 KB
Newer Older
1
2
3
4
/* 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/. */

5
package org.mozilla.fenix.search
6

7
import android.Manifest
Jeff Boek's avatar
Jeff Boek committed
8
import android.app.Activity
9
import android.app.Dialog
10
import android.content.Context
11
import android.content.DialogInterface
Jeff Boek's avatar
Jeff Boek committed
12
import android.content.Intent
13
import android.graphics.Typeface
14
import android.os.Build
15
import android.os.Bundle
16
import android.os.StrictMode
Jeff Boek's avatar
Jeff Boek committed
17
import android.speech.RecognizerIntent
18
import android.text.style.StyleSpan
19
20
21
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
22
import android.view.ViewStub
23
import android.view.WindowManager
24
import android.view.accessibility.AccessibilityEvent
25
import androidx.appcompat.app.AlertDialog
26
import androidx.appcompat.app.AppCompatDialogFragment
27
import androidx.appcompat.content.res.AppCompatResources
28
29
30
31
import androidx.constraintlayout.widget.ConstraintProperties.BOTTOM
import androidx.constraintlayout.widget.ConstraintProperties.PARENT_ID
import androidx.constraintlayout.widget.ConstraintProperties.TOP
import androidx.constraintlayout.widget.ConstraintSet
32
import androidx.core.view.isVisible
33
import androidx.lifecycle.lifecycleScope
34
35
36
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import kotlinx.android.synthetic.main.fragment_search_dialog.*
37
import kotlinx.android.synthetic.main.fragment_search_dialog.view.*
Jeff Boek's avatar
Jeff Boek committed
38
import kotlinx.android.synthetic.main.search_suggestions_hint.view.*
39
import kotlinx.coroutines.ExperimentalCoroutinesApi
40
import kotlinx.coroutines.launch
Jeff Boek's avatar
Jeff Boek committed
41
import mozilla.components.browser.toolbar.BrowserToolbar
42
import mozilla.components.concept.storage.HistoryStorage
43
import mozilla.components.feature.qr.QrFeature
44
import mozilla.components.lib.state.ext.consumeFrom
45
import mozilla.components.support.base.feature.UserInteractionHandler
46
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
47
import mozilla.components.support.ktx.android.content.getColorFromAttr
48
import mozilla.components.support.ktx.android.content.hasCamera
49
import mozilla.components.support.ktx.android.content.isPermissionGranted
50
import mozilla.components.support.ktx.android.content.res.getSpanned
51
import mozilla.components.support.ktx.android.view.hideKeyboard
52
import mozilla.components.ui.autocomplete.InlineAutocompleteEditText
53
import org.mozilla.fenix.BrowserDirection
54
import org.mozilla.fenix.HomeActivity
55
import org.mozilla.fenix.R
56
import org.mozilla.fenix.components.metrics.Event
57
58
import org.mozilla.fenix.components.searchengine.CustomSearchEngineStore
import org.mozilla.fenix.components.searchengine.FenixSearchEngineProvider
59
import org.mozilla.fenix.components.toolbar.ToolbarPosition
60
import org.mozilla.fenix.ext.components
61
import org.mozilla.fenix.ext.isKeyboardVisible
62
import org.mozilla.fenix.ext.requireComponents
63
import org.mozilla.fenix.ext.settings
64
65
import org.mozilla.fenix.search.awesomebar.AwesomeBarView
import org.mozilla.fenix.search.toolbar.ToolbarView
66
import org.mozilla.fenix.settings.SupportUtils
67
import org.mozilla.fenix.settings.registerOnSharedPreferenceChangeListener
Jeff Boek's avatar
Jeff Boek committed
68
import org.mozilla.fenix.widget.VoiceSearchActivity
69
70

typealias SearchDialogFragmentStore = SearchFragmentStore
71

Jeff Boek's avatar
Jeff Boek committed
72
@SuppressWarnings("LargeClass", "TooManyFunctions")
73
class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
74
75
    private lateinit var interactor: SearchDialogInteractor
    private lateinit var store: SearchDialogFragmentStore
76
77
    private lateinit var toolbarView: ToolbarView
    private lateinit var awesomeBarView: AwesomeBarView
78
    private var firstUpdate = true
79

80
    private val qrFeature = ViewBoundFeatureWrapper<QrFeature>()
Jeff Boek's avatar
Jeff Boek committed
81
    private val speechIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
82

83
84
    private var keyboardVisible: Boolean = false

85
86
87
88
89
90
    override fun onStart() {
        super.onStart()
        // https://github.com/mozilla-mobile/fenix/issues/14279
        // To prevent GeckoView from resizing we're going to change the softInputMode to not adjust
        // the size of the window.
        requireActivity().window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
91
92
93
        if (keyboardVisible) {
            toolbarView.view.edit.focus()
        }
94
95
96
97
98
99
100
    }

    override fun onStop() {
        super.onStop()
        // https://github.com/mozilla-mobile/fenix/issues/14279
        // Let's reset back to the default behavior after we're done searching
        requireActivity().window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
101
        keyboardVisible = toolbarView.view.isKeyboardVisible()
102
103
    }

104
105
106
107
108
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setStyle(STYLE_NO_TITLE, R.style.SearchDialogStyle)
    }

109
110
111
112
113
114
115
116
    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        return object : Dialog(requireContext(), this.theme) {
            override fun onBackPressed() {
                this@SearchDialogFragment.onBackPressed()
            }
        }
    }

117
118
119
120
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
121
    ): View? {
122
        val args by navArgs<SearchDialogFragmentArgs>()
123
        val view = inflater.inflate(R.layout.fragment_search_dialog, container, false)
124
        val activity = requireActivity() as HomeActivity
125
        val isPrivate = activity.browsingModeManager.mode.isPrivate
126

127
128
        requireComponents.analytics.metrics.track(Event.InteractWithSearchURLArea)

129
130
        store = SearchDialogFragmentStore(
            createInitialSearchFragmentState(
131
                activity,
132
133
134
135
136
137
                requireComponents,
                tabId = args.sessionId,
                pastedText = args.pastedText,
                searchAccessPoint = args.searchAccessPoint
            )
        )
138
139
140

        interactor = SearchDialogInteractor(
            SearchDialogController(
141
                activity = activity,
142
143
144
145
146
                sessionManager = requireComponents.core.sessionManager,
                store = store,
                navController = findNavController(),
                settings = requireContext().settings(),
                metrics = requireComponents.analytics.metrics,
147
                dismissDialog = { dismissAllowingStateLoss() },
148
                clearToolbarFocus = {
149
                    toolbarView.view.hideKeyboardAndSave()
150
151
152
153
                    toolbarView.view.clearFocus()
                }
            )
        )
154
155
156

        toolbarView = ToolbarView(
            requireContext(),
157
            interactor,
158
            historyStorageProvider(),
159
            isPrivate,
160
161
            view.toolbar,
            requireComponents.core.engine
Jeff Boek's avatar
Jeff Boek committed
162
        ).also(::addSearchButton)
163
164

        awesomeBarView = AwesomeBarView(
165
            activity,
166
            interactor,
167
            view.awesome_bar
168
169
        )

170
171
172
        setShortcutsChangedListener(CustomSearchEngineStore.PREF_FILE_SEARCH_ENGINES)
        setShortcutsChangedListener(FenixSearchEngineProvider.PREF_FILE_SEARCH_ENGINES)

173
        view.awesome_bar.setOnTouchListener { _, _ ->
174
            view.hideKeyboardAndSave()
175
176
177
            false
        }

178
179
180
181
182
183
184
185
        awesomeBarView.view.setOnEditSuggestionListener(toolbarView.view::setSearchTerms)

        val urlView = toolbarView.view
            .findViewById<InlineAutocompleteEditText>(R.id.mozac_browser_toolbar_edit_url_view)
        urlView?.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO

        requireComponents.core.engine.speculativeCreateSession(isPrivate)

186
187
        return view
    }
188
189

    @ExperimentalCoroutinesApi
Jeff Boek's avatar
Jeff Boek committed
190
    @SuppressWarnings("LongMethod")
191
192
193
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

Jeff Boek's avatar
Jeff Boek committed
194
        setupConstraints(view)
195

196
        search_wrapper.setOnClickListener {
197
            it.hideKeyboardAndSave()
198
199
200
            dismissAllowingStateLoss()
        }

201
202
203
204
        view.search_engines_shortcut_button.setOnClickListener {
            interactor.onSearchShortcutsButtonClicked()
        }

205
206
207
208
209
210
        qrFeature.set(
            createQrFeature(),
            owner = this,
            view = view
        )

Jeff Boek's avatar
Jeff Boek committed
211
        qr_scan_button.visibility = if (context?.hasCamera() == true) View.VISIBLE else View.GONE
212

Jeff Boek's avatar
Jeff Boek committed
213
        qr_scan_button.setOnClickListener {
214
            if (!requireContext().hasCamera()) { return@setOnClickListener }
215
            view.hideKeyboard()
216
            toolbarView.view.clearFocus()
217

218
            if (requireContext().settings().shouldShowCameraPermissionPrompt) {
219
                qrFeature.get()?.scan(R.id.search_wrapper)
220
221
222
223
224
225
226
227
228
            } else {
                if (requireContext().isPermissionGranted(Manifest.permission.CAMERA)) {
                    qrFeature.get()?.scan(R.id.search_wrapper)
                } else {
                    interactor.onCameraPermissionsNeeded()
                    resetFocus()
                    view.hideKeyboard()
                    toolbarView.view.requestFocus()
                }
229
            }
230
            requireContext().settings().setCameraPermissionNeededState = false
231
232
        }

233
        fill_link_from_clipboard.setOnClickListener {
234
235
            view.hideKeyboard()
            toolbarView.view.clearFocus()
236
237
238
239
240
241
242
243
            (activity as HomeActivity)
                .openToBrowserAndLoad(
                    searchTermOrURL = requireContext().components.clipboardHandler.url ?: "",
                    newTab = store.state.tabId == null,
                    from = BrowserDirection.FromSearchDialog
                )
        }

244
245
246
247
248
249
250
251
        val stubListener = ViewStub.OnInflateListener { _, inflated ->
            inflated.learn_more.setOnClickListener {
                (activity as HomeActivity)
                    .openToBrowserAndLoad(
                        searchTermOrURL = SupportUtils.getGenericSumoURLForTopic(
                            SupportUtils.SumoTopic.SEARCH_SUGGESTION
                        ),
                        newTab = store.state.tabId == null,
252
                        from = BrowserDirection.FromSearchDialog
253
254
255
256
257
                    )
            }

            inflated.allow.setOnClickListener {
                inflated.visibility = View.GONE
Jeff Boek's avatar
Jeff Boek committed
258
259
260
261
                requireContext().settings().also {
                    it.shouldShowSearchSuggestionsInPrivate = true
                    it.showSearchSuggestionsInPrivateOnboardingFinished = true
                }
262
263
264
265
266
267
268
                store.dispatch(SearchFragmentAction.SetShowSearchSuggestions(true))
                store.dispatch(SearchFragmentAction.AllowSearchSuggestionsInPrivateModePrompt(false))
                requireComponents.analytics.metrics.track(Event.PrivateBrowsingShowSearchSuggestions)
            }

            inflated.dismiss.setOnClickListener {
                inflated.visibility = View.GONE
Jeff Boek's avatar
Jeff Boek committed
269
270
271
272
                requireContext().settings().also {
                    it.shouldShowSearchSuggestionsInPrivate = false
                    it.showSearchSuggestionsInPrivateOnboardingFinished = true
                }
273
274
275
276
277
278
279
            }

            inflated.text.text =
                getString(R.string.search_suggestions_onboarding_text, getString(R.string.app_name))

            inflated.title.text =
                getString(R.string.search_suggestions_onboarding_title)
Alex Catarineu's avatar
Alex Catarineu committed
280
281
282
283
284
285
286

            // Hide Search Suggestions prompt and disable
            inflated.visibility = View.GONE
            requireContext().settings().also {
                it.shouldShowSearchSuggestionsInPrivate = false
                it.showSearchSuggestionsInPrivateOnboardingFinished = true
            }
287
288
        }

Jeff Boek's avatar
Jeff Boek committed
289
        view.search_suggestions_hint.setOnInflateListener((stubListener))
290
291
292
        if (view.context.settings().accessibilityServicesEnabled) {
            updateAccessibilityTraversalOrder()
        }
293

294
        consumeFrom(store) {
295
296
            val shouldShowAwesomebar =
                !firstUpdate &&
Jeff Boek's avatar
Jeff Boek committed
297
298
299
                it.query.isNotBlank() ||
                it.showSearchShortcuts

300
            awesome_bar?.visibility = if (shouldShowAwesomebar) View.VISIBLE else View.INVISIBLE
301
            updateSearchSuggestionsHintVisibility(it)
302
            updateClipboardSuggestion(it, requireContext().components.clipboardHandler.url)
303
            updateToolbarContentDescription(it)
304
            updateSearchShortcutsIcon(it)
305
306
            toolbarView.update(it)
            awesomeBarView.update(it)
307
            firstUpdate = false
308
309
310
        }
    }

311
312
313
314
315
316
317
318
319
320
321
322
323
    private fun updateAccessibilityTraversalOrder() {
        val searchWrapperId = search_wrapper.id
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
            qr_scan_button.accessibilityTraversalAfter = searchWrapperId
            search_engines_shortcut_button.accessibilityTraversalAfter = searchWrapperId
            fill_link_from_clipboard.accessibilityTraversalAfter = searchWrapperId
        } else {
            viewLifecycleOwner.lifecycleScope.launch {
                search_wrapper.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED)
            }
        }
    }

324
325
326
327
328
329
330
331
332
333
334
335
336
    override fun onResume() {
        super.onResume()
        resetFocus()
        toolbarView.view.edit.focus()
    }

    override fun onPause() {
        super.onPause()
        qr_scan_button.isChecked = false
        view?.hideKeyboard()
        toolbarView.view.requestFocus()
    }

Jeff Boek's avatar
Jeff Boek committed
337
338
339
340
341
342
343
344
345
346
    override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
        if (requestCode == VoiceSearchActivity.SPEECH_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
            intent?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)?.first()?.also {
                toolbarView.view.edit.updateUrl(url = it, shouldHighlight = true)
                interactor.onTextChanged(it)
                toolbarView.view.edit.focus()
            }
        }
    }

347
    override fun onBackPressed(): Boolean {
348
349
        return when {
            qrFeature.onBackPressed() -> {
350
                resetFocus()
351
352
353
                true
            }
            else -> {
354
                view?.hideKeyboardAndSave()
355
356
357
358
359
                dismissAllowingStateLoss()
                true
            }
        }
    }
360

361
362
363
364
365
366
    private fun historyStorageProvider(): HistoryStorage? {
        return if (requireContext().settings().shouldShowHistorySuggestions) {
            requireComponents.core.historyStorage
        } else null
    }

Jeff Boek's avatar
Jeff Boek committed
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
    private fun createQrFeature(): QrFeature {
        return QrFeature(
            requireContext(),
            fragmentManager = childFragmentManager,
            onNeedToRequestPermissions = { permissions ->
                requestPermissions(permissions, REQUEST_CODE_CAMERA_PERMISSIONS)
            },
            onScanResult = { result ->
                qr_scan_button.isChecked = false
                activity?.let {
                    AlertDialog.Builder(it).apply {
                        val spannable = resources.getSpanned(
                            R.string.qr_scanner_confirmation_dialog_message,
                            getString(R.string.app_name) to StyleSpan(Typeface.BOLD),
                            result to StyleSpan(Typeface.ITALIC)
                        )
                        setMessage(spannable)
                        setNegativeButton(R.string.qr_scanner_dialog_negative) { dialog: DialogInterface, _ ->
                            dialog.cancel()
                        }
                        setPositiveButton(R.string.qr_scanner_dialog_positive) { dialog: DialogInterface, _ ->
                            (activity as HomeActivity)
                                .openToBrowserAndLoad(
                                    searchTermOrURL = result,
                                    newTab = store.state.tabId == null,
392
                                    from = BrowserDirection.FromSearchDialog
Jeff Boek's avatar
Jeff Boek committed
393
394
395
396
397
398
                                )
                            dialog.dismiss()
                        }
                        create()
                    }.show()
                }
399
400
            }
        )
Jeff Boek's avatar
Jeff Boek committed
401
402
    }

403
404
405
406
407
408
409
    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<String>,
        grantResults: IntArray
    ) {
        when (requestCode) {
            REQUEST_CODE_CAMERA_PERMISSIONS -> qrFeature.withFeature {
410
411
412
                it.onPermissionsResult(permissions, grantResults)
                resetFocus()
                requireContext().settings().setCameraPermissionNeededState = false
413
414
415
416
417
418
419
420
421
422
423
            }
            else -> super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        }
    }

    private fun resetFocus() {
        qr_scan_button.isChecked = false
        toolbarView.view.edit.focus()
        toolbarView.view.requestFocus()
    }

Jeff Boek's avatar
Jeff Boek committed
424
425
426
427
428
429
430
431
432
433
434
    private fun setupConstraints(view: View) {
        if (view.context.settings().toolbarPosition == ToolbarPosition.BOTTOM) {
            ConstraintSet().apply {
                clone(search_wrapper)

                clear(toolbar.id, TOP)
                connect(toolbar.id, BOTTOM, PARENT_ID, BOTTOM)

                clear(pill_wrapper.id, BOTTOM)
                connect(pill_wrapper.id, BOTTOM, toolbar.id, TOP)

435
436
437
                clear(search_suggestions_hint.id, TOP)
                connect(search_suggestions_hint.id, TOP, PARENT_ID, TOP)

438
439
440
                clear(fill_link_from_clipboard.id, TOP)
                connect(fill_link_from_clipboard.id, BOTTOM, pill_wrapper.id, TOP)

Jeff Boek's avatar
Jeff Boek committed
441
442
443
444
445
                applyTo(search_wrapper)
            }
        }
    }

446
447
    private fun updateSearchSuggestionsHintVisibility(state: SearchFragmentState) {
        view?.apply {
448
449
450
            val showHint = state.showSearchSuggestionsHint && !state.showSearchShortcuts
            findViewById<View>(R.id.search_suggestions_hint)?.isVisible = showHint
            search_suggestions_hint_divider?.isVisible = showHint
451
452
453
        }
    }

Jeff Boek's avatar
Jeff Boek committed
454
455
456
    private fun addSearchButton(toolbarView: ToolbarView) {
        toolbarView.view.addEditAction(
            BrowserToolbar.Button(
457
                AppCompatResources.getDrawable(requireContext(), R.drawable.ic_microphone)!!,
Jeff Boek's avatar
Jeff Boek committed
458
459
460
461
462
463
464
465
466
467
468
                requireContext().getString(R.string.voice_search_content_description),
                visible = {
                    store.state.searchEngineSource.searchEngine.identifier.contains("google") &&
                            isSpeechAvailable() &&
                            requireContext().settings().shouldShowVoiceSearch
                },
                listener = ::launchVoiceSearch
            )
        )
    }

469
470
471
472
473
474
475
476
477
    /**
     * Used to save keyboard status on stop/sleep, to be restored later.
     * See #14559
     * */
    private fun View.hideKeyboardAndSave() {
        keyboardVisible = false
        this.hideKeyboard()
    }

Jeff Boek's avatar
Jeff Boek committed
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
    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 }

        requireComponents.analytics.metrics.track(Event.VoiceSearchTapped)
        speechIntent.apply {
            putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
            putExtra(RecognizerIntent.EXTRA_PROMPT, requireContext().getString(R.string.voice_search_explainer))
        }
        startActivityForResult(speechIntent, VoiceSearchActivity.SPEECH_REQUEST_CODE)
    }

    private fun isSpeechAvailable(): Boolean = speechIntent.resolveActivity(requireContext().packageManager) != null

495
    private fun setShortcutsChangedListener(preferenceFileName: String) {
496
497
498
499
500
501
502
        requireComponents.strictMode.resetAfter(StrictMode.allowThreadDiskReads()) {
            requireContext().getSharedPreferences(
                preferenceFileName,
                Context.MODE_PRIVATE
            ).registerOnSharedPreferenceChangeListener(viewLifecycleOwner) { _, _ ->
                awesomeBarView.update(store.state)
            }
503
504
505
        }
    }

506
    private fun updateClipboardSuggestion(searchState: SearchFragmentState, clipboardUrl: String?) {
Jeff Boek's avatar
Jeff Boek committed
507
        val shouldShowView = searchState.showClipboardSuggestions &&
508
                searchState.query.isEmpty() &&
509
                !clipboardUrl.isNullOrEmpty()
510

Jeff Boek's avatar
Jeff Boek committed
511
        fill_link_from_clipboard.visibility = if (shouldShowView) View.VISIBLE else View.GONE
512
513
514
515
516
517
518
        clipboard_url.text = clipboardUrl

        if (clipboardUrl != null && !((activity as HomeActivity).browsingModeManager.mode.isPrivate)) {
            requireComponents.core.engine.speculativeConnect(clipboardUrl)
        }
    }

519
520
521
522
523
524
525
526
    private fun updateToolbarContentDescription(searchState: SearchFragmentState) {
        val urlView = toolbarView.view
            .findViewById<InlineAutocompleteEditText>(R.id.mozac_browser_toolbar_edit_url_view)
        toolbarView.view.contentDescription =
            searchState.searchEngineSource.searchEngine.name + ", " + urlView.hint
        urlView?.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
    }

527
528
529
530
531
532
533
534
535
536
537
538
539
540
    private fun updateSearchShortcutsIcon(searchState: SearchFragmentState) {
        view?.apply {
            search_engines_shortcut_button.isVisible = searchState.areShortcutsAvailable

            val showShortcuts = searchState.showSearchShortcuts
            search_engines_shortcut_button.isChecked = showShortcuts

            val color = if (showShortcuts) R.attr.contrastText else R.attr.primaryText
            search_engines_shortcut_button.compoundDrawables[0]?.setTint(
                requireContext().getColorFromAttr(color)
            )
        }
    }

541
542
    companion object {
        private const val REQUEST_CODE_CAMERA_PERMISSIONS = 1
543
    }
544
}