* 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
This commit is contained in:
parent
4dd0c0f224
commit
023a4983fa
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) }
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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,
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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>
|
|
@ -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"
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
}
|
|
@ -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) }
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
|
@ -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()
|
||||
|
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 |
|
||||
|
|
Loading…
Reference in New Issue