1
0
Fork 0

Copione merged onto master
continuous-integration/drone/push Build is passing Details

master
blallo 2020-07-18 00:00:49 +02:00
commit 45aabf76c9
104 changed files with 2873 additions and 1036 deletions

View File

@ -2444,6 +2444,39 @@ logins:
notification_emails: notification_emails:
- fenix-core@mozilla.com - fenix-core@mozilla.com
expires: "2020-10-01" expires: "2020-10-01"
open_login_editor:
type: event
description: |
A user entered the edit screen for an individual saved login
bugs:
- https://github.com/mozilla-mobile/fenix/issues/10173
data_reviews:
- https://github.com/mozilla-mobile/fenix/issues/11208
notification_emails:
- fenix-core@mozilla.com
expires: "2020-10-01"
delete_saved_login:
type: event
description: |
A user confirms delete of a saved login
bugs:
- https://github.com/mozilla-mobile/fenix/issues/10173
data_reviews:
- https://github.com/mozilla-mobile/fenix/issues/11208
notification_emails:
- fenix-core@mozilla.com
expires: "2020-10-01"
save_edited_login:
type: event
description: |
A user saves changes made to an individual login
bugs:
- https://github.com/mozilla-mobile/fenix/issues/10173
data_reviews:
- https://github.com/mozilla-mobile/fenix/issues/11208
notification_emails:
- fenix-core@mozilla.com
expires: "2020-10-01"
download_notification: download_notification:
resume: resume:

View File

@ -159,6 +159,7 @@ class HistoryTest {
}.openHistory { }.openHistory {
}.openThreeDotMenu { }.openThreeDotMenu {
}.clickDelete { }.clickDelete {
verifyDeleteSnackbarText("Deleted")
verifyEmptyHistoryView() verifyEmptyHistoryView()
} }
} }
@ -175,6 +176,7 @@ class HistoryTest {
clickDeleteHistoryButton() clickDeleteHistoryButton()
verifyDeleteConfirmationMessage() verifyDeleteConfirmationMessage()
confirmDeleteAllHistory() confirmDeleteAllHistory()
verifyDeleteSnackbarText("Browsing data deleted")
verifyEmptyHistoryView() verifyEmptyHistoryView()
} }
} }

View File

@ -82,6 +82,8 @@ class HistoryRobot {
.click() .click()
} }
fun verifyDeleteSnackbarText(text: String) = assertSnackBarText(text)
class Transition { class Transition {
fun closeMenu(interact: HistoryRobot.() -> Unit): Transition { fun closeMenu(interact: HistoryRobot.() -> Unit): Transition {
closeButton().click() closeButton().click()
@ -158,3 +160,6 @@ private fun assertDeleteConfirmationMessage() =
.check(matches(isDisplayed())) .check(matches(isDisplayed()))
private fun assertCopySnackBarText() = snackBarText().check(matches(withText("URL copied"))) private fun assertCopySnackBarText() = snackBarText().check(matches(withText("URL copied")))
private fun assertSnackBarText(text: String) =
snackBarText().check(matches(withText(Matchers.containsString(text))))

View File

@ -26,6 +26,7 @@ import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.NavigationUI import androidx.navigation.ui.NavigationUI
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.gms.tasks.Tasks.call
import kotlinx.android.synthetic.main.activity_home.* import kotlinx.android.synthetic.main.activity_home.*
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -86,8 +87,8 @@ import org.mozilla.fenix.session.NotificationSessionObserver
import org.mozilla.fenix.settings.SettingsFragmentDirections import org.mozilla.fenix.settings.SettingsFragmentDirections
import org.mozilla.fenix.settings.TrackingProtectionFragmentDirections import org.mozilla.fenix.settings.TrackingProtectionFragmentDirections
import org.mozilla.fenix.settings.about.AboutFragmentDirections import org.mozilla.fenix.settings.about.AboutFragmentDirections
import org.mozilla.fenix.settings.logins.LoginDetailFragmentDirections import org.mozilla.fenix.settings.logins.fragment.SavedLoginsAuthFragmentDirections
import org.mozilla.fenix.settings.logins.SavedLoginsAuthFragmentDirections import org.mozilla.fenix.settings.logins.fragment.LoginDetailFragmentDirections
import org.mozilla.fenix.settings.search.AddSearchEngineFragmentDirections import org.mozilla.fenix.settings.search.AddSearchEngineFragmentDirections
import org.mozilla.fenix.settings.search.EditCustomSearchEngineFragmentDirections import org.mozilla.fenix.settings.search.EditCustomSearchEngineFragmentDirections
import org.mozilla.fenix.share.AddNewDeviceFragmentDirections import org.mozilla.fenix.share.AddNewDeviceFragmentDirections

View File

@ -8,13 +8,13 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Switch
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.Navigation import androidx.navigation.Navigation
import androidx.navigation.findNavController import androidx.navigation.findNavController
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import com.google.android.material.switchmaterial.SwitchMaterial
import kotlinx.android.synthetic.main.fragment_installed_add_on_details.view.* import kotlinx.android.synthetic.main.fragment_installed_add_on_details.view.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -301,7 +301,7 @@ class InstalledAddonDetailsFragment : Fragment() {
view.remove_add_on.isClickable = clickable view.remove_add_on.isClickable = clickable
} }
private fun Switch.setState(checked: Boolean) { private fun SwitchMaterial.setState(checked: Boolean) {
val text = if (checked) { val text = if (checked) {
R.string.mozac_feature_addons_enabled R.string.mozac_feature_addons_enabled
} else { } else {

View File

@ -40,7 +40,7 @@ import org.mozilla.fenix.ext.navigateSafe
import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.resetPoliciesAfter import org.mozilla.fenix.ext.resetPoliciesAfter
import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.shortcut.FirstTimePwaObserver import org.mozilla.fenix.shortcut.PwaOnboardingObserver
import org.mozilla.fenix.trackingprotection.TrackingProtectionOverlay import org.mozilla.fenix.trackingprotection.TrackingProtectionOverlay
/** /**
@ -156,9 +156,9 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler {
} }
session?.register(toolbarSessionObserver, viewLifecycleOwner, autoPause = true) session?.register(toolbarSessionObserver, viewLifecycleOwner, autoPause = true)
if (settings.shouldShowFirstTimePwaFragment) { if (!settings.userKnowsAboutPwas) {
session?.register( session?.register(
FirstTimePwaObserver( PwaOnboardingObserver(
navController = findNavController(), navController = findNavController(),
settings = settings, settings = settings,
webAppUseCases = context.components.useCases.webAppUseCases webAppUseCases = context.components.useCases.webAppUseCases

View File

@ -11,22 +11,24 @@ import androidx.core.view.isGone
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.collection_tab_list_row.view.* import kotlinx.android.synthetic.main.collection_tab_list_row.*
import org.mozilla.fenix.R import org.mozilla.fenix.R
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.Tab import org.mozilla.fenix.home.Tab
import org.mozilla.fenix.utils.view.ViewHolder
class CollectionCreationTabListAdapter( class CollectionCreationTabListAdapter(
private val interactor: CollectionCreationInteractor private val interactor: CollectionCreationInteractor
) : RecyclerView.Adapter<TabViewHolder>() { ) : RecyclerView.Adapter<TabViewHolder>() {
private var tabs: List<Tab> = listOf() private var tabs: List<Tab> = listOf()
private var selectedTabs: MutableSet<Tab> = mutableSetOf() private var selectedTabs: MutableSet<Tab> = mutableSetOf()
private var hideCheckboxes = false private var hideCheckboxes = false
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TabViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TabViewHolder {
val view = val view = LayoutInflater.from(parent.context)
LayoutInflater.from(parent.context).inflate(TabViewHolder.LAYOUT_ID, parent, false) .inflate(TabViewHolder.LAYOUT_ID, parent, false)
return TabViewHolder(view) return TabViewHolder(view)
} }
@ -39,11 +41,11 @@ class CollectionCreationTabListAdapter(
is CheckChanged -> { is CheckChanged -> {
val checkChanged = payloads[0] as CheckChanged val checkChanged = payloads[0] as CheckChanged
if (checkChanged.shouldBeChecked) { if (checkChanged.shouldBeChecked) {
holder.itemView.tab_selected_checkbox.isChecked = true holder.tab_selected_checkbox.isChecked = true
} else if (checkChanged.shouldBeUnchecked) { } else if (checkChanged.shouldBeUnchecked) {
holder.itemView.tab_selected_checkbox.isChecked = false holder.tab_selected_checkbox.isChecked = false
} }
holder.itemView.tab_selected_checkbox.isGone = checkChanged.shouldHideCheckBox holder.tab_selected_checkbox.isGone = checkChanged.shouldHideCheckBox
} }
} }
} }
@ -52,7 +54,7 @@ class CollectionCreationTabListAdapter(
override fun onBindViewHolder(holder: TabViewHolder, position: Int) { override fun onBindViewHolder(holder: TabViewHolder, position: Int) {
val tab = tabs[position] val tab = tabs[position]
val isSelected = selectedTabs.contains(tab) val isSelected = selectedTabs.contains(tab)
holder.itemView.tab_selected_checkbox.setOnCheckedChangeListener { _, isChecked -> holder.tab_selected_checkbox.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) { if (isChecked) {
selectedTabs.add(tab) selectedTabs.add(tab)
interactor.addTabToSelection(tab) interactor.addTabToSelection(tab)
@ -86,57 +88,24 @@ class CollectionCreationTabListAdapter(
} }
} }
private class TabDiffUtil( class TabViewHolder(view: View) : ViewHolder(view) {
val old: List<Tab>,
val new: List<Tab>,
val oldSelected: Set<Tab>,
val newSelected: Set<Tab>,
val oldHideCheckboxes: Boolean,
val newHideCheckboxes: Boolean
) : DiffUtil.Callback() {
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
old[oldItemPosition].sessionId == new[newItemPosition].sessionId
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val isSameTab = old[oldItemPosition].sessionId == new[newItemPosition].sessionId
val sameSelectedState = oldSelected.contains(old[oldItemPosition]) == newSelected.contains(new[newItemPosition])
val isSameHideCheckboxes = oldHideCheckboxes == newHideCheckboxes
return isSameTab && sameSelectedState && isSameHideCheckboxes
}
override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? {
val shouldBeChecked = newSelected.contains(new[newItemPosition]) && !oldSelected.contains(old[oldItemPosition])
val shouldBeUnchecked =
!newSelected.contains(new[newItemPosition]) && oldSelected.contains(old[oldItemPosition])
return CheckChanged(shouldBeChecked, shouldBeUnchecked, newHideCheckboxes)
}
override fun getOldListSize(): Int = old.size
override fun getNewListSize(): Int = new.size
}
data class CheckChanged(val shouldBeChecked: Boolean, val shouldBeUnchecked: Boolean, val shouldHideCheckBox: Boolean)
class TabViewHolder(view: View) : RecyclerView.ViewHolder(view) {
private val checkbox = view.tab_selected_checkbox!!
init { init {
view.collection_item_tab.setOnClickListener { collection_item_tab.setOnClickListener {
checkbox.isChecked = !checkbox.isChecked tab_selected_checkbox.isChecked = !tab_selected_checkbox.isChecked
} }
} }
fun bind(tab: Tab, isSelected: Boolean, shouldHideCheckBox: Boolean) { fun bind(tab: Tab, isSelected: Boolean, shouldHideCheckBox: Boolean) {
itemView.hostname.text = tab.hostname hostname.text = tab.hostname
itemView.tab_title.text = tab.title tab_title.text = tab.title
checkbox.isInvisible = shouldHideCheckBox tab_selected_checkbox.isInvisible = shouldHideCheckBox
itemView.isClickable = !shouldHideCheckBox itemView.isClickable = !shouldHideCheckBox
if (checkbox.isChecked != isSelected) { if (tab_selected_checkbox.isChecked != isSelected) {
checkbox.isChecked = isSelected tab_selected_checkbox.isChecked = isSelected
} }
itemView.context.components.core.icons.loadIntoView(itemView.favicon_image, tab.url) itemView.context.components.core.icons.loadIntoView(favicon_image, tab.url)
} }
companion object { companion object {

View File

@ -10,12 +10,13 @@ import android.view.ViewGroup
import androidx.core.graphics.BlendModeColorFilterCompat.createBlendModeColorFilterCompat import androidx.core.graphics.BlendModeColorFilterCompat.createBlendModeColorFilterCompat
import androidx.core.graphics.BlendModeCompat.SRC_IN import androidx.core.graphics.BlendModeCompat.SRC_IN
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.collections_list_item.view.* import kotlinx.android.synthetic.main.collections_list_item.*
import mozilla.components.feature.tab.collections.TabCollection import mozilla.components.feature.tab.collections.TabCollection
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.components.description import org.mozilla.fenix.components.description
import org.mozilla.fenix.ext.getIconColor import org.mozilla.fenix.ext.getIconColor
import org.mozilla.fenix.home.Tab import org.mozilla.fenix.home.Tab
import org.mozilla.fenix.utils.view.ViewHolder
class SaveCollectionListAdapter( class SaveCollectionListAdapter(
private val interactor: CollectionCreationInteractor private val interactor: CollectionCreationInteractor
@ -48,12 +49,12 @@ class SaveCollectionListAdapter(
} }
} }
class CollectionViewHolder(view: View) : RecyclerView.ViewHolder(view) { class CollectionViewHolder(view: View) : ViewHolder(view) {
fun bind(collection: TabCollection) { fun bind(collection: TabCollection) {
itemView.collection_item.text = collection.title collection_item.text = collection.title
itemView.collection_description.text = collection.description(itemView.context) collection_description.text = collection.description(itemView.context)
itemView.collection_icon.colorFilter = collection_icon.colorFilter =
createBlendModeColorFilterCompat(collection.getIconColor(itemView.context), SRC_IN) createBlendModeColorFilterCompat(collection.getIconColor(itemView.context), SRC_IN)
} }

View File

@ -0,0 +1,63 @@
/* 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.collections
import androidx.recyclerview.widget.DiffUtil
import org.mozilla.fenix.home.Tab
/**
* Diff callback for comparing tab lists with selected state.
*/
internal class TabDiffUtil(
private val old: List<Tab>,
private val new: List<Tab>,
private val oldSelected: Set<Tab>,
private val newSelected: Set<Tab>,
private val oldHideCheckboxes: Boolean,
private val newHideCheckboxes: Boolean
) : DiffUtil.Callback() {
/**
* Checks if the tabs in the given positions refer to the same tab (based on ID).
*/
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
old[oldItemPosition].sessionId == new[newItemPosition].sessionId
/**
* Checks if the combination of tab ID, selection, and checkbox visibility is the same.
*/
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val isSameTab = old[oldItemPosition].sessionId == new[newItemPosition].sessionId
val sameSelectedState = oldItemSelected(oldItemPosition) == newItemSelected(newItemPosition)
val isSameHideCheckboxes = oldHideCheckboxes == newHideCheckboxes
return isSameTab && sameSelectedState && isSameHideCheckboxes
}
/**
* Returns a change payload indication if the item is now/no longer selected.
*/
override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? {
val shouldBeChecked = newItemSelected(newItemPosition) && !oldItemSelected(oldItemPosition)
val shouldBeUnchecked = !newItemSelected(newItemPosition) && oldItemSelected(oldItemPosition)
return CheckChanged(shouldBeChecked, shouldBeUnchecked, newHideCheckboxes)
}
override fun getOldListSize(): Int = old.size
override fun getNewListSize(): Int = new.size
private fun oldItemSelected(oldItemPosition: Int) = oldSelected.contains(old[oldItemPosition])
private fun newItemSelected(newItemPosition: Int) = newSelected.contains(new[newItemPosition])
}
/**
* @property shouldBeChecked Item was previously unchecked and should be checked.
* @property shouldBeUnchecked Item was previously checked and should be unchecked.
* @property shouldHideCheckBox Checkbox should be visible.
*/
data class CheckChanged(
val shouldBeChecked: Boolean,
val shouldBeUnchecked: Boolean,
val shouldHideCheckBox: Boolean
)

View File

@ -63,7 +63,7 @@ class UseCases(
val downloadUseCases by lazy { DownloadsUseCases(store) } val downloadUseCases by lazy { DownloadsUseCases(store) }
val contextMenuUseCases by lazy { ContextMenuUseCases(sessionManager, store) } val contextMenuUseCases by lazy { ContextMenuUseCases(store) }
val engineSessionUseCases by lazy { EngineSessionUseCases(sessionManager) } val engineSessionUseCases by lazy { EngineSessionUseCases(sessionManager) }

View File

@ -466,6 +466,15 @@ private val Event.wrapper: EventWrapper<*>?
is Event.ViewLoginPassword -> EventWrapper<NoExtraKeys>( is Event.ViewLoginPassword -> EventWrapper<NoExtraKeys>(
{ Logins.viewPasswordLogin.record(it) } { Logins.viewPasswordLogin.record(it) }
) )
is Event.DeleteLogin -> EventWrapper<NoExtraKeys>(
{ Logins.deleteSavedLogin.record(it) }
)
is Event.EditLogin -> EventWrapper<NoExtraKeys>(
{ Logins.openLoginEditor.record(it) }
)
is Event.EditLoginSave -> EventWrapper<NoExtraKeys>(
{ Logins.saveEditedLogin.record(it) }
)
is Event.PrivateBrowsingShowSearchSuggestions -> EventWrapper<NoExtraKeys>( is Event.PrivateBrowsingShowSearchSuggestions -> EventWrapper<NoExtraKeys>(
{ SearchSuggestions.enableInPrivate.record(it) } { SearchSuggestions.enableInPrivate.record(it) }
) )

View File

@ -154,6 +154,9 @@ sealed class Event {
object OpenLogins : Event() object OpenLogins : Event()
object OpenOneLogin : Event() object OpenOneLogin : Event()
object CopyLogin : Event() object CopyLogin : Event()
object DeleteLogin : Event()
object EditLogin : Event()
object EditLoginSave : Event()
object ViewLoginPassword : Event() object ViewLoginPassword : Event()
object CustomEngineAdded : Event() object CustomEngineAdded : Event()
object CustomEngineDeleted : Event() object CustomEngineDeleted : Event()

View File

@ -98,7 +98,6 @@ import org.mozilla.fenix.home.sessioncontrol.SessionControlView
import org.mozilla.fenix.home.sessioncontrol.viewholders.CollectionViewHolder import org.mozilla.fenix.home.sessioncontrol.viewholders.CollectionViewHolder
import org.mozilla.fenix.onboarding.FenixOnboarding import org.mozilla.fenix.onboarding.FenixOnboarding
import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.settings.SupportUtils
import org.mozilla.fenix.settings.SupportUtils.MozillaPage.PRIVATE_NOTICE
import org.mozilla.fenix.settings.SupportUtils.SumoTopic.HELP import org.mozilla.fenix.settings.SupportUtils.SumoTopic.HELP
import org.mozilla.fenix.settings.deletebrowsingdata.deleteAndQuit import org.mozilla.fenix.settings.deletebrowsingdata.deleteAndQuit
import org.mozilla.fenix.tabtray.TabTrayDialogFragment import org.mozilla.fenix.tabtray.TabTrayDialogFragment
@ -175,6 +174,7 @@ class HomeFragment : Fragment() {
): View? { ): View? {
val view = inflater.inflate(R.layout.fragment_home, container, false) val view = inflater.inflate(R.layout.fragment_home, container, false)
val activity = activity as HomeActivity val activity = activity as HomeActivity
val components = requireComponents
currentMode = CurrentMode( currentMode = CurrentMode(
view.context, view.context,
@ -186,11 +186,11 @@ class HomeFragment : Fragment() {
homeFragmentStore = StoreProvider.get(this) { homeFragmentStore = StoreProvider.get(this) {
HomeFragmentStore( HomeFragmentStore(
HomeFragmentState( HomeFragmentState(
collections = requireComponents.core.tabCollectionStorage.cachedTabCollections, collections = components.core.tabCollectionStorage.cachedTabCollections,
expandedCollections = emptySet(), expandedCollections = emptySet(),
mode = currentMode.getCurrentMode(), mode = currentMode.getCurrentMode(),
topSites = StrictMode.allowThreadDiskReads().resetPoliciesAfter { topSites = StrictMode.allowThreadDiskReads().resetPoliciesAfter {
requireComponents.core.topSiteStorage.cachedTopSites components.core.topSiteStorage.cachedTopSites
}, },
tip = FenixTipManager(listOf(MigrationTipProvider(requireContext()))).getTip() tip = FenixTipManager(listOf(MigrationTipProvider(requireContext()))).getTip()
) )
@ -200,16 +200,18 @@ class HomeFragment : Fragment() {
_sessionControlInteractor = SessionControlInteractor( _sessionControlInteractor = SessionControlInteractor(
DefaultSessionControlController( DefaultSessionControlController(
activity = activity, activity = activity,
engine = components.core.engine,
metrics = components.analytics.metrics,
sessionManager = sessionManager,
tabCollectionStorage = components.core.tabCollectionStorage,
topSiteStorage = components.core.topSiteStorage,
addTabUseCase = components.useCases.tabsUseCases.addTab,
fragmentStore = homeFragmentStore, fragmentStore = homeFragmentStore,
navController = findNavController(), navController = findNavController(),
viewLifecycleScope = viewLifecycleOwner.lifecycleScope, viewLifecycleScope = viewLifecycleOwner.lifecycleScope,
getListOfTabs = ::getListOfTabs,
hideOnboarding = ::hideOnboardingAndOpenSearch, hideOnboarding = ::hideOnboardingAndOpenSearch,
registerCollectionStorageObserver = ::registerCollectionStorageObserver, registerCollectionStorageObserver = ::registerCollectionStorageObserver,
showDeleteCollectionPrompt = ::showDeleteCollectionPrompt, showDeleteCollectionPrompt = ::showDeleteCollectionPrompt,
openSettingsScreen = ::openSettingsScreen,
openWhatsNewLink = { openInNormalTab(SupportUtils.getWhatsNewUrl(activity)) },
openPrivacyNotice = { openInNormalTab(SupportUtils.getMozillaPageUrl(PRIVATE_NOTICE)) },
showTabTray = ::openTabTray showTabTray = ::openTabTray
) )
) )
@ -611,11 +613,6 @@ class HomeFragment : Fragment() {
nav(R.id.homeFragment, directions, getToolbarNavOptions(requireContext())) nav(R.id.homeFragment, directions, getToolbarNavOptions(requireContext()))
} }
private fun openSettingsScreen() {
val directions = HomeFragmentDirections.actionGlobalPrivateBrowsingFragment()
nav(R.id.homeFragment, directions)
}
private fun openInNormalTab(url: String) { private fun openInNormalTab(url: String) {
(activity as HomeActivity).openToBrowserAndLoad( (activity as HomeActivity).openToBrowserAndLoad(
searchTermOrURL = url, searchTermOrURL = url,
@ -767,13 +764,8 @@ class HomeFragment : Fragment() {
} }
} }
private fun getListOfSessions(private: Boolean = browsingModeManager.mode.isPrivate): List<Session> { private fun getNumberOfSessions(private: Boolean = browsingModeManager.mode.isPrivate): Int {
return sessionManager.sessionsOfType(private = private) return sessionManager.sessionsOfType(private = private).count()
.toList()
}
private fun getListOfTabs(): List<Tab> {
return getListOfSessions().toTabs()
} }
private fun registerCollectionStorageObserver() { private fun registerCollectionStorageObserver() {
@ -787,7 +779,7 @@ class HomeFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val recyclerView = sessionControlView!!.view val recyclerView = sessionControlView!!.view
delay(ANIM_SCROLL_DELAY) delay(ANIM_SCROLL_DELAY)
val tabsSize = getListOfSessions().size val tabsSize = getNumberOfSessions()
var indexOfCollection = tabsSize + NON_TAB_ITEM_NUM var indexOfCollection = tabsSize + NON_TAB_ITEM_NUM
changedCollection?.let { changedCollection -> changedCollection?.let { changedCollection ->

View File

@ -9,9 +9,11 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mozilla.components.browser.session.SessionManager import mozilla.components.browser.session.SessionManager
import mozilla.components.concept.engine.Engine
import mozilla.components.concept.engine.prompt.ShareData import mozilla.components.concept.engine.prompt.ShareData
import mozilla.components.feature.tab.collections.TabCollection import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.tab.collections.ext.restore import mozilla.components.feature.tab.collections.ext.restore
import mozilla.components.feature.tabs.TabsUseCases
import mozilla.components.feature.top.sites.TopSite import mozilla.components.feature.top.sites.TopSite
import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.HomeActivity
@ -23,13 +25,12 @@ import org.mozilla.fenix.components.TopSiteStorage
import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.components.tips.Tip import org.mozilla.fenix.components.tips.Tip
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.nav import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.ext.sessionsOfType
import org.mozilla.fenix.home.HomeFragment import org.mozilla.fenix.home.HomeFragment
import org.mozilla.fenix.home.HomeFragmentAction import org.mozilla.fenix.home.HomeFragmentAction
import org.mozilla.fenix.home.HomeFragmentDirections import org.mozilla.fenix.home.HomeFragmentDirections
import org.mozilla.fenix.home.HomeFragmentStore import org.mozilla.fenix.home.HomeFragmentStore
import org.mozilla.fenix.home.Tab
import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.settings.SupportUtils
import mozilla.components.feature.tab.collections.Tab as ComponentTab import mozilla.components.feature.tab.collections.Tab as ComponentTab
@ -130,26 +131,20 @@ interface SessionControlController {
@SuppressWarnings("TooManyFunctions", "LargeClass") @SuppressWarnings("TooManyFunctions", "LargeClass")
class DefaultSessionControlController( class DefaultSessionControlController(
private val activity: HomeActivity, private val activity: HomeActivity,
private val engine: Engine,
private val metrics: MetricController,
private val sessionManager: SessionManager,
private val tabCollectionStorage: TabCollectionStorage,
private val topSiteStorage: TopSiteStorage,
private val addTabUseCase: TabsUseCases.AddNewTabUseCase,
private val fragmentStore: HomeFragmentStore, private val fragmentStore: HomeFragmentStore,
private val navController: NavController, private val navController: NavController,
private val viewLifecycleScope: CoroutineScope, private val viewLifecycleScope: CoroutineScope,
private val getListOfTabs: () -> List<Tab>,
private val hideOnboarding: () -> Unit, private val hideOnboarding: () -> Unit,
private val registerCollectionStorageObserver: () -> Unit, private val registerCollectionStorageObserver: () -> Unit,
private val showDeleteCollectionPrompt: (tabCollection: TabCollection, title: String?, message: String) -> Unit, private val showDeleteCollectionPrompt: (tabCollection: TabCollection, title: String?, message: String) -> Unit,
private val openSettingsScreen: () -> Unit,
private val openWhatsNewLink: () -> Unit,
private val openPrivacyNotice: () -> Unit,
private val showTabTray: () -> Unit private val showTabTray: () -> Unit
) : SessionControlController { ) : SessionControlController {
private val metrics: MetricController
get() = activity.components.analytics.metrics
private val sessionManager: SessionManager
get() = activity.components.core.sessionManager
private val tabCollectionStorage: TabCollectionStorage
get() = activity.components.core.tabCollectionStorage
private val topSiteStorage: TopSiteStorage
get() = activity.components.core.topSiteStorage
override fun handleCollectionAddTabTapped(collection: TabCollection) { override fun handleCollectionAddTabTapped(collection: TabCollection) {
metrics.track(Event.CollectionAddTabPressed) metrics.track(Event.CollectionAddTabPressed)
@ -162,7 +157,7 @@ class DefaultSessionControlController(
override fun handleCollectionOpenTabClicked(tab: ComponentTab) { override fun handleCollectionOpenTabClicked(tab: ComponentTab) {
sessionManager.restore( sessionManager.restore(
activity, activity,
activity.components.core.engine, engine,
tab, tab,
onTabRestored = { onTabRestored = {
activity.openToBrowser(BrowserDirection.FromHome) activity.openToBrowser(BrowserDirection.FromHome)
@ -182,10 +177,10 @@ class DefaultSessionControlController(
override fun handleCollectionOpenTabsTapped(collection: TabCollection) { override fun handleCollectionOpenTabsTapped(collection: TabCollection) {
sessionManager.restore( sessionManager.restore(
activity, activity,
activity.components.core.engine, engine,
collection, collection,
onFailure = { url -> onFailure = { url ->
activity.components.useCases.tabsUseCases.addTab.invoke(url) addTabUseCase.invoke(url)
} }
) )
@ -261,7 +256,7 @@ class DefaultSessionControlController(
metrics.track(Event.TopSiteOpenInNewTab) metrics.track(Event.TopSiteOpenInNewTab)
if (isDefault) { metrics.track(Event.TopSiteOpenDefault) } if (isDefault) { metrics.track(Event.TopSiteOpenDefault) }
if (url == SupportUtils.POCKET_TRENDING_URL) { metrics.track(Event.PocketTopSiteClicked) } if (url == SupportUtils.POCKET_TRENDING_URL) { metrics.track(Event.PocketTopSiteClicked) }
activity.components.useCases.tabsUseCases.addTab.invoke( addTabUseCase.invoke(
url = url, url = url,
selectTab = true, selectTab = true,
startLoading = true startLoading = true
@ -274,15 +269,24 @@ class DefaultSessionControlController(
} }
override fun handleOpenSettingsClicked() { override fun handleOpenSettingsClicked() {
openSettingsScreen() val directions = HomeFragmentDirections.actionGlobalPrivateBrowsingFragment()
navController.nav(R.id.homeFragment, directions)
} }
override fun handleWhatsNewGetAnswersClicked() { override fun handleWhatsNewGetAnswersClicked() {
openWhatsNewLink() activity.openToBrowserAndLoad(
searchTermOrURL = SupportUtils.getWhatsNewUrl(activity),
newTab = true,
from = BrowserDirection.FromHome
)
} }
override fun handleReadPrivacyNoticeClicked() { override fun handleReadPrivacyNoticeClicked() {
openPrivacyNotice() activity.openToBrowserAndLoad(
searchTermOrURL = SupportUtils.getMozillaPageUrl(SupportUtils.MozillaPage.PRIVATE_NOTICE),
newTab = true,
from = BrowserDirection.FromHome
)
} }
override fun handleToggleCollectionExpanded(collection: TabCollection, expand: Boolean) { override fun handleToggleCollectionExpanded(collection: TabCollection, expand: Boolean) {
@ -303,7 +307,11 @@ class DefaultSessionControlController(
// Only register the observer right before moving to collection creation // Only register the observer right before moving to collection creation
registerCollectionStorageObserver() registerCollectionStorageObserver()
val tabIds = getListOfTabs().map { it.sessionId }.toTypedArray() val tabIds = sessionManager
.sessionsOfType(private = activity.browsingModeManager.mode.isPrivate)
.map { session -> session.id }
.toList()
.toTypedArray()
val directions = HomeFragmentDirections.actionGlobalCollectionCreationFragment( val directions = HomeFragmentDirections.actionGlobalCollectionCreationFragment(
tabIds = tabIds, tabIds = tabIds,
saveCollectionStep = step, saveCollectionStep = step,

View File

@ -42,7 +42,7 @@ interface BookmarkController {
fun handleBookmarkSharing(item: BookmarkNode) fun handleBookmarkSharing(item: BookmarkNode)
fun handleOpeningBookmark(item: BookmarkNode, mode: BrowsingMode) fun handleOpeningBookmark(item: BookmarkNode, mode: BrowsingMode)
fun handleBookmarkDeletion(nodes: Set<BookmarkNode>, eventType: Event) fun handleBookmarkDeletion(nodes: Set<BookmarkNode>, eventType: Event)
fun handleBookmarkFolderDeletion(node: BookmarkNode) fun handleBookmarkFolderDeletion(nodes: Set<BookmarkNode>)
fun handleRequestSync() fun handleRequestSync()
fun handleBackPressed() fun handleBackPressed()
} }
@ -58,7 +58,7 @@ class DefaultBookmarkController(
private val loadBookmarkNode: suspend (String) -> BookmarkNode?, private val loadBookmarkNode: suspend (String) -> BookmarkNode?,
private val showSnackbar: (String) -> Unit, private val showSnackbar: (String) -> Unit,
private val deleteBookmarkNodes: (Set<BookmarkNode>, Event) -> Unit, private val deleteBookmarkNodes: (Set<BookmarkNode>, Event) -> Unit,
private val deleteBookmarkFolder: (BookmarkNode) -> Unit, private val deleteBookmarkFolder: (Set<BookmarkNode>) -> Unit,
private val invokePendingDeletion: () -> Unit private val invokePendingDeletion: () -> Unit
) : BookmarkController { ) : BookmarkController {
@ -133,8 +133,8 @@ class DefaultBookmarkController(
deleteBookmarkNodes(nodes, eventType) deleteBookmarkNodes(nodes, eventType)
} }
override fun handleBookmarkFolderDeletion(node: BookmarkNode) { override fun handleBookmarkFolderDeletion(nodes: Set<BookmarkNode>) {
deleteBookmarkFolder(node) deleteBookmarkFolder(nodes)
} }
override fun handleRequestSync() { override fun handleRequestSync() {

View File

@ -283,13 +283,17 @@ class BookmarkFragment : LibraryPageFragment<BookmarkNode>(), UserInteractionHan
} }
private fun deleteMulti(selected: Set<BookmarkNode>, eventType: Event = Event.RemoveBookmarks) { private fun deleteMulti(selected: Set<BookmarkNode>, eventType: Event = Event.RemoveBookmarks) {
selected.forEach { if (it.type == BookmarkNodeType.FOLDER) {
showRemoveFolderDialog(selected)
return
} }
updatePendingBookmarksToDelete(selected) updatePendingBookmarksToDelete(selected)
pendingBookmarkDeletionJob = getDeleteOperation(eventType) pendingBookmarkDeletionJob = getDeleteOperation(eventType)
val message = when (eventType) { val message = when (eventType) {
is Event.RemoveBookmarks -> { is Event.RemoveBookmarks -> {
getRemoveBookmarksSnackBarMessage(selected) getRemoveBookmarksSnackBarMessage(selected, containsFolders = false)
} }
is Event.RemoveBookmarkFolder, is Event.RemoveBookmarkFolder,
is Event.RemoveBookmark -> { is Event.RemoveBookmark -> {
@ -310,9 +314,16 @@ class BookmarkFragment : LibraryPageFragment<BookmarkNode>(), UserInteractionHan
) )
} }
private fun getRemoveBookmarksSnackBarMessage(selected: Set<BookmarkNode>): String { private fun getRemoveBookmarksSnackBarMessage(
selected: Set<BookmarkNode>,
containsFolders: Boolean
): String {
return if (selected.size > 1) { return if (selected.size > 1) {
getString(R.string.bookmark_deletion_multiple_snackbar_message_2) return if (containsFolders) {
getString(R.string.bookmark_deletion_multiple_snackbar_message_3)
} else {
getString(R.string.bookmark_deletion_multiple_snackbar_message_2)
}
} else { } else {
val bookmarkNode = selected.first() val bookmarkNode = selected.first()
getString( getString(
@ -323,29 +334,38 @@ class BookmarkFragment : LibraryPageFragment<BookmarkNode>(), UserInteractionHan
} }
} }
private fun getDialogConfirmationMessage(selected: Set<BookmarkNode>): String {
return if (selected.size > 1) {
getString(R.string.bookmark_delete_multiple_folders_confirmation_dialog, getString(R.string.app_name))
} else {
getString(R.string.bookmark_delete_folder_confirmation_dialog)
}
}
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_bookmarkInteractor = null _bookmarkInteractor = null
} }
private fun showRemoveFolderDialog(selected: BookmarkNode) { private fun showRemoveFolderDialog(selected: Set<BookmarkNode>) {
activity?.let { activity -> activity?.let { activity ->
AlertDialog.Builder(activity).apply { AlertDialog.Builder(activity).apply {
setMessage(R.string.bookmark_delete_folder_confirmation_dialog) val dialogConfirmationMessage = getDialogConfirmationMessage(selected)
setMessage(dialogConfirmationMessage)
setNegativeButton(R.string.delete_browsing_data_prompt_cancel) { dialog: DialogInterface, _ -> setNegativeButton(R.string.delete_browsing_data_prompt_cancel) { dialog: DialogInterface, _ ->
dialog.cancel() dialog.cancel()
} }
setPositiveButton(R.string.delete_browsing_data_prompt_allow) { dialog: DialogInterface, _ -> setPositiveButton(R.string.delete_browsing_data_prompt_allow) { dialog: DialogInterface, _ ->
updatePendingBookmarksToDelete(setOf(selected)) updatePendingBookmarksToDelete(selected)
pendingBookmarkDeletionJob = getDeleteOperation(Event.RemoveBookmarkFolder) pendingBookmarkDeletionJob = getDeleteOperation(Event.RemoveBookmarkFolder)
dialog.dismiss() dialog.dismiss()
val message = getDeleteDialogString(selected) val snackbarMessage = getRemoveBookmarksSnackBarMessage(selected, containsFolders = true)
viewLifecycleOwner.lifecycleScope.allowUndo( viewLifecycleOwner.lifecycleScope.allowUndo(
requireView(), requireView(),
message, snackbarMessage,
getString(R.string.bookmark_undo_deletion), getString(R.string.bookmark_undo_deletion),
{ {
undoPendingDeletion(setOf(selected)) undoPendingDeletion(selected)
}, },
operation = getDeleteOperation(Event.RemoveBookmarkFolder) operation = getDeleteOperation(Event.RemoveBookmarkFolder)
) )
@ -362,14 +382,6 @@ class BookmarkFragment : LibraryPageFragment<BookmarkNode>(), UserInteractionHan
bookmarkInteractor.onBookmarksChanged(bookmarkTree) bookmarkInteractor.onBookmarksChanged(bookmarkTree)
} }
private fun getDeleteDialogString(selected: BookmarkNode): String {
return getString(
R.string.bookmark_deletion_snackbar_message,
context?.components?.publicSuffixList?.let { selected.url?.toShortUrl(it) }
?: selected.title
)
}
private suspend fun undoPendingDeletion(selected: Set<BookmarkNode>) { private suspend fun undoPendingDeletion(selected: Set<BookmarkNode>) {
pendingBookmarksToDelete.removeAll(selected) pendingBookmarksToDelete.removeAll(selected)
pendingBookmarkDeletionJob = null pendingBookmarkDeletionJob = null

View File

@ -88,7 +88,7 @@ class BookmarkFragmentInteractor(
null -> Event.RemoveBookmarks null -> Event.RemoveBookmarks
} }
if (eventType == Event.RemoveBookmarkFolder) { if (eventType == Event.RemoveBookmarkFolder) {
bookmarksController.handleBookmarkFolderDeletion(nodes.first()) bookmarksController.handleBookmarkFolderDeletion(nodes)
} else { } else {
bookmarksController.handleBookmarkDeletion(nodes, eventType) bookmarksController.handleBookmarkDeletion(nodes, eventType)
} }

View File

@ -33,6 +33,8 @@ class HistoryAdapter(
private var mode: HistoryFragmentState.Mode = HistoryFragmentState.Mode.Normal private var mode: HistoryFragmentState.Mode = HistoryFragmentState.Mode.Normal
override val selectedItems get() = mode.selectedItems override val selectedItems get() = mode.selectedItems
var pendingDeletionIds = emptySet<Long>()
private val itemsWithHeaders: MutableMap<HistoryItemTimeGroup, Int> = mutableMapOf()
override fun getItemViewType(position: Int): Int = HistoryListItemViewHolder.LAYOUT_ID override fun getItemViewType(position: Int): Int = HistoryListItemViewHolder.LAYOUT_ID
@ -48,13 +50,33 @@ class HistoryAdapter(
} }
override fun onBindViewHolder(holder: HistoryListItemViewHolder, position: Int) { override fun onBindViewHolder(holder: HistoryListItemViewHolder, position: Int) {
val previous = if (position == 0) null else getItem(position - 1)
val current = getItem(position) ?: return val current = getItem(position) ?: return
val headerForCurrentItem = timeGroupForHistoryItem(current)
val isPendingDeletion = pendingDeletionIds.contains(current.visitedAt)
var timeGroup: HistoryItemTimeGroup? = null
val previousHeader = previous?.let(::timeGroupForHistoryItem) // Add or remove the header and position to the map depending on it's deletion status
val currentHeader = timeGroupForHistoryItem(current) if (itemsWithHeaders.containsKey(headerForCurrentItem)) {
val timeGroup = if (currentHeader != previousHeader) currentHeader else null if (isPendingDeletion && itemsWithHeaders[headerForCurrentItem] == position) {
holder.bind(current, timeGroup, position == 0, mode) itemsWithHeaders.remove(headerForCurrentItem)
} else if (isPendingDeletion && itemsWithHeaders[headerForCurrentItem] != position) {
// do nothing
} else {
if (position <= itemsWithHeaders[headerForCurrentItem] as Int) {
itemsWithHeaders[headerForCurrentItem] = position
timeGroup = headerForCurrentItem
}
}
} else if (!isPendingDeletion) {
itemsWithHeaders[headerForCurrentItem] = position
timeGroup = headerForCurrentItem
}
holder.bind(current, timeGroup, position == 0, mode, isPendingDeletion)
}
fun updatePendingDeletionIds(pendingDeletionIds: Set<Long>) {
this.pendingDeletionIds = pendingDeletionIds
} }
companion object { companion object {

View File

@ -17,8 +17,11 @@ import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavDirections
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import kotlinx.android.synthetic.main.fragment_history.view.* import kotlinx.android.synthetic.main.fragment_history.view.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -31,7 +34,6 @@ import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.addons.showSnackBar import org.mozilla.fenix.addons.showSnackBar
import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.components.Components
import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.components.history.createSynchronousPagedHistoryProvider import org.mozilla.fenix.components.history.createSynchronousPagedHistoryProvider
@ -42,6 +44,7 @@ import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.ext.toShortUrl import org.mozilla.fenix.ext.toShortUrl
import org.mozilla.fenix.library.LibraryPageFragment import org.mozilla.fenix.library.LibraryPageFragment
import org.mozilla.fenix.utils.allowUndo
@SuppressWarnings("TooManyFunctions", "LargeClass") @SuppressWarnings("TooManyFunctions", "LargeClass")
class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandler { class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandler {
@ -49,6 +52,8 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
private lateinit var historyView: HistoryView private lateinit var historyView: HistoryView
private lateinit var historyInteractor: HistoryInteractor private lateinit var historyInteractor: HistoryInteractor
private lateinit var viewModel: HistoryViewModel private lateinit var viewModel: HistoryViewModel
private var undoScope: CoroutineScope? = null
private var pendingHistoryDeletionJob: (suspend () -> Unit)? = null
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -59,7 +64,10 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
historyStore = StoreProvider.get(this) { historyStore = StoreProvider.get(this) {
HistoryFragmentStore( HistoryFragmentStore(
HistoryFragmentState( HistoryFragmentState(
items = listOf(), mode = HistoryFragmentState.Mode.Normal items = listOf(),
mode = HistoryFragmentState.Mode.Normal,
pendingDeletionIds = emptySet(),
isDeletingItems = false
) )
) )
} }
@ -111,18 +119,18 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
} }
private fun deleteHistoryItems(items: Set<HistoryItem>) { private fun deleteHistoryItems(items: Set<HistoryItem>) {
val message = getMultiSelectSnackBarMessage(items)
viewLifecycleOwner.lifecycleScope.launch { updatePendingHistoryToDelete(items)
context?.components?.run { undoScope = CoroutineScope(IO)
for (item in items) { undoScope?.allowUndo(
analytics.metrics.track(Event.HistoryItemRemoved) requireView(),
core.historyStorage.deleteVisit(item.url, item.visitedAt) getMultiSelectSnackBarMessage(items),
} getString(R.string.bookmark_undo_deletion),
} {
viewModel.invalidate() undoPendingDeletion(items)
showSnackBar(requireView(), message) },
historyStore.dispatch(HistoryFragmentAction.ExitDeletionMode) getDeleteHistoryItemsOperation(items)
} )
} }
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
@ -146,8 +154,8 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
val menuRes = when (historyStore.state.mode) { val menuRes = when (historyStore.state.mode) {
HistoryFragmentState.Mode.Normal -> R.menu.library_menu HistoryFragmentState.Mode.Normal -> R.menu.library_menu
is HistoryFragmentState.Mode.Syncing -> R.menu.library_menu
is HistoryFragmentState.Mode.Editing -> R.menu.history_select_multi is HistoryFragmentState.Mode.Editing -> R.menu.history_select_multi
else -> return
} }
inflater.inflate(menuRes, menu) inflater.inflate(menuRes, menu)
@ -166,13 +174,8 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
true true
} }
R.id.delete_history_multi_select -> { R.id.delete_history_multi_select -> {
val message = getMultiSelectSnackBarMessage(selectedItems) deleteHistoryItems(historyStore.state.mode.selectedItems)
viewLifecycleOwner.lifecycleScope.launch(Main) { historyStore.dispatch(HistoryFragmentAction.ExitEditMode)
deleteSelectedHistory(historyStore.state.mode.selectedItems, requireComponents)
viewModel.invalidate()
historyStore.dispatch(HistoryFragmentAction.ExitDeletionMode)
showSnackBar(requireView(), message)
}
true true
} }
R.id.open_history_in_new_tabs_multi_select -> { R.id.open_history_in_new_tabs_multi_select -> {
@ -181,8 +184,7 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
selectedItem.url selectedItem.url
} }
nav( navigate(
R.id.historyFragment,
HistoryFragmentDirections.actionGlobalTabTrayDialogFragment() HistoryFragmentDirections.actionGlobalTabTrayDialogFragment()
) )
true true
@ -197,8 +199,7 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
browsingModeManager.mode = BrowsingMode.Private browsingModeManager.mode = BrowsingMode.Private
supportActionBar?.hide() supportActionBar?.hide()
} }
nav( navigate(
R.id.historyFragment,
HistoryFragmentDirections.actionGlobalTabTrayDialogFragment() HistoryFragmentDirections.actionGlobalTabTrayDialogFragment()
) )
true true
@ -210,14 +211,23 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
return if (historyItems.size > 1) { return if (historyItems.size > 1) {
getString(R.string.history_delete_multiple_items_snackbar) getString(R.string.history_delete_multiple_items_snackbar)
} else { } else {
getString( String.format(
R.string.history_delete_single_item_snackbar, requireContext().getString(
historyItems.first().url.toShortUrl(requireComponents.publicSuffixList) R.string.history_delete_single_item_snackbar
), historyItems.first().url.toShortUrl(requireComponents.publicSuffixList)
) )
} }
} }
override fun onBackPressed(): Boolean = historyView.onBackPressed() override fun onPause() {
invokePendingDeletion()
super.onPause()
}
override fun onBackPressed(): Boolean {
invokePendingDeletion()
return historyView.onBackPressed()
}
private fun openItem(item: HistoryItem, mode: BrowsingMode? = null) { private fun openItem(item: HistoryItem, mode: BrowsingMode? = null) {
requireComponents.analytics.metrics.track(Event.HistoryItemOpened) requireComponents.analytics.metrics.track(Event.HistoryItemOpened)
@ -257,23 +267,58 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
} }
} }
private suspend fun deleteSelectedHistory(
selected: Set<HistoryItem>,
components: Components = requireComponents
) {
requireComponents.analytics.metrics.track(Event.HistoryItemRemoved)
val storage = components.core.historyStorage
for (item in selected) {
storage.deleteVisit(item.url, item.visitedAt)
}
}
private fun share(data: List<ShareData>) { private fun share(data: List<ShareData>) {
requireComponents.analytics.metrics.track(Event.HistoryItemShared) requireComponents.analytics.metrics.track(Event.HistoryItemShared)
val directions = HistoryFragmentDirections.actionGlobalShareFragment( val directions = HistoryFragmentDirections.actionGlobalShareFragment(
data = data.toTypedArray() data = data.toTypedArray()
) )
nav(R.id.historyFragment, directions) navigate(directions)
}
private fun navigate(directions: NavDirections) {
invokePendingDeletion()
findNavController().nav(
R.id.historyFragment,
directions
)
}
private fun getDeleteHistoryItemsOperation(items: Set<HistoryItem>): (suspend () -> Unit) {
return {
CoroutineScope(IO).launch {
historyStore.dispatch(HistoryFragmentAction.EnterDeletionMode)
context?.components?.run {
for (item in items) {
analytics.metrics.track(Event.HistoryItemRemoved)
core.historyStorage.deleteVisit(item.url, item.visitedAt)
}
}
historyStore.dispatch(HistoryFragmentAction.ExitDeletionMode)
pendingHistoryDeletionJob = null
}
}
}
private fun updatePendingHistoryToDelete(items: Set<HistoryItem>) {
pendingHistoryDeletionJob = getDeleteHistoryItemsOperation(items)
val ids = items.map { item -> item.visitedAt }.toSet()
historyStore.dispatch(HistoryFragmentAction.AddPendingDeletionSet(ids))
}
private fun undoPendingDeletion(items: Set<HistoryItem>) {
pendingHistoryDeletionJob = null
val ids = items.map { item -> item.visitedAt }.toSet()
historyStore.dispatch(HistoryFragmentAction.UndoPendingDeletionSet(ids))
}
private fun invokePendingDeletion() {
pendingHistoryDeletionJob?.let {
viewLifecycleOwner.lifecycleScope.launch {
it.invoke()
}.invokeOnCompletion {
pendingHistoryDeletionJob = null
}
}
} }
private suspend fun syncHistory() { private suspend fun syncHistory() {

View File

@ -30,6 +30,8 @@ sealed class HistoryFragmentAction : Action {
object ExitEditMode : HistoryFragmentAction() object ExitEditMode : HistoryFragmentAction()
data class AddItemForRemoval(val item: HistoryItem) : HistoryFragmentAction() data class AddItemForRemoval(val item: HistoryItem) : HistoryFragmentAction()
data class RemoveItemForRemoval(val item: HistoryItem) : HistoryFragmentAction() data class RemoveItemForRemoval(val item: HistoryItem) : HistoryFragmentAction()
data class AddPendingDeletionSet(val itemIds: Set<Long>) : HistoryFragmentAction()
data class UndoPendingDeletionSet(val itemIds: Set<Long>) : HistoryFragmentAction()
object EnterDeletionMode : HistoryFragmentAction() object EnterDeletionMode : HistoryFragmentAction()
object ExitDeletionMode : HistoryFragmentAction() object ExitDeletionMode : HistoryFragmentAction()
object StartSync : HistoryFragmentAction() object StartSync : HistoryFragmentAction()
@ -41,12 +43,16 @@ sealed class HistoryFragmentAction : Action {
* @property items List of HistoryItem to display * @property items List of HistoryItem to display
* @property mode Current Mode of History * @property mode Current Mode of History
*/ */
data class HistoryFragmentState(val items: List<HistoryItem>, val mode: Mode) : State { data class HistoryFragmentState(
val items: List<HistoryItem>,
val mode: Mode,
val pendingDeletionIds: Set<Long>,
val isDeletingItems: Boolean
) : State {
sealed class Mode { sealed class Mode {
open val selectedItems = emptySet<HistoryItem>() open val selectedItems = emptySet<HistoryItem>()
object Normal : Mode() object Normal : Mode()
object Deleting : Mode()
object Syncing : Mode() object Syncing : Mode()
data class Editing(override val selectedItems: Set<HistoryItem>) : Mode() data class Editing(override val selectedItems: Set<HistoryItem>) : Mode()
} }
@ -73,9 +79,17 @@ private fun historyStateReducer(
) )
} }
is HistoryFragmentAction.ExitEditMode -> state.copy(mode = HistoryFragmentState.Mode.Normal) is HistoryFragmentAction.ExitEditMode -> state.copy(mode = HistoryFragmentState.Mode.Normal)
is HistoryFragmentAction.EnterDeletionMode -> state.copy(mode = HistoryFragmentState.Mode.Deleting) is HistoryFragmentAction.EnterDeletionMode -> state.copy(isDeletingItems = true)
is HistoryFragmentAction.ExitDeletionMode -> state.copy(mode = HistoryFragmentState.Mode.Normal) is HistoryFragmentAction.ExitDeletionMode -> state.copy(isDeletingItems = false)
is HistoryFragmentAction.StartSync -> state.copy(mode = HistoryFragmentState.Mode.Syncing) is HistoryFragmentAction.StartSync -> state.copy(mode = HistoryFragmentState.Mode.Syncing)
is HistoryFragmentAction.FinishSync -> state.copy(mode = HistoryFragmentState.Mode.Normal) is HistoryFragmentAction.FinishSync -> state.copy(mode = HistoryFragmentState.Mode.Normal)
is HistoryFragmentAction.AddPendingDeletionSet ->
state.copy(
pendingDeletionIds = state.pendingDeletionIds + action.itemIds
)
is HistoryFragmentAction.UndoPendingDeletionSet ->
state.copy(
pendingDeletionIds = state.pendingDeletionIds - action.itemIds
)
} }
} }

View File

@ -90,7 +90,6 @@ class HistoryView(
val view: View = LayoutInflater.from(container.context) val view: View = LayoutInflater.from(container.context)
.inflate(R.layout.component_history, container, true) .inflate(R.layout.component_history, container, true)
private var items: List<HistoryItem> = listOf()
var mode: HistoryFragmentState.Mode = HistoryFragmentState.Mode.Normal var mode: HistoryFragmentState.Mode = HistoryFragmentState.Mode.Normal
private set private set
@ -116,13 +115,16 @@ class HistoryView(
fun update(state: HistoryFragmentState) { fun update(state: HistoryFragmentState) {
val oldMode = mode val oldMode = mode
view.progress_bar.isVisible = state.mode === HistoryFragmentState.Mode.Deleting view.progress_bar.isVisible = state.isDeletingItems
view.swipe_refresh.isRefreshing = state.mode === HistoryFragmentState.Mode.Syncing view.swipe_refresh.isRefreshing = state.mode === HistoryFragmentState.Mode.Syncing
view.swipe_refresh.isEnabled = view.swipe_refresh.isEnabled =
state.mode === HistoryFragmentState.Mode.Normal || state.mode === HistoryFragmentState.Mode.Syncing state.mode === HistoryFragmentState.Mode.Normal || state.mode === HistoryFragmentState.Mode.Syncing
items = state.items
mode = state.mode mode = state.mode
historyAdapter.updatePendingDeletionIds(state.pendingDeletionIds)
updateEmptyState(state.pendingDeletionIds.size != historyAdapter.currentList?.size)
historyAdapter.updateMode(state.mode) historyAdapter.updateMode(state.mode)
val first = layoutManager.findFirstVisibleItemPosition() val first = layoutManager.findFirstVisibleItemPosition()
val last = layoutManager.findLastVisibleItemPosition() + 1 val last = layoutManager.findLastVisibleItemPosition() + 1

View File

@ -11,13 +11,13 @@ import kotlinx.android.synthetic.main.library_site_item.view.*
import org.mozilla.fenix.R import org.mozilla.fenix.R
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.utils.Do
import org.mozilla.fenix.library.SelectionHolder import org.mozilla.fenix.library.SelectionHolder
import org.mozilla.fenix.library.history.HistoryFragmentState import org.mozilla.fenix.library.history.HistoryFragmentState
import org.mozilla.fenix.library.history.HistoryInteractor import org.mozilla.fenix.library.history.HistoryInteractor
import org.mozilla.fenix.library.history.HistoryItem import org.mozilla.fenix.library.history.HistoryItem
import org.mozilla.fenix.library.history.HistoryItemMenu import org.mozilla.fenix.library.history.HistoryItemMenu
import org.mozilla.fenix.library.history.HistoryItemTimeGroup import org.mozilla.fenix.library.history.HistoryItemTimeGroup
import org.mozilla.fenix.utils.Do
class HistoryListItemViewHolder( class HistoryListItemViewHolder(
view: View, view: View,
@ -44,8 +44,15 @@ class HistoryListItemViewHolder(
item: HistoryItem, item: HistoryItem,
timeGroup: HistoryItemTimeGroup?, timeGroup: HistoryItemTimeGroup?,
showDeleteButton: Boolean, showDeleteButton: Boolean,
mode: HistoryFragmentState.Mode mode: HistoryFragmentState.Mode,
isPendingDeletion: Boolean = false
) { ) {
if (isPendingDeletion) {
itemView.history_layout.visibility = View.GONE
} else {
itemView.history_layout.visibility = View.VISIBLE
}
itemView.history_layout.titleView.text = item.title itemView.history_layout.titleView.text = item.title
itemView.history_layout.urlView.text = item.url itemView.history_layout.urlView.text = item.url

View File

@ -14,29 +14,22 @@ import org.mozilla.fenix.loginexceptions.viewholders.LoginExceptionsDeleteButton
import org.mozilla.fenix.loginexceptions.viewholders.LoginExceptionsHeaderViewHolder import org.mozilla.fenix.loginexceptions.viewholders.LoginExceptionsHeaderViewHolder
import org.mozilla.fenix.loginexceptions.viewholders.LoginExceptionsListItemViewHolder import org.mozilla.fenix.loginexceptions.viewholders.LoginExceptionsListItemViewHolder
sealed class AdapterItem {
object DeleteButton : AdapterItem()
object Header : AdapterItem()
data class Item(val item: LoginException) : AdapterItem()
}
/** /**
* Adapter for a list of sites that are exempted from saving logins, * Adapter for a list of sites that are exempted from saving logins,
* along with controls to remove the exception. * along with controls to remove the exception.
*/ */
class LoginExceptionsAdapter( class LoginExceptionsAdapter(
private val interactor: LoginExceptionsInteractor private val interactor: LoginExceptionsInteractor
) : ListAdapter<AdapterItem, RecyclerView.ViewHolder>(DiffCallback) { ) : ListAdapter<LoginExceptionsAdapter.AdapterItem, RecyclerView.ViewHolder>(DiffCallback) {
/** /**
* Change the list of items that are displayed. * Change the list of items that are displayed.
* Header and footer items are added to the list as well. * Header and footer items are added to the list as well.
*/ */
fun updateData(exceptions: List<LoginException>) { fun updateData(exceptions: List<LoginException>) {
val adapterItems: List<AdapterItem> = val adapterItems: List<AdapterItem> = listOf(AdapterItem.Header) +
listOf(AdapterItem.Header) + exceptions.map { AdapterItem.Item(it) } + listOf( exceptions.map { AdapterItem.Item(it) } +
AdapterItem.DeleteButton listOf(AdapterItem.DeleteButton)
)
submitList(adapterItems) submitList(adapterItems)
} }
@ -70,9 +63,18 @@ class LoginExceptionsAdapter(
} }
} }
private object DiffCallback : DiffUtil.ItemCallback<AdapterItem>() { sealed class AdapterItem {
object DeleteButton : AdapterItem()
object Header : AdapterItem()
data class Item(val item: LoginException) : AdapterItem()
}
internal object DiffCallback : DiffUtil.ItemCallback<AdapterItem>() {
override fun areItemsTheSame(oldItem: AdapterItem, newItem: AdapterItem) = override fun areItemsTheSame(oldItem: AdapterItem, newItem: AdapterItem) =
areContentsTheSame(oldItem, newItem) when (oldItem) {
AdapterItem.DeleteButton, AdapterItem.Header -> oldItem === newItem
is AdapterItem.Item -> newItem is AdapterItem.Item && oldItem.item.id == newItem.item.id
}
@Suppress("DiffUtilEquals") @Suppress("DiffUtilEquals")
override fun areContentsTheSame(oldItem: AdapterItem, newItem: AdapterItem) = override fun areContentsTheSame(oldItem: AdapterItem, newItem: AdapterItem) =

View File

@ -9,9 +9,9 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.observe
import kotlinx.android.synthetic.main.fragment_exceptions.view.* import kotlinx.android.synthetic.main.fragment_exceptions.view.*
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -57,18 +57,15 @@ class LoginExceptionsFragment : Fragment() {
return view return view
} }
private fun subscribeToLoginExceptions(): Observer<List<LoginException>> { private fun subscribeToLoginExceptions() {
return Observer<List<LoginException>> { exceptions -> requireComponents.core.loginExceptionStorage.getLoginExceptions().asLiveData()
exceptionsStore.dispatch(ExceptionsFragmentAction.Change(exceptions)) .observe(viewLifecycleOwner) { exceptions ->
}.also { observer -> exceptionsStore.dispatch(ExceptionsFragmentAction.Change(exceptions))
requireComponents.core.loginExceptionStorage.getLoginExceptions().asLiveData() }
.observe(viewLifecycleOwner, observer)
}
} }
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
consumeFrom(exceptionsStore) { consumeFrom(exceptionsStore) {
exceptionsView.update(it) exceptionsView.update(it)
} }

View File

@ -10,7 +10,7 @@ import android.widget.FrameLayout
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.android.extensions.LayoutContainer import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.component_exceptions.view.* import kotlinx.android.synthetic.main.component_exceptions.*
import mozilla.components.feature.logins.exceptions.LoginException import mozilla.components.feature.logins.exceptions.LoginException
import org.mozilla.fenix.R import org.mozilla.fenix.R
@ -34,29 +34,29 @@ interface ExceptionsViewInteractor {
* View that contains and configures the Exceptions List * View that contains and configures the Exceptions List
*/ */
class LoginExceptionsView( class LoginExceptionsView(
override val containerView: ViewGroup, container: ViewGroup,
val interactor: LoginExceptionsInteractor val interactor: LoginExceptionsInteractor
) : LayoutContainer { ) : LayoutContainer {
val view: FrameLayout = LayoutInflater.from(containerView.context) override val containerView: FrameLayout = LayoutInflater.from(container.context)
.inflate(R.layout.component_exceptions, containerView, true) .inflate(R.layout.component_exceptions, container, true)
.findViewById(R.id.exceptions_wrapper) .findViewById(R.id.exceptions_wrapper)
private val exceptionsAdapter = LoginExceptionsAdapter(interactor) private val exceptionsAdapter = LoginExceptionsAdapter(interactor)
init { init {
view.exceptions_learn_more.isVisible = false exceptions_learn_more.isVisible = false
view.exceptions_empty_message.text = exceptions_empty_message.text =
view.context.getString(R.string.preferences_passwords_exceptions_description_empty) containerView.context.getString(R.string.preferences_passwords_exceptions_description_empty)
view.exceptions_list.apply { exceptions_list.apply {
adapter = exceptionsAdapter adapter = exceptionsAdapter
layoutManager = LinearLayoutManager(containerView.context) layoutManager = LinearLayoutManager(containerView.context)
} }
} }
fun update(state: ExceptionsFragmentState) { fun update(state: ExceptionsFragmentState) {
view.exceptions_empty_view.isVisible = state.items.isEmpty() exceptions_empty_view.isVisible = state.items.isEmpty()
view.exceptions_list.isVisible = state.items.isNotEmpty() exceptions_list.isVisible = state.items.isNotEmpty()
exceptionsAdapter.updateData(state.items) exceptionsAdapter.updateData(state.items)
} }
} }

View File

@ -12,12 +12,13 @@ import org.mozilla.fenix.R
class LoginExceptionsHeaderViewHolder( class LoginExceptionsHeaderViewHolder(
view: View view: View
) : RecyclerView.ViewHolder(view) { ) : RecyclerView.ViewHolder(view) {
companion object {
const val LAYOUT_ID = R.layout.exceptions_description
}
init { init {
view.exceptions_description.text = view.exceptions_description.text =
view.context.getString(R.string.preferences_passwords_exceptions_description) view.context.getString(R.string.preferences_passwords_exceptions_description)
} }
companion object {
const val LAYOUT_ID = R.layout.exceptions_description
}
} }

View File

@ -5,28 +5,15 @@
package org.mozilla.fenix.migration package org.mozilla.fenix.migration
import android.content.Intent import android.content.Intent
import android.graphics.Rect
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup
import androidx.annotation.DimenRes
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.activity_migration.* import kotlinx.android.synthetic.main.activity_migration.*
import kotlinx.android.synthetic.main.migration_list_item.view.*
import mozilla.components.support.base.log.logger.Logger import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.ktx.android.content.getColorFromAttr import mozilla.components.support.ktx.android.content.getColorFromAttr
import mozilla.components.support.migration.AbstractMigrationProgressActivity import mozilla.components.support.migration.AbstractMigrationProgressActivity
import mozilla.components.support.migration.AbstractMigrationService import mozilla.components.support.migration.AbstractMigrationService
import mozilla.components.support.migration.Migration
import mozilla.components.support.migration.Migration.Bookmarks
import mozilla.components.support.migration.Migration.History
import mozilla.components.support.migration.Migration.Logins
import mozilla.components.support.migration.Migration.Settings
import mozilla.components.support.migration.MigrationResults import mozilla.components.support.migration.MigrationResults
import mozilla.components.support.migration.state.MigrationAction import mozilla.components.support.migration.state.MigrationAction
import mozilla.components.support.migration.state.MigrationProgress import mozilla.components.support.migration.state.MigrationProgress
@ -97,91 +84,10 @@ class MigrationProgressActivity : AbstractMigrationProgressActivity() {
migration_button.setBackgroundResource(R.drawable.migration_button_background) migration_button.setBackgroundResource(R.drawable.migration_button_background)
migration_button_progress_bar.visibility = View.INVISIBLE migration_button_progress_bar.visibility = View.INVISIBLE
// Keep the results list up-to-date. // Keep the results list up-to-date.
statusAdapter.submitList(results.toItemList()) statusAdapter.updateData(results)
} }
override fun onMigrationStateChanged(progress: MigrationProgress, results: MigrationResults) { override fun onMigrationStateChanged(progress: MigrationProgress, results: MigrationResults) {
statusAdapter.submitList(results.toItemList()) statusAdapter.updateData(results)
}
}
// These are the only items we want to show migrating in the UI.
internal val whiteList = linkedMapOf(
Settings to R.string.settings_title,
History to R.string.preferences_sync_history,
Bookmarks to R.string.preferences_sync_bookmarks,
Logins to R.string.migration_text_passwords
)
internal fun MigrationResults.toItemList() = whiteList.keys
.map {
if (containsKey(it)) {
MigrationItem(it, getValue(it).success)
} else {
MigrationItem(it)
}
}
internal data class MigrationItem(val migration: Migration, val status: Boolean = false)
internal class MigrationStatusAdapter :
ListAdapter<MigrationItem, MigrationStatusAdapter.ViewHolder>(DiffCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position))
}
override fun getItemViewType(position: Int): Int = R.layout.migration_list_item
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
private val context = view.context
private val title = view.migration_item_name
private val status = view.migration_status_image
fun bind(item: MigrationItem) {
// Get the resource ID for the item.
val migrationText = whiteList[item.migration]?.run {
context.getString(this)
} ?: ""
title.text = migrationText
status.visibility = if (item.status) View.VISIBLE else View.INVISIBLE
status.contentDescription = context.getString(R.string.migration_icon_description)
}
}
private object DiffCallback : DiffUtil.ItemCallback<MigrationItem>() {
override fun areItemsTheSame(oldItem: MigrationItem, newItem: MigrationItem) =
oldItem.migration.javaClass.simpleName == newItem.migration.javaClass.simpleName
override fun areContentsTheSame(oldItem: MigrationItem, newItem: MigrationItem) =
oldItem.migration.javaClass.simpleName == newItem.migration.javaClass.simpleName &&
oldItem.status == newItem.status
}
}
internal class MigrationStatusItemDecoration(
@DimenRes private val spacing: Int
) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
val position = parent.getChildViewHolder(view).adapterPosition
val itemCount = state.itemCount
outRect.left = spacing
outRect.right = spacing
outRect.top = spacing
outRect.bottom = if (position == itemCount - 1) spacing else 0
} }
} }

View File

@ -0,0 +1,107 @@
/* 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.migration
import android.graphics.Rect
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.Px
import androidx.core.view.isInvisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.migration_list_item.view.*
import mozilla.components.support.migration.Migration
import mozilla.components.support.migration.MigrationResults
import org.mozilla.fenix.R
internal data class MigrationItem(
val migration: Migration,
val status: Boolean = false
)
// These are the only items we want to show migrating in the UI.
internal val whiteList = linkedMapOf(
Migration.Settings to R.string.settings_title,
Migration.History to R.string.preferences_sync_history,
Migration.Bookmarks to R.string.preferences_sync_bookmarks,
Migration.Logins to R.string.migration_text_passwords
)
internal class MigrationStatusAdapter :
ListAdapter<MigrationItem, MigrationStatusAdapter.ViewHolder>(DiffCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.migration_list_item, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position))
}
/**
* Filter the [results] to only include items in [whiteList] and update the adapter.
*/
fun updateData(results: MigrationResults) {
val itemList = whiteList.keys.map {
if (results.containsKey(it)) {
MigrationItem(it, results.getValue(it).success)
} else {
MigrationItem(it)
}
}
submitList(itemList)
}
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
private val context = view.context
private val title = view.migration_item_name
private val status = view.migration_status_image
fun bind(item: MigrationItem) {
// Get the resource ID for the item.
val migrationText = whiteList[item.migration]?.let {
context.getString(it)
}.orEmpty()
title.text = migrationText
status.isInvisible = !item.status
status.contentDescription = context.getString(R.string.migration_icon_description)
}
}
private object DiffCallback : DiffUtil.ItemCallback<MigrationItem>() {
override fun areItemsTheSame(oldItem: MigrationItem, newItem: MigrationItem) =
oldItem.migration.javaClass.simpleName == newItem.migration.javaClass.simpleName
override fun areContentsTheSame(oldItem: MigrationItem, newItem: MigrationItem) =
oldItem.migration.javaClass.simpleName == newItem.migration.javaClass.simpleName &&
oldItem.status == newItem.status
}
}
internal class MigrationStatusItemDecoration(
@Px private val spacing: Int
) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
val position = parent.getChildViewHolder(view).adapterPosition
val itemCount = state.itemCount
outRect.left = spacing
outRect.right = spacing
outRect.top = spacing
outRect.bottom = if (position == itemCount - 1) spacing else 0
}
}

View File

@ -1,6 +1,6 @@
/* This Source Code Form is subject to the terms of the Mozilla Public /* 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 * 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/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.migration package org.mozilla.fenix.migration
@ -15,7 +15,8 @@ import org.mozilla.fenix.components.metrics.MetricController
class MigrationTelemetryListener( class MigrationTelemetryListener(
private val metrics: MetricController, private val metrics: MetricController,
private val store: MigrationStore private val store: MigrationStore,
private val logger: Logger = Logger("MigrationTelemetryListener")
) { ) {
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@ -23,7 +24,7 @@ class MigrationTelemetryListener(
// Observe for migration completed. // Observe for migration completed.
store.flowScoped { flow -> store.flowScoped { flow ->
flow.collect { state -> flow.collect { state ->
Logger("MigrationTelemetryListener").debug("Migration state: ${state.progress}") logger.debug("Migration state: ${state.progress}")
if (state.progress == MigrationProgress.COMPLETED) { if (state.progress == MigrationProgress.COMPLETED) {
metrics.track(Event.FennecToFenixMigrated) metrics.track(Event.FennecToFenixMigrated)
} }

View File

@ -74,6 +74,6 @@ object Performance {
* Disables the first time PWA popup. * Disables the first time PWA popup.
*/ */
private fun disableFirstTimePWAPopup(context: Context) { private fun disableFirstTimePWAPopup(context: Context) {
Settings.getInstance(context).userKnowsAboutPWAs = true Settings.getInstance(context).userKnowsAboutPwas = true
} }
} }

View File

@ -316,11 +316,20 @@ class SearchFragment : Fragment(), UserInteractionHandler {
updateSearchWithLabel(it) updateSearchWithLabel(it)
updateClipboardSuggestion(it, requireContext().components.clipboardHandler.url) updateClipboardSuggestion(it, requireContext().components.clipboardHandler.url)
updateSearchSuggestionsHintVisibility(it) updateSearchSuggestionsHintVisibility(it)
updateToolbarContentDescription(it)
} }
startPostponedEnterTransition() startPostponedEnterTransition()
} }
private fun updateToolbarContentDescription(searchState: SearchFragmentState) {
val urlView = toolbarView.view
.findViewById<InlineAutocompleteEditText>(R.id.mozac_browser_toolbar_edit_url_view)
toolbarView.view.contentDescription =
searchState.searchEngineSource.searchEngine.name + ", " + urlView.hint
urlView?.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()

View File

@ -1,20 +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.settings.logins
import android.view.ViewGroup
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.fragment_login_detail.*
/**
* View that contains and configures the Login Details
*/
class LoginDetailView(override val containerView: ViewGroup?) : LayoutContainer {
fun update(login: LoginsListState) {
webAddressText.text = login.currentItem?.origin
usernameText.text = login.currentItem?.username
passwordText.text = login.currentItem?.password
}
}

View File

@ -54,18 +54,20 @@ sealed class LoginsAction : Action {
data class UpdateLoginsList(val list: List<SavedLogin>) : LoginsAction() data class UpdateLoginsList(val list: List<SavedLogin>) : LoginsAction()
data class UpdateCurrentLogin(val item: SavedLogin) : LoginsAction() data class UpdateCurrentLogin(val item: SavedLogin) : LoginsAction()
data class SortLogins(val sortingStrategy: SortingStrategy) : LoginsAction() data class SortLogins(val sortingStrategy: SortingStrategy) : LoginsAction()
data class ListOfDupes(val dupeList: List<SavedLogin>) : LoginsAction()
data class LoginSelected(val item: SavedLogin) : LoginsAction() data class LoginSelected(val item: SavedLogin) : LoginsAction()
} }
/** /**
* The state for the Saved Logins Screen * The state for the Saved Logins Screen
* @property loginList Source of truth for local list of logins
* @property loginList Filterable list of logins to display * @property loginList Filterable list of logins to display
* @property currentItem The last item that was opened into the detail view * @property currentItem The last item that was opened into the detail view
* @property searchedForText String used by the user to filter logins * @property searchedForText String used by the user to filter logins
* @property sortingStrategy sorting strategy selected by the user (Currently we support * @property sortingStrategy sorting strategy selected by the user (Currently we support
* sorting alphabetically and by last used) * sorting alphabetically and by last used)
* @property highlightedItem The current selected sorting strategy from the sort menu * @property highlightedItem The current selected sorting strategy from the sort menu
* @property duplicateLogins The current list of possible duplicates for a selected login origin,
* httpRealm, and formActionOrigin
*/ */
data class LoginsListState( data class LoginsListState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
@ -74,7 +76,8 @@ data class LoginsListState(
val currentItem: SavedLogin? = null, val currentItem: SavedLogin? = null,
val searchedForText: String?, val searchedForText: String?,
val sortingStrategy: SortingStrategy, val sortingStrategy: SortingStrategy,
val highlightedItem: SavedLoginsSortingStrategyMenu.Item val highlightedItem: SavedLoginsSortingStrategyMenu.Item,
val duplicateLogins: List<SavedLogin>
) : State ) : State
/** /**
@ -113,9 +116,14 @@ private fun savedLoginsStateReducer(
} }
is LoginsAction.LoginSelected -> { is LoginsAction.LoginSelected -> {
state.copy( state.copy(
isLoading = true, isLoading = true,
loginList = emptyList(), loginList = emptyList(),
filteredItems = emptyList() filteredItems = emptyList()
)
}
is LoginsAction.ListOfDupes -> {
state.copy(
duplicateLogins = action.dupeList
) )
} }
} }

View File

@ -1,135 +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.settings.logins
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.view.isVisible
import androidx.navigation.NavController
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.component_saved_logins.view.*
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.ext.addUnderline
import org.mozilla.fenix.settings.SupportUtils
import org.mozilla.fenix.utils.Settings
/**
* View that contains and configures the Saved Logins List
*/
class SavedLoginsView(
override val containerView: ViewGroup,
val interactor: SavedLoginsInteractor
) : LayoutContainer {
val view: FrameLayout = LayoutInflater.from(containerView.context)
.inflate(R.layout.component_saved_logins, containerView, true)
.findViewById(R.id.saved_logins_wrapper)
private val loginsAdapter = LoginsAdapter(interactor)
init {
view.saved_logins_list.apply {
adapter = loginsAdapter
layoutManager = LinearLayoutManager(containerView.context)
itemAnimator = null
}
with(view.saved_passwords_empty_learn_more) {
movementMethod = LinkMovementMethod.getInstance()
addUnderline()
setOnClickListener { interactor.onLearnMoreClicked() }
}
with(view.saved_passwords_empty_message) {
val appName = context.getString(R.string.app_name)
text = String.format(
context.getString(
R.string.preferences_passwords_saved_logins_description_empty_text
), appName
)
}
}
fun update(state: LoginsListState) {
// todo MVI views should not have logic. Needs refactoring.
if (state.isLoading) {
view.progress_bar.isVisible = true
} else {
view.progress_bar.isVisible = false
view.saved_logins_list.isVisible = state.loginList.isNotEmpty()
view.saved_passwords_empty_view.isVisible = state.loginList.isEmpty()
}
loginsAdapter.submitList(state.filteredItems)
}
}
/**
* Interactor for the saved logins screen
*
* @param savedLoginsController [SavedLoginsController] which will be delegated for all users interactions.
*/
class SavedLoginsInteractor(
private val savedLoginsController: SavedLoginsController
) {
fun onItemClicked(item: SavedLogin) {
savedLoginsController.handleItemClicked(item)
}
fun onLearnMoreClicked() {
savedLoginsController.handleLearnMoreClicked()
}
fun onSortingStrategyChanged(sortingStrategy: SortingStrategy) {
savedLoginsController.handleSort(sortingStrategy)
}
}
/**
* Controller for the saved logins screen
*
* @param store Store used to hold in-memory collection state.
* @param navController NavController manages app navigation within a NavHost.
* @param browserNavigator Controller allowing browser navigation to any Uri.
* @param settings SharedPreferences wrapper for easier usage.
* @param metrics Controller that handles telemetry events.
*/
class SavedLoginsController(
private val store: LoginsFragmentStore,
private val navController: NavController,
private val browserNavigator: (
searchTermOrURL: String,
newTab: Boolean,
from: BrowserDirection
) -> Unit,
private val settings: Settings,
private val metrics: MetricController
) {
fun handleSort(sortingStrategy: SortingStrategy) {
store.dispatch(LoginsAction.SortLogins(sortingStrategy))
settings.savedLoginsSortingStrategy = sortingStrategy
}
fun handleItemClicked(item: SavedLogin) {
store.dispatch(LoginsAction.LoginSelected(item))
metrics.track(Event.OpenOneLogin)
navController.navigate(
SavedLoginsFragmentDirections.actionSavedLoginsFragmentToLoginDetailFragment(item.guid)
)
}
fun handleLearnMoreClicked() {
browserNavigator.invoke(
SupportUtils.getGenericSumoURLForTopic(SupportUtils.SumoTopic.SYNC_SETUP),
true,
BrowserDirection.FromSavedLoginsFragment
)
}
}

View File

@ -0,0 +1,64 @@
/* 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.logins.controller
import androidx.navigation.NavController
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.settings.SupportUtils
import org.mozilla.fenix.settings.logins.LoginsAction
import org.mozilla.fenix.settings.logins.LoginsFragmentStore
import org.mozilla.fenix.settings.logins.SavedLogin
import org.mozilla.fenix.settings.logins.SortingStrategy
import org.mozilla.fenix.settings.logins.fragment.SavedLoginsFragmentDirections
import org.mozilla.fenix.utils.Settings
/**
* Controller for the saved logins list
*
* @param loginsFragmentStore Store used to hold in-memory collection state.
* @param navController NavController manages app navigation within a NavHost.
* @param browserNavigator Controller allowing browser navigation to any Uri.
* @param settings SharedPreferences wrapper for easier usage.
* @param metrics Controller that handles telemetry events.
*/
class LoginsListController(
private val loginsFragmentStore: LoginsFragmentStore,
private val navController: NavController,
private val browserNavigator: (
searchTermOrURL: String,
newTab: Boolean,
from: BrowserDirection
) -> Unit,
private val settings: Settings,
private val metrics: MetricController
) {
fun handleItemClicked(item: SavedLogin) {
loginsFragmentStore.dispatch(LoginsAction.LoginSelected(item))
metrics.track(Event.OpenOneLogin)
navController.navigate(
SavedLoginsFragmentDirections.actionSavedLoginsFragmentToLoginDetailFragment(item.guid)
)
}
fun handleLearnMoreClicked() {
browserNavigator.invoke(
SupportUtils.getGenericSumoURLForTopic(SupportUtils.SumoTopic.SYNC_SETUP),
true,
BrowserDirection.FromSavedLoginsFragment
)
}
fun handleSort(sortingStrategy: SortingStrategy) {
loginsFragmentStore.dispatch(
LoginsAction.SortLogins(
sortingStrategy
)
)
settings.savedLoginsSortingStrategy = sortingStrategy
}
}

View File

@ -0,0 +1,198 @@
/* 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.logins.controller
import android.content.Context
import android.util.Log
import androidx.navigation.NavController
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mozilla.components.concept.storage.Login
import mozilla.components.service.sync.logins.InvalidRecordException
import mozilla.components.service.sync.logins.LoginsStorageException
import mozilla.components.service.sync.logins.NoSuchRecordException
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.settings.logins.LoginsAction
import org.mozilla.fenix.settings.logins.LoginsFragmentStore
import org.mozilla.fenix.settings.logins.fragment.EditLoginFragmentDirections
import org.mozilla.fenix.settings.logins.mapToSavedLogin
/**
* Controller for all saved logins interactions with the password storage component
*/
open class SavedLoginsStorageController(
private val context: Context,
private val viewLifecycleScope: CoroutineScope,
private val navController: NavController,
private val loginsFragmentStore: LoginsFragmentStore
) {
private suspend fun getLogin(loginId: String): Login? =
context.components.core.passwordsStorage.get(loginId)
fun delete(loginId: String) {
var deleteLoginJob: Deferred<Boolean>? = null
val deleteJob = viewLifecycleScope.launch(Dispatchers.IO) {
deleteLoginJob = async {
context.components.core.passwordsStorage.delete(loginId)
}
deleteLoginJob?.await()
withContext(Dispatchers.Main) {
navController.popBackStack(R.id.savedLoginsFragment, false)
}
}
deleteJob.invokeOnCompletion {
if (it is CancellationException) {
deleteLoginJob?.cancel()
}
}
}
fun save(loginId: String, usernameText: String, passwordText: String) {
var saveLoginJob: Deferred<Unit>? = null
viewLifecycleScope.launch(Dispatchers.IO) {
saveLoginJob = async {
// must retrieve from storage to get the httpsRealm and formActionOrigin
val oldLogin = context.components.core.passwordsStorage.get(loginId)
// Update requires a Login type, which needs at least one of
// httpRealm or formActionOrigin
val loginToSave = Login(
guid = loginId,
origin = oldLogin?.origin!!,
username = usernameText, // new value
password = passwordText, // new value
httpRealm = oldLogin.httpRealm,
formActionOrigin = oldLogin.formActionOrigin
)
save(loginToSave)
syncAndUpdateList(loginToSave)
}
saveLoginJob?.await()
withContext(Dispatchers.Main) {
val directions =
EditLoginFragmentDirections.actionEditLoginFragmentToLoginDetailFragment(
loginId
)
navController.navigate(directions)
}
}
saveLoginJob?.invokeOnCompletion {
if (it is CancellationException) {
saveLoginJob?.cancel()
}
}
}
private suspend fun save(loginToSave: Login) {
try {
context.components.core.passwordsStorage.update(loginToSave)
} catch (loginException: LoginsStorageException) {
when (loginException) {
is NoSuchRecordException,
is InvalidRecordException -> {
Log.e("Edit login",
"Failed to save edited login.", loginException)
}
else -> Log.e("Edit login",
"Failed to save edited login.", loginException)
}
}
}
private fun syncAndUpdateList(updatedLogin: Login) {
val login = updatedLogin.mapToSavedLogin()
loginsFragmentStore.dispatch(
LoginsAction.UpdateLoginsList(
listOf(login)
)
)
}
fun findPotentialDuplicates(loginId: String) {
var deferredLogin: Deferred<List<Login>>? = null
// What scope should be used here?
val fetchLoginJob = viewLifecycleScope.launch(Dispatchers.IO) {
deferredLogin = async {
val login = getLogin(loginId)
context.components.core.passwordsStorage.getPotentialDupesIgnoringUsername(login!!)
}
val fetchedDuplicatesList = deferredLogin?.await()
fetchedDuplicatesList?.let { list ->
withContext(Dispatchers.Main) {
val savedLoginList = list.map { it.mapToSavedLogin() }
loginsFragmentStore.dispatch(
LoginsAction.ListOfDupes(
savedLoginList
)
)
}
}
}
fetchLoginJob.invokeOnCompletion {
if (it is CancellationException) {
deferredLogin?.cancel()
}
}
}
fun fetchLoginDetails(loginId: String) {
var deferredLogin: Deferred<List<Login>>? = null
val fetchLoginJob = viewLifecycleScope.launch(Dispatchers.IO) {
deferredLogin = async {
context.components.core.passwordsStorage.list()
}
val fetchedLoginList = deferredLogin?.await()
fetchedLoginList?.let {
withContext(Dispatchers.Main) {
val login = fetchedLoginList.filter {
it.guid == loginId
}.first()
loginsFragmentStore.dispatch(
LoginsAction.UpdateCurrentLogin(
login.mapToSavedLogin()
)
)
}
}
}
fetchLoginJob.invokeOnCompletion {
if (it is CancellationException) {
deferredLogin?.cancel()
}
}
}
fun handleLoadAndMapLogins() {
var deferredLogins: Deferred<List<Login>>? = null
val fetchLoginsJob = viewLifecycleScope.launch(Dispatchers.IO) {
deferredLogins = async {
context.components.core.passwordsStorage.list()
}
val logins = deferredLogins?.await()
logins?.let {
withContext(Dispatchers.Main) {
loginsFragmentStore.dispatch(
LoginsAction.UpdateLoginsList(
logins.map { it.mapToSavedLogin() })
)
}
}
}
fetchLoginsJob.invokeOnCompletion {
if (it is CancellationException) {
deferredLogins?.cancel()
}
}
}
}

View File

@ -2,59 +2,72 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins package org.mozilla.fenix.settings.logins.fragment
import android.os.Bundle import android.os.Bundle
import android.text.Editable import android.text.Editable
import android.text.InputType import android.text.InputType
import android.text.TextWatcher import android.text.TextWatcher
import android.util.Log
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.core.content.ContextCompat import androidx.appcompat.view.menu.ActionMenuItemView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import kotlinx.android.synthetic.main.fragment_edit_login.* import kotlinx.android.synthetic.main.fragment_edit_login.*
import kotlinx.coroutines.CancellationException import kotlinx.android.synthetic.main.fragment_edit_login.view.*
import kotlinx.coroutines.Deferred import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Dispatchers.IO import mozilla.components.lib.state.ext.consumeFrom
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mozilla.components.concept.storage.Login
import mozilla.components.service.sync.logins.InvalidRecordException
import mozilla.components.service.sync.logins.LoginsStorageException
import mozilla.components.service.sync.logins.NoSuchRecordException
import mozilla.components.support.ktx.android.view.hideKeyboard import mozilla.components.support.ktx.android.view.hideKeyboard
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.redirectToReAuth import org.mozilla.fenix.ext.redirectToReAuth
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.settings.logins.LoginsAction
import org.mozilla.fenix.settings.logins.LoginsFragmentStore
import org.mozilla.fenix.settings.logins.LoginsListState
import org.mozilla.fenix.settings.logins.SavedLogin
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
import org.mozilla.fenix.settings.logins.interactor.EditLoginInteractor
import org.mozilla.fenix.settings.logins.view.EditLoginView
/** /**
* Displays the editable saved login information for a single website. * Displays the editable saved login information for a single website
*/ */
@ExperimentalCoroutinesApi
@Suppress("TooManyFunctions", "NestedBlockDepth", "ForbiddenComment") @Suppress("TooManyFunctions", "NestedBlockDepth", "ForbiddenComment")
class EditLoginFragment : Fragment(R.layout.fragment_edit_login) { class EditLoginFragment : Fragment(R.layout.fragment_edit_login) {
private val args by navArgs<EditLoginFragmentArgs>()
private lateinit var savedLoginsStore: LoginsFragmentStore
fun String.toEditable(): Editable = Editable.Factory.getInstance().newEditable(this) fun String.toEditable(): Editable = Editable.Factory.getInstance().newEditable(this)
private val args by navArgs<EditLoginFragmentArgs>()
private lateinit var loginsFragmentStore: LoginsFragmentStore
private lateinit var interactor: EditLoginInteractor
private lateinit var editLoginView: EditLoginView
private lateinit var oldLogin: SavedLogin private lateinit var oldLogin: SavedLogin
override fun onCreate(savedInstanceState: Bundle?) { private var listOfPossibleDupes: List<SavedLogin>? = null
super.onCreate(savedInstanceState)
private var usernameChanged = false
private var passwordChanged = false
private var saveEnabled = false
private var showPassword = true
private var validPassword = true
private var validUsername = true
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setHasOptionsMenu(true) setHasOptionsMenu(true)
oldLogin = args.savedLoginItem oldLogin = args.savedLoginItem
savedLoginsStore = StoreProvider.get(this) { editLoginView = EditLoginView(view.editLoginLayout)
loginsFragmentStore = StoreProvider.get(this) {
LoginsFragmentStore( LoginsFragmentStore(
LoginsListState( LoginsListState(
isLoading = true, isLoading = true,
@ -62,31 +75,59 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) {
filteredItems = listOf(), filteredItems = listOf(),
searchedForText = null, searchedForText = null,
sortingStrategy = requireContext().settings().savedLoginsSortingStrategy, sortingStrategy = requireContext().settings().savedLoginsSortingStrategy,
highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem,
duplicateLogins = listOf()
) )
) )
} }
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { interactor = EditLoginInteractor(
super.onViewCreated(view, savedInstanceState) SavedLoginsStorageController(
context = requireContext(),
viewLifecycleScope = viewLifecycleOwner.lifecycleScope,
navController = findNavController(),
loginsFragmentStore = loginsFragmentStore
)
)
// ensure hostname isn't editable loginsFragmentStore.dispatch(LoginsAction.UpdateCurrentLogin(args.savedLoginItem))
interactor.findPotentialDuplicates(args.savedLoginItem.guid)
// initialize editable values
hostnameText.text = args.savedLoginItem.origin.toEditable() hostnameText.text = args.savedLoginItem.origin.toEditable()
hostnameText.isClickable = false
hostnameText.isFocusable = false
usernameText.text = args.savedLoginItem.username.toEditable() usernameText.text = args.savedLoginItem.username.toEditable()
passwordText.text = args.savedLoginItem.password.toEditable() passwordText.text = args.savedLoginItem.password.toEditable()
formatEditableValues()
initSaveState()
setUpClickListeners()
setUpTextListeners()
editLoginView.showPassword()
consumeFrom(loginsFragmentStore) {
listOfPossibleDupes = loginsFragmentStore.state.duplicateLogins
}
}
private fun initSaveState() {
saveEnabled = false // don't enable saving until something has been changed
val saveButton =
activity?.findViewById<ActionMenuItemView>(R.id.save_login_button)
saveButton?.isEnabled = saveEnabled
usernameChanged = false
passwordChanged = false
}
private fun formatEditableValues() {
hostnameText.isClickable = false
hostnameText.isFocusable = false
usernameText.inputType = InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
// TODO: extend PasswordTransformationMethod() to change bullets to asterisks // TODO: extend PasswordTransformationMethod() to change bullets to asterisks
passwordText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD passwordText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
passwordText.compoundDrawablePadding = passwordText.compoundDrawablePadding =
requireContext().resources requireContext().resources
.getDimensionPixelOffset(R.dimen.saved_logins_end_icon_drawable_padding) .getDimensionPixelOffset(R.dimen.saved_logins_end_icon_drawable_padding)
setUpClickListeners()
setUpTextListeners()
} }
private fun setUpClickListeners() { private fun setUpClickListeners() {
@ -105,14 +146,11 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) {
it.isEnabled = false it.isEnabled = false
} }
revealPasswordButton.setOnClickListener { revealPasswordButton.setOnClickListener {
togglePasswordReveal() showPassword = !showPassword
} if (showPassword) {
editLoginView.showPassword()
var firstClick = true } else {
passwordText.setOnClickListener { editLoginView.hidePassword()
if (firstClick) {
togglePasswordReveal()
firstClick = false
} }
} }
} }
@ -124,7 +162,6 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) {
view?.hideKeyboard() view?.hideKeyboard()
} }
} }
editLoginLayout.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> editLoginLayout.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->
if (!hasFocus) { if (!hasFocus) {
view?.hideKeyboard() view?.hideKeyboard()
@ -133,13 +170,20 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) {
usernameText.addTextChangedListener(object : TextWatcher { usernameText.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(u: Editable?) { override fun afterTextChanged(u: Editable?) {
if (u.toString() == oldLogin.username) { when {
inputLayoutUsername.error = null u.toString() == oldLogin.username -> {
inputLayoutUsername.errorIconDrawable = null usernameChanged = false
} else { validUsername = true
clearUsernameTextButton.isEnabled = true inputLayoutUsername.error = null
// setDupeError() TODO in #10173 inputLayoutUsername.errorIconDrawable = null
}
else -> {
usernameChanged = true
clearUsernameTextButton.isEnabled = true
setDupeError()
}
} }
setSaveButtonState()
} }
override fun beforeTextChanged(u: CharSequence?, start: Int, count: Int, after: Int) { override fun beforeTextChanged(u: CharSequence?, start: Int, count: Int, after: Int) {
@ -155,20 +199,26 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) {
override fun afterTextChanged(p: Editable?) { override fun afterTextChanged(p: Editable?) {
when { when {
p.toString().isEmpty() -> { p.toString().isEmpty() -> {
passwordChanged = true
clearPasswordTextButton.isEnabled = false clearPasswordTextButton.isEnabled = false
setPasswordError() setPasswordError()
} }
p.toString() == oldLogin.password -> { p.toString() == oldLogin.password -> {
passwordChanged = false
validPassword = true
inputLayoutPassword.error = null inputLayoutPassword.error = null
inputLayoutPassword.errorIconDrawable = null inputLayoutPassword.errorIconDrawable = null
clearPasswordTextButton.isEnabled = true clearPasswordTextButton.isEnabled = true
} }
else -> { else -> {
passwordChanged = true
validPassword = true
inputLayoutPassword.error = null inputLayoutPassword.error = null
inputLayoutPassword.errorIconDrawable = null inputLayoutPassword.errorIconDrawable = null
clearPasswordTextButton.isEnabled = true clearPasswordTextButton.isEnabled = true
} }
} }
setSaveButtonState()
} }
override fun beforeTextChanged(p: CharSequence?, start: Int, count: Int, after: Int) { override fun beforeTextChanged(p: CharSequence?, start: Int, count: Int, after: Int) {
@ -181,14 +231,40 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) {
}) })
} }
private fun isDupe(username: String): Boolean =
loginsFragmentStore.state.duplicateLogins.filter { it.username == username }.any()
private fun setDupeError() {
if (isDupe(usernameText.text.toString())) {
inputLayoutUsername?.let {
usernameChanged = true
validUsername = false
it.setErrorIconDrawable(R.drawable.mozac_ic_warning)
it.error = context?.getString(R.string.saved_login_duplicate)
}
} else {
usernameChanged = true
validUsername = true
inputLayoutUsername.error = null
}
}
private fun setPasswordError() { private fun setPasswordError() {
inputLayoutPassword?.let { layout -> inputLayoutPassword?.let { layout ->
validPassword = false
layout.error = context?.getString(R.string.saved_login_password_required) layout.error = context?.getString(R.string.saved_login_password_required)
layout.setErrorIconDrawable(R.drawable.mozac_ic_warning) layout.setErrorIconDrawable(R.drawable.mozac_ic_warning)
}
}
layout.errorIconDrawable?.setTint( private fun setSaveButtonState() {
ContextCompat.getColor(requireContext(), R.color.design_default_color_error) val saveButton = activity?.findViewById<ActionMenuItemView>(R.id.save_login_button)
) val changesMadeWithNoErrors =
validUsername && validPassword && (usernameChanged || passwordChanged)
changesMadeWithNoErrors.let {
saveButton?.isEnabled = it
saveEnabled = it
} }
} }
@ -207,101 +283,16 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) {
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
R.id.save_login_button -> { R.id.save_login_button -> {
view?.hideKeyboard() view?.hideKeyboard()
if (!passwordText.text.isNullOrBlank()) { if (saveEnabled) {
try { interactor.onSaveLogin(
attemptSaveAndExit() args.savedLoginItem.guid,
} catch (loginException: LoginsStorageException) { usernameText.text.toString(),
when (loginException) { passwordText.text.toString()
is NoSuchRecordException, )
is InvalidRecordException -> { requireComponents.analytics.metrics.track(Event.EditLoginSave)
Log.e(
"Edit login",
"Failed to save edited login.",
loginException
)
}
else -> Log.e(
"Edit login",
"Failed to save edited login.",
loginException
)
}
}
} }
true true
} }
else -> false else -> false
} }
// TODO: Move interactions with the component's password storage into a separate datastore
// This includes Delete, Update/Edit, Create
private fun attemptSaveAndExit() {
var saveLoginJob: Deferred<Unit>? = null
viewLifecycleOwner.lifecycleScope.launch(IO) {
saveLoginJob = async {
val oldLogin =
requireContext().components.core.passwordsStorage.get(args.savedLoginItem.guid)
// Update requires a Login type, which needs at least one of
// httpRealm or formActionOrigin
val loginToSave = Login(
guid = oldLogin?.guid,
origin = oldLogin?.origin!!,
username = usernameText.text.toString(), // new value
password = passwordText.text.toString(), // new value
httpRealm = oldLogin.httpRealm,
formActionOrigin = oldLogin.formActionOrigin
)
save(loginToSave)
syncAndUpdateList(loginToSave)
}
saveLoginJob?.await()
withContext(Main) {
val directions =
EditLoginFragmentDirections
.actionEditLoginFragmentToLoginDetailFragment(args.savedLoginItem.guid)
findNavController().navigate(directions)
}
}
saveLoginJob?.invokeOnCompletion {
if (it is CancellationException) {
saveLoginJob?.cancel()
}
}
}
private suspend fun save(loginToSave: Login) =
requireContext().components.core.passwordsStorage.update(loginToSave)
private fun syncAndUpdateList(updatedLogin: Login) {
val login = updatedLogin.mapToSavedLogin()
savedLoginsStore.dispatch(LoginsAction.UpdateLoginsList(listOf(login)))
}
// TODO: create helper class for toggling passwords. Used in login info and edit fragments.
private fun togglePasswordReveal() {
val currText = passwordText.text
if (passwordText.inputType == InputType.TYPE_TEXT_VARIATION_PASSWORD
or InputType.TYPE_CLASS_TEXT
) {
context?.components?.analytics?.metrics?.track(Event.ViewLoginPassword)
passwordText.inputType = InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
revealPasswordButton.setImageDrawable(
resources.getDrawable(R.drawable.mozac_ic_password_hide, null)
)
revealPasswordButton.contentDescription =
resources.getString(R.string.saved_login_hide_password)
} else {
passwordText.inputType =
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
revealPasswordButton.setImageDrawable(
resources.getDrawable(R.drawable.mozac_ic_password_reveal, null)
)
revealPasswordButton.contentDescription =
context?.getString(R.string.saved_login_reveal_password)
}
// For the new type to take effect you need to reset the text to it's current edited version
passwordText?.text = currText
}
} }

View File

@ -2,7 +2,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins package org.mozilla.fenix.settings.logins.fragment
import android.content.DialogInterface import android.content.DialogInterface
import android.os.Bundle import android.os.Bundle
@ -21,16 +21,8 @@ import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.fragment_login_detail.* import kotlinx.android.synthetic.main.fragment_login_detail.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.ObsoleteCoroutinesApi import kotlinx.coroutines.ObsoleteCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mozilla.components.concept.storage.Login
import mozilla.components.lib.state.ext.consumeFrom import mozilla.components.lib.state.ext.consumeFrom
import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.FeatureFlags
@ -42,9 +34,16 @@ import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.increaseTapArea import org.mozilla.fenix.ext.increaseTapArea
import org.mozilla.fenix.ext.redirectToReAuth import org.mozilla.fenix.ext.redirectToReAuth
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.ext.urlToTrimmedHost import org.mozilla.fenix.ext.simplifiedUrl
import org.mozilla.fenix.settings.logins.LoginsFragmentStore
import org.mozilla.fenix.settings.logins.LoginsListState
import org.mozilla.fenix.settings.logins.SavedLogin
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
import org.mozilla.fenix.settings.logins.interactor.LoginDetailInteractor
import org.mozilla.fenix.settings.logins.view.LoginDetailView
/** /**
* Displays saved login information for a single website. * Displays saved login information for a single website.
@ -57,8 +56,10 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
private var login: SavedLogin? = null private var login: SavedLogin? = null
private lateinit var savedLoginsStore: LoginsFragmentStore private lateinit var savedLoginsStore: LoginsFragmentStore
private lateinit var loginDetailView: LoginDetailView private lateinit var loginDetailView: LoginDetailView
private lateinit var interactor: LoginDetailInteractor
private lateinit var menu: Menu private lateinit var menu: Menu
private var deleteDialog: AlertDialog? = null private var deleteDialog: AlertDialog? = null
private var showPassword = true
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -74,12 +75,14 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
filteredItems = listOf(), filteredItems = listOf(),
searchedForText = null, searchedForText = null,
sortingStrategy = requireContext().settings().savedLoginsSortingStrategy, sortingStrategy = requireContext().settings().savedLoginsSortingStrategy,
highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem,
duplicateLogins = listOf() // assume on load there are no dupes
) )
) )
} }
loginDetailView = LoginDetailView(view?.findViewById(R.id.loginDetailLayout)) loginDetailView = LoginDetailView(
fetchLoginDetails() view.findViewById(R.id.loginDetailLayout)
)
return view return view
} }
@ -87,16 +90,29 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
@ObsoleteCoroutinesApi @ObsoleteCoroutinesApi
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
interactor = LoginDetailInteractor(
SavedLoginsStorageController(
context = requireContext(),
viewLifecycleScope = viewLifecycleOwner.lifecycleScope,
navController = findNavController(),
loginsFragmentStore = savedLoginsStore
)
)
interactor.onFetchLoginList(args.savedLoginId)
consumeFrom(savedLoginsStore) { consumeFrom(savedLoginsStore) {
loginDetailView.update(it) loginDetailView.update(it)
login = savedLoginsStore.state.currentItem login = savedLoginsStore.state.currentItem
setUpCopyButtons() setUpCopyButtons()
showToolbar( showToolbar(
savedLoginsStore.state.currentItem?.origin?.urlToTrimmedHost(requireContext()) savedLoginsStore.state.currentItem?.origin?.simplifiedUrl()
?: "" ?: ""
) )
setUpPasswordReveal() setUpPasswordReveal()
} }
loginDetailView.togglePasswordReveal(showPassword)
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -124,7 +140,11 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
revealPasswordButton.increaseTapArea(BUTTON_INCREASE_DPS) revealPasswordButton.increaseTapArea(BUTTON_INCREASE_DPS)
revealPasswordButton.setOnClickListener { revealPasswordButton.setOnClickListener {
togglePasswordReveal() showPassword = !showPassword
loginDetailView.togglePasswordReveal(!showPassword)
}
passwordText.setOnClickListener {
loginDetailView.togglePasswordReveal(!showPassword)
} }
} }
@ -149,33 +169,6 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
) )
} }
// TODO: Move interactions with the component's password storage into a separate datastore
private fun fetchLoginDetails() {
var deferredLogin: Deferred<List<Login>>? = null
val fetchLoginJob = viewLifecycleOwner.lifecycleScope.launch(IO) {
deferredLogin = async {
requireContext().components.core.passwordsStorage.list()
}
val fetchedLoginList = deferredLogin?.await()
fetchedLoginList?.let {
withContext(Main) {
val login = fetchedLoginList.filter {
it.guid == args.savedLoginId
}.first()
savedLoginsStore.dispatch(
LoginsAction.UpdateCurrentLogin(login.mapToSavedLogin())
)
}
}
}
fetchLoginJob.invokeOnCompletion {
if (it is CancellationException) {
deferredLogin?.cancel()
}
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
if (FeatureFlags.loginsEdit) { if (FeatureFlags.loginsEdit) {
inflater.inflate(R.menu.login_options_menu, menu) inflater.inflate(R.menu.login_options_menu, menu)
@ -206,9 +199,11 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
} }
private fun editLogin() { private fun editLogin() {
requireComponents.analytics.metrics.track(Event.EditLogin)
val directions = val directions =
LoginDetailFragmentDirections LoginDetailFragmentDirections.actionLoginDetailFragmentToEditLoginFragment(
.actionLoginDetailFragmentToEditLoginFragment(login!!) login!!
)
findNavController().navigate(directions) findNavController().navigate(directions)
} }
@ -220,7 +215,8 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
dialog.cancel() dialog.cancel()
} }
setPositiveButton(R.string.dialog_delete_positive) { dialog: DialogInterface, _ -> setPositiveButton(R.string.dialog_delete_positive) { dialog: DialogInterface, _ ->
deleteLogin() requireComponents.analytics.metrics.track(Event.DeleteLogin)
interactor.onDeleteLogin(args.savedLoginId)
dialog.dismiss() dialog.dismiss()
} }
create() create()
@ -228,49 +224,6 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
} }
} }
// TODO: Move interactions with the component's password storage into a separate datastore
// This includes Delete, Update/Edit, Create
private fun deleteLogin() {
var deleteLoginJob: Deferred<Boolean>? = null
val deleteJob = viewLifecycleOwner.lifecycleScope.launch(IO) {
deleteLoginJob = async {
requireContext().components.core.passwordsStorage.delete(args.savedLoginId)
}
deleteLoginJob?.await()
withContext(Main) {
findNavController().popBackStack(R.id.savedLoginsFragment, false)
}
}
deleteJob.invokeOnCompletion {
if (it is CancellationException) {
deleteLoginJob?.cancel()
}
}
}
// TODO: create helper class for toggling passwords. Used in login info and edit fragments.
private fun togglePasswordReveal() {
if (passwordText.inputType == InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT) {
context?.components?.analytics?.metrics?.track(Event.ViewLoginPassword)
passwordText.inputType = InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
revealPasswordButton.setImageDrawable(
resources.getDrawable(R.drawable.mozac_ic_password_hide, null)
)
revealPasswordButton.contentDescription =
resources.getString(R.string.saved_login_hide_password)
} else {
passwordText.inputType =
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
revealPasswordButton.setImageDrawable(
resources.getDrawable(R.drawable.mozac_ic_password_reveal, null)
)
revealPasswordButton.contentDescription =
context?.getString(R.string.saved_login_reveal_password)
}
// For the new type to take effect you need to reset the text
passwordText.text = login?.password
}
/** /**
* Click listener for a textview's copy button. * Click listener for a textview's copy button.
* @param value Value to be copied * @param value Value to be copied

View File

@ -2,7 +2,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins package org.mozilla.fenix.settings.logins.fragment
import android.annotation.TargetApi import android.annotation.TargetApi
import android.app.Activity.RESULT_OK import android.app.Activity.RESULT_OK
@ -313,7 +313,8 @@ class SavedLoginsAuthFragment : PreferenceFragmentCompat(), AccountObserver {
} }
private fun navigateToAccountProblemFragment() { private fun navigateToAccountProblemFragment() {
val directions = SavedLoginsAuthFragmentDirections.actionGlobalAccountProblemFragment() val directions =
SavedLoginsAuthFragmentDirections.actionGlobalAccountProblemFragment()
findNavController().navigate(directions) findNavController().navigate(directions)
} }

View File

@ -2,7 +2,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins package org.mozilla.fenix.settings.logins.fragment
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
@ -21,17 +21,9 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import kotlinx.android.synthetic.main.fragment_saved_logins.view.* import kotlinx.android.synthetic.main.fragment_saved_logins.view.*
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.ObsoleteCoroutinesApi import kotlinx.coroutines.ObsoleteCoroutinesApi
import mozilla.components.browser.menu.BrowserMenu import mozilla.components.browser.menu.BrowserMenu
import mozilla.components.concept.storage.Login
import mozilla.components.lib.state.ext.consumeFrom import mozilla.components.lib.state.ext.consumeFrom
import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.HomeActivity
@ -41,17 +33,28 @@ import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.redirectToReAuth import org.mozilla.fenix.ext.redirectToReAuth
import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.settings.logins.LoginsAction
import org.mozilla.fenix.settings.logins.LoginsFragmentStore
import org.mozilla.fenix.settings.logins.controller.LoginsListController
import org.mozilla.fenix.settings.logins.LoginsListState
import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor
import org.mozilla.fenix.settings.logins.SavedLoginsSortingStrategyMenu
import org.mozilla.fenix.settings.logins.view.SavedLoginsListView
import org.mozilla.fenix.settings.logins.SortingStrategy
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
@SuppressWarnings("TooManyFunctions") @SuppressWarnings("TooManyFunctions")
class SavedLoginsFragment : Fragment() { class SavedLoginsFragment : Fragment() {
private lateinit var savedLoginsStore: LoginsFragmentStore private lateinit var savedLoginsStore: LoginsFragmentStore
private lateinit var savedLoginsView: SavedLoginsView private lateinit var savedLoginsListView: SavedLoginsListView
private lateinit var savedLoginsInteractor: SavedLoginsInteractor private lateinit var savedLoginsInteractor: SavedLoginsInteractor
private lateinit var dropDownMenuAnchorView: View private lateinit var dropDownMenuAnchorView: View
private lateinit var sortingStrategyMenu: SavedLoginsSortingStrategyMenu private lateinit var sortingStrategyMenu: SavedLoginsSortingStrategyMenu
private lateinit var sortingStrategyPopupMenu: BrowserMenu private lateinit var sortingStrategyPopupMenu: BrowserMenu
private lateinit var toolbarChildContainer: FrameLayout private lateinit var toolbarChildContainer: FrameLayout
private lateinit var sortLoginsMenuRoot: ConstraintLayout private lateinit var sortLoginsMenuRoot: ConstraintLayout
private lateinit var loginsListController: LoginsListController
private lateinit var savedLoginsStorageController: SavedLoginsStorageController
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
@ -81,21 +84,39 @@ class SavedLoginsFragment : Fragment() {
filteredItems = listOf(), filteredItems = listOf(),
searchedForText = null, searchedForText = null,
sortingStrategy = requireContext().settings().savedLoginsSortingStrategy, sortingStrategy = requireContext().settings().savedLoginsSortingStrategy,
highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem,
duplicateLogins = listOf() // assume on load there are no dupes
) )
) )
} }
val savedLoginsController: SavedLoginsController =
SavedLoginsController( loginsListController =
store = savedLoginsStore, LoginsListController(
navController = findNavController(), loginsFragmentStore = savedLoginsStore,
browserNavigator = ::openToBrowserAndLoad, navController = findNavController(),
settings = requireContext().settings(), browserNavigator = ::openToBrowserAndLoad,
metrics = requireContext().components.analytics.metrics settings = requireContext().settings(),
metrics = requireContext().components.analytics.metrics
) )
savedLoginsInteractor = SavedLoginsInteractor(savedLoginsController) savedLoginsStorageController =
savedLoginsView = SavedLoginsView(view.savedLoginsLayout, savedLoginsInteractor) SavedLoginsStorageController(
loadAndMapLogins() context = requireContext(),
viewLifecycleScope = viewLifecycleOwner.lifecycleScope,
navController = findNavController(),
loginsFragmentStore = savedLoginsStore
)
savedLoginsInteractor =
SavedLoginsInteractor(
loginsListController,
savedLoginsStorageController
)
savedLoginsListView = SavedLoginsListView(
view.savedLoginsLayout,
savedLoginsInteractor
)
savedLoginsInteractor.loadAndMapLogins()
return view return view
} }
@ -105,7 +126,7 @@ class SavedLoginsFragment : Fragment() {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
consumeFrom(savedLoginsStore) { consumeFrom(savedLoginsStore) {
sortingStrategyMenu.updateMenu(savedLoginsStore.state.highlightedItem) sortingStrategyMenu.updateMenu(savedLoginsStore.state.highlightedItem)
savedLoginsView.update(it) savedLoginsListView.update(it)
} }
} }
@ -122,7 +143,11 @@ class SavedLoginsFragment : Fragment() {
} }
override fun onQueryTextChange(newText: String?): Boolean { override fun onQueryTextChange(newText: String?): Boolean {
savedLoginsStore.dispatch(LoginsAction.FilterLogins(newText)) savedLoginsStore.dispatch(
LoginsAction.FilterLogins(
newText
)
)
return false return false
} }
}) })
@ -141,31 +166,11 @@ class SavedLoginsFragment : Fragment() {
super.onPause() super.onPause()
} }
private fun openToBrowserAndLoad(searchTermOrURL: String, newTab: Boolean, from: BrowserDirection) { private fun openToBrowserAndLoad(
(activity as HomeActivity).openToBrowserAndLoad(searchTermOrURL, newTab, from) searchTermOrURL: String,
} newTab: Boolean,
from: BrowserDirection
private fun loadAndMapLogins() { ) = (activity as HomeActivity).openToBrowserAndLoad(searchTermOrURL, newTab, from)
var deferredLogins: Deferred<List<Login>>? = null
val fetchLoginsJob = viewLifecycleOwner.lifecycleScope.launch(IO) {
deferredLogins = async {
requireContext().components.core.passwordsStorage.list()
}
val logins = deferredLogins?.await()
logins?.let {
withContext(Main) {
savedLoginsStore.dispatch(
LoginsAction.UpdateLoginsList(logins.map { it.mapToSavedLogin() })
)
}
}
}
fetchLoginsJob.invokeOnCompletion {
if (it is CancellationException) {
deferredLogins?.cancel()
}
}
}
private fun initToolbar() { private fun initToolbar() {
showToolbar(getString(R.string.preferences_passwords_saved_logins)) showToolbar(getString(R.string.preferences_passwords_saved_logins))
@ -175,8 +180,12 @@ class SavedLoginsFragment : Fragment() {
sortLoginsMenuRoot = inflateSortLoginsMenuRoot() sortLoginsMenuRoot = inflateSortLoginsMenuRoot()
dropDownMenuAnchorView = sortLoginsMenuRoot.findViewById(R.id.drop_down_menu_anchor_view) dropDownMenuAnchorView = sortLoginsMenuRoot.findViewById(R.id.drop_down_menu_anchor_view)
when (requireContext().settings().savedLoginsSortingStrategy) { when (requireContext().settings().savedLoginsSortingStrategy) {
is SortingStrategy.Alphabetically -> setupMenu(SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort) is SortingStrategy.Alphabetically -> setupMenu(
is SortingStrategy.LastUsed -> setupMenu(SavedLoginsSortingStrategyMenu.Item.LastUsedSort) SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort
)
is SortingStrategy.LastUsed -> setupMenu(
SavedLoginsSortingStrategyMenu.Item.LastUsedSort
)
} }
} }
@ -210,21 +219,29 @@ class SavedLoginsFragment : Fragment() {
} }
private fun setupMenu(itemToHighlight: SavedLoginsSortingStrategyMenu.Item) { private fun setupMenu(itemToHighlight: SavedLoginsSortingStrategyMenu.Item) {
sortingStrategyMenu = SavedLoginsSortingStrategyMenu(requireContext(), itemToHighlight) { sortingStrategyMenu =
when (it) { SavedLoginsSortingStrategyMenu(
SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort -> { requireContext(),
savedLoginsInteractor.onSortingStrategyChanged( itemToHighlight
SortingStrategy.Alphabetically(requireContext().applicationContext) ) {
) when (it) {
} SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort -> {
savedLoginsInteractor.onSortingStrategyChanged(
SortingStrategy.Alphabetically(
requireContext().applicationContext
)
)
}
SavedLoginsSortingStrategyMenu.Item.LastUsedSort -> { SavedLoginsSortingStrategyMenu.Item.LastUsedSort -> {
savedLoginsInteractor.onSortingStrategyChanged( savedLoginsInteractor.onSortingStrategyChanged(
SortingStrategy.LastUsed(requireContext().applicationContext) SortingStrategy.LastUsed(
) requireContext().applicationContext
)
)
}
} }
} }
}
attachMenu() attachMenu()
} }

View File

@ -2,7 +2,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins package org.mozilla.fenix.settings.logins.fragment
import android.os.Bundle import android.os.Bundle
import androidx.preference.Preference import androidx.preference.Preference

View File

@ -0,0 +1,24 @@
/* 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.logins.interactor
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
/**
* Interactor for the edit login screen
*
* @property savedLoginsController controller for the saved logins storage
*/
class EditLoginInteractor(
private val savedLoginsController: SavedLoginsStorageController
) {
fun findPotentialDuplicates(loginId: String) {
savedLoginsController.findPotentialDuplicates(loginId)
}
fun onSaveLogin(loginId: String, usernameText: String, passwordText: String) {
savedLoginsController.save(loginId, usernameText, passwordText)
}
}

View File

@ -0,0 +1,24 @@
/* 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.logins.interactor
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
/**
* Interactor for the login detail screen
*
* @property savedLoginsController controller for the saved logins storage
*/
class LoginDetailInteractor(
private val savedLoginsController: SavedLoginsStorageController
) {
fun onFetchLoginList(loginId: String) {
savedLoginsController.fetchLoginDetails(loginId)
}
fun onDeleteLogin(loginId: String) {
savedLoginsController.delete(loginId)
}
}

View File

@ -0,0 +1,39 @@
/* 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.logins.interactor
import org.mozilla.fenix.settings.logins.SavedLogin
import org.mozilla.fenix.settings.logins.SortingStrategy
import org.mozilla.fenix.settings.logins.controller.LoginsListController
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
/**
* Interactor for the saved logins screen
*
* @param loginsListController [LoginsListController] which will be delegated for all
* user interactions.
* @param savedLoginsStorageController [SavedLoginsStorageController] which will be delegated
* for all calls to the password storage component
*/
class SavedLoginsInteractor(
private val loginsListController: LoginsListController,
private val savedLoginsStorageController: SavedLoginsStorageController
) {
fun onItemClicked(item: SavedLogin) {
loginsListController.handleItemClicked(item)
}
fun onLearnMoreClicked() {
loginsListController.handleLearnMoreClicked()
}
fun onSortingStrategyChanged(sortingStrategy: SortingStrategy) {
loginsListController.handleSort(sortingStrategy)
}
fun loadAndMapLogins() {
savedLoginsStorageController.handleLoadAndMapLogins()
}
}

View File

@ -0,0 +1,54 @@
/* 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.logins.view
import android.text.InputType
import android.view.ViewGroup
import androidx.appcompat.content.res.AppCompatResources
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.fragment_edit_login.view.*
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
/**
* View that contains and configures the Edit Login screen
*/
@Suppress("ForbiddenComment")
class EditLoginView(
override val containerView: ViewGroup
) : LayoutContainer {
private val context = containerView.context
// TODO: create helper class for toggling passwords. https://github.com/mozilla-mobile/fenix/issues/12554
fun showPassword() {
val currText = containerView.passwordText?.text
context.components.analytics.metrics.track(Event.ViewLoginPassword)
containerView.passwordText?.inputType =
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
containerView.revealPasswordButton?.setImageDrawable(
AppCompatResources.getDrawable(context, R.drawable.mozac_ic_password_hide)
)
containerView.revealPasswordButton?.contentDescription =
context.resources.getString(R.string.saved_login_hide_password)
// For the new type to take effect you need to reset the text to it's current edited version
containerView.passwordText?.text = currText
}
fun hidePassword() {
val currText = containerView.passwordText?.text
containerView.passwordText?.inputType =
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
containerView.revealPasswordButton?.setImageDrawable(
AppCompatResources.getDrawable(context, R.drawable.mozac_ic_password_reveal)
)
containerView.revealPasswordButton?.contentDescription =
context.getString(R.string.saved_login_reveal_password)
// For the new type to take effect you need to reset the text to it's current edited version
containerView.passwordText?.text = currText
}
}

View File

@ -0,0 +1,65 @@
/* 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.logins.view
import android.text.InputType
import android.view.ViewGroup
import androidx.core.content.res.ResourcesCompat
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.fragment_login_detail.*
import kotlinx.android.synthetic.main.fragment_login_detail.view.*
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.settings.logins.LoginsListState
/**
* View that contains and configures the Login Details
*/
@Suppress("ForbiddenComment")
class LoginDetailView(override val containerView: ViewGroup) : LayoutContainer {
private val context = containerView.context
fun update(login: LoginsListState) {
webAddressText.text = login.currentItem?.origin
usernameText.text = login.currentItem?.username
passwordText.text = login.currentItem?.password
}
fun togglePasswordReveal(show: Boolean) {
if (show) showPassword() else { hidePassword() }
}
// TODO: create helper class for toggling passwords. https://github.com/mozilla-mobile/fenix/issues/12554
fun showPassword() {
if (passwordText.inputType == InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT) {
context?.components?.analytics?.metrics?.track(Event.ViewLoginPassword)
passwordText.inputType = InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
revealPasswordButton.setImageDrawable(
ResourcesCompat.getDrawable(
context.resources,
R.drawable.mozac_ic_password_hide, null
)
)
revealPasswordButton.contentDescription =
context.resources.getString(R.string.saved_login_hide_password)
}
// For the new type to take effect you need to reset the text
passwordText.text = containerView.passwordText.editableText
}
fun hidePassword() {
passwordText.inputType =
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
revealPasswordButton.setImageDrawable(
ResourcesCompat.getDrawable(context.resources,
R.drawable.mozac_ic_password_reveal, null)
)
revealPasswordButton.contentDescription =
context.getString(R.string.saved_login_reveal_password)
// For the new type to take effect you need to reset the text
passwordText.text = containerView.passwordText.editableText
}
}

View File

@ -2,13 +2,15 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins package org.mozilla.fenix.settings.logins.view
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.settings.logins.SavedLogin
import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor
class LoginsAdapter( class LoginsAdapter(
private val interactor: SavedLoginsInteractor private val interactor: SavedLoginsInteractor

View File

@ -2,13 +2,15 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins package org.mozilla.fenix.settings.logins.view
import android.view.View import android.view.View
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.logins_item.view.* import kotlinx.android.synthetic.main.logins_item.view.*
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.settings.logins.SavedLogin
import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor
class LoginsListViewHolder( class LoginsListViewHolder(
private val view: View, private val view: View,

View File

@ -0,0 +1,67 @@
/* 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.logins.view
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.component_saved_logins.view.*
import org.mozilla.fenix.R
import org.mozilla.fenix.settings.logins.LoginsListState
import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor
import org.mozilla.fenix.ext.addUnderline
/**
* View that contains and configures the Saved Logins List
*/
class SavedLoginsListView(
override val containerView: ViewGroup,
val interactor: SavedLoginsInteractor
) : LayoutContainer {
val view: FrameLayout = LayoutInflater.from(containerView.context)
.inflate(R.layout.component_saved_logins, containerView, true)
.findViewById(R.id.saved_logins_wrapper)
private val loginsAdapter = LoginsAdapter(interactor)
init {
view.saved_logins_list.apply {
adapter = loginsAdapter
layoutManager = LinearLayoutManager(containerView.context)
itemAnimator = null
}
with(view.saved_passwords_empty_learn_more) {
movementMethod = LinkMovementMethod.getInstance()
addUnderline()
setOnClickListener { interactor.onLearnMoreClicked() }
}
with(view.saved_passwords_empty_message) {
val appName = context.getString(R.string.app_name)
text = String.format(
context.getString(
R.string.preferences_passwords_saved_logins_description_empty_text
), appName
)
}
}
fun update(state: LoginsListState) {
if (state.isLoading) {
view.progress_bar.isVisible = true
} else {
view.progress_bar.isVisible = false
view.saved_logins_list.isVisible = state.loginList.isNotEmpty()
view.saved_passwords_empty_view.isVisible = state.loginList.isEmpty()
}
loginsAdapter.submitList(state.filteredItems)
}
}

View File

@ -16,9 +16,9 @@ import org.mozilla.fenix.R
import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.requireComponents
/** /**
* Dialog displayed the first time the user navigates to an installable web app. * Dialog displayed the third time the user navigates to an installable web app.
*/ */
class FirstTimePwaFragment : DialogFragment() { class PwaOnboardingDialogFragment : DialogFragment() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setStyle(STYLE_NO_TITLE, R.style.CreateShortcutDialogStyle) setStyle(STYLE_NO_TITLE, R.style.CreateShortcutDialogStyle)
@ -28,7 +28,7 @@ class FirstTimePwaFragment : DialogFragment() {
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View? = inflater.inflate(R.layout.fragment_pwa_first_time, container, false) ): View? = inflater.inflate(R.layout.fragment_pwa_onboarding, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)

View File

@ -14,20 +14,23 @@ import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.utils.Settings import org.mozilla.fenix.utils.Settings
/** /**
* Displays the [FirstTimePwaFragment] info dialog when a PWA is first opened in the browser. * Displays the [PwaOnboardingDialogFragment] info dialog when a PWA is opened in the browser for the third time.
*/ */
class FirstTimePwaObserver( class PwaOnboardingObserver(
private val navController: NavController, private val navController: NavController,
private val settings: Settings, private val settings: Settings,
private val webAppUseCases: WebAppUseCases private val webAppUseCases: WebAppUseCases
) : Session.Observer { ) : Session.Observer {
override fun onWebAppManifestChanged(session: Session, manifest: WebAppManifest?) { override fun onWebAppManifestChanged(session: Session, manifest: WebAppManifest?) {
if (webAppUseCases.isInstallable() && settings.shouldShowFirstTimePwaFragment) { if (webAppUseCases.isInstallable() && !settings.userKnowsAboutPwas) {
val directions = BrowserFragmentDirections.actionBrowserFragmentToFirstTimePwaFragment() settings.incrementVisitedInstallableCount()
navController.nav(R.id.browserFragment, directions) if (settings.shouldShowPwaOnboarding) {
val directions =
settings.userKnowsAboutPWAs = true BrowserFragmentDirections.actionBrowserFragmentToPwaOnboardingDialogFragment()
navController.nav(R.id.browserFragment, directions)
settings.userKnowsAboutPwas = true
}
} }
} }
} }

View File

@ -9,6 +9,7 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowManager
import androidx.appcompat.app.AppCompatDialogFragment import androidx.appcompat.app.AppCompatDialogFragment
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
@ -25,6 +26,7 @@ import mozilla.components.browser.session.SessionManager
import mozilla.components.browser.state.selector.normalTabs import mozilla.components.browser.state.selector.normalTabs
import mozilla.components.browser.state.selector.privateTabs import mozilla.components.browser.state.selector.privateTabs
import mozilla.components.browser.state.state.BrowserState import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.TabSessionState
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.tabs.tabstray.TabsFeature import mozilla.components.feature.tabs.tabstray.TabsFeature
@ -37,6 +39,7 @@ import org.mozilla.fenix.components.TabCollectionStorage
import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.utils.allowUndo import org.mozilla.fenix.utils.allowUndo
@SuppressWarnings("TooManyFunctions", "LargeClass") @SuppressWarnings("TooManyFunctions", "LargeClass")
@ -48,7 +51,7 @@ class TabTrayDialogFragment : AppCompatDialogFragment() {
private val snackbarAnchor: View? private val snackbarAnchor: View?
get() = if (tabTrayView.fabView.new_tab_button.isVisible) tabTrayView.fabView.new_tab_button get() = if (tabTrayView.fabView.new_tab_button.isVisible) tabTrayView.fabView.new_tab_button
else null else null
private val collectionStorageObserver = object : TabCollectionStorage.Observer { private val collectionStorageObserver = object : TabCollectionStorage.Observer {
override fun onCollectionCreated(title: String, sessions: List<Session>) { override fun onCollectionCreated(title: String, sessions: List<Session>) {
@ -131,7 +134,13 @@ class TabTrayDialogFragment : AppCompatDialogFragment() {
startingInLandscape = requireContext().resources.configuration.orientation == startingInLandscape = requireContext().resources.configuration.orientation ==
Configuration.ORIENTATION_LANDSCAPE, Configuration.ORIENTATION_LANDSCAPE,
lifecycleScope = viewLifecycleOwner.lifecycleScope lifecycleScope = viewLifecycleOwner.lifecycleScope
) { tabsFeature.get()?.filterTabs(it) } ) { private ->
val filter: (TabSessionState) -> Boolean = { state -> private == state.content.private }
tabsFeature.get()?.filterTabs(filter)
setSecureFlagsIfNeeded(private)
}
tabsFeature.set( tabsFeature.set(
TabsFeature( TabsFeature(
@ -171,6 +180,14 @@ class TabTrayDialogFragment : AppCompatDialogFragment() {
} }
} }
private fun setSecureFlagsIfNeeded(private: Boolean) {
if (private && context?.settings()?.allowScreenshotsInPrivateMode == false) {
dialog?.window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
} else if (!(activity as HomeActivity).browsingModeManager.mode.isPrivate) {
dialog?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
private fun showUndoSnackbarForTab(sessionId: String) { private fun showUndoSnackbarForTab(sessionId: String) {
val sessionManager = view?.context?.components?.core?.sessionManager val sessionManager = view?.context?.components?.core?.sessionManager
val snapshot = sessionManager val snapshot = sessionManager

View File

@ -27,7 +27,6 @@ import mozilla.components.browser.menu.item.SimpleBrowserMenuItem
import mozilla.components.browser.state.selector.normalTabs import mozilla.components.browser.state.selector.normalTabs
import mozilla.components.browser.state.selector.privateTabs import mozilla.components.browser.state.selector.privateTabs
import mozilla.components.browser.state.state.BrowserState import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.tabstray.BrowserTabsTray import mozilla.components.browser.tabstray.BrowserTabsTray
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.Event
@ -44,7 +43,7 @@ class TabTrayView(
isPrivate: Boolean, isPrivate: Boolean,
startingInLandscape: Boolean, startingInLandscape: Boolean,
lifecycleScope: LifecycleCoroutineScope, lifecycleScope: LifecycleCoroutineScope,
private val filterTabs: ((TabSessionState) -> Boolean) -> Unit private val filterTabs: (Boolean) -> Unit
) : LayoutContainer, TabLayout.OnTabSelectedListener { ) : LayoutContainer, TabLayout.OnTabSelectedListener {
val fabView = LayoutInflater.from(container.context) val fabView = LayoutInflater.from(container.context)
.inflate(R.layout.component_tabstray_fab, container, true) .inflate(R.layout.component_tabstray_fab, container, true)
@ -204,14 +203,8 @@ class TabTrayView(
} }
override fun onTabSelected(tab: TabLayout.Tab?) { override fun onTabSelected(tab: TabLayout.Tab?) {
// We need a better way to determine which tab was selected.
val filter: (TabSessionState) -> Boolean = when (tab?.position) {
1 -> { state -> state.content.private }
else -> { state -> !state.content.private }
}
toggleFabText(isPrivateModeSelected) toggleFabText(isPrivateModeSelected)
filterTabs.invoke(filter) filterTabs.invoke(isPrivateModeSelected)
updateState(view.context.components.core.store.state) updateState(view.context.components.core.store.state)
scrollToTab(view.context.components.core.store.state.selectedTabId) scrollToTab(view.context.components.core.store.state.selectedTabId)

View File

@ -33,7 +33,7 @@ import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getPreferenceKey import org.mozilla.fenix.ext.getPreferenceKey
import org.mozilla.fenix.settings.PhoneFeature import org.mozilla.fenix.settings.PhoneFeature
import org.mozilla.fenix.settings.deletebrowsingdata.DeleteBrowsingDataOnQuitType import org.mozilla.fenix.settings.deletebrowsingdata.DeleteBrowsingDataOnQuitType
import org.mozilla.fenix.settings.logins.SavedLoginsFragment import org.mozilla.fenix.settings.logins.fragment.SavedLoginsFragment
import org.mozilla.fenix.settings.logins.SavedLoginsSortingStrategyMenu import org.mozilla.fenix.settings.logins.SavedLoginsSortingStrategyMenu
import org.mozilla.fenix.settings.logins.SortingStrategy import org.mozilla.fenix.settings.logins.SortingStrategy
import org.mozilla.fenix.settings.registerOnSharedPreferenceChangeListener import org.mozilla.fenix.settings.registerOnSharedPreferenceChangeListener
@ -53,6 +53,7 @@ class Settings private constructor(
const val showLoginsSecureWarningSyncMaxCount = 1 const val showLoginsSecureWarningSyncMaxCount = 1
const val showLoginsSecureWarningMaxCount = 1 const val showLoginsSecureWarningMaxCount = 1
const val trackingProtectionOnboardingMaximumCount = 1 const val trackingProtectionOnboardingMaximumCount = 1
const val pwaVisitsToShowPromptMaxCount = 3
const val FENIX_PREFERENCES = "fenix_preferences" const val FENIX_PREFERENCES = "fenix_preferences"
private const val showSearchWidgetCFRMaxCount = 3 private const val showSearchWidgetCFRMaxCount = 3
@ -146,9 +147,18 @@ class Settings private constructor(
// If any of the prefs have been modified, quit displaying the fenix moved tip // If any of the prefs have been modified, quit displaying the fenix moved tip
fun shouldDisplayFenixMovingTip(): Boolean = fun shouldDisplayFenixMovingTip(): Boolean =
preferences.getBoolean(appContext.getString(R.string.pref_key_migrating_from_fenix_nightly_tip), true) && preferences.getBoolean(
preferences.getBoolean(appContext.getString(R.string.pref_key_migrating_from_firefox_nightly_tip), true) && appContext.getString(R.string.pref_key_migrating_from_fenix_nightly_tip),
preferences.getBoolean(appContext.getString(R.string.pref_key_migrating_from_fenix_tip), true) true
) &&
preferences.getBoolean(
appContext.getString(R.string.pref_key_migrating_from_firefox_nightly_tip),
true
) &&
preferences.getBoolean(
appContext.getString(R.string.pref_key_migrating_from_fenix_tip),
true
)
private val activeSearchCount by intPreference( private val activeSearchCount by intPreference(
appContext.getPreferenceKey(R.string.pref_key_search_count), appContext.getPreferenceKey(R.string.pref_key_search_count),
@ -167,9 +177,9 @@ class Settings private constructor(
fun shouldDisplaySearchWidgetCFR(): Boolean = fun shouldDisplaySearchWidgetCFR(): Boolean =
isActiveSearcher && isActiveSearcher &&
searchWidgetCFRDismissCount < showSearchWidgetCFRMaxCount && searchWidgetCFRDismissCount < showSearchWidgetCFRMaxCount &&
!searchWidgetInstalled && !searchWidgetInstalled &&
!searchWidgetCFRManuallyDismissed !searchWidgetCFRManuallyDismissed
private val searchWidgetCFRDisplayCount by intPreference( private val searchWidgetCFRDisplayCount by intPreference(
appContext.getPreferenceKey(R.string.pref_key_search_widget_cfr_display_count), appContext.getPreferenceKey(R.string.pref_key_search_widget_cfr_display_count),
@ -236,10 +246,10 @@ class Settings private constructor(
val isCrashReportingEnabled: Boolean val isCrashReportingEnabled: Boolean
get() = isCrashReportEnabledInBuild && get() = isCrashReportEnabledInBuild &&
preferences.getBoolean( preferences.getBoolean(
appContext.getPreferenceKey(R.string.pref_key_crash_reporter), appContext.getPreferenceKey(R.string.pref_key_crash_reporter),
true true
) )
val isRemoteDebuggingEnabled by booleanPreference( val isRemoteDebuggingEnabled by booleanPreference(
appContext.getPreferenceKey(R.string.pref_key_remote_debugging), appContext.getPreferenceKey(R.string.pref_key_remote_debugging),
@ -267,7 +277,7 @@ class Settings private constructor(
val shouldShowTrackingProtectionOnboarding: Boolean val shouldShowTrackingProtectionOnboarding: Boolean
get() = !isOverrideTPPopupsForPerformanceTest && get() = !isOverrideTPPopupsForPerformanceTest &&
(trackingProtectionOnboardingCount < trackingProtectionOnboardingMaximumCount && (trackingProtectionOnboardingCount < trackingProtectionOnboardingMaximumCount &&
!trackingProtectionOnboardingShownThisSession) !trackingProtectionOnboardingShownThisSession)
var showSecretDebugMenuThisSession = false var showSecretDebugMenuThisSession = false
@ -418,14 +428,14 @@ class Settings private constructor(
BrowsingMode.Normal BrowsingMode.Normal
} }
} }
set(value) { set(value) {
val lastKnownModeWasPrivate = (value == BrowsingMode.Private) val lastKnownModeWasPrivate = (value == BrowsingMode.Private)
preferences.edit() preferences.edit()
.putBoolean( .putBoolean(
appContext.getPreferenceKey(R.string.pref_key_last_known_mode_private), appContext.getPreferenceKey(R.string.pref_key_last_known_mode_private),
lastKnownModeWasPrivate) lastKnownModeWasPrivate
)
.apply() .apply()
field = value field = value
@ -495,7 +505,9 @@ class Settings private constructor(
} }
val accessibilityServicesEnabled: Boolean val accessibilityServicesEnabled: Boolean
get() { return touchExplorationIsEnabled || switchServiceIsEnabled } get() {
return touchExplorationIsEnabled || switchServiceIsEnabled
}
val toolbarSettingString: String val toolbarSettingString: String
get() = when { get() = when {
@ -569,22 +581,41 @@ class Settings private constructor(
default = false default = false
) )
val shouldShowFirstTimePwaFragment: Boolean fun incrementVisitedInstallableCount() {
preferences.edit().putInt(
appContext.getPreferenceKey(R.string.pref_key_install_pwa_visits),
pwaInstallableVisitCount + 1
).apply()
}
@VisibleForTesting(otherwise = PRIVATE)
internal val pwaInstallableVisitCount by intPreference(
appContext.getPreferenceKey(R.string.pref_key_install_pwa_visits),
default = 0
)
private val userNeedsToVisitInstallableSites: Boolean
get() = pwaInstallableVisitCount < pwaVisitsToShowPromptMaxCount
val shouldShowPwaOnboarding: Boolean
get() { get() {
// We only want to show this on the 3rd time a user visits a site
if (userNeedsToVisitInstallableSites) return false
// ShortcutManager::pinnedShortcuts is only available on Oreo+ // ShortcutManager::pinnedShortcuts is only available on Oreo+
if (!userKnowsAboutPWAs && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (!userKnowsAboutPwas && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val alreadyHavePWaInstalled = val alreadyHavePwaInstalled =
appContext.getSystemService(ShortcutManager::class.java) appContext.getSystemService(ShortcutManager::class.java)
.pinnedShortcuts.size > 0 .pinnedShortcuts.size > 0
// Users know about PWAs onboarding if they already have PWAs installed. // Users know about PWAs onboarding if they already have PWAs installed.
userKnowsAboutPWAs = alreadyHavePWaInstalled userKnowsAboutPwas = alreadyHavePwaInstalled
} }
// Show dialog only if user does not know abut PWAs // Show dialog only if user does not know abut PWAs
return !userKnowsAboutPWAs return !userKnowsAboutPwas
} }
var userKnowsAboutPWAs by booleanPreference( var userKnowsAboutPwas by booleanPreference(
appContext.getPreferenceKey(R.string.pref_key_user_knows_about_pwa), appContext.getPreferenceKey(R.string.pref_key_user_knows_about_pwa),
default = false default = false
) )
@ -809,8 +840,12 @@ class Settings private constructor(
var savedLoginsSortingStrategy: SortingStrategy var savedLoginsSortingStrategy: SortingStrategy
get() { get() {
return when (savedLoginsSortingStrategyString) { return when (savedLoginsSortingStrategyString) {
SavedLoginsFragment.SORTING_STRATEGY_ALPHABETICALLY -> SortingStrategy.Alphabetically(appContext) SavedLoginsFragment.SORTING_STRATEGY_ALPHABETICALLY -> SortingStrategy.Alphabetically(
SavedLoginsFragment.SORTING_STRATEGY_LAST_USED -> SortingStrategy.LastUsed(appContext) appContext
)
SavedLoginsFragment.SORTING_STRATEGY_LAST_USED -> SortingStrategy.LastUsed(
appContext
)
else -> SortingStrategy.Alphabetically(appContext) else -> SortingStrategy.Alphabetically(appContext)
} }
} }

View File

@ -0,0 +1,8 @@
<?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/. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_enabled="true" android:color="?primaryText"/>
<item android:state_enabled="false" android:color="?disabled" />
</selector>

View File

@ -6,5 +6,5 @@
<item android:state_enabled="true" <item android:state_enabled="true"
android:color="?primaryText" /> android:color="?primaryText" />
<item android:state_enabled="false" <item android:state_enabled="false"
android:color="@android:color/transparent" /> android:color="?disabled" />
</selector> </selector>

View File

@ -2,24 +2,24 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public <!-- 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 - 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/. --> - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<LinearLayout <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/permissions_blocked_container" android:id="@+id/permissions_blocked_container"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:paddingStart="@dimen/radio_button_preference_horizontal" android:paddingStart="@dimen/radio_button_preference_horizontal"
android:paddingEnd="@dimen/radio_button_preference_horizontal" android:paddingEnd="@dimen/radio_button_preference_horizontal"
android:visibility="gone" android:visibility="gone"
tools:visibility="visible"> tools:visibility="visible">
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceListItem" android:textAppearance="?android:attr/textAppearanceListItem"
android:layout_marginStart="@dimen/site_permissions_exceptions_item_height"
android:text="@string/phone_feature_blocked_by_android" android:text="@string/phone_feature_blocked_by_android"
android:layout_marginBottom="16dp" /> android:layout_marginBottom="16dp" />
@ -27,21 +27,24 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceListItemSmall" android:textAppearance="?android:attr/textAppearanceListItemSmall"
android:layout_marginStart="@dimen/site_permissions_exceptions_item_height"
android:text="@string/phone_feature_blocked_intro" android:text="@string/phone_feature_blocked_intro"
android:layout_marginBottom="16dp"/> android:layout_marginBottom="16dp" />
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceListItemSmall" android:textAppearance="?android:attr/textAppearanceListItemSmall"
android:layout_marginStart="@dimen/site_permissions_exceptions_item_height"
android:text="@string/phone_feature_blocked_step_settings" android:text="@string/phone_feature_blocked_step_settings"
android:layout_marginBottom="8dp"/> android:layout_marginBottom="8dp" />
<TextView <TextView
android:id="@+id/blocked_by_android_permissions_label" android:id="@+id/blocked_by_android_permissions_label"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceListItemSmall" android:textAppearance="?android:attr/textAppearanceListItemSmall"
android:layout_marginStart="@dimen/site_permissions_exceptions_item_height"
tools:text="@string/phone_feature_blocked_step_permissions" tools:text="@string/phone_feature_blocked_step_permissions"
android:layout_marginBottom="8dp" /> android:layout_marginBottom="8dp" />
@ -50,7 +53,8 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceListItemSmall" android:textAppearance="?android:attr/textAppearanceListItemSmall"
tools:text="@string/phone_feature_blocked_step_feature"/> android:layout_marginStart="@dimen/site_permissions_exceptions_item_height"
tools:text="@string/phone_feature_blocked_step_feature" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/settings_button" android:id="@+id/settings_button"

View File

@ -134,12 +134,13 @@
android:id="@+id/clearUsernameTextButton" android:id="@+id/clearUsernameTextButton"
android:layout_width="48dp" android:layout_width="48dp"
android:layout_height="30dp" android:layout_height="30dp"
android:layout_marginTop="3dp"
android:layout_marginBottom="10dp" android:layout_marginBottom="10dp"
android:background="@null" android:background="@null"
android:contentDescription="@string/saved_login_copy_username" android:contentDescription="@string/saved_login_copy_username"
app:tint="@color/saved_login_clear_edit_text_tint" app:tint="@color/saved_login_clear_edit_text_tint"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="@id/inputLayoutUsername" app:layout_constraintTop_toTopOf="@id/inputLayoutUsername"
app:srcCompat="@drawable/ic_clear" /> app:srcCompat="@drawable/ic_clear" />
<TextView <TextView

View File

@ -19,7 +19,7 @@
android:visibility="gone" android:visibility="gone"
android:orientation="vertical"> android:orientation="vertical">
<Switch <com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/enable_switch" android:id="@+id/enable_switch"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -34,7 +34,7 @@
android:textColor="?primaryText" android:textColor="?primaryText"
android:textSize="16sp" /> android:textSize="16sp" />
<Switch <com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/allow_in_private_browsing_switch" android:id="@+id/allow_in_private_browsing_switch"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -93,7 +93,7 @@
android:textSize="16sp" android:textSize="16sp"
app:drawableStartCompat="@drawable/ic_permission" /> app:drawableStartCompat="@drawable/ic_permission" />
<Button <com.google.android.material.button.MaterialButton
android:id="@+id/remove_add_on" android:id="@+id/remove_add_on"
style="@style/DestructiveButton" style="@style/DestructiveButton"
android:layout_marginHorizontal="16dp" android:layout_marginHorizontal="16dp"

View File

@ -6,18 +6,18 @@
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="fill_parent" android:layout_height="fill_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:layout_marginTop="@dimen/radio_button_preference_vertical"> android:layout_marginTop="@dimen/radio_button_preference_vertical">
<RadioGroup <RadioGroup
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<RadioButton <RadioButton
android:id="@+id/ask_to_allow_radio" android:id="@+id/ask_to_allow_radio"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -26,13 +26,13 @@
android:background="?android:attr/selectableItemBackground" android:background="?android:attr/selectableItemBackground"
android:button="@null" android:button="@null"
app:drawableStartCompat="?android:attr/listChoiceIndicatorSingle" app:drawableStartCompat="?android:attr/listChoiceIndicatorSingle"
android:drawablePadding="@dimen/radio_button_preference_drawable_padding" android:drawablePadding="@dimen/preference_seek_bar_padding"
android:paddingTop="@dimen/radio_button_preference_vertical" android:paddingTop="@dimen/radio_button_preference_vertical"
android:paddingStart="@dimen/radio_button_preference_horizontal" android:paddingStart="@dimen/radio_button_preference_horizontal"
android:paddingEnd="@dimen/radio_button_preference_horizontal" android:paddingEnd="@dimen/radio_button_preference_horizontal"
android:paddingBottom="@dimen/radio_button_preference_vertical"/> android:paddingBottom="@dimen/radio_button_preference_vertical" />
<RadioButton <RadioButton
android:id="@+id/block_radio" android:id="@+id/block_radio"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="@dimen/radio_button_preference_height" android:layout_height="@dimen/radio_button_preference_height"
@ -41,15 +41,15 @@
android:background="?android:attr/selectableItemBackground" android:background="?android:attr/selectableItemBackground"
android:button="@null" android:button="@null"
app:drawableStartCompat="?android:attr/listChoiceIndicatorSingle" app:drawableStartCompat="?android:attr/listChoiceIndicatorSingle"
android:drawablePadding="@dimen/radio_button_preference_drawable_padding" android:drawablePadding="@dimen/preference_seek_bar_padding"
android:paddingTop="@dimen/radio_button_preference_vertical" android:paddingTop="@dimen/radio_button_preference_vertical"
android:paddingStart="@dimen/radio_button_preference_horizontal" android:paddingStart="@dimen/radio_button_preference_horizontal"
android:paddingEnd="@dimen/radio_button_preference_horizontal" android:paddingEnd="@dimen/radio_button_preference_horizontal"
android:paddingBottom="@dimen/radio_button_preference_vertical"/> android:paddingBottom="@dimen/radio_button_preference_vertical" />
</RadioGroup> </RadioGroup>
<include layout="@layout/layout_clear_permission_button"/> <include layout="@layout/layout_clear_permission_button" />
<include layout="@layout/component_permissions_blocked_by_android"/> <include layout="@layout/component_permissions_blocked_by_android" />
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>

View File

@ -7,76 +7,77 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="fill_parent"> android:layout_height="fill_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="@dimen/radio_button_preference_vertical">
<RadioGroup <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="@dimen/radio_button_preference_vertical">
<RadioButton <RadioGroup
android:id="@+id/ask_to_allow_radio"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content">
android:background="?android:attr/selectableItemBackground"
android:button="@null"
android:drawablePadding="@dimen/radio_button_preference_drawable_padding"
android:paddingStart="@dimen/radio_button_preference_horizontal"
android:paddingTop="@dimen/radio_button_preference_vertical"
android:paddingEnd="@dimen/radio_button_preference_horizontal"
android:paddingBottom="@dimen/radio_button_preference_vertical"
android:textAppearance="?android:attr/textAppearanceListItem"
app:drawableStartCompat="?android:attr/listChoiceIndicatorSingle"
tools:text="@string/preference_option_phone_feature_ask_to_allow" />
<RadioButton <RadioButton
android:id="@+id/block_radio" android:id="@+id/ask_to_allow_radio"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground" android:background="?android:attr/selectableItemBackground"
android:button="@null" android:button="@null"
android:drawablePadding="@dimen/radio_button_preference_drawable_padding" android:drawablePadding="@dimen/preference_seek_bar_padding"
android:paddingStart="@dimen/radio_button_preference_horizontal" android:paddingStart="@dimen/radio_button_preference_horizontal"
android:paddingTop="@dimen/radio_button_preference_vertical" android:paddingTop="@dimen/radio_button_preference_vertical"
android:paddingEnd="@dimen/radio_button_preference_horizontal" android:paddingEnd="@dimen/radio_button_preference_horizontal"
android:paddingBottom="@dimen/radio_button_preference_vertical" android:paddingBottom="@dimen/radio_button_preference_vertical"
android:textAppearance="?android:attr/textAppearanceListItem" android:textAppearance="?android:attr/textAppearanceListItem"
app:drawableStartCompat="?android:attr/listChoiceIndicatorSingle" app:drawableStartCompat="?android:attr/listChoiceIndicatorSingle"
tools:text="@string/preference_option_phone_feature_blocked" /> tools:text="@string/preference_option_phone_feature_ask_to_allow" />
<RadioButton <RadioButton
android:id="@+id/third_radio" android:id="@+id/block_radio"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground" android:background="?android:attr/selectableItemBackground"
android:button="@null" android:button="@null"
android:drawablePadding="@dimen/radio_button_preference_drawable_padding" android:drawablePadding="@dimen/preference_seek_bar_padding"
android:paddingStart="@dimen/radio_button_preference_horizontal" android:paddingStart="@dimen/radio_button_preference_horizontal"
android:paddingTop="@dimen/radio_button_preference_vertical" android:paddingTop="@dimen/radio_button_preference_vertical"
android:paddingEnd="@dimen/radio_button_preference_horizontal" android:paddingEnd="@dimen/radio_button_preference_horizontal"
android:paddingBottom="@dimen/radio_button_preference_vertical" android:paddingBottom="@dimen/radio_button_preference_vertical"
android:textAppearance="?android:attr/textAppearanceListItem" android:textAppearance="?android:attr/textAppearanceListItem"
app:drawableStartCompat="?android:attr/listChoiceIndicatorSingle" /> app:drawableStartCompat="?android:attr/listChoiceIndicatorSingle"
tools:text="@string/preference_option_phone_feature_blocked" />
<RadioButton <RadioButton
android:id="@+id/fourth_radio" android:id="@+id/third_radio"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground" android:background="?android:attr/selectableItemBackground"
android:button="@null" android:button="@null"
android:drawablePadding="@dimen/radio_button_preference_drawable_padding" android:drawablePadding="@dimen/preference_seek_bar_padding"
android:paddingStart="@dimen/radio_button_preference_horizontal" android:paddingStart="@dimen/radio_button_preference_horizontal"
android:paddingTop="@dimen/radio_button_preference_vertical" android:paddingTop="@dimen/radio_button_preference_vertical"
android:paddingEnd="@dimen/radio_button_preference_horizontal" android:paddingEnd="@dimen/radio_button_preference_horizontal"
android:paddingBottom="@dimen/radio_button_preference_vertical" android:paddingBottom="@dimen/radio_button_preference_vertical"
android:textAppearance="?android:attr/textAppearanceListItem" android:textAppearance="?android:attr/textAppearanceListItem"
app:drawableStartCompat="?android:attr/listChoiceIndicatorSingle" /> app:drawableStartCompat="?android:attr/listChoiceIndicatorSingle" />
</RadioGroup>
<include layout="@layout/component_permissions_blocked_by_android"/> <RadioButton
android:id="@+id/fourth_radio"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:button="@null"
android:drawablePadding="@dimen/preference_seek_bar_padding"
android:paddingStart="@dimen/radio_button_preference_horizontal"
android:paddingTop="@dimen/radio_button_preference_vertical"
android:paddingEnd="@dimen/radio_button_preference_horizontal"
android:paddingBottom="@dimen/radio_button_preference_vertical"
android:textAppearance="?android:attr/textAppearanceListItem"
app:drawableStartCompat="?android:attr/listChoiceIndicatorSingle" />
</RadioGroup>
</LinearLayout> <include layout="@layout/component_permissions_blocked_by_android"/>
</LinearLayout>
</ScrollView> </ScrollView>

View File

@ -10,7 +10,7 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@drawable/scrim_background" android:background="@drawable/scrim_background"
android:fitsSystemWindows="true" android:fitsSystemWindows="true"
tools:context="org.mozilla.fenix.shortcut.FirstTimePwaFragment"> tools:context="org.mozilla.fenix.shortcut.PwaOnboardingDialogFragment">
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@ -7,4 +7,4 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
tools:context="org.mozilla.fenix.settings.logins.SavedLoginsFragment" /> tools:context="org.mozilla.fenix.settings.logins.fragment.SavedLoginsFragment" />

View File

@ -8,7 +8,7 @@
<item <item
android:id="@+id/save_login_button" android:id="@+id/save_login_button"
android:icon="@drawable/mozac_ic_check" android:icon="@drawable/mozac_ic_check"
app:iconTint="?primaryText" app:iconTint="@color/save_enabled_ic_color"
android:title="@string/save_changes_to_login" android:title="@string/save_changes_to_login"
app:showAsAction="always" /> app:showAsAction="always" />
</menu> </menu>

View File

@ -221,8 +221,8 @@
android:id="@+id/action_browserFragment_to_createShortcutFragment" android:id="@+id/action_browserFragment_to_createShortcutFragment"
app:destination="@id/createShortcutFragment" /> app:destination="@id/createShortcutFragment" />
<action <action
android:id="@+id/action_browserFragment_to_firstTimePwaFragment" android:id="@+id/action_browserFragment_to_pwaOnboardingDialogFragment"
app:destination="@id/firstTimePwaFragment" /> app:destination="@id/pwaOnboardingDialogFragment" />
<action <action
android:id="@+id/action_browserFragment_to_quickSettingsSheetDialogFragment" android:id="@+id/action_browserFragment_to_quickSettingsSheetDialogFragment"
app:destination="@id/quickSettingsSheetDialogFragment" /> app:destination="@id/quickSettingsSheetDialogFragment" />
@ -321,7 +321,7 @@
<fragment <fragment
android:id="@+id/savedLoginsAuthFragment" android:id="@+id/savedLoginsAuthFragment"
android:name="org.mozilla.fenix.settings.logins.SavedLoginsAuthFragment" android:name="org.mozilla.fenix.settings.logins.fragment.SavedLoginsAuthFragment"
android:label="@string/preferences_passwords_logins_and_passwords"> android:label="@string/preferences_passwords_logins_and_passwords">
<action <action
android:id="@+id/action_savedLoginsAuthFragment_to_loginsListFragment" android:id="@+id/action_savedLoginsAuthFragment_to_loginsListFragment"
@ -355,7 +355,7 @@
<fragment <fragment
android:id="@+id/savedLoginsFragment" android:id="@+id/savedLoginsFragment"
android:name="org.mozilla.fenix.settings.logins.SavedLoginsFragment" android:name="org.mozilla.fenix.settings.logins.fragment.SavedLoginsFragment"
tools:layout="@layout/fragment_saved_logins"> tools:layout="@layout/fragment_saved_logins">
<action <action
android:id="@+id/action_savedLoginsFragment_to_loginDetailFragment" android:id="@+id/action_savedLoginsFragment_to_loginDetailFragment"
@ -381,7 +381,7 @@
<fragment <fragment
android:id="@+id/loginDetailFragment" android:id="@+id/loginDetailFragment"
android:name="org.mozilla.fenix.settings.logins.LoginDetailFragment" android:name="org.mozilla.fenix.settings.logins.fragment.LoginDetailFragment"
tools:layout="@layout/fragment_login_detail"> tools:layout="@layout/fragment_login_detail">
<argument <argument
android:name="savedLoginId" android:name="savedLoginId"
@ -396,7 +396,7 @@
<fragment <fragment
android:id="@+id/editLoginFragment" android:id="@+id/editLoginFragment"
android:name="org.mozilla.fenix.settings.logins.EditLoginFragment" android:name="org.mozilla.fenix.settings.logins.fragment.EditLoginFragment"
android:label="@string/edit"> android:label="@string/edit">
<argument <argument
android:name="savedLoginItem" android:name="savedLoginItem"
@ -680,10 +680,9 @@
android:name="org.mozilla.fenix.shortcut.CreateShortcutFragment" android:name="org.mozilla.fenix.shortcut.CreateShortcutFragment"
tools:layout="@layout/fragment_create_shortcut" /> tools:layout="@layout/fragment_create_shortcut" />
<dialog <dialog
android:id="@+id/firstTimePwaFragment" android:id="@+id/pwaOnboardingDialogFragment"
android:name="org.mozilla.fenix.shortcut.FirstTimePwaFragment" android:name="org.mozilla.fenix.shortcut.PwaOnboardingDialogFragment"
android:label="fragment_pwa_first_time" tools:layout="@layout/fragment_pwa_onboarding" />
tools:layout="@layout/fragment_pwa_first_time" />
<dialog <dialog
android:id="@+id/shareFragment" android:id="@+id/shareFragment"
@ -790,7 +789,7 @@
android:name="org.mozilla.fenix.settings.advanced.LocaleSettingsFragment" /> android:name="org.mozilla.fenix.settings.advanced.LocaleSettingsFragment" />
<fragment <fragment
android:id="@+id/saveLoginSettingFragment" android:id="@+id/saveLoginSettingFragment"
android:name="org.mozilla.fenix.settings.logins.SavedLoginsSettingFragment" android:name="org.mozilla.fenix.settings.logins.fragment.SavedLoginsSettingFragment"
android:label="SaveLoginSettingFragment" /> android:label="SaveLoginSettingFragment" />
<fragment <fragment
android:id="@+id/addonsManagementFragment" android:id="@+id/addonsManagementFragment"

View File

@ -56,6 +56,7 @@
<string name="pref_key_private_mode_opened" translatable="false">pref_key_private_mode_opened</string> <string name="pref_key_private_mode_opened" translatable="false">pref_key_private_mode_opened</string>
<string name="pref_key_open_in_app_opened" translatable="false">pref_key_open_in_app_opened</string> <string name="pref_key_open_in_app_opened" translatable="false">pref_key_open_in_app_opened</string>
<string name="pref_key_install_pwa_opened" translatable="false">pref_key_install_pwa_opened</string> <string name="pref_key_install_pwa_opened" translatable="false">pref_key_install_pwa_opened</string>
<string name="pref_key_install_pwa_visits" translatable="false">pref_key_install_pwa_visits</string>
<!-- Data Choices --> <!-- Data Choices -->
<string name="pref_key_telemetry" translatable="false">pref_key_telemetry</string> <string name="pref_key_telemetry" translatable="false">pref_key_telemetry</string>

View File

@ -566,6 +566,8 @@
<string name="bookmark_select_folder">Select folder</string> <string name="bookmark_select_folder">Select folder</string>
<!-- Confirmation message for a dialog confirming if the user wants to delete the selected folder --> <!-- Confirmation message for a dialog confirming if the user wants to delete the selected folder -->
<string name="bookmark_delete_folder_confirmation_dialog">Are you sure you want to delete this folder?</string> <string name="bookmark_delete_folder_confirmation_dialog">Are you sure you want to delete this folder?</string>
<!-- Confirmation message for a dialog confirming if the user wants to delete multiple items including folders. Parameter will be replaced by app name. -->
<string name="bookmark_delete_multiple_folders_confirmation_dialog">%s will delete the selected items.</string>
<!-- Snackbar title shown after a folder has been deleted. This first parameter is the name of the deleted folder --> <!-- Snackbar title shown after a folder has been deleted. This first parameter is the name of the deleted folder -->
<string name="bookmark_delete_folder_snackbar">Deleted %1$s</string> <string name="bookmark_delete_folder_snackbar">Deleted %1$s</string>
<!-- Screen title for adding a bookmarks folder --> <!-- Screen title for adding a bookmarks folder -->
@ -620,8 +622,10 @@
<!-- Bookmark snackbar message on deletion <!-- Bookmark snackbar message on deletion
The first parameter is the host part of the URL of the bookmark deleted, if any --> The first parameter is the host part of the URL of the bookmark deleted, if any -->
<string name="bookmark_deletion_snackbar_message">Deleted %1$s</string> <string name="bookmark_deletion_snackbar_message">Deleted %1$s</string>
<!-- Bookmark snackbar message on deleting multiple bookmarks --> <!-- Bookmark snackbar message on deleting multiple bookmarks not including folders-->
<string name="bookmark_deletion_multiple_snackbar_message_2">Bookmarks deleted</string> <string name="bookmark_deletion_multiple_snackbar_message_2">Bookmarks deleted</string>
<!-- Bookmark snackbar message on deleting multiple bookmarks including folders-->
<string name="bookmark_deletion_multiple_snackbar_message_3">Deleting selected folders</string>
<!-- Bookmark undo button for deletion snackbar action --> <!-- Bookmark undo button for deletion snackbar action -->
<string name="bookmark_undo_deletion">UNDO</string> <string name="bookmark_undo_deletion">UNDO</string>

View File

@ -2,20 +2,16 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public <!-- 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 - 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/. --> - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android" <PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
xmlns:app="http://schemas.android.com/apk/res-auto"> <Preference
<androidx.preference.Preference
android:key="@string/pref_key_add_private_browsing_shortcut" android:key="@string/pref_key_add_private_browsing_shortcut"
android:title="@string/preferences_add_private_browsing_shortcut" android:title="@string/preferences_add_private_browsing_shortcut" />
app:iconSpaceReserved="false" />
<SwitchPreference <SwitchPreference
android:defaultValue="false" android:defaultValue="false"
android:key="@string/pref_key_open_links_in_a_private_tab" android:key="@string/pref_key_open_links_in_a_private_tab"
android:title="@string/preferences_open_links_in_a_private_tab" android:title="@string/preferences_open_links_in_a_private_tab" />
app:iconSpaceReserved="false" />
<SwitchPreference <SwitchPreference
android:defaultValue="false" android:defaultValue="false"
android:key="@string/pref_key_allow_screenshots_in_private_mode" android:key="@string/pref_key_allow_screenshots_in_private_mode"
android:title="@string/preferences_allow_screenshots_in_private_mode" android:title="@string/preferences_allow_screenshots_in_private_mode" />
app:iconSpaceReserved="false" />
</PreferenceScreen> </PreferenceScreen>

View File

@ -12,9 +12,11 @@ import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runBlockingTest
import mozilla.components.feature.intent.processing.IntentProcessor import mozilla.components.feature.intent.processing.IntentProcessor
import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
@ -39,6 +41,7 @@ class IntentReceiverActivityTest {
@Before @Before
fun setup() { fun setup() {
mockkStatic("org.mozilla.fenix.ext.ContextKt")
settings = mockk() settings = mockk()
intentProcessors = mockk() intentProcessors = mockk()
@ -54,6 +57,11 @@ class IntentReceiverActivityTest {
coEvery { intentProcessors.intentProcessor.process(any()) } returns true coEvery { intentProcessors.intentProcessor.process(any()) } returns true
} }
@After
fun teardown() {
unmockkStatic("org.mozilla.fenix.ext.ContextKt")
}
@Test @Test
fun `process intent with flag launched from history`() = runBlockingTest { fun `process intent with flag launched from history`() = runBlockingTest {
val intent = Intent() val intent = Intent()
@ -185,7 +193,6 @@ class IntentReceiverActivityTest {
} }
private fun attachMocks(activity: Activity) { private fun attachMocks(activity: Activity) {
mockkStatic("org.mozilla.fenix.ext.ContextKt")
every { activity.settings() } returns settings every { activity.settings() } returns settings
every { activity.components.analytics } returns mockk(relaxed = true) every { activity.components.analytics } returns mockk(relaxed = true)
every { activity.components.intentProcessors } returns intentProcessors every { activity.components.intentProcessors } returns intentProcessors

View File

@ -81,25 +81,28 @@ class AddonDetailsViewTest {
@Test @Test
fun `bind addons version`() { fun `bind addons version`() {
detailsView.bind(baseAddon.copy( val addon1 = baseAddon.copy(
version = "1.0.0", version = "1.0.0",
installedState = null installedState = null
)) )
detailsView.bind(addon1)
assertEquals("1.0.0", view.version_text.text) assertEquals("1.0.0", view.version_text.text)
view.version_text.performLongClick() view.version_text.performLongClick()
verify(exactly = 0) { interactor.showUpdaterDialog(any()) } verify(exactly = 0) { interactor.showUpdaterDialog(addon1) }
detailsView.bind(baseAddon.copy( val addon2 = baseAddon.copy(
version = "1.0.0", version = "1.0.0",
installedState = Addon.InstalledState( installedState = Addon.InstalledState(
id = "", id = "",
version = "2.0.0", version = "2.0.0",
optionsPageUrl = null optionsPageUrl = null
) )
)) )
detailsView.bind(addon2)
assertEquals("2.0.0", view.version_text.text) assertEquals("2.0.0", view.version_text.text)
view.version_text.performLongClick() view.version_text.performLongClick()
verify { interactor.showUpdaterDialog(any()) } verify { interactor.showUpdaterDialog(addon2) }
} }
@Test @Test

View File

@ -32,12 +32,12 @@ class DefaultBrowsingModeManagerTest {
manager.mode = BrowsingMode.Private manager.mode = BrowsingMode.Private
manager.mode = BrowsingMode.Private manager.mode = BrowsingMode.Private
verify(exactly = 3) { callback.invoke(any()) } verify(exactly = 3) { callback.invoke(BrowsingMode.Private) }
manager.mode = BrowsingMode.Normal manager.mode = BrowsingMode.Normal
manager.mode = BrowsingMode.Normal manager.mode = BrowsingMode.Normal
verify(exactly = 5) { callback.invoke(any()) } verify(exactly = 2) { callback.invoke(BrowsingMode.Normal) }
} }
@Test @Test

View File

@ -0,0 +1,89 @@
/* 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.collections
import android.widget.FrameLayout
import androidx.core.view.isInvisible
import androidx.recyclerview.widget.RecyclerView
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.verify
import kotlinx.android.synthetic.main.collection_tab_list_row.*
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.home.Tab
@RunWith(FenixRobolectricTestRunner::class)
class CollectionCreationTabListAdapterTest {
private lateinit var interactor: CollectionCreationInteractor
private lateinit var adapter: CollectionCreationTabListAdapter
@Before
fun setup() {
interactor = mockk()
adapter = CollectionCreationTabListAdapter(interactor)
every { interactor.selectCollection(any(), any()) } just Runs
}
@Test
fun `getItemCount should return the number of tab collections`() {
val tab = mockk<Tab>()
assertEquals(0, adapter.itemCount)
adapter.updateData(
tabs = listOf(tab),
selectedTabs = emptySet()
)
assertEquals(1, adapter.itemCount)
}
@Test
fun `creates and binds viewholder`() {
val tab = mockk<Tab> {
every { sessionId } returns "abc"
every { title } returns "Mozilla"
every { hostname } returns "mozilla.org"
every { url } returns "https://mozilla.org"
}
adapter.updateData(
tabs = listOf(tab),
selectedTabs = emptySet()
)
val holder = adapter.createViewHolder(FrameLayout(testContext), 0)
adapter.bindViewHolder(holder, 0)
assertEquals("Mozilla", holder.tab_title.text)
assertEquals("mozilla.org", holder.hostname.text)
assertFalse(holder.tab_selected_checkbox.isInvisible)
assertTrue(holder.itemView.isClickable)
}
@Test
fun `updateData inserts item`() {
val tab = mockk<Tab> {
every { sessionId } returns "abc"
}
val observer = mockk<RecyclerView.AdapterDataObserver>(relaxed = true)
adapter.registerAdapterDataObserver(observer)
adapter.updateData(
tabs = listOf(tab),
selectedTabs = emptySet()
)
verify { observer.onItemRangeInserted(0, 1) }
}
}

View File

@ -0,0 +1,80 @@
/* 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.collections
import android.view.ViewGroup
import android.widget.FrameLayout
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.verify
import kotlinx.android.synthetic.main.collections_list_item.view.*
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
@RunWith(FenixRobolectricTestRunner::class)
class SaveCollectionListAdapterTest {
private lateinit var parent: ViewGroup
private lateinit var interactor: CollectionCreationInteractor
private lateinit var adapter: SaveCollectionListAdapter
@Before
fun setup() {
parent = FrameLayout(testContext)
interactor = mockk()
adapter = SaveCollectionListAdapter(interactor)
every { interactor.selectCollection(any(), any()) } just Runs
}
@Test
fun `getItemCount should return the number of tab collections`() {
val collection = mockk<TabCollection>()
assertEquals(0, adapter.itemCount)
adapter.updateData(
tabCollections = listOf(collection),
selectedTabs = emptySet()
)
assertEquals(1, adapter.itemCount)
}
@Test
fun `creates and binds viewholder`() {
val collection = mockk<TabCollection> {
every { id } returns 0L
every { title } returns "Collection"
every { tabs } returns listOf(
mockk {
every { url } returns "https://mozilla.org"
},
mockk {
every { url } returns "https://firefox.com"
}
)
}
adapter.updateData(
tabCollections = listOf(collection),
selectedTabs = emptySet()
)
val holder = adapter.createViewHolder(parent, 0)
adapter.bindViewHolder(holder, 0)
assertEquals("Collection", holder.itemView.collection_item.text)
assertEquals("mozilla.org, firefox.com", holder.itemView.collection_description.text)
holder.itemView.performClick()
verify { interactor.selectCollection(collection, emptyList()) }
}
}

View File

@ -0,0 +1,151 @@
/* 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.collections
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.mozilla.fenix.home.Tab
class TabDiffUtilTest {
@Test
fun `list size is returned`() {
val diffUtil = TabDiffUtil(
old = listOf(mockk(), mockk()),
new = listOf(mockk()),
oldSelected = emptySet(),
newSelected = emptySet(),
oldHideCheckboxes = false,
newHideCheckboxes = false
)
assertEquals(2, diffUtil.oldListSize)
assertEquals(1, diffUtil.newListSize)
}
@Test
fun `single lists are the same`() {
val tab = mockk<Tab> {
every { sessionId } returns "abc"
}
val diffUtil = TabDiffUtil(
old = listOf(tab),
new = listOf(tab),
oldSelected = emptySet(),
newSelected = emptySet(),
oldHideCheckboxes = false,
newHideCheckboxes = false
)
assertTrue(diffUtil.areItemsTheSame(0, 0))
assertTrue(diffUtil.areContentsTheSame(0, 0))
}
@Test
fun `selection affects contents`() {
val tab = mockk<Tab> {
every { sessionId } returns "abc"
}
val diffUtil = TabDiffUtil(
old = listOf(tab),
new = listOf(tab),
oldSelected = emptySet(),
newSelected = setOf(tab),
oldHideCheckboxes = false,
newHideCheckboxes = false
)
assertTrue(diffUtil.areItemsTheSame(0, 0))
assertFalse(diffUtil.areContentsTheSame(0, 0))
}
@Test
fun `hide checkboxes affects contents`() {
val tab = mockk<Tab> {
every { sessionId } returns "abc"
}
val diffUtil = TabDiffUtil(
old = listOf(tab),
new = listOf(tab),
oldSelected = setOf(tab),
newSelected = setOf(tab),
oldHideCheckboxes = false,
newHideCheckboxes = true
)
assertTrue(diffUtil.areItemsTheSame(0, 0))
assertFalse(diffUtil.areContentsTheSame(0, 0))
}
@Test
fun `change payload covers no change case`() {
val tab = mockk<Tab>()
val payload = TabDiffUtil(
old = listOf(tab),
new = listOf(tab),
oldSelected = setOf(tab),
newSelected = setOf(tab),
oldHideCheckboxes = false,
newHideCheckboxes = false
).getChangePayload(0, 0)
assertEquals(
CheckChanged(
shouldBeChecked = false,
shouldBeUnchecked = false,
shouldHideCheckBox = false
),
payload
)
}
@Test
fun `include shouldBeChecked in change payload`() {
val tab = mockk<Tab>()
val payload = TabDiffUtil(
old = listOf(tab),
new = listOf(tab),
oldSelected = emptySet(),
newSelected = setOf(tab),
oldHideCheckboxes = false,
newHideCheckboxes = false
).getChangePayload(0, 0)
assertEquals(
CheckChanged(
shouldBeChecked = true,
shouldBeUnchecked = false,
shouldHideCheckBox = false
),
payload
)
}
@Test
fun `include shouldBeUnchecked in change payload`() {
val tab = mockk<Tab>()
val payload = TabDiffUtil(
old = listOf(tab),
new = listOf(tab),
oldSelected = setOf(tab),
newSelected = emptySet(),
oldHideCheckboxes = false,
newHideCheckboxes = true
).getChangePayload(0, 0)
assertEquals(
CheckChanged(
shouldBeChecked = false,
shouldBeUnchecked = true,
shouldHideCheckBox = true
),
payload
)
}
}

View File

@ -13,6 +13,7 @@ import mozilla.components.lib.crash.Crash
import mozilla.components.lib.crash.CrashReporter import mozilla.components.lib.crash.CrashReporter
import mozilla.components.lib.crash.service.CrashReporterService import mozilla.components.lib.crash.service.CrashReporterService
import mozilla.components.support.base.crash.Breadcrumb import mozilla.components.support.base.crash.Breadcrumb
import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
internal class BreadcrumbRecorderTest { internal class BreadcrumbRecorderTest {
@ -36,17 +37,18 @@ internal class BreadcrumbRecorderTest {
) )
) )
fun getBreadcrumbMessage(@Suppress("UNUSED_PARAMETER") destination: NavDestination): String {
return "test"
}
val navController: NavController = mockk() val navController: NavController = mockk()
val navDestination: NavDestination = mockk() val navDestination: NavDestination = mockk()
val breadCrumbRecorder = val breadCrumbRecorder = BreadcrumbsRecorder(reporter, navController) { "test" }
BreadcrumbsRecorder(reporter, navController, ::getBreadcrumbMessage)
breadCrumbRecorder.onDestinationChanged(navController, navDestination, null) breadCrumbRecorder.onDestinationChanged(navController, navDestination, null)
verify { reporter.recordCrashBreadcrumb(any()) } verify {
reporter.recordCrashBreadcrumb(withArg {
assertEquals("test", it.message)
assertEquals("DestinationChanged", it.category)
assertEquals(Breadcrumb.Level.INFO, it.level)
})
}
} }
} }

View File

@ -8,7 +8,6 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.Intent.ACTION_VIEW import android.content.Intent.ACTION_VIEW
import androidx.core.net.toUri import androidx.core.net.toUri
import io.mockk.MockKMatcherScope
import io.mockk.Runs import io.mockk.Runs
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
@ -32,6 +31,7 @@ import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.MozillaProductDetector import org.mozilla.fenix.components.metrics.MozillaProductDetector
import org.mozilla.fenix.components.metrics.MozillaProductDetector.MozillaProducts import org.mozilla.fenix.components.metrics.MozillaProductDetector.MozillaProducts
import org.mozilla.fenix.components.tips.TipType import org.mozilla.fenix.components.tips.TipType
import org.mozilla.fenix.ext.intentFilterEq
import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.settings.SupportUtils
@ -205,7 +205,4 @@ class MigrationTipProviderTest {
every { settings.shouldDisplayFenixMovingTip() } returns true every { settings.shouldDisplayFenixMovingTip() } returns true
assertTrue(MigrationTipProvider(context).shouldDisplay) assertTrue(MigrationTipProvider(context).shouldDisplay)
} }
private fun MockKMatcherScope.intentFilterEq(value: Intent): Intent =
match { it.filterEquals(value) }
} }

View File

@ -1,5 +1,6 @@
package org.mozilla.fenix.ext package org.mozilla.fenix.ext
import android.content.Intent
import androidx.navigation.NavDirections import androidx.navigation.NavDirections
import io.mockk.Matcher import io.mockk.Matcher
import io.mockk.MockKMatcherScope import io.mockk.MockKMatcherScope
@ -11,6 +12,13 @@ import mozilla.components.support.ktx.android.os.contentEquals
*/ */
fun MockKMatcherScope.directionsEq(value: NavDirections) = match(EqNavDirectionsMatcher(value)) fun MockKMatcherScope.directionsEq(value: NavDirections) = match(EqNavDirectionsMatcher(value))
/**
* Verify that two intents are the same for the purposes of intent resolution (filtering).
* Checks if their action, data, type, identity, class, and categories are the same.
* Does not compare extras.
*/
fun MockKMatcherScope.intentFilterEq(value: Intent) = match(EqIntentFilterMatcher(value))
private data class EqNavDirectionsMatcher(private val value: NavDirections) : Matcher<NavDirections> { private data class EqNavDirectionsMatcher(private val value: NavDirections) : Matcher<NavDirections> {
override fun match(arg: NavDirections?): Boolean = override fun match(arg: NavDirections?): Boolean =
@ -19,3 +27,13 @@ private data class EqNavDirectionsMatcher(private val value: NavDirections) : Ma
override fun substitute(map: Map<Any, Any>) = override fun substitute(map: Map<Any, Any>) =
copy(value = value.internalSubstitute(map)) copy(value = value.internalSubstitute(map))
} }
private data class EqIntentFilterMatcher(private val value: Intent) : Matcher<Intent> {
override fun match(arg: Intent?): Boolean = value.filterEquals(arg)
override fun substitute(map: Map<Any, Any>) =
copy(value = value.internalSubstitute(map))
override fun toString() = "intentFilterEq($value)"
}

View File

@ -5,13 +5,13 @@
package org.mozilla.fenix.home package org.mozilla.fenix.home
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavDirections
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.test.TestCoroutineDispatcher import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.TestCoroutineScope
import mozilla.components.browser.session.SessionManager 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
@ -23,12 +23,11 @@ import org.junit.Test
import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.collections.SaveCollectionStep
import org.mozilla.fenix.components.TabCollectionStorage import org.mozilla.fenix.components.TabCollectionStorage
import org.mozilla.fenix.components.TopSiteStorage
import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.ext.components import org.mozilla.fenix.components.tips.Tip
import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController
import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.settings.SupportUtils
import mozilla.components.feature.tab.collections.Tab as ComponentTab import mozilla.components.feature.tab.collections.Tab as ComponentTab
@ -42,77 +41,138 @@ class DefaultSessionControlControllerTest {
private val activity: HomeActivity = mockk(relaxed = true) private val activity: HomeActivity = mockk(relaxed = true)
private val fragmentStore: HomeFragmentStore = mockk(relaxed = true) private val fragmentStore: HomeFragmentStore = mockk(relaxed = true)
private val navController: NavController = mockk(relaxed = true) private val navController: NavController = mockk(relaxed = true)
private val getListOfTabs: () -> List<Tab> = { emptyList() } private val metrics: MetricController = mockk(relaxed = true)
private val sessionManager: SessionManager = mockk(relaxed = true)
private val engine: Engine = mockk(relaxed = true)
private val tabCollectionStorage: TabCollectionStorage = mockk(relaxed = true)
private val topSiteStorage: TopSiteStorage = mockk(relaxed = true)
private val tabsUseCases: TabsUseCases = mockk(relaxed = true)
private val hideOnboarding: () -> Unit = mockk(relaxed = true) private val hideOnboarding: () -> Unit = mockk(relaxed = true)
private val openSettingsScreen: () -> Unit = mockk(relaxed = true)
private val openWhatsNewLink: () -> Unit = mockk(relaxed = true)
private val openPrivacyNotice: () -> Unit = mockk(relaxed = true)
private val registerCollectionStorageObserver: () -> Unit = mockk(relaxed = true) private val registerCollectionStorageObserver: () -> Unit = mockk(relaxed = true)
private val showTabTray: () -> Unit = mockk(relaxed = true) private val showTabTray: () -> Unit = mockk(relaxed = true)
private val showDeleteCollectionPrompt: (tabCollection: TabCollection, title: String?, message: String) -> Unit = private val showDeleteCollectionPrompt: (tabCollection: TabCollection, title: String?, message: String) -> Unit =
mockk(relaxed = true) mockk(relaxed = true)
private val metrics: MetricController = mockk(relaxed = true)
private val state: HomeFragmentState = mockk(relaxed = true)
private val sessionManager: SessionManager = mockk(relaxed = true)
private val engine: Engine = mockk(relaxed = true)
private val tabCollectionStorage: TabCollectionStorage = mockk(relaxed = true)
private val tabsUseCases: TabsUseCases = mockk(relaxed = true)
private lateinit var controller: DefaultSessionControlController private lateinit var controller: DefaultSessionControlController
@Before @Before
fun setup() { fun setup() {
mockkStatic("org.mozilla.fenix.ext.ContextKt") every { fragmentStore.state } returns HomeFragmentState(
every { activity.components.core.engine } returns engine collections = emptyList(),
every { activity.components.core.sessionManager } returns sessionManager expandedCollections = emptySet(),
every { activity.components.core.tabCollectionStorage } returns tabCollectionStorage mode = Mode.Normal,
every { activity.components.useCases.tabsUseCases } returns tabsUseCases topSites = emptyList()
)
every { fragmentStore.state } returns state every { sessionManager.sessions } returns emptyList()
every { state.collections } returns emptyList() every { navController.currentDestination } returns mockk {
every { state.expandedCollections } returns emptySet() every { id } returns R.id.homeFragment
every { state.mode } returns Mode.Normal }
every { activity.components.analytics.metrics } returns metrics
controller = DefaultSessionControlController( controller = DefaultSessionControlController(
activity = activity, activity = activity,
engine = engine,
metrics = metrics,
sessionManager = sessionManager,
tabCollectionStorage = tabCollectionStorage,
topSiteStorage = topSiteStorage,
addTabUseCase = tabsUseCases.addTab,
fragmentStore = fragmentStore, fragmentStore = fragmentStore,
navController = navController, navController = navController,
viewLifecycleScope = MainScope(), viewLifecycleScope = TestCoroutineScope(),
getListOfTabs = getListOfTabs,
hideOnboarding = hideOnboarding, hideOnboarding = hideOnboarding,
registerCollectionStorageObserver = registerCollectionStorageObserver, registerCollectionStorageObserver = registerCollectionStorageObserver,
showDeleteCollectionPrompt = showDeleteCollectionPrompt, showDeleteCollectionPrompt = showDeleteCollectionPrompt,
openSettingsScreen = openSettingsScreen,
openWhatsNewLink = openWhatsNewLink,
openPrivacyNotice = openPrivacyNotice,
showTabTray = showTabTray showTabTray = showTabTray
) )
} }
@Test @Test
fun handleCollectionAddTabTapped() { fun handleCollectionAddTabTapped() {
val collection: TabCollection = mockk(relaxed = true) val collection = mockk<TabCollection> {
every { id } returns 12L
}
controller.handleCollectionAddTabTapped(collection) controller.handleCollectionAddTabTapped(collection)
verify { metrics.track(Event.CollectionAddTabPressed) } verify { metrics.track(Event.CollectionAddTabPressed) }
verify {
navController.navigate(
match<NavDirections> { it.actionId == R.id.action_global_collectionCreationFragment },
null
)
}
} }
@Test @Test
fun handleCollectionOpenTabClicked() { fun `handleCollectionOpenTabClicked onFailure`() {
val tab: ComponentTab = mockk(relaxed = true) val tab = mockk<ComponentTab> {
every { url } returns "https://mozilla.org"
every { restore(activity, engine, restoreSessionId = false) } returns null
}
controller.handleCollectionOpenTabClicked(tab) controller.handleCollectionOpenTabClicked(tab)
verify { metrics.track(Event.CollectionTabRestored) } verify { metrics.track(Event.CollectionTabRestored) }
verify {
activity.openToBrowserAndLoad(
searchTermOrURL = "https://mozilla.org",
newTab = true,
from = BrowserDirection.FromHome
)
}
}
@Test
fun `handleCollectionOpenTabClicked onTabRestored`() {
val tab = mockk<ComponentTab> {
every { restore(activity, engine, restoreSessionId = false) } returns mockk {
every { session } returns mockk()
every { engineSessionState } returns mockk()
}
}
controller.handleCollectionOpenTabClicked(tab)
verify { metrics.track(Event.CollectionTabRestored) }
verify { activity.openToBrowser(BrowserDirection.FromHome) }
} }
@Test @Test
fun handleCollectionOpenTabsTapped() { fun handleCollectionOpenTabsTapped() {
val collection: TabCollection = mockk(relaxed = true) val collection = mockk<TabCollection> {
every { tabs } returns emptyList()
}
controller.handleCollectionOpenTabsTapped(collection) controller.handleCollectionOpenTabsTapped(collection)
verify { metrics.track(Event.CollectionAllTabsRestored) } verify { metrics.track(Event.CollectionAllTabsRestored) }
} }
@Test @Test
fun handleCollectionRemoveTab() { fun `handleCollectionRemoveTab one tab`() {
val collection = mockk<TabCollection> {
every { tabs } returns listOf(mockk())
every { title } returns "Collection"
}
val tab = mockk<ComponentTab>()
every {
activity.resources.getString(R.string.delete_tab_and_collection_dialog_title, "Collection")
} returns "Delete Collection?"
every {
activity.resources.getString(R.string.delete_tab_and_collection_dialog_message)
} returns "Deleting this tab will delete everything."
controller.handleCollectionRemoveTab(collection, tab)
verify { metrics.track(Event.CollectionTabRemoved) }
verify {
showDeleteCollectionPrompt(
collection,
"Delete Collection?",
"Deleting this tab will delete everything."
)
}
}
@Test
fun `handleCollectionRemoveTab multiple tabs`() {
val collection: TabCollection = mockk(relaxed = true) val collection: TabCollection = mockk(relaxed = true)
val tab: ComponentTab = mockk(relaxed = true) val tab: ComponentTab = mockk(relaxed = true)
controller.handleCollectionRemoveTab(collection, tab) controller.handleCollectionRemoveTab(collection, tab)
@ -121,16 +181,37 @@ class DefaultSessionControlControllerTest {
@Test @Test
fun handleCollectionShareTabsClicked() { fun handleCollectionShareTabsClicked() {
val collection: TabCollection = mockk(relaxed = true) val collection = mockk<TabCollection> {
every { tabs } returns emptyList()
}
controller.handleCollectionShareTabsClicked(collection) controller.handleCollectionShareTabsClicked(collection)
verify { metrics.track(Event.CollectionShared) } verify { metrics.track(Event.CollectionShared) }
verify {
navController.navigate(
match<NavDirections> { it.actionId == R.id.action_global_shareFragment },
null
)
}
} }
@Test @Test
fun handleDeleteCollectionTapped() { fun handleDeleteCollectionTapped() {
val collection: TabCollection = mockk(relaxed = true) val collection = mockk<TabCollection> {
every { title } returns "Collection"
}
every {
activity.resources.getString(R.string.tab_collection_dialog_message, "Collection")
} returns "Are you sure you want to delete Collection?"
controller.handleDeleteCollectionTapped(collection) controller.handleDeleteCollectionTapped(collection)
verify { showDeleteCollectionPrompt(collection, null, any()) } verify {
showDeleteCollectionPrompt(
collection,
null,
"Are you sure you want to delete Collection?"
)
}
} }
@Test @Test
@ -148,9 +229,18 @@ class DefaultSessionControlControllerTest {
@Test @Test
fun handleRenameCollectionTapped() { fun handleRenameCollectionTapped() {
val collection: TabCollection = mockk(relaxed = true) val collection = mockk<TabCollection> {
every { id } returns 3L
}
controller.handleRenameCollectionTapped(collection) controller.handleRenameCollectionTapped(collection)
verify { metrics.track(Event.CollectionRenamePressed) } verify { metrics.track(Event.CollectionRenamePressed) }
verify {
navController.navigate(
match<NavDirections> { it.actionId == R.id.action_global_collectionCreationFragment },
null
)
}
} }
@Test @Test
@ -191,20 +281,62 @@ class DefaultSessionControlControllerTest {
@Test @Test
fun handleOpenSettingsClicked() { fun handleOpenSettingsClicked() {
controller.handleOpenSettingsClicked() controller.handleOpenSettingsClicked()
verify { openSettingsScreen() } verify {
navController.navigate(
match<NavDirections> { it.actionId == R.id.action_global_privateBrowsingFragment },
null
)
}
}
@Test
fun handleWhatsNewGetAnswersClicked() {
controller.handleWhatsNewGetAnswersClicked()
val whatsNewUrl = SupportUtils.getWhatsNewUrl(activity)
verify {
activity.openToBrowserAndLoad(
searchTermOrURL = whatsNewUrl,
newTab = true,
from = BrowserDirection.FromHome
)
}
}
@Test
fun handleReadPrivacyNoticeClicked() {
controller.handleReadPrivacyNoticeClicked()
verify {
activity.openToBrowserAndLoad(
searchTermOrURL = SupportUtils.getMozillaPageUrl(SupportUtils.MozillaPage.PRIVATE_NOTICE),
newTab = true,
from = BrowserDirection.FromHome
)
}
} }
@Test @Test
fun handleToggleCollectionExpanded() { fun handleToggleCollectionExpanded() {
val collection: TabCollection = mockk(relaxed = true) val collection = mockk<TabCollection>()
controller.handleToggleCollectionExpanded(collection, true) controller.handleToggleCollectionExpanded(collection, true)
verify { fragmentStore.dispatch(HomeFragmentAction.CollectionExpanded(collection, true)) } verify { fragmentStore.dispatch(HomeFragmentAction.CollectionExpanded(collection, true)) }
} }
@Test
fun handleCloseTip() {
val tip = mockk<Tip>()
controller.handleCloseTip(tip)
verify { fragmentStore.dispatch(HomeFragmentAction.RemoveTip(tip)) }
}
@Test @Test
fun handleCreateCollection() { fun handleCreateCollection() {
controller.handleCreateCollection() controller.handleCreateCollection()
val directions = HomeFragmentDirections.actionGlobalCollectionCreationFragment(saveCollectionStep = SaveCollectionStep.SelectTabs)
verify { navController.nav(R.id.homeFragment, directions) } verify {
navController.navigate(
match<NavDirections> { it.actionId == R.id.action_global_collectionCreationFragment },
null
)
}
} }
} }

View File

@ -55,7 +55,7 @@ class BookmarkControllerTest {
private val loadBookmarkNode: suspend (String) -> BookmarkNode? = mockk(relaxed = true) private val loadBookmarkNode: suspend (String) -> BookmarkNode? = mockk(relaxed = true)
private val showSnackbar: (String) -> Unit = mockk(relaxed = true) private val showSnackbar: (String) -> Unit = mockk(relaxed = true)
private val deleteBookmarkNodes: (Set<BookmarkNode>, Event) -> Unit = mockk(relaxed = true) private val deleteBookmarkNodes: (Set<BookmarkNode>, Event) -> Unit = mockk(relaxed = true)
private val deleteBookmarkFolder: (BookmarkNode) -> Unit = mockk(relaxed = true) private val deleteBookmarkFolder: (Set<BookmarkNode>) -> Unit = mockk(relaxed = true)
private val invokePendingDeletion: () -> Unit = mockk(relaxed = true) private val invokePendingDeletion: () -> Unit = mockk(relaxed = true)
private val homeActivity: HomeActivity = mockk(relaxed = true) private val homeActivity: HomeActivity = mockk(relaxed = true)
@ -304,10 +304,10 @@ class BookmarkControllerTest {
@Test @Test
fun `handleBookmarkDeletion for a folder should properly call the delete folder delegate`() { fun `handleBookmarkDeletion for a folder should properly call the delete folder delegate`() {
controller.handleBookmarkFolderDeletion(subfolder) controller.handleBookmarkFolderDeletion(setOf(subfolder))
verify { verify {
deleteBookmarkFolder(subfolder) deleteBookmarkFolder(setOf(subfolder))
} }
} }

View File

@ -180,7 +180,7 @@ class BookmarkFragmentInteractorTest {
interactor.onDelete(setOf(subfolder)) interactor.onDelete(setOf(subfolder))
verify { verify {
bookmarkController.handleBookmarkFolderDeletion(subfolder) bookmarkController.handleBookmarkFolderDeletion(setOf(subfolder))
} }
} }

View File

@ -60,7 +60,9 @@ class HistoryFragmentStoreTest {
fun finishSync() = runBlocking { fun finishSync() = runBlocking {
val initialState = HistoryFragmentState( val initialState = HistoryFragmentState(
items = listOf(), items = listOf(),
mode = HistoryFragmentState.Mode.Syncing mode = HistoryFragmentState.Mode.Syncing,
pendingDeletionIds = emptySet(),
isDeletingItems = false
) )
val store = HistoryFragmentStore(initialState) val store = HistoryFragmentStore(initialState)
@ -71,16 +73,22 @@ class HistoryFragmentStoreTest {
private fun emptyDefaultState(): HistoryFragmentState = HistoryFragmentState( private fun emptyDefaultState(): HistoryFragmentState = HistoryFragmentState(
items = listOf(), items = listOf(),
mode = HistoryFragmentState.Mode.Normal mode = HistoryFragmentState.Mode.Normal,
pendingDeletionIds = emptySet(),
isDeletingItems = false
) )
private fun oneItemEditState(): HistoryFragmentState = HistoryFragmentState( private fun oneItemEditState(): HistoryFragmentState = HistoryFragmentState(
items = listOf(), items = listOf(),
mode = HistoryFragmentState.Mode.Editing(setOf(historyItem)) mode = HistoryFragmentState.Mode.Editing(setOf(historyItem)),
pendingDeletionIds = emptySet(),
isDeletingItems = false
) )
private fun twoItemEditState(): HistoryFragmentState = HistoryFragmentState( private fun twoItemEditState(): HistoryFragmentState = HistoryFragmentState(
items = listOf(), items = listOf(),
mode = HistoryFragmentState.Mode.Editing(setOf(historyItem, newHistoryItem)) mode = HistoryFragmentState.Mode.Editing(setOf(historyItem, newHistoryItem)),
pendingDeletionIds = emptySet(),
isDeletingItems = false
) )
} }

View File

@ -0,0 +1,113 @@
/* 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.loginexceptions
import android.widget.LinearLayout
import androidx.appcompat.view.ContextThemeWrapper
import io.mockk.every
import io.mockk.mockk
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.R
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.loginexceptions.viewholders.LoginExceptionsDeleteButtonViewHolder
import org.mozilla.fenix.loginexceptions.viewholders.LoginExceptionsHeaderViewHolder
import org.mozilla.fenix.loginexceptions.viewholders.LoginExceptionsListItemViewHolder
@RunWith(FenixRobolectricTestRunner::class)
class LoginExceptionsAdapterTest {
private lateinit var interactor: LoginExceptionsInteractor
private lateinit var adapter: LoginExceptionsAdapter
@Before
fun setup() {
interactor = mockk()
adapter = LoginExceptionsAdapter(interactor)
}
@Test
fun `creates correct view holder type`() {
val parent = LinearLayout(ContextThemeWrapper(testContext, R.style.NormalTheme))
adapter.updateData(listOf(mockk(), mockk()))
assertEquals(4, adapter.itemCount)
val holders = (0 until adapter.itemCount).asSequence()
.map { i -> adapter.getItemViewType(i) }
.map { viewType -> adapter.onCreateViewHolder(parent, viewType) }
.toList()
assertEquals(4, holders.size)
assertTrue(holders[0] is LoginExceptionsHeaderViewHolder)
assertTrue(holders[1] is LoginExceptionsListItemViewHolder)
assertTrue(holders[2] is LoginExceptionsListItemViewHolder)
assertTrue(holders[3] is LoginExceptionsDeleteButtonViewHolder)
}
@Test
fun `headers and delete should check if the other object is the same`() {
assertTrue(LoginExceptionsAdapter.DiffCallback.areItemsTheSame(
LoginExceptionsAdapter.AdapterItem.Header,
LoginExceptionsAdapter.AdapterItem.Header
))
assertTrue(LoginExceptionsAdapter.DiffCallback.areItemsTheSame(
LoginExceptionsAdapter.AdapterItem.DeleteButton,
LoginExceptionsAdapter.AdapterItem.DeleteButton
))
assertFalse(LoginExceptionsAdapter.DiffCallback.areItemsTheSame(
LoginExceptionsAdapter.AdapterItem.Header,
LoginExceptionsAdapter.AdapterItem.DeleteButton
))
assertTrue(LoginExceptionsAdapter.DiffCallback.areContentsTheSame(
LoginExceptionsAdapter.AdapterItem.Header,
LoginExceptionsAdapter.AdapterItem.Header
))
assertTrue(LoginExceptionsAdapter.DiffCallback.areContentsTheSame(
LoginExceptionsAdapter.AdapterItem.DeleteButton,
LoginExceptionsAdapter.AdapterItem.DeleteButton
))
assertFalse(LoginExceptionsAdapter.DiffCallback.areContentsTheSame(
LoginExceptionsAdapter.AdapterItem.DeleteButton,
LoginExceptionsAdapter.AdapterItem.Header
))
}
@Test
fun `items with the same id should be marked as same`() {
assertTrue(LoginExceptionsAdapter.DiffCallback.areItemsTheSame(
LoginExceptionsAdapter.AdapterItem.Item(mockk {
every { id } returns 12L
}),
LoginExceptionsAdapter.AdapterItem.Item(mockk {
every { id } returns 12L
})
))
assertFalse(LoginExceptionsAdapter.DiffCallback.areItemsTheSame(
LoginExceptionsAdapter.AdapterItem.Item(mockk {
every { id } returns 14L
}),
LoginExceptionsAdapter.AdapterItem.Item(mockk {
every { id } returns 12L
})
))
assertFalse(LoginExceptionsAdapter.DiffCallback.areItemsTheSame(
LoginExceptionsAdapter.AdapterItem.Item(mockk {
every { id } returns 14L
}),
LoginExceptionsAdapter.AdapterItem.Header
))
assertFalse(LoginExceptionsAdapter.DiffCallback.areItemsTheSame(
LoginExceptionsAdapter.AdapterItem.DeleteButton,
LoginExceptionsAdapter.AdapterItem.Item(mockk {
every { id } returns 14L
})
))
}
}

View File

@ -0,0 +1,65 @@
/* 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.loginexceptions
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import io.mockk.mockk
import kotlinx.android.synthetic.main.component_exceptions.*
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
@RunWith(FenixRobolectricTestRunner::class)
class LoginExceptionsViewTest {
private lateinit var parent: ViewGroup
private lateinit var interactor: LoginExceptionsInteractor
private lateinit var view: LoginExceptionsView
@Before
fun setup() {
parent = FrameLayout(testContext)
interactor = mockk()
view = LoginExceptionsView(parent, interactor)
}
@Test
fun `sets empty message text`() {
assertEquals(
"Logins and passwords that are not saved will be shown here.",
view.exceptions_empty_message.text
)
assertTrue(view.exceptions_list.adapter is LoginExceptionsAdapter)
assertTrue(view.exceptions_list.layoutManager is LinearLayoutManager)
}
@Test
fun `hide list when there are no items`() {
view.update(ExceptionsFragmentState(
items = emptyList()
))
assertTrue(view.exceptions_empty_view.isVisible)
assertFalse(view.exceptions_list.isVisible)
}
@Test
fun `shows list when there are items`() {
view.update(ExceptionsFragmentState(
items = listOf(mockk())
))
assertFalse(view.exceptions_empty_view.isVisible)
assertTrue(view.exceptions_list.isVisible)
}
}

View File

@ -0,0 +1,45 @@
/* 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.loginexceptions.viewholders
import android.view.View
import com.google.android.material.button.MaterialButton
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.R
import org.mozilla.fenix.loginexceptions.LoginExceptionsInteractor
class LoginExceptionsDeleteButtonViewHolderTest {
private lateinit var view: View
private lateinit var deleteButton: MaterialButton
private lateinit var interactor: LoginExceptionsInteractor
@Before
fun setup() {
deleteButton = mockk()
view = mockk {
every { findViewById<MaterialButton>(R.id.removeAllExceptions) } returns deleteButton
}
interactor = mockk()
}
@Test
fun `delete button calls interactor`() {
val slot = slot<View.OnClickListener>()
every { deleteButton.setOnClickListener(capture(slot)) } just Runs
LoginExceptionsDeleteButtonViewHolder(view, interactor)
every { interactor.onDeleteAll() } just Runs
slot.captured.onClick(mockk())
verify { interactor.onDeleteAll() }
}
}

View File

@ -0,0 +1,37 @@
/* 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.loginexceptions.viewholders
import android.view.View
import android.widget.TextView
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.R
class LoginExceptionsHeaderViewHolderTest {
private lateinit var view: View
private lateinit var description: TextView
@Before
fun setup() {
description = mockk(relaxUnitFun = true)
view = mockk {
every { findViewById<TextView>(R.id.exceptions_description) } returns description
every {
context.getString(R.string.preferences_passwords_exceptions_description)
} returns "Logins and passwords will not be saved for these sites."
}
}
@Test
fun `sets description text`() {
LoginExceptionsHeaderViewHolder(view)
verify { description.text = "Logins and passwords will not be saved for these sites." }
}
}

View File

@ -0,0 +1,63 @@
/* 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.loginexceptions.viewholders
import android.view.View
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import mozilla.components.feature.logins.exceptions.LoginException
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.R
import org.mozilla.fenix.loginexceptions.LoginExceptionsInteractor
class LoginExceptionsListItemViewHolderTest {
private lateinit var view: View
private lateinit var url: TextView
private lateinit var deleteButton: ImageButton
private lateinit var interactor: LoginExceptionsInteractor
@Before
fun setup() {
url = mockk(relaxUnitFun = true)
deleteButton = mockk(relaxUnitFun = true)
view = mockk {
every { findViewById<TextView>(R.id.webAddressView) } returns url
every { findViewById<ImageButton>(R.id.delete_exception) } returns deleteButton
every { findViewById<ImageView>(R.id.favicon_image) } returns mockk()
}
interactor = mockk()
}
@Test
fun `sets url text`() {
LoginExceptionsListItemViewHolder(view, interactor).bind(mockk {
every { origin } returns "mozilla.org"
})
verify { url.text = "mozilla.org" }
}
@Test
fun `delete button calls interactor`() {
val slot = slot<View.OnClickListener>()
val loginException = mockk<LoginException> {
every { origin } returns "mozilla.org"
}
every { deleteButton.setOnClickListener(capture(slot)) } just Runs
LoginExceptionsListItemViewHolder(view, interactor).bind(loginException)
every { interactor.onDeleteOne(loginException) } just Runs
slot.captured.onClick(mockk())
verify { interactor.onDeleteOne(loginException) }
}
}

View File

@ -0,0 +1,59 @@
/* 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.migration
import android.view.View
import android.widget.FrameLayout
import kotlinx.android.synthetic.main.migration_list_item.view.*
import mozilla.components.support.migration.Migration
import mozilla.components.support.migration.MigrationRun
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
@RunWith(FenixRobolectricTestRunner::class)
class MigrationStatusAdapterTest {
private lateinit var adapter: MigrationStatusAdapter
@Before
fun setup() {
adapter = MigrationStatusAdapter()
}
@Test
fun `getItemCount should return the number of items in whitelist`() {
assertEquals(0, adapter.itemCount)
adapter.updateData(mapOf(
Migration.Addons to MigrationRun(0, success = true),
Migration.Settings to MigrationRun(0, success = true),
Migration.Bookmarks to MigrationRun(0, success = false)
))
assertEquals(4, adapter.itemCount)
}
@Test
fun `creates and binds viewholder`() {
adapter.updateData(mapOf(
Migration.History to MigrationRun(0, success = true)
))
val holder1 = adapter.createViewHolder(FrameLayout(testContext), 0)
val holder2 = adapter.createViewHolder(FrameLayout(testContext), 0)
adapter.bindViewHolder(holder1, 0)
adapter.bindViewHolder(holder2, 1)
assertEquals("Settings", holder1.itemView.migration_item_name.text)
assertEquals(View.INVISIBLE, holder1.itemView.migration_status_image.visibility)
assertEquals("History", holder2.itemView.migration_item_name.text)
assertEquals(View.VISIBLE, holder2.itemView.migration_status_image.visibility)
assertEquals("Migration completed", holder2.itemView.migration_status_image.contentDescription)
}
}

View File

@ -0,0 +1,70 @@
/* 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.migration
import io.mockk.MockKAnnotations
import io.mockk.impl.annotations.MockK
import io.mockk.verify
import io.mockk.verifyOrder
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.runBlockingTest
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.migration.state.MigrationAction
import mozilla.components.support.migration.state.MigrationStore
import mozilla.components.support.test.ext.joinBlocking
import mozilla.components.support.test.rule.MainCoroutineRule
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
@ExperimentalCoroutinesApi
class MigrationTelemetryListenerTest {
private val testDispatcher = TestCoroutineDispatcher()
@get:Rule
val coroutinesTestRule = MainCoroutineRule(testDispatcher)
@MockK(relaxed = true) private lateinit var metrics: MetricController
@MockK(relaxed = true) private lateinit var logger: Logger
private lateinit var store: MigrationStore
private lateinit var listener: MigrationTelemetryListener
@Before
fun setup() {
MockKAnnotations.init(this)
store = MigrationStore()
listener = MigrationTelemetryListener(
metrics = metrics,
store = store,
logger = logger
)
}
@Test
fun `progress state is logged`() = testDispatcher.runBlockingTest {
listener.start()
store.dispatch(MigrationAction.Started).joinBlocking()
store.dispatch(MigrationAction.Completed).joinBlocking()
store.dispatch(MigrationAction.Clear).joinBlocking()
verifyOrder {
logger.debug("Migration state: MIGRATING")
logger.debug("Migration state: COMPLETED")
logger.debug("Migration state: NONE")
}
}
@Test
fun `metrics are logged when migration is completed`() = testDispatcher.runBlockingTest {
listener.start()
store.dispatch(MigrationAction.Completed).joinBlocking()
verify { metrics.track(Event.FennecToFenixMigrated) }
}
}

View File

@ -4,6 +4,7 @@
package org.mozilla.fenix.search package org.mozilla.fenix.search
import android.content.Intent
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavDirections import androidx.navigation.NavDirections
import io.mockk.every import io.mockk.every
@ -24,7 +25,9 @@ import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.crashes.CrashListActivity
import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.intentFilterEq
import org.mozilla.fenix.ext.metrics import org.mozilla.fenix.ext.metrics
import org.mozilla.fenix.ext.navigateSafe import org.mozilla.fenix.ext.navigateSafe
import org.mozilla.fenix.ext.searchEngineManager import org.mozilla.fenix.ext.searchEngineManager
@ -88,10 +91,13 @@ class DefaultSearchControllerTest {
@Test @Test
fun handleCrashesUrlCommitted() { fun handleCrashesUrlCommitted() {
val url = "about:crashes" val url = "about:crashes"
every { activity.packageName } returns testContext.packageName
controller.handleUrlCommitted(url) controller.handleUrlCommitted(url)
verify { activity.startActivity(any()) } verify {
activity.startActivity(intentFilterEq(Intent(testContext, CrashListActivity::class.java)))
}
} }
@Test @Test

View File

@ -61,7 +61,7 @@ class InContentTelemetryTest {
telemetry.processMessage(message) telemetry.processMessage(message)
verify { telemetry.trackPartnerUrlTypeMetric(url, any()) } verify { telemetry.trackPartnerUrlTypeMetric(url, listOf(first, second)) }
} }
@Test @Test

View File

@ -44,7 +44,7 @@ class AboutItemViewHolderTest {
fun `call listener on click`() { fun `call listener on click`() {
val holder = AboutItemViewHolder(view, listener) val holder = AboutItemViewHolder(view, listener)
holder.bind(item) holder.bind(item)
view.performClick() holder.itemView.performClick()
verify { listener.onAboutItemClicked(AboutItem.Libraries) } verify { listener.onAboutItemClicked(AboutItem.Libraries) }
} }

View File

@ -54,7 +54,7 @@ class DefaultDeleteBrowsingDataControllerTest {
controller.deleteBrowsingData() controller.deleteBrowsingData()
verify { verify {
context.components.core.engine.clearData(any()) context.components.core.engine.clearData(Engine.BrowsingData.select(Engine.BrowsingData.DOM_STORAGES))
context.components.core.historyStorage context.components.core.historyStorage
} }
} }

View File

@ -0,0 +1,34 @@
/* 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.logins
import io.mockk.mockk
import io.mockk.verify
import org.junit.Test
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
import org.mozilla.fenix.settings.logins.interactor.EditLoginInteractor
class EditLoginInteractorTest {
private val loginsController: SavedLoginsStorageController = mockk(relaxed = true)
private val interactor = EditLoginInteractor(loginsController)
@Test
fun findPotentialDupesTest() {
val id = "anyId"
interactor.findPotentialDuplicates(id)
verify { loginsController.findPotentialDuplicates(id) }
}
@Test
fun saveLoginTest() {
val id = "anyId"
val username = "usernameText"
val password = "passwordText"
interactor.onSaveLogin(id, username, password)
verify { loginsController.save(id, username, password) }
}
}

View File

@ -0,0 +1,30 @@
/* 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.logins
import io.mockk.mockk
import io.mockk.verifyAll
import org.junit.Test
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
import org.mozilla.fenix.settings.logins.interactor.LoginDetailInteractor
class LoginDetailInteractorTest {
private val loginsController: SavedLoginsStorageController = mockk(relaxed = true)
private val interactor = LoginDetailInteractor(loginsController)
@Test
fun fetchLoginListTest() {
val id = "anyId"
interactor.onFetchLoginList(id)
verifyAll { loginsController.fetchLoginDetails(id) }
}
@Test
fun deleteLoginTest() {
val id = "anyId"
interactor.onDeleteLogin(id)
verifyAll { loginsController.delete(id) }
}
}

View File

@ -15,6 +15,7 @@ import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.settings.logins.view.LoginDetailView
@RunWith(FenixRobolectricTestRunner::class) @RunWith(FenixRobolectricTestRunner::class)
class LoginDetailViewTest { class LoginDetailViewTest {
@ -31,7 +32,8 @@ class LoginDetailViewTest {
), ),
searchedForText = null, searchedForText = null,
sortingStrategy = SortingStrategy.LastUsed(mockk()), sortingStrategy = SortingStrategy.LastUsed(mockk()),
highlightedItem = SavedLoginsSortingStrategyMenu.Item.LastUsedSort highlightedItem = SavedLoginsSortingStrategyMenu.Item.LastUsedSort,
duplicateLogins = listOf()
) )
private lateinit var view: ViewGroup private lateinit var view: ViewGroup

View File

@ -30,7 +30,8 @@ class LoginsFragmentStoreTest {
filteredItems = emptyList(), filteredItems = emptyList(),
searchedForText = null, searchedForText = null,
sortingStrategy = SortingStrategy.LastUsed(mockk()), sortingStrategy = SortingStrategy.LastUsed(mockk()),
highlightedItem = SavedLoginsSortingStrategyMenu.Item.LastUsedSort highlightedItem = SavedLoginsSortingStrategyMenu.Item.LastUsedSort,
duplicateLogins = listOf()
) )
@Test @Test

View File

@ -6,7 +6,6 @@ package org.mozilla.fenix.settings.logins
import androidx.navigation.NavController import androidx.navigation.NavController
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify
import io.mockk.verifyAll import io.mockk.verifyAll
import mozilla.components.support.test.robolectric.testContext import mozilla.components.support.test.robolectric.testContext
import org.junit.Test import org.junit.Test
@ -16,24 +15,32 @@ import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.settings.SupportUtils
import org.mozilla.fenix.settings.logins.controller.LoginsListController
import org.mozilla.fenix.settings.logins.fragment.SavedLoginsFragmentDirections
import org.mozilla.fenix.utils.Settings import org.mozilla.fenix.utils.Settings
@RunWith(FenixRobolectricTestRunner::class) @RunWith(FenixRobolectricTestRunner::class)
class SavedLoginsControllerTest { class LoginsListControllerTest {
private val store: LoginsFragmentStore = mockk(relaxed = true) private val store: LoginsFragmentStore = mockk(relaxed = true)
private val settings: Settings = mockk(relaxed = true)
private val sortingStrategy: SortingStrategy = SortingStrategy.Alphabetically(testContext)
private val navController: NavController = mockk(relaxed = true) private val navController: NavController = mockk(relaxed = true)
private val browserNavigator: (String, Boolean, BrowserDirection) -> Unit = mockk(relaxed = true) private val browserNavigator: (String, Boolean, BrowserDirection) -> Unit = mockk(relaxed = true)
private val settings: Settings = mockk(relaxed = true)
private val metrics: MetricController = mockk(relaxed = true) private val metrics: MetricController = mockk(relaxed = true)
private val sortingStrategy: SortingStrategy = SortingStrategy.Alphabetically(testContext) private val controller =
private val controller = SavedLoginsController(store, navController, browserNavigator, settings, metrics) LoginsListController(
loginsFragmentStore = store,
navController = navController,
browserNavigator = browserNavigator,
settings = settings,
metrics = metrics
)
@Test @Test
fun `GIVEN a sorting strategy, WHEN handleSort is called on the controller, THEN the correct action should be dispatched and the strategy saved in sharedPref`() { fun `GIVEN a sorting strategy, WHEN handleSort is called on the controller, THEN the correct action should be dispatched and the strategy saved in sharedPref`() {
controller.handleSort(sortingStrategy) controller.handleSort(sortingStrategy)
verify { verifyAll {
store.dispatch( store.dispatch(
LoginsAction.SortLogins( LoginsAction.SortLogins(
SortingStrategy.Alphabetically( SortingStrategy.Alphabetically(
@ -55,7 +62,7 @@ class SavedLoginsControllerTest {
store.dispatch(LoginsAction.LoginSelected(login)) store.dispatch(LoginsAction.LoginSelected(login))
metrics.track(Event.OpenOneLogin) metrics.track(Event.OpenOneLogin)
navController.navigate( navController.navigate(
SavedLoginsFragmentDirections.actionSavedLoginsFragmentToLoginDetailFragment(login.guid) SavedLoginsFragmentDirections.actionSavedLoginsFragmentToLoginDetailFragment(login.guid)
) )
} }
} }
@ -64,7 +71,7 @@ class SavedLoginsControllerTest {
fun `GIVEN the learn more option, WHEN handleLearnMoreClicked is called for it, then we should open the right support webpage`() { fun `GIVEN the learn more option, WHEN handleLearnMoreClicked is called for it, then we should open the right support webpage`() {
controller.handleLearnMoreClicked() controller.handleLearnMoreClicked()
verify { verifyAll {
browserNavigator.invoke( browserNavigator.invoke(
SupportUtils.getGenericSumoURLForTopic(SupportUtils.SumoTopic.SYNC_SETUP), SupportUtils.getGenericSumoURLForTopic(SupportUtils.SumoTopic.SYNC_SETUP),
true, true,

View File

@ -16,6 +16,8 @@ import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor
import org.mozilla.fenix.settings.logins.view.LoginsListViewHolder
@RunWith(FenixRobolectricTestRunner::class) @RunWith(FenixRobolectricTestRunner::class)
class LoginsListViewHolderTest { class LoginsListViewHolderTest {
@ -39,7 +41,10 @@ class LoginsListViewHolderTest {
@Test @Test
fun `bind url and username`() { fun `bind url and username`() {
val holder = LoginsListViewHolder(view, interactor) val holder = LoginsListViewHolder(
view,
interactor
)
holder.bind(baseLogin) holder.bind(baseLogin)
assertEquals("mozilla.org", view.webAddressView.text) assertEquals("mozilla.org", view.webAddressView.text)
@ -48,7 +53,10 @@ class LoginsListViewHolderTest {
@Test @Test
fun `call interactor on click`() { fun `call interactor on click`() {
val holder = LoginsListViewHolder(view, interactor) val holder = LoginsListViewHolder(
view,
interactor
)
holder.bind(baseLogin) holder.bind(baseLogin)
view.performClick() view.performClick()

View File

@ -7,15 +7,25 @@ package org.mozilla.fenix.settings.logins
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verifyAll import io.mockk.verifyAll
import mozilla.components.support.test.robolectric.testContext import mozilla.components.support.test.robolectric.testContext
import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.settings.logins.controller.LoginsListController
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor
import kotlin.random.Random import kotlin.random.Random
@RunWith(FenixRobolectricTestRunner::class) @RunWith(FenixRobolectricTestRunner::class)
class SavedLoginsInteractorTest { class SavedLoginsInteractorTest {
private val controller: SavedLoginsController = mockk(relaxed = true) private val listController: LoginsListController = mockk(relaxed = true)
private val interactor = SavedLoginsInteractor(controller) private val savedLoginsStorageController: SavedLoginsStorageController = mockk(relaxed = true)
private lateinit var interactor: SavedLoginsInteractor
@Before
fun setup() {
interactor = SavedLoginsInteractor(listController, savedLoginsStorageController)
}
@Test @Test
fun `GIVEN a SavedLogin being clicked, WHEN the interactor is called for it, THEN it should just delegate the controller`() { fun `GIVEN a SavedLogin being clicked, WHEN the interactor is called for it, THEN it should just delegate the controller`() {
@ -23,7 +33,7 @@ class SavedLoginsInteractorTest {
interactor.onItemClicked(item) interactor.onItemClicked(item)
verifyAll { verifyAll {
controller.handleItemClicked(item) listController.handleItemClicked(item)
} }
} }
@ -34,7 +44,7 @@ class SavedLoginsInteractorTest {
interactor.onSortingStrategyChanged(sortingStrategy) interactor.onSortingStrategyChanged(sortingStrategy)
verifyAll { verifyAll {
controller.handleSort(sortingStrategy) listController.handleSort(sortingStrategy)
} }
} }
@ -43,7 +53,13 @@ class SavedLoginsInteractorTest {
interactor.onLearnMoreClicked() interactor.onLearnMoreClicked()
verifyAll { verifyAll {
controller.handleLearnMoreClicked() listController.handleLearnMoreClicked()
} }
} }
@Test
fun loadAndMapLoginsTest() {
interactor.loadAndMapLogins()
verifyAll { savedLoginsStorageController.handleLoadAndMapLogins() }
}
} }

View File

@ -0,0 +1,139 @@
/* 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.logins
import android.content.Context
import android.os.Looper
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestCoroutineScope
import mozilla.components.concept.storage.Login
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.R
import org.mozilla.fenix.components.Components
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.LooperMode
@ExperimentalCoroutinesApi
@LooperMode(LooperMode.Mode.PAUSED)
@RunWith(FenixRobolectricTestRunner::class)
class SavedLoginsStorageControllerTest {
private lateinit var components: Components
private val context: Context = mockk(relaxed = true)
private lateinit var controller: SavedLoginsStorageController
private val navController: NavController = mockk(relaxed = true)
private val loginsFragmentStore: LoginsFragmentStore = mockk(relaxed = true)
private val scope = TestCoroutineScope()
private val loginMock: Login = mockk(relaxed = true)
@Before
fun setup() {
every { navController.currentDestination } returns NavDestination("").apply {
id = R.id.loginDetailFragment
}
coEvery { context.components.core.passwordsStorage.get(any()) } returns loginMock
every { loginsFragmentStore.dispatch(any()) } returns mockk()
coEvery { context.components.core.passwordsStorage } returns mockk(relaxed = true)
components = mockk(relaxed = true)
controller = SavedLoginsStorageController(
context = context,
viewLifecycleScope = MainScope(),
navController = navController,
loginsFragmentStore = loginsFragmentStore
)
}
@After
fun cleanUp() {
scope.cleanupTestCoroutines()
}
@Test
fun `WHEN a login is deleted, THEN navigate back to the previous page`() = runBlocking {
val loginId = "id"
// mock for deleteLoginJob: Deferred<Boolean>?
coEvery { context.components.core.passwordsStorage.delete(any()) } returns true
controller.delete(loginId)
shadow()
coVerify { context.components.core.passwordsStorage.delete(loginId) }
}
private fun shadow() {
// solves issue with Roboelectric v4.3 and SDK 28
// https://github.com/robolectric/robolectric/issues/5356
shadowOf(Looper.getMainLooper()).idle()
}
@Test
fun `WHEN fetching the login list, THEN update the state in the store`() {
val loginId = "id"
// for deferredLogin: Deferred<List<Login>>?
coEvery { context.components.core.passwordsStorage.list() } returns listOf()
controller.fetchLoginDetails(loginId)
coVerify { context.components.core.passwordsStorage.list() }
}
@Test
fun `WHEN saving an update to an item, THEN navigate to login detail view`() {
val login = Login(
guid = "id",
origin = "https://www.test.co.gov.org",
username = "user123",
password = "securePassword1",
httpRealm = "httpRealm",
formActionOrigin = ""
)
coEvery { context.components.core.passwordsStorage.get(any()) } returns loginMock
controller.save(login.guid!!, login.username, login.password)
coVerify { context.components.core.passwordsStorage.get(any()) }
}
@Test
fun `WHEN finding login dupes, THEN update duplicates in the store`() {
val login = Login(
guid = "id",
origin = "https://www.test.co.gov.org",
username = "user123",
password = "securePassword1",
httpRealm = "httpRealm",
formActionOrigin = ""
)
coEvery { context.components.core.passwordsStorage.get(any()) } returns login
// for deferredLogin: Deferred<List<Login>>?
coEvery {
context.components.core.passwordsStorage.getPotentialDupesIgnoringUsername(any())
} returns listOf()
controller.findPotentialDuplicates(login.guid!!)
shadow()
coVerify {
context.components.core.passwordsStorage.getPotentialDupesIgnoringUsername(login)
}
}
}

Some files were not shown because too many files have changed in this diff Show More