From fb8aabefa14ac7122e5507558dffcdf5b457a5e5 Mon Sep 17 00:00:00 2001 From: DreVla Date: Thu, 17 Aug 2023 12:41:32 +0300 Subject: [PATCH] Bug 1845747 - Add "Add search widget" card for Juno Onboarding Added a new card for the Juno Onboarding, "Add search widget to homescreen". This is an experiment that aims to increase DAU and searches. (cherry picked from commit cba23f261cb6b81b60f994022ae41967e7915ade) --- app/metrics.yaml | 99 ++++++ app/onboarding.fml.yaml | 11 + .../view/JunoOnboardingMapperTest.kt | 40 ++- app/src/main/AndroidManifest.xml | 7 + .../onboarding/JunoOnboardingFragment.kt | 53 +++- .../JunoOnboardingTelemetryRecorder.kt | 43 +++ .../fenix/onboarding/WidgetPinnedReceiver.kt | 83 +++++ .../onboarding/view/JunoOnboardingMapper.kt | 29 +- .../onboarding/view/JunoOnboardingScreen.kt | 41 ++- .../onboarding/view/OnboardingPageUiData.kt | 3 + .../drawable/ic_onboarding_search_widget.xml | 293 ++++++++++++++++++ app/src/main/res/values/strings.xml | 11 + .../view/JunoOnboardingMapperTest.kt | 46 +++ 13 files changed, 745 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/onboarding/WidgetPinnedReceiver.kt create mode 100644 app/src/main/res/drawable/ic_onboarding_search_widget.xml diff --git a/app/metrics.yaml b/app/metrics.yaml index d68c093a70..8aeb63e4a3 100644 --- a/app/metrics.yaml +++ b/app/metrics.yaml @@ -1318,6 +1318,105 @@ onboarding: metadata: tags: - Onboarding + add_search_widget_card: + type: event + description: | + User viewed juno onboarding add search widget card. + extra_keys: + element_type: + type: string + description: | + Type of element that was viewed. + action: + type: string + description: | + Type of action taken by the user. + sequence_position: + type: string + description: | + Position of the onboarding card in the onboarding flow. + sequence_id: + type: string + description: | + Identifier for the sequence. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848960 + data_reviews: + - https://github.com/mozilla-mobile/firefox-android/pull/3310 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: 126 + metadata: + tags: + - Onboarding + add_search_widget: + type: event + description: | + User tapped on Add Firefox Widget in juno onboarding. + extra_keys: + element_type: + type: string + description: | + Type of element that was viewed. + action: + type: string + description: | + Type of action taken by the user. + sequence_position: + type: string + description: | + Position of the onboarding card in the onboarding flow. + sequence_id: + type: string + description: | + Identifier for the sequence. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848960 + data_reviews: + - https://github.com/mozilla-mobile/firefox-android/pull/3310 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: 126 + metadata: + tags: + - Onboarding + skip_add_search_widget: + type: event + description: | + User tapped on skip add search widget button in juno onboarding. + extra_keys: + element_type: + type: string + description: | + Type of element that was viewed. + action: + type: string + description: | + Type of action taken by the user. + sequence_position: + type: string + description: | + Position of the onboarding card in the onboarding flow. + sequence_id: + type: string + description: | + Identifier for the sequence. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848960 + data_reviews: + - https://github.com/mozilla-mobile/firefox-android/pull/3310 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: 126 + metadata: + tags: + - Onboarding privacy_policy: type: event description: | diff --git a/app/onboarding.fml.yaml b/app/onboarding.fml.yaml index 1f004cb100..1922b43ce3 100644 --- a/app/onboarding.fml.yaml +++ b/app/onboarding.fml.yaml @@ -23,6 +23,15 @@ features: primary-button-label: juno_onboarding_default_browser_positive_button secondary-button-label: juno_onboarding_default_browser_negative_button + add-search-widget: + card-type: add-search-widget + title: juno_onboarding_add_search_widget_title + body: juno_onboarding_add_search_widget_description + image-res: ic_onboarding_search_widget + ordering: 15 + primary-button-label: juno_onboarding_add_search_widget_positive_button + secondary-button-label: juno_onboarding_add_search_widget_negative_button + sync-sign-in: card-type: sync-sign-in title: juno_onboarding_sign_in_title @@ -108,3 +117,5 @@ enums: description: Allows user to sync with a Firefox account. notification-permission: description: Allows user to enable notification permission. + add-search-widget: + description: Allows user to add search widget to homescreen. diff --git a/app/src/androidTest/java/org/mozilla/fenix/onboarding/view/JunoOnboardingMapperTest.kt b/app/src/androidTest/java/org/mozilla/fenix/onboarding/view/JunoOnboardingMapperTest.kt index 4016c16ef2..d74edfcd4b 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/onboarding/view/JunoOnboardingMapperTest.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/onboarding/view/JunoOnboardingMapperTest.kt @@ -20,15 +20,27 @@ class JunoOnboardingMapperTest { HomeActivityIntentTestRule.withDefaultSettingsOverrides(skipOnboarding = true) @Test - fun showNotificationTrue_pagesToDisplay_returnsSortedListOfAllConvertedPages() { + fun showNotificationTrue_showAddWidgetFalse_pagesToDisplay_returnsSortedListOfAllConvertedPages_withoutAddWidgetPage() { val expected = listOf(defaultBrowserPageUiData, syncPageUiData, notificationPageUiData) - assertEquals(expected, unsortedAllKnownCardData.toPageUiData(true)) + assertEquals(expected, unsortedAllKnownCardData.toPageUiData(true, false)) } @Test - fun showNotificationFalse_pagesToDisplay_returnsSortedListOfConvertedPagesWithoutNotificationPage() { + fun showNotificationFalse_showAddWidgetFalse_pagesToDisplay_returnsSortedListOfConvertedPages_withoutNotificationPage_and_addWidgetPage() { val expected = listOf(defaultBrowserPageUiData, syncPageUiData) - assertEquals(expected, unsortedAllKnownCardData.toPageUiData(false)) + assertEquals(expected, unsortedAllKnownCardData.toPageUiData(false, false)) + } + + @Test + fun showNotificationFalse_showAddWidgetTrue_pagesToDisplay_returnsSortedListOfAllConvertedPages_withoutNotificationPage() { + val expected = listOf(defaultBrowserPageUiData, addSearchWidgetPageUiData, syncPageUiData) + assertEquals(expected, unsortedAllKnownCardData.toPageUiData(false, true)) + } + + @Test + fun showNotificationTrue_and_showAddWidgetTrue_pagesToDisplay_returnsSortedListOfConvertedPages() { + val expected = listOf(defaultBrowserPageUiData, addSearchWidgetPageUiData, syncPageUiData, notificationPageUiData) + assertEquals(expected, unsortedAllKnownCardData.toPageUiData(true, true)) } } @@ -41,6 +53,15 @@ private val defaultBrowserPageUiData = OnboardingPageUiData( primaryButtonLabel = "default browser primary button text", secondaryButtonLabel = "default browser secondary button text", ) +private val addSearchWidgetPageUiData = OnboardingPageUiData( + type = OnboardingPageUiData.Type.ADD_SEARCH_WIDGET, + imageRes = R.drawable.ic_onboarding_search_widget, + title = "add search widget title", + description = "add search widget body with link text", + linkText = "link text", + primaryButtonLabel = "add search widget primary button text", + secondaryButtonLabel = "add search widget secondary button text", +) private val syncPageUiData = OnboardingPageUiData( type = OnboardingPageUiData.Type.SYNC_SIGN_IN, imageRes = R.drawable.ic_onboarding_sync, @@ -68,6 +89,16 @@ private val defaultBrowserCardData = OnboardingCardData( secondaryButtonLabel = StringHolder(null, "default browser secondary button text"), ordering = 10, ) +private val addSearchWidgetCardData = OnboardingCardData( + cardType = OnboardingCardType.ADD_SEARCH_WIDGET, + imageRes = R.drawable.ic_onboarding_search_widget, + title = StringHolder(null, "add search widget title"), + body = StringHolder(null, "add search widget body with link text"), + linkText = StringHolder(null, "link text"), + primaryButtonLabel = StringHolder(null, "add search widget primary button text"), + secondaryButtonLabel = StringHolder(null, "add search widget secondary button text"), + ordering = 15, +) private val syncCardData = OnboardingCardData( cardType = OnboardingCardType.SYNC_SIGN_IN, imageRes = R.drawable.ic_onboarding_sync, @@ -91,4 +122,5 @@ private val unsortedAllKnownCardData = listOf( syncCardData, notificationCardData, defaultBrowserCardData, + addSearchWidgetCardData, ) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 302b5ed108..a2c21352fa 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -338,6 +338,13 @@ android:resource="@xml/search_widget_info" /> + + + + + + diff --git a/app/src/main/java/org/mozilla/fenix/onboarding/JunoOnboardingFragment.kt b/app/src/main/java/org/mozilla/fenix/onboarding/JunoOnboardingFragment.kt index 640d829559..5f96d60053 100644 --- a/app/src/main/java/org/mozilla/fenix/onboarding/JunoOnboardingFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/onboarding/JunoOnboardingFragment.kt @@ -5,7 +5,10 @@ package org.mozilla.fenix.onboarding import android.annotation.SuppressLint +import android.appwidget.AppWidgetManager +import android.content.ComponentName import android.content.Context +import android.content.IntentFilter import android.content.pm.ActivityInfo import android.os.Build import android.os.Bundle @@ -18,6 +21,7 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext import androidx.core.app.NotificationManagerCompat import androidx.fragment.app.Fragment +import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.navigation.fragment.findNavController import mozilla.components.support.base.ext.areNotificationsEnabledSafe import org.mozilla.fenix.R @@ -34,14 +38,21 @@ import org.mozilla.fenix.onboarding.view.telemetrySequenceId import org.mozilla.fenix.onboarding.view.toPageUiData import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.theme.FirefoxTheme +import org.mozilla.gecko.search.SearchWidgetProvider /** * Fragment displaying the juno onboarding flow. */ class JunoOnboardingFragment : Fragment() { - private val pagesToDisplay by lazy { pagesToDisplay(shouldShowNotificationPage(requireContext())) } + private val pagesToDisplay by lazy { + pagesToDisplay( + canShowNotificationPage(requireContext()), + canShowAddWidgetCard(), + ) + } private val telemetryRecorder by lazy { JunoOnboardingTelemetryRecorder() } + private val pinAppWidgetReceiver = WidgetPinnedReceiver() @SuppressLint("SourceLockedOrientationActivity") override fun onCreate(savedInstanceState: Bundle?) { @@ -49,6 +60,9 @@ class JunoOnboardingFragment : Fragment() { if (isNotATablet()) { activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT } + val filter = IntentFilter(WidgetPinnedReceiver.ACTION) + LocalBroadcastManager.getInstance(requireContext()) + .registerReceiver(pinAppWidgetReceiver, filter) } @RequiresApi(Build.VERSION_CODES.TIRAMISU) @@ -74,6 +88,7 @@ class JunoOnboardingFragment : Fragment() { if (isNotATablet()) { activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } + LocalBroadcastManager.getInstance(requireContext()).unregisterReceiver(pinAppWidgetReceiver) } @RequiresApi(Build.VERSION_CODES.TIRAMISU) @@ -141,6 +156,15 @@ class JunoOnboardingFragment : Fragment() { pagesToDisplay.sequencePosition(OnboardingPageUiData.Type.NOTIFICATION_PERMISSION), ) }, + onAddFirefoxWidgetClick = { + showAddSearchWidgetDialog() + }, + onSkipFirefoxWidgetClick = { + telemetryRecorder.onSkipAddWidgetClick( + pagesToDisplay.telemetrySequenceId(), + pagesToDisplay.sequencePosition(OnboardingPageUiData.Type.ADD_SEARCH_WIDGET), + ) + }, onFinish = { onFinish( sequenceId = pagesToDisplay.telemetrySequenceId(), @@ -157,6 +181,19 @@ class JunoOnboardingFragment : Fragment() { ) } + private fun showAddSearchWidgetDialog() { + // Requesting to pin app widget is only available for Android 8.0 and above + if (canShowAddWidgetCard()) { + val appWidgetManager = AppWidgetManager.getInstance(activity) + val searchWidgetProvider = + ComponentName(requireActivity(), SearchWidgetProvider::class.java) + if (appWidgetManager.isRequestPinAppWidgetSupported) { + val successCallback = WidgetPinnedReceiver.getPendingIntent(requireContext()) + appWidgetManager.requestPinAppWidget(searchWidgetProvider, null, successCallback) + } + } + } + private fun onFinish(sequenceId: String, sequencePosition: String) { requireComponents.fenixOnboarding.finish() findNavController().nav( @@ -169,12 +206,20 @@ class JunoOnboardingFragment : Fragment() { ) } - private fun shouldShowNotificationPage(context: Context) = + private fun canShowNotificationPage(context: Context) = !NotificationManagerCompat.from(context.applicationContext) .areNotificationsEnabledSafe() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + private fun canShowAddWidgetCard() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + private fun isNotATablet() = !resources.getBoolean(R.bool.tablet) - private fun pagesToDisplay(showNotificationPage: Boolean): List = - FxNimbus.features.junoOnboarding.value().cards.values.toPageUiData(showNotificationPage) + private fun pagesToDisplay( + showNotificationPage: Boolean, + showAddWidgetPage: Boolean, + ): List = + FxNimbus.features.junoOnboarding.value().cards.values.toPageUiData( + showNotificationPage, + showAddWidgetPage, + ) } diff --git a/app/src/main/java/org/mozilla/fenix/onboarding/JunoOnboardingTelemetryRecorder.kt b/app/src/main/java/org/mozilla/fenix/onboarding/JunoOnboardingTelemetryRecorder.kt index c1b048e4fd..c820a9df85 100644 --- a/app/src/main/java/org/mozilla/fenix/onboarding/JunoOnboardingTelemetryRecorder.kt +++ b/app/src/main/java/org/mozilla/fenix/onboarding/JunoOnboardingTelemetryRecorder.kt @@ -49,6 +49,17 @@ class JunoOnboardingTelemetryRecorder { ) } + OnboardingPageUiData.Type.ADD_SEARCH_WIDGET -> { + Onboarding.addSearchWidgetCard.record( + Onboarding.AddSearchWidgetCardExtra( + action = ACTION_IMPRESSION, + elementType = ET_ONBOARDING_CARD, + sequenceId = sequenceId, + sequencePosition = sequencePosition, + ), + ) + } + OnboardingPageUiData.Type.SYNC_SIGN_IN -> { Onboarding.signInCard.record( Onboarding.SignInCardExtra( @@ -121,6 +132,22 @@ class JunoOnboardingTelemetryRecorder { ) } + /** + * Records add search widget click event. + * @param sequenceId The identifier of the onboarding sequence shown to the user. + * @param sequencePosition The sequence position of the page for which the impression occurred. + */ + fun onAddSearchWidgetClick(sequenceId: String, sequencePosition: String) { + Onboarding.addSearchWidget.record( + Onboarding.AddSearchWidgetExtra( + action = ACTION_CLICK, + elementType = ET_PRIMARY_BUTTON, + sequenceId = sequenceId, + sequencePosition = sequencePosition, + ), + ) + } + /** * Records skip set to default click event. * @param sequenceId The identifier of the onboarding sequence shown to the user. @@ -153,6 +180,22 @@ class JunoOnboardingTelemetryRecorder { ) } + /** + * Records skip add widget click event. + * @param sequenceId The identifier of the onboarding sequence shown to the user. + * @param sequencePosition The sequence position of the page for which the impression occurred. + */ + fun onSkipAddWidgetClick(sequenceId: String, sequencePosition: String) { + Onboarding.skipAddSearchWidget.record( + Onboarding.SkipAddSearchWidgetExtra( + action = ACTION_CLICK, + elementType = ET_SECONDARY_BUTTON, + sequenceId = sequenceId, + sequencePosition = sequencePosition, + ), + ) + } + /** * Records skip notification permission click event. * @param sequenceId The identifier of the onboarding sequence shown to the user. diff --git a/app/src/main/java/org/mozilla/fenix/onboarding/WidgetPinnedReceiver.kt b/app/src/main/java/org/mozilla/fenix/onboarding/WidgetPinnedReceiver.kt new file mode 100644 index 0000000000..a036f16684 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/onboarding/WidgetPinnedReceiver.kt @@ -0,0 +1,83 @@ +/* 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.onboarding + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import mozilla.components.support.utils.PendingIntentUtils +import org.mozilla.fenix.onboarding.view.JunoOnboardingScreen + +/** + * Receiver required to catch callback from Launcher when prompted + * to add search widget from the Juno Onboarding. + */ +class WidgetPinnedReceiver : BroadcastReceiver() { + + companion object { + const val ACTION = "org.mozilla.fenix.onboarding.WidgetPinnedReceiver.PIN_SEARCH_WIDGET_SUCCESS" + + /** + * Prepare success callback for when requesting to pin Search Widget. + */ + fun getPendingIntent(context: Context): PendingIntent { + val callbackIntent = Intent(context, WidgetPinnedReceiver::class.java) + val bundle = Bundle() + bundle.putInt(AppWidgetManager.EXTRA_APPWIDGET_ID, 1) + callbackIntent.putExtras(bundle) + return PendingIntent.getBroadcast( + context, + 0, + callbackIntent, + PendingIntentUtils.defaultFlags or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + } + + /** + * Object containing boolean that updates behavior of Add Search Widget + * card from [JunoOnboardingScreen]. + * - True if widget added successfully and app resumed from launcher add widget dialog. + * - False if dialog opened but widget was not added. + */ + object WidgetPinnedState { + private val _isPinned = MutableStateFlow(false) + val isPinned: StateFlow = _isPinned + + /** + * Update state when resumed to add search widget card + * and the widget was added successfully. + */ + fun widgetPinned() { + _isPinned.value = true + } + } + + override fun onReceive(context: Context?, intent: Intent?) { + if (context == null || intent == null) { + return + } else if (intent.action == ACTION) { + // Returned to fragment, go to next page and update button behavior. + WidgetPinnedState.widgetPinned() + } + + val widgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1) + + if (widgetId == -1) { + // No widget id received. + return + } else { + // Callback from system, widget pinned successfully, update compose now. + val updateIntent = Intent(ACTION) + LocalBroadcastManager.getInstance(context).sendBroadcast(updateIntent) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/onboarding/view/JunoOnboardingMapper.kt b/app/src/main/java/org/mozilla/fenix/onboarding/view/JunoOnboardingMapper.kt index 5b3e4578b0..b57d489d96 100644 --- a/app/src/main/java/org/mozilla/fenix/onboarding/view/JunoOnboardingMapper.kt +++ b/app/src/main/java/org/mozilla/fenix/onboarding/view/JunoOnboardingMapper.kt @@ -12,12 +12,21 @@ import org.mozilla.fenix.settings.SupportUtils /** * Returns a list of all the required Nimbus 'cards' that have been converted to [OnboardingPageUiData]. */ -internal fun Collection.toPageUiData(showNotificationPage: Boolean): List = +internal fun Collection.toPageUiData( + showNotificationPage: Boolean, + canShowAddWidgetPage: Boolean, +): List = filter { - if (it.cardType == OnboardingCardType.NOTIFICATION_PERMISSION) { - showNotificationPage - } else { - true + when (it.cardType) { + OnboardingCardType.NOTIFICATION_PERMISSION -> { + showNotificationPage + } + OnboardingCardType.ADD_SEARCH_WIDGET -> { + canShowAddWidgetPage + } + else -> { + true + } } }.sortedBy { it.ordering } .map { it.toPageUiData() } @@ -36,6 +45,7 @@ private fun OnboardingCardType.toPageUiDataType() = when (this) { OnboardingCardType.DEFAULT_BROWSER -> OnboardingPageUiData.Type.DEFAULT_BROWSER OnboardingCardType.SYNC_SIGN_IN -> OnboardingPageUiData.Type.SYNC_SIGN_IN OnboardingCardType.NOTIFICATION_PERMISSION -> OnboardingPageUiData.Type.NOTIFICATION_PERMISSION + OnboardingCardType.ADD_SEARCH_WIDGET -> OnboardingPageUiData.Type.ADD_SEARCH_WIDGET } /** @@ -52,6 +62,8 @@ internal fun mapToOnboardingPageState( onSignInSkipClick: () -> Unit, onNotificationPermissionButtonClick: () -> Unit, onNotificationPermissionSkipClick: () -> Unit, + onAddFirefoxWidgetClick: () -> Unit, + onAddFirefoxWidgetSkipClick: () -> Unit, ): OnboardingPageState = when (onboardingPageUiData.type) { OnboardingPageUiData.Type.DEFAULT_BROWSER -> createOnboardingPageState( onboardingPageUiData = onboardingPageUiData, @@ -60,6 +72,13 @@ internal fun mapToOnboardingPageState( onUrlClick = onPrivacyPolicyClick, ) + OnboardingPageUiData.Type.ADD_SEARCH_WIDGET -> createOnboardingPageState( + onboardingPageUiData = onboardingPageUiData, + onPositiveButtonClick = onAddFirefoxWidgetClick, + onNegativeButtonClick = onAddFirefoxWidgetSkipClick, + onUrlClick = onPrivacyPolicyClick, + ) + OnboardingPageUiData.Type.SYNC_SIGN_IN -> createOnboardingPageState( onboardingPageUiData = onboardingPageUiData, onPositiveButtonClick = onSignInButtonClick, diff --git a/app/src/main/java/org/mozilla/fenix/onboarding/view/JunoOnboardingScreen.kt b/app/src/main/java/org/mozilla/fenix/onboarding/view/JunoOnboardingScreen.kt index 3f97e6fb14..5eea74fdc6 100644 --- a/app/src/main/java/org/mozilla/fenix/onboarding/view/JunoOnboardingScreen.kt +++ b/app/src/main/java/org/mozilla/fenix/onboarding/view/JunoOnboardingScreen.kt @@ -19,6 +19,8 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshotFlow @@ -30,12 +32,15 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import mozilla.components.lib.state.ext.observeAsComposableState import org.mozilla.fenix.R import org.mozilla.fenix.components.components import org.mozilla.fenix.compose.PagerIndicator import org.mozilla.fenix.compose.annotation.LightDarkPreview +import org.mozilla.fenix.onboarding.JunoOnboardingTelemetryRecorder +import org.mozilla.fenix.onboarding.WidgetPinnedReceiver.WidgetPinnedState import org.mozilla.fenix.theme.FirefoxTheme /** @@ -50,11 +55,13 @@ import org.mozilla.fenix.theme.FirefoxTheme * @param onNotificationPermissionButtonClick Invoked when positive button on notification page is * clicked. * @param onSkipNotificationClick Invoked when negative button on notification page is clicked. + * @param onAddFirefoxWidgetClick Invoked when positive button on add search widget page is clicked. + * @param onSkipFirefoxWidgetClick Invoked when negative button on add search widget page is clicked. * @param onFinish Invoked when the onboarding is completed. * @param onImpression Invoked when a page in the pager is displayed. */ @Composable -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LongMethod") fun JunoOnboardingScreen( pagesToDisplay: List, onMakeFirefoxDefaultClick: () -> Unit, @@ -64,6 +71,8 @@ fun JunoOnboardingScreen( onSkipSignInClick: () -> Unit, onNotificationPermissionButtonClick: () -> Unit, onSkipNotificationClick: () -> Unit, + onAddFirefoxWidgetClick: () -> Unit, + onSkipFirefoxWidgetClick: () -> Unit, onFinish: (pageType: OnboardingPageUiData) -> Unit, onImpression: (pageType: OnboardingPageUiData) -> Unit, ) { @@ -71,6 +80,9 @@ fun JunoOnboardingScreen( val pagerState = rememberPagerState() val isSignedIn: State = components.backgroundServices.syncStore .observeAsComposableState { it.account != null } + val telemetryRecorder by lazy { JunoOnboardingTelemetryRecorder() } + val widgetPinnedFlow: StateFlow = WidgetPinnedState.isPinned + val isWidgetPinnedState by widgetPinnedFlow.collectAsState() BackHandler(enabled = pagerState.currentPage > 0) { coroutineScope.launch { @@ -100,6 +112,16 @@ fun JunoOnboardingScreen( } } + LaunchedEffect(isWidgetPinnedState) { + if (isWidgetPinnedState) { + scrollToNextPageOrDismiss() + telemetryRecorder.onAddSearchWidgetClick( + pagesToDisplay.telemetrySequenceId(), + pagesToDisplay.sequencePosition(OnboardingPageUiData.Type.ADD_SEARCH_WIDGET), + ) + } + } + JunoOnboardingContent( pagesToDisplay = pagesToDisplay, pagerState = pagerState, @@ -130,6 +152,17 @@ fun JunoOnboardingScreen( scrollToNextPageOrDismiss() onSkipNotificationClick() }, + onAddFirefoxWidgetClick = { + if (isWidgetPinnedState) { + scrollToNextPageOrDismiss() + } else { + onAddFirefoxWidgetClick() + } + }, + onSkipFirefoxWidgetClick = { + scrollToNextPageOrDismiss() + onSkipFirefoxWidgetClick() + }, ) } @@ -145,6 +178,8 @@ private fun JunoOnboardingContent( onSignInSkipClick: () -> Unit, onNotificationPermissionButtonClick: () -> Unit, onNotificationPermissionSkipClick: () -> Unit, + onAddFirefoxWidgetClick: () -> Unit, + onSkipFirefoxWidgetClick: () -> Unit, ) { val nestedScrollConnection = remember { DisableForwardSwipeNestedScrollConnection(pagerState) } @@ -172,6 +207,8 @@ private fun JunoOnboardingContent( onSignInSkipClick = onSignInSkipClick, onNotificationPermissionButtonClick = onNotificationPermissionButtonClick, onNotificationPermissionSkipClick = onNotificationPermissionSkipClick, + onAddFirefoxWidgetClick = onAddFirefoxWidgetClick, + onAddFirefoxWidgetSkipClick = onSkipFirefoxWidgetClick, ) OnboardingPage(pageState = onboardingPageState) } @@ -224,6 +261,8 @@ private fun JunoOnboardingScreenPreview() { onSignInSkipClick = {}, onNotificationPermissionButtonClick = {}, onNotificationPermissionSkipClick = {}, + onAddFirefoxWidgetClick = {}, + onSkipFirefoxWidgetClick = {}, ) } } diff --git a/app/src/main/java/org/mozilla/fenix/onboarding/view/OnboardingPageUiData.kt b/app/src/main/java/org/mozilla/fenix/onboarding/view/OnboardingPageUiData.kt index 9c0b357fcb..10dc40e033 100644 --- a/app/src/main/java/org/mozilla/fenix/onboarding/view/OnboardingPageUiData.kt +++ b/app/src/main/java/org/mozilla/fenix/onboarding/view/OnboardingPageUiData.kt @@ -32,6 +32,9 @@ data class OnboardingPageUiData( SYNC_SIGN_IN( telemetryId = "sync", ), + ADD_SEARCH_WIDGET( + telemetryId = "search_widget", + ), NOTIFICATION_PERMISSION( telemetryId = "notification", ), diff --git a/app/src/main/res/drawable/ic_onboarding_search_widget.xml b/app/src/main/res/drawable/ic_onboarding_search_widget.xml new file mode 100644 index 0000000000..75dc5ee5eb --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_search_widget.xml @@ -0,0 +1,293 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7441cbfc7b..12d94fe152 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -330,6 +330,17 @@ Turn on notifications Not now + + Try the Firefox search widget + + With Firefox on your home screen, you’ll have easy access to the privacy-first browser that blocks cross-site trackers. + + Add Firefox widget + + Not now diff --git a/app/src/test/java/org/mozilla/fenix/onboarding/view/JunoOnboardingMapperTest.kt b/app/src/test/java/org/mozilla/fenix/onboarding/view/JunoOnboardingMapperTest.kt index ff2278636b..a48b4edd39 100644 --- a/app/src/test/java/org/mozilla/fenix/onboarding/view/JunoOnboardingMapperTest.kt +++ b/app/src/test/java/org/mozilla/fenix/onboarding/view/JunoOnboardingMapperTest.kt @@ -45,6 +45,8 @@ class JunoOnboardingMapperTest { onSignInSkipClick = {}, onNotificationPermissionButtonClick = {}, onNotificationPermissionSkipClick = {}, + onAddFirefoxWidgetClick = {}, + onAddFirefoxWidgetSkipClick = {}, ) assertEquals(expected, actual) @@ -78,6 +80,8 @@ class JunoOnboardingMapperTest { onSignInSkipClick = unitLambda, onNotificationPermissionButtonClick = {}, onNotificationPermissionSkipClick = {}, + onAddFirefoxWidgetClick = {}, + onAddFirefoxWidgetSkipClick = {}, ) assertEquals(expected, actual) @@ -111,6 +115,48 @@ class JunoOnboardingMapperTest { onSignInSkipClick = {}, onNotificationPermissionButtonClick = unitLambda, onNotificationPermissionSkipClick = unitLambda, + onAddFirefoxWidgetClick = {}, + onAddFirefoxWidgetSkipClick = {}, + ) + + assertEquals(expected, actual) + } + + @Test + fun `GIVEN an add search widget page WHEN mapToOnboardingPageState is called THEN creates the expected OnboardingPageState`() { + val expected = OnboardingPageState( + imageRes = R.drawable.ic_onboarding_search_widget, + title = "add search widget title", + description = "add search widget body with link text", + linkTextState = LinkTextState( + text = "link text", + url = SupportUtils.getMozillaPageUrl(SupportUtils.MozillaPage.PRIVATE_NOTICE), + onClick = stringLambda, + ), + primaryButton = Action("add search widget primary button text", unitLambda), + secondaryButton = Action("add search widget secondary button text", unitLambda), + ) + + val onboardingPageUiData = OnboardingPageUiData( + type = OnboardingPageUiData.Type.ADD_SEARCH_WIDGET, + imageRes = R.drawable.ic_onboarding_search_widget, + title = "add search widget title", + description = "add search widget body with link text", + linkText = "link text", + primaryButtonLabel = "add search widget primary button text", + secondaryButtonLabel = "add search widget secondary button text", + ) + val actual = mapToOnboardingPageState( + onboardingPageUiData = onboardingPageUiData, + onMakeFirefoxDefaultClick = {}, + onMakeFirefoxDefaultSkipClick = {}, + onPrivacyPolicyClick = stringLambda, + onSignInButtonClick = {}, + onSignInSkipClick = {}, + onNotificationPermissionButtonClick = {}, + onNotificationPermissionSkipClick = {}, + onAddFirefoxWidgetClick = unitLambda, + onAddFirefoxWidgetSkipClick = unitLambda, ) assertEquals(expected, actual)