diff --git a/components/feature/search/build.gradle b/components/feature/search/build.gradle
index 4fcc7c88b36..aec5c7ae78f 100644
--- a/components/feature/search/build.gradle
+++ b/components/feature/search/build.gradle
@@ -45,7 +45,10 @@ dependencies {
implementation project(':service-location')
implementation project(':support-utils')
implementation project(':support-ktx')
+ implementation project(':ui-colors')
+ implementation project(':support-base')
+ implementation Dependencies.androidx_core_ktx
implementation Dependencies.kotlin_stdlib
testImplementation project(':support-test')
diff --git a/components/feature/search/src/main/AndroidManifest.xml b/components/feature/search/src/main/AndroidManifest.xml
index 7f9652e1ec9..e2ccfb4ee0a 100644
--- a/components/feature/search/src/main/AndroidManifest.xml
+++ b/components/feature/search/src/main/AndroidManifest.xml
@@ -1,5 +1,4 @@
-
+
diff --git a/components/feature/search/src/main/java/mozilla/components/feature/search/widget/AppSearchWidgetProvider.kt b/components/feature/search/src/main/java/mozilla/components/feature/search/widget/AppSearchWidgetProvider.kt
new file mode 100644
index 00000000000..82d296bdb13
--- /dev/null
+++ b/components/feature/search/src/main/java/mozilla/components/feature/search/widget/AppSearchWidgetProvider.kt
@@ -0,0 +1,309 @@
+/* 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 mozilla.components.feature.search.widget
+
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH
+import android.appwidget.AppWidgetProvider
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.view.View
+import android.widget.RemoteViews
+import androidx.annotation.Dimension
+import androidx.annotation.Dimension.DP
+import androidx.annotation.VisibleForTesting
+import androidx.appcompat.content.res.AppCompatResources
+import androidx.core.graphics.drawable.toBitmap
+import mozilla.components.feature.search.R
+import mozilla.components.feature.search.widget.BaseVoiceSearchActivity.Companion.SPEECH_PROCESSING
+import mozilla.components.support.utils.PendingIntentUtils
+
+/**
+ * An abstract [AppWidgetProvider] that implements core behaviour needed to support a Search Widget
+ * on the launcher.
+ */
+abstract class AppSearchWidgetProvider : AppWidgetProvider() {
+
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray
+ ) {
+ val textSearchIntent = createTextSearchIntent(context)
+ val voiceSearchIntent = createVoiceSearchIntent(context)
+
+ appWidgetIds.forEach { appWidgetId ->
+ updateWidgetLayout(
+ context = context,
+ appWidgetId = appWidgetId,
+ appWidgetManager = appWidgetManager,
+ voiceSearchIntent = voiceSearchIntent,
+ textSearchIntent = textSearchIntent
+ )
+ }
+ }
+
+ override fun onAppWidgetOptionsChanged(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetId: Int,
+ newOptions: Bundle?
+ ) {
+ val textSearchIntent = createTextSearchIntent(context)
+ val voiceSearchIntent = createVoiceSearchIntent(context)
+
+ updateWidgetLayout(
+ context = context,
+ appWidgetId = appWidgetId,
+ appWidgetManager = appWidgetManager,
+ voiceSearchIntent = voiceSearchIntent,
+ textSearchIntent = textSearchIntent
+ )
+ }
+
+ /**
+ * Builds pending intent that opens the browser and starts a new text search.
+ */
+ abstract fun createTextSearchIntent(context: Context): PendingIntent
+
+ /**
+ * If the microphone will appear on the Search Widget and the user can perform a voice search.
+ */
+ abstract fun shouldShowVoiceSearch(context: Context): Boolean
+
+ /**
+ * Activity that extends BaseVoiceSearchActivity.
+ */
+ abstract fun voiceSearchActivity(): Class
+
+ /**
+ * Config that sets the icons and the strings for search widget.
+ */
+ abstract val config: SearchWidgetConfig
+
+ /**
+ * Builds pending intent that starts a new voice search.
+ */
+ @VisibleForTesting
+ internal fun createVoiceSearchIntent(context: Context): PendingIntent? {
+ if (!shouldShowVoiceSearch(context)) {
+ return null
+ }
+
+ val voiceIntent = Intent(context, voiceSearchActivity()).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ putExtra(SPEECH_PROCESSING, true)
+ }
+
+ return PendingIntent.getActivity(
+ context,
+ REQUEST_CODE_VOICE, voiceIntent, PendingIntentUtils.defaultFlags
+ )
+ }
+
+ private fun updateWidgetLayout(
+ context: Context,
+ appWidgetId: Int,
+ appWidgetManager: AppWidgetManager,
+ voiceSearchIntent: PendingIntent?,
+ textSearchIntent: PendingIntent
+ ) {
+ val currentWidth =
+ appWidgetManager.getAppWidgetOptions(appWidgetId).getInt(OPTION_APPWIDGET_MIN_WIDTH)
+ val layoutSize = getLayoutSize(currentWidth)
+ // It's not enough to just hide the microphone on the "small" sized widget due to its design.
+ // The "small" widget needs a complete redesign, meaning it needs a new layout file.
+ val showMic = (voiceSearchIntent != null)
+ val layout = getLayout(layoutSize, showMic)
+ val text = getText(layoutSize, context)
+
+ val views =
+ createRemoteViews(context, layout, textSearchIntent, voiceSearchIntent, text)
+ appWidgetManager.updateAppWidget(appWidgetId, views)
+ }
+
+ private fun createRemoteViews(
+ context: Context,
+ layout: Int,
+ textSearchIntent: PendingIntent,
+ voiceSearchIntent: PendingIntent?,
+ text: String?
+ ): RemoteViews {
+ return RemoteViews(context.packageName, layout).apply {
+ setSearchWidgetIcon(context)
+ setMicrophoneIcon(context)
+ when (layout) {
+ R.layout.mozac_search_widget_extra_small_v1,
+ R.layout.mozac_search_widget_extra_small_v2,
+ R.layout.mozac_search_widget_small_no_mic -> {
+ setOnClickPendingIntent(
+ R.id.mozac_button_search_widget_new_tab,
+ textSearchIntent
+ )
+ }
+ R.layout.mozac_search_widget_small -> {
+ setOnClickPendingIntent(
+ R.id.mozac_button_search_widget_new_tab,
+ textSearchIntent
+ )
+ setOnClickPendingIntent(
+ R.id.mozac_button_search_widget_voice,
+ voiceSearchIntent
+ )
+ }
+ R.layout.mozac_search_widget_medium,
+ R.layout.mozac_search_widget_large -> {
+
+ setOnClickPendingIntent(
+ R.id.mozac_button_search_widget_new_tab,
+ textSearchIntent
+ )
+ setOnClickPendingIntent(
+ R.id.mozac_button_search_widget_voice,
+ voiceSearchIntent
+ )
+ setOnClickPendingIntent(
+ R.id.mozac_button_search_widget_new_tab_icon,
+ textSearchIntent
+ )
+ setTextViewText(R.id.mozac_button_search_widget_new_tab, text)
+
+ // Unlike "small" widget, "medium" and "large" sizes do not have separate layouts
+ // that exclude the microphone icon, which is why we must hide it accordingly here.
+ if (voiceSearchIntent == null) {
+ setViewVisibility(R.id.mozac_button_search_widget_voice, View.GONE)
+ }
+ }
+ }
+ }
+ }
+
+ private fun RemoteViews.setMicrophoneIcon(context: Context) {
+ setImageView(
+ context,
+ R.id.mozac_button_search_widget_voice,
+ config.searchWidgetMicrophoneResource
+ )
+ }
+
+ private fun RemoteViews.setSearchWidgetIcon(context: Context) {
+ setImageView(
+ context,
+ R.id.mozac_button_search_widget_new_tab_icon,
+ config.searchWidgetIconResource
+ )
+ val appName = context.getString(config.appName)
+ setContentDescription(
+ R.id.mozac_button_search_widget_new_tab_icon,
+ context.getString(R.string.search_widget_content_description, appName)
+ )
+ }
+
+ private fun RemoteViews.setImageView(context: Context, viewId: Int, resourceId: Int) {
+ // gradient color available for android:fillColor only on SDK 24+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ setImageViewResource(
+ viewId,
+ resourceId
+ )
+ } else {
+ setImageViewBitmap(
+ viewId,
+ AppCompatResources.getDrawable(
+ context,
+ resourceId
+ )?.toBitmap()
+ )
+ }
+ }
+
+ // Cell sizes obtained from the actual dimensions listed in search widget specs.
+ companion object {
+ private const val DP_EXTRA_SMALL = 64
+ private const val DP_SMALL = 100
+ private const val DP_MEDIUM = 192
+ private const val DP_LARGE = 256
+ private const val REQUEST_CODE_VOICE = 1
+
+ /**
+ * It updates AppSearchWidgetProvider size and microphone icon visibility.
+ */
+ fun updateAllWidgets(context: Context, clazz: Class) {
+ val widgetManager = AppWidgetManager.getInstance(context)
+ val widgetIds = widgetManager.getAppWidgetIds(
+ ComponentName(
+ context,
+ clazz
+ )
+ )
+ if (widgetIds.isNotEmpty()) {
+ context.sendBroadcast(
+ Intent(context, clazz).apply {
+ action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
+ putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, widgetIds)
+ }
+ )
+ }
+ }
+
+ @VisibleForTesting
+ internal fun getLayoutSize(@Dimension(unit = DP) dp: Int) = when {
+ dp >= DP_LARGE -> SearchWidgetProviderSize.LARGE
+ dp >= DP_MEDIUM -> SearchWidgetProviderSize.MEDIUM
+ dp >= DP_SMALL -> SearchWidgetProviderSize.SMALL
+ dp >= DP_EXTRA_SMALL -> SearchWidgetProviderSize.EXTRA_SMALL_V2
+ else -> SearchWidgetProviderSize.EXTRA_SMALL_V1
+ }
+
+ /**
+ * Get the layout resource to use for the search widget.
+ */
+ @VisibleForTesting
+ internal fun getLayout(size: SearchWidgetProviderSize, showMic: Boolean) = when (size) {
+ SearchWidgetProviderSize.LARGE -> R.layout.mozac_search_widget_large
+ SearchWidgetProviderSize.MEDIUM -> R.layout.mozac_search_widget_medium
+ SearchWidgetProviderSize.SMALL -> {
+ if (showMic) {
+ R.layout.mozac_search_widget_small
+ } else {
+ R.layout.mozac_search_widget_small_no_mic
+ }
+ }
+ SearchWidgetProviderSize.EXTRA_SMALL_V2 -> R.layout.mozac_search_widget_extra_small_v2
+ SearchWidgetProviderSize.EXTRA_SMALL_V1 -> R.layout.mozac_search_widget_extra_small_v1
+ }
+
+ /**
+ * Get the text to place in the search widget.
+ */
+ @VisibleForTesting
+ internal fun getText(layout: SearchWidgetProviderSize, context: Context) = when (layout) {
+ SearchWidgetProviderSize.MEDIUM -> context.getString(R.string.search_widget_text_short)
+ SearchWidgetProviderSize.LARGE -> context.getString(R.string.search_widget_text_long)
+ else -> null
+ }
+ }
+}
+
+/**
+ * Client App can set from this config icons and the app name for search widget.
+ */
+data class SearchWidgetConfig(
+ val searchWidgetIconResource: Int,
+ val searchWidgetMicrophoneResource: Int,
+ val appName: Int
+)
+
+internal enum class SearchWidgetProviderSize {
+ EXTRA_SMALL_V1,
+ EXTRA_SMALL_V2,
+ SMALL,
+ MEDIUM,
+ LARGE,
+}
diff --git a/components/feature/search/src/main/java/mozilla/components/feature/search/widget/BaseVoiceSearchActivity.kt b/components/feature/search/src/main/java/mozilla/components/feature/search/widget/BaseVoiceSearchActivity.kt
new file mode 100644
index 00000000000..b875996af12
--- /dev/null
+++ b/components/feature/search/src/main/java/mozilla/components/feature/search/widget/BaseVoiceSearchActivity.kt
@@ -0,0 +1,133 @@
+/* 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 mozilla.components.feature.search.widget
+
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.os.Bundle
+import android.speech.RecognizerIntent
+import androidx.activity.result.ActivityResult
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.VisibleForTesting
+import androidx.appcompat.app.AppCompatActivity
+import mozilla.components.support.base.log.logger.Logger
+import java.util.Locale
+
+/**
+ * Launches voice recognition then uses it to start a new web search.
+ */
+abstract class BaseVoiceSearchActivity : AppCompatActivity() {
+
+ /**
+ * Holds the intent that initially started this activity
+ * so that it can persist through the speech activity.
+ */
+ private var previousIntent: Intent? = null
+
+ private var activityResultLauncher: ActivityResultLauncher = getActivityResultLauncher()
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ outState.putParcelable(PREVIOUS_INTENT, previousIntent)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ // Retrieve the previous intent from the saved state
+ previousIntent = savedInstanceState?.get(PREVIOUS_INTENT) as Intent?
+ if (previousIntent.isForSpeechProcessing()) {
+ // Don't reopen the speech recognizer
+ return
+ }
+
+ // The intent property is nullable, but the rest of the code below assumes it is not.
+ val intent = intent?.let { Intent(intent) } ?: Intent()
+ if (intent.isForSpeechProcessing()) {
+ previousIntent = intent
+ displaySpeechRecognizer()
+ } else {
+ finish()
+ }
+ }
+
+ /**
+ * Language locale for Voice Search.
+ */
+ abstract fun getCurrentLocale(): Locale
+
+ /**
+ * Speech recognizer popup is shown.
+ */
+ abstract fun onSpeechRecognitionStarted()
+
+ /**
+ * Start intent after voice search ,for example a browser page is open with the spokenText.
+ * @param spokenText what the user voice search
+ */
+ abstract fun onSpeechRecognitionEnded(spokenText: String)
+
+ @VisibleForTesting
+ internal fun activityResultImplementation(activityResult: ActivityResult) {
+ if (activityResult.resultCode == Activity.RESULT_OK) {
+ val spokenText =
+ activityResult.data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
+ ?.first()
+ previousIntent?.apply {
+ spokenText?.let { onSpeechRecognitionEnded(it) }
+ }
+ }
+ finish()
+ }
+
+ private fun getActivityResultLauncher(): ActivityResultLauncher {
+ return registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) {
+ activityResultImplementation(it)
+ }
+ }
+
+ /**
+ * Displays a speech recognizer popup that listens for input from the user.
+ */
+ private fun displaySpeechRecognizer() {
+ val intentSpeech = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
+ putExtra(
+ RecognizerIntent.EXTRA_LANGUAGE_MODEL,
+ RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
+ )
+ putExtra(
+ RecognizerIntent.EXTRA_LANGUAGE,
+ getCurrentLocale()
+ )
+ }
+ onSpeechRecognitionStarted()
+ try {
+ activityResultLauncher.launch(intentSpeech)
+ } catch (e: ActivityNotFoundException) {
+ Logger(TAG).error("ActivityNotFoundException " + e.message.toString())
+ finish()
+ }
+ }
+
+ /**
+ * Returns true if the [SPEECH_PROCESSING] extra is present and set to true.
+ * Returns false if the intent is null.
+ */
+ private fun Intent?.isForSpeechProcessing(): Boolean =
+ this?.getBooleanExtra(SPEECH_PROCESSING, false) == true
+
+ companion object {
+ const val PREVIOUS_INTENT = "org.mozilla.components.previous_intent"
+
+ /**
+ * In [BaseVoiceSearchActivity] activity, used to store if the speech processing should start.
+ */
+ const val SPEECH_PROCESSING = "speech_processing"
+ const val TAG = "BaseVoiceSearchActivity"
+ }
+}
diff --git a/components/feature/search/src/main/res/drawable/mozac_rounded_search_widget_background.xml b/components/feature/search/src/main/res/drawable/mozac_rounded_search_widget_background.xml
new file mode 100644
index 00000000000..fd61fe25577
--- /dev/null
+++ b/components/feature/search/src/main/res/drawable/mozac_rounded_search_widget_background.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/components/feature/search/src/main/res/layout/mozac_search_widget_extra_small_v1.xml b/components/feature/search/src/main/res/layout/mozac_search_widget_extra_small_v1.xml
new file mode 100644
index 00000000000..ee95021031b
--- /dev/null
+++ b/components/feature/search/src/main/res/layout/mozac_search_widget_extra_small_v1.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
diff --git a/components/feature/search/src/main/res/layout/mozac_search_widget_extra_small_v2.xml b/components/feature/search/src/main/res/layout/mozac_search_widget_extra_small_v2.xml
new file mode 100644
index 00000000000..c5edbbb48ed
--- /dev/null
+++ b/components/feature/search/src/main/res/layout/mozac_search_widget_extra_small_v2.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
diff --git a/components/feature/search/src/main/res/layout/mozac_search_widget_large.xml b/components/feature/search/src/main/res/layout/mozac_search_widget_large.xml
new file mode 100644
index 00000000000..7801adb460a
--- /dev/null
+++ b/components/feature/search/src/main/res/layout/mozac_search_widget_large.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/feature/search/src/main/res/layout/mozac_search_widget_medium.xml b/components/feature/search/src/main/res/layout/mozac_search_widget_medium.xml
new file mode 100644
index 00000000000..8731d4017c3
--- /dev/null
+++ b/components/feature/search/src/main/res/layout/mozac_search_widget_medium.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/feature/search/src/main/res/layout/mozac_search_widget_small.xml b/components/feature/search/src/main/res/layout/mozac_search_widget_small.xml
new file mode 100644
index 00000000000..c051da230c0
--- /dev/null
+++ b/components/feature/search/src/main/res/layout/mozac_search_widget_small.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
diff --git a/components/feature/search/src/main/res/layout/mozac_search_widget_small_no_mic.xml b/components/feature/search/src/main/res/layout/mozac_search_widget_small_no_mic.xml
new file mode 100644
index 00000000000..c838fc6277b
--- /dev/null
+++ b/components/feature/search/src/main/res/layout/mozac_search_widget_small_no_mic.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
diff --git a/components/feature/search/src/main/res/values-night/colors.xml b/components/feature/search/src/main/res/values-night/colors.xml
new file mode 100644
index 00000000000..50b0875a99a
--- /dev/null
+++ b/components/feature/search/src/main/res/values-night/colors.xml
@@ -0,0 +1,10 @@
+
+
+
+ @color/photonDarkGrey60
+ @color/photonLightGrey05
+ @color/photonLightGrey05
+
+
diff --git a/components/feature/search/src/main/res/values/colors.xml b/components/feature/search/src/main/res/values/colors.xml
new file mode 100644
index 00000000000..07c5961bbe5
--- /dev/null
+++ b/components/feature/search/src/main/res/values/colors.xml
@@ -0,0 +1,9 @@
+
+
+
+ @color/photonLightGrey10
+ @color/photonDarkGrey90
+ @color/photonDarkGrey90
+
diff --git a/components/feature/search/src/main/res/values/dimens.xml b/components/feature/search/src/main/res/values/dimens.xml
new file mode 100644
index 00000000000..52c1f7545a7
--- /dev/null
+++ b/components/feature/search/src/main/res/values/dimens.xml
@@ -0,0 +1,7 @@
+
+
+
+ 8dp
+
diff --git a/components/feature/search/src/main/res/values/strings.xml b/components/feature/search/src/main/res/values/strings.xml
new file mode 100644
index 00000000000..6f1f2b42e78
--- /dev/null
+++ b/components/feature/search/src/main/res/values/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ Open a new %1$s tab
+
+ Search
+
+ Search the web
+
+ Voice search
+
diff --git a/components/feature/search/src/test/java/mozilla/components/feature/search/widget/AppSearchWidgetProviderTest.kt b/components/feature/search/src/test/java/mozilla/components/feature/search/widget/AppSearchWidgetProviderTest.kt
new file mode 100644
index 00000000000..2f5286ede4a
--- /dev/null
+++ b/components/feature/search/src/test/java/mozilla/components/feature/search/widget/AppSearchWidgetProviderTest.kt
@@ -0,0 +1,160 @@
+/* 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 mozilla.components.feature.search.widget
+
+import mozilla.components.feature.search.R
+import mozilla.components.feature.search.widget.AppSearchWidgetProvider.Companion.getLayout
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.doReturn
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class AppSearchWidgetProviderTest {
+
+ @Test
+ fun testGetLayoutSize() {
+ val sizes = mapOf(
+ 0 to SearchWidgetProviderSize.EXTRA_SMALL_V1,
+ 10 to SearchWidgetProviderSize.EXTRA_SMALL_V1,
+ 63 to SearchWidgetProviderSize.EXTRA_SMALL_V1,
+ 64 to SearchWidgetProviderSize.EXTRA_SMALL_V2,
+ 99 to SearchWidgetProviderSize.EXTRA_SMALL_V2,
+ 100 to SearchWidgetProviderSize.SMALL,
+ 191 to SearchWidgetProviderSize.SMALL,
+ 192 to SearchWidgetProviderSize.MEDIUM,
+ 255 to SearchWidgetProviderSize.MEDIUM,
+ 256 to SearchWidgetProviderSize.LARGE,
+ 1000 to SearchWidgetProviderSize.LARGE
+ )
+
+ for ((dp, layoutSize) in sizes) {
+ assertEquals(layoutSize, AppSearchWidgetProvider.getLayoutSize(dp))
+ }
+ }
+
+ @Test
+ fun testGetLargeLayout() {
+ assertEquals(
+ R.layout.mozac_search_widget_large,
+ getLayout(SearchWidgetProviderSize.LARGE, showMic = false)
+ )
+ assertEquals(
+ R.layout.mozac_search_widget_large,
+ getLayout(SearchWidgetProviderSize.LARGE, showMic = true)
+ )
+ }
+
+ @Test
+ fun testGetMediumLayout() {
+ assertEquals(
+ R.layout.mozac_search_widget_medium,
+ getLayout(SearchWidgetProviderSize.MEDIUM, showMic = false)
+ )
+ assertEquals(
+ R.layout.mozac_search_widget_medium,
+ getLayout(SearchWidgetProviderSize.MEDIUM, showMic = true)
+ )
+ }
+
+ @Test
+ fun testGetSmallLayout() {
+ assertEquals(
+ R.layout.mozac_search_widget_small_no_mic,
+ getLayout(SearchWidgetProviderSize.SMALL, showMic = false)
+ )
+ assertEquals(
+ R.layout.mozac_search_widget_small,
+ getLayout(SearchWidgetProviderSize.SMALL, showMic = true)
+ )
+ }
+
+ @Test
+ fun testGetExtraSmall2Layout() {
+ assertEquals(
+ R.layout.mozac_search_widget_extra_small_v2,
+ getLayout(
+ SearchWidgetProviderSize.EXTRA_SMALL_V2,
+ showMic = false
+ )
+ )
+ assertEquals(
+ R.layout.mozac_search_widget_extra_small_v2,
+ getLayout(
+ SearchWidgetProviderSize.EXTRA_SMALL_V2,
+ showMic = true
+ )
+ )
+ }
+
+ @Test
+ fun testGetExtraSmall1Layout() {
+ assertEquals(
+ R.layout.mozac_search_widget_extra_small_v1,
+ getLayout(
+ SearchWidgetProviderSize.EXTRA_SMALL_V1,
+ showMic = false
+ )
+ )
+ assertEquals(
+ R.layout.mozac_search_widget_extra_small_v1,
+ getLayout(
+ SearchWidgetProviderSize.EXTRA_SMALL_V1,
+ showMic = true
+ )
+ )
+ }
+
+ @Test
+ fun testGetText() {
+ assertEquals(
+ testContext.getString(R.string.search_widget_text_long),
+ AppSearchWidgetProvider.getText(
+ SearchWidgetProviderSize.LARGE,
+ testContext
+ )
+ )
+ assertEquals(
+ testContext.getString(R.string.search_widget_text_short),
+ AppSearchWidgetProvider.getText(
+ SearchWidgetProviderSize.MEDIUM,
+ testContext
+ )
+ )
+ assertNull(
+ AppSearchWidgetProvider.getText(
+ SearchWidgetProviderSize.SMALL,
+ testContext
+ )
+ )
+ assertNull(
+ AppSearchWidgetProvider.getText(
+ SearchWidgetProviderSize.EXTRA_SMALL_V1,
+ testContext
+ )
+ )
+ assertNull(
+ AppSearchWidgetProvider.getText(
+ SearchWidgetProviderSize.EXTRA_SMALL_V2,
+ testContext
+ )
+ )
+ }
+
+ @Test
+ fun `GIVEN voice search is disabled WHEN createVoiceSearchIntent is called THEN it returns null`() {
+ val appSearchWidgetProvider: AppSearchWidgetProvider =
+ mock()
+ doReturn(false).`when`(appSearchWidgetProvider).shouldShowVoiceSearch(testContext)
+
+ val result = appSearchWidgetProvider.createVoiceSearchIntent(testContext)
+
+ assertNull(result)
+ }
+}
diff --git a/components/feature/search/src/test/java/mozilla/components/feature/search/widget/BaseVoiceSearchActivityExtendedForTests.kt b/components/feature/search/src/test/java/mozilla/components/feature/search/widget/BaseVoiceSearchActivityExtendedForTests.kt
new file mode 100644
index 00000000000..a3ea6070338
--- /dev/null
+++ b/components/feature/search/src/test/java/mozilla/components/feature/search/widget/BaseVoiceSearchActivityExtendedForTests.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 mozilla.components.feature.search.widget
+
+import java.util.Locale
+
+class BaseVoiceSearchActivityExtendedForTests : BaseVoiceSearchActivity() {
+
+ override fun getCurrentLocale(): Locale {
+ return Locale.getDefault()
+ }
+
+ override fun onSpeechRecognitionStarted() {
+ }
+
+ override fun onSpeechRecognitionEnded(spokenText: String) {
+ }
+}
diff --git a/components/feature/search/src/test/java/mozilla/components/feature/search/widget/BaseVoiceSearchActivityTest.kt b/components/feature/search/src/test/java/mozilla/components/feature/search/widget/BaseVoiceSearchActivityTest.kt
new file mode 100644
index 00000000000..9f74c3da5f4
--- /dev/null
+++ b/components/feature/search/src/test/java/mozilla/components/feature/search/widget/BaseVoiceSearchActivityTest.kt
@@ -0,0 +1,133 @@
+/* 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 mozilla.components.feature.search.widget
+
+import android.app.Activity
+import android.app.Activity.RESULT_OK
+import android.content.ComponentName
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Bundle
+import android.speech.RecognizerIntent.ACTION_RECOGNIZE_SPEECH
+import android.speech.RecognizerIntent.EXTRA_RESULTS
+import androidx.activity.result.ActivityResult
+import mozilla.components.feature.search.widget.BaseVoiceSearchActivity.Companion.PREVIOUS_INTENT
+import mozilla.components.feature.search.widget.BaseVoiceSearchActivity.Companion.SPEECH_PROCESSING
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.android.controller.ActivityController
+import org.robolectric.shadows.ShadowActivity
+
+@RunWith(RobolectricTestRunner::class)
+class BaseVoiceSearchActivityTest {
+
+ private lateinit var controller: ActivityController
+ private lateinit var activity: BaseVoiceSearchActivityExtendedForTests
+ private lateinit var shadow: ShadowActivity
+
+ @Before
+ fun setup() {
+ val intent = Intent()
+ intent.putExtra(SPEECH_PROCESSING, true)
+
+ controller = Robolectric.buildActivity(BaseVoiceSearchActivityExtendedForTests::class.java, intent)
+ activity = controller.get()
+ shadow = shadowOf(activity)
+ }
+
+ private fun allowVoiceIntentToResolveActivity() {
+ val shadowPackageManager = shadowOf(testContext.packageManager)
+ val component = ComponentName("com.test", "Test")
+ shadowPackageManager.addActivityIfNotPresent(component)
+ shadowPackageManager.addIntentFilterForActivity(
+ component,
+ IntentFilter(ACTION_RECOGNIZE_SPEECH).apply { addCategory(Intent.CATEGORY_DEFAULT) }
+ )
+ }
+
+ @Test
+ fun `process intent with speech processing set to true`() {
+ val intent = Intent()
+ intent.putStringArrayListExtra(EXTRA_RESULTS, ArrayList(listOf("hello world")))
+ val activityResult = ActivityResult(RESULT_OK, intent)
+ controller.get().activityResultImplementation(activityResult)
+
+ assertTrue(activity.isFinishing)
+ }
+
+ @Test
+ fun `process intent with speech processing set to false`() {
+ allowVoiceIntentToResolveActivity()
+ val intent = Intent()
+ intent.putExtra(SPEECH_PROCESSING, false)
+
+ val controller = Robolectric.buildActivity(BaseVoiceSearchActivityExtendedForTests::class.java, intent)
+ val activity = controller.get()
+
+ controller.create()
+
+ assertTrue(activity.isFinishing)
+ }
+
+ @Test
+ fun `process null intent`() {
+ allowVoiceIntentToResolveActivity()
+ val controller = Robolectric.buildActivity(BaseVoiceSearchActivityExtendedForTests::class.java, null)
+ val activity = controller.get()
+
+ controller.create()
+
+ assertTrue(activity.isFinishing)
+ }
+
+ @Test
+ fun `save previous intent to instance state`() {
+ allowVoiceIntentToResolveActivity()
+ val previousIntent = Intent().apply {
+ putExtra(SPEECH_PROCESSING, true)
+ }
+ val savedInstanceState = Bundle().apply {
+ putParcelable(PREVIOUS_INTENT, previousIntent)
+ }
+ val outState = Bundle()
+
+ controller.create(savedInstanceState)
+ controller.saveInstanceState(outState)
+
+ assertEquals(previousIntent, outState.getParcelable(PREVIOUS_INTENT))
+ }
+
+ @Test
+ fun `process intent with speech processing in previous intent set to true`() {
+ allowVoiceIntentToResolveActivity()
+ val savedInstanceState = Bundle()
+ val previousIntent = Intent().apply {
+ putExtra(SPEECH_PROCESSING, true)
+ }
+ savedInstanceState.putParcelable(PREVIOUS_INTENT, previousIntent)
+
+ controller.create(savedInstanceState)
+
+ assertFalse(activity.isFinishing)
+ assertNull(shadow.peekNextStartedActivityForResult())
+ }
+
+ @Test
+ fun `handle invalid result code`() {
+ val activityResult = ActivityResult(Activity.RESULT_CANCELED, Intent())
+ controller.get().activityResultImplementation(activityResult)
+
+ assertTrue(activity.isFinishing)
+ }
+}
diff --git a/docs/changelog.md b/docs/changelog.md
index 19d86d13d22..26c4a43e91f 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -11,6 +11,9 @@ permalink: /changelog/
* [Gecko](https://github.com/mozilla-mobile/android-components/blob/main/buildSrc/src/main/java/Gecko.kt)
* [Configuration](https://github.com/mozilla-mobile/android-components/blob/main/.config.yml)
+* **feature-search**:
+ * Implement the common part of search widget in Android Components [#12565](https://github.com/mozilla-mobile/android-components/issues/12565).
+
* **feature-prompts**:
* Added prompt dismiss listener to `ChoicePromptDelegate`. [#12562](https://github.com/mozilla-mobile/android-components/issues/12562)