1
0
Fork 0

Fixes #9504: Edit logins (#9693)

* Create editable view and fragment. Update login info page to display options menu with edit and delete.

* Create feature flag for edit. Check flag in the login detail fragment and default to just delete.

* Add three-dot kebab options menu in login detail fragment. Add title to the login item.

* Nav to and from edit view on save and back pressed.

* Save login through AC login manager. Clear text in editable field on button click.

* Match colors, fonts, dimens to UX specs for edit logins. Enable password reveal/hide and clearing text fields.

* Refactoring logins fragments. Using component Login object for consistency.

Fetch login list when saved logins are opened. Fetch login details when detail view is opened.

Revert "Fetch login list when saved logins are opened. Fetch login details when detail view is opened."

This reverts commit 44fe17166c3332b330229258b2e8982832672e3b.

* Using parcelable login and Login component class to pass ids and items between fragments

* Retrieve login from storage when viewing login details.

Rename login logic for consistency.

Ktlint cleanup

Fix nits and naming consistency.

* UX consistency for login detail and edit login pages

* Rebasing with logins sort - updating logins store.

* Rebasing with logins sort - merging fragments and controllers.

* Lint and removing unused files.

* UX cleanup.

* Update string description
master
Elise Richards 2020-05-12 17:32:01 -05:00 committed by GitHub
parent f7b4f1c959
commit edc75c3ad0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1308 additions and 641 deletions

View File

@ -39,6 +39,11 @@ object FeatureFlags {
*/
val tips = Config.channel.isDebug
/**
* Allows edit of saved logins.
*/
val loginsEdit = Config.channel.isNightlyOrDebug
/**
* Enables new tab tray pref
*/

View File

@ -69,10 +69,10 @@ import org.mozilla.fenix.perf.Performance
import org.mozilla.fenix.perf.StartupTimeline
import org.mozilla.fenix.search.SearchFragmentDirections
import org.mozilla.fenix.settings.DefaultBrowserSettingsFragmentDirections
import org.mozilla.fenix.settings.logins.SavedLoginsAuthFragmentDirections
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.SavedLoginsFragmentDirections
import org.mozilla.fenix.theme.DefaultThemeManager
import org.mozilla.fenix.theme.ThemeManager
import org.mozilla.fenix.utils.BrowsersCache
@ -387,7 +387,7 @@ open class HomeActivity : LocaleAwareAppCompatActivity() {
BrowserDirection.FromDefaultBrowserSettingsFragment ->
DefaultBrowserSettingsFragmentDirections.actionGlobalBrowser(customTabSessionId)
BrowserDirection.FromSavedLoginsFragment ->
SavedLoginsFragmentDirections.actionGlobalBrowser(customTabSessionId)
SavedLoginsAuthFragmentDirections.actionGlobalBrowser(customTabSessionId)
}
private fun load(

View File

@ -22,7 +22,7 @@ class ExceptionsListItemViewHolder(
) : RecyclerView.ViewHolder(view) {
private val favicon = view.favicon_image
private val url = view.domainView
private val url = view.webAddressView
private val deleteButton = view.delete_exception
private var item: TrackingProtectionException? = null

View File

@ -240,7 +240,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
null
}
resources.getString(R.string.pref_key_passwords) -> {
SettingsFragmentDirections.actionSettingsFragmentToLoginsFragment()
SettingsFragmentDirections.actionSettingsFragmentToSavedLoginsAuthFragment()
}
resources.getString(R.string.pref_key_about) -> {
SettingsFragmentDirections.actionSettingsFragmentToAboutFragment()

View File

@ -0,0 +1,215 @@
/* 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.os.Bundle
import android.text.Editable
import android.text.InputType
import android.util.Log
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.fragment_edit_login.inputLayoutPassword
import kotlinx.android.synthetic.main.fragment_edit_login.inputLayoutUsername
import kotlinx.android.synthetic.main.fragment_edit_login.hostnameText
import kotlinx.android.synthetic.main.fragment_edit_login.usernameText
import kotlinx.android.synthetic.main.fragment_edit_login.passwordText
import kotlinx.android.synthetic.main.fragment_edit_login.clearUsernameTextButton
import kotlinx.android.synthetic.main.fragment_edit_login.clearPasswordTextButton
import kotlinx.android.synthetic.main.fragment_edit_login.revealPasswordButton
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import mozilla.components.concept.storage.Login
import mozilla.components.service.sync.logins.InvalidRecordException
import mozilla.components.service.sync.logins.LoginsStorageException
import mozilla.components.service.sync.logins.NoSuchRecordException
import mozilla.components.support.ktx.android.view.hideKeyboard
import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
/**
* Displays the editable saved login information for a single website.
*/
@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)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
savedLoginsStore = StoreProvider.get(this) {
LoginsFragmentStore(
LoginsListState(
isLoading = true,
loginList = listOf(),
filteredItems = listOf(),
searchedForText = null,
sortingStrategy = requireContext().settings().savedLoginsSortingStrategy,
highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem
)
)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// ensure hostname isn't editable
hostnameText.text = args.savedLoginItem.origin.toEditable()
hostnameText.isClickable = false
hostnameText.isFocusable = false
usernameText.text = args.savedLoginItem.username.toEditable()
passwordText.text = args.savedLoginItem.password!!.toEditable()
// TODO: extend PasswordTransformationMethod() to change bullets to asterisks
passwordText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
setUpClickListeners()
}
private fun setUpClickListeners() {
clearUsernameTextButton.setOnClickListener {
usernameText.text?.clear()
usernameText.isCursorVisible = true
usernameText.hasFocus()
inputLayoutUsername.hasFocus()
}
clearPasswordTextButton.setOnClickListener {
passwordText.text?.clear()
passwordText.isCursorVisible = true
passwordText.hasFocus()
inputLayoutPassword.hasFocus()
}
revealPasswordButton.setOnClickListener {
togglePasswordReveal()
}
passwordText.setOnClickListener {
togglePasswordReveal()
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.login_save, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
R.id.save_login_button -> {
view?.hideKeyboard()
try {
if (!passwordText.text.isNullOrBlank()) {
attemptSaveAndExit()
} else {
view?.let {
FenixSnackbar.make(
view = it,
duration = Snackbar.LENGTH_SHORT,
isDisplayedWithBrowserToolbar = false
).setText(getString(R.string.saved_login_password_required)).show()
}
}
} 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)
}
}
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

@ -0,0 +1,265 @@
/* 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.DialogInterface
import android.os.Bundle
import android.text.InputType
import android.view.MenuItem
import android.view.MenuInflater
import android.view.Menu
import android.view.View
import android.view.ViewGroup
import android.view.LayoutInflater
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
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.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.ObsoleteCoroutinesApi
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import mozilla.components.concept.storage.Login
import mozilla.components.lib.state.ext.consumeFrom
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.ext.urlToTrimmedHost
/**
* Displays saved login information for a single website.
*/
@Suppress("TooManyFunctions", "ForbiddenComment")
@ExperimentalCoroutinesApi
class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
private val args by navArgs<LoginDetailFragmentArgs>()
private var login: SavedLogin? = null
private lateinit var savedLoginsStore: LoginsFragmentStore
private lateinit var loginDetailView: LoginDetailView
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_login_detail, container, false)
savedLoginsStore = StoreProvider.get(this) {
LoginsFragmentStore(
LoginsListState(
isLoading = true,
loginList = listOf(),
filteredItems = listOf(),
searchedForText = null,
sortingStrategy = requireContext().settings().savedLoginsSortingStrategy,
highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem
)
)
}
loginDetailView = LoginDetailView(view?.findViewById(R.id.loginDetailLayout))
fetchLoginDetails()
return view
}
@ObsoleteCoroutinesApi
@ExperimentalCoroutinesApi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
consumeFrom(savedLoginsStore) {
loginDetailView.update(it)
login = savedLoginsStore.state.currentItem
setUpCopyButtons()
showToolbar(
savedLoginsStore.state.currentItem?.origin?.urlToTrimmedHost(requireContext())
?: ""
)
setUpPasswordReveal()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
private fun setUpPasswordReveal() {
passwordText.inputType =
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
revealPasswordButton.setOnClickListener {
togglePasswordReveal()
}
}
private fun setUpCopyButtons() {
webAddressText.text = login?.origin
copyWebAddress.setOnClickListener(
CopyButtonListener(login?.origin, R.string.logins_site_copied)
)
usernameText.text = login?.username
copyUsername.setOnClickListener(
CopyButtonListener(login?.username, R.string.logins_username_copied)
)
passwordText.text = login?.password
copyPassword.setOnClickListener(
CopyButtonListener(login?.password, R.string.logins_password_copied)
)
}
// 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)
} else {
inflater.inflate(R.menu.login_delete, menu)
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
R.id.delete_login_button -> {
displayDeleteLoginDialog()
true
}
R.id.edit_login_button -> {
editLogin()
true
}
else -> false
}
private fun editLogin() {
val directions =
LoginDetailFragmentDirections
.actionLoginDetailFragmentToEditLoginFragment(login!!)
findNavController().navigate(directions)
}
private fun displayDeleteLoginDialog() {
activity?.let { activity ->
AlertDialog.Builder(activity).apply {
setMessage(R.string.login_deletion_confirmation)
setNegativeButton(android.R.string.cancel) { dialog: DialogInterface, _ ->
dialog.cancel()
}
setPositiveButton(R.string.dialog_delete_positive) { dialog: DialogInterface, _ ->
deleteLogin()
dialog.dismiss()
}
create()
}.show()
}
}
// 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
* @param snackbarText Text to display in snackbar after copying.
*/
private inner class CopyButtonListener(
private val value: String?,
@StringRes private val snackbarText: Int
) : View.OnClickListener {
override fun onClick(view: View) {
val clipboard = view.context.components.clipboardHandler
clipboard.text = value
showCopiedSnackbar(view.context.getString(snackbarText))
view.context.components.analytics.metrics.track(Event.CopyLogin)
}
private fun showCopiedSnackbar(copiedItem: String) {
view?.let {
FenixSnackbar.make(
view = it,
duration = Snackbar.LENGTH_SHORT,
isDisplayedWithBrowserToolbar = false
).setText(copiedItem).show()
}
}
}
}

View File

@ -0,0 +1,20 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.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

@ -8,33 +8,30 @@ import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import org.mozilla.fenix.R
private sealed class AdapterItem {
data class Item(val item: SavedLoginsItem) : AdapterItem()
}
class SavedLoginsAdapter(
class LoginsAdapter(
private val interactor: SavedLoginsInteractor
) : ListAdapter<SavedLoginsItem, SavedLoginsListItemViewHolder>(DiffCallback) {
) : ListAdapter<SavedLogin, LoginsListViewHolder>(DiffCallback) {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): SavedLoginsListItemViewHolder {
): LoginsListViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(SavedLoginsListItemViewHolder.LAYOUT_ID, parent, false)
return SavedLoginsListItemViewHolder(view, interactor)
.inflate(R.layout.logins_item, parent, false)
return LoginsListViewHolder(view, interactor)
}
override fun onBindViewHolder(holder: SavedLoginsListItemViewHolder, position: Int) {
override fun onBindViewHolder(holder: LoginsListViewHolder, position: Int) {
holder.bind(getItem(position))
}
private object DiffCallback : DiffUtil.ItemCallback<SavedLoginsItem>() {
override fun areItemsTheSame(oldItem: SavedLoginsItem, newItem: SavedLoginsItem) =
oldItem.url == newItem.url
private object DiffCallback : DiffUtil.ItemCallback<SavedLogin>() {
override fun areItemsTheSame(oldItem: SavedLogin, newItem: SavedLogin) =
oldItem.origin == newItem.origin
override fun areContentsTheSame(oldItem: SavedLoginsItem, newItem: SavedLoginsItem) =
override fun areContentsTheSame(oldItem: SavedLogin, newItem: SavedLogin) =
oldItem == newItem
}
}

View File

@ -0,0 +1,161 @@
/* 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.os.Parcelable
import kotlinx.android.parcel.Parcelize
import mozilla.components.concept.storage.Login
import mozilla.components.lib.state.Action
import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
/**
* Class representing a parcelable saved logins item
* @property guid The id of the saved login
* @property origin Site of the saved login
* @property username Username that's saved for this site
* @property password Password that's saved for this site
* @property timeLastUsed Time of last use in milliseconds from the unix epoch.
*/
@Parcelize
data class SavedLogin(
val guid: String,
val origin: String,
val username: String,
val password: String?,
val timeLastUsed: Long
) : Parcelable
fun Login.mapToSavedLogin(): SavedLogin =
SavedLogin(
guid = this.guid!!,
origin = this.origin,
username = this.username,
password = this.password,
timeLastUsed = this.timeLastUsed
)
/**
* The [Store] for holding the [LoginsListState] and applying [LoginsAction]s.
*/
class LoginsFragmentStore(initialState: LoginsListState) :
Store<LoginsListState, LoginsAction>(
initialState,
::savedLoginsStateReducer
)
/**
* Actions to dispatch through the `LoginsFragmentStore` to modify `LoginsListState` through the reducer.
*/
sealed class LoginsAction : Action {
data class FilterLogins(val newText: String?) : LoginsAction()
data class UpdateLoginsList(val list: List<SavedLogin>) : LoginsAction()
data class UpdateCurrentLogin(val item: SavedLogin) : LoginsAction()
data class SortLogins(val sortingStrategy: SortingStrategy) : 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
*/
data class LoginsListState(
val isLoading: Boolean = false,
val loginList: List<SavedLogin>,
val filteredItems: List<SavedLogin>,
val currentItem: SavedLogin? = null,
val searchedForText: String?,
val sortingStrategy: SortingStrategy,
val highlightedItem: SavedLoginsSortingStrategyMenu.Item
) : State
/**
* Handles changes in the saved logins list, including updates and filtering.
*/
private fun savedLoginsStateReducer(
state: LoginsListState,
action: LoginsAction
): LoginsListState {
return when (action) {
is LoginsAction.UpdateLoginsList -> state.copy(
isLoading = false,
loginList = action.list,
filteredItems = action.list
)
is LoginsAction.FilterLogins -> {
filterItems(
action.newText,
state.sortingStrategy,
state
)
}
is LoginsAction.UpdateCurrentLogin -> {
state.copy(
currentItem = action.item
)
}
is LoginsAction.SortLogins -> {
filterItems(
state.searchedForText,
action.sortingStrategy,
state
)
}
}
}
/**
* @return [LoginsListState] containing a new [LoginsListState.filteredItems]
* with filtered [LoginsListState.items]
*
* @param searchedForText based on which [LoginsListState.items] will be filtered.
* @param sortingStrategy based on which [LoginsListState.items] will be sorted.
* @param state previous [LoginsListState] containing all the other properties
* with which a new state will be created
*/
private fun filterItems(
searchedForText: String?,
sortingStrategy: SortingStrategy,
state: LoginsListState
): LoginsListState {
return if (searchedForText.isNullOrBlank()) {
state.copy(
isLoading = false,
sortingStrategy = sortingStrategy,
highlightedItem = sortingStrategyToMenuItem(sortingStrategy),
searchedForText = searchedForText,
filteredItems = sortingStrategy(state.loginList)
)
} else {
state.copy(
isLoading = false,
sortingStrategy = sortingStrategy,
highlightedItem = sortingStrategyToMenuItem(sortingStrategy),
searchedForText = searchedForText,
filteredItems = sortingStrategy(state.loginList).filter {
it.origin.contains(
searchedForText
)
}
)
}
}
private fun sortingStrategyToMenuItem(sortingStrategy: SortingStrategy): SavedLoginsSortingStrategyMenu.Item {
return when (sortingStrategy) {
is SortingStrategy.Alphabetically -> {
SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort
}
is SortingStrategy.LastUsed -> {
SavedLoginsSortingStrategyMenu.Item.LastUsedSort
}
}
}

View File

@ -7,26 +7,32 @@ package org.mozilla.fenix.settings.logins
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.logins_item.view.*
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.loadIntoView
class SavedLoginsListItemViewHolder(
class LoginsListViewHolder(
private val view: View,
private val interactor: SavedLoginsInteractor
) : RecyclerView.ViewHolder(view) {
private val favicon = view.favicon_image
private val url = view.domainView
private val userName = view.userView
private val url = view.webAddressView
private val username = view.usernameView
private var loginItem: SavedLogin? = null
private var item: SavedLoginsItem? = null
fun bind(item: SavedLogin) {
this.loginItem = SavedLogin(
guid = item.guid,
origin = item.origin,
password = item.password,
username = item.username,
timeLastUsed = item.timeLastUsed
)
url.text = item.origin
username.text = item.username
updateFavIcon(item.origin)
fun bind(item: SavedLoginsItem) {
this.item = item
url.text = item.url
userName.text = item.userName
updateFavIcon(item.url)
view.setOnClickListener {
interactor.itemClicked(item)
}
@ -35,8 +41,4 @@ class SavedLoginsListItemViewHolder(
private fun updateFavIcon(url: String) {
favicon.context.components.core.icons.loadIntoView(favicon, url)
}
companion object {
const val LAYOUT_ID = R.layout.logins_item
}
}

View File

@ -1,194 +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.content.Context
import android.content.DialogInterface
import android.os.Bundle
import android.text.InputType
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.WindowManager
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources.getDrawable
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.fragment_saved_login_site_info.*
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 org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.checkAndUpdateScreenshotPermission
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.showToolbar
/**
* Displays saved login information for a single website.
*/
class SavedLoginSiteInfoFragment : Fragment(R.layout.fragment_saved_login_site_info) {
private val args by navArgs<SavedLoginSiteInfoFragmentArgs>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onPause() {
// If we pause this fragment, we want to pop users back to reauth
if (findNavController().currentDestination?.id != R.id.savedLoginsFragment) {
activity?.let { it.checkAndUpdateScreenshotPermission(it.settings()) }
findNavController().popBackStack(R.id.loginsFragment, false)
}
super.onPause()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
siteInfoText.text = args.savedLoginItem.url
copySiteItem.setOnClickListener(
CopyButtonListener(args.savedLoginItem.url, R.string.logins_site_copied)
)
usernameInfoText.text = args.savedLoginItem.userName
copyUsernameItem.setOnClickListener(
CopyButtonListener(args.savedLoginItem.userName, R.string.logins_username_copied)
)
passwordInfoText.inputType =
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
passwordInfoText.text = args.savedLoginItem.password
revealPasswordItem.setOnClickListener {
togglePasswordReveal(it.context)
}
copyPasswordItem.setOnClickListener(
CopyButtonListener(args.savedLoginItem.password, R.string.logins_password_copied)
)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.login_edit, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
R.id.delete_login_button -> {
displayDeleteLoginDialog()
true
}
else -> false
}
private fun deleteLogin() {
var deleteLoginJob: Deferred<Boolean>? = null
val deleteJob = viewLifecycleOwner.lifecycleScope.launch(IO) {
deleteLoginJob = async {
requireContext().components.core.passwordsStorage.delete(args.savedLoginItem.id)
}
deleteLoginJob?.await()
withContext(Main) {
findNavController().popBackStack(R.id.savedLoginsFragment, false)
}
}
deleteJob.invokeOnCompletion {
if (it is CancellationException) {
deleteLoginJob?.cancel()
}
}
}
private fun togglePasswordReveal(context: Context) {
if (passwordInfoText.inputType == InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT) {
context.components.analytics.metrics.track(Event.ViewLoginPassword)
passwordInfoText.inputType = InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
revealPasswordItem.setImageDrawable(
getDrawable(
context,
R.drawable.mozac_ic_password_hide
)
)
revealPasswordItem.contentDescription =
context.getString(R.string.saved_login_hide_password)
} else {
passwordInfoText.inputType =
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
revealPasswordItem.setImageDrawable(
getDrawable(
context,
R.drawable.mozac_ic_password_reveal
)
)
revealPasswordItem.contentDescription =
context.getString(R.string.saved_login_reveal_password)
}
// For the new type to take effect you need to reset the text
passwordInfoText.text = args.savedLoginItem.password
}
override fun onResume() {
super.onResume()
activity?.window?.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE
)
showToolbar(args.savedLoginItem.url)
}
private fun displayDeleteLoginDialog() {
activity?.let { activity ->
AlertDialog.Builder(activity).apply {
setMessage(R.string.login_deletion_confirmation)
setNegativeButton(android.R.string.cancel) { dialog: DialogInterface, _ ->
dialog.cancel()
}
setPositiveButton(R.string.dialog_delete_positive) { dialog: DialogInterface, _ ->
deleteLogin()
dialog.dismiss()
}
create()
}.show()
}
}
/**
* Click listener for a textview's copy button.
* @param value Value to be copied
* @param snackbarText Text to display in snackbar after copying.
*/
private inner class CopyButtonListener(
private val value: String?,
@StringRes private val snackbarText: Int
) : View.OnClickListener {
override fun onClick(view: View) {
val clipboard = view.context.components.clipboardHandler
clipboard.text = value
showCopiedSnackbar(view.context.getString(snackbarText))
view.context.components.analytics.metrics.track(Event.CopyLogin)
}
private fun showCopiedSnackbar(copiedItem: String) {
view?.let {
FenixSnackbar.make(
view = it,
duration = Snackbar.LENGTH_SHORT,
isDisplayedWithBrowserToolbar = false
).setText(copiedItem).show()
}
}
}
}

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
package org.mozilla.fenix.settings.logins
import android.annotation.TargetApi
import android.app.Activity.RESULT_OK
@ -38,10 +38,11 @@ import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.secure
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.settings.SharedPreferenceUpdater
import java.util.concurrent.Executors
@Suppress("TooManyFunctions", "LargeClass")
class LoginsFragment : PreferenceFragmentCompat(), AccountObserver {
class SavedLoginsAuthFragment : PreferenceFragmentCompat(), AccountObserver {
@TargetApi(M)
private lateinit var biometricPromptCallback: BiometricPrompt.AuthenticationCallback
@ -114,7 +115,8 @@ class LoginsFragment : PreferenceFragmentCompat(), AccountObserver {
isEnabled = context.settings().shouldPromptToSaveLogins
isChecked =
context.settings().shouldAutofillLogins && context.settings().shouldPromptToSaveLogins
onPreferenceChangeListener = SharedPreferenceUpdater()
onPreferenceChangeListener =
SharedPreferenceUpdater()
}
val savedLoginsKey = getPreferenceKey(R.string.pref_key_saved_logins)
@ -253,7 +255,9 @@ class LoginsFragment : PreferenceFragmentCompat(), AccountObserver {
getString(R.string.logins_biometric_prompt_message_pin),
getString(R.string.logins_biometric_prompt_message)
)
startActivityForResult(intent, PIN_REQUEST)
startActivityForResult(intent,
PIN_REQUEST
)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
@ -266,29 +270,29 @@ class LoginsFragment : PreferenceFragmentCompat(), AccountObserver {
private fun navigateToSavedLoginsFragment() {
context?.components?.analytics?.metrics?.track(Event.OpenLogins)
val directions = LoginsFragmentDirections.actionLoginsFragmentToSavedLoginsFragment()
val directions = SavedLoginsAuthFragmentDirections.actionSavedLoginsAuthFragmentToLoginsListFragment()
findNavController().navigate(directions)
}
private fun navigateToAccountSettingsFragment() {
val directions =
LoginsFragmentDirections.actionGlobalAccountSettingsFragment()
SavedLoginsAuthFragmentDirections.actionGlobalAccountSettingsFragment()
findNavController().navigate(directions)
}
private fun navigateToAccountProblemFragment() {
val directions = LoginsFragmentDirections.actionGlobalAccountProblemFragment()
val directions = SavedLoginsAuthFragmentDirections.actionGlobalAccountProblemFragment()
findNavController().navigate(directions)
}
private fun navigateToTurnOnSyncFragment() {
val directions = LoginsFragmentDirections.actionLoginsFragmentToTurnOnSyncFragment()
val directions = SavedLoginsAuthFragmentDirections.actionSavedLoginsAuthFragmentToTurnOnSyncFragment()
findNavController().navigate(directions)
}
private fun navigateToSaveLoginSettingFragment() {
val directions =
LoginsFragmentDirections.actionLoginsFragmentToSaveLoginSettingFragment()
SavedLoginsAuthFragmentDirections.actionSavedLoginsAuthFragmentToSavedLoginsSettingFragment()
findNavController().navigate(directions)
}

View File

@ -1,22 +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 org.mozilla.fenix.utils.Settings
interface SavedLoginsController {
fun handleSort(sortingStrategy: SortingStrategy)
}
class DefaultSavedLoginsController(
val store: SavedLoginsFragmentStore,
val settings: Settings
) : SavedLoginsController {
override fun handleSort(sortingStrategy: SortingStrategy) {
store.dispatch(SavedLoginsFragmentAction.SortLogins(sortingStrategy))
settings.savedLoginsSortingStrategy = sortingStrategy
}
}

View File

@ -46,7 +46,7 @@ import org.mozilla.fenix.settings.SupportUtils
@SuppressWarnings("TooManyFunctions")
class SavedLoginsFragment : Fragment() {
private lateinit var savedLoginsStore: SavedLoginsFragmentStore
private lateinit var savedLoginsStore: LoginsFragmentStore
private lateinit var savedLoginsView: SavedLoginsView
private lateinit var savedLoginsInteractor: SavedLoginsInteractor
private lateinit var dropDownMenuAnchorView: View
@ -76,10 +76,10 @@ class SavedLoginsFragment : Fragment() {
): View? {
val view = inflater.inflate(R.layout.fragment_saved_logins, container, false)
savedLoginsStore = StoreProvider.get(this) {
SavedLoginsFragmentStore(
SavedLoginsFragmentState(
LoginsFragmentStore(
LoginsListState(
isLoading = true,
items = listOf(),
loginList = listOf(),
filteredItems = listOf(),
searchedForText = null,
sortingStrategy = requireContext().settings().savedLoginsSortingStrategy,
@ -88,7 +88,7 @@ class SavedLoginsFragment : Fragment() {
)
}
val savedLoginsController: SavedLoginsController =
DefaultSavedLoginsController(savedLoginsStore, requireContext().settings())
SavedLoginsController(savedLoginsStore, requireContext().settings())
savedLoginsInteractor =
SavedLoginsInteractor(savedLoginsController, ::itemClicked, ::openLearnMore)
savedLoginsView = SavedLoginsView(view.savedLoginsLayout, savedLoginsInteractor)
@ -119,7 +119,7 @@ class SavedLoginsFragment : Fragment() {
}
override fun onQueryTextChange(newText: String?): Boolean {
savedLoginsStore.dispatch(SavedLoginsFragmentAction.FilterLogins(newText))
savedLoginsStore.dispatch(LoginsAction.FilterLogins(newText))
return false
}
})
@ -134,17 +134,17 @@ class SavedLoginsFragment : Fragment() {
(activity as HomeActivity).getSupportActionBarAndInflateIfNecessary().setDisplayShowTitleEnabled(true)
sortingStrategyPopupMenu.dismiss()
if (findNavController().currentDestination?.id != R.id.savedLoginSiteInfoFragment) {
if (findNavController().currentDestination?.id != R.id.loginDetailFragment) {
activity?.let { it.checkAndUpdateScreenshotPermission(it.settings()) }
findNavController().popBackStack(R.id.loginsFragment, false)
findNavController().popBackStack(R.id.savedLoginsAuthFragment, false)
}
super.onPause()
}
private fun itemClicked(item: SavedLoginsItem) {
private fun itemClicked(item: SavedLogin) {
context?.components?.analytics?.metrics?.track(Event.OpenOneLogin)
val directions =
SavedLoginsFragmentDirections.actionSavedLoginsFragmentToSavedLoginSiteInfoFragment(item)
SavedLoginsFragmentDirections.actionSavedLoginsFragmentToLoginDetailFragment(item.guid)
findNavController().navigate(directions)
}
@ -166,9 +166,9 @@ class SavedLoginsFragment : Fragment() {
val logins = deferredLogins?.await()
logins?.let {
withContext(Main) {
savedLoginsStore.dispatch(SavedLoginsFragmentAction.UpdateLogins(logins.map { item ->
SavedLoginsItem(item.origin, item.username, item.password, item.guid!!, item.timeLastUsed)
}))
savedLoginsStore.dispatch(
LoginsAction.UpdateLoginsList(logins.map { it.mapToSavedLogin() })
)
}
}
}

View File

@ -1,146 +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.os.Parcelable
import kotlinx.android.parcel.Parcelize
import mozilla.components.lib.state.Action
import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
/**
* Class representing an saved logins item
* @property url Site of the saved login
* @property userName Username that's saved for this site
* @property password Password that's saved for this site
* @property id The unique identifier for this login entry
* @property timeLastUsed Time of last use in milliseconds from the unix epoch.
*/
@Parcelize
data class SavedLoginsItem(
val url: String,
val userName: String?,
val password: String?,
val id: String,
val timeLastUsed: Long
) :
Parcelable
/**
* The [Store] for holding the [SavedLoginsFragmentState] and applying [SavedLoginsFragmentAction]s.
*/
class SavedLoginsFragmentStore(initialState: SavedLoginsFragmentState) :
Store<SavedLoginsFragmentState, SavedLoginsFragmentAction>(
initialState,
::savedLoginsStateReducer
)
/**
* Actions to dispatch through the `SavedLoginsStore` to modify `SavedLoginsFragmentState` through the reducer.
*/
sealed class SavedLoginsFragmentAction : Action {
data class FilterLogins(val newText: String?) : SavedLoginsFragmentAction()
data class UpdateLogins(val list: List<SavedLoginsItem>) : SavedLoginsFragmentAction()
data class SortLogins(val sortingStrategy: SortingStrategy) : SavedLoginsFragmentAction()
}
/**
* The state for the Saved Logins Screen
* @property isLoading State to know when to show loading
* @property items Source of truth of list of logins
* @property filteredItems Filtered (or not) list of logins to display
* @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)
*/
data class SavedLoginsFragmentState(
val isLoading: Boolean = false,
val items: List<SavedLoginsItem>,
val filteredItems: List<SavedLoginsItem>,
val searchedForText: String?,
val sortingStrategy: SortingStrategy,
val highlightedItem: SavedLoginsSortingStrategyMenu.Item
) : State
/**
* The SavedLoginsState Reducer.
*/
private fun savedLoginsStateReducer(
state: SavedLoginsFragmentState,
action: SavedLoginsFragmentAction
): SavedLoginsFragmentState {
return when (action) {
is SavedLoginsFragmentAction.UpdateLogins -> {
filterItems(
state.searchedForText, state.sortingStrategy, state.copy(
isLoading = false,
items = action.list,
filteredItems = emptyList()
)
)
}
is SavedLoginsFragmentAction.FilterLogins ->
filterItems(
action.newText,
state.sortingStrategy,
state
)
is SavedLoginsFragmentAction.SortLogins ->
filterItems(
state.searchedForText,
action.sortingStrategy,
state
)
}
}
/**
* @return [SavedLoginsFragmentState] containing a new [SavedLoginsFragmentState.filteredItems]
* with filtered [SavedLoginsFragmentState.items]
*
* @param searchedForText based on which [SavedLoginsFragmentState.items] will be filtered.
* @param sortingStrategy based on which [SavedLoginsFragmentState.items] will be sorted.
* @param state previous [SavedLoginsFragmentState] containing all the other properties
* with which a new state will be created
*/
private fun filterItems(
searchedForText: String?,
sortingStrategy: SortingStrategy,
state: SavedLoginsFragmentState
): SavedLoginsFragmentState {
return if (searchedForText.isNullOrBlank()) {
state.copy(
isLoading = false,
sortingStrategy = sortingStrategy,
highlightedItem = sortingStrategyToMenuItem(sortingStrategy),
searchedForText = searchedForText,
filteredItems = sortingStrategy(state.items)
)
} else {
state.copy(
isLoading = false,
sortingStrategy = sortingStrategy,
highlightedItem = sortingStrategyToMenuItem(sortingStrategy),
searchedForText = searchedForText,
filteredItems = sortingStrategy(state.items).filter {
it.url.contains(
searchedForText
)
}
)
}
}
private fun sortingStrategyToMenuItem(sortingStrategy: SortingStrategy): SavedLoginsSortingStrategyMenu.Item {
return when (sortingStrategy) {
is SortingStrategy.Alphabetically -> {
SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort
}
is SortingStrategy.LastUsed -> {
SavedLoginsSortingStrategyMenu.Item.LastUsedSort
}
}
}

View File

@ -1,26 +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
/**
* Interactor for the saved logins screen
* Provides implementations for the SavedLoginsViewInteractor
*/
class SavedLoginsInteractor(
private val savedLoginsController: SavedLoginsController,
private val itemClicked: (SavedLoginsItem) -> Unit,
private val learnMore: () -> Unit
) : SavedLoginsViewInteractor {
override fun itemClicked(item: SavedLoginsItem) {
itemClicked.invoke(item)
}
override fun onLearnMore() {
learnMore.invoke()
}
override fun sort(sortingStrategy: SortingStrategy) {
savedLoginsController.handleSort(sortingStrategy)
}
}

View File

@ -15,7 +15,7 @@ import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.settings.RadioButtonPreference
import org.mozilla.fenix.settings.SharedPreferenceUpdater
class SaveLoginSettingFragment : PreferenceFragmentCompat() {
class SavedLoginsSettingFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.save_logins_preferences, rootKey)
}

View File

@ -16,21 +16,7 @@ import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.component_saved_logins.view.*
import kotlinx.android.synthetic.main.component_saved_logins.view.progress_bar
import org.mozilla.fenix.R
/**
* Interface for the SavedLoginsViewInteractor. This interface is implemented by objects that want
* to respond to user interaction on the SavedLoginsView
*/
interface SavedLoginsViewInteractor {
/**
* Called whenever one item is clicked
*/
fun itemClicked(item: SavedLoginsItem)
fun onLearnMore()
fun sort(sortingStrategy: SortingStrategy)
}
import org.mozilla.fenix.utils.Settings
/**
* View that contains and configures the Saved Logins List
@ -44,7 +30,7 @@ class SavedLoginsView(
.inflate(R.layout.component_saved_logins, containerView, true)
.findViewById(R.id.saved_logins_wrapper)
private val loginsAdapter = SavedLoginsAdapter(interactor)
private val loginsAdapter = LoginsAdapter(interactor)
init {
view.saved_logins_list.apply {
@ -65,21 +51,51 @@ class SavedLoginsView(
with(view.saved_passwords_empty_message) {
val appName = context.getString(R.string.app_name)
text = context.getString(
R.string.preferences_passwords_saved_logins_description_empty_text,
appName
text = String.format(
context.getString(
R.string.preferences_passwords_saved_logins_description_empty_text
), appName
)
}
}
fun update(state: SavedLoginsFragmentState) {
fun update(state: LoginsListState) {
if (state.isLoading) {
view.progress_bar.isVisible = true
} else {
view.progress_bar.isVisible = false
view.saved_logins_list.isVisible = state.items.isNotEmpty()
view.saved_passwords_empty_view.isVisible = state.items.isEmpty()
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
*/
class SavedLoginsInteractor(
private val savedLoginsController: SavedLoginsController,
private val itemClicked: (SavedLogin) -> Unit,
private val learnMore: () -> Unit
) {
fun itemClicked(item: SavedLogin) {
itemClicked.invoke(item)
}
fun onLearnMore() {
learnMore.invoke()
}
fun sort(sortingStrategy: SortingStrategy) {
savedLoginsController.handleSort(sortingStrategy)
}
}
/**
* Controller for the saved logins screen
*/
class SavedLoginsController(val store: LoginsFragmentStore, val settings: Settings) {
fun handleSort(sortingStrategy: SortingStrategy) {
store.dispatch(LoginsAction.SortLogins(sortingStrategy))
settings.savedLoginsSortingStrategy = sortingStrategy
}
}

View File

@ -8,17 +8,17 @@ import android.content.Context
import org.mozilla.fenix.ext.urlToTrimmedHost
sealed class SortingStrategy {
abstract operator fun invoke(logins: List<SavedLoginsItem>): List<SavedLoginsItem>
abstract operator fun invoke(logins: List<SavedLogin>): List<SavedLogin>
abstract val appContext: Context
data class Alphabetically(override val appContext: Context) : SortingStrategy() {
override fun invoke(logins: List<SavedLoginsItem>): List<SavedLoginsItem> {
return logins.sortedBy { it.url.urlToTrimmedHost(appContext) }
override fun invoke(logins: List<SavedLogin>): List<SavedLogin> {
return logins.sortedBy { it.origin.urlToTrimmedHost(appContext) }
}
}
data class LastUsed(override val appContext: Context) : SortingStrategy() {
override fun invoke(logins: List<SavedLoginsItem>): List<SavedLoginsItem> {
override fun invoke(logins: List<SavedLogin>): List<SavedLogin> {
return logins.sortedByDescending { it.timeLastUsed }
}
}

View File

@ -0,0 +1,16 @@
<!--
~ This Source Code Form is subject to the terms of the Mozilla Public
~ License, v. 2.0. If a copy of the MPL was not distributed with this
~ file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="#FF000000"
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"/>
</vector>

View File

@ -26,7 +26,7 @@
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/domainView"
android:id="@+id/webAddressView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"

View File

@ -0,0 +1,227 @@
<?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/. -->
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/editLoginLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="72dp"
android:layout_marginEnd="20dp"
android:layout_marginTop="12dp" >
<TextView
android:id="@+id/hostnameHeaderText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingStart="3dp"
android:gravity="center_vertical"
android:text="@string/preferences_passwords_saved_logins_site"
android:textColor="?primaryText"
android:textSize="12sp"
android:letterSpacing="0.05"
app:fontFamily="@font/metropolis_semibold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:ignore="RtlSymmetry" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/inputLayoutHostname"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="false"
android:focusable="false"
android:background="@android:color/transparent"
android:textColor="?primaryText"
android:layout_marginTop="12dp"
android:layout_marginStart="3dp"
android:contentDescription="@string/saved_login_hostname_description"
app:backgroundTint="@android:color/transparent"
app:hintEnabled="false"
app:layout_constraintEnd_toEndOf="@id/hostnameHeaderText"
app:layout_constraintStart_toStartOf="@id/hostnameHeaderText"
app:layout_constraintTop_toBottomOf="@id/hostnameHeaderText"
tools:ignore="RtlSymmetry">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/hostnameText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="16sp"
android:fontFamily="sans-serif"
android:textStyle="normal"
android:textColor="?disabled"
android:letterSpacing="0.01"
android:lineSpacingExtra="8sp"
android:hint="@string/saved_login_hostname_description"
android:inputType="textNoSuggestions"
android:ellipsize="end"
android:maxLines="1"
android:singleLine="true"
android:clickable="false"
android:focusable="false"
android:textCursorDrawable="@android:color/transparent"
android:background="@android:color/transparent"
app:backgroundTint="@android:color/transparent"
tools:ignore="Autofill"/>
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/usernameHeader"
android:layout_width="0dp"
android:layout_height="16dp"
android:gravity="center_vertical"
android:paddingStart="3dp"
android:layout_marginTop="20dp"
android:text="@string/preferences_passwords_saved_logins_username"
android:textColor="?primaryText"
android:textSize="12sp"
android:letterSpacing="0.05"
app:fontFamily="@font/metropolis_semibold"
app:layout_constraintBottom_toTopOf="@id/inputLayoutUsername"
app:layout_constraintEnd_toStartOf="@id/clearUsernameTextButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/inputLayoutHostname"
app:layout_constraintVertical_chainStyle="packed"
tools:ignore="RtlSymmetry" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/inputLayoutUsername"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:colorControlHighlight="?primaryText"
android:colorControlActivated="?primaryText"
android:textColor="?primaryText"
android:contentDescription="@string/saved_login_username_description"
app:layout_constraintEnd_toEndOf="@id/usernameHeader"
app:layout_constraintStart_toStartOf="@id/usernameHeader"
app:layout_constraintTop_toBottomOf="@id/usernameHeader"
app:layout_constraintVertical_chainStyle="packed"
app:hintEnabled="false"
tools:ignore="RtlSymmetry">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/usernameText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="16sp"
android:fontFamily="sans-serif"
android:textStyle="normal"
android:colorControlHighlight="?primaryText"
android:colorControlActivated="?primaryText"
android:textColor="?primaryText"
android:letterSpacing="0.01"
android:lineSpacingExtra="8sp"
android:inputType="textNoSuggestions"
android:ellipsize="end"
android:maxLines="1"
android:singleLine="true"
android:clickable="true"
android:focusable="true"
android:cursorVisible="true"
android:textCursorDrawable="?primaryText"
app:backgroundTint="?primaryText"
tools:ignore="Autofill"/>
</com.google.android.material.textfield.TextInputLayout>
<ImageButton
android:id="@+id/clearUsernameTextButton"
android:layout_width="48dp"
android:layout_height="30dp"
android:layout_marginBottom="10dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/saved_login_copy_username"
app:tint="?android:colorAccent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="@id/inputLayoutUsername"
app:srcCompat="@drawable/ic_clear" />
<TextView
android:id="@+id/passwordHeader"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:paddingStart="3dp"
android:text="@string/preferences_passwords_saved_logins_password"
android:textColor="?primaryText"
android:textSize="12sp"
android:layout_marginTop="10dp"
android:letterSpacing="0.05"
app:fontFamily="@font/metropolis_semibold"
app:layout_constraintBottom_toTopOf="@id/inputLayoutPassword"
app:layout_constraintEnd_toStartOf="@id/revealPasswordButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/inputLayoutUsername"
app:layout_constraintVertical_chainStyle="packed"
tools:ignore="RtlSymmetry" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/inputLayoutPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="11dp"
android:colorControlHighlight="?primaryText"
android:colorControlActivated="?primaryText"
android:textColor="?primaryText"
android:contentDescription="@string/saved_login_password_description"
app:hintEnabled="false"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@id/passwordHeader"
app:layout_constraintStart_toStartOf="@id/passwordHeader"
app:layout_constraintTop_toBottomOf="@id/passwordHeader"
app:layout_constraintVertical_chainStyle="packed"
tools:ignore="RtlSymmetry">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/passwordText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:colorControlActivated="?primaryText"
android:colorControlHighlight="?primaryText"
android:cursorVisible="true"
android:ellipsize="end"
android:focusable="true"
android:fontFamily="sans-serif"
android:inputType="textNoSuggestions"
android:letterSpacing="0.01"
android:lineSpacingExtra="8sp"
android:maxLines="1"
android:singleLine="true"
android:textColor="?primaryText"
android:textCursorDrawable="?primaryText"
android:textSize="16sp"
android:textStyle="normal"
app:backgroundTint="?primaryText"
tools:ignore="Autofill" />
</com.google.android.material.textfield.TextInputLayout>
<ImageButton
android:id="@+id/revealPasswordButton"
android:layout_width="48dp"
android:layout_height="30dp"
android:layout_marginTop="3dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/saved_login_reveal_password"
app:tint="?android:colorAccent"
app:layout_constraintEnd_toStartOf="@id/clearPasswordTextButton"
app:layout_constraintTop_toTopOf="@id/inputLayoutPassword"
app:srcCompat="@drawable/mozac_ic_password_reveal" />
<ImageButton
android:id="@+id/clearPasswordTextButton"
android:layout_width="48dp"
android:layout_height="30dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/saved_logins_copy_password"
app:tint="?android:colorAccent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/revealPasswordButton"
app:srcCompat="@drawable/ic_clear" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,159 @@
<?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/. -->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/loginDetailLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="73dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="20dp">
<TextView
android:id="@+id/webAddressHeader"
android:layout_width="0dp"
android:layout_height="24dp"
android:gravity="center_vertical"
android:letterSpacing="0.05"
android:paddingBottom="5dp"
android:text="@string/preferences_passwords_saved_logins_site"
android:textColor="?primaryText"
android:textSize="12sp"
app:fontFamily="@font/metropolis_semibold"
app:layout_constraintBottom_toTopOf="@id/webAddressText"
app:layout_constraintEnd_toStartOf="@id/copyWebAddress"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/webAddressText"
android:layout_width="0dp"
android:layout_height="30dp"
android:gravity="center_vertical"
android:textColor="?primaryText"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="@id/webAddressHeader"
app:layout_constraintStart_toStartOf="@id/webAddressHeader"
app:layout_constraintTop_toBottomOf="@id/webAddressHeader"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Info" />
<ImageButton
android:id="@+id/copyWebAddress"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/saved_login_copy_site"
app:layout_constraintBottom_toBottomOf="@id/webAddressText"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/webAddressText"
app:srcCompat="@drawable/ic_copy"
app:tint="?android:colorAccent" />
<TextView
android:id="@+id/usernameHeader"
android:layout_width="0dp"
android:layout_height="24dp"
android:layout_marginTop="16dp"
android:gravity="center_vertical"
android:letterSpacing="0.05"
android:text="@string/preferences_passwords_saved_logins_username"
android:textColor="?primaryText"
android:textSize="12sp"
app:fontFamily="@font/metropolis_semibold"
app:layout_constraintBottom_toTopOf="@id/usernameText"
app:layout_constraintEnd_toStartOf="@id/copyUsername"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/webAddressText"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/usernameText"
android:layout_width="0dp"
android:layout_height="30dp"
android:gravity="center_vertical"
android:textColor="?primaryText"
android:textSize="16sp"
android:layout_marginTop="1dp"
app:layout_constraintEnd_toEndOf="@id/usernameHeader"
app:layout_constraintStart_toStartOf="@id/usernameHeader"
app:layout_constraintTop_toBottomOf="@id/usernameHeader"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Info" />
<ImageButton
android:id="@+id/copyUsername"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/saved_login_copy_username"
app:layout_constraintBottom_toBottomOf="@id/usernameText"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/usernameText"
app:srcCompat="@drawable/ic_copy"
app:tint="?android:colorAccent" />
<TextView
android:id="@+id/passwordHeader"
android:layout_width="0dp"
android:layout_height="24dp"
android:gravity="center_vertical"
android:letterSpacing="0.05"
android:text="@string/preferences_passwords_saved_logins_password"
android:textColor="?primaryText"
android:textSize="12sp"
android:layout_marginTop="12dp"
app:fontFamily="@font/metropolis_semibold"
app:layout_constraintBottom_toTopOf="@id/passwordText"
app:layout_constraintEnd_toStartOf="@id/revealPasswordButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/usernameText"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/passwordText"
android:layout_width="0dp"
android:layout_height="30dp"
android:gravity="center_vertical"
android:inputType="textPassword|text"
android:letterSpacing="0.01"
android:lineSpacingExtra="8sp"
android:layout_marginTop="2dp"
android:textColor="?primaryText"
android:textSize="16sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@id/passwordHeader"
app:layout_constraintStart_toStartOf="@id/passwordHeader"
app:layout_constraintTop_toBottomOf="@id/passwordHeader"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Info" />
<ImageButton
android:id="@+id/revealPasswordButton"
android:layout_width="48dp"
android:layout_height="30dp"
android:layout_marginBottom="2dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/saved_login_reveal_password"
app:layout_constraintBottom_toBottomOf="@id/passwordText"
app:layout_constraintEnd_toStartOf="@id/copyPassword"
app:srcCompat="@drawable/mozac_ic_password_reveal"
app:tint="?android:colorAccent" />
<ImageButton
android:id="@+id/copyPassword"
android:layout_width="48dp"
android:layout_height="30dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/saved_logins_copy_password"
app:layout_constraintBottom_toBottomOf="@id/revealPasswordButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/revealPasswordButton"
app:srcCompat="@drawable/ic_copy"
app:tint="?android:colorAccent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,134 +0,0 @@
<?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/. -->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/siteLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="66dp"
android:layout_marginTop="12dp">
<TextView
android:id="@+id/siteHeaderText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/preferences_passwords_saved_logins_site"
android:textColor="?primaryText"
android:textSize="12sp"
app:layout_constraintBottom_toTopOf="@id/siteInfoText"
app:layout_constraintEnd_toStartOf="@id/copySiteItem"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/siteInfoText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="@id/siteHeaderText"
app:layout_constraintStart_toStartOf="@id/siteHeaderText"
app:layout_constraintTop_toBottomOf="@id/siteHeaderText"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Info" />
<ImageButton
android:id="@+id/copySiteItem"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/saved_login_copy_site"
app:layout_constraintBottom_toBottomOf="@id/siteInfoText"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/siteHeaderText"
app:srcCompat="@drawable/ic_copy" />
<TextView
android:id="@+id/usernameHeaderText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/preferences_passwords_saved_logins_username"
android:textColor="?primaryText"
android:textSize="12sp"
app:layout_constraintBottom_toTopOf="@id/usernameInfoText"
app:layout_constraintEnd_toStartOf="@id/copyUsernameItem"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/siteInfoText"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/usernameInfoText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="@id/usernameHeaderText"
app:layout_constraintStart_toStartOf="@id/usernameHeaderText"
app:layout_constraintTop_toBottomOf="@id/usernameHeaderText"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Info" />
<ImageButton
android:id="@+id/copyUsernameItem"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/saved_login_copy_username"
app:layout_constraintBottom_toBottomOf="@id/usernameInfoText"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/usernameHeaderText"
app:srcCompat="@drawable/ic_copy" />
<TextView
android:id="@+id/passwordHeaderText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/preferences_passwords_saved_logins_password"
android:textColor="?primaryText"
android:textSize="12sp"
app:layout_constraintBottom_toTopOf="@id/passwordInfoText"
app:layout_constraintEnd_toStartOf="@id/revealPasswordItem"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/usernameInfoText"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/passwordInfoText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:inputType="textPassword|text"
android:textSize="16sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@id/passwordHeaderText"
app:layout_constraintStart_toStartOf="@id/passwordHeaderText"
app:layout_constraintTop_toBottomOf="@id/passwordHeaderText"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Info" />
<ImageButton
android:id="@+id/revealPasswordItem"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="16dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/saved_login_reveal_password"
android:tint="?android:colorAccent"
app:layout_constraintBottom_toBottomOf="@id/passwordInfoText"
app:layout_constraintEnd_toStartOf="@id/copyPasswordItem"
app:layout_constraintTop_toTopOf="@id/passwordHeaderText"
app:srcCompat="@drawable/mozac_ic_password_reveal" />
<ImageButton
android:id="@+id/copyPasswordItem"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/saved_logins_copy_password"
app:layout_constraintBottom_toBottomOf="@id/revealPasswordItem"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/revealPasswordItem"
app:srcCompat="@drawable/ic_copy" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,7 +1,11 @@
<?xml version="1.0" encoding="utf-8"?><!-- This Source Code Form is subject to the terms of the Mozilla Public
<?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/. -->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
@ -25,7 +29,7 @@
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/domainView"
android:id="@+id/webAddressView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
@ -36,7 +40,7 @@
android:singleLine="true"
android:textAppearance="?android:attr/textAppearanceListItem"
android:textColor="?primaryText"
app:layout_constraintBottom_toTopOf="@id/userView"
app:layout_constraintBottom_toTopOf="@id/usernameView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/favicon_image"
app:layout_constraintTop_toTopOf="parent"
@ -44,7 +48,7 @@
tools:text="mozilla.org" />
<TextView
android:id="@+id/userView"
android:id="@+id/usernameView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
@ -57,7 +61,7 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/favicon_image"
app:layout_constraintTop_toBottomOf="@id/domainView"
app:layout_constraintTop_toBottomOf="@id/webAddressView"
app:layout_constraintVertical_chainStyle="packed"
tools:text="mozilla.org" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,9 +1,12 @@
<?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/. -->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/login_delete" >
<item
android:id="@+id/delete_login_button"
android:contentDescription="@string/login_menu_delete_button"
@ -11,4 +14,4 @@
android:title="@string/login_menu_delete_button"
app:iconTint="?primaryText"
app:showAsAction="ifRoom" />
</menu>
</menu>

View File

@ -0,0 +1,35 @@
<?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/. -->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/loginOptionsEditDelete"
android:contentDescription="@string/login_options_menu"
android:title="@string/login_options_menu"
app:showAsAction="never" >
<item
android:id="@+id/edit_login_button"
android:contentDescription="@string/login_menu_delete_button"
android:icon="@drawable/ic_edit"
android:minWidth="82dp"
android:padding="20dp"
android:paddingStart="20dp"
android:title="@string/login_menu_edit_button"
app:iconTint="?primaryText"
app:showAsAction="never" />
<item
android:id="@+id/delete_login_button"
android:contentDescription="@string/login_menu_delete_button"
android:icon="@drawable/ic_delete"
android:minWidth="82dp"
android:padding="20dp"
android:paddingStart="20dp"
android:title="@string/login_menu_delete_button"
app:iconTint="?primaryText"
app:showAsAction="never" />
</menu>

View File

@ -0,0 +1,14 @@
<?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/. -->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/login_save">
<item
android:id="@+id/save_login_button"
android:icon="@drawable/mozac_ic_check"
app:iconTint="?primaryText"
android:title="@string/save_changes_to_login"
app:showAsAction="always" />
</menu>

View File

@ -263,25 +263,25 @@
</fragment>
<fragment
android:id="@+id/loginsFragment"
android:name="org.mozilla.fenix.settings.LoginsFragment"
android:id="@+id/savedLoginsAuthFragment"
android:name="org.mozilla.fenix.settings.logins.SavedLoginsAuthFragment"
android:label="@string/preferences_passwords_logins_and_passwords">
<action
android:id="@+id/action_loginsFragment_to_savedLoginsFragment"
android:id="@+id/action_savedLoginsAuthFragment_to_loginsListFragment"
app:enterAnim="@anim/slide_in_right"
app:exitAnim="@anim/slide_out_left"
app:popEnterAnim="@anim/slide_in_left"
app:popExitAnim="@anim/slide_out_right"
app:destination="@id/savedLoginsFragment" />
<action
android:id="@+id/action_loginsFragment_to_turnOnSyncFragment"
android:id="@+id/action_savedLoginsAuthFragment_to_turnOnSyncFragment"
app:enterAnim="@anim/slide_in_right"
app:exitAnim="@anim/slide_out_left"
app:popEnterAnim="@anim/slide_in_left"
app:popExitAnim="@anim/slide_out_right"
app:destination="@id/turnOnSyncFragment" />
<action
android:id="@+id/action_loginsFragment_to_saveLoginSettingFragment"
android:id="@+id/action_savedLoginsAuthFragment_to_savedLoginsSettingFragment"
app:enterAnim="@anim/slide_in_right"
app:exitAnim="@anim/slide_out_left"
app:popEnterAnim="@anim/slide_in_left"
@ -289,6 +289,50 @@
app:destination="@id/saveLoginSettingFragment" />
</fragment>
<fragment
android:id="@+id/savedLoginsFragment"
android:name="org.mozilla.fenix.settings.logins.SavedLoginsFragment"
tools:layout="@layout/fragment_saved_logins">
<action
android:id="@+id/action_savedLoginsFragment_to_loginDetailFragment"
app:destination="@id/loginDetailFragment" />
<action
android:id="@+id/action_savedLoginsFragment_to_browserFragment"
app:destination="@id/browserFragment"
app:popUpTo="@id/settingsFragment"
app:popUpToInclusive="true" />
</fragment>
<fragment
android:id="@+id/loginDetailFragment"
android:name="org.mozilla.fenix.settings.logins.LoginDetailFragment"
tools:layout="@layout/fragment_login_detail">
<argument
android:name="savedLoginId"
app:argType="string"
app:nullable="false"/>
<action
android:id="@+id/action_loginDetailFragment_to_editLoginFragment"
app:destination="@id/editLoginFragment"
app:popUpTo="@id/editLoginFragment"
app:popUpToInclusive="true"/>
</fragment>
<fragment
android:id="@+id/editLoginFragment"
android:name="org.mozilla.fenix.settings.logins.EditLoginFragment"
android:label="@string/edit">
<argument
android:name="savedLoginItem"
app:argType="org.mozilla.fenix.settings.logins.SavedLogin"
app:nullable="false"/>
<action
android:id="@+id/action_editLoginFragment_to_loginDetailFragment"
app:destination="@id/loginDetailFragment"
app:popUpTo="@id/loginDetailFragment"
app:popUpToInclusive="true"/>
</fragment>
<fragment
android:id="@+id/settingsFragment"
android:name="org.mozilla.fenix.settings.SettingsFragment"
@ -309,12 +353,12 @@
app:popExitAnim="@anim/slide_out_right"
app:destination="@id/sitePermissionsFragment" />
<action
android:id="@+id/action_settingsFragment_to_loginsFragment"
android:id="@+id/action_settingsFragment_to_savedLoginsAuthFragment"
app:enterAnim="@anim/slide_in_right"
app:exitAnim="@anim/slide_out_left"
app:popEnterAnim="@anim/slide_in_left"
app:popExitAnim="@anim/slide_out_right"
app:destination="@id/loginsFragment" />
app:destination="@id/savedLoginsAuthFragment" />
<action
android:id="@+id/action_settingsFragment_to_accessibilityFragment"
app:enterAnim="@anim/slide_in_right"
@ -668,22 +712,6 @@
<fragment
android:id="@+id/defaultBrowserSettingsFragment"
android:name="org.mozilla.fenix.settings.DefaultBrowserSettingsFragment"/>
<fragment
android:id="@+id/savedLoginsFragment"
android:name="org.mozilla.fenix.settings.logins.SavedLoginsFragment"
tools:layout="@layout/fragment_saved_logins">
<action
android:id="@+id/action_savedLoginsFragment_to_savedLoginSiteInfoFragment"
app:destination="@id/savedLoginSiteInfoFragment" />
</fragment>
<fragment
android:id="@+id/savedLoginSiteInfoFragment"
android:name="org.mozilla.fenix.settings.logins.SavedLoginSiteInfoFragment"
tools:layout="@layout/fragment_saved_login_site_info">
<argument
android:name="savedLoginItem"
app:argType="org.mozilla.fenix.settings.logins.SavedLoginsItem" />
</fragment>
<fragment
android:id="@+id/addSearchEngineFragment"
android:name="org.mozilla.fenix.settings.search.AddSearchEngineFragment" />
@ -699,7 +727,7 @@
android:name="org.mozilla.fenix.settings.advanced.LocaleSettingsFragment" />
<fragment
android:id="@+id/saveLoginSettingFragment"
android:name="org.mozilla.fenix.settings.logins.SaveLoginSettingFragment"
android:name="org.mozilla.fenix.settings.logins.SavedLoginsSettingFragment"
android:label="SaveLoginSettingFragment" />
<fragment
android:id="@+id/addonsManagementFragment"

View File

@ -1336,8 +1336,26 @@
<string name="certificate_info_verified_by">Verified By: %1$s </string>
<!-- Login overflow menu delete button -->
<string name="login_menu_delete_button">Delete</string>
<!-- Login overflow menu edit button -->
<string name="login_menu_edit_button">Edit</string>
<!-- Message in delete confirmation dialog for logins -->
<string name="login_deletion_confirmation">Are you sure you want to delete this login?</string>
<!-- Positive action of a dialog asking to delete -->
<string name="dialog_delete_positive">Delete</string>
<!-- The saved login options menu description. -->
<string name="login_options_menu">Login options</string>
<!-- The editable text field for a login's web address. -->
<string name="saved_login_hostname_description">The editable text field for the web address of the login.</string>
<!-- The editable text field for a login's username. -->
<string name="saved_login_username_description">The editable text field for the username of the login.</string>
<!-- The editable text field for a login's password. -->
<string name="saved_login_password_description">The editable text field for the password of the login.</string>
<!-- The button description to save changes to an edited login. -->
<string name="save_changes_to_login">Save changes to login.</string>
<!-- The button description to discard changes to an edited login. -->
<string name="discard_changes">Discard changes</string>
<!-- The page title for editing a saved login. -->
<string name="edit">Edit</string>
<!-- The error message in edit login view when password field is blank. -->
<string name="saved_login_password_required">Password required</string>
</resources>

View File

@ -14,10 +14,10 @@ import org.mozilla.fenix.utils.Settings
@RunWith(FenixRobolectricTestRunner::class)
class SavedLoginsControllerTest {
private val store: SavedLoginsFragmentStore = mockk(relaxed = true)
private val store: LoginsFragmentStore = mockk(relaxed = true)
private val settings: Settings = mockk(relaxed = true)
private val sortingStrategy: SortingStrategy = SortingStrategy.Alphabetically(testContext)
private val controller = DefaultSavedLoginsController(store, settings)
private val controller = SavedLoginsController(store, settings)
@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`() {
@ -25,7 +25,7 @@ class SavedLoginsControllerTest {
verify {
store.dispatch(
SavedLoginsFragmentAction.SortLogins(
LoginsAction.SortLogins(
SortingStrategy.Alphabetically(
testContext
)

View File

@ -15,7 +15,7 @@ import kotlin.random.Random
@RunWith(FenixRobolectricTestRunner::class)
class SavedLoginsInteractorTest {
private val controller: SavedLoginsController = mockk(relaxed = true)
private val savedLoginClicked: (SavedLoginsItem) -> Unit = mockk(relaxed = true)
private val savedLoginClicked: (SavedLogin) -> Unit = mockk(relaxed = true)
private val learnMore: () -> Unit = mockk(relaxed = true)
private val interactor = SavedLoginsInteractor(
controller,
@ -25,7 +25,7 @@ class SavedLoginsInteractorTest {
@Test
fun itemClicked() {
val item = SavedLoginsItem("mozilla.org", "username", "password", "id", Random.nextLong())
val item = SavedLogin("mozilla.org", "username", "password", "id", Random.nextLong())
interactor.itemClicked(item)
verify {