Bug 1844319 - Add review quality check CFR

fenix/119.0
Alexandru2909 10 months ago committed by mergify[bot]
parent 47681e21d9
commit 454fc73fd1

@ -5,19 +5,24 @@
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.Modifier
import androidx.compose.ui.res.stringResource
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.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
@ -28,8 +33,11 @@ import mozilla.components.service.glean.private.NoExtras
import org.mozilla.fenix.GleanMetrics.TrackingProtection
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
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
@ -39,9 +47,9 @@ import org.mozilla.fenix.utils.Settings
private const val CFR_TO_ANCHOR_VERTICAL_PADDING = -6
/**
* The minimum number of opened tabs to show the CFR.
* The minimum number of opened tabs to show the Total Cookie Protection CFR.
*/
private const val CRF_MINIMUM_NUMBER_OPENED_TABS = 5
private const val CFR_MINIMUM_NUMBER_OPENED_TABS = 5
/**
* Delegate for handling all the business logic for showing CFRs in the toolbar.
@ -51,6 +59,7 @@ private const val CRF_MINIMUM_NUMBER_OPENED_TABS = 5
* @param settings used to read and write persistent user settings
* @param toolbar will serve as anchor for the CFRs
* @param sessionId optional custom tab id used to identify the custom tab in which to show a CFR.
* @param shoppingExperienceFeature Used to determine if [ShoppingExperienceFeature] is enabled.
*/
class BrowserToolbarCFRPresenter(
private val context: Context,
@ -58,41 +67,73 @@ class BrowserToolbarCFRPresenter(
private val settings: Settings,
private val toolbar: BrowserToolbar,
private val sessionId: String? = null,
private val shoppingExperienceFeature: ShoppingExperienceFeature = DefaultShoppingExperienceFeature(
context.settings(),
),
) {
@VisibleForTesting
internal var tcpCfrScope: CoroutineScope? = null
internal var scope: CoroutineScope? = null
@VisibleForTesting
internal var tcpCfrPopup: CFRPopup? = null
internal var popup: CFRPopup? = null
/**
* Start observing [browserStore] for updates which may trigger showing a CFR.
*/
@Suppress("MagicNumber")
fun start() {
if (shouldShowCFR()) {
tcpCfrScope = 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 { it == 100 }
.collect {
tcpCfrScope?.cancel()
showTcpCfr()
}
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 { it == 100 }.collect {
scope?.cancel()
showTcpCfr()
}
}
}
ToolbarCFR.SHOPPING, ToolbarCFR.SHOPPING_OPTED_IN -> {
scope = browserStore.flowScoped { flow ->
flow.mapNotNull { it.selectedTab }
.filter { it.isProductUrl && it.content.progress == 100 && !it.content.loading }
.distinctUntilChanged()
.collect {
if (toolbar.findViewById<View>(R.id.mozac_browser_toolbar_page_actions).isVisible) {
scope?.cancel()
showShoppingCFR(getCFRToShow() == ToolbarCFR.SHOPPING_OPTED_IN)
}
}
}
}
ToolbarCFR.NONE -> {
// no-op
}
}
}
private fun shouldShowCFR(): Boolean {
return settings.shouldShowTotalCookieProtectionCFR && (
private fun getCFRToShow(): ToolbarCFR = when {
settings.shouldShowTotalCookieProtectionCFR && (
!settings.shouldShowCookieBannerReEngagementDialog() ||
settings.openTabsCount >= CRF_MINIMUM_NUMBER_OPENED_TABS
)
settings.openTabsCount >= CFR_MINIMUM_NUMBER_OPENED_TABS
) -> ToolbarCFR.TCP
shoppingExperienceFeature.isEnabled &&
settings.shouldShowReviewQualityCheckCFR -> {
val optInTime = settings.reviewQualityCheckOptInTimeInMillis
if (optInTime != 0L && System.currentTimeMillis() - optInTime > Settings.ONE_DAY_MS) {
ToolbarCFR.SHOPPING_OPTED_IN
} else {
ToolbarCFR.SHOPPING
}
}
else -> ToolbarCFR.NONE
}
/**
@ -100,7 +141,7 @@ class BrowserToolbarCFRPresenter(
* CFRs already shown are not automatically dismissed.
*/
fun stop() {
tcpCfrScope?.cancel()
scope?.cancel()
}
@VisibleForTesting
@ -151,7 +192,7 @@ class BrowserToolbarCFRPresenter(
),
)
TrackingProtection.tcpSumoLinkClicked.record(NoExtras())
tcpCfrPopup?.dismiss()
popup?.dismiss()
},
style = FirefoxTheme.typography.body2.copy(
textDecoration = TextDecoration.Underline,
@ -161,9 +202,60 @@ class BrowserToolbarCFRPresenter(
},
).run {
settings.shouldShowTotalCookieProtectionCFR = false
tcpCfrPopup = this
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 = false,
dismissOnClickOutside = false,
),
onDismiss = {},
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,
)
}
},
).run {
settings.shouldShowReviewQualityCheckCFR = false
popup = this
show()
}
}
}
/**
* The CFR to be shown in the toolbar.
*/
private enum class ToolbarCFR {
TCP, SHOPPING, SHOPPING_OPTED_IN, NONE
}

@ -31,6 +31,11 @@ interface ReviewQualityCheckPreferences {
* Sets whether the user has enabled product recommendations.
*/
suspend fun setProductRecommendationsEnabled(isEnabled: Boolean)
/**
* Updates the condition to display the opted in CFR.
*/
suspend fun updateCFRCondition(time: Long)
}
/**
@ -58,4 +63,11 @@ class ReviewQualityCheckPreferencesImpl(
override suspend fun setProductRecommendationsEnabled(isEnabled: Boolean) {
settings.isReviewQualityCheckProductRecommendationsEnabled = isEnabled
}
override suspend fun updateCFRCondition(time: Long) = with(settings) {
if (reviewQualityCheckOptInTimeInMillis == 0L) {
reviewQualityCheckOptInTimeInMillis = time
shouldShowReviewQualityCheckCFR = true
}
}
}

@ -73,6 +73,7 @@ class ReviewQualityCheckPreferencesMiddleware(
// Update the preference
reviewQualityCheckPreferences.setEnabled(true)
reviewQualityCheckPreferences.updateCFRCondition(System.currentTimeMillis())
}
}

@ -1698,6 +1698,22 @@ class Settings(private val appContext: Context) : PreferencesHolder {
default = false,
)
/**
* Indicates if the review quality check CFR should be displayed to the user.
*/
var shouldShowReviewQualityCheckCFR by booleanPreference(
key = appContext.getPreferenceKey(R.string.pref_key_should_show_review_quality_cfr),
default = true,
)
/**
* Time in milliseconds since the user first opted in the review quality check feature.
*/
var reviewQualityCheckOptInTimeInMillis by longPreference(
appContext.getPreferenceKey(R.string.pref_key_should_show_review_quality_opt_in_time),
default = 0L,
)
/**
* Get the current mode for how https-only is enabled.
*/

@ -369,4 +369,6 @@
<!--Shopping -->
<string name="pref_key_is_review_quality_check_enabled">pref_key_is_review_quality_check_enabled</string>
<string name="pref_key_is_review_quality_check_product_recommendations_enabled">pref_key_is_review_quality_check_product_recommendations_enabled</string>
<string name="pref_key_should_show_review_quality_cfr">pref_key_should_show_review_quality_cfr</string>
<string name="pref_key_should_show_review_quality_opt_in_time">pref_key_should_show_review_quality_opt_in_time</string>
</resources>

@ -2068,6 +2068,11 @@
<!-- Snackbar button text to navigate to telemetry settings.-->
<string name="experiments_snackbar_button">Go to settings</string>
<!-- Text for the first CFR presenting the review quality check feature. -->
<string name="review_quality_check_first_cfr_message">Find out if you can trust this products reviews — before you buy.</string>
<!-- Text for the second CFR presenting the review quality check feature. -->
<string name="review_quality_check_second_cfr_message">Are these reviews reliable? Check now to see an adjusted rating.</string>
<!-- Accessibility services actions labels. These will be appended to accessibility actions like "Double tap to.." but not by or applications but by services like Talkback. -->
<!-- Action label for elements that can be collapsed if interacting with them. Talkback will append this to say "Double tap to collapse". -->
<string name="a11y_action_label_collapse">collapse</string>

@ -15,6 +15,7 @@ import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.action.ShoppingProductAction
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.CustomTabSessionState
import mozilla.components.browser.state.state.TabSessionState
@ -34,6 +35,7 @@ import org.junit.runner.RunWith
import org.mozilla.fenix.GleanMetrics.TrackingProtection
import org.mozilla.fenix.R
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.shopping.ShoppingExperienceFeature
import org.mozilla.fenix.utils.Settings
@RunWith(FenixRobolectricTestRunner::class)
@ -55,7 +57,7 @@ class BrowserToolbarCFRPresenterTest {
presenter.start()
assertNotNull(presenter.tcpCfrScope)
assertNotNull(presenter.scope)
browserStore.dispatch(ContentAction.UpdateProgressAction(customTab.id, 0)).joinBlocking()
verify(exactly = 0) { presenter.showTcpCfr() }
@ -78,7 +80,7 @@ class BrowserToolbarCFRPresenterTest {
presenter.start()
assertNotNull(presenter.tcpCfrScope)
assertNotNull(presenter.scope)
browserStore.dispatch(ContentAction.UpdateProgressAction(normalTab.id, 1)).joinBlocking()
verify(exactly = 0) { presenter.showTcpCfr() }
@ -101,7 +103,7 @@ class BrowserToolbarCFRPresenterTest {
presenter.start()
assertNotNull(presenter.tcpCfrScope)
assertNotNull(presenter.scope)
browserStore.dispatch(ContentAction.UpdateProgressAction(privateTab.id, 14)).joinBlocking()
verify(exactly = 0) { presenter.showTcpCfr() }
@ -124,7 +126,7 @@ class BrowserToolbarCFRPresenterTest {
presenter.start()
assertNotNull(presenter.tcpCfrScope)
assertNotNull(presenter.scope)
browserStore.dispatch(ContentAction.UpdateProgressAction(tab.id, 99)).joinBlocking()
browserStore.dispatch(ContentAction.UpdateProgressAction(tab.id, 100)).joinBlocking()
@ -139,12 +141,13 @@ class BrowserToolbarCFRPresenterTest {
settings = mockk {
every { shouldShowTotalCookieProtectionCFR } returns false
every { shouldShowCookieBannerReEngagementDialog() } returns false
every { shouldShowReviewQualityCheckCFR } returns false
},
)
presenter.start()
assertNull(presenter.tcpCfrScope)
assertNull(presenter.scope)
}
@Test
@ -153,7 +156,7 @@ class BrowserToolbarCFRPresenterTest {
every { cancel() } just Runs
}
val presenter = createPresenter()
presenter.tcpCfrScope = tcpScope
presenter.scope = tcpScope
presenter.stop()
@ -171,7 +174,7 @@ class BrowserToolbarCFRPresenterTest {
presenter.showTcpCfr()
verify { settings.shouldShowTotalCookieProtectionCFR = false }
assertNotNull(presenter.tcpCfrPopup)
assertNotNull(presenter.popup)
}
@Test
@ -187,6 +190,104 @@ class BrowserToolbarCFRPresenterTest {
assertNotNull(TrackingProtection.tcpCfrShown.testGetValue())
}
@Test
fun `GIVEN the current tab is showing a product page WHEN the tab is not loading THEN the CFR is shown`() {
val tab = createTab(url = "")
val browserStore = createBrowserStore(
tab = tab,
selectedTabId = tab.id,
)
val presenter = createPresenter(
browserStore = browserStore,
settings = mockk {
every { shouldShowTotalCookieProtectionCFR } returns false
every { shouldShowReviewQualityCheckCFR } returns true
every { reviewQualityCheckOptInTimeInMillis } returns System.currentTimeMillis()
},
)
every { presenter.showShoppingCFR(any()) } just Runs
presenter.start()
assertNotNull(presenter.scope)
browserStore.dispatch(ContentAction.UpdateLoadingStateAction(tab.id, true)).joinBlocking()
verify(exactly = 0) { presenter.showShoppingCFR(eq(false)) }
browserStore.dispatch(ShoppingProductAction.UpdateProductUrlStatusAction(tab.id, true)).joinBlocking()
verify(exactly = 0) { presenter.showShoppingCFR(eq(false)) }
browserStore.dispatch(ContentAction.UpdateProgressAction(tab.id, 100)).joinBlocking()
verify(exactly = 0) { presenter.showShoppingCFR(eq(false)) }
browserStore.dispatch(ContentAction.UpdateLoadingStateAction(tab.id, false)).joinBlocking()
verify { presenter.showShoppingCFR(eq(false)) }
}
@Test
fun `GIVEN review quality CFR was previously displayed WHEN starting the presenter THEN don't observe the store`() {
val settings = mockk<Settings> {
every { shouldShowReviewQualityCheckCFR } returns false
every { shouldShowTotalCookieProtectionCFR } returns false
}
val presenter = createPresenter(settings = settings)
presenter.start()
assertNull(presenter.scope)
}
@Test
fun `GIVEN review quality feature is not enabled WHEN starting the presenter THEN don't observe the store`() {
val presenter = createPresenter(
settings = mockk {
every { shouldShowTotalCookieProtectionCFR } returns false
},
shoppingExperienceFeature = mockk {
every { isEnabled } returns false
},
)
presenter.start()
assertNull(presenter.scope)
}
@Test
fun `GIVEN the user opted in the shopping feature AND the opted in shopping CFR should be shown WHEN the tab is not loading THEN the CFR is shown`() {
val tab = createTab(url = "")
val browserStore = createBrowserStore(
tab = tab,
selectedTabId = tab.id,
)
val presenter = createPresenter(
settings = mockk {
every { shouldShowTotalCookieProtectionCFR } returns false
every { shouldShowReviewQualityCheckCFR } returns true
every { reviewQualityCheckOptInTimeInMillis } returns System.currentTimeMillis() - Settings.TWO_DAYS_MS
},
browserStore = browserStore,
)
every { presenter.showShoppingCFR(any()) } just Runs
presenter.start()
assertNotNull(presenter.scope)
browserStore.dispatch(ContentAction.UpdateLoadingStateAction(tab.id, true)).joinBlocking()
verify(exactly = 0) { presenter.showShoppingCFR(eq(true)) }
browserStore.dispatch(ShoppingProductAction.UpdateProductUrlStatusAction(tab.id, true)).joinBlocking()
verify(exactly = 0) { presenter.showShoppingCFR(eq(true)) }
browserStore.dispatch(ContentAction.UpdateProgressAction(tab.id, 100)).joinBlocking()
verify(exactly = 0) { presenter.showShoppingCFR(eq(true)) }
browserStore.dispatch(ContentAction.UpdateLoadingStateAction(tab.id, false)).joinBlocking()
verify { presenter.showShoppingCFR(eq(true)) }
}
/**
* Creates and return a [spyk] of a [BrowserToolbarCFRPresenter] that can handle actually showing CFRs.
*/
@ -197,11 +298,13 @@ class BrowserToolbarCFRPresenterTest {
settings: Settings = mockk {
every { shouldShowTotalCookieProtectionCFR } returns true
every { shouldShowCookieBannerReEngagementDialog() } returns false
every { shouldShowReviewQualityCheckCFR } returns false
},
toolbar: BrowserToolbar = mockk(),
sessionId: String? = null,
) = spyk(createPresenter(context, anchor, browserStore, settings, toolbar, sessionId)) {
every { showTcpCfr() } just Runs
every { showShoppingCFR(any()) } just Runs
}
/**
@ -212,17 +315,23 @@ class BrowserToolbarCFRPresenterTest {
context: Context = mockk {
every { getString(R.string.tcp_cfr_message) } returns "Test"
every { getColor(any()) } returns 0
every { getString(R.string.pref_key_should_show_review_quality_cfr) } returns "test"
},
anchor: View = mockk(),
anchor: View = mockk(relaxed = true),
browserStore: BrowserStore = mockk(),
settings: Settings = mockk(relaxed = true) {
every { shouldShowTotalCookieProtectionCFR } returns true
every { shouldShowCookieBannerReEngagementDialog() } returns false
every { shouldShowReviewQualityCheckCFR } returns true
},
toolbar: BrowserToolbar = mockk {
every { findViewById<View>(R.id.mozac_browser_toolbar_security_indicator) } returns anchor
every { findViewById<View>(R.id.mozac_browser_toolbar_page_actions) } returns anchor
},
sessionId: String? = null,
shoppingExperienceFeature: ShoppingExperienceFeature = mockk {
every { isEnabled } returns true
},
) = spyk(
BrowserToolbarCFRPresenter(
context = context,
@ -230,6 +339,7 @@ class BrowserToolbarCFRPresenterTest {
settings = settings,
toolbar = toolbar,
sessionId = sessionId,
shoppingExperienceFeature = shoppingExperienceFeature,
),
)

@ -48,11 +48,13 @@ class ReviewQualityCheckStoreTest {
@Test
fun `GIVEN the user has not opted in the feature WHEN the user opts in THEN state should display opted in UI`() =
runTest {
var cfrConditionUpdated = false
val tested = ReviewQualityCheckStore(
middleware = provideReviewQualityCheckMiddleware(
reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
isEnabled = false,
isProductRecommendationsEnabled = false,
updateCFRCallback = { cfrConditionUpdated = true },
),
),
)
@ -64,6 +66,7 @@ class ReviewQualityCheckStoreTest {
val expected = ReviewQualityCheckState.OptedIn(productRecommendationsPreference = false)
assertEquals(expected, tested.state)
assertEquals(true, cfrConditionUpdated)
}
@Test
@ -157,7 +160,7 @@ class ReviewQualityCheckStoreTest {
}
@Test
fun `GIVEN the user has opted in the feature WHEN the a product analysis fetch fails THEN state should reflect that`() =
fun `WHEN the user opts in the feature THEN update the preferences`() =
runTest {
val reviewQualityCheckService = mock<ReviewQualityCheckService>()
whenever(reviewQualityCheckService.fetchProductReview()).thenReturn(null)
@ -211,6 +214,7 @@ class ReviewQualityCheckStoreTest {
private class FakeReviewQualityCheckPreferences(
private val isEnabled: Boolean = false,
private val isProductRecommendationsEnabled: Boolean = false,
private val updateCFRCallback: () -> Unit = { },
) : ReviewQualityCheckPreferences {
override suspend fun enabled(): Boolean = isEnabled
@ -221,4 +225,8 @@ private class FakeReviewQualityCheckPreferences(
override suspend fun setProductRecommendationsEnabled(isEnabled: Boolean) {
}
override suspend fun updateCFRCondition(time: Long) {
updateCFRCallback()
}
}

Loading…
Cancel
Save