/* 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.util.Log import androidx.navigation.NavController import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher 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.concept.storage.LoginEntry import mozilla.components.service.sync.logins.InvalidRecordException import mozilla.components.service.sync.logins.LoginsStorageException import mozilla.components.service.sync.logins.NoSuchRecordException import mozilla.components.service.sync.logins.SyncableLoginsStorage import org.mozilla.fenix.R 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.fragment.AddLoginFragmentDirections import org.mozilla.fenix.settings.logins.mapToSavedLogin /** * Controller for all saved logins interactions with the password storage component */ @Suppress("TooManyFunctions", "LargeClass") open class SavedLoginsStorageController( private val passwordsStorage: SyncableLoginsStorage, private val lifecycleScope: CoroutineScope, private val navController: NavController, private val loginsFragmentStore: LoginsFragmentStore, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO ) { fun delete(loginId: String) { var deleteLoginJob: Deferred? = null val deleteJob = lifecycleScope.launch(ioDispatcher) { deleteLoginJob = async { passwordsStorage.delete(loginId) } deleteLoginJob?.await() withContext(Dispatchers.Main) { navController.popBackStack(R.id.savedLoginsFragment, false) } } deleteJob.invokeOnCompletion { if (it is CancellationException) { deleteLoginJob?.cancel() } } } // Create a [LoginEntry] for the add login dialog private fun loginEntryForAdd(originText: String, usernameText: String, passwordText: String) = LoginEntry( origin = originText, username = usernameText, password = passwordText, // Implicitly fill in httpRealm with the origin httpRealm = originText ) fun add(originText: String, usernameText: String, passwordText: String) { var saveLoginJob: Deferred? = null lifecycleScope.launch(ioDispatcher) { saveLoginJob = async { add(loginEntryForAdd(originText, usernameText, passwordText)) } saveLoginJob?.await() withContext(Dispatchers.Main) { val directions = AddLoginFragmentDirections.actionAddLoginFragmentToSavedLoginsFragment() navController.navigate(directions) } } saveLoginJob?.invokeOnCompletion { if (it is CancellationException) { saveLoginJob?.cancel() } } } private suspend fun add(loginEntryToSave: LoginEntry) { try { val encryptedLogin = passwordsStorage.add(loginEntryToSave) syncAndUpdateList(passwordsStorage.decryptLogin(encryptedLogin)) } catch (loginException: LoginsStorageException) { Log.e( "Add new login", "Failed to add new login.", loginException ) } } // Create a [LoginEntry] for the edit login dialog private suspend fun loginEntryForSave(loginId: String, usernameText: String, passwordText: String): LoginEntry { // must retrieve from storage to get the httpsRealm and formActionOrigin val oldLogin = passwordsStorage.get(loginId)!! return LoginEntry( // Copied from the existing login origin = oldLogin.origin, httpRealm = oldLogin.httpRealm, formActionOrigin = oldLogin.formActionOrigin, // New values username = usernameText, password = passwordText, ) } fun save(loginId: String, usernameText: String, passwordText: String) { var saveLoginJob: Deferred? = null lifecycleScope.launch(ioDispatcher) { saveLoginJob = async { save(loginId, loginEntryForSave(loginId, usernameText, passwordText)) } 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(guid: String, loginEntryToSave: LoginEntry) { try { val encryptedLogin = passwordsStorage.update(guid, loginEntryToSave) syncAndUpdateList(passwordsStorage.decryptLogin(encryptedLogin)) } 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 findDuplicateForAdd(originText: String, usernameText: String, passwordText: String) { lifecycleScope.launch(ioDispatcher) { findDuplicate( loginEntryForAdd(originText, usernameText, passwordText) ) } } fun findDuplicateForSave(loginId: String, usernameText: String, passwordText: String) { lifecycleScope.launch(ioDispatcher) { findDuplicate( loginEntryForSave(loginId, usernameText, passwordText), loginId ) } } private suspend fun findDuplicate(entry: LoginEntry, currentGuid: String? = null) { // Ensure that we have a valid, non-blank password. The value doesn't // matter for dupe-checking and we want to make sure that // findLoginToUpdate() doesn't throw an error because the [LoginEntry] // is invalid val validEntry = if (entry.password.isNotEmpty()) entry else entry.copy(password = "password") var dupe = try { passwordsStorage.findLoginToUpdate(validEntry)?.mapToSavedLogin() } catch (e: LoginsStorageException) { // If the entry was invalid, then consider it not a dupe null } if (dupe != null && dupe.guid == currentGuid) { // If the found login matches the current login, don't consider it a dupe dupe = null } loginsFragmentStore.dispatch(LoginsAction.DuplicateLogin(dupe)) } fun fetchLoginDetails(loginId: String) = lifecycleScope.launch(ioDispatcher) { val fetchedLogin = passwordsStorage.get(loginId) withContext(Dispatchers.Main) { if (fetchedLogin != null) { loginsFragmentStore.dispatch( LoginsAction.UpdateCurrentLogin( fetchedLogin.mapToSavedLogin() ) ) } else { navController.popBackStack() } } } fun handleLoadAndMapLogins() { // Don't touch the store if we already have the logins loaded. // This has a slight downside of possibly being out of date with the storage if, say, Sync // ran in the meantime, but that's fairly unlikely and the speedy UI is worth it. if (loginsFragmentStore.state.loginList.isNotEmpty()) { lifecycleScope.launch(Dispatchers.Main) { loginsFragmentStore.dispatch(LoginsAction.LoginsListUpToDate) } return } var deferredLogins: Deferred>? = null val fetchLoginsJob = lifecycleScope.launch(ioDispatcher) { deferredLogins = async { 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() } } } }