diff --git a/.buildconfig.yml b/.buildconfig.yml
index 6c29d3afd4e..1c0bbbeb050 100644
--- a/.buildconfig.yml
+++ b/.buildconfig.yml
@@ -112,6 +112,10 @@ projects:
path: components/feature/search
description: 'Feature implementation connecting an engine implementation with the search module.'
publish: true
+ feature-searchwidget:
+ path: components/feature/searchwidget
+ description: 'Feature implementation for Search Widget'
+ publish: true
feature-serviceworker:
path: components/feature/serviceworker
description: 'Feature that adds support for service workers when using GeckoEngine.'
diff --git a/components/feature/searchwidget/build.gradle b/components/feature/searchwidget/build.gradle
new file mode 100644
index 00000000000..090f97081fa
--- /dev/null
+++ b/components/feature/searchwidget/build.gradle
@@ -0,0 +1,36 @@
+/* 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/. */
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ compileSdkVersion config.compileSdkVersion
+
+ defaultConfig {
+ minSdkVersion config.minSdkVersion
+ targetSdkVersion config.targetSdkVersion
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+}
+
+dependencies {
+ implementation project(':support-base')
+ implementation project(':ui-colors')
+ implementation Dependencies.androidx_core_ktx
+
+ testImplementation project(':support-test')
+ testImplementation Dependencies.androidx_test_core
+ testImplementation Dependencies.androidx_test_junit
+ testImplementation Dependencies.testing_robolectric
+ testImplementation Dependencies.testing_mockito
+}
+apply from: '../../../publish.gradle'
+ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
diff --git a/components/feature/searchwidget/proguard-rules.pro b/components/feature/searchwidget/proguard-rules.pro
new file mode 100644
index 00000000000..481bb434814
--- /dev/null
+++ b/components/feature/searchwidget/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/components/feature/searchwidget/src/main/AndroidManifest.xml b/components/feature/searchwidget/src/main/AndroidManifest.xml
new file mode 100644
index 00000000000..2b116f1096a
--- /dev/null
+++ b/components/feature/searchwidget/src/main/AndroidManifest.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/components/feature/searchwidget/src/main/java/mozilla/components/feature/searchwidget/AppSearchWidgetProvider.kt b/components/feature/searchwidget/src/main/java/mozilla/components/feature/searchwidget/AppSearchWidgetProvider.kt
new file mode 100644
index 00000000000..a86ad0f64d7
--- /dev/null
+++ b/components/feature/searchwidget/src/main/java/mozilla/components/feature/searchwidget/AppSearchWidgetProvider.kt
@@ -0,0 +1,274 @@
+/* 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.searchwidget
+
+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.speech.RecognizerIntent
+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.searchwidget.VoiceSearchActivity.Companion.SPEECH_PROCESSING
+
+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 VoiceSearchActivity
+ */
+ abstract fun voiceSearchActivity(): VoiceSearchActivity
+
+ /**
+ * 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()::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ putExtra(SPEECH_PROCESSING, true)
+ }
+
+ val intentSpeech = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
+
+ return intentSpeech.resolveActivity(context.packageManager)?.let {
+ PendingIntent.getActivity(
+ context,
+ REQUEST_CODE_VOICE, voiceIntent, IntentUtils.defaultIntentPendingFlags
+ )
+ }
+ }
+
+ 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 {
+ setIcon(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.setIcon(context: Context) {
+ // gradient color available for android:fillColor only on SDK 24+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ setImageViewResource(
+ R.id.mozac_button_search_widget_new_tab_icon,
+ R.drawable.mozac_ic_launcher_foreground
+ )
+ } else {
+ setImageViewBitmap(
+ R.id.mozac_button_search_widget_new_tab_icon,
+ AppCompatResources.getDrawable(
+ context,
+ R.drawable.mozac_ic_launcher_foreground
+ )?.toBitmap()
+ )
+ }
+
+ val appName = context.getString(R.string.app_name)
+ setContentDescription(
+ R.id.mozac_button_search_widget_new_tab_icon,
+ context.getString(R.string.search_widget_content_description, appName)
+ )
+ }
+
+ // 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
+
+ fun updateAllWidgets(context: Context, widgetClassNameApp: AppSearchWidgetProvider) {
+ val widgetManager = AppWidgetManager.getInstance(context)
+ val widgetIds = widgetManager.getAppWidgetIds(
+ ComponentName(
+ context,
+ widgetClassNameApp::class.java
+ )
+ )
+
+ if (widgetIds.isNotEmpty()) {
+ context.sendBroadcast(
+ Intent(context, widgetClassNameApp::class.java).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
+ }
+ }
+}
+
+enum class SearchWidgetProviderSize {
+ EXTRA_SMALL_V1,
+ EXTRA_SMALL_V2,
+ SMALL,
+ MEDIUM,
+ LARGE,
+}
diff --git a/components/feature/searchwidget/src/main/java/mozilla/components/feature/searchwidget/IntentUtils.kt b/components/feature/searchwidget/src/main/java/mozilla/components/feature/searchwidget/IntentUtils.kt
new file mode 100644
index 00000000000..8613393d574
--- /dev/null
+++ b/components/feature/searchwidget/src/main/java/mozilla/components/feature/searchwidget/IntentUtils.kt
@@ -0,0 +1,23 @@
+/* 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.searchwidget
+
+import android.app.PendingIntent
+import android.os.Build
+
+object IntentUtils {
+
+ /**
+ * Since Android 12 we need to set PendingIntent mutability explicitly, but Android 6 can be the minimum version
+ * This additional requirement improves your app's security.
+ * FLAG_IMMUTABLE -> Flag indicating that the created PendingIntent should be immutable.
+ */
+ val defaultIntentPendingFlags
+ get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ PendingIntent.FLAG_IMMUTABLE
+ } else {
+ 0 // No flags. Default behavior.
+ }
+}
diff --git a/components/feature/searchwidget/src/main/java/mozilla/components/feature/searchwidget/VoiceSearchActivity.kt b/components/feature/searchwidget/src/main/java/mozilla/components/feature/searchwidget/VoiceSearchActivity.kt
new file mode 100644
index 00000000000..fb5630cc07e
--- /dev/null
+++ b/components/feature/searchwidget/src/main/java/mozilla/components/feature/searchwidget/VoiceSearchActivity.kt
@@ -0,0 +1,126 @@
+/* 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.searchwidget
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import android.speech.RecognizerIntent
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.VisibleForTesting
+import androidx.appcompat.app.AppCompatActivity
+import java.util.Locale
+
+/**
+ * Launches voice recognition then uses it to start a new web search.
+ */
+abstract class VoiceSearchActivity : AppCompatActivity() {
+
+ /**
+ * Holds the intent that initially started this activity
+ * so that it can persist through the speech activity.
+ */
+ private var previousIntent: Intent? = null
+
+ @VisibleForTesting
+ private var activityResultLauncher: ActivityResultLauncher? = null
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ outState.putParcelable(PREVIOUS_INTENT, previousIntent)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ activityResultLauncher = getActivityResultLauncher()
+ if (Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).resolveActivity(packageManager) == null) {
+ finish()
+ return
+ }
+
+ // 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
+
+ /**
+ *records telemetry when speech recognizer popup is shown
+ */
+ abstract fun recordVoiceButtonTelemetry()
+
+ /**
+ *start intent after voice search for example a browser page is open with the spokenText
+ * @param spokenText what the user voice search
+ */
+ abstract fun startIntentAfterVoiceSearch(spokenText: String?)
+
+ @VisibleForTesting
+ private fun getActivityResultLauncher(): ActivityResultLauncher {
+ return registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) {
+ if (it.resultCode == Activity.RESULT_OK) {
+ val spokenText =
+ it.data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)?.first()
+ previousIntent?.apply {
+ startIntentAfterVoiceSearch(spokenText)
+ }
+ }
+ finish()
+ }
+ }
+
+ /**
+ * 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()
+ )
+ }
+ recordVoiceButtonTelemetry()
+ activityResultLauncher?.launch(intentSpeech)
+ }
+
+ /**
+ * 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 [VoiceSearchActivity] activity, used to store if the speech processing should start.
+ */
+ const val SPEECH_PROCESSING = "speech_processing"
+ }
+}
diff --git a/components/feature/searchwidget/src/main/res/drawable-hdpi/mozac_search_widget.png b/components/feature/searchwidget/src/main/res/drawable-hdpi/mozac_search_widget.png
new file mode 100644
index 00000000000..216234c1483
Binary files /dev/null and b/components/feature/searchwidget/src/main/res/drawable-hdpi/mozac_search_widget.png differ
diff --git a/components/feature/searchwidget/src/main/res/drawable/mozac_ic_launcher_foreground.xml b/components/feature/searchwidget/src/main/res/drawable/mozac_ic_launcher_foreground.xml
new file mode 100644
index 00000000000..844e479ef48
--- /dev/null
+++ b/components/feature/searchwidget/src/main/res/drawable/mozac_ic_launcher_foreground.xml
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/feature/searchwidget/src/main/res/drawable/mozac_ic_microphone_widget.xml b/components/feature/searchwidget/src/main/res/drawable/mozac_ic_microphone_widget.xml
new file mode 100644
index 00000000000..53e709cd81f
--- /dev/null
+++ b/components/feature/searchwidget/src/main/res/drawable/mozac_ic_microphone_widget.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/components/feature/searchwidget/src/main/res/drawable/mozac_ic_microphone_widget_padded.xml b/components/feature/searchwidget/src/main/res/drawable/mozac_ic_microphone_widget_padded.xml
new file mode 100644
index 00000000000..eaaea7dc809
--- /dev/null
+++ b/components/feature/searchwidget/src/main/res/drawable/mozac_ic_microphone_widget_padded.xml
@@ -0,0 +1,6 @@
+
+
diff --git a/components/feature/searchwidget/src/main/res/drawable/mozac_rounded_search_widget_background.xml b/components/feature/searchwidget/src/main/res/drawable/mozac_rounded_search_widget_background.xml
new file mode 100644
index 00000000000..d4cd59c1a52
--- /dev/null
+++ b/components/feature/searchwidget/src/main/res/drawable/mozac_rounded_search_widget_background.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/components/feature/searchwidget/src/main/res/layout/mozac_search_widget_extra_small_v1.xml b/components/feature/searchwidget/src/main/res/layout/mozac_search_widget_extra_small_v1.xml
new file mode 100644
index 00000000000..f9cf5d3089f
--- /dev/null
+++ b/components/feature/searchwidget/src/main/res/layout/mozac_search_widget_extra_small_v1.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/components/feature/searchwidget/src/main/res/layout/mozac_search_widget_extra_small_v2.xml b/components/feature/searchwidget/src/main/res/layout/mozac_search_widget_extra_small_v2.xml
new file mode 100644
index 00000000000..7d78096f131
--- /dev/null
+++ b/components/feature/searchwidget/src/main/res/layout/mozac_search_widget_extra_small_v2.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
diff --git a/components/feature/searchwidget/src/main/res/layout/mozac_search_widget_large.xml b/components/feature/searchwidget/src/main/res/layout/mozac_search_widget_large.xml
new file mode 100644
index 00000000000..7044e42e340
--- /dev/null
+++ b/components/feature/searchwidget/src/main/res/layout/mozac_search_widget_large.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/components/feature/searchwidget/src/main/res/layout/mozac_search_widget_medium.xml b/components/feature/searchwidget/src/main/res/layout/mozac_search_widget_medium.xml
new file mode 100644
index 00000000000..6b021ea0748
--- /dev/null
+++ b/components/feature/searchwidget/src/main/res/layout/mozac_search_widget_medium.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/components/feature/searchwidget/src/main/res/layout/mozac_search_widget_small.xml b/components/feature/searchwidget/src/main/res/layout/mozac_search_widget_small.xml
new file mode 100644
index 00000000000..da75440cf20
--- /dev/null
+++ b/components/feature/searchwidget/src/main/res/layout/mozac_search_widget_small.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
diff --git a/components/feature/searchwidget/src/main/res/layout/mozac_search_widget_small_no_mic.xml b/components/feature/searchwidget/src/main/res/layout/mozac_search_widget_small_no_mic.xml
new file mode 100644
index 00000000000..c838fc6277b
--- /dev/null
+++ b/components/feature/searchwidget/src/main/res/layout/mozac_search_widget_small_no_mic.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
diff --git a/components/feature/searchwidget/src/main/res/values-night/colors.xml b/components/feature/searchwidget/src/main/res/values-night/colors.xml
new file mode 100644
index 00000000000..d74cbd0201c
--- /dev/null
+++ b/components/feature/searchwidget/src/main/res/values-night/colors.xml
@@ -0,0 +1,10 @@
+
+
+
+ @color/photonDarkGrey60
+ @color/photonLightGrey05
+ @color/photonLightGrey05
+
+
diff --git a/components/feature/searchwidget/src/main/res/values/colors.xml b/components/feature/searchwidget/src/main/res/values/colors.xml
new file mode 100644
index 00000000000..dc6e846772a
--- /dev/null
+++ b/components/feature/searchwidget/src/main/res/values/colors.xml
@@ -0,0 +1,9 @@
+
+
+
+ @color/photonLightGrey10
+ @color/photonDarkGrey90
+ @color/photonDarkGrey90
+
diff --git a/components/feature/searchwidget/src/main/res/values/dimens.xml b/components/feature/searchwidget/src/main/res/values/dimens.xml
new file mode 100644
index 00000000000..d9611e74f1c
--- /dev/null
+++ b/components/feature/searchwidget/src/main/res/values/dimens.xml
@@ -0,0 +1,4 @@
+
+
+ 8dp
+
diff --git a/components/feature/searchwidget/src/main/res/values/strings.xml b/components/feature/searchwidget/src/main/res/values/strings.xml
new file mode 100644
index 00000000000..6e43e8720fe
--- /dev/null
+++ b/components/feature/searchwidget/src/main/res/values/strings.xml
@@ -0,0 +1,14 @@
+
+
+ Search Widget
+
+
+
+ Open a new %1$s tab
+
+ Search
+
+ Search the web
+
+ Voice search
+
diff --git a/components/feature/searchwidget/src/main/res/xml/mozac_search_widget_info.xml b/components/feature/searchwidget/src/main/res/xml/mozac_search_widget_info.xml
new file mode 100644
index 00000000000..fe4de999c7b
--- /dev/null
+++ b/components/feature/searchwidget/src/main/res/xml/mozac_search_widget_info.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/components/feature/searchwidget/src/test/java/mozilla/components/feature/searchwidget/AppSearchWidgetProviderTest.kt b/components/feature/searchwidget/src/test/java/mozilla/components/feature/searchwidget/AppSearchWidgetProviderTest.kt
new file mode 100644
index 00000000000..2a9763a47bc
--- /dev/null
+++ b/components/feature/searchwidget/src/test/java/mozilla/components/feature/searchwidget/AppSearchWidgetProviderTest.kt
@@ -0,0 +1,146 @@
+/* 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.searchwidget
+
+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,
+ AppSearchWidgetProvider.getLayout(SearchWidgetProviderSize.LARGE, showMic = false)
+ )
+ assertEquals(
+ R.layout.mozac_search_widget_large,
+ AppSearchWidgetProvider.getLayout(SearchWidgetProviderSize.LARGE, showMic = true)
+ )
+ }
+
+ @Test
+ fun testGetMediumLayout() {
+ assertEquals(
+ R.layout.mozac_search_widget_medium,
+ AppSearchWidgetProvider.getLayout(SearchWidgetProviderSize.MEDIUM, showMic = false)
+ )
+ assertEquals(
+ R.layout.mozac_search_widget_medium,
+ AppSearchWidgetProvider.getLayout(SearchWidgetProviderSize.MEDIUM, showMic = true)
+ )
+ }
+
+ @Test
+ fun testGetSmallLayout() {
+ assertEquals(
+ R.layout.mozac_search_widget_small_no_mic,
+ AppSearchWidgetProvider.getLayout(SearchWidgetProviderSize.SMALL, showMic = false)
+ )
+ assertEquals(
+ R.layout.mozac_search_widget_small,
+ AppSearchWidgetProvider.getLayout(SearchWidgetProviderSize.SMALL, showMic = true)
+ )
+ }
+
+ @Test
+ fun testGetExtraSmall2Layout() {
+ assertEquals(
+ R.layout.mozac_search_widget_extra_small_v2,
+ AppSearchWidgetProvider.getLayout(
+ SearchWidgetProviderSize.EXTRA_SMALL_V2,
+ showMic = false
+ )
+ )
+ assertEquals(
+ R.layout.mozac_search_widget_extra_small_v2,
+ AppSearchWidgetProvider.getLayout(
+ SearchWidgetProviderSize.EXTRA_SMALL_V2,
+ showMic = true
+ )
+ )
+ }
+
+ @Test
+ fun testGetExtraSmall1Layout() {
+ assertEquals(
+ R.layout.mozac_search_widget_extra_small_v1,
+ AppSearchWidgetProvider.getLayout(
+ SearchWidgetProviderSize.EXTRA_SMALL_V1,
+ showMic = false
+ )
+ )
+ assertEquals(
+ R.layout.mozac_search_widget_extra_small_v1,
+ AppSearchWidgetProvider.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/taskcluster/ci/config.yml b/taskcluster/ci/config.yml
index 12d81ff37ba..2627ea97b93 100644
--- a/taskcluster/ci/config.yml
+++ b/taskcluster/ci/config.yml
@@ -56,6 +56,7 @@ treeherder:
feature-readerview: feature-readerview
feature-recentlyclosed: feature-recentlyclosed
feature-search: feature-search
+ feature-searchwidget: feature-searchwidget
feature-serviceworker: feature-serviceworker
feature-session: feature-session
feature-share: feature-share