From e300109cc31c2e4dfd437193196da7a9e756c81d Mon Sep 17 00:00:00 2001 From: Ionut Cristian Bedregeanu Date: Fri, 19 Aug 2022 17:29:14 +0300 Subject: [PATCH] For #7503 - Show Start browsing cfr at fresh install --- app/build.gradle | 1 + .../mozilla/focus/activity/CustomTabTest.kt | 1 + .../focus/helpers/FeatureSettingsHelper.kt | 4 + .../focus/helpers/MainActivityTestRule.kt | 7 +- .../focus/screenshots/ScreenshotTest.java | 2 +- .../integration/BrowserToolbarIntegration.kt | 67 ++- .../org/mozilla/focus/compose/CFRPopup.kt | 515 ------------------ .../mozilla/focus/compose/CFRPopupShape.kt | 191 ------- .../org/mozilla/focus/compose/Modifier.kt | 24 - .../focus/input/InputToolbarIntegration.kt | 80 ++- .../navigation/MainActivityNavigation.kt | 15 + .../java/org/mozilla/focus/state/AppAction.kt | 5 + .../org/mozilla/focus/state/AppReducer.kt | 8 + .../java/org/mozilla/focus/state/AppState.kt | 2 + .../mozilla/focus/ui/theme/FocusTypography.kt | 6 + .../java/org/mozilla/focus/utils/Settings.kt | 8 + app/src/main/res/values/preference_keys.xml | 2 + app/src/main/res/values/strings.xml | 3 + .../InputToolbarIntegrationTest.kt | 82 +++ .../org/mozilla/focus/compose/CFRPopupTest.kt | 109 ---- 20 files changed, 279 insertions(+), 853 deletions(-) delete mode 100644 app/src/main/java/org/mozilla/focus/compose/CFRPopup.kt delete mode 100644 app/src/main/java/org/mozilla/focus/compose/CFRPopupShape.kt delete mode 100644 app/src/main/java/org/mozilla/focus/compose/Modifier.kt create mode 100644 app/src/test/java/org/mozilla/focus/browser/integration/InputToolbarIntegrationTest.kt delete mode 100644 app/src/test/java/org/mozilla/focus/compose/CFRPopupTest.kt diff --git a/app/build.gradle b/app/build.gradle index 7dadf7df752..e37ac77308d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -293,6 +293,7 @@ dependencies { implementation "org.mozilla.components:feature-webcompat-reporter:${AndroidComponents.VERSION}" implementation "org.mozilla.components:support-webextensions:${AndroidComponents.VERSION}" implementation "org.mozilla.components:support-locale:${AndroidComponents.VERSION}" + implementation "org.mozilla.components:compose-cfr:${AndroidComponents.VERSION}" implementation project(':service-telemetry') diff --git a/app/src/androidTest/java/org/mozilla/focus/activity/CustomTabTest.kt b/app/src/androidTest/java/org/mozilla/focus/activity/CustomTabTest.kt index cce5873ae0d..f2d544092ba 100644 --- a/app/src/androidTest/java/org/mozilla/focus/activity/CustomTabTest.kt +++ b/app/src/androidTest/java/org/mozilla/focus/activity/CustomTabTest.kt @@ -48,6 +48,7 @@ class CustomTabTest { @Before fun setUp() { featureSettingsHelper.setCfrForTrackingProtectionEnabled(false) + featureSettingsHelper.setShowStartBrowsingCfrEnabled(false) webServer = MockWebServer().apply { dispatcher = MockWebServerHelper.AndroidAssetDispatcher() start() diff --git a/app/src/androidTest/java/org/mozilla/focus/helpers/FeatureSettingsHelper.kt b/app/src/androidTest/java/org/mozilla/focus/helpers/FeatureSettingsHelper.kt index be7da05b919..ae8524f826e 100644 --- a/app/src/androidTest/java/org/mozilla/focus/helpers/FeatureSettingsHelper.kt +++ b/app/src/androidTest/java/org/mozilla/focus/helpers/FeatureSettingsHelper.kt @@ -19,6 +19,10 @@ class FeatureSettingsHelper { settings.shouldShowCfrForTrackingProtection = enabled } + fun setShowStartBrowsingCfrEnabled(enabled: Boolean) { + settings.shouldShowStartBrowsingCfr = enabled + } + fun setSearchWidgetDialogEnabled(enabled: Boolean) { if (enabled) { settings.addClearBrowsingSessions(4) diff --git a/app/src/androidTest/java/org/mozilla/focus/helpers/MainActivityTestRule.kt b/app/src/androidTest/java/org/mozilla/focus/helpers/MainActivityTestRule.kt index 6efe00660a8..46affec39ca 100644 --- a/app/src/androidTest/java/org/mozilla/focus/helpers/MainActivityTestRule.kt +++ b/app/src/androidTest/java/org/mozilla/focus/helpers/MainActivityTestRule.kt @@ -29,6 +29,7 @@ open class MainActivityFirstrunTestRule( launchActivity: Boolean = true, private val showFirstRun: Boolean, private val showNewOnboarding: Boolean = true, + private val showStartBrowsingCfrVisibility: Boolean = false, ) : ActivityTestRule(MainActivity::class.java, launchActivity) { private val longTapUserPreference = getLongPressTimeout() private val featureSettingsHelper = FeatureSettingsHelper() @@ -37,6 +38,7 @@ open class MainActivityFirstrunTestRule( override fun beforeActivityLaunched() { super.beforeActivityLaunched() updateFirstRun(showFirstRun) + featureSettingsHelper.setShowStartBrowsingCfrEnabled(showStartBrowsingCfrVisibility) setNewOnboarding(showNewOnboarding) setLongTapTimeout(3000) } @@ -61,14 +63,17 @@ open class MainActivityFirstrunTestRule( } // Test rule that allows usage of Espresso Intents -open class MainActivityIntentsTestRule(launchActivity: Boolean = true, private val showFirstRun: Boolean) : +open class MainActivityIntentsTestRule(launchActivity: Boolean = true, private val showFirstRun: Boolean, private val showStartBrowsingCfrVisibility: Boolean = false) : IntentsTestRule(MainActivity::class.java, launchActivity) { private val longTapUserPreference = getLongPressTimeout() + private val featureSettingsHelper = FeatureSettingsHelper() @CallSuper override fun beforeActivityLaunched() { super.beforeActivityLaunched() + updateFirstRun(showFirstRun) + featureSettingsHelper.setShowStartBrowsingCfrEnabled(showStartBrowsingCfrVisibility) setLongTapTimeout(3000) } diff --git a/app/src/androidTest/java/org/mozilla/focus/screenshots/ScreenshotTest.java b/app/src/androidTest/java/org/mozilla/focus/screenshots/ScreenshotTest.java index 59b3b9b75f6..427d2f06611 100644 --- a/app/src/androidTest/java/org/mozilla/focus/screenshots/ScreenshotTest.java +++ b/app/src/androidTest/java/org/mozilla/focus/screenshots/ScreenshotTest.java @@ -35,7 +35,7 @@ public abstract class ScreenshotTest { UiDevice device; @Rule - public ActivityTestRule mActivityTestRule = new MainActivityFirstrunTestRule(true, false, true) { + public ActivityTestRule mActivityTestRule = new MainActivityFirstrunTestRule(true, false, true,false) { @Override protected void beforeActivityLaunched() { super.beforeActivityLaunched(); diff --git a/app/src/main/java/org/mozilla/focus/browser/integration/BrowserToolbarIntegration.kt b/app/src/main/java/org/mozilla/focus/browser/integration/BrowserToolbarIntegration.kt index d323669daa4..56872db2ef9 100644 --- a/app/src/main/java/org/mozilla/focus/browser/integration/BrowserToolbarIntegration.kt +++ b/app/src/main/java/org/mozilla/focus/browser/integration/BrowserToolbarIntegration.kt @@ -8,6 +8,9 @@ import android.graphics.Color import android.widget.LinearLayout import androidx.annotation.VisibleForTesting import androidx.appcompat.content.res.AppCompatResources +import androidx.compose.material.Text +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat import androidx.core.view.children @@ -18,6 +21,8 @@ import mozilla.components.browser.state.selector.findCustomTabOrSelectedTab import mozilla.components.browser.state.store.BrowserStore import mozilla.components.browser.toolbar.BrowserToolbar import mozilla.components.browser.toolbar.display.DisplayToolbar.Indicators +import mozilla.components.compose.cfr.CFRPopup +import mozilla.components.compose.cfr.CFRPopupProperties import mozilla.components.feature.customtabs.CustomTabsToolbarFeature import mozilla.components.feature.session.SessionUseCases import mozilla.components.feature.tabs.CustomTabsUseCases @@ -31,7 +36,6 @@ import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged import org.mozilla.focus.GleanMetrics.TabCount import org.mozilla.focus.GleanMetrics.TrackingProtection import org.mozilla.focus.R -import org.mozilla.focus.compose.CFRPopup import org.mozilla.focus.ext.components import org.mozilla.focus.ext.isCustomTab import org.mozilla.focus.ext.isTablet @@ -41,6 +45,7 @@ import org.mozilla.focus.menu.browser.CustomTabMenu import org.mozilla.focus.nimbus.FocusNimbus import org.mozilla.focus.state.AppAction import org.mozilla.focus.telemetry.TelemetryWrapper +import org.mozilla.focus.ui.theme.focusTypography @Suppress("LongParameterList", "LargeClass", "TooManyFunctions") class BrowserToolbarIntegration( @@ -238,10 +243,34 @@ class BrowserToolbarIntegration( .children .last() CFRPopup( - container = fragment.requireView(), - text = fragment.getString(R.string.cfr_for_toolbar_delete_icon2), anchor = eraseActionView, - onDismiss = ::onDismissEraseTabsCfr, + properties = CFRPopupProperties( + popupWidth = 256.dp, + popupAlignment = CFRPopup.PopupAlignment.INDICATOR_CENTERED_IN_ANCHOR, + popupBodyColors = listOf( + ContextCompat.getColor( + fragment.requireContext(), + R.color.cfr_pop_up_shape_end_color, + ), + ContextCompat.getColor( + fragment.requireContext(), + R.color.cfr_pop_up_shape_start_color, + ), + ), + dismissButtonColor = ContextCompat.getColor( + fragment.requireContext(), + R.color.cardview_light_background, + ), + popupVerticalOffset = 0.dp, + ), + onDismiss = { onDismissEraseTabsCfr() }, + text = { + Text( + style = focusTypography.cfrTextStyle, + text = fragment.getString(R.string.cfr_for_toolbar_delete_icon2), + color = colorResource(R.color.cfr_text_color), + ) + }, ).apply { show() } @@ -262,12 +291,36 @@ class BrowserToolbarIntegration( .collect { showTrackingProtectionCfrForTab -> if (showTrackingProtectionCfrForTab[store.state.selectedTabId] == true) { CFRPopup( - container = fragment.requireView(), - text = fragment.getString(R.string.cfr_for_toolbar_shield_icon2), anchor = toolbar.findViewById( R.id.mozac_browser_toolbar_tracking_protection_indicator, ), - onDismiss = ::onDismissTrackingProtectionCfr, + properties = CFRPopupProperties( + popupWidth = 256.dp, + popupAlignment = CFRPopup.PopupAlignment.INDICATOR_CENTERED_IN_ANCHOR, + popupBodyColors = listOf( + ContextCompat.getColor( + fragment.requireContext(), + R.color.cfr_pop_up_shape_end_color, + ), + ContextCompat.getColor( + fragment.requireContext(), + R.color.cfr_pop_up_shape_start_color, + ), + ), + dismissButtonColor = ContextCompat.getColor( + fragment.requireContext(), + R.color.cardview_light_background, + ), + popupVerticalOffset = 0.dp, + ), + onDismiss = { onDismissTrackingProtectionCfr() }, + text = { + Text( + style = focusTypography.cfrTextStyle, + text = fragment.getString(R.string.cfr_for_toolbar_shield_icon2), + color = colorResource(R.color.cfr_text_color), + ) + }, ).apply { show() } diff --git a/app/src/main/java/org/mozilla/focus/compose/CFRPopup.kt b/app/src/main/java/org/mozilla/focus/compose/CFRPopup.kt deleted file mode 100644 index e32b45ba642..00000000000 --- a/app/src/main/java/org/mozilla/focus/compose/CFRPopup.kt +++ /dev/null @@ -1,515 +0,0 @@ -/* 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.focus.compose - -import android.annotation.SuppressLint -import android.content.Context -import android.graphics.PixelFormat -import android.view.View -import android.view.WindowManager -import androidx.annotation.Px -import androidx.annotation.VisibleForTesting -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.AbstractComposeView -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.ViewRootForInspector -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntRect -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupPositionProvider -import androidx.compose.ui.window.PopupProperties -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.lifecycle.ViewTreeLifecycleOwner -import androidx.savedstate.findViewTreeSavedStateRegistryOwner -import androidx.savedstate.setViewTreeSavedStateRegistryOwner -import mozilla.components.support.ktx.android.util.dpToPx -import org.mozilla.focus.R -import org.mozilla.focus.R.color -import org.mozilla.focus.ui.theme.FocusTheme -import org.mozilla.gecko.GeckoScreenOrientation -import kotlin.math.absoluteValue -import kotlin.math.roundToInt - -/** - * Fixed width for all CFRs. - */ -private const val POPUP_WIDTH_DP = 256 - -/** - * Fixed horizontal padding. - * Allows the close button to extend with 10dp more to the end and intercept touches to a bit outside of the popup - * to ensure it respects a11y recomendations of 48dp size while also offer a bit more space to the text. - */ -private const val HORIZONTAL_PADDING = 10 - -/** - * Value class allowing to easily reason about what an `Int` represents. - * This is compiled to the underlying `Int` type so incurs no performance penalty. - */ -@JvmInline -internal value class Pixels(val value: Int) - -/** - * Simple wrapper over the absolute x-coordinates of the popup. Includes any paddings. - */ -internal data class PopupHorizontalBounds( - val startCoord: Pixels, - val endCoord: Pixels, -) - -/** - * Properties used to customize the behavior of a [CFRPopup]. - * - * @property dismissOnBackPress Whether the popup can be dismissed by pressing the back button. - * If true, pressing the back button will also call onDismiss(). - * @property dismissOnClickOutside Whether the popup can be dismissed by clicking outside the - * popup's bounds. If true, clicking outside the popup will call onDismiss(). - * @property overlapAnchor How the popup will be anchored with it's top-start corner: - * - true - popup will be anchored in the exactly in the middle horizontally and vertically - * - false - popup will be anchored horizontally in the middle of the anchor but immediately below it - * @property indicatorArrowStartOffset Maximum distance between the popup start and the indicator arrow. - * If there isn't enough space this could automatically be overridden up to 0. - * @property indicatorArrowHeight How tall the indicator arrow should be. - * This will also affect how wide the base of the indicator arrow will be. - * @property elevation The z-coordinate at which to place the popup. - * This controls the size of the shadow below the popup. - */ -data class CFRPopupProperties( - val dismissOnBackPress: Boolean = true, - val dismissOnClickOutside: Boolean = true, - val overlapAnchor: Boolean = false, - val indicatorArrowStartOffset: Dp = 30.dp, - val indicatorArrowHeight: Dp = 10.dp, - val elevation: Dp = 10.dp, -) - -/** - * CFR - Contextual Feature Recommendation popup. - * - * @param container [View] that this popup's lifecycle will be tied to. - * @param text [String] shown as the popup content. - * @param anchor [View] that will serve as the anchor of the popup. - * @param onDismiss Callback for when the popup is dismissed. - * @param properties [CFRPopupProperties] allowing to customize the popup behavior. - */ -class CFRPopup( - private val container: View, - private val text: String, - private val anchor: View, - private val properties: CFRPopupProperties = CFRPopupProperties(), - private val onDismiss: () -> Unit = {}, -) { - // This is just a facade for CFRPopupFullScreenLayout to offer a cleaner API. - - /** - * Construct and display a styled CFR popup shown at the coordinates of [anchor]. - * This popup will be dismissed when the user clicks on the "x" button or based on other user actions - * with such behavior set in [CFRPopupProperties]. - */ - fun show() { - anchor.post { - CFRPopupFullScreenLayout(container, anchor, text, properties, onDismiss).apply { - this.show() - isTransitionGroup = true - } - } - } -} - -/** - * [AbstractComposeView] that can be added or removed dynamically in the current window to display - * a [Composable] based popup anywhere on the screen. - */ -@OptIn(ExperimentalComposeUiApi::class) -@SuppressLint("ViewConstructor") -// Intended to be used only in code, don't need a View constructor -@VisibleForTesting -internal class CFRPopupFullScreenLayout( - private val container: View, - private val anchor: View, - private val text: String, - private val properties: CFRPopupProperties, - private val onDismiss: () -> Unit, -) : AbstractComposeView(container.context), ViewRootForInspector { - private val windowManager = - container.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager - - /** - * Listener for when the anchor is removed from the screen. - * Useful in the following situations: - * - lack of purpose - if there is no anchor the context/action to which this popup refers to disappeared - * - leak from WindowManager - if removing the app from task manager while the popup is shown. - * - * Will not inform client about this since the user did not expressly dismissed this popup. - */ - private val anchorDetachedListener = OnViewDetachedListener { - dismiss() - } - - /** - * When the screen is rotated the popup may get improperly anchored - * because of the async nature of insets and screen rotation. - * To avoid any improper anchorage the popups are automatically dismissed. - * Will not inform client about this since the user did not expressly dismissed this popup. - */ - private val orientationChangeListener = GeckoScreenOrientation.OrientationChangeListener { - dismiss() - } - - override var shouldCreateCompositionOnAttachedToWindow: Boolean = false - private set - - init { - ViewTreeLifecycleOwner.set(this, ViewTreeLifecycleOwner.get(container)) - this.setViewTreeSavedStateRegistryOwner(container.findViewTreeSavedStateRegistryOwner()) - GeckoScreenOrientation.getInstance().addListener(orientationChangeListener) - anchor.addOnAttachStateChangeListener(anchorDetachedListener) - } - - /** - * Add a new CFR popup to the current window overlaying everything already displayed. - * This popup will be dismissed when the user clicks on the "x" button or based on other user actions - * with such behavior set in [CFRPopupProperties]. - */ - fun show() { - windowManager.addView(this, createLayoutParams()) - } - - @Composable - override fun Content() { - val anchorLocation = IntArray(2).apply { - anchor.getLocationOnScreen(this) - } - - val anchorXCoordMiddle = Pixels(anchorLocation.first() + anchor.width / 2) - val indicatorArrowHeight = Pixels(properties.indicatorArrowHeight.toPx()) - - val popupBounds = computePopupHorizontalBounds( - anchorMiddleXCoord = anchorXCoordMiddle, - arrowIndicatorWidth = Pixels(CFRPopupShape.getIndicatorBaseWidthForHeight(indicatorArrowHeight.value)), - ) - val indicatorOffset = computeIndicatorArrowStartCoord( - anchorMiddleXCoord = anchorXCoordMiddle, - popupStartCoord = popupBounds.startCoord, - arrowIndicatorWidth = Pixels( - CFRPopupShape.getIndicatorBaseWidthForHeight( - properties.indicatorArrowHeight.toPx(), - ), - ), - ) - - Popup( - popupPositionProvider = object : PopupPositionProvider { - override fun calculatePosition( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: IntSize, - ): IntOffset { - // Popup will be anchored such that the indicator arrow will point to the middle of the anchor View - // but the popup is allowed some space as start padding in which it can be displayed such that - // the indicator arrow is now exactly at the top-start corner but slightly translated to end. - // Values are in pixels. - return IntOffset( - when (layoutDirection) { - LayoutDirection.Ltr -> popupBounds.startCoord.value - else -> popupBounds.endCoord.value - }, - when (properties.overlapAnchor) { - true -> anchorLocation.last() + anchor.height / 2 - else -> anchorLocation.last() + anchor.height - }, - ) - } - }, - properties = PopupProperties( - focusable = properties.dismissOnBackPress, - dismissOnBackPress = properties.dismissOnBackPress, - dismissOnClickOutside = properties.dismissOnClickOutside, - ), - onDismissRequest = { - // For when tapping outside the popup or on the back button. - dismiss() - onDismiss() - }, - ) { - CFRPopupContent( - text = text, - indicatorArrowStartOffset = with(LocalDensity.current) { - (indicatorOffset.value).toDp() - }, - indicatorArrowHeight = properties.indicatorArrowHeight, - elevation = properties.elevation, - onDismissButton = { - // For when tapping the "X" button. - dismiss() - onDismiss() - }, - ) - } - } - - /** - * Compute the x-coordinates for the absolute start and end position of the popup, including any padding. - * This assumes anchoring is indicated with an arrow to the horizontal middle of the anchor with the popup's - * body potentially extending to the `start` of the arrow indicator. - * - * @param anchorMiddleXCoord x-coordinate for the middle of the anchor. - * @param arrowIndicatorWidth x-distance the arrow indicator occupies. - */ - @Composable - @VisibleForTesting - internal fun computePopupHorizontalBounds( - anchorMiddleXCoord: Pixels, - arrowIndicatorWidth: Pixels, - ): PopupHorizontalBounds { - val arrowIndicatorHalfWidth = arrowIndicatorWidth.value / 2 - - if (LocalConfiguration.current.layoutDirection == View.LAYOUT_DIRECTION_LTR) { - // Push the popup as far to the start as needed including any needed paddings. - val startCoord = Pixels( - (anchorMiddleXCoord.value - arrowIndicatorHalfWidth) - .minus(properties.indicatorArrowStartOffset.toPx()) - .minus(HORIZONTAL_PADDING.dp.toPx()) - .coerceAtLeast(getLeftInsets()), - ) - - return PopupHorizontalBounds( - startCoord = startCoord, - endCoord = Pixels( - startCoord.value + POPUP_WIDTH_DP.dp.toPx() + HORIZONTAL_PADDING.dp.toPx() * 2, - ), - ) - } else { - val startCoord = Pixels( - // Push the popup as far to the start (in RTL) as possible. - anchorMiddleXCoord.value - .plus(arrowIndicatorHalfWidth) - .plus(properties.indicatorArrowStartOffset.toPx()) - .plus(HORIZONTAL_PADDING.dp.toPx()) - .coerceAtMost( - LocalDensity.current.run { - LocalConfiguration.current.screenWidthDp.dp.toPx() - } - .roundToInt() - .plus(getLeftInsets()), - ), - ) - return PopupHorizontalBounds( - startCoord = startCoord, - endCoord = Pixels( - startCoord.value - POPUP_WIDTH_DP.dp.toPx() - HORIZONTAL_PADDING.dp.toPx() * 2, - ), - ) - } - } - - /** - * Compute the x-coordinate for where the popup's indicator arrow should start - * relative to the available distance between it and the popup's starting x-coordinate. - * - * @param anchorMiddleXCoord x-coordinate for the middle of the anchor. - * @param popupStartCoord x-coordinate for the popup start - * @param arrowIndicatorWidth Width of the arrow indicator. - */ - @Composable - private fun computeIndicatorArrowStartCoord( - anchorMiddleXCoord: Pixels, - popupStartCoord: Pixels, - arrowIndicatorWidth: Pixels, - ): Pixels { - val arrowIndicatorHalfWidth = arrowIndicatorWidth.value / 2 - - return if (LocalConfiguration.current.layoutDirection == View.LAYOUT_DIRECTION_LTR) { - val visiblePopupStartCoord = popupStartCoord.value + HORIZONTAL_PADDING.dp.toPx() - val arrowIndicatorStartCoord = anchorMiddleXCoord.value - arrowIndicatorHalfWidth - - Pixels((visiblePopupStartCoord - arrowIndicatorStartCoord).absoluteValue) - } else { - val indicatorStartCoord = popupStartCoord.value - HORIZONTAL_PADDING.dp.toPx() - - anchorMiddleXCoord.value - arrowIndicatorHalfWidth - - Pixels(indicatorStartCoord.absoluteValue) - } - } - - /** - * Cleanup and remove the current popup from the screen. - * Clients are not automatically informed about this. Use a separate call to [onDismiss] if needed. - */ - @VisibleForTesting - internal fun dismiss() { - anchor.removeOnAttachStateChangeListener(anchorDetachedListener) - GeckoScreenOrientation.getInstance().removeListener(orientationChangeListener) - disposeComposition() - ViewTreeLifecycleOwner.set(this, null) - this.setViewTreeSavedStateRegistryOwner(null) - windowManager.removeViewImmediate(this) - } - - /** - * Create fullscreen translucent layout params. - * This will allow placing the visible popup anywhere on the screen. - */ - @VisibleForTesting internal fun createLayoutParams(): WindowManager.LayoutParams = - WindowManager.LayoutParams().apply { - type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL - token = container.applicationWindowToken - width = WindowManager.LayoutParams.MATCH_PARENT - height = WindowManager.LayoutParams.MATCH_PARENT - format = PixelFormat.TRANSLUCENT - flags = WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or - WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED - } - - /** - * Intended to allow querying the insets of the navigation bar. - * Value will be `0` except for when the screen is rotated by 90 degrees. - */ - private fun getLeftInsets() = ViewCompat.getRootWindowInsets(container) - ?.getInsets(WindowInsetsCompat.Type.systemBars())?.left - ?: 0.coerceAtLeast(0) - - @Px - private fun Dp.toPx(): Int { - return this.value - .dpToPx(container.resources.displayMetrics) - .roundToInt() - } -} - -/** - * Simpler [View.OnAttachStateChangeListener] only informing about - * [View.OnAttachStateChangeListener.onViewDetachedFromWindow]. - */ -private class OnViewDetachedListener(val onDismiss: () -> Unit) : View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View) { - // no-op - } - - override fun onViewDetachedFromWindow(v: View) { - onDismiss() - } -} - -/** - * Complete content of the popup. - * [CFRPopupShape] with a gradient background containing [text] and a dismiss ("X") button. - * - * @param text String offering more context about a particular feature to the user. - * @param indicatorArrowStartOffset Maximum distance between the popup start and the indicator arrow. - * If there isn't enough space this could automatically be overridden up to 0. - * @param indicatorArrowHeight How tall the indicator arrow should be. - * This will also affect how wide the base of the indicator arrow will be. - * @param elevation The z-coordinate at which to place the popup. - * This controls the size of the shadow below the popup. - * @param onDismissButton Callback for when the user clicks on the "X" button. - */ -@Composable -private fun CFRPopupContent( - text: String, - indicatorArrowStartOffset: Dp, - indicatorArrowHeight: Dp, - elevation: Dp = 0.dp, - onDismissButton: () -> Unit, -) { - FocusTheme { - Box(modifier = Modifier.width(POPUP_WIDTH_DP.dp + HORIZONTAL_PADDING.dp * 2)) { - Surface( - color = Color.Transparent, - elevation = elevation, - // Need to override the default RectangleShape to avoid casting shadows for that shape. - shape = CFRPopupShape(indicatorArrowStartOffset, 10.dp, 10.dp), - modifier = Modifier - .align(Alignment.Center) - .background( - shape = CFRPopupShape(indicatorArrowStartOffset, 10.dp, 10.dp), - brush = Brush.linearGradient( - colors = listOf( - colorResource(color.cfr_pop_up_shape_end_color), - colorResource(color.cfr_pop_up_shape_start_color), - ), - end = Offset(0f, Float.POSITIVE_INFINITY), - start = Offset(Float.POSITIVE_INFINITY, 0f), - ), - ) - .wrapContentHeight() - .width(POPUP_WIDTH_DP.dp), - ) { - Text( - text = text, - fontSize = 16.sp, - letterSpacing = 0.5.sp, - color = colorResource(color.cfr_text_color), - lineHeight = 24.sp, - modifier = Modifier - .padding( - start = 16.dp, - top = 16.dp + indicatorArrowHeight, - end = 33.dp, - bottom = 16.dp, - ), - ) - } - - IconButton( - onClick = onDismissButton, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = 6.dp, end = 6.dp) - .size(48.dp), - ) { - Icon( - Icons.Filled.Close, - contentDescription = stringResource(id = R.string.cfr_close_button_description), - modifier = Modifier.size(22.dp), - tint = colorResource(color.cardview_light_background), - ) - } - } - } -} - -@Preview -@Composable -private fun CFRPopupPreview() { - FocusTheme { - CFRPopupContent( - text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt", - indicatorArrowStartOffset = 10.dp, - indicatorArrowHeight = 15.dp, - elevation = 30.dp, - onDismissButton = { }, - ) - } -} diff --git a/app/src/main/java/org/mozilla/focus/compose/CFRPopupShape.kt b/app/src/main/java/org/mozilla/focus/compose/CFRPopupShape.kt deleted file mode 100644 index 9861997a08a..00000000000 --- a/app/src/main/java/org/mozilla/focus/compose/CFRPopupShape.kt +++ /dev/null @@ -1,191 +0,0 @@ -/* 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.focus.compose - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Outline -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import org.mozilla.focus.R.color -import org.mozilla.focus.ui.theme.FocusTheme -import kotlin.math.roundToInt - -private const val INDICATOR_BASE_TO_HEIGHT_RATIO = 1.5f - -/** - * A [Shape] describing a popup with an indicator triangle shown at top. - * - * @param indicatorArrowStartOffset Distance between the popup start and the indicator arrow start - * @param indicatorArrowHeight Height of the indicator triangle. This influences the base length. - * @param cornerRadius The radius of the popup's corners. - * If [indicatorArrowStartOffset] is `0` then the top-start corner will not be rounded. - */ -class CFRPopupShape( - private val indicatorArrowStartOffset: Dp, - private val indicatorArrowHeight: Dp, - private val cornerRadius: Dp, -) : Shape { - override fun createOutline( - size: Size, - layoutDirection: LayoutDirection, - density: Density, - ): Outline { - val indicatorArrowStartOffsetPx = indicatorArrowStartOffset.value * density.density - val indicatorArrowHeightPx = indicatorArrowHeight.value * density.density - val indicatorArrowBasePx = - getIndicatorBaseWidthForHeight((indicatorArrowHeight.value * density.density).roundToInt()) - val cornerRadiusPx = cornerRadius.value * density.density - - return Outline.Generic( - path = Path().apply { - reset() - - lineTo(0f, size.height - cornerRadiusPx) - quadraticBezierTo( - 0f, - size.height, - cornerRadiusPx, - size.height, - ) - - lineTo(size.width - cornerRadiusPx, size.height) - quadraticBezierTo( - size.width, - size.height, - size.width, - size.height - cornerRadiusPx, - ) - - val indicatorCornerRadius = cornerRadiusPx.coerceAtMost(indicatorArrowStartOffsetPx) - if (layoutDirection == LayoutDirection.Ltr) { - lineTo(size.width, cornerRadiusPx + indicatorArrowHeightPx) - quadraticBezierTo( - size.width, - indicatorArrowHeightPx, - size.width - cornerRadiusPx, - indicatorArrowHeightPx, - ) - - lineTo(indicatorArrowStartOffsetPx + indicatorArrowBasePx, indicatorArrowHeightPx) - lineTo(indicatorArrowStartOffsetPx + indicatorArrowBasePx / 2, 0f) - lineTo(indicatorArrowStartOffsetPx, indicatorArrowHeightPx) - - lineTo(indicatorCornerRadius, indicatorArrowHeightPx) - quadraticBezierTo( - 0f, - indicatorArrowHeightPx, - 0f, - indicatorArrowHeightPx + indicatorCornerRadius, - ) - } else { - lineTo(size.width, indicatorCornerRadius + indicatorArrowHeightPx) - quadraticBezierTo( - size.width, - indicatorArrowHeightPx, - size.width - indicatorCornerRadius, - indicatorArrowHeightPx, - ) - - val indicatorEnd = size.width - indicatorArrowStartOffsetPx - lineTo(indicatorEnd, indicatorArrowHeightPx) - lineTo(indicatorEnd - indicatorArrowBasePx / 2, 0f) - lineTo(indicatorEnd - indicatorArrowBasePx, indicatorArrowHeightPx) - - lineTo(cornerRadiusPx, indicatorArrowHeightPx) - quadraticBezierTo( - 0f, - indicatorArrowHeightPx, - 0f, - indicatorArrowHeightPx + cornerRadiusPx, - ) - } - - close() - }, - ) - } - - companion object { - /** - * This [Shape]'s arrow indicator will have an automatic width depending on the set height. - * This method allows knowing what the base width will be before instantiating the class. - */ - fun getIndicatorBaseWidthForHeight(height: Int): Int { - return (height * INDICATOR_BASE_TO_HEIGHT_RATIO).roundToInt() - } - } -} - -@Preview -@Composable -private fun CFRPopupShapeLTRPreview() { - FocusTheme { - Box( - modifier = Modifier - .height(100.dp) - .width(200.dp) - .background( - shape = CFRPopupShape(10.dp, 10.dp, 10.dp), - brush = Brush.linearGradient( - colors = listOf( - colorResource(color.cfr_pop_up_shape_end_color), - colorResource(color.cfr_pop_up_shape_start_color), - ), - end = Offset(0f, Float.POSITIVE_INFINITY), - start = Offset(Float.POSITIVE_INFINITY, 0f), - ), - ), - contentAlignment = Alignment.Center, - ) { - Text(text = "This is just a test") - } - } -} - -@Preview -@Composable -private fun CFRPopupShapeRTLPreview() { - FocusTheme { - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { - Box( - modifier = Modifier - .height(100.dp) - .width(200.dp) - .background( - shape = CFRPopupShape(10.dp, 10.dp, 10.dp), - brush = Brush.linearGradient( - colors = listOf( - colorResource(color.cfr_pop_up_shape_end_color), - colorResource(color.cfr_pop_up_shape_start_color), - ), - end = Offset(0f, Float.POSITIVE_INFINITY), - start = Offset(Float.POSITIVE_INFINITY, 0f), - ), - ), - contentAlignment = Alignment.Center, - ) { - Text(text = "This is just a test") - } - } - } -} diff --git a/app/src/main/java/org/mozilla/focus/compose/Modifier.kt b/app/src/main/java/org/mozilla/focus/compose/Modifier.kt deleted file mode 100644 index 9572fff4dd7..00000000000 --- a/app/src/main/java/org/mozilla/focus/compose/Modifier.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* 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.focus.compose - -import androidx.compose.runtime.Stable -import androidx.compose.ui.Modifier -import androidx.compose.ui.composed -import androidx.compose.ui.draw.scale -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.unit.LayoutDirection - -/** - * Modifier used for performing auto mirroring in RTL for composable. - */ -@Stable -fun Modifier.autoMirror(): Modifier = composed { - if (LocalLayoutDirection.current == LayoutDirection.Rtl) { - this.scale(scaleX = -1f, scaleY = 1f) - } else { - this - } -} diff --git a/app/src/main/java/org/mozilla/focus/input/InputToolbarIntegration.kt b/app/src/main/java/org/mozilla/focus/input/InputToolbarIntegration.kt index ef8bd56abe9..b9ab2f78273 100644 --- a/app/src/main/java/org/mozilla/focus/input/InputToolbarIntegration.kt +++ b/app/src/main/java/org/mozilla/focus/input/InputToolbarIntegration.kt @@ -4,21 +4,36 @@ package org.mozilla.focus.input +import androidx.annotation.VisibleForTesting +import androidx.appcompat.widget.AppCompatEditText +import androidx.compose.material.Text +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.mapNotNull import mozilla.components.browser.domains.autocomplete.CustomDomainsProvider import mozilla.components.browser.domains.autocomplete.DomainAutocompleteResult import mozilla.components.browser.domains.autocomplete.ShippedDomainsProvider import mozilla.components.browser.toolbar.BrowserToolbar +import mozilla.components.compose.cfr.CFRPopup +import mozilla.components.compose.cfr.CFRPopupProperties import mozilla.components.concept.toolbar.Toolbar +import mozilla.components.lib.state.ext.flowScoped import mozilla.components.support.base.feature.LifecycleAwareFeature +import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged import org.mozilla.focus.R +import org.mozilla.focus.ext.components import org.mozilla.focus.ext.settings import org.mozilla.focus.fragment.UrlInputFragment +import org.mozilla.focus.state.AppAction +import org.mozilla.focus.ui.theme.focusTypography class InputToolbarIntegration( - toolbar: BrowserToolbar, - fragment: UrlInputFragment, + private val toolbar: BrowserToolbar, + private val fragment: UrlInputFragment, shippedDomainsProvider: ShippedDomainsProvider, customDomainsProvider: CustomDomainsProvider, ) : LifecycleAwareFeature { @@ -27,16 +42,19 @@ class InputToolbarIntegration( private var useShippedDomainProvider: Boolean = false private var useCustomDomainProvider: Boolean = false + @VisibleForTesting + internal var startBrowsingCfrScope: CoroutineScope? = null + init { with(toolbar.display) { indicators = emptyList() - hint = fragment.getString(R.string.urlbar_hint) + hint = fragment.resources.getString(R.string.urlbar_hint) colors = toolbar.display.colors.copy( hint = ContextCompat.getColor(toolbar.context, R.color.urlBarHintText), text = ContextCompat.getColor(toolbar.context, R.color.primaryText), ) } - toolbar.edit.hint = fragment.getString(R.string.urlbar_hint) + toolbar.edit.hint = fragment.resources.getString(R.string.urlbar_hint) toolbar.private = true toolbar.edit.colors = toolbar.edit.colors.copy( hint = ContextCompat.getColor(toolbar.context, R.color.urlBarHintText), @@ -105,9 +123,61 @@ class InputToolbarIntegration( override fun start() { useCustomDomainProvider = settings.shouldAutocompleteFromCustomDomainList() useShippedDomainProvider = settings.shouldAutocompleteFromShippedDomainList() + if (fragment.components?.appStore?.state?.showStartBrowsingTabsCfr == true) { + observeStartBrowserCfrVisibility() + } + } + + @VisibleForTesting + internal fun observeStartBrowserCfrVisibility() { + startBrowsingCfrScope = fragment.components?.appStore?.flowScoped { flow -> + flow.mapNotNull { state -> state.showStartBrowsingTabsCfr }.ifChanged().collect { showStartBrowsingCfr -> + if (showStartBrowsingCfr) { + CFRPopup( + anchor = toolbar.findViewById(R.id.mozac_browser_toolbar_background), + properties = CFRPopupProperties( + popupWidth = 256.dp, + popupAlignment = CFRPopup.PopupAlignment.BODY_TO_ANCHOR_START, + popupBodyColors = listOf( + ContextCompat.getColor( + fragment.requireContext(), + R.color.cfr_pop_up_shape_end_color, + ), + ContextCompat.getColor( + fragment.requireContext(), + R.color.cfr_pop_up_shape_start_color, + ), + ), + dismissButtonColor = ContextCompat.getColor( + fragment.requireContext(), + R.color.cardview_light_background, + ), + popupVerticalOffset = 0.dp, + ), + onDismiss = { + onDismissStartBrowsingCfr() + }, + text = { + Text( + style = focusTypography.cfrTextStyle, + text = fragment.resources.getString(R.string.cfr_for_start_browsing), + color = colorResource(R.color.cfr_text_color), + ) + }, + ).apply { + show() + } + } + } + } + } + + private fun onDismissStartBrowsingCfr() { + fragment.components?.appStore?.dispatch(AppAction.ShowStartBrowsingCfrChange(false)) + fragment.requireContext().settings.shouldShowStartBrowsingCfr = false } override fun stop() { - // Do nothing + startBrowsingCfrScope?.cancel() } } diff --git a/app/src/main/java/org/mozilla/focus/navigation/MainActivityNavigation.kt b/app/src/main/java/org/mozilla/focus/navigation/MainActivityNavigation.kt index f265dfb2eea..127072aefdc 100644 --- a/app/src/main/java/org/mozilla/focus/navigation/MainActivityNavigation.kt +++ b/app/src/main/java/org/mozilla/focus/navigation/MainActivityNavigation.kt @@ -43,6 +43,7 @@ import org.mozilla.focus.settings.permissions.permissionoptions.SitePermission import org.mozilla.focus.settings.permissions.permissionoptions.SitePermissionOptionsFragment import org.mozilla.focus.settings.privacy.PrivacySecuritySettingsFragment import org.mozilla.focus.settings.privacy.studies.StudiesFragment +import org.mozilla.focus.state.AppAction import org.mozilla.focus.state.Screen import org.mozilla.focus.utils.ViewUtils import kotlin.collections.forEach as withEach @@ -51,6 +52,7 @@ import kotlin.collections.forEach as withEach * Class performing the actual navigation in [MainActivity] by performing fragment transactions if * needed. */ +@Suppress("TooManyFunctions") class MainActivityNavigation( private val activity: MainActivity, ) { @@ -83,6 +85,7 @@ class MainActivityNavigation( transaction.setCustomAnimations(0, R.anim.erase_animation) } + showStartBrowsingCfr() // Currently this callback can get invoked while the app is in the background. Therefore we are using // commitAllowingStateLoss() here because we can't do a fragment transaction while the app is in the // background - like we already do in showBrowserScreenForCurrentSession(). @@ -97,6 +100,18 @@ class MainActivityNavigation( .commitAllowingStateLoss() } + private fun showStartBrowsingCfr() { + val onboardingConfig = FocusNimbus.features.onboarding.value(activity) + if ( + onboardingConfig.isCfrEnabled && + !activity.settings.isFirstRun && + activity.settings.shouldShowStartBrowsingCfr + ) { + FocusNimbus.features.onboarding.recordExposure() + activity.components.appStore.dispatch(AppAction.ShowStartBrowsingCfrChange(true)) + } + } + /** * Display the widget promo at first data clearing action and if it wasn't added after 5th Focus session * or display branded snackbar when widget promo is not shown. diff --git a/app/src/main/java/org/mozilla/focus/state/AppAction.kt b/app/src/main/java/org/mozilla/focus/state/AppAction.kt index fb73d61e91a..665cc7ac129 100644 --- a/app/src/main/java/org/mozilla/focus/state/AppAction.kt +++ b/app/src/main/java/org/mozilla/focus/state/AppAction.kt @@ -111,4 +111,9 @@ sealed class AppAction : Action { * State of Snackbar for promote search widget has changed */ data class ShowSearchWidgetSnackBar(val value: Boolean) : AppAction() + + /** + * State of start browsing CFR has changed + */ + data class ShowStartBrowsingCfrChange(val value: Boolean) : AppAction() } diff --git a/app/src/main/java/org/mozilla/focus/state/AppReducer.kt b/app/src/main/java/org/mozilla/focus/state/AppReducer.kt index f84e8fc4921..6d6b7578b97 100644 --- a/app/src/main/java/org/mozilla/focus/state/AppReducer.kt +++ b/app/src/main/java/org/mozilla/focus/state/AppReducer.kt @@ -35,6 +35,7 @@ object AppReducer : Reducer { action, ) is AppAction.ShowEraseTabsCfrChange -> showEraseTabsCfrChanged(state, action) + is AppAction.ShowStartBrowsingCfrChange -> showStartBrowsingCfrChanged(state, action) is AppAction.ShowTrackingProtectionCfrChange -> showTrackingProtectionCfrChanged(state, action) is AppAction.OpenSitePermissionOptionsScreen -> openSitePermissionOptionsScreen(state, action) is AppAction.ShowHomeScreen -> showHomeScreen(state) @@ -187,6 +188,13 @@ private fun showEraseTabsCfrChanged(state: AppState, action: AppAction.ShowErase return state.copy(showEraseTabsCfr = action.value) } +/** + * Update whether the start browsing CFR should be shown or not + */ +private fun showStartBrowsingCfrChanged(state: AppState, action: AppAction.ShowStartBrowsingCfrChange): AppState { + return state.copy(showStartBrowsingTabsCfr = action.value) +} + /** * The state of search widget snackBar changed */ diff --git a/app/src/main/java/org/mozilla/focus/state/AppState.kt b/app/src/main/java/org/mozilla/focus/state/AppState.kt index 65b945f6379..e681053dbe6 100644 --- a/app/src/main/java/org/mozilla/focus/state/AppState.kt +++ b/app/src/main/java/org/mozilla/focus/state/AppState.kt @@ -19,6 +19,7 @@ import java.util.UUID * whether they have been updated or not * @property secretSettingsEnabled A flag which reflects the state of debug secret settings * @property showEraseTabsCfr A flag which reflects the state erase tabs CFR + * @property showStartBrowsingTabsCfr A flag which reflects the state of start browsing CFR * @property showSearchWidgetSnackbar A flag which reflects the state of search widget snackbar */ data class AppState( @@ -29,6 +30,7 @@ data class AppState( val showEraseTabsCfr: Boolean = false, val showSearchWidgetSnackbar: Boolean = false, val showTrackingProtectionCfrForTab: Map = emptyMap(), + val showStartBrowsingTabsCfr: Boolean = false, ) : State /** diff --git a/app/src/main/java/org/mozilla/focus/ui/theme/FocusTypography.kt b/app/src/main/java/org/mozilla/focus/ui/theme/FocusTypography.kt index 055d8f4627a..28a515c88bb 100644 --- a/app/src/main/java/org/mozilla/focus/ui/theme/FocusTypography.kt +++ b/app/src/main/java/org/mozilla/focus/ui/theme/FocusTypography.kt @@ -39,6 +39,7 @@ data class FocusTypography( val onboardingFeatureTitle: TextStyle, val onboardingFeatureDescription: TextStyle, val onboardingButton: TextStyle, + val cfrTextStyle: TextStyle, ) { val h1: TextStyle get() = materialTypography.h1 val h2: TextStyle get() = materialTypography.h2 @@ -121,4 +122,9 @@ val focusTypography: FocusTypography fontSize = 14.sp, color = PhotonColors.LightGrey05, ), + cfrTextStyle = TextStyle( + fontSize = 16.sp, + letterSpacing = 0.5.sp, + lineHeight = 24.sp, + ), ) diff --git a/app/src/main/java/org/mozilla/focus/utils/Settings.kt b/app/src/main/java/org/mozilla/focus/utils/Settings.kt index eb4cc551e76..5f903e66024 100644 --- a/app/src/main/java/org/mozilla/focus/utils/Settings.kt +++ b/app/src/main/java/org/mozilla/focus/utils/Settings.kt @@ -184,6 +184,14 @@ class Settings( .apply() } + var shouldShowStartBrowsingCfr: Boolean + get() = preferences.getBoolean(getPreferenceKey(R.string.pref_cfr_visibility_for_start_browsing), true) + set(value) { + preferences.edit() + .putBoolean(getPreferenceKey(R.string.pref_cfr_visibility_for_start_browsing), value) + .apply() + } + var isFirstRun: Boolean get() = preferences.getBoolean(getPreferenceKey(R.string.firstrun_shown), true) set(value) { diff --git a/app/src/main/res/values/preference_keys.xml b/app/src/main/res/values/preference_keys.xml index e93391bc4ef..06a41d757cd 100644 --- a/app/src/main/res/values/preference_keys.xml +++ b/app/src/main/res/values/preference_keys.xml @@ -76,6 +76,8 @@ pref_screen_exceptions pref_cfr_visibility_for_tracking_protection + pref_cfr_visibility_for_start_browsing + pref_tool_tip_privacy_security_settings pref_key_light_theme diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 28bc5b418ec..386bf7c3955 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -964,6 +964,9 @@ !-- This is the title of promote search widget dialog. --> Browsing history cleared! 🎉 + + Start your private browsing session, and we’ll block trackers and other bad stuff as you go. + !-- This is the subtitle of promote search widget dialog. %1$s will get replaced with the name of the app (e.g. "Focus") --> We’ll leave you to your private browsing, but get a quicker start next time with the %1$s widget on your Home screen. diff --git a/app/src/test/java/org/mozilla/focus/browser/integration/InputToolbarIntegrationTest.kt b/app/src/test/java/org/mozilla/focus/browser/integration/InputToolbarIntegrationTest.kt new file mode 100644 index 00000000000..c2ac323e702 --- /dev/null +++ b/app/src/test/java/org/mozilla/focus/browser/integration/InputToolbarIntegrationTest.kt @@ -0,0 +1,82 @@ +/* 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.focus.browser.integration + +import android.view.View +import kotlinx.coroutines.isActive +import mozilla.components.browser.toolbar.BrowserToolbar +import mozilla.components.support.test.ext.joinBlocking +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.whenever +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mozilla.focus.ext.components +import org.mozilla.focus.fragment.UrlInputFragment +import org.mozilla.focus.input.InputToolbarIntegration +import org.mozilla.focus.state.AppAction +import org.mozilla.focus.state.AppStore +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class InputToolbarIntegrationTest { + private lateinit var toolbar: BrowserToolbar + + @Mock + private lateinit var fragment: UrlInputFragment + + @Mock + private lateinit var fragmentView: View + + private lateinit var inputToolbarIntegration: InputToolbarIntegration + + private val appStore: AppStore = testContext.components.appStore + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + toolbar = BrowserToolbar(testContext) + whenever(fragment.resources).thenReturn(testContext.resources) + whenever(fragment.context).thenReturn(testContext) + whenever(fragment.view).thenReturn(fragmentView) + + inputToolbarIntegration = InputToolbarIntegration( + toolbar, + fragment, + mock(), + mock(), + ) + } + + @Test + fun `GIVEN app fresh install WHEN input toolbar integration is starting THEN start browsing scope is populated`() { + appStore.dispatch(AppAction.ShowStartBrowsingCfrChange(true)).joinBlocking() + + assertNull(inputToolbarIntegration.startBrowsingCfrScope) + + inputToolbarIntegration.start() + + assertNotNull(inputToolbarIntegration.startBrowsingCfrScope) + } + + @Test + fun `GIVEN app fresh install WHEN input toolbar integration is stoping THEN start browsing scope is canceled`() { + inputToolbarIntegration.start() + + assertTrue(inputToolbarIntegration.startBrowsingCfrScope?.isActive ?: true) + + inputToolbarIntegration.stop() + + assertFalse(inputToolbarIntegration.startBrowsingCfrScope?.isActive ?: false) + } +} diff --git a/app/src/test/java/org/mozilla/focus/compose/CFRPopupTest.kt b/app/src/test/java/org/mozilla/focus/compose/CFRPopupTest.kt deleted file mode 100644 index 44ec5edd9ad..00000000000 --- a/app/src/test/java/org/mozilla/focus/compose/CFRPopupTest.kt +++ /dev/null @@ -1,109 +0,0 @@ -/* 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.focus.compose - -import android.content.Context -import android.graphics.PixelFormat -import android.view.View -import android.view.ViewManager -import android.view.WindowManager -import androidx.lifecycle.ViewTreeLifecycleOwner -import androidx.savedstate.findViewTreeSavedStateRegistryOwner -import mozilla.components.support.test.argumentCaptor -import mozilla.components.support.test.eq -import mozilla.components.support.test.mock -import mozilla.components.support.test.robolectric.testContext -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mockito.doReturn -import org.mockito.Mockito.spy -import org.mockito.Mockito.verify -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -class CFRPopupTest { - @Test - fun `WHEN the popup is constructed THEN setup lifecycle owners`() { - val container = View(testContext) - - val popupView = spy(CFRPopupFullScreenLayout(container, mock(), "", mock(), mock())) - - assertEquals(ViewTreeLifecycleOwner.get(container), ViewTreeLifecycleOwner.get(popupView)) - assertEquals(container.findViewTreeSavedStateRegistryOwner(), popupView.findViewTreeSavedStateRegistryOwner()) - } - - @Test - fun `WHEN the popup is dismissed THEN cleanup lifecycle owners and detach from window`() { - val context = spy(testContext) - val container = View(context) - val windowManager = spy(context.getSystemService(Context.WINDOW_SERVICE)) as WindowManager - doReturn(windowManager).`when`(context).getSystemService(Context.WINDOW_SERVICE) - val popupView = spy(CFRPopupFullScreenLayout(container, mock(), "", mock(), mock())) - popupView.show() - - popupView.dismiss() - - assertEquals(null, ViewTreeLifecycleOwner.get(popupView)) - assertEquals(null, popupView.findViewTreeSavedStateRegistryOwner()) - verify(popupView).disposeComposition() - verify(windowManager).removeViewImmediate(popupView) - } - - @Test - fun `GIVEN a popup WHEN adding it to window THEN use translucent layout params`() { - val context = spy(testContext) - val container = View(context) - val windowManager = spy(context.getSystemService(Context.WINDOW_SERVICE)) - doReturn(windowManager).`when`(context).getSystemService(Context.WINDOW_SERVICE) - val popupView = CFRPopupFullScreenLayout( - container, - mock(), - "", - mock(), - mock(), - ) - // The equality check in `verify` fails for the layout params. - // Seems like we have to verify `addView`s arguments manually. - val layoutParamsCaptor = argumentCaptor() - - popupView.show() - - verify(windowManager as ViewManager).addView(eq(popupView), layoutParamsCaptor.capture()) - assertEquals(WindowManager.LayoutParams.TYPE_APPLICATION_PANEL, layoutParamsCaptor.value.type) - assertEquals(container.applicationWindowToken, layoutParamsCaptor.value.token) - assertEquals(WindowManager.LayoutParams.MATCH_PARENT, layoutParamsCaptor.value.width) - assertEquals(WindowManager.LayoutParams.MATCH_PARENT, layoutParamsCaptor.value.height) - assertEquals(PixelFormat.TRANSLUCENT, layoutParamsCaptor.value.format) - assertEquals( - WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED, - layoutParamsCaptor.value.flags, - ) - } - - @Test - fun `WHEN creating layout params THEN get fullscreen translucent layout params`() { - val container = View(testContext) - val popupView = CFRPopupFullScreenLayout( - container, - mock(), - "", - mock(), - mock(), - ) - - val result = popupView.createLayoutParams() - - assertEquals(WindowManager.LayoutParams.TYPE_APPLICATION_PANEL, result.type) - assertEquals(container.applicationWindowToken, result.token) - assertEquals(WindowManager.LayoutParams.MATCH_PARENT, result.width) - assertEquals(WindowManager.LayoutParams.MATCH_PARENT, result.height) - assertEquals(PixelFormat.TRANSLUCENT, result.format) - assertEquals( - WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED, - result.flags, - ) - } -}