diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f0d7a2b4a..2976ff0c8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -108,6 +108,16 @@ + + + + + + + diff --git a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt index bc951f124..ec5cfb62a 100644 --- a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt +++ b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt @@ -189,6 +189,17 @@ open class HomeActivity : AppCompatActivity(), ShareFragment.TabsSharedCallback } private fun handleOpenedFromExternalSourceIfNecessary(intent: Intent?) { + if (intent?.extras?.getBoolean(OPEN_TO_BROWSER_AND_LOAD) == true) { + this.intent.putExtra(OPEN_TO_BROWSER_AND_LOAD, false) + openToBrowserAndLoad(intent.getStringExtra( + IntentReceiverActivity.SPEECH_PROCESSING), true, BrowserDirection.FromGlobal, forceSearch = true) + return + } else if (intent?.extras?.getBoolean(OPEN_TO_SEARCH) == true) { + this.intent.putExtra(OPEN_TO_SEARCH, false) + navHost.navController.nav(null, NavGraphDirections.actionGlobalSearch(null, true)) + return + } + if (intent?.extras?.getBoolean(OPEN_TO_BROWSER) != true) return this.intent.putExtra(OPEN_TO_BROWSER, false) @@ -391,6 +402,8 @@ open class HomeActivity : AppCompatActivity(), ShareFragment.TabsSharedCallback companion object { const val OPEN_TO_BROWSER = "open_to_browser" + const val OPEN_TO_BROWSER_AND_LOAD = "open_to_browser_and_load" + const val OPEN_TO_SEARCH = "open_to_search" } } diff --git a/app/src/main/java/org/mozilla/fenix/IntentReceiverActivity.kt b/app/src/main/java/org/mozilla/fenix/IntentReceiverActivity.kt index 395532df8..e034cd12d 100644 --- a/app/src/main/java/org/mozilla/fenix/IntentReceiverActivity.kt +++ b/app/src/main/java/org/mozilla/fenix/IntentReceiverActivity.kt @@ -7,6 +7,7 @@ package org.mozilla.fenix import android.app.Activity import android.content.Intent import android.os.Bundle +import android.speech.RecognizerIntent import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch import org.mozilla.fenix.components.NotificationManager @@ -17,10 +18,19 @@ import org.mozilla.fenix.utils.Settings class IntentReceiverActivity : Activity() { + // Holds the intent that initially started this activity + // so that it can persist through the speech activity. + private var previousIntent: Intent? = null + @Suppress("ComplexMethod") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + previousIntent = savedInstanceState?.get(PREVIOUS_INTENT) as Intent? + if (previousIntent?.getBooleanExtra(SPEECH_PROCESSING, false) == true) { + return + } + val isPrivate = Settings.getInstance(this).usePrivateMode MainScope().launch { @@ -34,12 +44,17 @@ class IntentReceiverActivity : Activity() { if (isPrivate) components.utils.privateIntentProcessor else components.utils.intentProcessor ) - intentProcessors.any { it.process(intent) } - setIntentActivity(intent) + if (intent.getBooleanExtra(SPEECH_PROCESSING, false)) { + previousIntent = intent + displaySpeechRecognizer() + } else { + intentProcessors.any { it.process(intent) } + setIntentActivity(intent) - startActivity(intent) + startActivity(intent) - finish() + finish() + } } } @@ -78,4 +93,42 @@ class IntentReceiverActivity : Activity() { intent.putExtra(HomeActivity.OPEN_TO_BROWSER, openToBrowser) } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putParcelable(PREVIOUS_INTENT, previousIntent) + } + + private fun displaySpeechRecognizer() { + val intentSpeech = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + } + + startActivityForResult(intentSpeech, SPEECH_REQUEST_CODE) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + if (requestCode == SPEECH_REQUEST_CODE && resultCode == RESULT_OK) { + val spokenText: String? = + data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)?.let { results -> + results[0] + } + + previousIntent?.let { + it.putExtra(SPEECH_PROCESSING, spokenText) + it.putExtra(HomeActivity.OPEN_TO_BROWSER_AND_LOAD, true) + startActivity(it) + } + } + + finish() + } + + companion object { + private const val SPEECH_REQUEST_CODE = 0 + const val SPEECH_PROCESSING = "speech_processing" + const val PREVIOUS_INTENT = "previous_intent" + } } diff --git a/app/src/main/java/org/mozilla/fenix/SearchWidgetProvider.kt b/app/src/main/java/org/mozilla/fenix/SearchWidgetProvider.kt new file mode 100644 index 000000000..62c436e7e --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/SearchWidgetProvider.kt @@ -0,0 +1,136 @@ +/* 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 + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.RemoteViews + +class SearchWidgetProvider : AppWidgetProvider() { + + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + val textSearchIntent = createTextSearchIntent(context) + val voiceSearchIntent = createVoiceSearchIntent(context) + + appWidgetIds.forEach { appWidgetId -> + val currentWidth = appWidgetManager.getAppWidgetOptions(appWidgetId).getInt(OPTION_APPWIDGET_MIN_WIDTH) + val layoutSize = getLayoutSize(currentWidth) + val layout = getLayout(layoutSize) + val text = getText(layoutSize, context) + + val views = createRemoteViews(context, layout, textSearchIntent, voiceSearchIntent, text) + appWidgetManager.updateAppWidget(appWidgetId, views) + } + } + + override fun onAppWidgetOptionsChanged( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + newOptions: Bundle? + ) { + val textSearchIntent = createTextSearchIntent(context) + val voiceSearchIntent = createVoiceSearchIntent(context) + + val currentWidth = appWidgetManager.getAppWidgetOptions(appWidgetId).getInt(OPTION_APPWIDGET_MIN_WIDTH) + val layoutSize = getLayoutSize(currentWidth) + val layout = getLayout(layoutSize) + val text = getText(layoutSize, context) + + val views = createRemoteViews(context, layout, textSearchIntent, voiceSearchIntent, text) + appWidgetManager.updateAppWidget(appWidgetId, views) + } + + private fun getLayoutSize(dp: Int) = when { + dp >= DP_EXTRA_LARGE -> SearchWidgetProviderSize.EXTRA_LARGE + dp >= DP_LARGE -> SearchWidgetProviderSize.LARGE + dp >= DP_MEDIUM -> SearchWidgetProviderSize.MEDIUM + dp >= DP_SMALL -> SearchWidgetProviderSize.SMALL + else -> SearchWidgetProviderSize.EXTRA_SMALL + } + + private fun getLayout(size: SearchWidgetProviderSize) = when (size) { + SearchWidgetProviderSize.EXTRA_LARGE -> R.layout.search_widget_extra_large + SearchWidgetProviderSize.LARGE -> R.layout.search_widget_large + SearchWidgetProviderSize.MEDIUM -> R.layout.search_widget_medium + SearchWidgetProviderSize.SMALL -> R.layout.search_widget_small + SearchWidgetProviderSize.EXTRA_SMALL -> R.layout.search_widget_extra_small + } + + private fun getText(layout: SearchWidgetProviderSize, context: Context) = when (layout) { + SearchWidgetProviderSize.MEDIUM -> context.getString(R.string.search_widget_text_short) + SearchWidgetProviderSize.LARGE, + SearchWidgetProviderSize.EXTRA_LARGE -> context.getString(R.string.search_widget_text_long) + else -> null + } + + private fun createTextSearchIntent(context: Context): PendingIntent { + return Intent(context, HomeActivity::class.java) + .let { intent -> + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + intent.putExtra(HomeActivity.OPEN_TO_SEARCH, true) + PendingIntent.getActivity(context, REQUEST_CODE_NEW_TAB, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + } + + private fun createVoiceSearchIntent(context: Context): PendingIntent { + return Intent(context, IntentReceiverActivity::class.java) + .let { intent -> + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + intent.putExtra(IntentReceiverActivity.SPEECH_PROCESSING, true) + PendingIntent.getActivity(context, REQUEST_CODE_VOICE, intent, 0) + } + } + + private fun createRemoteViews( + context: Context, + layout: Int, + textSearchIntent: PendingIntent, + voiceSearchIntent: PendingIntent, + text: String? + ): RemoteViews { + return RemoteViews(context.packageName, layout).apply { + when (layout) { + R.layout.search_widget_extra_small -> { + setOnClickPendingIntent(R.id.button_search_widget_new_tab, textSearchIntent) + } + R.layout.search_widget_small -> { + setOnClickPendingIntent(R.id.button_search_widget_new_tab, textSearchIntent) + setOnClickPendingIntent(R.id.button_search_widget_voice, voiceSearchIntent) + } + R.layout.search_widget_medium, + R.layout.search_widget_large, + R.layout.search_widget_extra_large -> { + setOnClickPendingIntent(R.id.button_search_widget_new_tab, textSearchIntent) + setOnClickPendingIntent(R.id.button_search_widget_voice, voiceSearchIntent) + setTextViewText(R.id.text_search_widget, text) + } + } + } + } + + // Cell sizes obtained from the actual dimensions listed in search widget specs + companion object { + private const val DP_SMALL = 100 + private const val DP_MEDIUM = 192 + private const val DP_LARGE = 256 + private const val DP_EXTRA_LARGE = 360 + private const val REQUEST_CODE_NEW_TAB = 0 + private const val REQUEST_CODE_VOICE = 1 + } +} + +enum class SearchWidgetProviderSize { + EXTRA_SMALL, + SMALL, + MEDIUM, + LARGE, + EXTRA_LARGE +} diff --git a/app/src/main/java/org/mozilla/fenix/search/SearchFragment.kt b/app/src/main/java/org/mozilla/fenix/search/SearchFragment.kt index e925464a2..c47709ef3 100644 --- a/app/src/main/java/org/mozilla/fenix/search/SearchFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/search/SearchFragment.kt @@ -65,6 +65,11 @@ class SearchFragment : Fragment(), BackHandler { ?.let { it.sessionId } ?.let(requireComponents.core.sessionManager::findSessionById) + val displaySearchShortcuts = arguments + ?.let(SearchFragmentArgs.Companion::fromBundle) + ?.let { it.displaySearchShortcuts } + ?: false + val view = inflater.inflate(R.layout.fragment_search, container, false) val url = session?.url ?: "" @@ -72,7 +77,7 @@ class SearchFragment : Fragment(), BackHandler { SearchStore( SearchState( query = url, - showShortcutEnginePicker = false, + showShortcutEnginePicker = displaySearchShortcuts, searchEngineSource = SearchEngineSource.Default( requireComponents.search.searchEngineManager.getDefaultSearchEngine(requireContext()) ), @@ -165,6 +170,8 @@ class SearchFragment : Fragment(), BackHandler { } else { requireComponents.analytics.metrics.track(Event.SearchShortcutMenuOpened) } + + searchInteractor.turnOnStartedTyping() } consumeFrom(searchStore) { diff --git a/app/src/main/java/org/mozilla/fenix/search/SearchInteractor.kt b/app/src/main/java/org/mozilla/fenix/search/SearchInteractor.kt index b3d749e31..c88f03413 100644 --- a/app/src/main/java/org/mozilla/fenix/search/SearchInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/search/SearchInteractor.kt @@ -30,6 +30,10 @@ class SearchInteractor( private val store: SearchStore ) : AwesomeBarInteractor, ToolbarInteractor { + data class UserTypingCheck(var ranOnTextChanged: Boolean, var userHasTyped: Boolean) + + private val userTypingCheck = UserTypingCheck(false, !store.state.showShortcutEnginePicker) + override fun onUrlCommitted(url: String) { if (url.isNotBlank()) { (context as HomeActivity).openToBrowserAndLoad( @@ -55,6 +59,13 @@ class SearchInteractor( override fun onTextChanged(text: String) { store.dispatch(SearchAction.UpdateQuery(text)) + + if (userTypingCheck.ranOnTextChanged && !userTypingCheck.userHasTyped) { + store.dispatch(SearchAction.ShowSearchShortcutEnginePicker(false)) + turnOnStartedTyping() + } + + userTypingCheck.ranOnTextChanged = true } override fun onUrlTapped(url: String) { @@ -90,6 +101,11 @@ class SearchInteractor( navController.navigate(directions) } + fun turnOnStartedTyping() { + userTypingCheck.ranOnTextChanged = true + userTypingCheck.userHasTyped = true + } + override fun onExistingSessionSelected(session: Session) { val directions = SearchFragmentDirections.actionSearchFragmentToBrowserFragment(null) navController.nav(R.id.searchFragment, directions) diff --git a/app/src/main/res/drawable-hdpi/fenix_search_widget.png b/app/src/main/res/drawable-hdpi/fenix_search_widget.png new file mode 100644 index 000000000..a2b9a00cb Binary files /dev/null and b/app/src/main/res/drawable-hdpi/fenix_search_widget.png differ diff --git a/app/src/main/res/drawable/ic_logo_widget.xml b/app/src/main/res/drawable/ic_logo_widget.xml new file mode 100644 index 000000000..4f07b2fdc --- /dev/null +++ b/app/src/main/res/drawable/ic_logo_widget.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_microphone_widget.xml b/app/src/main/res/drawable/ic_microphone_widget.xml new file mode 100644 index 000000000..7cbf04262 --- /dev/null +++ b/app/src/main/res/drawable/ic_microphone_widget.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_white_corners.xml b/app/src/main/res/drawable/rounded_white_corners.xml new file mode 100644 index 000000000..88f681fd0 --- /dev/null +++ b/app/src/main/res/drawable/rounded_white_corners.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/search_widget_extra_large.xml b/app/src/main/res/layout/search_widget_extra_large.xml new file mode 100644 index 000000000..e501858c1 --- /dev/null +++ b/app/src/main/res/layout/search_widget_extra_large.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/search_widget_extra_small.xml b/app/src/main/res/layout/search_widget_extra_small.xml new file mode 100644 index 000000000..87d204637 --- /dev/null +++ b/app/src/main/res/layout/search_widget_extra_small.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/search_widget_large.xml b/app/src/main/res/layout/search_widget_large.xml new file mode 100644 index 000000000..1e08a2ea7 --- /dev/null +++ b/app/src/main/res/layout/search_widget_large.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/search_widget_medium.xml b/app/src/main/res/layout/search_widget_medium.xml new file mode 100644 index 000000000..350a3ab11 --- /dev/null +++ b/app/src/main/res/layout/search_widget_medium.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/search_widget_small.xml b/app/src/main/res/layout/search_widget_small.xml new file mode 100644 index 000000000..be7cdec96 --- /dev/null +++ b/app/src/main/res/layout/search_widget_small.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index 6942e148f..a618ea96b 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -11,6 +11,12 @@ app:popUpTo="@id/nav_graph" app:popUpToInclusive="true" /> + + @@ -66,6 +72,11 @@ android:name="session_id" app:argType="string" app:nullable="true" /> + #E0E0E6 #C50042 #312A65 + + + #737373 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d9f644f55..a65035a7d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -81,6 +81,12 @@ Fill link from clipboard + + + Search + + Search the web + Settings diff --git a/app/src/main/res/xml/search_widget_info.xml b/app/src/main/res/xml/search_widget_info.xml new file mode 100644 index 000000000..c8d9ebbc0 --- /dev/null +++ b/app/src/main/res/xml/search_widget_info.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/app/src/test/java/org/mozilla/fenix/search/SearchInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/search/SearchInteractorTest.kt index 0a2cc7733..2ef79363c 100644 --- a/app/src/test/java/org/mozilla/fenix/search/SearchInteractorTest.kt +++ b/app/src/test/java/org/mozilla/fenix/search/SearchInteractorTest.kt @@ -38,6 +38,7 @@ class SearchInteractorTest { every { context.openToBrowserAndLoad(any(), any(), any(), any(), any(), any()) } just Runs every { store.state } returns state + every { state.showShortcutEnginePicker } returns true every { state.session } returns null every { state.searchEngineSource } returns searchEngine @@ -58,7 +59,11 @@ class SearchInteractorTest { @Test fun onEditingCanceled() { val navController: NavController = mockk(relaxed = true) - val interactor = SearchInteractor(mockk(), navController, mockk()) + val store: SearchStore = mockk() + + every { store.state } returns mockk(relaxed = true) + + val interactor = SearchInteractor(mockk(), navController, store) interactor.onEditingCanceled() @@ -70,6 +75,9 @@ class SearchInteractorTest { @Test fun onTextChanged() { val store: SearchStore = mockk(relaxed = true) + + every { store.state } returns mockk(relaxed = true) + val interactor = SearchInteractor(mockk(), mockk(), store) interactor.onTextChanged("test") @@ -88,6 +96,7 @@ class SearchInteractorTest { every { store.state } returns state every { state.session } returns null + every { state.showShortcutEnginePicker } returns true val interactor = SearchInteractor(context, mockk(), store) @@ -117,6 +126,7 @@ class SearchInteractorTest { every { store.state } returns state every { state.session } returns null every { state.searchEngineSource } returns searchEngine + every { state.showShortcutEnginePicker } returns true val interactor = SearchInteractor(context, mockk(), store) @@ -137,6 +147,10 @@ class SearchInteractorTest { every { context.metrics } returns mockk(relaxed = true) val store: SearchStore = mockk(relaxed = true) + val state: SearchState = mockk(relaxed = true) + + every { store.state } returns state + val interactor = SearchInteractor(context, mockk(), store) val searchEngine: SearchEngine = mockk(relaxed = true) @@ -148,7 +162,11 @@ class SearchInteractorTest { @Test fun onClickSearchEngineSettings() { val navController: NavController = mockk() - val interactor = SearchInteractor(mockk(), navController, mockk()) + val store: SearchStore = mockk() + + every { store.state } returns mockk(relaxed = true) + + val interactor = SearchInteractor(mockk(), navController, store) every { navController.navigate(any() as NavDirections) } just Runs @@ -166,7 +184,9 @@ class SearchInteractorTest { val context: Context = mockk(relaxed = true) val applicationContext: FenixApplication = mockk(relaxed = true) every { context.applicationContext } returns applicationContext - val interactor = SearchInteractor(context, navController, mockk()) + val store: SearchStore = mockk() + every { store.state } returns mockk(relaxed = true) + val interactor = SearchInteractor(context, navController, store) val session = Session("http://mozilla.org", false) interactor.onExistingSessionSelected(session)