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:
- fenix-core@mozilla.com
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:
resume:

View File

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

View File

@ -82,6 +82,8 @@ class HistoryRobot {
.click()
}
fun verifyDeleteSnackbarText(text: String) = assertSnackBarText(text)
class Transition {
fun closeMenu(interact: HistoryRobot.() -> Unit): Transition {
closeButton().click()
@ -158,3 +160,6 @@ private fun assertDeleteConfirmationMessage() =
.check(matches(isDisplayed()))
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.NavigationUI
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.gms.tasks.Tasks.call
import kotlinx.android.synthetic.main.activity_home.*
import kotlinx.coroutines.CoroutineScope
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.TrackingProtectionFragmentDirections
import org.mozilla.fenix.settings.about.AboutFragmentDirections
import org.mozilla.fenix.settings.logins.LoginDetailFragmentDirections
import org.mozilla.fenix.settings.logins.SavedLoginsAuthFragmentDirections
import org.mozilla.fenix.settings.logins.fragment.SavedLoginsAuthFragmentDirections
import org.mozilla.fenix.settings.logins.fragment.LoginDetailFragmentDirections
import org.mozilla.fenix.settings.search.AddSearchEngineFragmentDirections
import org.mozilla.fenix.settings.search.EditCustomSearchEngineFragmentDirections
import org.mozilla.fenix.share.AddNewDeviceFragmentDirections

View File

@ -8,13 +8,13 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Switch
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.Navigation
import androidx.navigation.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.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -301,7 +301,7 @@ class InstalledAddonDetailsFragment : Fragment() {
view.remove_add_on.isClickable = clickable
}
private fun Switch.setState(checked: Boolean) {
private fun SwitchMaterial.setState(checked: Boolean) {
val text = if (checked) {
R.string.mozac_feature_addons_enabled
} else {

View File

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

View File

@ -11,22 +11,24 @@ import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.recyclerview.widget.DiffUtil
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.ext.components
import org.mozilla.fenix.ext.loadIntoView
import org.mozilla.fenix.home.Tab
import org.mozilla.fenix.utils.view.ViewHolder
class CollectionCreationTabListAdapter(
private val interactor: CollectionCreationInteractor
) : RecyclerView.Adapter<TabViewHolder>() {
private var tabs: List<Tab> = listOf()
private var selectedTabs: MutableSet<Tab> = mutableSetOf()
private var hideCheckboxes = false
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TabViewHolder {
val view =
LayoutInflater.from(parent.context).inflate(TabViewHolder.LAYOUT_ID, parent, false)
val view = LayoutInflater.from(parent.context)
.inflate(TabViewHolder.LAYOUT_ID, parent, false)
return TabViewHolder(view)
}
@ -39,11 +41,11 @@ class CollectionCreationTabListAdapter(
is CheckChanged -> {
val checkChanged = payloads[0] as CheckChanged
if (checkChanged.shouldBeChecked) {
holder.itemView.tab_selected_checkbox.isChecked = true
holder.tab_selected_checkbox.isChecked = true
} 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) {
val tab = tabs[position]
val isSelected = selectedTabs.contains(tab)
holder.itemView.tab_selected_checkbox.setOnCheckedChangeListener { _, isChecked ->
holder.tab_selected_checkbox.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) {
selectedTabs.add(tab)
interactor.addTabToSelection(tab)
@ -86,57 +88,24 @@ class CollectionCreationTabListAdapter(
}
}
private class TabDiffUtil(
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!!
class TabViewHolder(view: View) : ViewHolder(view) {
init {
view.collection_item_tab.setOnClickListener {
checkbox.isChecked = !checkbox.isChecked
collection_item_tab.setOnClickListener {
tab_selected_checkbox.isChecked = !tab_selected_checkbox.isChecked
}
}
fun bind(tab: Tab, isSelected: Boolean, shouldHideCheckBox: Boolean) {
itemView.hostname.text = tab.hostname
itemView.tab_title.text = tab.title
checkbox.isInvisible = shouldHideCheckBox
hostname.text = tab.hostname
tab_title.text = tab.title
tab_selected_checkbox.isInvisible = shouldHideCheckBox
itemView.isClickable = !shouldHideCheckBox
if (checkbox.isChecked != isSelected) {
checkbox.isChecked = isSelected
if (tab_selected_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 {

View File

@ -10,12 +10,13 @@ import android.view.ViewGroup
import androidx.core.graphics.BlendModeColorFilterCompat.createBlendModeColorFilterCompat
import androidx.core.graphics.BlendModeCompat.SRC_IN
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 org.mozilla.fenix.R
import org.mozilla.fenix.components.description
import org.mozilla.fenix.ext.getIconColor
import org.mozilla.fenix.home.Tab
import org.mozilla.fenix.utils.view.ViewHolder
class SaveCollectionListAdapter(
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) {
itemView.collection_item.text = collection.title
itemView.collection_description.text = collection.description(itemView.context)
itemView.collection_icon.colorFilter =
collection_item.text = collection.title
collection_description.text = collection.description(itemView.context)
collection_icon.colorFilter =
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 contextMenuUseCases by lazy { ContextMenuUseCases(sessionManager, store) }
val contextMenuUseCases by lazy { ContextMenuUseCases(store) }
val engineSessionUseCases by lazy { EngineSessionUseCases(sessionManager) }

View File

@ -466,6 +466,15 @@ private val Event.wrapper: EventWrapper<*>?
is Event.ViewLoginPassword -> EventWrapper<NoExtraKeys>(
{ 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>(
{ SearchSuggestions.enableInPrivate.record(it) }
)

View File

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

View File

@ -9,9 +9,11 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import mozilla.components.browser.session.SessionManager
import mozilla.components.concept.engine.Engine
import mozilla.components.concept.engine.prompt.ShareData
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.tab.collections.ext.restore
import mozilla.components.feature.tabs.TabsUseCases
import mozilla.components.feature.top.sites.TopSite
import org.mozilla.fenix.BrowserDirection
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.MetricController
import org.mozilla.fenix.components.tips.Tip
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.ext.sessionsOfType
import org.mozilla.fenix.home.HomeFragment
import org.mozilla.fenix.home.HomeFragmentAction
import org.mozilla.fenix.home.HomeFragmentDirections
import org.mozilla.fenix.home.HomeFragmentStore
import org.mozilla.fenix.home.Tab
import org.mozilla.fenix.settings.SupportUtils
import mozilla.components.feature.tab.collections.Tab as ComponentTab
@ -130,26 +131,20 @@ interface SessionControlController {
@SuppressWarnings("TooManyFunctions", "LargeClass")
class DefaultSessionControlController(
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 navController: NavController,
private val viewLifecycleScope: CoroutineScope,
private val getListOfTabs: () -> List<Tab>,
private val hideOnboarding: () -> Unit,
private val registerCollectionStorageObserver: () -> 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
) : 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) {
metrics.track(Event.CollectionAddTabPressed)
@ -162,7 +157,7 @@ class DefaultSessionControlController(
override fun handleCollectionOpenTabClicked(tab: ComponentTab) {
sessionManager.restore(
activity,
activity.components.core.engine,
engine,
tab,
onTabRestored = {
activity.openToBrowser(BrowserDirection.FromHome)
@ -182,10 +177,10 @@ class DefaultSessionControlController(
override fun handleCollectionOpenTabsTapped(collection: TabCollection) {
sessionManager.restore(
activity,
activity.components.core.engine,
engine,
collection,
onFailure = { url ->
activity.components.useCases.tabsUseCases.addTab.invoke(url)
addTabUseCase.invoke(url)
}
)
@ -261,7 +256,7 @@ class DefaultSessionControlController(
metrics.track(Event.TopSiteOpenInNewTab)
if (isDefault) { metrics.track(Event.TopSiteOpenDefault) }
if (url == SupportUtils.POCKET_TRENDING_URL) { metrics.track(Event.PocketTopSiteClicked) }
activity.components.useCases.tabsUseCases.addTab.invoke(
addTabUseCase.invoke(
url = url,
selectTab = true,
startLoading = true
@ -274,15 +269,24 @@ class DefaultSessionControlController(
}
override fun handleOpenSettingsClicked() {
openSettingsScreen()
val directions = HomeFragmentDirections.actionGlobalPrivateBrowsingFragment()
navController.nav(R.id.homeFragment, directions)
}
override fun handleWhatsNewGetAnswersClicked() {
openWhatsNewLink()
activity.openToBrowserAndLoad(
searchTermOrURL = SupportUtils.getWhatsNewUrl(activity),
newTab = true,
from = BrowserDirection.FromHome
)
}
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) {
@ -303,7 +307,11 @@ class DefaultSessionControlController(
// Only register the observer right before moving to collection creation
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(
tabIds = tabIds,
saveCollectionStep = step,

View File

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

View File

@ -283,13 +283,17 @@ class BookmarkFragment : LibraryPageFragment<BookmarkNode>(), UserInteractionHan
}
private fun deleteMulti(selected: Set<BookmarkNode>, eventType: Event = Event.RemoveBookmarks) {
selected.forEach { if (it.type == BookmarkNodeType.FOLDER) {
showRemoveFolderDialog(selected)
return
} }
updatePendingBookmarksToDelete(selected)
pendingBookmarkDeletionJob = getDeleteOperation(eventType)
val message = when (eventType) {
is Event.RemoveBookmarks -> {
getRemoveBookmarksSnackBarMessage(selected)
getRemoveBookmarksSnackBarMessage(selected, containsFolders = false)
}
is Event.RemoveBookmarkFolder,
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) {
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 {
val bookmarkNode = selected.first()
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() {
super.onDestroyView()
_bookmarkInteractor = null
}
private fun showRemoveFolderDialog(selected: BookmarkNode) {
private fun showRemoveFolderDialog(selected: Set<BookmarkNode>) {
activity?.let { activity ->
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, _ ->
dialog.cancel()
}
setPositiveButton(R.string.delete_browsing_data_prompt_allow) { dialog: DialogInterface, _ ->
updatePendingBookmarksToDelete(setOf(selected))
updatePendingBookmarksToDelete(selected)
pendingBookmarkDeletionJob = getDeleteOperation(Event.RemoveBookmarkFolder)
dialog.dismiss()
val message = getDeleteDialogString(selected)
val snackbarMessage = getRemoveBookmarksSnackBarMessage(selected, containsFolders = true)
viewLifecycleOwner.lifecycleScope.allowUndo(
requireView(),
message,
snackbarMessage,
getString(R.string.bookmark_undo_deletion),
{
undoPendingDeletion(setOf(selected))
undoPendingDeletion(selected)
},
operation = getDeleteOperation(Event.RemoveBookmarkFolder)
)
@ -362,14 +382,6 @@ class BookmarkFragment : LibraryPageFragment<BookmarkNode>(), UserInteractionHan
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>) {
pendingBookmarksToDelete.removeAll(selected)
pendingBookmarkDeletionJob = null

View File

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

View File

@ -33,6 +33,8 @@ class HistoryAdapter(
private var mode: HistoryFragmentState.Mode = HistoryFragmentState.Mode.Normal
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
@ -48,13 +50,33 @@ class HistoryAdapter(
}
override fun onBindViewHolder(holder: HistoryListItemViewHolder, position: Int) {
val previous = if (position == 0) null else getItem(position - 1)
val current = getItem(position) ?: return
val headerForCurrentItem = timeGroupForHistoryItem(current)
val isPendingDeletion = pendingDeletionIds.contains(current.visitedAt)
var timeGroup: HistoryItemTimeGroup? = null
val previousHeader = previous?.let(::timeGroupForHistoryItem)
val currentHeader = timeGroupForHistoryItem(current)
val timeGroup = if (currentHeader != previousHeader) currentHeader else null
holder.bind(current, timeGroup, position == 0, mode)
// Add or remove the header and position to the map depending on it's deletion status
if (itemsWithHeaders.containsKey(headerForCurrentItem)) {
if (isPendingDeletion && itemsWithHeaders[headerForCurrentItem] == position) {
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 {

View File

@ -17,8 +17,11 @@ import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavDirections
import androidx.navigation.fragment.findNavController
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.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
@ -31,7 +34,6 @@ import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.addons.showSnackBar
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.components.Components
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.StoreProvider
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.toShortUrl
import org.mozilla.fenix.library.LibraryPageFragment
import org.mozilla.fenix.utils.allowUndo
@SuppressWarnings("TooManyFunctions", "LargeClass")
class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandler {
@ -49,6 +52,8 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
private lateinit var historyView: HistoryView
private lateinit var historyInteractor: HistoryInteractor
private lateinit var viewModel: HistoryViewModel
private var undoScope: CoroutineScope? = null
private var pendingHistoryDeletionJob: (suspend () -> Unit)? = null
override fun onCreateView(
inflater: LayoutInflater,
@ -59,7 +64,10 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
historyStore = StoreProvider.get(this) {
HistoryFragmentStore(
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>) {
val message = getMultiSelectSnackBarMessage(items)
viewLifecycleOwner.lifecycleScope.launch {
context?.components?.run {
for (item in items) {
analytics.metrics.track(Event.HistoryItemRemoved)
core.historyStorage.deleteVisit(item.url, item.visitedAt)
}
}
viewModel.invalidate()
showSnackBar(requireView(), message)
historyStore.dispatch(HistoryFragmentAction.ExitDeletionMode)
}
updatePendingHistoryToDelete(items)
undoScope = CoroutineScope(IO)
undoScope?.allowUndo(
requireView(),
getMultiSelectSnackBarMessage(items),
getString(R.string.bookmark_undo_deletion),
{
undoPendingDeletion(items)
},
getDeleteHistoryItemsOperation(items)
)
}
@ExperimentalCoroutinesApi
@ -146,8 +154,8 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
val menuRes = when (historyStore.state.mode) {
HistoryFragmentState.Mode.Normal -> R.menu.library_menu
is HistoryFragmentState.Mode.Syncing -> R.menu.library_menu
is HistoryFragmentState.Mode.Editing -> R.menu.history_select_multi
else -> return
}
inflater.inflate(menuRes, menu)
@ -166,13 +174,8 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
true
}
R.id.delete_history_multi_select -> {
val message = getMultiSelectSnackBarMessage(selectedItems)
viewLifecycleOwner.lifecycleScope.launch(Main) {
deleteSelectedHistory(historyStore.state.mode.selectedItems, requireComponents)
viewModel.invalidate()
historyStore.dispatch(HistoryFragmentAction.ExitDeletionMode)
showSnackBar(requireView(), message)
}
deleteHistoryItems(historyStore.state.mode.selectedItems)
historyStore.dispatch(HistoryFragmentAction.ExitEditMode)
true
}
R.id.open_history_in_new_tabs_multi_select -> {
@ -181,8 +184,7 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
selectedItem.url
}
nav(
R.id.historyFragment,
navigate(
HistoryFragmentDirections.actionGlobalTabTrayDialogFragment()
)
true
@ -197,8 +199,7 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
browsingModeManager.mode = BrowsingMode.Private
supportActionBar?.hide()
}
nav(
R.id.historyFragment,
navigate(
HistoryFragmentDirections.actionGlobalTabTrayDialogFragment()
)
true
@ -210,14 +211,23 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
return if (historyItems.size > 1) {
getString(R.string.history_delete_multiple_items_snackbar)
} else {
getString(
R.string.history_delete_single_item_snackbar,
historyItems.first().url.toShortUrl(requireComponents.publicSuffixList)
String.format(
requireContext().getString(
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) {
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>) {
requireComponents.analytics.metrics.track(Event.HistoryItemShared)
val directions = HistoryFragmentDirections.actionGlobalShareFragment(
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() {

View File

@ -30,6 +30,8 @@ sealed class HistoryFragmentAction : Action {
object ExitEditMode : HistoryFragmentAction()
data class AddItemForRemoval(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 ExitDeletionMode : HistoryFragmentAction()
object StartSync : HistoryFragmentAction()
@ -41,12 +43,16 @@ sealed class HistoryFragmentAction : Action {
* @property items List of HistoryItem to display
* @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 {
open val selectedItems = emptySet<HistoryItem>()
object Normal : Mode()
object Deleting : Mode()
object Syncing : 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.EnterDeletionMode -> state.copy(mode = HistoryFragmentState.Mode.Deleting)
is HistoryFragmentAction.ExitDeletionMode -> state.copy(mode = HistoryFragmentState.Mode.Normal)
is HistoryFragmentAction.EnterDeletionMode -> state.copy(isDeletingItems = true)
is HistoryFragmentAction.ExitDeletionMode -> state.copy(isDeletingItems = false)
is HistoryFragmentAction.StartSync -> state.copy(mode = HistoryFragmentState.Mode.Syncing)
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)
.inflate(R.layout.component_history, container, true)
private var items: List<HistoryItem> = listOf()
var mode: HistoryFragmentState.Mode = HistoryFragmentState.Mode.Normal
private set
@ -116,13 +115,16 @@ class HistoryView(
fun update(state: HistoryFragmentState) {
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.isEnabled =
state.mode === HistoryFragmentState.Mode.Normal || state.mode === HistoryFragmentState.Mode.Syncing
items = state.items
mode = state.mode
historyAdapter.updatePendingDeletionIds(state.pendingDeletionIds)
updateEmptyState(state.pendingDeletionIds.size != historyAdapter.currentList?.size)
historyAdapter.updateMode(state.mode)
val first = layoutManager.findFirstVisibleItemPosition()
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.ext.hideAndDisable
import org.mozilla.fenix.ext.showAndEnable
import org.mozilla.fenix.utils.Do
import org.mozilla.fenix.library.SelectionHolder
import org.mozilla.fenix.library.history.HistoryFragmentState
import org.mozilla.fenix.library.history.HistoryInteractor
import org.mozilla.fenix.library.history.HistoryItem
import org.mozilla.fenix.library.history.HistoryItemMenu
import org.mozilla.fenix.library.history.HistoryItemTimeGroup
import org.mozilla.fenix.utils.Do
class HistoryListItemViewHolder(
view: View,
@ -44,8 +44,15 @@ class HistoryListItemViewHolder(
item: HistoryItem,
timeGroup: HistoryItemTimeGroup?,
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.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.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,
* along with controls to remove the exception.
*/
class LoginExceptionsAdapter(
private val interactor: LoginExceptionsInteractor
) : ListAdapter<AdapterItem, RecyclerView.ViewHolder>(DiffCallback) {
) : ListAdapter<LoginExceptionsAdapter.AdapterItem, RecyclerView.ViewHolder>(DiffCallback) {
/**
* Change the list of items that are displayed.
* Header and footer items are added to the list as well.
*/
fun updateData(exceptions: List<LoginException>) {
val adapterItems: List<AdapterItem> =
listOf(AdapterItem.Header) + exceptions.map { AdapterItem.Item(it) } + listOf(
AdapterItem.DeleteButton
)
val adapterItems: List<AdapterItem> = listOf(AdapterItem.Header) +
exceptions.map { AdapterItem.Item(it) } +
listOf(AdapterItem.DeleteButton)
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) =
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")
override fun areContentsTheSame(oldItem: AdapterItem, newItem: AdapterItem) =

View File

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

View File

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

View File

@ -12,12 +12,13 @@ import org.mozilla.fenix.R
class LoginExceptionsHeaderViewHolder(
view: View
) : RecyclerView.ViewHolder(view) {
companion object {
const val LAYOUT_ID = R.layout.exceptions_description
}
init {
view.exceptions_description.text =
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
import android.content.Intent
import android.graphics.Rect
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.DimenRes
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.DiffUtil
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.migration_list_item.view.*
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.ktx.android.content.getColorFromAttr
import mozilla.components.support.migration.AbstractMigrationProgressActivity
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.state.MigrationAction
import mozilla.components.support.migration.state.MigrationProgress
@ -97,91 +84,10 @@ class MigrationProgressActivity : AbstractMigrationProgressActivity() {
migration_button.setBackgroundResource(R.drawable.migration_button_background)
migration_button_progress_bar.visibility = View.INVISIBLE
// Keep the results list up-to-date.
statusAdapter.submitList(results.toItemList())
statusAdapter.updateData(results)
}
override fun onMigrationStateChanged(progress: MigrationProgress, results: MigrationResults) {
statusAdapter.submitList(results.toItemList())
}
}
// 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
statusAdapter.updateData(results)
}
}

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
* 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/. */
* 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
@ -15,7 +15,8 @@ import org.mozilla.fenix.components.metrics.MetricController
class MigrationTelemetryListener(
private val metrics: MetricController,
private val store: MigrationStore
private val store: MigrationStore,
private val logger: Logger = Logger("MigrationTelemetryListener")
) {
@OptIn(ExperimentalCoroutinesApi::class)
@ -23,7 +24,7 @@ class MigrationTelemetryListener(
// Observe for migration completed.
store.flowScoped { flow ->
flow.collect { state ->
Logger("MigrationTelemetryListener").debug("Migration state: ${state.progress}")
logger.debug("Migration state: ${state.progress}")
if (state.progress == MigrationProgress.COMPLETED) {
metrics.track(Event.FennecToFenixMigrated)
}

View File

@ -74,6 +74,6 @@ object Performance {
* Disables the first time PWA popup.
*/
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)
updateClipboardSuggestion(it, requireContext().components.clipboardHandler.url)
updateSearchSuggestionsHintVisibility(it)
updateToolbarContentDescription(it)
}
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() {
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 UpdateCurrentLogin(val item: SavedLogin) : LoginsAction()
data class SortLogins(val sortingStrategy: SortingStrategy) : LoginsAction()
data class ListOfDupes(val dupeList: List<SavedLogin>) : LoginsAction()
data class LoginSelected(val item: SavedLogin) : LoginsAction()
}
/**
* 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 currentItem The last item that was opened into the detail view
* @property searchedForText String used by the user to filter logins
* @property sortingStrategy sorting strategy selected by the user (Currently we support
* sorting alphabetically and by last used)
* @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(
val isLoading: Boolean = false,
@ -74,7 +76,8 @@ data class LoginsListState(
val currentItem: SavedLogin? = null,
val searchedForText: String?,
val sortingStrategy: SortingStrategy,
val highlightedItem: SavedLoginsSortingStrategyMenu.Item
val highlightedItem: SavedLoginsSortingStrategyMenu.Item,
val duplicateLogins: List<SavedLogin>
) : State
/**
@ -113,9 +116,14 @@ private fun savedLoginsStateReducer(
}
is LoginsAction.LoginSelected -> {
state.copy(
isLoading = true,
loginList = emptyList(),
filteredItems = emptyList()
isLoading = true,
loginList = 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
* 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.text.Editable
import android.text.InputType
import android.text.TextWatcher
import android.util.Log
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.core.content.ContextCompat
import androidx.appcompat.view.menu.ActionMenuItemView
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import kotlinx.android.synthetic.main.fragment_edit_login.*
import kotlinx.coroutines.CancellationException
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 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 kotlinx.android.synthetic.main.fragment_edit_login.view.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import mozilla.components.lib.state.ext.consumeFrom
import mozilla.components.support.ktx.android.view.hideKeyboard
import org.mozilla.fenix.R
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.redirectToReAuth
import org.mozilla.fenix.ext.requireComponents
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")
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)
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
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
private var listOfPossibleDupes: List<SavedLogin>? = null
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)
oldLogin = args.savedLoginItem
savedLoginsStore = StoreProvider.get(this) {
editLoginView = EditLoginView(view.editLoginLayout)
loginsFragmentStore = StoreProvider.get(this) {
LoginsFragmentStore(
LoginsListState(
isLoading = true,
@ -62,31 +75,59 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) {
filteredItems = listOf(),
searchedForText = null,
sortingStrategy = requireContext().settings().savedLoginsSortingStrategy,
highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem
highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem,
duplicateLogins = listOf()
)
)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
interactor = EditLoginInteractor(
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.isClickable = false
hostnameText.isFocusable = false
usernameText.text = args.savedLoginItem.username.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
passwordText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
passwordText.compoundDrawablePadding =
requireContext().resources
.getDimensionPixelOffset(R.dimen.saved_logins_end_icon_drawable_padding)
setUpClickListeners()
setUpTextListeners()
}
private fun setUpClickListeners() {
@ -105,14 +146,11 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) {
it.isEnabled = false
}
revealPasswordButton.setOnClickListener {
togglePasswordReveal()
}
var firstClick = true
passwordText.setOnClickListener {
if (firstClick) {
togglePasswordReveal()
firstClick = false
showPassword = !showPassword
if (showPassword) {
editLoginView.showPassword()
} else {
editLoginView.hidePassword()
}
}
}
@ -124,7 +162,6 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) {
view?.hideKeyboard()
}
}
editLoginLayout.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->
if (!hasFocus) {
view?.hideKeyboard()
@ -133,13 +170,20 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) {
usernameText.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(u: Editable?) {
if (u.toString() == oldLogin.username) {
inputLayoutUsername.error = null
inputLayoutUsername.errorIconDrawable = null
} else {
clearUsernameTextButton.isEnabled = true
// setDupeError() TODO in #10173
when {
u.toString() == oldLogin.username -> {
usernameChanged = false
validUsername = true
inputLayoutUsername.error = null
inputLayoutUsername.errorIconDrawable = null
}
else -> {
usernameChanged = true
clearUsernameTextButton.isEnabled = true
setDupeError()
}
}
setSaveButtonState()
}
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?) {
when {
p.toString().isEmpty() -> {
passwordChanged = true
clearPasswordTextButton.isEnabled = false
setPasswordError()
}
p.toString() == oldLogin.password -> {
passwordChanged = false
validPassword = true
inputLayoutPassword.error = null
inputLayoutPassword.errorIconDrawable = null
clearPasswordTextButton.isEnabled = true
}
else -> {
passwordChanged = true
validPassword = true
inputLayoutPassword.error = null
inputLayoutPassword.errorIconDrawable = null
clearPasswordTextButton.isEnabled = true
}
}
setSaveButtonState()
}
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() {
inputLayoutPassword?.let { layout ->
validPassword = false
layout.error = context?.getString(R.string.saved_login_password_required)
layout.setErrorIconDrawable(R.drawable.mozac_ic_warning)
}
}
layout.errorIconDrawable?.setTint(
ContextCompat.getColor(requireContext(), R.color.design_default_color_error)
)
private fun setSaveButtonState() {
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) {
R.id.save_login_button -> {
view?.hideKeyboard()
if (!passwordText.text.isNullOrBlank()) {
try {
attemptSaveAndExit()
} 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
)
}
}
if (saveEnabled) {
interactor.onSaveLogin(
args.savedLoginItem.guid,
usernameText.text.toString(),
passwordText.text.toString()
)
requireComponents.analytics.metrics.track(Event.EditLoginSave)
}
true
}
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
* 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.os.Bundle
@ -21,16 +21,8 @@ import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.google.android.material.snackbar.Snackbar
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.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 org.mozilla.fenix.BrowserDirection
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.increaseTapArea
import org.mozilla.fenix.ext.redirectToReAuth
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.settings
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.
@ -57,8 +56,10 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
private var login: SavedLogin? = null
private lateinit var savedLoginsStore: LoginsFragmentStore
private lateinit var loginDetailView: LoginDetailView
private lateinit var interactor: LoginDetailInteractor
private lateinit var menu: Menu
private var deleteDialog: AlertDialog? = null
private var showPassword = true
override fun onCreateView(
inflater: LayoutInflater,
@ -74,12 +75,14 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
filteredItems = listOf(),
searchedForText = null,
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))
fetchLoginDetails()
loginDetailView = LoginDetailView(
view.findViewById(R.id.loginDetailLayout)
)
return view
}
@ -87,16 +90,29 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
@ObsoleteCoroutinesApi
@ExperimentalCoroutinesApi
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) {
loginDetailView.update(it)
login = savedLoginsStore.state.currentItem
setUpCopyButtons()
showToolbar(
savedLoginsStore.state.currentItem?.origin?.urlToTrimmedHost(requireContext())
savedLoginsStore.state.currentItem?.origin?.simplifiedUrl()
?: ""
)
setUpPasswordReveal()
}
loginDetailView.togglePasswordReveal(showPassword)
}
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
revealPasswordButton.increaseTapArea(BUTTON_INCREASE_DPS)
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) {
if (FeatureFlags.loginsEdit) {
inflater.inflate(R.menu.login_options_menu, menu)
@ -206,9 +199,11 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
}
private fun editLogin() {
requireComponents.analytics.metrics.track(Event.EditLogin)
val directions =
LoginDetailFragmentDirections
.actionLoginDetailFragmentToEditLoginFragment(login!!)
LoginDetailFragmentDirections.actionLoginDetailFragmentToEditLoginFragment(
login!!
)
findNavController().navigate(directions)
}
@ -220,7 +215,8 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
dialog.cancel()
}
setPositiveButton(R.string.dialog_delete_positive) { dialog: DialogInterface, _ ->
deleteLogin()
requireComponents.analytics.metrics.track(Event.DeleteLogin)
interactor.onDeleteLogin(args.savedLoginId)
dialog.dismiss()
}
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.
* @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
* 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.app.Activity.RESULT_OK
@ -313,7 +313,8 @@ class SavedLoginsAuthFragment : PreferenceFragmentCompat(), AccountObserver {
}
private fun navigateToAccountProblemFragment() {
val directions = SavedLoginsAuthFragmentDirections.actionGlobalAccountProblemFragment()
val directions =
SavedLoginsAuthFragmentDirections.actionGlobalAccountProblemFragment()
findNavController().navigate(directions)
}

View File

@ -2,7 +2,7 @@
* 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
package org.mozilla.fenix.settings.logins.fragment
import android.os.Bundle
import android.view.LayoutInflater
@ -21,17 +21,9 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
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.ObsoleteCoroutinesApi
import mozilla.components.browser.menu.BrowserMenu
import mozilla.components.concept.storage.Login
import mozilla.components.lib.state.ext.consumeFrom
import org.mozilla.fenix.BrowserDirection
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.settings
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")
class SavedLoginsFragment : Fragment() {
private lateinit var savedLoginsStore: LoginsFragmentStore
private lateinit var savedLoginsView: SavedLoginsView
private lateinit var savedLoginsListView: SavedLoginsListView
private lateinit var savedLoginsInteractor: SavedLoginsInteractor
private lateinit var dropDownMenuAnchorView: View
private lateinit var sortingStrategyMenu: SavedLoginsSortingStrategyMenu
private lateinit var sortingStrategyPopupMenu: BrowserMenu
private lateinit var toolbarChildContainer: FrameLayout
private lateinit var sortLoginsMenuRoot: ConstraintLayout
private lateinit var loginsListController: LoginsListController
private lateinit var savedLoginsStorageController: SavedLoginsStorageController
override fun onResume() {
super.onResume()
@ -81,21 +84,39 @@ class SavedLoginsFragment : Fragment() {
filteredItems = listOf(),
searchedForText = null,
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(
store = savedLoginsStore,
navController = findNavController(),
browserNavigator = ::openToBrowserAndLoad,
settings = requireContext().settings(),
metrics = requireContext().components.analytics.metrics
loginsListController =
LoginsListController(
loginsFragmentStore = savedLoginsStore,
navController = findNavController(),
browserNavigator = ::openToBrowserAndLoad,
settings = requireContext().settings(),
metrics = requireContext().components.analytics.metrics
)
savedLoginsInteractor = SavedLoginsInteractor(savedLoginsController)
savedLoginsView = SavedLoginsView(view.savedLoginsLayout, savedLoginsInteractor)
loadAndMapLogins()
savedLoginsStorageController =
SavedLoginsStorageController(
context = requireContext(),
viewLifecycleScope = viewLifecycleOwner.lifecycleScope,
navController = findNavController(),
loginsFragmentStore = savedLoginsStore
)
savedLoginsInteractor =
SavedLoginsInteractor(
loginsListController,
savedLoginsStorageController
)
savedLoginsListView = SavedLoginsListView(
view.savedLoginsLayout,
savedLoginsInteractor
)
savedLoginsInteractor.loadAndMapLogins()
return view
}
@ -105,7 +126,7 @@ class SavedLoginsFragment : Fragment() {
super.onViewCreated(view, savedInstanceState)
consumeFrom(savedLoginsStore) {
sortingStrategyMenu.updateMenu(savedLoginsStore.state.highlightedItem)
savedLoginsView.update(it)
savedLoginsListView.update(it)
}
}
@ -122,7 +143,11 @@ class SavedLoginsFragment : Fragment() {
}
override fun onQueryTextChange(newText: String?): Boolean {
savedLoginsStore.dispatch(LoginsAction.FilterLogins(newText))
savedLoginsStore.dispatch(
LoginsAction.FilterLogins(
newText
)
)
return false
}
})
@ -141,31 +166,11 @@ class SavedLoginsFragment : Fragment() {
super.onPause()
}
private fun openToBrowserAndLoad(searchTermOrURL: String, newTab: Boolean, from: BrowserDirection) {
(activity as HomeActivity).openToBrowserAndLoad(searchTermOrURL, newTab, from)
}
private fun loadAndMapLogins() {
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 openToBrowserAndLoad(
searchTermOrURL: String,
newTab: Boolean,
from: BrowserDirection
) = (activity as HomeActivity).openToBrowserAndLoad(searchTermOrURL, newTab, from)
private fun initToolbar() {
showToolbar(getString(R.string.preferences_passwords_saved_logins))
@ -175,8 +180,12 @@ class SavedLoginsFragment : Fragment() {
sortLoginsMenuRoot = inflateSortLoginsMenuRoot()
dropDownMenuAnchorView = sortLoginsMenuRoot.findViewById(R.id.drop_down_menu_anchor_view)
when (requireContext().settings().savedLoginsSortingStrategy) {
is SortingStrategy.Alphabetically -> setupMenu(SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort)
is SortingStrategy.LastUsed -> setupMenu(SavedLoginsSortingStrategyMenu.Item.LastUsedSort)
is SortingStrategy.Alphabetically -> setupMenu(
SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort
)
is SortingStrategy.LastUsed -> setupMenu(
SavedLoginsSortingStrategyMenu.Item.LastUsedSort
)
}
}
@ -210,21 +219,29 @@ class SavedLoginsFragment : Fragment() {
}
private fun setupMenu(itemToHighlight: SavedLoginsSortingStrategyMenu.Item) {
sortingStrategyMenu = SavedLoginsSortingStrategyMenu(requireContext(), itemToHighlight) {
when (it) {
SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort -> {
savedLoginsInteractor.onSortingStrategyChanged(
SortingStrategy.Alphabetically(requireContext().applicationContext)
)
}
sortingStrategyMenu =
SavedLoginsSortingStrategyMenu(
requireContext(),
itemToHighlight
) {
when (it) {
SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort -> {
savedLoginsInteractor.onSortingStrategyChanged(
SortingStrategy.Alphabetically(
requireContext().applicationContext
)
)
}
SavedLoginsSortingStrategyMenu.Item.LastUsedSort -> {
savedLoginsInteractor.onSortingStrategyChanged(
SortingStrategy.LastUsed(requireContext().applicationContext)
)
SavedLoginsSortingStrategyMenu.Item.LastUsedSort -> {
savedLoginsInteractor.onSortingStrategyChanged(
SortingStrategy.LastUsed(
requireContext().applicationContext
)
)
}
}
}
}
attachMenu()
}

View File

@ -2,7 +2,7 @@
* 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
package org.mozilla.fenix.settings.logins.fragment
import android.os.Bundle
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
* 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.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import org.mozilla.fenix.R
import org.mozilla.fenix.settings.logins.SavedLogin
import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor
class LoginsAdapter(
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
* 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 androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.logins_item.view.*
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.loadIntoView
import org.mozilla.fenix.settings.logins.SavedLogin
import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor
class LoginsListViewHolder(
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
/**
* 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?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_TITLE, R.style.CreateShortcutDialogStyle)
@ -28,7 +28,7 @@ class FirstTimePwaFragment : DialogFragment() {
inflater: LayoutInflater,
container: ViewGroup?,
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?) {
super.onViewCreated(view, savedInstanceState)

View File

@ -14,20 +14,23 @@ import org.mozilla.fenix.ext.nav
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 settings: Settings,
private val webAppUseCases: WebAppUseCases
) : Session.Observer {
override fun onWebAppManifestChanged(session: Session, manifest: WebAppManifest?) {
if (webAppUseCases.isInstallable() && settings.shouldShowFirstTimePwaFragment) {
val directions = BrowserFragmentDirections.actionBrowserFragmentToFirstTimePwaFragment()
navController.nav(R.id.browserFragment, directions)
settings.userKnowsAboutPWAs = true
if (webAppUseCases.isInstallable() && !settings.userKnowsAboutPwas) {
settings.incrementVisitedInstallableCount()
if (settings.shouldShowPwaOnboarding) {
val directions =
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.View
import android.view.ViewGroup
import android.view.WindowManager
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.core.view.isVisible
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.privateTabs
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.tabs.TabsUseCases
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.ext.components
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.utils.allowUndo
@SuppressWarnings("TooManyFunctions", "LargeClass")
@ -48,7 +51,7 @@ class TabTrayDialogFragment : AppCompatDialogFragment() {
private val snackbarAnchor: View?
get() = if (tabTrayView.fabView.new_tab_button.isVisible) tabTrayView.fabView.new_tab_button
else null
else null
private val collectionStorageObserver = object : TabCollectionStorage.Observer {
override fun onCollectionCreated(title: String, sessions: List<Session>) {
@ -131,7 +134,13 @@ class TabTrayDialogFragment : AppCompatDialogFragment() {
startingInLandscape = requireContext().resources.configuration.orientation ==
Configuration.ORIENTATION_LANDSCAPE,
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(
@ -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) {
val sessionManager = view?.context?.components?.core?.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.privateTabs
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.tabstray.BrowserTabsTray
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
@ -44,7 +43,7 @@ class TabTrayView(
isPrivate: Boolean,
startingInLandscape: Boolean,
lifecycleScope: LifecycleCoroutineScope,
private val filterTabs: ((TabSessionState) -> Boolean) -> Unit
private val filterTabs: (Boolean) -> Unit
) : LayoutContainer, TabLayout.OnTabSelectedListener {
val fabView = LayoutInflater.from(container.context)
.inflate(R.layout.component_tabstray_fab, container, true)
@ -204,14 +203,8 @@ class TabTrayView(
}
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)
filterTabs.invoke(filter)
filterTabs.invoke(isPrivateModeSelected)
updateState(view.context.components.core.store.state)
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.settings.PhoneFeature
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.SortingStrategy
import org.mozilla.fenix.settings.registerOnSharedPreferenceChangeListener
@ -53,6 +53,7 @@ class Settings private constructor(
const val showLoginsSecureWarningSyncMaxCount = 1
const val showLoginsSecureWarningMaxCount = 1
const val trackingProtectionOnboardingMaximumCount = 1
const val pwaVisitsToShowPromptMaxCount = 3
const val FENIX_PREFERENCES = "fenix_preferences"
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
fun shouldDisplayFenixMovingTip(): Boolean =
preferences.getBoolean(appContext.getString(R.string.pref_key_migrating_from_fenix_nightly_tip), 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)
preferences.getBoolean(
appContext.getString(R.string.pref_key_migrating_from_fenix_nightly_tip),
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(
appContext.getPreferenceKey(R.string.pref_key_search_count),
@ -167,9 +177,9 @@ class Settings private constructor(
fun shouldDisplaySearchWidgetCFR(): Boolean =
isActiveSearcher &&
searchWidgetCFRDismissCount < showSearchWidgetCFRMaxCount &&
!searchWidgetInstalled &&
!searchWidgetCFRManuallyDismissed
searchWidgetCFRDismissCount < showSearchWidgetCFRMaxCount &&
!searchWidgetInstalled &&
!searchWidgetCFRManuallyDismissed
private val searchWidgetCFRDisplayCount by intPreference(
appContext.getPreferenceKey(R.string.pref_key_search_widget_cfr_display_count),
@ -236,10 +246,10 @@ class Settings private constructor(
val isCrashReportingEnabled: Boolean
get() = isCrashReportEnabledInBuild &&
preferences.getBoolean(
appContext.getPreferenceKey(R.string.pref_key_crash_reporter),
true
)
preferences.getBoolean(
appContext.getPreferenceKey(R.string.pref_key_crash_reporter),
true
)
val isRemoteDebuggingEnabled by booleanPreference(
appContext.getPreferenceKey(R.string.pref_key_remote_debugging),
@ -267,7 +277,7 @@ class Settings private constructor(
val shouldShowTrackingProtectionOnboarding: Boolean
get() = !isOverrideTPPopupsForPerformanceTest &&
(trackingProtectionOnboardingCount < trackingProtectionOnboardingMaximumCount &&
!trackingProtectionOnboardingShownThisSession)
!trackingProtectionOnboardingShownThisSession)
var showSecretDebugMenuThisSession = false
@ -418,14 +428,14 @@ class Settings private constructor(
BrowsingMode.Normal
}
}
set(value) {
val lastKnownModeWasPrivate = (value == BrowsingMode.Private)
preferences.edit()
.putBoolean(
appContext.getPreferenceKey(R.string.pref_key_last_known_mode_private),
lastKnownModeWasPrivate)
appContext.getPreferenceKey(R.string.pref_key_last_known_mode_private),
lastKnownModeWasPrivate
)
.apply()
field = value
@ -495,7 +505,9 @@ class Settings private constructor(
}
val accessibilityServicesEnabled: Boolean
get() { return touchExplorationIsEnabled || switchServiceIsEnabled }
get() {
return touchExplorationIsEnabled || switchServiceIsEnabled
}
val toolbarSettingString: String
get() = when {
@ -569,22 +581,41 @@ class Settings private constructor(
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() {
// 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+
if (!userKnowsAboutPWAs && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val alreadyHavePWaInstalled =
if (!userKnowsAboutPwas && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val alreadyHavePwaInstalled =
appContext.getSystemService(ShortcutManager::class.java)
.pinnedShortcuts.size > 0
// 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
return !userKnowsAboutPWAs
return !userKnowsAboutPwas
}
var userKnowsAboutPWAs by booleanPreference(
var userKnowsAboutPwas by booleanPreference(
appContext.getPreferenceKey(R.string.pref_key_user_knows_about_pwa),
default = false
)
@ -809,8 +840,12 @@ class Settings private constructor(
var savedLoginsSortingStrategy: SortingStrategy
get() {
return when (savedLoginsSortingStrategyString) {
SavedLoginsFragment.SORTING_STRATEGY_ALPHABETICALLY -> SortingStrategy.Alphabetically(appContext)
SavedLoginsFragment.SORTING_STRATEGY_LAST_USED -> SortingStrategy.LastUsed(appContext)
SavedLoginsFragment.SORTING_STRATEGY_ALPHABETICALLY -> SortingStrategy.Alphabetically(
appContext
)
SavedLoginsFragment.SORTING_STRATEGY_LAST_USED -> SortingStrategy.LastUsed(
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"
android:color="?primaryText" />
<item android:state_enabled="false"
android:color="@android:color/transparent" />
android:color="?disabled" />
</selector>

View File

@ -2,24 +2,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/. -->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/permissions_blocked_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="16dp"
android:paddingStart="@dimen/radio_button_preference_horizontal"
android:paddingEnd="@dimen/radio_button_preference_horizontal"
android:visibility="gone"
tools:visibility="visible">
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/permissions_blocked_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="16dp"
android:paddingStart="@dimen/radio_button_preference_horizontal"
android:paddingEnd="@dimen/radio_button_preference_horizontal"
android:visibility="gone"
tools:visibility="visible">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceListItem"
android:layout_marginStart="@dimen/site_permissions_exceptions_item_height"
android:text="@string/phone_feature_blocked_by_android"
android:layout_marginBottom="16dp" />
@ -27,21 +27,24 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceListItemSmall"
android:layout_marginStart="@dimen/site_permissions_exceptions_item_height"
android:text="@string/phone_feature_blocked_intro"
android:layout_marginBottom="16dp"/>
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceListItemSmall"
android:layout_marginStart="@dimen/site_permissions_exceptions_item_height"
android:text="@string/phone_feature_blocked_step_settings"
android:layout_marginBottom="8dp"/>
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/blocked_by_android_permissions_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceListItemSmall"
android:layout_marginStart="@dimen/site_permissions_exceptions_item_height"
tools:text="@string/phone_feature_blocked_step_permissions"
android:layout_marginBottom="8dp" />
@ -50,7 +53,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
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
android:id="@+id/settings_button"

View File

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

View File

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

View File

@ -6,18 +6,18 @@
android:layout_width="fill_parent"
android:layout_height="fill_parent"
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_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="@dimen/radio_button_preference_vertical">
<RadioGroup
<RadioGroup
android:layout_width="match_parent"
android:layout_height="wrap_content">
<RadioButton
<RadioButton
android:id="@+id/ask_to_allow_radio"
android:layout_width="match_parent"
android:layout_height="wrap_content"
@ -26,13 +26,13 @@
android:background="?android:attr/selectableItemBackground"
android:button="@null"
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:paddingStart="@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:layout_width="match_parent"
android:layout_height="@dimen/radio_button_preference_height"
@ -41,15 +41,15 @@
android:background="?android:attr/selectableItemBackground"
android:button="@null"
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:paddingStart="@dimen/radio_button_preference_horizontal"
android:paddingEnd="@dimen/radio_button_preference_horizontal"
android:paddingBottom="@dimen/radio_button_preference_vertical"/>
</RadioGroup>
android:paddingBottom="@dimen/radio_button_preference_vertical" />
</RadioGroup>
<include layout="@layout/layout_clear_permission_button"/>
<include layout="@layout/component_permissions_blocked_by_android"/>
<include layout="@layout/layout_clear_permission_button" />
<include layout="@layout/component_permissions_blocked_by_android" />
</LinearLayout>
</LinearLayout>
</ScrollView>

View File

@ -7,76 +7,77 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="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_height="wrap_content">
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="@dimen/radio_button_preference_vertical">
<RadioButton
android:id="@+id/ask_to_allow_radio"
<RadioGroup
android:layout_width="match_parent"
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" />
android:layout_height="wrap_content">
<RadioButton
android:id="@+id/block_radio"
android:layout_width="match_parent"
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_blocked" />
<RadioButton
android:id="@+id/ask_to_allow_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"
tools:text="@string/preference_option_phone_feature_ask_to_allow" />
<RadioButton
android:id="@+id/third_radio"
android:layout_width="match_parent"
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" />
<RadioButton
android:id="@+id/block_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"
tools:text="@string/preference_option_phone_feature_blocked" />
<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/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" />
</RadioGroup>
<RadioButton
android:id="@+id/third_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" />
<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>

View File

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

View File

@ -7,4 +7,4 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
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
android:id="@+id/save_login_button"
android:icon="@drawable/mozac_ic_check"
app:iconTint="?primaryText"
app:iconTint="@color/save_enabled_ic_color"
android:title="@string/save_changes_to_login"
app:showAsAction="always" />
</menu>

View File

@ -221,8 +221,8 @@
android:id="@+id/action_browserFragment_to_createShortcutFragment"
app:destination="@id/createShortcutFragment" />
<action
android:id="@+id/action_browserFragment_to_firstTimePwaFragment"
app:destination="@id/firstTimePwaFragment" />
android:id="@+id/action_browserFragment_to_pwaOnboardingDialogFragment"
app:destination="@id/pwaOnboardingDialogFragment" />
<action
android:id="@+id/action_browserFragment_to_quickSettingsSheetDialogFragment"
app:destination="@id/quickSettingsSheetDialogFragment" />
@ -321,7 +321,7 @@
<fragment
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">
<action
android:id="@+id/action_savedLoginsAuthFragment_to_loginsListFragment"
@ -355,7 +355,7 @@
<fragment
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">
<action
android:id="@+id/action_savedLoginsFragment_to_loginDetailFragment"
@ -381,7 +381,7 @@
<fragment
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">
<argument
android:name="savedLoginId"
@ -396,7 +396,7 @@
<fragment
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">
<argument
android:name="savedLoginItem"
@ -680,10 +680,9 @@
android:name="org.mozilla.fenix.shortcut.CreateShortcutFragment"
tools:layout="@layout/fragment_create_shortcut" />
<dialog
android:id="@+id/firstTimePwaFragment"
android:name="org.mozilla.fenix.shortcut.FirstTimePwaFragment"
android:label="fragment_pwa_first_time"
tools:layout="@layout/fragment_pwa_first_time" />
android:id="@+id/pwaOnboardingDialogFragment"
android:name="org.mozilla.fenix.shortcut.PwaOnboardingDialogFragment"
tools:layout="@layout/fragment_pwa_onboarding" />
<dialog
android:id="@+id/shareFragment"
@ -790,7 +789,7 @@
android:name="org.mozilla.fenix.settings.advanced.LocaleSettingsFragment" />
<fragment
android:id="@+id/saveLoginSettingFragment"
android:name="org.mozilla.fenix.settings.logins.SavedLoginsSettingFragment"
android:name="org.mozilla.fenix.settings.logins.fragment.SavedLoginsSettingFragment"
android:label="SaveLoginSettingFragment" />
<fragment
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_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_visits" translatable="false">pref_key_install_pwa_visits</string>
<!-- Data Choices -->
<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>
<!-- 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>
<!-- 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 -->
<string name="bookmark_delete_folder_snackbar">Deleted %1$s</string>
<!-- Screen title for adding a bookmarks folder -->
@ -620,8 +622,10 @@
<!-- Bookmark snackbar message on deletion
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>
<!-- 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>
<!-- 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 -->
<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
- 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/. -->
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.preference.Preference
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<Preference
android:key="@string/pref_key_add_private_browsing_shortcut"
android:title="@string/preferences_add_private_browsing_shortcut"
app:iconSpaceReserved="false" />
android:title="@string/preferences_add_private_browsing_shortcut" />
<SwitchPreference
android:defaultValue="false"
android:key="@string/pref_key_open_links_in_a_private_tab"
android:title="@string/preferences_open_links_in_a_private_tab"
app:iconSpaceReserved="false" />
android:title="@string/preferences_open_links_in_a_private_tab" />
<SwitchPreference
android:defaultValue="false"
android:key="@string/pref_key_allow_screenshots_in_private_mode"
android:title="@string/preferences_allow_screenshots_in_private_mode"
app:iconSpaceReserved="false" />
android:title="@string/preferences_allow_screenshots_in_private_mode" />
</PreferenceScreen>

View File

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

View File

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

View File

@ -32,12 +32,12 @@ class DefaultBrowsingModeManagerTest {
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
verify(exactly = 5) { callback.invoke(any()) }
verify(exactly = 2) { callback.invoke(BrowsingMode.Normal) }
}
@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.service.CrashReporterService
import mozilla.components.support.base.crash.Breadcrumb
import org.junit.Assert.assertEquals
import org.junit.Test
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 navDestination: NavDestination = mockk()
val breadCrumbRecorder =
BreadcrumbsRecorder(reporter, navController, ::getBreadcrumbMessage)
val breadCrumbRecorder = BreadcrumbsRecorder(reporter, navController) { "test" }
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.ACTION_VIEW
import androidx.core.net.toUri
import io.mockk.MockKMatcherScope
import io.mockk.Runs
import io.mockk.every
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.MozillaProducts
import org.mozilla.fenix.components.tips.TipType
import org.mozilla.fenix.ext.intentFilterEq
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.settings.SupportUtils
@ -205,7 +205,4 @@ class MigrationTipProviderTest {
every { settings.shouldDisplayFenixMovingTip() } returns true
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
import android.content.Intent
import androidx.navigation.NavDirections
import io.mockk.Matcher
import io.mockk.MockKMatcherScope
@ -11,6 +12,13 @@ import mozilla.components.support.ktx.android.os.contentEquals
*/
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> {
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>) =
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
import androidx.navigation.NavController
import androidx.navigation.NavDirections
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.TestCoroutineScope
import mozilla.components.browser.session.SessionManager
import mozilla.components.concept.engine.Engine
import mozilla.components.feature.tab.collections.TabCollection
@ -23,12 +23,11 @@ import org.junit.Test
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.collections.SaveCollectionStep
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.MetricController
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.components.tips.Tip
import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController
import org.mozilla.fenix.settings.SupportUtils
import mozilla.components.feature.tab.collections.Tab as ComponentTab
@ -42,77 +41,138 @@ class DefaultSessionControlControllerTest {
private val activity: HomeActivity = mockk(relaxed = true)
private val fragmentStore: HomeFragmentStore = 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 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 showTabTray: () -> Unit = mockk(relaxed = true)
private val showDeleteCollectionPrompt: (tabCollection: TabCollection, title: String?, message: String) -> Unit =
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
@Before
fun setup() {
mockkStatic("org.mozilla.fenix.ext.ContextKt")
every { activity.components.core.engine } returns engine
every { activity.components.core.sessionManager } returns sessionManager
every { activity.components.core.tabCollectionStorage } returns tabCollectionStorage
every { activity.components.useCases.tabsUseCases } returns tabsUseCases
every { fragmentStore.state } returns state
every { state.collections } returns emptyList()
every { state.expandedCollections } returns emptySet()
every { state.mode } returns Mode.Normal
every { activity.components.analytics.metrics } returns metrics
every { fragmentStore.state } returns HomeFragmentState(
collections = emptyList(),
expandedCollections = emptySet(),
mode = Mode.Normal,
topSites = emptyList()
)
every { sessionManager.sessions } returns emptyList()
every { navController.currentDestination } returns mockk {
every { id } returns R.id.homeFragment
}
controller = DefaultSessionControlController(
activity = activity,
engine = engine,
metrics = metrics,
sessionManager = sessionManager,
tabCollectionStorage = tabCollectionStorage,
topSiteStorage = topSiteStorage,
addTabUseCase = tabsUseCases.addTab,
fragmentStore = fragmentStore,
navController = navController,
viewLifecycleScope = MainScope(),
getListOfTabs = getListOfTabs,
viewLifecycleScope = TestCoroutineScope(),
hideOnboarding = hideOnboarding,
registerCollectionStorageObserver = registerCollectionStorageObserver,
showDeleteCollectionPrompt = showDeleteCollectionPrompt,
openSettingsScreen = openSettingsScreen,
openWhatsNewLink = openWhatsNewLink,
openPrivacyNotice = openPrivacyNotice,
showTabTray = showTabTray
)
}
@Test
fun handleCollectionAddTabTapped() {
val collection: TabCollection = mockk(relaxed = true)
val collection = mockk<TabCollection> {
every { id } returns 12L
}
controller.handleCollectionAddTabTapped(collection)
verify { metrics.track(Event.CollectionAddTabPressed) }
verify {
navController.navigate(
match<NavDirections> { it.actionId == R.id.action_global_collectionCreationFragment },
null
)
}
}
@Test
fun handleCollectionOpenTabClicked() {
val tab: ComponentTab = mockk(relaxed = true)
fun `handleCollectionOpenTabClicked onFailure`() {
val tab = mockk<ComponentTab> {
every { url } returns "https://mozilla.org"
every { restore(activity, engine, restoreSessionId = false) } returns null
}
controller.handleCollectionOpenTabClicked(tab)
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
fun handleCollectionOpenTabsTapped() {
val collection: TabCollection = mockk(relaxed = true)
val collection = mockk<TabCollection> {
every { tabs } returns emptyList()
}
controller.handleCollectionOpenTabsTapped(collection)
verify { metrics.track(Event.CollectionAllTabsRestored) }
}
@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 tab: ComponentTab = mockk(relaxed = true)
controller.handleCollectionRemoveTab(collection, tab)
@ -121,16 +181,37 @@ class DefaultSessionControlControllerTest {
@Test
fun handleCollectionShareTabsClicked() {
val collection: TabCollection = mockk(relaxed = true)
val collection = mockk<TabCollection> {
every { tabs } returns emptyList()
}
controller.handleCollectionShareTabsClicked(collection)
verify { metrics.track(Event.CollectionShared) }
verify {
navController.navigate(
match<NavDirections> { it.actionId == R.id.action_global_shareFragment },
null
)
}
}
@Test
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)
verify { showDeleteCollectionPrompt(collection, null, any()) }
verify {
showDeleteCollectionPrompt(
collection,
null,
"Are you sure you want to delete Collection?"
)
}
}
@Test
@ -148,9 +229,18 @@ class DefaultSessionControlControllerTest {
@Test
fun handleRenameCollectionTapped() {
val collection: TabCollection = mockk(relaxed = true)
val collection = mockk<TabCollection> {
every { id } returns 3L
}
controller.handleRenameCollectionTapped(collection)
verify { metrics.track(Event.CollectionRenamePressed) }
verify {
navController.navigate(
match<NavDirections> { it.actionId == R.id.action_global_collectionCreationFragment },
null
)
}
}
@Test
@ -191,20 +281,62 @@ class DefaultSessionControlControllerTest {
@Test
fun 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
fun handleToggleCollectionExpanded() {
val collection: TabCollection = mockk(relaxed = true)
val collection = mockk<TabCollection>()
controller.handleToggleCollectionExpanded(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
fun 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 showSnackbar: (String) -> 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 homeActivity: HomeActivity = mockk(relaxed = true)
@ -304,10 +304,10 @@ class BookmarkControllerTest {
@Test
fun `handleBookmarkDeletion for a folder should properly call the delete folder delegate`() {
controller.handleBookmarkFolderDeletion(subfolder)
controller.handleBookmarkFolderDeletion(setOf(subfolder))
verify {
deleteBookmarkFolder(subfolder)
deleteBookmarkFolder(setOf(subfolder))
}
}

View File

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

View File

@ -60,7 +60,9 @@ class HistoryFragmentStoreTest {
fun finishSync() = runBlocking {
val initialState = HistoryFragmentState(
items = listOf(),
mode = HistoryFragmentState.Mode.Syncing
mode = HistoryFragmentState.Mode.Syncing,
pendingDeletionIds = emptySet(),
isDeletingItems = false
)
val store = HistoryFragmentStore(initialState)
@ -71,16 +73,22 @@ class HistoryFragmentStoreTest {
private fun emptyDefaultState(): HistoryFragmentState = HistoryFragmentState(
items = listOf(),
mode = HistoryFragmentState.Mode.Normal
mode = HistoryFragmentState.Mode.Normal,
pendingDeletionIds = emptySet(),
isDeletingItems = false
)
private fun oneItemEditState(): HistoryFragmentState = HistoryFragmentState(
items = listOf(),
mode = HistoryFragmentState.Mode.Editing(setOf(historyItem))
mode = HistoryFragmentState.Mode.Editing(setOf(historyItem)),
pendingDeletionIds = emptySet(),
isDeletingItems = false
)
private fun twoItemEditState(): HistoryFragmentState = HistoryFragmentState(
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
import android.content.Intent
import androidx.navigation.NavController
import androidx.navigation.NavDirections
import io.mockk.every
@ -24,7 +25,9 @@ import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.crashes.CrashListActivity
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.intentFilterEq
import org.mozilla.fenix.ext.metrics
import org.mozilla.fenix.ext.navigateSafe
import org.mozilla.fenix.ext.searchEngineManager
@ -88,10 +91,13 @@ class DefaultSearchControllerTest {
@Test
fun handleCrashesUrlCommitted() {
val url = "about:crashes"
every { activity.packageName } returns testContext.packageName
controller.handleUrlCommitted(url)
verify { activity.startActivity(any()) }
verify {
activity.startActivity(intentFilterEq(Intent(testContext, CrashListActivity::class.java)))
}
}
@Test

View File

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

View File

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

View File

@ -54,7 +54,7 @@ class DefaultDeleteBrowsingDataControllerTest {
controller.deleteBrowsingData()
verify {
context.components.core.engine.clearData(any())
context.components.core.engine.clearData(Engine.BrowsingData.select(Engine.BrowsingData.DOM_STORAGES))
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.mozilla.fenix.R
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.settings.logins.view.LoginDetailView
@RunWith(FenixRobolectricTestRunner::class)
class LoginDetailViewTest {
@ -31,7 +32,8 @@ class LoginDetailViewTest {
),
searchedForText = null,
sortingStrategy = SortingStrategy.LastUsed(mockk()),
highlightedItem = SavedLoginsSortingStrategyMenu.Item.LastUsedSort
highlightedItem = SavedLoginsSortingStrategyMenu.Item.LastUsedSort,
duplicateLogins = listOf()
)
private lateinit var view: ViewGroup

View File

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

View File

@ -6,7 +6,6 @@ package org.mozilla.fenix.settings.logins
import androidx.navigation.NavController
import io.mockk.mockk
import io.mockk.verify
import io.mockk.verifyAll
import mozilla.components.support.test.robolectric.testContext
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.helpers.FenixRobolectricTestRunner
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
@RunWith(FenixRobolectricTestRunner::class)
class SavedLoginsControllerTest {
class LoginsListControllerTest {
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 browserNavigator: (String, Boolean, BrowserDirection) -> Unit = mockk(relaxed = true)
private val settings: Settings = mockk(relaxed = true)
private val metrics: MetricController = mockk(relaxed = true)
private val sortingStrategy: SortingStrategy = SortingStrategy.Alphabetically(testContext)
private val controller = SavedLoginsController(store, navController, browserNavigator, settings, metrics)
private val controller =
LoginsListController(
loginsFragmentStore = store,
navController = navController,
browserNavigator = browserNavigator,
settings = settings,
metrics = metrics
)
@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`() {
controller.handleSort(sortingStrategy)
verify {
verifyAll {
store.dispatch(
LoginsAction.SortLogins(
SortingStrategy.Alphabetically(
@ -55,7 +62,7 @@ class SavedLoginsControllerTest {
store.dispatch(LoginsAction.LoginSelected(login))
metrics.track(Event.OpenOneLogin)
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`() {
controller.handleLearnMoreClicked()
verify {
verifyAll {
browserNavigator.invoke(
SupportUtils.getGenericSumoURLForTopic(SupportUtils.SumoTopic.SYNC_SETUP),
true,

View File

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

View File

@ -7,15 +7,25 @@ package org.mozilla.fenix.settings.logins
import io.mockk.mockk
import io.mockk.verifyAll
import mozilla.components.support.test.robolectric.testContext
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
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
@RunWith(FenixRobolectricTestRunner::class)
class SavedLoginsInteractorTest {
private val controller: SavedLoginsController = mockk(relaxed = true)
private val interactor = SavedLoginsInteractor(controller)
private val listController: LoginsListController = mockk(relaxed = true)
private val savedLoginsStorageController: SavedLoginsStorageController = mockk(relaxed = true)
private lateinit var interactor: SavedLoginsInteractor
@Before
fun setup() {
interactor = SavedLoginsInteractor(listController, savedLoginsStorageController)
}
@Test
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)
verifyAll {
controller.handleItemClicked(item)
listController.handleItemClicked(item)
}
}
@ -34,7 +44,7 @@ class SavedLoginsInteractorTest {
interactor.onSortingStrategyChanged(sortingStrategy)
verifyAll {
controller.handleSort(sortingStrategy)
listController.handleSort(sortingStrategy)
}
}
@ -43,7 +53,13 @@ class SavedLoginsInteractorTest {
interactor.onLearnMoreClicked()
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