Merge remote-tracking branch 'upstream/master' into upstream-sync

pull/128/head
Adam Novak 4 years ago
commit bde8b72516

@ -3195,6 +3195,66 @@ top_sites:
notification_emails: notification_emails:
- fenix-core@mozilla.com - fenix-core@mozilla.com
expires: "2020-11-15" expires: "2020-11-15"
open_frecency:
type: event
description: |
A user opened a frecency top site
bugs:
- https://github.com/mozilla-mobile/fenix/issues/14565
data_reviews:
- https://github.com/mozilla-mobile/fenix/pull/15136
data_sensitivity:
- interaction
notification_emails:
- fenix-core@mozilla.com
expires: "2021-03-15"
open_pinned:
type: event
description: |
A user opened a pinned top site
bugs:
- https://github.com/mozilla-mobile/fenix/issues/14565
data_reviews:
- https://github.com/mozilla-mobile/fenix/pull/15136
data_sensitivity:
- interaction
notification_emails:
- fenix-core@mozilla.com
expires: "2021-03-15"
swipe_carousel:
type: event
description: |
A user swiped to change the page of the top sites carousel
extra_keys:
page:
description: |
The page number the carousel is now on
bugs:
- https://github.com/mozilla-mobile/fenix/issues/14565
data_reviews:
- https://github.com/mozilla-mobile/fenix/pull/15136
data_sensitivity:
- interaction
notification_emails:
- fenix-core@mozilla.com
expires: "2021-03-15"
long_press:
type: event
description: |
A user long pressed on a top site
extra_keys:
type:
description: |
The type of top site. Options are: "FRECENCY," "DEFAULT," or "PINNED."
bugs:
- https://github.com/mozilla-mobile/fenix/issues/14565
data_reviews:
- https://github.com/mozilla-mobile/fenix/pull/15136
data_sensitivity:
- interaction
notification_emails:
- fenix-core@mozilla.com
expires: "2021-03-15"
open_in_new_tab: open_in_new_tab:
type: event type: event
description: | description: |

@ -6,7 +6,6 @@ package org.mozilla.fenix.addons
import android.net.Uri import android.net.Uri
import android.view.View import android.view.View
import androidx.annotation.StringRes
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.android.extensions.LayoutContainer import kotlinx.android.extensions.LayoutContainer
@ -40,10 +39,7 @@ class AddonPermissionsDetailsView(
private fun bindPermissions(addon: Addon) { private fun bindPermissions(addon: Addon) {
add_ons_permissions.apply { add_ons_permissions.apply {
layoutManager = LinearLayoutManager(context) layoutManager = LinearLayoutManager(context)
val sortedPermissions = addon.translatePermissions().map { val sortedPermissions = addon.translatePermissions(context).sorted()
@StringRes val stringId = it
context.getString(stringId)
}.sorted()
adapter = AddonPermissionsAdapter( adapter = AddonPermissionsAdapter(
sortedPermissions, sortedPermissions,
style = AddonPermissionsAdapter.Style( style = AddonPermissionsAdapter.Style(

@ -7,6 +7,7 @@ package org.mozilla.fenix.components.metrics
import android.content.Context import android.content.Context
import mozilla.components.browser.errorpages.ErrorType import mozilla.components.browser.errorpages.ErrorType
import mozilla.components.browser.search.SearchEngine import mozilla.components.browser.search.SearchEngine
import mozilla.components.feature.top.sites.TopSite
import org.mozilla.fenix.GleanMetrics.Addons import org.mozilla.fenix.GleanMetrics.Addons
import org.mozilla.fenix.GleanMetrics.AppTheme import org.mozilla.fenix.GleanMetrics.AppTheme
import org.mozilla.fenix.GleanMetrics.Autoplay import org.mozilla.fenix.GleanMetrics.Autoplay
@ -21,6 +22,7 @@ import org.mozilla.fenix.GleanMetrics.ProgressiveWebApp
import org.mozilla.fenix.GleanMetrics.SearchShortcuts import org.mozilla.fenix.GleanMetrics.SearchShortcuts
import org.mozilla.fenix.GleanMetrics.Tip import org.mozilla.fenix.GleanMetrics.Tip
import org.mozilla.fenix.GleanMetrics.ToolbarSettings import org.mozilla.fenix.GleanMetrics.ToolbarSettings
import org.mozilla.fenix.GleanMetrics.TopSites
import org.mozilla.fenix.GleanMetrics.TrackingProtection import org.mozilla.fenix.GleanMetrics.TrackingProtection
import org.mozilla.fenix.R import org.mozilla.fenix.R
import java.util.Locale import java.util.Locale
@ -121,6 +123,8 @@ sealed class Event {
object NotificationMediaPlay : Event() object NotificationMediaPlay : Event()
object NotificationMediaPause : Event() object NotificationMediaPause : Event()
object TopSiteOpenDefault : Event() object TopSiteOpenDefault : Event()
object TopSiteOpenFrecent : Event()
object TopSiteOpenPinned : Event()
object TopSiteOpenInNewTab : Event() object TopSiteOpenInNewTab : Event()
object TopSiteOpenInPrivateTab : Event() object TopSiteOpenInPrivateTab : Event()
object TopSiteRemoved : Event() object TopSiteRemoved : Event()
@ -191,6 +195,16 @@ sealed class Event {
// Interaction events with extras // Interaction events with extras
data class TopSiteSwipeCarousel(val page: Int) : Event() {
override val extras: Map<TopSites.swipeCarouselKeys, String>?
get() = hashMapOf(TopSites.swipeCarouselKeys.page to page.toString())
}
data class TopSiteLongPress(val type: TopSite.Type) : Event() {
override val extras: Map<TopSites.longPressKeys, String>?
get() = hashMapOf(TopSites.longPressKeys.type to type.name)
}
data class ProgressiveWebAppForeground(val timeForegrounded: Long) : Event() { data class ProgressiveWebAppForeground(val timeForegrounded: Long) : Event() {
override val extras: Map<ProgressiveWebApp.foregroundKeys, String>? override val extras: Map<ProgressiveWebApp.foregroundKeys, String>?
get() = mapOf(ProgressiveWebApp.foregroundKeys.timeMs to timeForegrounded.toString()) get() = mapOf(ProgressiveWebApp.foregroundKeys.timeMs to timeForegrounded.toString())

@ -517,6 +517,12 @@ private val Event.wrapper: EventWrapper<*>?
is Event.TopSiteOpenDefault -> EventWrapper<NoExtraKeys>( is Event.TopSiteOpenDefault -> EventWrapper<NoExtraKeys>(
{ TopSites.openDefault.record(it) } { TopSites.openDefault.record(it) }
) )
is Event.TopSiteOpenFrecent -> EventWrapper<NoExtraKeys>(
{ TopSites.openFrecency.record(it) }
)
is Event.TopSiteOpenPinned -> EventWrapper<NoExtraKeys>(
{ TopSites.openPinned.record(it) }
)
is Event.TopSiteOpenInNewTab -> EventWrapper<NoExtraKeys>( is Event.TopSiteOpenInNewTab -> EventWrapper<NoExtraKeys>(
{ TopSites.openInNewTab.record(it) } { TopSites.openInNewTab.record(it) }
) )
@ -526,6 +532,14 @@ private val Event.wrapper: EventWrapper<*>?
is Event.TopSiteRemoved -> EventWrapper<NoExtraKeys>( is Event.TopSiteRemoved -> EventWrapper<NoExtraKeys>(
{ TopSites.remove.record(it) } { TopSites.remove.record(it) }
) )
is Event.TopSiteLongPress -> EventWrapper(
{ TopSites.longPress.record(it) },
{ TopSites.longPressKeys.valueOf(it) }
)
is Event.TopSiteSwipeCarousel -> EventWrapper(
{ TopSites.swipeCarousel.record(it) },
{ TopSites.swipeCarouselKeys.valueOf(it) }
)
is Event.SupportTapped -> EventWrapper<NoExtraKeys>( is Event.SupportTapped -> EventWrapper<NoExtraKeys>(
{ AboutPage.supportTapped.record(it) } { AboutPage.supportTapped.record(it) }
) )

@ -23,6 +23,7 @@ import kotlinx.android.synthetic.main.component_browser_top_toolbar.*
import kotlinx.android.synthetic.main.component_browser_top_toolbar.view.* import kotlinx.android.synthetic.main.component_browser_top_toolbar.view.*
import mozilla.components.browser.domains.autocomplete.ShippedDomainsProvider import mozilla.components.browser.domains.autocomplete.ShippedDomainsProvider
import mozilla.components.browser.session.Session import mozilla.components.browser.session.Session
import mozilla.components.browser.state.state.ExternalAppType
import mozilla.components.browser.toolbar.BrowserToolbar import mozilla.components.browser.toolbar.BrowserToolbar
import mozilla.components.browser.toolbar.behavior.BrowserToolbarBottomBehavior import mozilla.components.browser.toolbar.behavior.BrowserToolbarBottomBehavior
import mozilla.components.browser.toolbar.display.DisplayToolbar import mozilla.components.browser.toolbar.display.DisplayToolbar
@ -77,6 +78,10 @@ class BrowserToolbarView(
val toolbarIntegration: ToolbarIntegration val toolbarIntegration: ToolbarIntegration
private val isPwaTabOrTwaTab: Boolean
get() = customTabSession?.customTabConfig?.externalAppType == ExternalAppType.PROGRESSIVE_WEB_APP ||
customTabSession?.customTabConfig?.externalAppType == ExternalAppType.TRUSTED_WEB_ACTIVITY
init { init {
val isCustomTabSession = customTabSession != null val isCustomTabSession = customTabSession != null
@ -213,9 +218,13 @@ class BrowserToolbarView(
} }
fun expand() { fun expand() {
// expand only for normal tabs and custom tabs not for PWA or TWA
if (isPwaTabOrTwaTab) {
return
}
when (settings.toolbarPosition) { when (settings.toolbarPosition) {
ToolbarPosition.BOTTOM -> { ToolbarPosition.BOTTOM -> {
(view.layoutParams as CoordinatorLayout.LayoutParams).apply { (view.layoutParams as? CoordinatorLayout.LayoutParams)?.apply {
// behavior can be null if the "Scroll to hide toolbar" setting is toggled off. // behavior can be null if the "Scroll to hide toolbar" setting is toggled off.
(behavior as? BrowserToolbarBottomBehavior)?.forceExpand(view) (behavior as? BrowserToolbarBottomBehavior)?.forceExpand(view)
} }
@ -234,7 +243,7 @@ class BrowserToolbarView(
fun setScrollFlags(shouldDisableScroll: Boolean = false) { fun setScrollFlags(shouldDisableScroll: Boolean = false) {
when (settings.toolbarPosition) { when (settings.toolbarPosition) {
ToolbarPosition.BOTTOM -> { ToolbarPosition.BOTTOM -> {
if (settings.isDynamicToolbarEnabled) { if (settings.isDynamicToolbarEnabled && !isPwaTabOrTwaTab) {
(view.layoutParams as? CoordinatorLayout.LayoutParams)?.apply { (view.layoutParams as? CoordinatorLayout.LayoutParams)?.apply {
behavior = BrowserToolbarBottomBehavior(view.context, null) behavior = BrowserToolbarBottomBehavior(view.context, null)
} }

@ -89,7 +89,7 @@ class CustomTabsIntegration(
menuItemIndex = START_OF_MENU_ITEMS_INDEX, menuItemIndex = START_OF_MENU_ITEMS_INDEX,
window = activity.window, window = activity.window,
shareListener = { onItemTapped.invoke(ToolbarMenu.Item.Share) }, shareListener = { onItemTapped.invoke(ToolbarMenu.Item.Share) },
closeListener = { activity.finish() } closeListener = { activity.finishAndRemoveTask() }
) )
override fun start() = feature.start() override fun start() = feature.start()

@ -44,7 +44,7 @@ open class ExternalAppBrowserActivity : HomeActivity() {
customTabSessionId: String? customTabSessionId: String?
): NavDirections? { ): NavDirections? {
if (customTabSessionId == null) { if (customTabSessionId == null) {
finish() finishAndRemoveTask()
return null return null
} }

@ -98,7 +98,7 @@ interface SessionControlController {
/** /**
* @see [TopSiteInteractor.onSelectTopSite] * @see [TopSiteInteractor.onSelectTopSite]
*/ */
fun handleSelectTopSite(url: String, isDefault: Boolean) fun handleSelectTopSite(url: String, type: TopSite.Type)
/** /**
* @see [OnboardingInteractor.onStartBrowsingClicked] * @see [OnboardingInteractor.onStartBrowsingClicked]
@ -302,11 +302,14 @@ class DefaultSessionControlController(
metrics.track(Event.CollectionRenamePressed) metrics.track(Event.CollectionRenamePressed)
} }
override fun handleSelectTopSite(url: String, isDefault: Boolean) { override fun handleSelectTopSite(url: String, type: TopSite.Type) {
metrics.track(Event.TopSiteOpenInNewTab) metrics.track(Event.TopSiteOpenInNewTab)
if (isDefault) { when (type) {
metrics.track(Event.TopSiteOpenDefault) TopSite.Type.DEFAULT -> metrics.track(Event.TopSiteOpenDefault)
TopSite.Type.FRECENT -> metrics.track(Event.TopSiteOpenFrecent)
TopSite.Type.PINNED -> metrics.track(Event.TopSiteOpenPinned)
} }
if (url == SupportUtils.POCKET_TRENDING_URL) { if (url == SupportUtils.POCKET_TRENDING_URL) {
metrics.track(Event.PocketTopSiteClicked) metrics.track(Event.PocketTopSiteClicked)
} }

@ -167,9 +167,9 @@ interface TopSiteInteractor {
* Selects the given top site. Called when a user clicks on a top site. * Selects the given top site. Called when a user clicks on a top site.
* *
* @param url The URL of the top site. * @param url The URL of the top site.
* @param isDefault Whether or not the top site is a default one. * @param type The type of the top site.
*/ */
fun onSelectTopSite(url: String, isDefault: Boolean) fun onSelectTopSite(url: String, type: TopSite.Type)
} }
/** /**
@ -218,8 +218,8 @@ class SessionControlInteractor(
controller.handleRenameCollectionTapped(collection) controller.handleRenameCollectionTapped(collection)
} }
override fun onSelectTopSite(url: String, isDefault: Boolean) { override fun onSelectTopSite(url: String, type: TopSite.Type) {
controller.handleSelectTopSite(url, isDefault) controller.handleSelectTopSite(url, type)
} }
override fun onStartBrowsingClicked() { override fun onStartBrowsingClicked() {

@ -11,6 +11,8 @@ import androidx.viewpager2.widget.ViewPager2
import kotlinx.android.synthetic.main.component_top_sites_pager.view.* import kotlinx.android.synthetic.main.component_top_sites_pager.view.*
import mozilla.components.feature.top.sites.TopSite import mozilla.components.feature.top.sites.TopSite
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.home.sessioncontrol.TopSiteInteractor import org.mozilla.fenix.home.sessioncontrol.TopSiteInteractor
import org.mozilla.fenix.home.sessioncontrol.viewholders.topsites.TopSitesPagerAdapter import org.mozilla.fenix.home.sessioncontrol.viewholders.topsites.TopSitesPagerAdapter
@ -21,10 +23,16 @@ class TopSitePagerViewHolder(
private val topSitesPagerAdapter = TopSitesPagerAdapter(interactor) private val topSitesPagerAdapter = TopSitesPagerAdapter(interactor)
private val pageIndicator = view.page_indicator private val pageIndicator = view.page_indicator
private var currentPage = 0
private val topSitesPageChangeCallback = object : ViewPager2.OnPageChangeCallback() { private val topSitesPageChangeCallback = object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) { override fun onPageSelected(position: Int) {
if (currentPage != position) {
pageIndicator.context.components.analytics.metrics.track(Event.TopSiteSwipeCarousel(position))
}
pageIndicator.setSelection(position) pageIndicator.setSelection(position)
currentPage = position
} }
} }

@ -14,10 +14,10 @@ import kotlinx.android.synthetic.main.top_site_item.*
import mozilla.components.browser.menu.BrowserMenuBuilder import mozilla.components.browser.menu.BrowserMenuBuilder
import mozilla.components.browser.menu.item.SimpleBrowserMenuItem import mozilla.components.browser.menu.item.SimpleBrowserMenuItem
import mozilla.components.feature.top.sites.TopSite import mozilla.components.feature.top.sites.TopSite
import mozilla.components.feature.top.sites.TopSite.Type.DEFAULT
import mozilla.components.feature.top.sites.TopSite.Type.FRECENT import mozilla.components.feature.top.sites.TopSite.Type.FRECENT
import mozilla.components.feature.top.sites.TopSite.Type.PINNED import mozilla.components.feature.top.sites.TopSite.Type.PINNED
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.loadIntoView import org.mozilla.fenix.ext.loadIntoView
import org.mozilla.fenix.home.sessioncontrol.TopSiteInteractor import org.mozilla.fenix.home.sessioncontrol.TopSiteInteractor
@ -32,10 +32,12 @@ class TopSiteItemViewHolder(
init { init {
top_site_item.setOnClickListener { top_site_item.setOnClickListener {
interactor.onSelectTopSite(topSite.url, topSite.type === DEFAULT) interactor.onSelectTopSite(topSite.url, topSite.type)
} }
top_site_item.setOnLongClickListener { top_site_item.setOnLongClickListener {
it.context.components.analytics.metrics.track(Event.TopSiteLongPress(topSite.type))
val topSiteMenu = TopSiteItemMenu(view.context, topSite.type != FRECENT) { item -> val topSiteMenu = TopSiteItemMenu(view.context, topSite.type != FRECENT) { item ->
when (item) { when (item) {
is TopSiteItemMenu.Item.OpenInPrivateTab -> interactor.onOpenInPrivateTabClicked( is TopSiteItemMenu.Item.OpenInPrivateTab -> interactor.onOpenInPrivateTabClicked(

@ -63,8 +63,6 @@ class LibrarySiteItemView @JvmOverloads constructor(
val overflowView: ImageButton get() = overflow_menu val overflowView: ImageButton get() = overflow_menu
private var iconUrl: String? = null
init { init {
LayoutInflater.from(context).inflate(R.layout.library_site_item, this, true) LayoutInflater.from(context).inflate(R.layout.library_site_item, this, true)
@ -75,13 +73,7 @@ class LibrarySiteItemView @JvmOverloads constructor(
* Change visibility of parts of this view based on what type of item is being represented. * Change visibility of parts of this view based on what type of item is being represented.
*/ */
fun displayAs(mode: ItemType) { fun displayAs(mode: ItemType) {
favicon.isVisible = mode != ItemType.SEPARATOR
title.isVisible = mode != ItemType.SEPARATOR
url.isVisible = mode == ItemType.SITE url.isVisible = mode == ItemType.SITE
overflow_menu.isVisible = mode != ItemType.SEPARATOR
separator.isVisible = mode == ItemType.SEPARATOR
isClickable = mode != ItemType.SEPARATOR
isFocusable = mode != ItemType.SEPARATOR
} }
/** /**
@ -92,9 +84,6 @@ class LibrarySiteItemView @JvmOverloads constructor(
} }
fun loadFavicon(url: String) { fun loadFavicon(url: String) {
if (iconUrl == url) return
iconUrl = url
context.components.core.icons.loadIntoView(favicon, url) context.components.core.icons.loadIntoView(favicon, url)
} }
@ -136,7 +125,7 @@ class LibrarySiteItemView @JvmOverloads constructor(
} }
enum class ItemType { enum class ItemType {
SITE, FOLDER, SEPARATOR; SITE, FOLDER;
} }
companion object { companion object {

@ -14,15 +14,12 @@ import androidx.recyclerview.widget.RecyclerView
import mozilla.appservices.places.BookmarkRoot import mozilla.appservices.places.BookmarkRoot
import mozilla.components.concept.storage.BookmarkNode import mozilla.components.concept.storage.BookmarkNode
import mozilla.components.concept.storage.BookmarkNodeType import mozilla.components.concept.storage.BookmarkNodeType
import org.mozilla.fenix.R
import org.mozilla.fenix.library.LibrarySiteItemView import org.mozilla.fenix.library.LibrarySiteItemView
import org.mozilla.fenix.library.bookmarks.viewholders.BookmarkFolderViewHolder
import org.mozilla.fenix.library.bookmarks.viewholders.BookmarkItemViewHolder
import org.mozilla.fenix.library.bookmarks.viewholders.BookmarkNodeViewHolder import org.mozilla.fenix.library.bookmarks.viewholders.BookmarkNodeViewHolder
import org.mozilla.fenix.library.bookmarks.viewholders.BookmarkSeparatorViewHolder import org.mozilla.fenix.library.bookmarks.viewholders.BookmarkSeparatorViewHolder
class BookmarkAdapter(private val emptyView: View, private val interactor: BookmarkViewInteractor) : class BookmarkAdapter(private val emptyView: View, private val interactor: BookmarkViewInteractor) :
RecyclerView.Adapter<BookmarkNodeViewHolder>() { RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var tree: List<BookmarkNode> = listOf() private var tree: List<BookmarkNode> = listOf()
private var mode: BookmarkFragmentState.Mode = BookmarkFragmentState.Mode.Normal() private var mode: BookmarkFragmentState.Mode = BookmarkFragmentState.Mode.Normal()
@ -70,7 +67,8 @@ class BookmarkAdapter(private val emptyView: View, private val interactor: Bookm
titleChanged = oldItem.title != newItem.title, titleChanged = oldItem.title != newItem.title,
urlChanged = oldItem.url != newItem.url, urlChanged = oldItem.url != newItem.url,
selectedChanged = oldItem in oldMode.selectedItems != newItem in newMode.selectedItems, selectedChanged = oldItem in oldMode.selectedItems != newItem in newMode.selectedItems,
modeChanged = oldMode::class != newMode::class modeChanged = oldMode::class != newMode::class,
iconChanged = oldItem.type != newItem.type || oldItem.url != newItem.url
) )
} }
@ -78,43 +76,42 @@ class BookmarkAdapter(private val emptyView: View, private val interactor: Bookm
override fun getNewListSize(): Int = new.size override fun getNewListSize(): Int = new.size
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookmarkNodeViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val view = LayoutInflater.from(parent.context) val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
.inflate(R.layout.bookmark_list_item, parent, false) as LibrarySiteItemView
return when (viewType) { return when (viewType) {
LibrarySiteItemView.ItemType.SITE.ordinal -> BookmarkItemViewHolder(view, interactor) BookmarkNodeViewHolder.LAYOUT_ID ->
LibrarySiteItemView.ItemType.FOLDER.ordinal -> BookmarkFolderViewHolder(view, interactor) BookmarkNodeViewHolder(view as LibrarySiteItemView, interactor)
LibrarySiteItemView.ItemType.SEPARATOR.ordinal -> BookmarkSeparatorViewHolder(view, interactor) BookmarkSeparatorViewHolder.LAYOUT_ID ->
BookmarkSeparatorViewHolder(view)
else -> throw IllegalStateException("ViewType $viewType does not match to a ViewHolder") else -> throw IllegalStateException("ViewType $viewType does not match to a ViewHolder")
} }
} }
override fun getItemViewType(position: Int): Int { override fun getItemViewType(position: Int) = when (tree[position].type) {
return when (tree[position].type) { BookmarkNodeType.ITEM, BookmarkNodeType.FOLDER -> BookmarkNodeViewHolder.LAYOUT_ID
BookmarkNodeType.ITEM -> LibrarySiteItemView.ItemType.SITE BookmarkNodeType.SEPARATOR -> BookmarkSeparatorViewHolder.LAYOUT_ID
BookmarkNodeType.FOLDER -> LibrarySiteItemView.ItemType.FOLDER
BookmarkNodeType.SEPARATOR -> LibrarySiteItemView.ItemType.SEPARATOR
else -> throw IllegalStateException("Item $tree[position] does not match to a ViewType")
}.ordinal
} }
override fun getItemCount(): Int = tree.size override fun getItemCount(): Int = tree.size
override fun onBindViewHolder( override fun onBindViewHolder(
holder: BookmarkNodeViewHolder, holder: RecyclerView.ViewHolder,
position: Int, position: Int,
payloads: MutableList<Any> payloads: MutableList<Any>
) { ) {
if (payloads.isNotEmpty() && payloads[0] is BookmarkPayload) { (holder as? BookmarkNodeViewHolder)?.apply {
holder.bind(tree[position], mode, payloads[0] as BookmarkPayload) val diffPayload = if (payloads.isNotEmpty() && payloads[0] is BookmarkPayload) {
} else { payloads[0] as BookmarkPayload
super.onBindViewHolder(holder, position, payloads) } else {
BookmarkPayload()
}
bind(tree[position], mode, diffPayload)
} }
} }
override fun onBindViewHolder(holder: BookmarkNodeViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
holder.bind(tree[position], mode) (holder as? BookmarkNodeViewHolder)?.bind(tree[position], mode, BookmarkPayload())
} }
} }
@ -125,12 +122,22 @@ class BookmarkAdapter(private val emptyView: View, private val interactor: Bookm
* @property urlChanged true if there has been a change to [BookmarkNode.url]. * @property urlChanged true if there has been a change to [BookmarkNode.url].
* @property selectedChanged true if there has been a change in the BookmarkNode's selected state. * @property selectedChanged true if there has been a change in the BookmarkNode's selected state.
* @property modeChanged true if there has been a change in the state's mode type. * @property modeChanged true if there has been a change in the state's mode type.
* @property iconChanged true if the icon displayed for the node should be changed.
*/ */
data class BookmarkPayload( data class BookmarkPayload(
val titleChanged: Boolean, val titleChanged: Boolean,
val urlChanged: Boolean, val urlChanged: Boolean,
val selectedChanged: Boolean, val selectedChanged: Boolean,
val modeChanged: Boolean val modeChanged: Boolean,
) val iconChanged: Boolean
) {
constructor() : this(
titleChanged = true,
urlChanged = true,
selectedChanged = true,
modeChanged = true,
iconChanged = true
)
}
fun BookmarkNode.inRoots() = enumValues<BookmarkRoot>().any { it.id == guid } fun BookmarkNode.inRoots() = enumValues<BookmarkRoot>().any { it.id == guid }

@ -1,81 +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.fenix.library.bookmarks.viewholders
import android.view.View
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import mozilla.components.concept.storage.BookmarkNode
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.hideAndDisable
import org.mozilla.fenix.ext.showAndEnable
import org.mozilla.fenix.library.LibrarySiteItemView
import org.mozilla.fenix.library.bookmarks.BookmarkFragmentState
import org.mozilla.fenix.library.bookmarks.BookmarkPayload
import org.mozilla.fenix.library.bookmarks.BookmarkViewInteractor
import org.mozilla.fenix.library.bookmarks.inRoots
/**
* Represents a folder with other bookmarks inside.
*/
class BookmarkFolderViewHolder(
view: LibrarySiteItemView,
interactor: BookmarkViewInteractor
) : BookmarkNodeViewHolder(view, interactor) {
override var item: BookmarkNode? = null
init {
containerView.displayAs(LibrarySiteItemView.ItemType.FOLDER)
}
override fun bind(
item: BookmarkNode,
mode: BookmarkFragmentState.Mode
) {
bind(item, mode, BookmarkPayload(true, true, true, true))
}
override fun bind(item: BookmarkNode, mode: BookmarkFragmentState.Mode, payload: BookmarkPayload) {
this.item = item
setSelectionListeners(item, mode)
if (!item.inRoots()) {
updateMenu(item.type)
if (payload.modeChanged) {
if (mode is BookmarkFragmentState.Mode.Selecting) {
containerView.overflowView.hideAndDisable()
} else {
containerView.overflowView.showAndEnable()
}
}
} else {
containerView.overflowView.visibility = View.GONE
}
if (payload.selectedChanged) {
containerView.changeSelected(item in mode.selectedItems)
}
containerView.iconView.setImageDrawable(
AppCompatResources.getDrawable(
containerView.context,
R.drawable.ic_folder_icon
)?.apply {
setTint(
ContextCompat.getColor(
containerView.context,
R.color.primary_text_light_theme
)
)
}
)
if (payload.titleChanged) {
containerView.titleView.text = item.title
}
}
}

@ -1,76 +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.fenix.library.bookmarks.viewholders
import androidx.annotation.VisibleForTesting
import mozilla.components.concept.storage.BookmarkNode
import org.mozilla.fenix.ext.hideAndDisable
import org.mozilla.fenix.ext.showAndEnable
import org.mozilla.fenix.library.LibrarySiteItemView
import org.mozilla.fenix.library.bookmarks.BookmarkFragmentState
import org.mozilla.fenix.library.bookmarks.BookmarkPayload
import org.mozilla.fenix.library.bookmarks.BookmarkViewInteractor
/**
* Represents a bookmarked website in the bookmarks page.
*/
class BookmarkItemViewHolder(
view: LibrarySiteItemView,
interactor: BookmarkViewInteractor
) : BookmarkNodeViewHolder(view, interactor) {
override var item: BookmarkNode? = null
init {
containerView.displayAs(LibrarySiteItemView.ItemType.SITE)
}
override fun bind(
item: BookmarkNode,
mode: BookmarkFragmentState.Mode
) {
bind(item, mode, BookmarkPayload(true, true, true, true))
}
override fun bind(item: BookmarkNode, mode: BookmarkFragmentState.Mode, payload: BookmarkPayload) {
this.item = item
updateMenu(item.type)
if (payload.modeChanged) {
if (mode is BookmarkFragmentState.Mode.Selecting) {
containerView.overflowView.hideAndDisable()
} else {
containerView.overflowView.showAndEnable()
}
}
if (payload.selectedChanged) {
containerView.changeSelected(item in mode.selectedItems)
}
if (payload.titleChanged) {
containerView.titleView.text = if (item.title.isNullOrBlank()) item.url else item.title
} else if (payload.urlChanged && item.title.isNullOrBlank()) {
containerView.titleView.text = item.url
}
if (payload.urlChanged) {
containerView.urlView.text = item.url
setColorsAndIcons(item.url)
}
setSelectionListeners(item, mode)
}
@VisibleForTesting
internal fun setColorsAndIcons(url: String?) {
if (url != null && url.startsWith("http")) {
containerView.loadFavicon(url)
} else {
containerView.iconView.setImageDrawable(null)
}
}
}

@ -4,45 +4,38 @@
package org.mozilla.fenix.library.bookmarks.viewholders package org.mozilla.fenix.library.bookmarks.viewholders
import android.view.View
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import mozilla.components.concept.storage.BookmarkNode import mozilla.components.concept.storage.BookmarkNode
import mozilla.components.concept.storage.BookmarkNodeType import mozilla.components.concept.storage.BookmarkNodeType
import mozilla.components.support.ktx.android.content.getDrawableWithTint
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.hideAndDisable
import org.mozilla.fenix.ext.loadIntoView
import org.mozilla.fenix.ext.showAndEnable
import org.mozilla.fenix.library.LibrarySiteItemView import org.mozilla.fenix.library.LibrarySiteItemView
import org.mozilla.fenix.library.SelectionHolder
import org.mozilla.fenix.library.bookmarks.BookmarkFragmentState import org.mozilla.fenix.library.bookmarks.BookmarkFragmentState
import org.mozilla.fenix.library.bookmarks.BookmarkItemMenu import org.mozilla.fenix.library.bookmarks.BookmarkItemMenu
import org.mozilla.fenix.library.bookmarks.BookmarkPayload import org.mozilla.fenix.library.bookmarks.BookmarkPayload
import org.mozilla.fenix.library.bookmarks.BookmarkViewInteractor import org.mozilla.fenix.library.bookmarks.BookmarkViewInteractor
import org.mozilla.fenix.library.bookmarks.inRoots
import org.mozilla.fenix.utils.Do import org.mozilla.fenix.utils.Do
/** /**
* Base class for bookmark node view holders. * Base class for bookmark node view holders.
*/ */
abstract class BookmarkNodeViewHolder( class BookmarkNodeViewHolder(
protected val containerView: LibrarySiteItemView, private val containerView: LibrarySiteItemView,
private val interactor: BookmarkViewInteractor private val interactor: BookmarkViewInteractor
) : RecyclerView.ViewHolder(containerView) { ) : RecyclerView.ViewHolder(containerView) {
abstract var item: BookmarkNode? var item: BookmarkNode? = null
private lateinit var menu: BookmarkItemMenu private val menu: BookmarkItemMenu
init { init {
setupMenu()
}
abstract fun bind(item: BookmarkNode, mode: BookmarkFragmentState.Mode)
abstract fun bind(
item: BookmarkNode,
mode: BookmarkFragmentState.Mode,
payload: BookmarkPayload
)
protected fun setSelectionListeners(item: BookmarkNode, selectionHolder: SelectionHolder<BookmarkNode>) {
containerView.setSelectionInteractor(item, selectionHolder, interactor)
}
private fun setupMenu() {
menu = BookmarkItemMenu(containerView.context) { menuItem -> menu = BookmarkItemMenu(containerView.context) { menuItem ->
val item = this.item ?: return@BookmarkItemMenu val item = this.item ?: return@BookmarkItemMenu
Do exhaustive when (menuItem) { Do exhaustive when (menuItem) {
@ -58,5 +51,71 @@ abstract class BookmarkNodeViewHolder(
containerView.attachMenu(menu.menuController) containerView.attachMenu(menu.menuController)
} }
protected fun updateMenu(itemType: BookmarkNodeType) = menu.updateMenu(itemType) fun bind(
item: BookmarkNode,
mode: BookmarkFragmentState.Mode,
payload: BookmarkPayload
) {
this.item = item
containerView.urlView.isVisible = item.type == BookmarkNodeType.ITEM
containerView.setSelectionInteractor(item, mode, interactor)
menu.updateMenu(item.type)
// Hide menu button if this item is a root folder or is selected
if (item.type == BookmarkNodeType.FOLDER && item.inRoots()) {
containerView.overflowView.visibility = View.GONE
} else if (payload.modeChanged) {
if (mode is BookmarkFragmentState.Mode.Selecting) {
containerView.overflowView.hideAndDisable()
} else {
containerView.overflowView.showAndEnable()
}
}
if (payload.selectedChanged) {
containerView.changeSelected(item in mode.selectedItems)
}
val useTitleFallback = item.type == BookmarkNodeType.ITEM && item.title.isNullOrBlank()
if (payload.titleChanged) {
containerView.titleView.text = if (useTitleFallback) item.url else item.title
} else if (payload.urlChanged && useTitleFallback) {
containerView.titleView.text = item.url
}
if (payload.urlChanged) {
containerView.urlView.text = item.url
}
if (payload.iconChanged) {
updateIcon(item)
}
}
private fun updateIcon(item: BookmarkNode) {
val context = containerView.context
val iconView = containerView.iconView
val url = item.url
when {
// Item is a folder
item.type == BookmarkNodeType.FOLDER ->
iconView.setImageDrawable(
context.getDrawableWithTint(
R.drawable.ic_folder_icon,
ContextCompat.getColor(context, R.color.primary_text_light_theme)
)
)
// Item has a http/https URL
url != null && url.startsWith("http") ->
context.components.core.icons.loadIntoView(iconView, url)
else ->
iconView.setImageDrawable(null)
}
}
companion object {
const val LAYOUT_ID = R.layout.bookmark_list_item
}
} }

@ -4,36 +4,15 @@
package org.mozilla.fenix.library.bookmarks.viewholders package org.mozilla.fenix.library.bookmarks.viewholders
import mozilla.components.concept.storage.BookmarkNode import android.view.View
import org.mozilla.fenix.library.LibrarySiteItemView import androidx.recyclerview.widget.RecyclerView
import org.mozilla.fenix.library.bookmarks.BookmarkFragmentState import org.mozilla.fenix.R
import org.mozilla.fenix.library.bookmarks.BookmarkPayload
import org.mozilla.fenix.library.bookmarks.BookmarkViewInteractor
/** /**
* Simple view holder for dividers in the bookmarks list. * Simple view holder for dividers in the bookmarks list.
*/ */
class BookmarkSeparatorViewHolder( class BookmarkSeparatorViewHolder(view: View) : RecyclerView.ViewHolder(view) {
view: LibrarySiteItemView, companion object {
interactor: BookmarkViewInteractor const val LAYOUT_ID = R.layout.library_separator
) : BookmarkNodeViewHolder(view, interactor) {
override var item: BookmarkNode? = null
override fun bind(
item: BookmarkNode,
mode: BookmarkFragmentState.Mode
) {
this.item = item
containerView.displayAs(LibrarySiteItemView.ItemType.SEPARATOR)
updateMenu(item.type)
}
override fun bind(
item: BookmarkNode,
mode: BookmarkFragmentState.Mode,
payload: BookmarkPayload
) {
bind(item, mode)
} }
} }

@ -26,7 +26,6 @@ import androidx.core.widget.NestedScrollView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.preference.PreferenceManager
import kotlinx.android.synthetic.main.fragment_search.* import kotlinx.android.synthetic.main.fragment_search.*
import kotlinx.android.synthetic.main.fragment_search.view.* import kotlinx.android.synthetic.main.fragment_search.view.*
import kotlinx.android.synthetic.main.search_suggestions_hint.view.* import kotlinx.android.synthetic.main.search_suggestions_hint.view.*
@ -51,7 +50,6 @@ import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.searchengine.CustomSearchEngineStore import org.mozilla.fenix.components.searchengine.CustomSearchEngineStore
import org.mozilla.fenix.components.searchengine.FenixSearchEngineProvider import org.mozilla.fenix.components.searchengine.FenixSearchEngineProvider
import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getPreferenceKey
import org.mozilla.fenix.ext.hideToolbar import org.mozilla.fenix.ext.hideToolbar
import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.settings
@ -67,7 +65,6 @@ class SearchFragment : Fragment(), UserInteractionHandler {
private lateinit var toolbarView: ToolbarView private lateinit var toolbarView: ToolbarView
private lateinit var awesomeBarView: AwesomeBarView private lateinit var awesomeBarView: AwesomeBarView
private val qrFeature = ViewBoundFeatureWrapper<QrFeature>() private val qrFeature = ViewBoundFeatureWrapper<QrFeature>()
private var permissionDidUpdate = false
private lateinit var searchStore: SearchFragmentStore private lateinit var searchStore: SearchFragmentStore
private lateinit var searchInteractor: SearchInteractor private lateinit var searchInteractor: SearchInteractor
@ -202,62 +199,26 @@ class SearchFragment : Fragment(), UserInteractionHandler {
search_scan_button.visibility = if (context?.hasCamera() == true) View.VISIBLE else View.GONE search_scan_button.visibility = if (context?.hasCamera() == true) View.VISIBLE else View.GONE
qrFeature.set( qrFeature.set(
QrFeature( createQrFeature(),
requireContext(),
fragmentManager = parentFragmentManager,
onNeedToRequestPermissions = { permissions ->
requestPermissions(permissions, REQUEST_CODE_CAMERA_PERMISSIONS)
},
onScanResult = { result ->
search_scan_button.isChecked = false
activity?.let {
AlertDialog.Builder(it).apply {
val spannable = resources.getSpanned(
R.string.qr_scanner_confirmation_dialog_message,
getString(R.string.app_name) to StyleSpan(BOLD),
result to StyleSpan(ITALIC)
)
setMessage(spannable)
setNegativeButton(R.string.qr_scanner_dialog_negative) { dialog: DialogInterface, _ ->
requireComponents.analytics.metrics.track(Event.QRScannerNavigationDenied)
dialog.cancel()
resetFocus()
}
setPositiveButton(R.string.qr_scanner_dialog_positive) { dialog: DialogInterface, _ ->
requireComponents.analytics.metrics.track(Event.QRScannerNavigationAllowed)
(activity as HomeActivity)
.openToBrowserAndLoad(
searchTermOrURL = result,
newTab = searchStore.state.tabId == null,
from = BrowserDirection.FromSearch
)
dialog.dismiss()
resetFocus()
}
create()
}.show()
requireComponents.analytics.metrics.track(Event.QRScannerPromptDisplayed)
}
}),
owner = this, owner = this,
view = view view = view
) )
view.search_scan_button.setOnClickListener { view.search_scan_button.setOnClickListener {
toolbarView.view.clearFocus() if (requireContext().settings().shouldShowCameraPermissionPrompt) {
val cameraPermissionsDenied = PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(
getPreferenceKey(R.string.pref_key_camera_permissions),
false
)
if (cameraPermissionsDenied) {
searchInteractor.onCameraPermissionsNeeded()
} else {
requireComponents.analytics.metrics.track(Event.QRScannerOpened) requireComponents.analytics.metrics.track(Event.QRScannerOpened)
qrFeature.get()?.scan(R.id.container) qrFeature.get()?.scan(R.id.container)
} else {
if (requireContext().isPermissionGranted(Manifest.permission.CAMERA)) {
requireComponents.analytics.metrics.track(Event.QRScannerOpened)
qrFeature.get()?.scan(R.id.container)
} else {
searchInteractor.onCameraPermissionsNeeded()
}
} }
view.hideKeyboard()
search_scan_button.isChecked = false
requireContext().settings().setCameraPermissionNeededState = false
} }
view.search_engines_shortcut_button.setOnClickListener { view.search_engines_shortcut_button.setOnClickListener {
@ -322,6 +283,47 @@ class SearchFragment : Fragment(), UserInteractionHandler {
startPostponedEnterTransition() startPostponedEnterTransition()
} }
private fun createQrFeature(): QrFeature {
return QrFeature(
requireContext(),
fragmentManager = parentFragmentManager,
onNeedToRequestPermissions = { permissions ->
requestPermissions(permissions, REQUEST_CODE_CAMERA_PERMISSIONS)
},
onScanResult = { result ->
search_scan_button.isChecked = false
activity?.let {
AlertDialog.Builder(it).apply {
val spannable = resources.getSpanned(
R.string.qr_scanner_confirmation_dialog_message,
getString(R.string.app_name) to StyleSpan(BOLD),
result to StyleSpan(ITALIC)
)
setMessage(spannable)
setNegativeButton(R.string.qr_scanner_dialog_negative) { dialog: DialogInterface, _ ->
requireComponents.analytics.metrics.track(Event.QRScannerNavigationDenied)
dialog.cancel()
resetFocus()
}
setPositiveButton(R.string.qr_scanner_dialog_positive) { dialog: DialogInterface, _ ->
requireComponents.analytics.metrics.track(Event.QRScannerNavigationAllowed)
(activity as HomeActivity)
.openToBrowserAndLoad(
searchTermOrURL = result,
newTab = searchStore.state.tabId == null,
from = BrowserDirection.FromSearch
)
dialog.dismiss()
resetFocus()
}
create()
}.show()
requireComponents.analytics.metrics.track(Event.QRScannerPromptDisplayed)
}
}
)
}
private fun updateToolbarContentDescription(searchState: SearchFragmentState) { private fun updateToolbarContentDescription(searchState: SearchFragmentState) {
val urlView = toolbarView.view val urlView = toolbarView.view
.findViewById<InlineAutocompleteEditText>(R.id.mozac_browser_toolbar_edit_url_view) .findViewById<InlineAutocompleteEditText>(R.id.mozac_browser_toolbar_edit_url_view)
@ -352,16 +354,11 @@ class SearchFragment : Fragment(), UserInteractionHandler {
searchStore.dispatch(SearchFragmentAction.UpdateShortcutsAvailability(areShortcutsAvailable)) searchStore.dispatch(SearchFragmentAction.UpdateShortcutsAvailability(areShortcutsAvailable))
} }
if (!permissionDidUpdate) {
toolbarView.view.edit.focus()
}
updateClipboardSuggestion( updateClipboardSuggestion(
searchStore.state, searchStore.state,
requireComponents.clipboardHandler.url requireComponents.clipboardHandler.url
) )
permissionDidUpdate = false
hideToolbar() hideToolbar()
} }
@ -423,22 +420,8 @@ class SearchFragment : Fragment(), UserInteractionHandler {
when (requestCode) { when (requestCode) {
REQUEST_CODE_CAMERA_PERMISSIONS -> qrFeature.withFeature { REQUEST_CODE_CAMERA_PERMISSIONS -> qrFeature.withFeature {
it.onPermissionsResult(permissions, grantResults) it.onPermissionsResult(permissions, grantResults)
resetFocus()
context?.let { context: Context -> requireContext().settings().setCameraPermissionNeededState = false
if (context.isPermissionGranted(Manifest.permission.CAMERA)) {
permissionDidUpdate = true
PreferenceManager.getDefaultSharedPreferences(context)
.edit().putBoolean(
getPreferenceKey(R.string.pref_key_camera_permissions), false
).apply()
} else {
PreferenceManager.getDefaultSharedPreferences(context)
.edit().putBoolean(
getPreferenceKey(R.string.pref_key_camera_permissions), true
).apply()
resetFocus()
}
}
} }
else -> super.onRequestPermissionsResult(requestCode, permissions, grantResults) else -> super.onRequestPermissionsResult(requestCode, permissions, grantResults)
} }

@ -29,7 +29,6 @@ import androidx.core.content.ContextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.preference.PreferenceManager
import kotlinx.android.synthetic.main.fragment_search_dialog.* import kotlinx.android.synthetic.main.fragment_search_dialog.*
import kotlinx.android.synthetic.main.fragment_search_dialog.view.* import kotlinx.android.synthetic.main.fragment_search_dialog.view.*
import kotlinx.android.synthetic.main.search_suggestions_hint.view.* import kotlinx.android.synthetic.main.search_suggestions_hint.view.*
@ -54,7 +53,6 @@ import org.mozilla.fenix.components.searchengine.CustomSearchEngineStore
import org.mozilla.fenix.components.searchengine.FenixSearchEngineProvider import org.mozilla.fenix.components.searchengine.FenixSearchEngineProvider
import org.mozilla.fenix.components.toolbar.ToolbarPosition import org.mozilla.fenix.components.toolbar.ToolbarPosition
import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getPreferenceKey
import org.mozilla.fenix.ext.isKeyboardVisible import org.mozilla.fenix.ext.isKeyboardVisible
import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.settings
@ -205,28 +203,34 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
interactor.onSearchShortcutsButtonClicked() interactor.onSearchShortcutsButtonClicked()
} }
qrFeature.set(
createQrFeature(),
owner = this,
view = view
)
qr_scan_button.visibility = if (context?.hasCamera() == true) View.VISIBLE else View.GONE qr_scan_button.visibility = if (context?.hasCamera() == true) View.VISIBLE else View.GONE
qr_scan_button.setOnClickListener { qr_scan_button.setOnClickListener {
if (!requireContext().hasCamera()) { return@setOnClickListener } if (!requireContext().hasCamera()) { return@setOnClickListener }
view.hideKeyboard()
toolbarView.view.clearFocus() toolbarView.view.clearFocus()
val cameraPermissionsDenied = if (requireContext().settings().shouldShowCameraPermissionPrompt) {
PreferenceManager.getDefaultSharedPreferences(context).getBoolean(
getPreferenceKey(R.string.pref_key_camera_permissions),
false
)
if (cameraPermissionsDenied) {
interactor.onCameraPermissionsNeeded()
resetFocus()
view.hideKeyboard()
toolbarView.view.requestFocus()
} else {
requireComponents.analytics.metrics.track(Event.QRScannerOpened) requireComponents.analytics.metrics.track(Event.QRScannerOpened)
qrFeature.get()?.scan(R.id.search_wrapper) qrFeature.get()?.scan(R.id.search_wrapper)
} else {
if (requireContext().isPermissionGranted(Manifest.permission.CAMERA)) {
requireComponents.analytics.metrics.track(Event.QRScannerOpened)
qrFeature.get()?.scan(R.id.search_wrapper)
} else {
interactor.onCameraPermissionsNeeded()
resetFocus()
view.hideKeyboard()
toolbarView.view.requestFocus()
}
} }
requireContext().settings().setCameraPermissionNeededState = false
} }
fill_link_from_clipboard.setOnClickListener { fill_link_from_clipboard.setOnClickListener {
@ -238,12 +242,6 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
) )
} }
qrFeature.set(
createQrFeature(),
owner = this,
view = view
)
val stubListener = ViewStub.OnInflateListener { _, inflated -> val stubListener = ViewStub.OnInflateListener { _, inflated ->
inflated.learn_more.setOnClickListener { inflated.learn_more.setOnClickListener {
(activity as HomeActivity) (activity as HomeActivity)
@ -379,7 +377,8 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
}.show() }.show()
requireComponents.analytics.metrics.track(Event.QRScannerPromptDisplayed) requireComponents.analytics.metrics.track(Event.QRScannerPromptDisplayed)
} }
}) }
)
} }
override fun onRequestPermissionsResult( override fun onRequestPermissionsResult(
@ -389,21 +388,9 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
) { ) {
when (requestCode) { when (requestCode) {
REQUEST_CODE_CAMERA_PERMISSIONS -> qrFeature.withFeature { REQUEST_CODE_CAMERA_PERMISSIONS -> qrFeature.withFeature {
context?.let { context: Context -> it.onPermissionsResult(permissions, grantResults)
it.onPermissionsResult(permissions, grantResults) resetFocus()
if (!context.isPermissionGranted(Manifest.permission.CAMERA)) { requireContext().settings().setCameraPermissionNeededState = false
PreferenceManager.getDefaultSharedPreferences(context)
.edit().putBoolean(
getPreferenceKey(R.string.pref_key_camera_permissions), true
).apply()
resetFocus()
} else {
PreferenceManager.getDefaultSharedPreferences(context)
.edit().putBoolean(
getPreferenceKey(R.string.pref_key_camera_permissions), false
).apply()
}
}
} }
else -> super.onRequestPermissionsResult(requestCode, permissions, grantResults) else -> super.onRequestPermissionsResult(requestCode, permissions, grantResults)
} }

@ -4,28 +4,21 @@
package org.mozilla.fenix.settings package org.mozilla.fenix.settings
import android.content.DialogInterface
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.VibrationEffect import android.os.VibrationEffect
import android.os.Vibrator import android.os.Vibrator
import android.provider.Settings
import android.text.SpannableString
import android.view.View import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.navigation.fragment.NavHostFragment.findNavController import androidx.navigation.fragment.NavHostFragment.findNavController
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.preference.PreferenceManager
import mozilla.components.feature.qr.QrFeature import mozilla.components.feature.qr.QrFeature
import mozilla.components.support.base.feature.UserInteractionHandler import mozilla.components.support.base.feature.UserInteractionHandler
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.ext.getPreferenceKey
import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.ext.showToolbar
@ -65,23 +58,14 @@ class PairFragment : Fragment(R.layout.fragment_pair), UserInteractionHandler {
false false
) )
}, },
scanMessage = R.string.pair_instructions_2), scanMessage = R.string.pair_instructions_2
),
owner = this, owner = this,
view = view view = view
) )
val cameraPermissionsDenied = PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(
getPreferenceKey(R.string.pref_key_camera_permissions),
false
)
qrFeature.withFeature { qrFeature.withFeature {
if (cameraPermissionsDenied) { it.scan(R.id.pair_layout)
showPermissionsNeededDialog()
} else {
it.scan(R.id.pair_layout)
}
} }
} }
@ -116,57 +100,10 @@ class PairFragment : Fragment(R.layout.fragment_pair), UserInteractionHandler {
qrFeature.withFeature { qrFeature.withFeature {
it.onPermissionsResult(permissions, grantResults) it.onPermissionsResult(permissions, grantResults)
} }
PreferenceManager.getDefaultSharedPreferences(context)
.edit().putBoolean(
getPreferenceKey(R.string.pref_key_camera_permissions), false
).apply()
} else { } else {
PreferenceManager.getDefaultSharedPreferences(context)
.edit().putBoolean(
getPreferenceKey(R.string.pref_key_camera_permissions), true
).apply()
findNavController().popBackStack(R.id.turnOnSyncFragment, false) findNavController().popBackStack(R.id.turnOnSyncFragment, false)
} }
} }
} }
} }
/**
* Shows an [AlertDialog] when camera permissions are needed.
*
* In versions above M, [AlertDialog.BUTTON_POSITIVE] takes the user to the app settings. This
* intent only exists in M and above. Below M, [AlertDialog.BUTTON_POSITIVE] routes to a SUMO
* help page to find the app settings.
*
* [AlertDialog.BUTTON_NEGATIVE] dismisses the dialog.
*/
private fun showPermissionsNeededDialog() {
AlertDialog.Builder(requireContext()).apply {
val spannableText = SpannableString(
resources.getString(R.string.camera_permissions_needed_message)
)
setMessage(spannableText)
setNegativeButton(R.string.camera_permissions_needed_negative_button_text) {
dialog: DialogInterface, _ ->
dialog.cancel()
}
setPositiveButton(R.string.camera_permissions_needed_positive_button_text) {
dialog: DialogInterface, _ ->
val intent: Intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
} else {
SupportUtils.createCustomTabIntent(
requireContext(),
SupportUtils.getSumoURLForTopic(
requireContext(),
SupportUtils.SumoTopic.QR_CAMERA_ACCESS
)
)
}
dialog.cancel()
startActivity(intent)
}
create()
}.show()
}
} }

@ -27,6 +27,7 @@ import mozilla.components.concept.sync.AccountObserver
import mozilla.components.concept.sync.AuthType import mozilla.components.concept.sync.AuthType
import mozilla.components.concept.sync.OAuthAccount import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.concept.sync.Profile import mozilla.components.concept.sync.Profile
import mozilla.components.support.ktx.android.content.getColorFromAttr
import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.Config import org.mozilla.fenix.Config
import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.FeatureFlags
@ -312,6 +313,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
private fun setupPreferences() { private fun setupPreferences() {
val leakKey = getPreferenceKey(R.string.pref_key_leakcanary) val leakKey = getPreferenceKey(R.string.pref_key_leakcanary)
val debuggingKey = getPreferenceKey(R.string.pref_key_remote_debugging) val debuggingKey = getPreferenceKey(R.string.pref_key_remote_debugging)
val preferencePrivateBrowsing =
requirePreference<Preference>(R.string.pref_key_private_browsing)
val preferenceExternalDownloadManager = val preferenceExternalDownloadManager =
requirePreference<Preference>(R.string.pref_key_external_download_manager) requirePreference<Preference>(R.string.pref_key_external_download_manager)
val preferenceLeakCanary = findPreference<Preference>(leakKey) val preferenceLeakCanary = findPreference<Preference>(leakKey)
@ -319,6 +322,10 @@ class SettingsFragment : PreferenceFragmentCompat() {
val preferenceMakeDefaultBrowser = val preferenceMakeDefaultBrowser =
requirePreference<Preference>(R.string.pref_key_make_default_browser) requirePreference<Preference>(R.string.pref_key_make_default_browser)
preferencePrivateBrowsing.icon.mutate().apply {
setTint(requireContext().getColorFromAttr(R.attr.primaryText))
}
if (!Config.channel.isReleased) { if (!Config.channel.isReleased) {
preferenceLeakCanary?.setOnPreferenceChangeListener { _, newValue -> preferenceLeakCanary?.setOnPreferenceChangeListener { _, newValue ->
val isEnabled = newValue == true val isEnabled = newValue == true

@ -0,0 +1,74 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.account
import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.text.SpannableString
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.settings.SupportUtils
interface SyncController {
fun handleCameraPermissionsNeeded()
}
/**
* Controller for handling [DefaultSyncInteractor] requests.
*/
class DefaultSyncController(
private val activity: HomeActivity
) : SyncController {
/**
* Creates and shows an [AlertDialog] when camera permissions are needed.
*
* In versions above M, [AlertDialog.BUTTON_POSITIVE] takes the user to the app settings. This
* intent only exists in M and above. Below M, [AlertDialog.BUTTON_POSITIVE] routes to a SUMO
* help page to find the app settings.
*
* [AlertDialog.BUTTON_NEGATIVE] dismisses the dialog.
*/
override fun handleCameraPermissionsNeeded() {
val dialog = buildDialog()
dialog.show()
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun buildDialog(): AlertDialog.Builder {
return AlertDialog.Builder(activity).apply {
val spannableText = SpannableString(
activity.resources.getString(R.string.camera_permissions_needed_message)
)
setMessage(spannableText)
setNegativeButton(R.string.camera_permissions_needed_negative_button_text) { dialog: DialogInterface, _ ->
dialog.cancel()
}
setPositiveButton(R.string.camera_permissions_needed_positive_button_text) { dialog: DialogInterface, _ ->
val intent: Intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
} else {
SupportUtils.createCustomTabIntent(
activity,
SupportUtils.getSumoURLForTopic(
activity,
SupportUtils.SumoTopic.QR_CAMERA_ACCESS
)
)
}
val uri = Uri.fromParts("package", activity.packageName, null)
intent.data = uri
dialog.cancel()
activity.startActivity(intent)
}
create()
}
}
}

@ -0,0 +1,20 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.account
interface SyncInteractor {
fun onCameraPermissionsNeeded()
}
/**
* Interactor for [TurnOnSyncFragment].
*
* @param syncController Handles the interactions
*/
class DefaultSyncInteractor(private val syncController: DefaultSyncController) : SyncInteractor {
override fun onCameraPermissionsNeeded() {
syncController.handleCameraPermissionsNeeded()
}
}

@ -4,6 +4,7 @@
package org.mozilla.fenix.settings.account package org.mozilla.fenix.settings.account
import android.Manifest
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@ -18,15 +19,21 @@ import mozilla.components.concept.sync.AccountObserver
import mozilla.components.concept.sync.AuthType import mozilla.components.concept.sync.AuthType
import mozilla.components.concept.sync.OAuthAccount import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.support.ktx.android.content.hasCamera import mozilla.components.support.ktx.android.content.hasCamera
import mozilla.components.support.ktx.android.content.isPermissionGranted
import mozilla.components.support.ktx.android.view.hideKeyboard
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.ext.showToolbar
class TurnOnSyncFragment : Fragment(), AccountObserver { class TurnOnSyncFragment : Fragment(), AccountObserver {
private val args by navArgs<TurnOnSyncFragmentArgs>() private val args by navArgs<TurnOnSyncFragmentArgs>()
private lateinit var interactor: DefaultSyncInteractor
private var shouldLoginJustWithEmail = false private var shouldLoginJustWithEmail = false
private var pairWithEmailStarted = false private var pairWithEmailStarted = false
@ -35,6 +42,23 @@ class TurnOnSyncFragment : Fragment(), AccountObserver {
} }
private val paringClickListener = View.OnClickListener { private val paringClickListener = View.OnClickListener {
if (requireContext().settings().shouldShowCameraPermissionPrompt) {
requireComponents.analytics.metrics.track(Event.QRScannerOpened)
navigateToPairFragment()
} else {
if (requireContext().isPermissionGranted(Manifest.permission.CAMERA)) {
requireComponents.analytics.metrics.track(Event.QRScannerOpened)
navigateToPairFragment()
} else {
interactor.onCameraPermissionsNeeded()
view?.hideKeyboard()
}
}
view?.hideKeyboard()
requireContext().settings().setCameraPermissionNeededState = false
}
private fun navigateToPairFragment() {
val directions = TurnOnSyncFragmentDirections.actionTurnOnSyncFragmentToPairFragment() val directions = TurnOnSyncFragmentDirections.actionTurnOnSyncFragmentToPairFragment()
requireView().findNavController().navigate(directions) requireView().findNavController().navigate(directions)
requireComponents.analytics.metrics.track(Event.SyncAuthScanPairing) requireComponents.analytics.metrics.track(Event.SyncAuthScanPairing)
@ -89,6 +113,11 @@ class TurnOnSyncFragment : Fragment(), AccountObserver {
getString(R.string.sign_in_instructions), getString(R.string.sign_in_instructions),
HtmlCompat.FROM_HTML_MODE_LEGACY HtmlCompat.FROM_HTML_MODE_LEGACY
) )
interactor = DefaultSyncInteractor(
DefaultSyncController(activity = activity as HomeActivity)
)
return view return view
} }

@ -60,7 +60,6 @@ class Settings(private val appContext: Context) : PreferencesHolder {
private const val ALLOWED_INT = 2 private const val ALLOWED_INT = 2
private const val CFR_COUNT_CONDITION_FOCUS_INSTALLED = 1 private const val CFR_COUNT_CONDITION_FOCUS_INSTALLED = 1
private const val CFR_COUNT_CONDITION_FOCUS_NOT_INSTALLED = 3 private const val CFR_COUNT_CONDITION_FOCUS_NOT_INSTALLED = 3
private const val MIN_DAYS_SINCE_FEEDBACK_PROMPT = 120
const val ONE_DAY_MS = 60 * 60 * 24 * 1000L const val ONE_DAY_MS = 60 * 60 * 24 * 1000L
const val ONE_WEEK_MS = 60 * 60 * 24 * 7 * 1000L const val ONE_WEEK_MS = 60 * 60 * 24 * 7 * 1000L
@ -771,6 +770,24 @@ class Settings(private val appContext: Context) : PreferencesHolder {
default = true default = true
) )
/**
* Used in [SearchDialogFragment.kt], [SearchFragment.kt] (deprecated), and [PairFragment.kt]
* to see if we need to check for camera permissions before using the QR code scanner.
*/
var shouldShowCameraPermissionPrompt by booleanPreference(
appContext.getPreferenceKey(R.string.pref_key_camera_permissions_needed),
default = true
)
/**
* Sets the state of permissions that have been checked, where [false] denotes already checked
* and [true] denotes needing to check. See [shouldShowCameraPermissionPrompt].
*/
var setCameraPermissionNeededState by booleanPreference(
appContext.getPreferenceKey(R.string.pref_key_camera_permissions_needed),
default = true
)
var shouldPromptToSaveLogins by booleanPreference( var shouldPromptToSaveLogins by booleanPreference(
appContext.getPreferenceKey(R.string.pref_key_save_logins), appContext.getPreferenceKey(R.string.pref_key_save_logins),
default = true default = true

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="@dimen/library_item_height"
android:importantForAccessibility="no">
<View
android:id="@+id/separator"
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_gravity="center"
android:background="?neutralFaded"/>
</FrameLayout>

@ -84,18 +84,4 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"/> app:layout_constraintTop_toTopOf="parent"/>
<View
android:id="@+id/separator"
android:layout_width="0dp"
android:layout_height="2dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:importantForAccessibility="no"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:background="?neutralFaded"
android:visibility="gone"/>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

@ -6,6 +6,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:importantForAccessibility="no"
android:orientation="horizontal"> android:orientation="horizontal">
<TextView <TextView
@ -25,6 +26,7 @@
android:layout_marginTop="48dp" android:layout_marginTop="48dp"
android:layout_marginEnd="4dp" android:layout_marginEnd="4dp"
android:background="?android:attr/selectableItemBackgroundBorderless" android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/resync_button_content_description"
app:srcCompat="@drawable/mozac_ic_refresh" app:srcCompat="@drawable/mozac_ic_refresh"
app:tint="?primaryText" /> app:tint="?primaryText" />

@ -35,7 +35,7 @@
<color name="dark_grey_90_gradient_start">#FF15141A</color> <color name="dark_grey_90_gradient_start">#FF15141A</color>
<color name="dark_grey_90_gradient_end">#0015141A</color> <color name="dark_grey_90_gradient_end">#0015141A</color>
<color name="synced_tabs_separator_light_theme">@color/photonLightGrey30</color> <color name="synced_tabs_separator_light_theme">@color/photonLightGrey30</color>
<color name="top_site_title_text_light_theme">@color/photonLightGrey80</color> <color name="top_site_title_text_light_theme">@color/photonDarkGrey10</color>
<color name="collection_icon_color_violet_light_theme">#7542E5</color> <color name="collection_icon_color_violet_light_theme">#7542E5</color>
<color name="collection_icon_color_blue_light_theme">#0250BB</color> <color name="collection_icon_color_blue_light_theme">#0250BB</color>
<color name="collection_icon_color_pink_light_theme">#E31587</color> <color name="collection_icon_color_pink_light_theme">#E31587</color>
@ -103,7 +103,7 @@
<color name="scrimStart_dark_theme">#F520123A</color> <color name="scrimStart_dark_theme">#F520123A</color>
<color name="scrimEnd_dark_theme">#F515141A</color> <color name="scrimEnd_dark_theme">#F515141A</color>
<color name="synced_tabs_separator_dark_theme">@color/photonDarkGrey10</color> <color name="synced_tabs_separator_dark_theme">@color/photonDarkGrey10</color>
<color name="top_site_title_text_dark_theme">@color/photonLightGrey90</color> <color name="top_site_title_text_dark_theme">@color/photonLightGrey60</color>
<color name="collection_icon_color_violet_dark_theme">#AB71FF</color> <color name="collection_icon_color_violet_dark_theme">#AB71FF</color>
<color name="collection_icon_color_blue_dark_theme">#00B3F4</color> <color name="collection_icon_color_blue_dark_theme">#00B3F4</color>
<color name="collection_icon_color_pink_dark_theme">#FF6BBA</color> <color name="collection_icon_color_pink_dark_theme">#FF6BBA</color>

@ -244,5 +244,5 @@
<string name="pref_key_close_tabs_after_one_week" translatable="false">pref_key_close_tabs_after_one_week</string> <string name="pref_key_close_tabs_after_one_week" translatable="false">pref_key_close_tabs_after_one_week</string>
<string name="pref_key_close_tabs_after_one_month" translatable="false">pref_key_close_tabs_after_one_month</string> <string name="pref_key_close_tabs_after_one_month" translatable="false">pref_key_close_tabs_after_one_month</string>
<string name="pref_key_camera_permissions" translatable="false">pref_key_camera_permissions</string> <string name="pref_key_camera_permissions_needed" translatable="false">pref_key_camera_permissions_needed</string>
</resources> </resources>

@ -145,6 +145,8 @@
<string name="browser_menu_install_on_homescreen">Install</string> <string name="browser_menu_install_on_homescreen">Install</string>
<!-- Menu option on the toolbar that takes you to synced tabs page--> <!-- Menu option on the toolbar that takes you to synced tabs page-->
<string name="synced_tabs">Synced tabs</string> <string name="synced_tabs">Synced tabs</string>
<!-- Content description (not visible, for screen readers etc.) for the Resync tabs button -->
<string name="resync_button_content_description">Resync</string>
<!-- Browser menu button that opens the find in page menu --> <!-- Browser menu button that opens the find in page menu -->
<string name="browser_menu_find_in_page">Find in page</string> <string name="browser_menu_find_in_page">Find in page</string>
<!-- Browser menu button that creates a private tab --> <!-- Browser menu button that creates a private tab -->

@ -58,10 +58,10 @@ class ExternalAppBrowserActivityTest {
var directions = activity.getNavDirections(BrowserDirection.FromGlobal, "id") var directions = activity.getNavDirections(BrowserDirection.FromGlobal, "id")
assertNotNull(directions) assertNotNull(directions)
verify(exactly = 0) { activity.finish() } verify(exactly = 0) { activity.finishAndRemoveTask() }
directions = activity.getNavDirections(BrowserDirection.FromGlobal, null) directions = activity.getNavDirections(BrowserDirection.FromGlobal, null)
assertNull(directions) assertNull(directions)
verify { activity.finish() } verify { activity.finishAndRemoveTask() }
} }
} }

@ -18,6 +18,7 @@ import mozilla.components.browser.session.SessionManager
import mozilla.components.concept.engine.Engine import mozilla.components.concept.engine.Engine
import mozilla.components.feature.tab.collections.TabCollection import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.tabs.TabsUseCases import mozilla.components.feature.tabs.TabsUseCases
import mozilla.components.feature.top.sites.TopSite
import mozilla.components.support.test.rule.MainCoroutineRule import mozilla.components.support.test.rule.MainCoroutineRule
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
@ -279,7 +280,7 @@ class DefaultSessionControlControllerTest {
fun handleSelectDefaultTopSite() { fun handleSelectDefaultTopSite() {
val topSiteUrl = "mozilla.org" val topSiteUrl = "mozilla.org"
controller.handleSelectTopSite(topSiteUrl, true) controller.handleSelectTopSite(topSiteUrl, TopSite.Type.DEFAULT)
verify { metrics.track(Event.TopSiteOpenInNewTab) } verify { metrics.track(Event.TopSiteOpenInNewTab) }
verify { metrics.track(Event.TopSiteOpenDefault) } verify { metrics.track(Event.TopSiteOpenDefault) }
verify { verify {
@ -296,7 +297,7 @@ class DefaultSessionControlControllerTest {
fun handleSelectNonDefaultTopSite() { fun handleSelectNonDefaultTopSite() {
val topSiteUrl = "mozilla.org" val topSiteUrl = "mozilla.org"
controller.handleSelectTopSite(topSiteUrl, false) controller.handleSelectTopSite(topSiteUrl, TopSite.Type.FRECENT)
verify { metrics.track(Event.TopSiteOpenInNewTab) } verify { metrics.track(Event.TopSiteOpenInNewTab) }
verify { verify {
tabsUseCases.addTab.invoke( tabsUseCases.addTab.invoke(

@ -42,6 +42,6 @@ class TopSiteItemViewHolderTest {
TopSiteItemViewHolder(view, interactor).bind(pocket) TopSiteItemViewHolder(view, interactor).bind(pocket)
view.top_site_item.performClick() view.top_site_item.performClick()
verify { interactor.onSelectTopSite("https://getpocket.com", isDefault = true) } verify { interactor.onSelectTopSite("https://getpocket.com", TopSite.Type.DEFAULT) }
} }
} }

@ -1,87 +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.fenix.library.bookmarks.viewholders
import androidx.appcompat.content.res.AppCompatResources
import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.verify
import mozilla.components.concept.storage.BookmarkNode
import mozilla.components.concept.storage.BookmarkNodeType
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.ext.hideAndDisable
import org.mozilla.fenix.ext.showAndEnable
import org.mozilla.fenix.library.LibrarySiteItemView
import org.mozilla.fenix.library.bookmarks.BookmarkFragmentInteractor
import org.mozilla.fenix.library.bookmarks.BookmarkFragmentState
import org.mozilla.fenix.library.bookmarks.BookmarkPayload
class BookmarkFolderViewHolderTest {
@MockK
private lateinit var interactor: BookmarkFragmentInteractor
@MockK(relaxed = true)
private lateinit var siteItemView: LibrarySiteItemView
private lateinit var holder: BookmarkFolderViewHolder
private val folder = BookmarkNode(
type = BookmarkNodeType.FOLDER,
guid = "456",
parentGuid = "123",
position = 0,
title = "Folder",
url = null,
children = listOf()
)
@Before
fun setup() {
MockKAnnotations.init(this)
mockkStatic(AppCompatResources::class)
every { AppCompatResources.getDrawable(any(), any()) } returns mockk(relaxed = true)
holder = BookmarkFolderViewHolder(siteItemView, interactor)
}
@Test
fun `binds title and selected state`() {
holder.bind(folder, BookmarkFragmentState.Mode.Normal())
verify {
siteItemView.titleView.text = folder.title
siteItemView.overflowView.showAndEnable()
siteItemView.changeSelected(false)
}
holder.bind(folder, BookmarkFragmentState.Mode.Selecting(setOf(folder)))
verify {
siteItemView.titleView.text = folder.title
siteItemView.overflowView.hideAndDisable()
siteItemView.changeSelected(true)
}
}
@Test
fun `bind with payload of no changes does not rebind views`() {
holder.bind(
folder,
BookmarkFragmentState.Mode.Normal(),
BookmarkPayload(false, false, false, false)
)
verify(inverse = true) {
siteItemView.titleView.text = folder.title
siteItemView.overflowView.showAndEnable()
siteItemView.overflowView.hideAndDisable()
siteItemView.changeSelected(any())
}
}
}

@ -4,13 +4,23 @@
package org.mozilla.fenix.library.bookmarks.viewholders package org.mozilla.fenix.library.bookmarks.viewholders
import androidx.appcompat.content.res.AppCompatResources
import io.mockk.Called
import io.mockk.MockKAnnotations import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify import io.mockk.verify
import mozilla.components.browser.icons.BrowserIcons
import mozilla.components.browser.icons.IconRequest
import mozilla.components.concept.storage.BookmarkNode import mozilla.components.concept.storage.BookmarkNode
import mozilla.components.concept.storage.BookmarkNodeType import mozilla.components.concept.storage.BookmarkNodeType
import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.hideAndDisable import org.mozilla.fenix.ext.hideAndDisable
import org.mozilla.fenix.ext.showAndEnable import org.mozilla.fenix.ext.showAndEnable
import org.mozilla.fenix.library.LibrarySiteItemView import org.mozilla.fenix.library.LibrarySiteItemView
@ -18,15 +28,12 @@ import org.mozilla.fenix.library.bookmarks.BookmarkFragmentInteractor
import org.mozilla.fenix.library.bookmarks.BookmarkFragmentState import org.mozilla.fenix.library.bookmarks.BookmarkFragmentState
import org.mozilla.fenix.library.bookmarks.BookmarkPayload import org.mozilla.fenix.library.bookmarks.BookmarkPayload
class BookmarkItemViewHolderTest { class BookmarkNodeViewHolderTest {
@MockK @MockK private lateinit var interactor: BookmarkFragmentInteractor
private lateinit var interactor: BookmarkFragmentInteractor @MockK(relaxed = true) private lateinit var siteItemView: LibrarySiteItemView
@MockK private lateinit var icons: BrowserIcons
@MockK(relaxed = true) private lateinit var holder: BookmarkNodeViewHolder
private lateinit var siteItemView: LibrarySiteItemView
private lateinit var holder: BookmarkItemViewHolder
private val item = BookmarkNode( private val item = BookmarkNode(
type = BookmarkNodeType.ITEM, type = BookmarkNodeType.ITEM,
@ -37,17 +44,45 @@ class BookmarkItemViewHolderTest {
url = "https://www.mozilla.org", url = "https://www.mozilla.org",
children = listOf() children = listOf()
) )
private val folder = BookmarkNode(
type = BookmarkNodeType.FOLDER,
guid = "456",
parentGuid = "123",
position = 0,
title = "Folder",
url = null,
children = listOf()
)
private val falsePayload = BookmarkPayload(
titleChanged = false,
urlChanged = false,
selectedChanged = false,
modeChanged = false,
iconChanged = false
)
@Before @Before
fun setup() { fun setup() {
MockKAnnotations.init(this) MockKAnnotations.init(this)
holder = BookmarkItemViewHolder(siteItemView, interactor)
mockkStatic(AppCompatResources::class)
every { AppCompatResources.getDrawable(any(), any()) } returns mockk(relaxed = true)
every { siteItemView.context.components.core.icons } returns icons
every { icons.loadIntoView(siteItemView.iconView, any()) } returns mockk()
holder = BookmarkNodeViewHolder(siteItemView, interactor)
}
@After
fun teardown() {
unmockkStatic(AppCompatResources::class)
} }
@Test @Test
fun `binds views for unselected item`() { fun `binds views for unselected item`() {
val mode = BookmarkFragmentState.Mode.Normal() val mode = BookmarkFragmentState.Mode.Normal()
holder.bind(item, mode) holder.bind(item, mode, BookmarkPayload())
verify { verify {
siteItemView.setSelectionInteractor(item, mode, interactor) siteItemView.setSelectionInteractor(item, mode, interactor)
@ -55,14 +90,14 @@ class BookmarkItemViewHolderTest {
siteItemView.urlView.text = item.url siteItemView.urlView.text = item.url
siteItemView.overflowView.showAndEnable() siteItemView.overflowView.showAndEnable()
siteItemView.changeSelected(false) siteItemView.changeSelected(false)
holder.setColorsAndIcons(item.url) icons.loadIntoView(siteItemView.iconView, IconRequest(item.url!!))
} }
} }
@Test @Test
fun `binds views for selected item`() { fun `binds views for selected item for item`() {
val mode = BookmarkFragmentState.Mode.Selecting(setOf(item)) val mode = BookmarkFragmentState.Mode.Selecting(setOf(item))
holder.bind(item, mode) holder.bind(item, mode, BookmarkPayload())
verify { verify {
siteItemView.setSelectionInteractor(item, mode, interactor) siteItemView.setSelectionInteractor(item, mode, interactor)
@ -70,16 +105,15 @@ class BookmarkItemViewHolderTest {
siteItemView.urlView.text = item.url siteItemView.urlView.text = item.url
siteItemView.overflowView.hideAndDisable() siteItemView.overflowView.hideAndDisable()
siteItemView.changeSelected(true) siteItemView.changeSelected(true)
holder.setColorsAndIcons(item.url)
} }
} }
@Test @Test
fun `bind with payload of no changes does not rebind views`() { fun `bind with payload of no changes does not rebind views for item`() {
holder.bind( holder.bind(
item, item,
BookmarkFragmentState.Mode.Normal(), BookmarkFragmentState.Mode.Normal(),
BookmarkPayload(false, false, false, false) falsePayload
) )
verify(inverse = true) { verify(inverse = true) {
@ -88,28 +122,28 @@ class BookmarkItemViewHolderTest {
siteItemView.overflowView.showAndEnable() siteItemView.overflowView.showAndEnable()
siteItemView.overflowView.hideAndDisable() siteItemView.overflowView.hideAndDisable()
siteItemView.changeSelected(any()) siteItemView.changeSelected(any())
holder.setColorsAndIcons(item.url)
} }
verify { siteItemView.iconView wasNot Called }
} }
@Test @Test
fun `binding an item with a null title uses the url as the title`() { fun `binding an item with a null title uses the url as the title for item`() {
val item = item.copy(title = null) val item = item.copy(title = null)
holder.bind(item, BookmarkFragmentState.Mode.Normal()) holder.bind(item, BookmarkFragmentState.Mode.Normal(), BookmarkPayload())
verify { siteItemView.titleView.text = item.url } verify { siteItemView.titleView.text = item.url }
} }
@Test @Test
fun `binding an item with a blank title uses the url as the title`() { fun `binding an item with a blank title uses the url as the title for item`() {
val item = item.copy(title = " ") val item = item.copy(title = " ")
holder.bind(item, BookmarkFragmentState.Mode.Normal()) holder.bind(item, BookmarkFragmentState.Mode.Normal(), BookmarkPayload())
verify { siteItemView.titleView.text = item.url } verify { siteItemView.titleView.text = item.url }
} }
@Test @Test
fun `rebinds title if item title is null and the item url has changed`() { fun `rebinds title if item title is null and the item url has changed for item`() {
val item = item.copy(title = null) val item = item.copy(title = null)
holder.bind( holder.bind(
item, item,
@ -118,7 +152,8 @@ class BookmarkItemViewHolderTest {
titleChanged = false, titleChanged = false,
urlChanged = true, urlChanged = true,
selectedChanged = false, selectedChanged = false,
modeChanged = false modeChanged = false,
iconChanged = false
) )
) )
@ -126,7 +161,7 @@ class BookmarkItemViewHolderTest {
} }
@Test @Test
fun `rebinds title if item title is blank and the item url has changed`() { fun `rebinds title if item title is blank and the item url has changed for item`() {
val item = item.copy(title = " ") val item = item.copy(title = " ")
holder.bind( holder.bind(
item, item,
@ -135,10 +170,46 @@ class BookmarkItemViewHolderTest {
titleChanged = false, titleChanged = false,
urlChanged = true, urlChanged = true,
selectedChanged = false, selectedChanged = false,
modeChanged = false modeChanged = false,
iconChanged = false
) )
) )
verify { siteItemView.titleView.text = item.url } verify { siteItemView.titleView.text = item.url }
} }
@Test
fun `binds title and selected state for folder`() {
holder.bind(folder, BookmarkFragmentState.Mode.Normal(), BookmarkPayload())
verify {
siteItemView.titleView.text = folder.title
siteItemView.overflowView.showAndEnable()
siteItemView.changeSelected(false)
}
holder.bind(folder, BookmarkFragmentState.Mode.Selecting(setOf(folder)), BookmarkPayload())
verify {
siteItemView.titleView.text = folder.title
siteItemView.overflowView.hideAndDisable()
siteItemView.changeSelected(true)
}
}
@Test
fun `bind with payload of no changes does not rebind views for folder`() {
holder.bind(
folder,
BookmarkFragmentState.Mode.Normal(),
falsePayload
)
verify(inverse = true) {
siteItemView.titleView.text = folder.title
siteItemView.overflowView.showAndEnable()
siteItemView.overflowView.hideAndDisable()
siteItemView.changeSelected(any())
}
}
} }

@ -106,4 +106,13 @@ class SearchInteractorTest {
searchController.handleExistingSessionSelected(session) searchController.handleExistingSessionSelected(session)
} }
} }
@Test
fun onCameraPermissionsNeeded() {
interactor.onCameraPermissionsNeeded()
verify {
searchController.handleCameraPermissionsNeeded()
}
}
} }

@ -0,0 +1,40 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.account
import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.mockk
import io.mockk.spyk
import io.mockk.verify
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.search.AlertDialogBuilder
class DefaultSyncControllerTest {
private lateinit var syncController: DefaultSyncController
@MockK(relaxed = true) private lateinit var activity: HomeActivity
@Before
fun setUp() {
MockKAnnotations.init(this)
syncController = DefaultSyncController(activity)
}
@Test
fun `show camera permissions needed dialog`() {
val dialogBuilder: AlertDialogBuilder = mockk(relaxed = true)
val spyController = spyk(syncController)
every { spyController.buildDialog() } returns dialogBuilder
spyController.handleCameraPermissionsNeeded()
verify { dialogBuilder.show() }
}
}

@ -0,0 +1,31 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.account
import io.mockk.mockk
import io.mockk.verify
import org.junit.Before
import org.junit.Test
class DefaultSyncInteractorTest {
private lateinit var syncInteractor: DefaultSyncInteractor
private lateinit var syncController: DefaultSyncController
@Before
fun setUp() {
syncController = mockk(relaxed = true)
syncInteractor = DefaultSyncInteractor(syncController)
}
@Test
fun onCameraPermissionsNeeded() {
syncInteractor.onCameraPermissionsNeeded()
verify {
syncController.handleCameraPermissionsNeeded()
}
}
}

@ -3,5 +3,5 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
object AndroidComponents { object AndroidComponents {
const val VERSION = "59.0.20200916130055" const val VERSION = "60.0.20200917130150"
} }

@ -213,10 +213,14 @@ The following metrics are added to the ping:
| tip.displayed |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |The tip was displayed |[1](https://github.com/mozilla-mobile/fenix/pull/9836)|<ul><li>identifier: The identifier of the tip displayed</li></ul>|2020-11-15 |2 | | tip.displayed |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |The tip was displayed |[1](https://github.com/mozilla-mobile/fenix/pull/9836)|<ul><li>identifier: The identifier of the tip displayed</li></ul>|2020-11-15 |2 |
| tip.pressed |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |The tip's button was pressed |[1](https://github.com/mozilla-mobile/fenix/pull/9836)|<ul><li>identifier: The identifier of the tip the action was taken on</li></ul>|2020-11-15 |2 | | tip.pressed |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |The tip's button was pressed |[1](https://github.com/mozilla-mobile/fenix/pull/9836)|<ul><li>identifier: The identifier of the tip the action was taken on</li></ul>|2020-11-15 |2 |
| toolbar_settings.changed_position |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |The user selected a new position for the toolbar |[1](https://github.com/mozilla-mobile/fenix/pull/6608)|<ul><li>position: A string that indicates the new position of the toolbar TOP or BOTTOM </li></ul>|2020-11-15 |2 | | toolbar_settings.changed_position |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |The user selected a new position for the toolbar |[1](https://github.com/mozilla-mobile/fenix/pull/6608)|<ul><li>position: A string that indicates the new position of the toolbar TOP or BOTTOM </li></ul>|2020-11-15 |2 |
| top_sites.long_press |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user long pressed on a top site |[1](https://github.com/mozilla-mobile/fenix/pull/15136)|<ul><li>type: The type of top site. Options are: "FRECENCY," "DEFAULT," or "PINNED." </li></ul>|2021-03-15 |2 |
| top_sites.open_default |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user opened a default top site |[1](https://github.com/mozilla-mobile/fenix/pull/10752)||2020-11-15 |2 | | top_sites.open_default |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user opened a default top site |[1](https://github.com/mozilla-mobile/fenix/pull/10752)||2020-11-15 |2 |
| top_sites.open_frecency |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user opened a frecency top site |[1](https://github.com/mozilla-mobile/fenix/pull/15136)||2021-03-15 |2 |
| top_sites.open_in_new_tab |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user opens a new tab based on a top site item |[1](https://github.com/mozilla-mobile/fenix/pull/7523)||2020-11-15 |2 | | top_sites.open_in_new_tab |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user opens a new tab based on a top site item |[1](https://github.com/mozilla-mobile/fenix/pull/7523)||2020-11-15 |2 |
| top_sites.open_in_private_tab |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user opens a new private tab based on a top site item |[1](https://github.com/mozilla-mobile/fenix/pull/7523)||2020-11-15 |2 | | top_sites.open_in_private_tab |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user opens a new private tab based on a top site item |[1](https://github.com/mozilla-mobile/fenix/pull/7523)||2020-11-15 |2 |
| top_sites.open_pinned |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user opened a pinned top site |[1](https://github.com/mozilla-mobile/fenix/pull/15136)||2021-03-15 |2 |
| top_sites.remove |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user removes a top site item |[1](https://github.com/mozilla-mobile/fenix/pull/7523)||2020-11-15 |2 | | top_sites.remove |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user removes a top site item |[1](https://github.com/mozilla-mobile/fenix/pull/7523)||2020-11-15 |2 |
| top_sites.swipe_carousel |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user swiped to change the page of the top sites carousel |[1](https://github.com/mozilla-mobile/fenix/pull/15136)|<ul><li>page: The page number the carousel is now on </li></ul>|2021-03-15 |2 |
| tracking_protection.etp_setting_changed |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user changed their tracking protection level setting to either strict, standard, or custom. |[1](https://github.com/mozilla-mobile/fenix/pull/5414#issuecomment-532847188), [2](https://github.com/mozilla-mobile/fenix/pull/11383)|<ul><li>etp_setting: The new setting for ETP: strict, standard, custom</li></ul>|2020-11-15 |2 | | tracking_protection.etp_setting_changed |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user changed their tracking protection level setting to either strict, standard, or custom. |[1](https://github.com/mozilla-mobile/fenix/pull/5414#issuecomment-532847188), [2](https://github.com/mozilla-mobile/fenix/pull/11383)|<ul><li>etp_setting: The new setting for ETP: strict, standard, custom</li></ul>|2020-11-15 |2 |
| tracking_protection.etp_settings |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user opened tracking protection settings through settings. |[1](https://github.com/mozilla-mobile/fenix/pull/5414#issuecomment-532847188)||2020-11-15 |2 | | tracking_protection.etp_settings |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user opened tracking protection settings through settings. |[1](https://github.com/mozilla-mobile/fenix/pull/5414#issuecomment-532847188)||2020-11-15 |2 |
| tracking_protection.etp_shield |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user pressed the tracking protection shield icon in toolbar. |[1](https://github.com/mozilla-mobile/fenix/pull/5414#issuecomment-532847188)||2020-11-15 |2 | | tracking_protection.etp_shield |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user pressed the tracking protection shield icon in toolbar. |[1](https://github.com/mozilla-mobile/fenix/pull/5414#issuecomment-532847188)||2020-11-15 |2 |

Loading…
Cancel
Save