1
0
Fork 0

For #10173: login duplicates and save (#11208)

* Extract controller into it's own class. Implement find dupes and filter based on username.

Create edit login controller. Add text watchers and check for duplicates.

Edit controller test

* Find duplicates and save to store

* Retrieve duplicates from AC and check list on username text changed

Move duplicates logic into the controller

* Add glean pings for delete and edit. Move logic for login manipulation into the datastore.

* Use correct threads in controller. Enable save button when applicable.

Save enabled in datastore.

Move login data to datastore

Rebase with password error states

Update metrics to be more specific for edit

* Create logins controller for AC calls

* Interactor and controller methods for edit login. Add edit view to separate out some layout manipulation.

Inflate view in edit fragment. Double layout showing up.

Edit view

Controller tests

Controller tests passing

Interactor tests

Lint and detekt cleanup

* Remove datastore and use storage controller for all logins calls to password storage.

Addressed comments

Lint
:

Rebase - 1
master
Elise Richards 2020-07-16 17:08:04 -05:00 committed by GitHub
parent 4dd0c0f224
commit 023a4983fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 1140 additions and 490 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -210,9 +210,10 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
return if (historyItems.size > 1) {
getString(R.string.history_delete_multiple_items_snackbar)
} else {
getString(
R.string.history_delete_single_item_snackbar,
historyItems.first().url.toShortUrl(requireComponents.publicSuffixList)
String.format(
requireContext().getString(
R.string.history_delete_single_item_snackbar
), historyItems.first().url.toShortUrl(requireComponents.publicSuffixList)
)
}
}

View File

@ -1,20 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins
import android.view.ViewGroup
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.fragment_login_detail.*
/**
* View that contains and configures the Login Details
*/
class LoginDetailView(override val containerView: ViewGroup?) : LayoutContainer {
fun update(login: LoginsListState) {
webAddressText.text = login.currentItem?.origin
usernameText.text = login.currentItem?.username
passwordText.text = login.currentItem?.password
}
}

View File

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

View File

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

View File

@ -0,0 +1,64 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins.controller
import androidx.navigation.NavController
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.settings.SupportUtils
import org.mozilla.fenix.settings.logins.LoginsAction
import org.mozilla.fenix.settings.logins.LoginsFragmentStore
import org.mozilla.fenix.settings.logins.SavedLogin
import org.mozilla.fenix.settings.logins.SortingStrategy
import org.mozilla.fenix.settings.logins.fragment.SavedLoginsFragmentDirections
import org.mozilla.fenix.utils.Settings
/**
* Controller for the saved logins list
*
* @param loginsFragmentStore Store used to hold in-memory collection state.
* @param navController NavController manages app navigation within a NavHost.
* @param browserNavigator Controller allowing browser navigation to any Uri.
* @param settings SharedPreferences wrapper for easier usage.
* @param metrics Controller that handles telemetry events.
*/
class LoginsListController(
private val loginsFragmentStore: LoginsFragmentStore,
private val navController: NavController,
private val browserNavigator: (
searchTermOrURL: String,
newTab: Boolean,
from: BrowserDirection
) -> Unit,
private val settings: Settings,
private val metrics: MetricController
) {
fun handleItemClicked(item: SavedLogin) {
loginsFragmentStore.dispatch(LoginsAction.LoginSelected(item))
metrics.track(Event.OpenOneLogin)
navController.navigate(
SavedLoginsFragmentDirections.actionSavedLoginsFragmentToLoginDetailFragment(item.guid)
)
}
fun handleLearnMoreClicked() {
browserNavigator.invoke(
SupportUtils.getGenericSumoURLForTopic(SupportUtils.SumoTopic.SYNC_SETUP),
true,
BrowserDirection.FromSavedLoginsFragment
)
}
fun handleSort(sortingStrategy: SortingStrategy) {
loginsFragmentStore.dispatch(
LoginsAction.SortLogins(
sortingStrategy
)
)
settings.savedLoginsSortingStrategy = sortingStrategy
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,24 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins.interactor
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
/**
* Interactor for the edit login screen
*
* @property savedLoginsController controller for the saved logins storage
*/
class EditLoginInteractor(
private val savedLoginsController: SavedLoginsStorageController
) {
fun findPotentialDuplicates(loginId: String) {
savedLoginsController.findPotentialDuplicates(loginId)
}
fun onSaveLogin(loginId: String, usernameText: String, passwordText: String) {
savedLoginsController.save(loginId, usernameText, passwordText)
}
}

View File

@ -0,0 +1,24 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins.interactor
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
/**
* Interactor for the login detail screen
*
* @property savedLoginsController controller for the saved logins storage
*/
class LoginDetailInteractor(
private val savedLoginsController: SavedLoginsStorageController
) {
fun onFetchLoginList(loginId: String) {
savedLoginsController.fetchLoginDetails(loginId)
}
fun onDeleteLogin(loginId: String) {
savedLoginsController.delete(loginId)
}
}

View File

@ -0,0 +1,39 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins.interactor
import org.mozilla.fenix.settings.logins.SavedLogin
import org.mozilla.fenix.settings.logins.SortingStrategy
import org.mozilla.fenix.settings.logins.controller.LoginsListController
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
/**
* Interactor for the saved logins screen
*
* @param loginsListController [LoginsListController] which will be delegated for all
* user interactions.
* @param savedLoginsStorageController [SavedLoginsStorageController] which will be delegated
* for all calls to the password storage component
*/
class SavedLoginsInteractor(
private val loginsListController: LoginsListController,
private val savedLoginsStorageController: SavedLoginsStorageController
) {
fun onItemClicked(item: SavedLogin) {
loginsListController.handleItemClicked(item)
}
fun onLearnMoreClicked() {
loginsListController.handleLearnMoreClicked()
}
fun onSortingStrategyChanged(sortingStrategy: SortingStrategy) {
loginsListController.handleSort(sortingStrategy)
}
fun loadAndMapLogins() {
savedLoginsStorageController.handleLoadAndMapLogins()
}
}

View File

@ -0,0 +1,54 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins.view
import android.text.InputType
import android.view.ViewGroup
import androidx.appcompat.content.res.AppCompatResources
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.fragment_edit_login.view.*
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
/**
* View that contains and configures the Edit Login screen
*/
@Suppress("ForbiddenComment")
class EditLoginView(
override val containerView: ViewGroup
) : LayoutContainer {
private val context = containerView.context
// TODO: create helper class for toggling passwords. https://github.com/mozilla-mobile/fenix/issues/12554
fun showPassword() {
val currText = containerView.passwordText?.text
context.components.analytics.metrics.track(Event.ViewLoginPassword)
containerView.passwordText?.inputType =
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
containerView.revealPasswordButton?.setImageDrawable(
AppCompatResources.getDrawable(context, R.drawable.mozac_ic_password_hide)
)
containerView.revealPasswordButton?.contentDescription =
context.resources.getString(R.string.saved_login_hide_password)
// For the new type to take effect you need to reset the text to it's current edited version
containerView.passwordText?.text = currText
}
fun hidePassword() {
val currText = containerView.passwordText?.text
containerView.passwordText?.inputType =
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
containerView.revealPasswordButton?.setImageDrawable(
AppCompatResources.getDrawable(context, R.drawable.mozac_ic_password_reveal)
)
containerView.revealPasswordButton?.contentDescription =
context.getString(R.string.saved_login_reveal_password)
// For the new type to take effect you need to reset the text to it's current edited version
containerView.passwordText?.text = currText
}
}

View File

@ -0,0 +1,65 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins.view
import android.text.InputType
import android.view.ViewGroup
import androidx.core.content.res.ResourcesCompat
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.fragment_login_detail.*
import kotlinx.android.synthetic.main.fragment_login_detail.view.*
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.settings.logins.LoginsListState
/**
* View that contains and configures the Login Details
*/
@Suppress("ForbiddenComment")
class LoginDetailView(override val containerView: ViewGroup) : LayoutContainer {
private val context = containerView.context
fun update(login: LoginsListState) {
webAddressText.text = login.currentItem?.origin
usernameText.text = login.currentItem?.username
passwordText.text = login.currentItem?.password
}
fun togglePasswordReveal(show: Boolean) {
if (show) showPassword() else { hidePassword() }
}
// TODO: create helper class for toggling passwords. https://github.com/mozilla-mobile/fenix/issues/12554
fun showPassword() {
if (passwordText.inputType == InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT) {
context?.components?.analytics?.metrics?.track(Event.ViewLoginPassword)
passwordText.inputType = InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
revealPasswordButton.setImageDrawable(
ResourcesCompat.getDrawable(
context.resources,
R.drawable.mozac_ic_password_hide, null
)
)
revealPasswordButton.contentDescription =
context.resources.getString(R.string.saved_login_hide_password)
}
// For the new type to take effect you need to reset the text
passwordText.text = containerView.passwordText.editableText
}
fun hidePassword() {
passwordText.inputType =
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
revealPasswordButton.setImageDrawable(
ResourcesCompat.getDrawable(context.resources,
R.drawable.mozac_ic_password_reveal, null)
)
revealPasswordButton.contentDescription =
context.getString(R.string.saved_login_reveal_password)
// For the new type to take effect you need to reset the text
passwordText.text = containerView.passwordText.editableText
}
}

View File

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

View File

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

View File

@ -0,0 +1,67 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins.view
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.component_saved_logins.view.*
import org.mozilla.fenix.R
import org.mozilla.fenix.settings.logins.LoginsListState
import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor
import org.mozilla.fenix.ext.addUnderline
/**
* View that contains and configures the Saved Logins List
*/
class SavedLoginsListView(
override val containerView: ViewGroup,
val interactor: SavedLoginsInteractor
) : LayoutContainer {
val view: FrameLayout = LayoutInflater.from(containerView.context)
.inflate(R.layout.component_saved_logins, containerView, true)
.findViewById(R.id.saved_logins_wrapper)
private val loginsAdapter = LoginsAdapter(interactor)
init {
view.saved_logins_list.apply {
adapter = loginsAdapter
layoutManager = LinearLayoutManager(containerView.context)
itemAnimator = null
}
with(view.saved_passwords_empty_learn_more) {
movementMethod = LinkMovementMethod.getInstance()
addUnderline()
setOnClickListener { interactor.onLearnMoreClicked() }
}
with(view.saved_passwords_empty_message) {
val appName = context.getString(R.string.app_name)
text = String.format(
context.getString(
R.string.preferences_passwords_saved_logins_description_empty_text
), appName
)
}
}
fun update(state: LoginsListState) {
if (state.isLoading) {
view.progress_bar.isVisible = true
} else {
view.progress_bar.isVisible = false
view.saved_logins_list.isVisible = state.loginList.isNotEmpty()
view.saved_passwords_empty_view.isVisible = state.loginList.isEmpty()
}
loginsAdapter.submitList(state.filteredItems)
}
}

View File

@ -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

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_enabled="true" android:color="?primaryText"/>
<item android:state_enabled="false" android:color="?disabled" />
</selector>

View File

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

View File

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

View File

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

View File

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

View File

@ -321,7 +321,7 @@
<fragment
android:id="@+id/savedLoginsAuthFragment"
android:name="org.mozilla.fenix.settings.logins.SavedLoginsAuthFragment"
android:name="org.mozilla.fenix.settings.logins.fragment.SavedLoginsAuthFragment"
android:label="@string/preferences_passwords_logins_and_passwords">
<action
android:id="@+id/action_savedLoginsAuthFragment_to_loginsListFragment"
@ -355,7 +355,7 @@
<fragment
android:id="@+id/savedLoginsFragment"
android:name="org.mozilla.fenix.settings.logins.SavedLoginsFragment"
android:name="org.mozilla.fenix.settings.logins.fragment.SavedLoginsFragment"
tools:layout="@layout/fragment_saved_logins">
<action
android:id="@+id/action_savedLoginsFragment_to_loginDetailFragment"
@ -381,7 +381,7 @@
<fragment
android:id="@+id/loginDetailFragment"
android:name="org.mozilla.fenix.settings.logins.LoginDetailFragment"
android:name="org.mozilla.fenix.settings.logins.fragment.LoginDetailFragment"
tools:layout="@layout/fragment_login_detail">
<argument
android:name="savedLoginId"
@ -396,7 +396,7 @@
<fragment
android:id="@+id/editLoginFragment"
android:name="org.mozilla.fenix.settings.logins.EditLoginFragment"
android:name="org.mozilla.fenix.settings.logins.fragment.EditLoginFragment"
android:label="@string/edit">
<argument
android:name="savedLoginItem"
@ -790,7 +790,7 @@
android:name="org.mozilla.fenix.settings.advanced.LocaleSettingsFragment" />
<fragment
android:id="@+id/saveLoginSettingFragment"
android:name="org.mozilla.fenix.settings.logins.SavedLoginsSettingFragment"
android:name="org.mozilla.fenix.settings.logins.fragment.SavedLoginsSettingFragment"
android:label="SaveLoginSettingFragment" />
<fragment
android:id="@+id/addonsManagementFragment"

View File

@ -0,0 +1,34 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins
import io.mockk.mockk
import io.mockk.verify
import org.junit.Test
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
import org.mozilla.fenix.settings.logins.interactor.EditLoginInteractor
class EditLoginInteractorTest {
private val loginsController: SavedLoginsStorageController = mockk(relaxed = true)
private val interactor = EditLoginInteractor(loginsController)
@Test
fun findPotentialDupesTest() {
val id = "anyId"
interactor.findPotentialDuplicates(id)
verify { loginsController.findPotentialDuplicates(id) }
}
@Test
fun saveLoginTest() {
val id = "anyId"
val username = "usernameText"
val password = "passwordText"
interactor.onSaveLogin(id, username, password)
verify { loginsController.save(id, username, password) }
}
}

View File

@ -0,0 +1,30 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins
import io.mockk.mockk
import io.mockk.verifyAll
import org.junit.Test
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
import org.mozilla.fenix.settings.logins.interactor.LoginDetailInteractor
class LoginDetailInteractorTest {
private val loginsController: SavedLoginsStorageController = mockk(relaxed = true)
private val interactor = LoginDetailInteractor(loginsController)
@Test
fun fetchLoginListTest() {
val id = "anyId"
interactor.onFetchLoginList(id)
verifyAll { loginsController.fetchLoginDetails(id) }
}
@Test
fun deleteLoginTest() {
val id = "anyId"
interactor.onDeleteLogin(id)
verifyAll { loginsController.delete(id) }
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -118,8 +118,11 @@ The following metrics are added to the ping:
| history.removed_all |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user removed all history items |[1](https://github.com/mozilla-mobile/fenix/pull/3940)||2020-10-01 |
| history.shared |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user shared a history item |[1](https://github.com/mozilla-mobile/fenix/pull/3940)||2020-10-01 |
| logins.copy_login |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user copied a piece of a login in saved logins |[1](https://github.com/mozilla-mobile/fenix/pull/6352)||2020-10-01 |
| logins.delete_saved_login |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user confirms delete of a saved login |[1](https://github.com/mozilla-mobile/fenix/issues/11208)||2020-10-01 |
| logins.open_individual_login |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user accessed an individual login in saved logins |[1](https://github.com/mozilla-mobile/fenix/pull/6352)||2020-10-01 |
| logins.open_login_editor |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user entered the edit screen for an individual saved login |[1](https://github.com/mozilla-mobile/fenix/issues/11208)||2020-10-01 |
| logins.open_logins |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user accessed Logins in Settings |[1](https://github.com/mozilla-mobile/fenix/pull/6352)||2020-10-01 |
| logins.save_edited_login |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user saves changes made to an individual login |[1](https://github.com/mozilla-mobile/fenix/issues/11208)||2020-10-01 |
| logins.save_logins_setting_changed |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user changed their setting for asking to save logins |[1](https://github.com/mozilla-mobile/fenix/pull/7767)|<ul><li>setting: The new setting for saving logins the user selected. Either `ask_to_save` or `never_save` </li></ul>|2020-10-01 |
| logins.view_password_login |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user viewed a password in an individual saved login |[1](https://github.com/mozilla-mobile/fenix/pull/6352)||2020-10-01 |
| media_notification.pause |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user pressed the pause icon on the media notification |[1](https://github.com/mozilla-mobile/fenix/pull/5520)||2020-10-01 |