You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
iceraven-browser/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarCFRPresenter.kt

400 lines
16 KiB
Kotlin

/* 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.toolbar
import android.content.Context
import android.view.View
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.clickable
import androidx.compose.material.Text
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat.getColor
import androidx.core.view.isVisible
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.transformWhile
import mozilla.components.browser.state.selector.findCustomTabOrSelectedTab
import mozilla.components.browser.state.selector.selectedTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.browser.toolbar.BrowserToolbar
import mozilla.components.compose.cfr.CFRPopup
import mozilla.components.compose.cfr.CFRPopup.PopupAlignment.INDICATOR_CENTERED_IN_ANCHOR
import mozilla.components.compose.cfr.CFRPopupProperties
import mozilla.components.lib.state.ext.flowScoped
import mozilla.components.service.glean.private.NoExtras
import org.mozilla.fenix.GleanMetrics.Shopping
import org.mozilla.fenix.GleanMetrics.TrackingProtection
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.settings.SupportUtils
import org.mozilla.fenix.settings.SupportUtils.SumoTopic.TOTAL_COOKIE_PROTECTION
import org.mozilla.fenix.shopping.DefaultShoppingExperienceFeature
import org.mozilla.fenix.shopping.ShoppingExperienceFeature
import org.mozilla.fenix.theme.FirefoxTheme
import org.mozilla.fenix.utils.Settings
/**
* Vertical padding needed to improve the visual alignment of the popup and respect the UX design.
*/
private const val CFR_TO_ANCHOR_VERTICAL_PADDING = -6
/**
* The minimum number of opened tabs to show the Total Cookie Protection CFR.
*/
private const val CFR_MINIMUM_NUMBER_OPENED_TABS = 5
/**
* Delegate for handling all the business logic for showing CFRs in the toolbar.
*
* @property context used for various Android interactions.
* @property browserStore will be observed for tabs updates
* @property settings used to read and write persistent user settings
* @property toolbar will serve as anchor for the CFRs
* @property isPrivate Whether or not the session is private.
* @property sessionId optional custom tab id used to identify the custom tab in which to show a CFR.
* @property onShoppingCfrActionClicked Triggered when the user taps on the shopping CFR action.
* @property onShoppingCfrDismiss Triggered when the user closes the shopping CFR using the "X" button.
* @property shoppingExperienceFeature Used to determine if [ShoppingExperienceFeature] is enabled.
*/
@Suppress("LongParameterList")
class BrowserToolbarCFRPresenter(
private val context: Context,
private val browserStore: BrowserStore,
private val settings: Settings,
private val toolbar: BrowserToolbar,
private val isPrivate: Boolean,
private val sessionId: String? = null,
private val onShoppingCfrActionClicked: () -> Unit,
private val onShoppingCfrDismiss: () -> Unit,
private val shoppingExperienceFeature: ShoppingExperienceFeature = DefaultShoppingExperienceFeature(),
) {
@VisibleForTesting
internal var scope: CoroutineScope? = null
@VisibleForTesting
internal var popup: CFRPopup? = null
/**
* Start observing [browserStore] for updates which may trigger showing a CFR.
*/
@Suppress("MagicNumber")
fun start() {
when (getCFRToShow()) {
ToolbarCFR.TCP -> {
scope = browserStore.flowScoped { flow ->
flow.mapNotNull { it.findCustomTabOrSelectedTab(sessionId)?.content?.progress }
// The "transformWhile" below ensures that the 100% progress is only collected once.
.transformWhile { progress ->
emit(progress)
progress != 100
}.filter { popup == null && it == 100 }.collect {
scope?.cancel()
showTcpCfr()
}
}
}
ToolbarCFR.SHOPPING, ToolbarCFR.SHOPPING_OPTED_IN -> {
scope = browserStore.flowScoped { flow ->
val shouldShowCfr: Boolean? = flow.mapNotNull { it.selectedTab }
.filter { it.content.isProductUrl && it.content.progress == 100 && !it.content.loading }
.distinctUntilChanged()
.map { toolbar.findViewById<View>(R.id.mozac_browser_toolbar_page_actions).isVisible }
.filter { popup == null && it }
.firstOrNull()
if (shouldShowCfr == true) {
showShoppingCFR(getCFRToShow() == ToolbarCFR.SHOPPING_OPTED_IN)
}
scope?.cancel()
}
}
ToolbarCFR.ERASE -> {
scope = browserStore.flowScoped { flow ->
flow
.mapNotNull { it.findCustomTabOrSelectedTab(sessionId) }
.filter { it.content.private }
.map { it.content.progress }
// The "transformWhile" below ensures that the 100% progress is only collected once.
.transformWhile { progress ->
emit(progress)
progress != 100
}
.filter { popup == null && it == 100 }
.collect {
scope?.cancel()
showEraseCfr()
}
}
}
ToolbarCFR.NONE -> {
// no-op
}
}
}
private fun whichShoppingCFR(): ToolbarCFR {
fun Long.isInitialized(): Boolean = this != 0L
fun Long.afterOneDay(): Boolean = this.isInitialized() &&
System.currentTimeMillis() - this > Settings.ONE_DAY_MS
val optInTime = settings.reviewQualityCheckOptInTimeInMillis
val firstCfrShownTime = settings.reviewQualityCheckCfrDisplayTimeInMillis
return when {
// First CFR should be displayed on first product page visit
!firstCfrShownTime.isInitialized() ->
ToolbarCFR.SHOPPING
// First CFR should be displayed again 24 hours later only for not opted in users
!optInTime.isInitialized() && firstCfrShownTime.afterOneDay() ->
ToolbarCFR.SHOPPING
// Second CFR should be shown 24 hours after opt in
optInTime.afterOneDay() ->
ToolbarCFR.SHOPPING_OPTED_IN
else -> {
ToolbarCFR.NONE
}
}
}
private fun getCFRToShow(): ToolbarCFR = when {
settings.shouldShowEraseActionCFR && isPrivate -> {
ToolbarCFR.ERASE
}
settings.shouldShowTotalCookieProtectionCFR && (
!settings.shouldShowCookieBannerReEngagementDialog() ||
settings.openTabsCount >= CFR_MINIMUM_NUMBER_OPENED_TABS
) -> ToolbarCFR.TCP
shoppingExperienceFeature.isEnabled &&
settings.shouldShowReviewQualityCheckCFR -> whichShoppingCFR()
else -> ToolbarCFR.NONE
}
/**
* Stop listening for [browserStore] updates.
* CFRs already shown are not automatically dismissed.
*/
fun stop() {
scope?.cancel()
}
@VisibleForTesting
internal fun showEraseCfr() {
CFRPopup(
anchor = toolbar.findViewById(
R.id.mozac_browser_toolbar_navigation_actions,
),
properties = CFRPopupProperties(
popupAlignment = INDICATOR_CENTERED_IN_ANCHOR,
popupBodyColors = listOf(
getColor(context, R.color.fx_mobile_layer_color_gradient_end),
getColor(context, R.color.fx_mobile_layer_color_gradient_start),
),
popupVerticalOffset = CFR_TO_ANCHOR_VERTICAL_PADDING.dp,
dismissButtonColor = getColor(context, R.color.fx_mobile_icon_color_oncolor),
indicatorDirection = if (settings.toolbarPosition == ToolbarPosition.TOP) {
CFRPopup.IndicatorDirection.UP
} else {
CFRPopup.IndicatorDirection.DOWN
},
),
onDismiss = {
when (it) {
true -> TrackingProtection.tcpCfrExplicitDismissal.record(NoExtras())
false -> TrackingProtection.tcpCfrImplicitDismissal.record(NoExtras())
}
},
text = {
FirefoxTheme {
Text(
text = context.getString(R.string.erase_action_cfr_message),
color = FirefoxTheme.colors.textOnColorPrimary,
style = FirefoxTheme.typography.body2,
)
}
},
).run {
settings.shouldShowEraseActionCFR = false
popup = this
show()
}
}
@OptIn(ExperimentalComposeUiApi::class)
@VisibleForTesting
@Suppress("LongMethod")
internal fun showTcpCfr() {
CFRPopup(
anchor = toolbar.findViewById(
R.id.mozac_browser_toolbar_security_indicator,
),
properties = CFRPopupProperties(
popupAlignment = INDICATOR_CENTERED_IN_ANCHOR,
popupBodyColors = listOf(
getColor(context, R.color.fx_mobile_layer_color_gradient_end),
getColor(context, R.color.fx_mobile_layer_color_gradient_start),
),
popupVerticalOffset = CFR_TO_ANCHOR_VERTICAL_PADDING.dp,
dismissButtonColor = getColor(context, R.color.fx_mobile_icon_color_oncolor),
indicatorDirection = if (settings.toolbarPosition == ToolbarPosition.TOP) {
CFRPopup.IndicatorDirection.UP
} else {
CFRPopup.IndicatorDirection.DOWN
},
),
onDismiss = {
when (it) {
true -> {
TrackingProtection.tcpCfrExplicitDismissal.record(NoExtras())
settings.shouldShowTotalCookieProtectionCFR = false
}
false -> TrackingProtection.tcpCfrImplicitDismissal.record(NoExtras())
}
},
text = {
FirefoxTheme {
Text(
text = context.getString(R.string.tcp_cfr_message),
color = FirefoxTheme.colors.textOnColorPrimary,
style = FirefoxTheme.typography.body2,
modifier = Modifier
.semantics {
testTagsAsResourceId = true
testTag = "tcp_cfr.message"
},
)
}
},
action = {
FirefoxTheme {
Text(
text = context.getString(R.string.tcp_cfr_learn_more),
color = FirefoxTheme.colors.textOnColorPrimary,
modifier = Modifier
.semantics {
testTagsAsResourceId = true
testTag = "tcp_cfr.action"
}
.clickable {
context.components.useCases.tabsUseCases.selectOrAddTab.invoke(
SupportUtils.getSumoURLForTopic(
context,
TOTAL_COOKIE_PROTECTION,
),
)
TrackingProtection.tcpSumoLinkClicked.record(NoExtras())
settings.shouldShowTotalCookieProtectionCFR = false
popup?.dismiss()
},
style = FirefoxTheme.typography.body2.copy(
textDecoration = TextDecoration.Underline,
),
)
}
},
).run {
popup = this
show()
TrackingProtection.tcpCfrShown.record(NoExtras())
}
}
@VisibleForTesting
internal fun showShoppingCFR(shouldShowOptedInCFR: Boolean) {
CFRPopup(
anchor = toolbar.findViewById(
R.id.mozac_browser_toolbar_page_actions,
),
properties = CFRPopupProperties(
popupWidth = 475.dp,
popupAlignment = CFRPopup.PopupAlignment.BODY_CENTERED_IN_SCREEN,
popupBodyColors = listOf(
getColor(context, R.color.fx_mobile_layer_color_gradient_start),
getColor(context, R.color.fx_mobile_layer_color_gradient_end),
),
popupVerticalOffset = CFR_TO_ANCHOR_VERTICAL_PADDING.dp,
dismissButtonColor = getColor(context, R.color.fx_mobile_icon_color_oncolor),
indicatorDirection = if (settings.toolbarPosition == ToolbarPosition.TOP) {
CFRPopup.IndicatorDirection.UP
} else {
CFRPopup.IndicatorDirection.DOWN
},
dismissOnBackPress = true,
dismissOnClickOutside = true,
),
onDismiss = {
when (it) {
true -> {
onShoppingCfrDismiss()
}
false -> {}
}
},
text = {
FirefoxTheme {
Text(
text = if (shouldShowOptedInCFR) {
stringResource(id = R.string.review_quality_check_second_cfr_message)
} else {
stringResource(id = R.string.review_quality_check_first_cfr_message)
},
color = FirefoxTheme.colors.textOnColorPrimary,
style = FirefoxTheme.typography.body2,
)
}
},
action = {
FirefoxTheme {
Text(
text = if (shouldShowOptedInCFR) {
stringResource(id = R.string.review_quality_check_second_cfr_action)
} else {
stringResource(id = R.string.review_quality_check_first_cfr_action)
},
color = FirefoxTheme.colors.textOnColorPrimary,
modifier = Modifier
.clickable {
onShoppingCfrActionClicked()
popup?.dismiss()
},
style = FirefoxTheme.typography.body2.copy(
textDecoration = TextDecoration.Underline,
),
)
}
},
).run {
Shopping.addressBarFeatureCalloutDisplayed.record()
popup = this
show()
}
}
}
/**
* The CFR to be shown in the toolbar.
*/
private enum class ToolbarCFR {
TCP, SHOPPING, SHOPPING_OPTED_IN, ERASE, NONE
}