You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
266 lines
9.7 KiB
Kotlin
266 lines
9.7 KiB
Kotlin
/* 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()
|
|
}
|
|
}
|
|
}
|
|
}
|