1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 package org.mozilla.focus.browser.integration
7 import android.graphics.Color
8 import android.widget.LinearLayout
9 import androidx.annotation.VisibleForTesting
10 import androidx.appcompat.content.res.AppCompatResources
11 import androidx.appcompat.widget.AppCompatEditText
12 import androidx.compose.material.Text
13 import androidx.compose.ui.platform.LocalContext
14 import androidx.compose.ui.res.colorResource
15 import androidx.compose.ui.res.stringResource
16 import androidx.compose.ui.text.style.TextDecoration
17 import androidx.compose.ui.unit.dp
18 import androidx.core.content.ContextCompat
19 import androidx.core.content.res.ResourcesCompat
20 import androidx.core.view.children
21 import kotlinx.coroutines.CoroutineScope
22 import kotlinx.coroutines.cancel
23 import kotlinx.coroutines.flow.distinctUntilChanged
24 import kotlinx.coroutines.flow.distinctUntilChangedBy
25 import kotlinx.coroutines.flow.mapNotNull
26 import mozilla.components.browser.state.selector.findCustomTabOrSelectedTab
27 import mozilla.components.browser.state.store.BrowserStore
28 import mozilla.components.browser.toolbar.BrowserToolbar
29 import mozilla.components.browser.toolbar.display.DisplayToolbar.Indicators
30 import mozilla.components.compose.cfr.CFRPopup
31 import mozilla.components.compose.cfr.CFRPopupProperties
32 import mozilla.components.feature.customtabs.CustomTabsToolbarFeature
33 import mozilla.components.feature.session.SessionUseCases
34 import mozilla.components.feature.tabs.CustomTabsUseCases
35 import mozilla.components.feature.tabs.toolbar.TabCounterToolbarButton
36 import mozilla.components.feature.toolbar.ToolbarBehaviorController
37 import mozilla.components.feature.toolbar.ToolbarPresenter
38 import mozilla.components.lib.state.ext.flowScoped
39 import mozilla.components.support.base.feature.LifecycleAwareFeature
40 import mozilla.components.support.ktx.android.view.hideKeyboard
41 import org.mozilla.focus.GleanMetrics.TabCount
42 import org.mozilla.focus.GleanMetrics.TrackingProtection
43 import org.mozilla.focus.R
44 import org.mozilla.focus.cookiebanner.CookieBannerOption
45 import org.mozilla.focus.ext.components
46 import org.mozilla.focus.ext.isCustomTab
47 import org.mozilla.focus.ext.isTablet
48 import org.mozilla.focus.ext.requireComponents
49 import org.mozilla.focus.ext.settings
50 import org.mozilla.focus.fragment.BrowserFragment
51 import org.mozilla.focus.menu.browser.CustomTabMenu
52 import org.mozilla.focus.nimbus.FocusNimbus
53 import org.mozilla.focus.state.AppAction
54 import org.mozilla.focus.state.Screen
55 import org.mozilla.focus.telemetry.TelemetryWrapper
56 import org.mozilla.focus.ui.theme.focusTypography
57 import org.mozilla.focus.utils.ClickableSubstringLink
59 @Suppress("LongParameterList", "LargeClass", "TooManyFunctions")
60 class BrowserToolbarIntegration(
61 private val store: BrowserStore,
62 private val toolbar: BrowserToolbar,
63 private val fragment: BrowserFragment,
64 controller: BrowserMenuController,
65 sessionUseCases: SessionUseCases,
66 customTabsUseCases: CustomTabsUseCases,
67 private val onUrlLongClicked: () -> Boolean,
68 private val eraseActionListener: () -> Unit,
69 private val tabCounterListener: () -> Unit,
70 private val customTabId: String? = null,
71 inTesting: Boolean = false,
72 ) : LifecycleAwareFeature {
73 private val presenter = ToolbarPresenter(
80 internal var securityIndicatorScope: CoroutineScope? = null
83 internal var eraseTabsCfrScope: CoroutineScope? = null
86 internal var trackingProtectionCfrScope: CoroutineScope? = null
89 internal var cookieBannerCfrScope: CoroutineScope? = null
91 private var tabsCounterScope: CoroutineScope? = null
92 private var customTabsFeature: CustomTabsToolbarFeature? = null
93 private var navigationButtonsIntegration: NavigationButtonsIntegration? = null
94 private val eraseAction = BrowserToolbar.Button(
95 imageDrawable = AppCompatResources.getDrawable(
97 R.drawable.mozac_ic_delete_24,
99 contentDescription = toolbar.context.getString(R.string.content_description_erase),
100 iconTintColorResource = R.color.primaryText,
102 val openedTabs = store.state.tabs.size
103 TabCount.eraseButtonTapped.record(TabCount.EraseButtonTappedExtra(openedTabs))
105 TelemetryWrapper.eraseEvent()
107 eraseActionListener.invoke()
110 private val tabsAction = TabCounterToolbarButton(
111 lifecycleOwner = fragment,
113 toolbar.hideKeyboard()
114 tabCounterListener.invoke()
120 internal var toolbarController = ToolbarBehaviorController(toolbar, store, customTabId)
123 val context = toolbar.context
125 toolbar.display.apply {
126 colors = colors.copy(
127 hint = ContextCompat.getColor(toolbar.context, R.color.urlBarHintText),
128 securityIconInsecure = Color.TRANSPARENT,
129 text = ContextCompat.getColor(toolbar.context, R.color.primaryText),
130 menu = ContextCompat.getColor(toolbar.context, R.color.primaryText),
133 addTrackingProtectionIndicator()
135 displayIndicatorSeparator = false
137 setOnSiteSecurityClickedListener {
138 TrackingProtection.toolbarShieldClicked.add()
139 fragment.initCookieBanner()
140 fragment.showTrackingProtectionPanel()
145 false // Do not switch to edit mode
148 setOnUrlLongClickListener { onUrlLongClicked() }
151 trackingProtectionTrackersBlocked = AppCompatResources.getDrawable(
153 R.drawable.mozac_ic_shield_24,
155 trackingProtectionNothingBlocked = AppCompatResources.getDrawable(
157 R.drawable.mozac_ic_shield_24,
159 trackingProtectionException = AppCompatResources.getDrawable(
161 R.drawable.mozac_ic_shield_disabled,
166 toolbar.display.setOnTrackingProtectionClickedListener {
167 TrackingProtection.toolbarShieldClicked.add()
168 fragment.initCookieBanner()
169 fragment.showTrackingProtectionPanel()
172 if (customTabId != null) {
173 val menu = CustomTabMenu(
174 context = fragment.requireContext(),
176 currentTabId = customTabId,
177 onItemTapped = { controller.handleMenuInteraction(it) },
179 customTabsFeature = CustomTabsToolbarFeature(
182 sessionId = customTabId,
183 useCases = customTabsUseCases,
184 menuBuilder = menu.menuBuilder,
185 window = fragment.activity?.window,
186 menuItemIndex = menu.menuBuilder.items.size - 1,
187 closeListener = { fragment.closeCustomTab() },
188 updateToolbarBackground = true,
189 forceActionButtonTinting = false,
193 val isCustomTab = store.state.findCustomTabOrSelectedTab(customTabId)?.isCustomTab()
195 if (context.isTablet() && isCustomTab == false) {
196 navigationButtonsIntegration = NavigationButtonsIntegration(
205 if (isCustomTab == false) {
206 toolbar.addNavigationAction(eraseAction)
213 // Use the same background for display/edit modes.
214 private fun setUrlBackground() {
215 val urlBackground = ResourcesCompat.getDrawable(
217 R.drawable.toolbar_url_background,
218 fragment.context?.theme,
220 toolbar.display.setUrlBackground(urlBackground)
223 private fun setBrowserActionButtons() {
224 tabsCounterScope = store.flowScoped { flow ->
225 flow.distinctUntilChangedBy { state -> state.tabs.size > 1 }
227 if (state.tabs.size > 1) {
228 toolbar.addBrowserAction(tabsAction)
230 toolbar.removeBrowserAction(tabsAction)
236 override fun start() {
238 toolbarController.start()
239 customTabsFeature?.start()
240 navigationButtonsIntegration?.start()
241 observerSecurityIndicatorChanges()
242 if (store.state.findCustomTabOrSelectedTab(customTabId)?.isCustomTab() == false) {
243 setBrowserActionButtons()
247 if (fragment.requireContext().settings.shouldShowCookieBannerCfr &&
248 fragment.requireContext().settings.isCookieBannerEnable &&
249 fragment.requireContext().settings.getCurrentCookieBannerOptionFromSharePref() ==
250 CookieBannerOption.CookieBannerRejectAll()
252 observeCookieBannerCfr()
255 observeTrackingProtectionCfr()
259 internal fun observeEraseCfr() {
260 eraseTabsCfrScope = fragment.components?.appStore?.flowScoped { flow ->
261 flow.mapNotNull { state -> state.showEraseTabsCfr }
262 .distinctUntilChanged()
263 .collect { showEraseCfr ->
265 val eraseActionView =
266 toolbar.findViewById<LinearLayout>(R.id.mozac_browser_toolbar_navigation_actions)
270 anchor = eraseActionView,
271 properties = CFRPopupProperties(
273 popupAlignment = CFRPopup.PopupAlignment.INDICATOR_CENTERED_IN_ANCHOR,
274 popupBodyColors = listOf(
275 ContextCompat.getColor(
276 fragment.requireContext(),
277 R.color.cfr_pop_up_shape_end_color,
279 ContextCompat.getColor(
280 fragment.requireContext(),
281 R.color.cfr_pop_up_shape_start_color,
284 dismissButtonColor = ContextCompat.getColor(
285 fragment.requireContext(),
286 R.color.cardview_light_background,
288 popupVerticalOffset = 0.dp,
290 onDismiss = { onDismissEraseTabsCfr() },
293 style = focusTypography.cfrTextStyle,
294 text = fragment.getString(R.string.cfr_for_toolbar_delete_icon2),
295 color = colorResource(R.color.cfr_text_color),
306 private fun onDismissEraseTabsCfr() {
307 fragment.components?.appStore?.dispatch(AppAction.ShowEraseTabsCfrChange(false))
311 internal fun observeCookieBannerCfr() {
312 cookieBannerCfrScope = fragment.components?.appStore?.flowScoped { flow ->
313 flow.mapNotNull { state -> state.showCookieBannerCfr }
314 .distinctUntilChanged()
315 .collect { showCookieBannerCfr ->
316 if (showCookieBannerCfr) {
318 anchor = toolbar.findViewById<AppCompatEditText>(R.id.mozac_browser_toolbar_background),
319 properties = CFRPopupProperties(
321 popupAlignment = CFRPopup.PopupAlignment.BODY_TO_ANCHOR_START,
322 popupBodyColors = listOf(
323 ContextCompat.getColor(
324 fragment.requireContext(),
325 R.color.cfr_pop_up_shape_end_color,
327 ContextCompat.getColor(
328 fragment.requireContext(),
329 R.color.cfr_pop_up_shape_start_color,
332 dismissButtonColor = ContextCompat.getColor(
333 fragment.requireContext(),
334 R.color.cardview_light_background,
336 popupVerticalOffset = 0.dp,
337 indicatorArrowStartOffset = 10.dp,
339 onDismiss = { onDismissCookieBannerCfr() },
341 val textCookieBannerCfr = stringResource(
342 id = R.string.cfr_cookie_banner,
343 LocalContext.current.getString(R.string.onboarding_short_app_name),
344 LocalContext.current.getString(R.string.cfr_cookie_banner_link),
346 ClickableSubstringLink(
347 text = textCookieBannerCfr,
348 style = focusTypography.cfrCookieBannerTextStyle,
349 linkTextDecoration = TextDecoration.Underline,
350 clickableStartIndex = textCookieBannerCfr.indexOf(
351 LocalContext.current.getString(
352 R.string.cfr_cookie_banner_link,
355 clickableEndIndex = textCookieBannerCfr.length,
357 fragment.requireComponents.appStore.dispatch(
358 AppAction.OpenSettings(Screen.Settings.Page.CookieBanner),
360 onDismissCookieBannerCfr()
366 stopObserverCookieBannerCfrChanges()
374 internal fun observeTrackingProtectionCfr() {
375 trackingProtectionCfrScope = fragment.components?.appStore?.flowScoped { flow ->
376 flow.mapNotNull { state -> state.showTrackingProtectionCfrForTab }
377 .distinctUntilChanged()
378 .collect { showTrackingProtectionCfrForTab ->
379 if (showTrackingProtectionCfrForTab[store.state.selectedTabId] == true) {
381 anchor = toolbar.findViewById(
382 R.id.mozac_browser_toolbar_tracking_protection_indicator,
384 properties = CFRPopupProperties(
386 popupAlignment = CFRPopup.PopupAlignment.INDICATOR_CENTERED_IN_ANCHOR,
387 popupBodyColors = listOf(
388 ContextCompat.getColor(
389 fragment.requireContext(),
390 R.color.cfr_pop_up_shape_end_color,
392 ContextCompat.getColor(
393 fragment.requireContext(),
394 R.color.cfr_pop_up_shape_start_color,
397 dismissButtonColor = ContextCompat.getColor(
398 fragment.requireContext(),
399 R.color.cardview_light_background,
401 popupVerticalOffset = 0.dp,
403 onDismiss = { onDismissTrackingProtectionCfr() },
406 style = focusTypography.cfrTextStyle,
407 text = fragment.getString(R.string.cfr_for_toolbar_shield_icon2),
408 color = colorResource(R.color.cfr_text_color),
419 private fun onDismissCookieBannerCfr() {
420 fragment.components?.appStore?.dispatch(
421 AppAction.ShowCookieBannerCfrChange(
425 fragment.requireContext().settings.shouldShowCookieBannerCfr = false
428 private fun onDismissTrackingProtectionCfr() {
429 store.state.selectedTabId?.let {
430 fragment.components?.appStore?.dispatch(
431 AppAction.ShowTrackingProtectionCfrChange(
438 fragment.requireContext().settings.shouldShowCfrForTrackingProtection = false
439 FocusNimbus.features.onboarding.recordExposure()
440 fragment.components?.appStore?.dispatch(AppAction.ShowEraseTabsCfrChange(true))
444 internal fun observerSecurityIndicatorChanges() {
445 securityIndicatorScope = store.flowScoped { flow ->
446 flow.mapNotNull { state -> state.findCustomTabOrSelectedTab(customTabId) }
447 .distinctUntilChangedBy { tab -> tab.content.securityInfo }
449 val secure = it.content.securityInfo.secure
450 val url = it.content.url
451 if (secure && Indicators.SECURITY in toolbar.display.indicators) {
452 addTrackingProtectionIndicator()
453 } else if (!secure && Indicators.SECURITY !in toolbar.display.indicators &&
454 !url.trim().startsWith("about:")
456 addSecurityIndicator()
462 override fun stop() {
464 toolbarController.stop()
465 customTabsFeature?.stop()
466 navigationButtonsIntegration?.stop()
467 stopObserverSecurityIndicatorChanges()
468 toolbar.removeBrowserAction(tabsAction)
469 tabsCounterScope?.cancel()
470 stopObserverEraseTabsCfrChanges()
471 stopObserverTrackingProtectionCfrChanges()
472 stopObserverCookieBannerCfrChanges()
476 internal fun stopObserverTrackingProtectionCfrChanges() {
477 trackingProtectionCfrScope?.cancel()
481 internal fun stopObserverEraseTabsCfrChanges() {
482 eraseTabsCfrScope?.cancel()
486 internal fun stopObserverSecurityIndicatorChanges() {
487 securityIndicatorScope?.cancel()
491 internal fun stopObserverCookieBannerCfrChanges() {
492 cookieBannerCfrScope?.cancel()
496 internal fun addSecurityIndicator() {
497 toolbar.display.indicators = listOf(Indicators.SECURITY)
501 internal fun addTrackingProtectionIndicator() {
502 toolbar.display.indicators = listOf(Indicators.TRACKING_PROTECTION)