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)