Bug 1840210 - Rename mozac_ic_shield to mozac_ic_shield_24
[gecko.git] / mobile / android / focus-android / app / src / main / java / org / mozilla / focus / browser / integration / BrowserToolbarIntegration.kt
bloba33b7830a2b1323cb3888b25d0077a87138b3b9c
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(
74         toolbar,
75         store,
76         customTabId,
77     )
79     @VisibleForTesting
80     internal var securityIndicatorScope: CoroutineScope? = null
82     @VisibleForTesting
83     internal var eraseTabsCfrScope: CoroutineScope? = null
85     @VisibleForTesting
86     internal var trackingProtectionCfrScope: CoroutineScope? = null
88     @VisibleForTesting
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(
96             toolbar.context,
97             R.drawable.mozac_ic_delete_24,
98         )!!,
99         contentDescription = toolbar.context.getString(R.string.content_description_erase),
100         iconTintColorResource = R.color.primaryText,
101         listener = {
102             val openedTabs = store.state.tabs.size
103             TabCount.eraseButtonTapped.record(TabCount.EraseButtonTappedExtra(openedTabs))
105             TelemetryWrapper.eraseEvent()
107             eraseActionListener.invoke()
108         },
109     )
110     private val tabsAction = TabCounterToolbarButton(
111         lifecycleOwner = fragment,
112         showTabs = {
113             toolbar.hideKeyboard()
114             tabCounterListener.invoke()
115         },
116         store = store,
117     )
119     @VisibleForTesting
120     internal var toolbarController = ToolbarBehaviorController(toolbar, store, customTabId)
122     init {
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),
131             )
133             addTrackingProtectionIndicator()
135             displayIndicatorSeparator = false
137             setOnSiteSecurityClickedListener {
138                 TrackingProtection.toolbarShieldClicked.add()
139                 fragment.initCookieBanner()
140                 fragment.showTrackingProtectionPanel()
141             }
143             onUrlClicked = {
144                 fragment.edit()
145                 false // Do not switch to edit mode
146             }
148             setOnUrlLongClickListener { onUrlLongClicked() }
150             icons = icons.copy(
151                 trackingProtectionTrackersBlocked = AppCompatResources.getDrawable(
152                     context,
153                     R.drawable.mozac_ic_shield_24,
154                 )!!,
155                 trackingProtectionNothingBlocked = AppCompatResources.getDrawable(
156                     context,
157                     R.drawable.mozac_ic_shield_24,
158                 )!!,
159                 trackingProtectionException = AppCompatResources.getDrawable(
160                     context,
161                     R.drawable.mozac_ic_shield_disabled,
162                 )!!,
163             )
164         }
166         toolbar.display.setOnTrackingProtectionClickedListener {
167             TrackingProtection.toolbarShieldClicked.add()
168             fragment.initCookieBanner()
169             fragment.showTrackingProtectionPanel()
170         }
172         if (customTabId != null) {
173             val menu = CustomTabMenu(
174                 context = fragment.requireContext(),
175                 store = store,
176                 currentTabId = customTabId,
177                 onItemTapped = { controller.handleMenuInteraction(it) },
178             )
179             customTabsFeature = CustomTabsToolbarFeature(
180                 store,
181                 toolbar,
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,
190             )
191         }
193         val isCustomTab = store.state.findCustomTabOrSelectedTab(customTabId)?.isCustomTab()
195         if (context.isTablet() && isCustomTab == false) {
196             navigationButtonsIntegration = NavigationButtonsIntegration(
197                 context,
198                 store,
199                 toolbar,
200                 sessionUseCases,
201                 customTabId,
202             )
203         }
205         if (isCustomTab == false) {
206             toolbar.addNavigationAction(eraseAction)
207             if (!inTesting) {
208                 setUrlBackground()
209             }
210         }
211     }
213     // Use the same background for display/edit modes.
214     private fun setUrlBackground() {
215         val urlBackground = ResourcesCompat.getDrawable(
216             fragment.resources,
217             R.drawable.toolbar_url_background,
218             fragment.context?.theme,
219         )
220         toolbar.display.setUrlBackground(urlBackground)
221     }
223     private fun setBrowserActionButtons() {
224         tabsCounterScope = store.flowScoped { flow ->
225             flow.distinctUntilChangedBy { state -> state.tabs.size > 1 }
226                 .collect { state ->
227                     if (state.tabs.size > 1) {
228                         toolbar.addBrowserAction(tabsAction)
229                     } else {
230                         toolbar.removeBrowserAction(tabsAction)
231                     }
232                 }
233         }
234     }
236     override fun start() {
237         presenter.start()
238         toolbarController.start()
239         customTabsFeature?.start()
240         navigationButtonsIntegration?.start()
241         observerSecurityIndicatorChanges()
242         if (store.state.findCustomTabOrSelectedTab(customTabId)?.isCustomTab() == false) {
243             setBrowserActionButtons()
244             observeEraseCfr()
245         }
247         if (fragment.requireContext().settings.shouldShowCookieBannerCfr &&
248             fragment.requireContext().settings.isCookieBannerEnable &&
249             fragment.requireContext().settings.getCurrentCookieBannerOptionFromSharePref() ==
250             CookieBannerOption.CookieBannerRejectAll()
251         ) {
252             observeCookieBannerCfr()
253         }
255         observeTrackingProtectionCfr()
256     }
258     @VisibleForTesting
259     internal fun observeEraseCfr() {
260         eraseTabsCfrScope = fragment.components?.appStore?.flowScoped { flow ->
261             flow.mapNotNull { state -> state.showEraseTabsCfr }
262                 .distinctUntilChanged()
263                 .collect { showEraseCfr ->
264                     if (showEraseCfr) {
265                         val eraseActionView =
266                             toolbar.findViewById<LinearLayout>(R.id.mozac_browser_toolbar_navigation_actions)
267                                 .children
268                                 .last()
269                         CFRPopup(
270                             anchor = eraseActionView,
271                             properties = CFRPopupProperties(
272                                 popupWidth = 256.dp,
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,
278                                     ),
279                                     ContextCompat.getColor(
280                                         fragment.requireContext(),
281                                         R.color.cfr_pop_up_shape_start_color,
282                                     ),
283                                 ),
284                                 dismissButtonColor = ContextCompat.getColor(
285                                     fragment.requireContext(),
286                                     R.color.cardview_light_background,
287                                 ),
288                                 popupVerticalOffset = 0.dp,
289                             ),
290                             onDismiss = { onDismissEraseTabsCfr() },
291                             text = {
292                                 Text(
293                                     style = focusTypography.cfrTextStyle,
294                                     text = fragment.getString(R.string.cfr_for_toolbar_delete_icon2),
295                                     color = colorResource(R.color.cfr_text_color),
296                                 )
297                             },
298                         ).apply {
299                             show()
300                         }
301                     }
302                 }
303         }
304     }
306     private fun onDismissEraseTabsCfr() {
307         fragment.components?.appStore?.dispatch(AppAction.ShowEraseTabsCfrChange(false))
308     }
310     @VisibleForTesting
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) {
317                         CFRPopup(
318                             anchor = toolbar.findViewById<AppCompatEditText>(R.id.mozac_browser_toolbar_background),
319                             properties = CFRPopupProperties(
320                                 popupWidth = 256.dp,
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,
326                                     ),
327                                     ContextCompat.getColor(
328                                         fragment.requireContext(),
329                                         R.color.cfr_pop_up_shape_start_color,
330                                     ),
331                                 ),
332                                 dismissButtonColor = ContextCompat.getColor(
333                                     fragment.requireContext(),
334                                     R.color.cardview_light_background,
335                                 ),
336                                 popupVerticalOffset = 0.dp,
337                                 indicatorArrowStartOffset = 10.dp,
338                             ),
339                             onDismiss = { onDismissCookieBannerCfr() },
340                             text = {
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),
345                                 )
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,
353                                         ),
354                                     ),
355                                     clickableEndIndex = textCookieBannerCfr.length,
356                                     onClick = {
357                                         fragment.requireComponents.appStore.dispatch(
358                                             AppAction.OpenSettings(Screen.Settings.Page.CookieBanner),
359                                         )
360                                         onDismissCookieBannerCfr()
361                                     },
362                                 )
363                             },
364                         ).apply {
365                             show()
366                             stopObserverCookieBannerCfrChanges()
367                         }
368                     }
369                 }
370         }
371     }
373     @VisibleForTesting
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) {
380                         CFRPopup(
381                             anchor = toolbar.findViewById(
382                                 R.id.mozac_browser_toolbar_tracking_protection_indicator,
383                             ),
384                             properties = CFRPopupProperties(
385                                 popupWidth = 256.dp,
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,
391                                     ),
392                                     ContextCompat.getColor(
393                                         fragment.requireContext(),
394                                         R.color.cfr_pop_up_shape_start_color,
395                                     ),
396                                 ),
397                                 dismissButtonColor = ContextCompat.getColor(
398                                     fragment.requireContext(),
399                                     R.color.cardview_light_background,
400                                 ),
401                                 popupVerticalOffset = 0.dp,
402                             ),
403                             onDismiss = { onDismissTrackingProtectionCfr() },
404                             text = {
405                                 Text(
406                                     style = focusTypography.cfrTextStyle,
407                                     text = fragment.getString(R.string.cfr_for_toolbar_shield_icon2),
408                                     color = colorResource(R.color.cfr_text_color),
409                                 )
410                             },
411                         ).apply {
412                             show()
413                         }
414                     }
415                 }
416         }
417     }
419     private fun onDismissCookieBannerCfr() {
420         fragment.components?.appStore?.dispatch(
421             AppAction.ShowCookieBannerCfrChange(
422                 false,
423             ),
424         )
425         fragment.requireContext().settings.shouldShowCookieBannerCfr = false
426     }
428     private fun onDismissTrackingProtectionCfr() {
429         store.state.selectedTabId?.let {
430             fragment.components?.appStore?.dispatch(
431                 AppAction.ShowTrackingProtectionCfrChange(
432                     mapOf(
433                         it to false,
434                     ),
435                 ),
436             )
437         }
438         fragment.requireContext().settings.shouldShowCfrForTrackingProtection = false
439         FocusNimbus.features.onboarding.recordExposure()
440         fragment.components?.appStore?.dispatch(AppAction.ShowEraseTabsCfrChange(true))
441     }
443     @VisibleForTesting
444     internal fun observerSecurityIndicatorChanges() {
445         securityIndicatorScope = store.flowScoped { flow ->
446             flow.mapNotNull { state -> state.findCustomTabOrSelectedTab(customTabId) }
447                 .distinctUntilChangedBy { tab -> tab.content.securityInfo }
448                 .collect {
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:")
455                     ) {
456                         addSecurityIndicator()
457                     }
458                 }
459         }
460     }
462     override fun stop() {
463         presenter.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()
473     }
475     @VisibleForTesting
476     internal fun stopObserverTrackingProtectionCfrChanges() {
477         trackingProtectionCfrScope?.cancel()
478     }
480     @VisibleForTesting
481     internal fun stopObserverEraseTabsCfrChanges() {
482         eraseTabsCfrScope?.cancel()
483     }
485     @VisibleForTesting
486     internal fun stopObserverSecurityIndicatorChanges() {
487         securityIndicatorScope?.cancel()
488     }
490     @VisibleForTesting
491     internal fun stopObserverCookieBannerCfrChanges() {
492         cookieBannerCfrScope?.cancel()
493     }
495     @VisibleForTesting
496     internal fun addSecurityIndicator() {
497         toolbar.display.indicators = listOf(Indicators.SECURITY)
498     }
500     @VisibleForTesting
501     internal fun addTrackingProtectionIndicator() {
502         toolbar.display.indicators = listOf(Indicators.TRACKING_PROTECTION)
503     }