diff --git a/app/metrics.yaml b/app/metrics.yaml index 8f7e414e7..2c9163ba4 100644 --- a/app/metrics.yaml +++ b/app/metrics.yaml @@ -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: diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/HistoryTest.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/HistoryTest.kt index 248584e3e..db54d7480 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/HistoryTest.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/HistoryTest.kt @@ -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() } } diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/HistoryRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/HistoryRobot.kt index 792ad6e6e..2debc3999 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/HistoryRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/HistoryRobot.kt @@ -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)))) diff --git a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt index ca06e6964..8acd3d709 100644 --- a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt +++ b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt @@ -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 diff --git a/app/src/main/java/org/mozilla/fenix/addons/InstalledAddonDetailsFragment.kt b/app/src/main/java/org/mozilla/fenix/addons/InstalledAddonDetailsFragment.kt index 57f709748..3a946c236 100644 --- a/app/src/main/java/org/mozilla/fenix/addons/InstalledAddonDetailsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/addons/InstalledAddonDetailsFragment.kt @@ -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 { diff --git a/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt b/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt index ed9b83c58..5c6bc0492 100644 --- a/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt @@ -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 diff --git a/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationTabListAdapter.kt b/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationTabListAdapter.kt index a936608b2..e74cc340e 100644 --- a/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationTabListAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationTabListAdapter.kt @@ -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() { + private var tabs: List = listOf() private var selectedTabs: MutableSet = 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, - val new: List, - val oldSelected: Set, - val newSelected: Set, - 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 { diff --git a/app/src/main/java/org/mozilla/fenix/collections/SaveCollectionListAdapter.kt b/app/src/main/java/org/mozilla/fenix/collections/SaveCollectionListAdapter.kt index fa35f442c..0ee663b53 100644 --- a/app/src/main/java/org/mozilla/fenix/collections/SaveCollectionListAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/collections/SaveCollectionListAdapter.kt @@ -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) } diff --git a/app/src/main/java/org/mozilla/fenix/collections/TabDiffUtil.kt b/app/src/main/java/org/mozilla/fenix/collections/TabDiffUtil.kt new file mode 100644 index 000000000..a555dd8f0 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/collections/TabDiffUtil.kt @@ -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, + private val new: List, + private val oldSelected: Set, + private val newSelected: Set, + 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 +) diff --git a/app/src/main/java/org/mozilla/fenix/components/UseCases.kt b/app/src/main/java/org/mozilla/fenix/components/UseCases.kt index 08469e9c3..fba92eaaa 100644 --- a/app/src/main/java/org/mozilla/fenix/components/UseCases.kt +++ b/app/src/main/java/org/mozilla/fenix/components/UseCases.kt @@ -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) } diff --git a/app/src/main/java/org/mozilla/fenix/components/metrics/GleanMetricsService.kt b/app/src/main/java/org/mozilla/fenix/components/metrics/GleanMetricsService.kt index bd1c43e7c..677e0aba7 100644 --- a/app/src/main/java/org/mozilla/fenix/components/metrics/GleanMetricsService.kt +++ b/app/src/main/java/org/mozilla/fenix/components/metrics/GleanMetricsService.kt @@ -466,6 +466,15 @@ private val Event.wrapper: EventWrapper<*>? is Event.ViewLoginPassword -> EventWrapper( { Logins.viewPasswordLogin.record(it) } ) + is Event.DeleteLogin -> EventWrapper( + { Logins.deleteSavedLogin.record(it) } + ) + is Event.EditLogin -> EventWrapper( + { Logins.openLoginEditor.record(it) } + ) + is Event.EditLoginSave -> EventWrapper( + { Logins.saveEditedLogin.record(it) } + ) is Event.PrivateBrowsingShowSearchSuggestions -> EventWrapper( { SearchSuggestions.enableInPrivate.record(it) } ) diff --git a/app/src/main/java/org/mozilla/fenix/components/metrics/Metrics.kt b/app/src/main/java/org/mozilla/fenix/components/metrics/Metrics.kt index 9e8fe2d88..77a08ce20 100644 --- a/app/src/main/java/org/mozilla/fenix/components/metrics/Metrics.kt +++ b/app/src/main/java/org/mozilla/fenix/components/metrics/Metrics.kt @@ -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() diff --git a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt index 60240cfd2..9ac274560 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt @@ -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 { - return sessionManager.sessionsOfType(private = private) - .toList() - } - - private fun getListOfTabs(): List { - 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 -> diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlController.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlController.kt index 74b4e1bd0..2935fd075 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlController.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlController.kt @@ -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, 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, diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkController.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkController.kt index 63dcd716e..1aca89890 100644 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkController.kt +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkController.kt @@ -42,7 +42,7 @@ interface BookmarkController { fun handleBookmarkSharing(item: BookmarkNode) fun handleOpeningBookmark(item: BookmarkNode, mode: BrowsingMode) fun handleBookmarkDeletion(nodes: Set, eventType: Event) - fun handleBookmarkFolderDeletion(node: BookmarkNode) + fun handleBookmarkFolderDeletion(nodes: Set) 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, Event) -> Unit, - private val deleteBookmarkFolder: (BookmarkNode) -> Unit, + private val deleteBookmarkFolder: (Set) -> 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) { + deleteBookmarkFolder(nodes) } override fun handleRequestSync() { diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragment.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragment.kt index 51d8e12de..9b76c072f 100644 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragment.kt @@ -283,13 +283,17 @@ class BookmarkFragment : LibraryPageFragment(), UserInteractionHan } private fun deleteMulti(selected: Set, 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(), UserInteractionHan ) } - private fun getRemoveBookmarksSnackBarMessage(selected: Set): String { + private fun getRemoveBookmarksSnackBarMessage( + selected: Set, + 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(), UserInteractionHan } } + private fun getDialogConfirmationMessage(selected: Set): 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) { 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(), 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) { pendingBookmarksToDelete.removeAll(selected) pendingBookmarkDeletionJob = null diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractor.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractor.kt index 1a9cc0842..4f5e757fc 100644 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractor.kt @@ -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) } diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryAdapter.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryAdapter.kt index 5f970334a..234082b74 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryAdapter.kt @@ -33,6 +33,8 @@ class HistoryAdapter( private var mode: HistoryFragmentState.Mode = HistoryFragmentState.Mode.Normal override val selectedItems get() = mode.selectedItems + var pendingDeletionIds = emptySet() + private val itemsWithHeaders: MutableMap = 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) { + this.pendingDeletionIds = pendingDeletionIds } companion object { diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt index a6796f1d6..a228d12c7 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt @@ -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(), UserInteractionHandler { @@ -49,6 +52,8 @@ class HistoryFragment : LibraryPageFragment(), 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(), 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(), UserInteractionHandl } private fun deleteHistoryItems(items: Set) { - 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(), 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(), 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(), UserInteractionHandl selectedItem.url } - nav( - R.id.historyFragment, + navigate( HistoryFragmentDirections.actionGlobalTabTrayDialogFragment() ) true @@ -197,8 +199,7 @@ class HistoryFragment : LibraryPageFragment(), UserInteractionHandl browsingModeManager.mode = BrowsingMode.Private supportActionBar?.hide() } - nav( - R.id.historyFragment, + navigate( HistoryFragmentDirections.actionGlobalTabTrayDialogFragment() ) true @@ -210,14 +211,23 @@ class HistoryFragment : LibraryPageFragment(), 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(), UserInteractionHandl } } - private suspend fun deleteSelectedHistory( - selected: Set, - 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) { 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): (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) { + pendingHistoryDeletionJob = getDeleteHistoryItemsOperation(items) + val ids = items.map { item -> item.visitedAt }.toSet() + historyStore.dispatch(HistoryFragmentAction.AddPendingDeletionSet(ids)) + } + + private fun undoPendingDeletion(items: Set) { + 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() { diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragmentStore.kt index 5b16b200c..f19ffc41c 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragmentStore.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragmentStore.kt @@ -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) : HistoryFragmentAction() + data class UndoPendingDeletionSet(val itemIds: Set) : 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, val mode: Mode) : State { +data class HistoryFragmentState( + val items: List, + val mode: Mode, + val pendingDeletionIds: Set, + val isDeletingItems: Boolean +) : State { sealed class Mode { open val selectedItems = emptySet() object Normal : Mode() - object Deleting : Mode() object Syncing : Mode() data class Editing(override val selectedItems: Set) : 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 + ) } } diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryView.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryView.kt index f831f3305..09be8a3d7 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryView.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryView.kt @@ -90,7 +90,6 @@ class HistoryView( val view: View = LayoutInflater.from(container.context) .inflate(R.layout.component_history, container, true) - private var items: List = 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 diff --git a/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryListItemViewHolder.kt b/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryListItemViewHolder.kt index 685081167..c14be966b 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryListItemViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryListItemViewHolder.kt @@ -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 diff --git a/app/src/main/java/org/mozilla/fenix/loginexceptions/LoginExceptionsAdapter.kt b/app/src/main/java/org/mozilla/fenix/loginexceptions/LoginExceptionsAdapter.kt index 5a5031490..a04a705fe 100644 --- a/app/src/main/java/org/mozilla/fenix/loginexceptions/LoginExceptionsAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/loginexceptions/LoginExceptionsAdapter.kt @@ -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(DiffCallback) { +) : ListAdapter(DiffCallback) { /** * Change the list of items that are displayed. * Header and footer items are added to the list as well. */ fun updateData(exceptions: List) { - val adapterItems: List = - listOf(AdapterItem.Header) + exceptions.map { AdapterItem.Item(it) } + listOf( - AdapterItem.DeleteButton - ) + val adapterItems: List = listOf(AdapterItem.Header) + + exceptions.map { AdapterItem.Item(it) } + + listOf(AdapterItem.DeleteButton) submitList(adapterItems) } @@ -70,9 +63,18 @@ class LoginExceptionsAdapter( } } - private object DiffCallback : DiffUtil.ItemCallback() { + sealed class AdapterItem { + object DeleteButton : AdapterItem() + object Header : AdapterItem() + data class Item(val item: LoginException) : AdapterItem() + } + + internal object DiffCallback : DiffUtil.ItemCallback() { 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) = diff --git a/app/src/main/java/org/mozilla/fenix/loginexceptions/LoginExceptionsFragment.kt b/app/src/main/java/org/mozilla/fenix/loginexceptions/LoginExceptionsFragment.kt index d2be2d66f..c1519bb00 100644 --- a/app/src/main/java/org/mozilla/fenix/loginexceptions/LoginExceptionsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/loginexceptions/LoginExceptionsFragment.kt @@ -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> { - return Observer> { 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) } diff --git a/app/src/main/java/org/mozilla/fenix/loginexceptions/LoginExceptionsView.kt b/app/src/main/java/org/mozilla/fenix/loginexceptions/LoginExceptionsView.kt index 26854bba7..f6924870b 100644 --- a/app/src/main/java/org/mozilla/fenix/loginexceptions/LoginExceptionsView.kt +++ b/app/src/main/java/org/mozilla/fenix/loginexceptions/LoginExceptionsView.kt @@ -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) } } diff --git a/app/src/main/java/org/mozilla/fenix/loginexceptions/viewholders/LoginExceptionsHeaderViewHolder.kt b/app/src/main/java/org/mozilla/fenix/loginexceptions/viewholders/LoginExceptionsHeaderViewHolder.kt index 0a2033f16..7733563f7 100644 --- a/app/src/main/java/org/mozilla/fenix/loginexceptions/viewholders/LoginExceptionsHeaderViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/loginexceptions/viewholders/LoginExceptionsHeaderViewHolder.kt @@ -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 + } } diff --git a/app/src/main/java/org/mozilla/fenix/migration/MigrationProgressActivity.kt b/app/src/main/java/org/mozilla/fenix/migration/MigrationProgressActivity.kt index a4880fd46..16503f283 100644 --- a/app/src/main/java/org/mozilla/fenix/migration/MigrationProgressActivity.kt +++ b/app/src/main/java/org/mozilla/fenix/migration/MigrationProgressActivity.kt @@ -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(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() { - - 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) } } diff --git a/app/src/main/java/org/mozilla/fenix/migration/MigrationStatusAdapter.kt b/app/src/main/java/org/mozilla/fenix/migration/MigrationStatusAdapter.kt new file mode 100644 index 000000000..39ddc3405 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/migration/MigrationStatusAdapter.kt @@ -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(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() { + + 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 + } +} diff --git a/app/src/main/java/org/mozilla/fenix/migration/MigrationTelemetryListener.kt b/app/src/main/java/org/mozilla/fenix/migration/MigrationTelemetryListener.kt index 1b5268272..3821091e2 100644 --- a/app/src/main/java/org/mozilla/fenix/migration/MigrationTelemetryListener.kt +++ b/app/src/main/java/org/mozilla/fenix/migration/MigrationTelemetryListener.kt @@ -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) } diff --git a/app/src/main/java/org/mozilla/fenix/perf/Performance.kt b/app/src/main/java/org/mozilla/fenix/perf/Performance.kt index 4b0a73b22..f1eb26ffb 100644 --- a/app/src/main/java/org/mozilla/fenix/perf/Performance.kt +++ b/app/src/main/java/org/mozilla/fenix/perf/Performance.kt @@ -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 } } diff --git a/app/src/main/java/org/mozilla/fenix/search/SearchFragment.kt b/app/src/main/java/org/mozilla/fenix/search/SearchFragment.kt index 1490ae2a1..6250eb2cb 100644 --- a/app/src/main/java/org/mozilla/fenix/search/SearchFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/search/SearchFragment.kt @@ -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(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() diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/LoginDetailView.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/LoginDetailView.kt deleted file mode 100644 index 0eb6300d8..000000000 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/LoginDetailView.kt +++ /dev/null @@ -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 - } -} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsFragmentStore.kt index 9e10508fd..ea130f07e 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsFragmentStore.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsFragmentStore.kt @@ -54,18 +54,20 @@ sealed class LoginsAction : Action { data class UpdateLoginsList(val list: List) : LoginsAction() data class UpdateCurrentLogin(val item: SavedLogin) : LoginsAction() data class SortLogins(val sortingStrategy: SortingStrategy) : LoginsAction() + data class ListOfDupes(val dupeList: List) : 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 ) : 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 ) } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsView.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsView.kt deleted file mode 100644 index dd1564df3..000000000 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsView.kt +++ /dev/null @@ -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 - ) - } -} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/controller/LoginsListController.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/controller/LoginsListController.kt new file mode 100644 index 000000000..8d4c5629f --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/controller/LoginsListController.kt @@ -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 + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/controller/SavedLoginsStorageController.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/controller/SavedLoginsStorageController.kt new file mode 100644 index 000000000..7df5dd741 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/controller/SavedLoginsStorageController.kt @@ -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? = 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? = 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>? = 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>? = 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>? = 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() + } + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/EditLoginFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt similarity index 53% rename from app/src/main/java/org/mozilla/fenix/settings/logins/EditLoginFragment.kt rename to app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt index 80f54d5b6..15827fdf5 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/EditLoginFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt @@ -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() - private lateinit var savedLoginsStore: LoginsFragmentStore fun String.toEditable(): Editable = Editable.Factory.getInstance().newEditable(this) + private val args by navArgs() + 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? = 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(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(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? = 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 - } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/LoginDetailFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/LoginDetailFragment.kt similarity index 66% rename from app/src/main/java/org/mozilla/fenix/settings/logins/LoginDetailFragment.kt rename to app/src/main/java/org/mozilla/fenix/settings/logins/fragment/LoginDetailFragment.kt index f7c842978..501e26763 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/LoginDetailFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/LoginDetailFragment.kt @@ -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>? = 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? = 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 diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsAuthFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsAuthFragment.kt similarity index 98% rename from app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsAuthFragment.kt rename to app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsAuthFragment.kt index d8051e7f3..63fc052e0 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsAuthFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsAuthFragment.kt @@ -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) } diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt similarity index 66% rename from app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsFragment.kt rename to app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt index cec4c1c29..16a4a8974 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt @@ -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>? = 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() } diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsSettingFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsSettingFragment.kt similarity index 98% rename from app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsSettingFragment.kt rename to app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsSettingFragment.kt index f044da1b4..b781e0ee3 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsSettingFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsSettingFragment.kt @@ -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 diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/EditLoginInteractor.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/EditLoginInteractor.kt new file mode 100644 index 000000000..3039dd693 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/EditLoginInteractor.kt @@ -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) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/LoginDetailInteractor.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/LoginDetailInteractor.kt new file mode 100644 index 000000000..7d8f6cb9c --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/LoginDetailInteractor.kt @@ -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) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/SavedLoginsInteractor.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/SavedLoginsInteractor.kt new file mode 100644 index 000000000..5bf69a99b --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/SavedLoginsInteractor.kt @@ -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() + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/view/EditLoginView.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/view/EditLoginView.kt new file mode 100644 index 000000000..cdc1c8a9b --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/view/EditLoginView.kt @@ -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 + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginDetailView.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginDetailView.kt new file mode 100644 index 000000000..6276772f6 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginDetailView.kt @@ -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 + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsAdapter.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginsAdapter.kt similarity index 88% rename from app/src/main/java/org/mozilla/fenix/settings/logins/LoginsAdapter.kt rename to app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginsAdapter.kt index 40e1d39ca..b5801ff9d 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginsAdapter.kt @@ -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 diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsListViewHolder.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginsListViewHolder.kt similarity index 88% rename from app/src/main/java/org/mozilla/fenix/settings/logins/LoginsListViewHolder.kt rename to app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginsListViewHolder.kt index f7bd68faa..b5cd79943 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsListViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginsListViewHolder.kt @@ -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, diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/view/SavedLoginsListView.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/view/SavedLoginsListView.kt new file mode 100644 index 000000000..400d3442f --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/view/SavedLoginsListView.kt @@ -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) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/shortcut/FirstTimePwaFragment.kt b/app/src/main/java/org/mozilla/fenix/shortcut/PwaOnboardingDialogFragment.kt similarity index 88% rename from app/src/main/java/org/mozilla/fenix/shortcut/FirstTimePwaFragment.kt rename to app/src/main/java/org/mozilla/fenix/shortcut/PwaOnboardingDialogFragment.kt index c42c2842f..43b9099fc 100644 --- a/app/src/main/java/org/mozilla/fenix/shortcut/FirstTimePwaFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/shortcut/PwaOnboardingDialogFragment.kt @@ -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) diff --git a/app/src/main/java/org/mozilla/fenix/shortcut/FirstTimePwaObserver.kt b/app/src/main/java/org/mozilla/fenix/shortcut/PwaOnboardingObserver.kt similarity index 59% rename from app/src/main/java/org/mozilla/fenix/shortcut/FirstTimePwaObserver.kt rename to app/src/main/java/org/mozilla/fenix/shortcut/PwaOnboardingObserver.kt index 2c19810d7..5e312df86 100644 --- a/app/src/main/java/org/mozilla/fenix/shortcut/FirstTimePwaObserver.kt +++ b/app/src/main/java/org/mozilla/fenix/shortcut/PwaOnboardingObserver.kt @@ -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 + } } } } diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayDialogFragment.kt b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayDialogFragment.kt index 62f5cacf7..b7340d1d4 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayDialogFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayDialogFragment.kt @@ -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) { @@ -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 diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt index f82e5f923..b9acc4858 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt @@ -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) diff --git a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt index fbf5b28e3..a76825bef 100644 --- a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt +++ b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt @@ -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) } } diff --git a/app/src/main/res/color/save_enabled_ic_color.xml b/app/src/main/res/color/save_enabled_ic_color.xml new file mode 100644 index 000000000..4c0a682f3 --- /dev/null +++ b/app/src/main/res/color/save_enabled_ic_color.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/color/saved_login_clear_edit_text_tint.xml b/app/src/main/res/color/saved_login_clear_edit_text_tint.xml index acb4e5e7e..ada9ebc97 100644 --- a/app/src/main/res/color/saved_login_clear_edit_text_tint.xml +++ b/app/src/main/res/color/saved_login_clear_edit_text_tint.xml @@ -6,5 +6,5 @@ + android:color="?disabled" /> \ No newline at end of file diff --git a/app/src/main/res/layout/component_permissions_blocked_by_android.xml b/app/src/main/res/layout/component_permissions_blocked_by_android.xml index 5d179c8bb..7ddd84db2 100644 --- a/app/src/main/res/layout/component_permissions_blocked_by_android.xml +++ b/app/src/main/res/layout/component_permissions_blocked_by_android.xml @@ -2,24 +2,24 @@ - + 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"> @@ -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" /> + 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" /> - - -