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