Skip to content

Commit

Permalink
For mozilla-mobile#12565 Implement the common part of search widget i…
Browse files Browse the repository at this point in the history
…n Android Components
  • Loading branch information
iorgamgabriel committed Aug 11, 2022
1 parent eaabbcb commit a2bace4
Show file tree
Hide file tree
Showing 25 changed files with 982 additions and 0 deletions.
4 changes: 4 additions & 0 deletions .buildconfig.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
Expand Down
36 changes: 36 additions & 0 deletions components/feature/searchwidget/build.gradle
Original file line number Diff line number Diff line change
@@ -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)
21 changes: 21 additions & 0 deletions components/feature/searchwidget/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions components/feature/searchwidget/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="mozilla.components.feature.searchwidget"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<!-- Needed to interact with all apps installed on a device -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />

</manifest>
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
@@ -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.
}
}
Loading

0 comments on commit a2bace4

Please sign in to comment.