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)