diff --git a/app/metrics.yaml b/app/metrics.yaml index dc1e4f58bd8b..168ba0ceecff 100644 --- a/app/metrics.yaml +++ b/app/metrics.yaml @@ -480,7 +480,6 @@ metrics: notification_emails: - fenix-core@mozilla.com expires: "2020-09-01" - toolbar_position: type: string lifetime: application @@ -495,7 +494,20 @@ metrics: notification_emails: - fenix-core@mozilla.com expires: "2020-09-01" - + search_widget_installed: + type: boolean + lifetime: application + description: | + Whether or not the search widget is installed + send_in_pings: + - metrics + bugs: + - https://github.com/mozilla-mobile/fenix/issues/9488 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/10958 + notification_emails: + - fenix-core@mozilla.com + expires: "2020-09-01" search.default_engine: code: @@ -1356,6 +1368,53 @@ search_widget: - fenix-core@mozilla.com expires: "2020-09-01" +search_widget_cfr: + displayed: + type: event + description: | + The search widget cfr was displayed. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/9488 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/10958 + notification_emails: + - fenix-core@mozilla.com + expires: "2020-09-01" + add_widget_pressed: + type: event + description: | + The user pressed the "add widget" button. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/9488 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/10958 + notification_emails: + - fenix-core@mozilla.com + expires: "2020-09-01" + not_now_pressed: + type: event + description: | + The user pressed the "not now" button. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/9488 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/10958 + notification_emails: + - fenix-core@mozilla.com + expires: "2020-09-01" + canceled: + type: event + description: | + The user dismissed the search widget cfr by + tapping outside of the prompt + bugs: + - https://github.com/mozilla-mobile/fenix/issues/9488 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/10958 + notification_emails: + - fenix-core@mozilla.com + expires: "2020-09-01" + private_browsing_mode: garbage_icon: type: event diff --git a/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt b/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt index d539cdecd7c7..9f3dbf6794dc 100644 --- a/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt +++ b/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt @@ -53,4 +53,10 @@ object FeatureFlags { * Enables new voice search feature */ val voiceSearch = Config.channel.isNightlyOrDebug + + /** + * Allows search widget CFR to be displayed. + * This is a placeholder for the experimentation framework determining cohorts. + */ + val searchWidgetCFR = Config.channel.isDebug } diff --git a/app/src/main/java/org/mozilla/fenix/cfr/SearchWidgetCFR.kt b/app/src/main/java/org/mozilla/fenix/cfr/SearchWidgetCFR.kt new file mode 100644 index 000000000000..00f0c084f1f5 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/cfr/SearchWidgetCFR.kt @@ -0,0 +1,98 @@ +/* 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 org.mozilla.fenix.cfr + +import android.app.Dialog +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.core.view.marginTop +import kotlinx.android.synthetic.main.search_widget_cfr.view.* +import kotlinx.android.synthetic.main.tracking_protection_onboarding_popup.view.drop_down_triangle +import kotlinx.android.synthetic.main.tracking_protection_onboarding_popup.view.pop_up_triangle +import org.mozilla.fenix.FeatureFlags +import org.mozilla.fenix.R +import org.mozilla.fenix.components.SearchWidgetCreator +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.utils.Settings + +/** + * Displays a CFR above the HomeFragment toolbar that recommends usage / installation of the search widget. + */ +class SearchWidgetCFR( + private val context: Context, + private val getToolbar: () -> View +) { + + fun displayIfNecessary() { + if (!context.settings().shouldDisplaySearchWidgetCFR() || !FeatureFlags.searchWidgetCFR) { return } + showSearchWidgetCFR() + } + + @Suppress("MagicNumber", "InflateParams") + private fun showSearchWidgetCFR() { + context.settings().incrementSearchWidgetCFRDisplayed() + + val searchWidgetCFRDialog = Dialog(context) + val layout = LayoutInflater.from(context) + .inflate(R.layout.search_widget_cfr, null) + val isBottomToolbar = Settings.getInstance(context).shouldUseBottomToolbar + + layout.drop_down_triangle.isGone = isBottomToolbar + layout.pop_up_triangle.isVisible = isBottomToolbar + + val toolbar = getToolbar() + + val gravity = if (isBottomToolbar) { + Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM + } else { + Gravity.CENTER_HORIZONTAL or Gravity.TOP + } + + layout.cfr_neg_button.setOnClickListener { + context.components.analytics.metrics.track(Event.SearchWidgetCFRNotNowPressed) + searchWidgetCFRDialog.dismiss() + context.settings().manuallyDismissSearchWidgetCFR() + } + + layout.cfr_pos_button.setOnClickListener { + context.components.analytics.metrics.track(Event.SearchWidgetCFRAddWidgetPressed) + SearchWidgetCreator.createSearchWidget(context) + searchWidgetCFRDialog.dismiss() + context.settings().manuallyDismissSearchWidgetCFR() + } + + searchWidgetCFRDialog.apply { + setContentView(layout) + } + + searchWidgetCFRDialog.window?.let { + it.setGravity(gravity) + val attr = it.attributes + attr.y = + (toolbar.y + toolbar.height - toolbar.marginTop - toolbar.paddingTop).toInt() + it.attributes = attr + it.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + } + + searchWidgetCFRDialog.setOnCancelListener { + context.components.analytics.metrics.track(Event.SearchWidgetCFRCanceled) + } + + searchWidgetCFRDialog.setOnDismissListener { + context.settings().incrementSearchWidgetCFRDismissed() + } + + searchWidgetCFRDialog.show() + context.components.analytics.metrics.track(Event.SearchWidgetCFRDisplayed) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/components/SearchWidgetCreator.kt b/app/src/main/java/org/mozilla/fenix/components/SearchWidgetCreator.kt new file mode 100644 index 000000000000..41734d7549e6 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/components/SearchWidgetCreator.kt @@ -0,0 +1,33 @@ +/* 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 org.mozilla.fenix.components + +import android.annotation.TargetApi +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.os.Build +import org.mozilla.gecko.search.SearchWidgetProvider + +/** + * Handles the creation of the pinning search widget dialog. + */ +object SearchWidgetCreator { + + /** + * Attempts to display a prompt requesting the user pin the search widget + * Returns true if the prompt is displayed successfully, and false otherwise. + */ + @TargetApi(Build.VERSION_CODES.O) + fun createSearchWidget(context: Context): Boolean { + val appWidgetManager: AppWidgetManager = context.getSystemService(AppWidgetManager::class.java) + if (!appWidgetManager.isRequestPinAppWidgetSupported) { return false } + + val myProvider = ComponentName(context, SearchWidgetProvider::class.java) + appWidgetManager.requestPinAppWidget(myProvider, null, null) + + return true + } +} diff --git a/app/src/main/java/org/mozilla/fenix/components/metrics/GleanMetricsService.kt b/app/src/main/java/org/mozilla/fenix/components/metrics/GleanMetricsService.kt index bb3e09f11716..7989e6ce1a53 100644 --- a/app/src/main/java/org/mozilla/fenix/components/metrics/GleanMetricsService.kt +++ b/app/src/main/java/org/mozilla/fenix/components/metrics/GleanMetricsService.kt @@ -36,6 +36,7 @@ import org.mozilla.fenix.GleanMetrics.SearchDefaultEngine import org.mozilla.fenix.GleanMetrics.SearchShortcuts import org.mozilla.fenix.GleanMetrics.SearchSuggestions import org.mozilla.fenix.GleanMetrics.SearchWidget +import org.mozilla.fenix.GleanMetrics.SearchWidgetCfr import org.mozilla.fenix.GleanMetrics.SyncAccount import org.mozilla.fenix.GleanMetrics.SyncAuth import org.mozilla.fenix.GleanMetrics.Tab @@ -529,6 +530,19 @@ private val Event.wrapper: EventWrapper<*>? is Event.VoiceSearchTapped -> EventWrapper( { VoiceSearch.tapped.record(it) } ) + is Event.SearchWidgetCFRDisplayed -> EventWrapper( + { SearchWidgetCfr.displayed.record(it) } + ) + is Event.SearchWidgetCFRCanceled -> EventWrapper( + { SearchWidgetCfr.canceled.record(it) } + ) + is Event.SearchWidgetCFRNotNowPressed -> EventWrapper( + { SearchWidgetCfr.notNowPressed.record(it) } + ) + is Event.SearchWidgetCFRAddWidgetPressed -> EventWrapper( + { SearchWidgetCfr.addWidgetPressed.record(it) } + ) + // Don't record other events in Glean: is Event.AddBookmark -> null is Event.OpenedBookmark -> null @@ -584,6 +598,9 @@ class GleanMetricsService(private val context: Context) : MetricsService { adjustAdGroup.set(context.settings().adjustAdGroup) adjustCreative.set(context.settings().adjustCreative) adjustNetwork.set(context.settings().adjustNetwork) + + searchWidgetInstalled.set(context.settings().searchWidgetInstalled) + val topSitesSize = context.settings().topSitesSize hasTopSites.set(topSitesSize > 0) if (topSitesSize > 0) { diff --git a/app/src/main/java/org/mozilla/fenix/components/metrics/Metrics.kt b/app/src/main/java/org/mozilla/fenix/components/metrics/Metrics.kt index 4b7d49839f2c..3bcc9ead4014 100644 --- a/app/src/main/java/org/mozilla/fenix/components/metrics/Metrics.kt +++ b/app/src/main/java/org/mozilla/fenix/components/metrics/Metrics.kt @@ -168,6 +168,10 @@ sealed class Event { object AddonsOpenInSettings : Event() object AddonsOpenInToolbarMenu : Event() object VoiceSearchTapped : Event() + object SearchWidgetCFRDisplayed : Event() + object SearchWidgetCFRCanceled : Event() + object SearchWidgetCFRNotNowPressed : Event() + object SearchWidgetCFRAddWidgetPressed : Event() // Interaction events with extras diff --git a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt index 8d3f9461c020..ccaf1c4b4dd5 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt @@ -26,6 +26,7 @@ import androidx.constraintlayout.widget.ConstraintSet.PARENT_ID import androidx.constraintlayout.widget.ConstraintSet.TOP import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat +import androidx.core.view.doOnLayout import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.fragment.app.Fragment @@ -78,6 +79,7 @@ import org.mozilla.fenix.addons.runIfFragmentIsAttached import org.mozilla.fenix.browser.BrowserAnimator.Companion.getToolbarNavOptions import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.collections.SaveCollectionStep +import org.mozilla.fenix.cfr.SearchWidgetCFR import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.PrivateShortcutCreateManager import org.mozilla.fenix.components.StoreProvider @@ -380,6 +382,14 @@ class HomeFragment : Fragment() { ) } } + + // We call this onLayout so that the bottom bar width is correctly set for us to center + // the CFR in. + view.toolbar_wrapper.doOnLayout { + if (!browsingModeManager.mode.isPrivate) { + SearchWidgetCFR(view.context) { view.toolbar_wrapper }.displayIfNecessary() + } + } } override fun onDestroyView() { diff --git a/app/src/main/java/org/mozilla/fenix/search/SearchController.kt b/app/src/main/java/org/mozilla/fenix/search/SearchController.kt index c69515c53a98..5a01ba3cea52 100644 --- a/app/src/main/java/org/mozilla/fenix/search/SearchController.kt +++ b/app/src/main/java/org/mozilla/fenix/search/SearchController.kt @@ -81,6 +81,8 @@ class DefaultSearchController( val event = if (url.isUrl()) { Event.EnteredUrl(false) } else { + activity.settings().incrementActiveSearchCount() + val searchAccessPoint = when (store.state.searchAccessPoint) { NONE -> ACTION else -> store.state.searchAccessPoint @@ -142,6 +144,8 @@ class DefaultSearchController( } override fun handleSearchTermsTapped(searchTerms: String) { + activity.settings().incrementActiveSearchCount() + activity.openToBrowserAndLoad( searchTermOrURL = searchTerms, newTab = store.state.session == null, diff --git a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt index f63b874c0767..f7caa06c0b47 100644 --- a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt +++ b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt @@ -55,6 +55,7 @@ class Settings private constructor( const val trackingProtectionOnboardingMaximumCount = 1 const val FENIX_PREFERENCES = "fenix_preferences" + private const val showSearchWidgetCFRMaxCount = 3 private const val BLOCKED_INT = 0 private const val ASK_TO_ALLOW_INT = 1 private const val ALLOWED_INT = 2 @@ -149,6 +150,63 @@ class Settings private constructor( preferences.getBoolean(appContext.getString(R.string.pref_key_migrating_from_firefox_nightly_tip), true) && preferences.getBoolean(appContext.getString(R.string.pref_key_migrating_from_fenix_tip), true) + private val activeSearchCount by intPreference( + appContext.getPreferenceKey(R.string.pref_key_search_count), + default = 0 + ) + + fun incrementActiveSearchCount() { + preferences.edit().putInt( + appContext.getPreferenceKey(R.string.pref_key_search_count), + activeSearchCount + 1 + ).apply() + } + + private val isActiveSearcher: Boolean + get() = activeSearchCount > 2 + + fun shouldDisplaySearchWidgetCFR(): Boolean = + isActiveSearcher && + searchWidgetCFRDismissCount < showSearchWidgetCFRMaxCount && + !searchWidgetInstalled && + !searchWidgetCFRManuallyDismissed + + private val searchWidgetCFRDisplayCount by intPreference( + appContext.getPreferenceKey(R.string.pref_key_search_widget_cfr_display_count), + default = 0 + ) + + fun incrementSearchWidgetCFRDisplayed() { + preferences.edit().putInt( + appContext.getPreferenceKey(R.string.pref_key_search_widget_cfr_display_count), + searchWidgetCFRDisplayCount + 1 + ).apply() + } + + private val searchWidgetCFRManuallyDismissed by booleanPreference( + appContext.getPreferenceKey(R.string.pref_key_search_widget_cfr_manually_dismissed), + default = false + ) + + fun manuallyDismissSearchWidgetCFR() { + preferences.edit().putBoolean( + appContext.getPreferenceKey(R.string.pref_key_search_widget_cfr_manually_dismissed), + true + ).apply() + } + + private val searchWidgetCFRDismissCount by intPreference( + appContext.getPreferenceKey(R.string.pref_key_search_widget_cfr_dismiss_count), + default = 0 + ) + + fun incrementSearchWidgetCFRDismissed() { + preferences.edit().putInt( + appContext.getPreferenceKey(R.string.pref_key_search_widget_cfr_dismiss_count), + searchWidgetCFRDismissCount + 1 + ).apply() + } + var defaultSearchEngineName by stringPreference( appContext.getPreferenceKey(R.string.pref_key_search_engine), default = "" diff --git a/app/src/main/res/drawable-xhdpi/search_widget_illustration.png b/app/src/main/res/drawable-xhdpi/search_widget_illustration.png new file mode 100644 index 000000000000..dc6539813c9a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/search_widget_illustration.png differ diff --git a/app/src/main/res/layout/search_widget_cfr.xml b/app/src/main/res/layout/search_widget_cfr.xml new file mode 100644 index 000000000000..e5f6be05b60d --- /dev/null +++ b/app/src/main/res/layout/search_widget_cfr.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + +