diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/controller/SavedLoginsStorageController.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/controller/SavedLoginsStorageController.kt index d1cd8de3a1..3877acdcd9 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/controller/SavedLoginsStorageController.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/controller/SavedLoginsStorageController.kt @@ -26,6 +26,7 @@ import org.mozilla.fenix.settings.logins.LoginsFragmentStore import org.mozilla.fenix.settings.logins.fragment.AddLoginFragmentDirections import org.mozilla.fenix.settings.logins.fragment.EditLoginFragmentDirections import org.mozilla.fenix.settings.logins.mapToSavedLogin +import org.mozilla.fenix.utils.ClipboardHandler /** * Controller for all saved logins interactions with the password storage component @@ -37,6 +38,7 @@ open class SavedLoginsStorageController( private val navController: NavController, private val loginsFragmentStore: LoginsFragmentStore, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val clipboardHandler: ClipboardHandler, ) { fun delete(loginId: String) { @@ -252,4 +254,22 @@ open class SavedLoginsStorageController( } } } + + /** + * Copy login username to clipboard + * @param loginId id of the login entry to copy username from + */ + fun copyUsername(loginId: String) = lifecycleScope.launch { + val login = passwordsStorage.get(loginId) + clipboardHandler.text = login?.username + } + + /** + * Copy login password to clipboard + * @param loginId id of the login entry to copy password from + */ + fun copyPassword(loginId: String) = lifecycleScope.launch { + val login = passwordsStorage.get(loginId) + clipboardHandler.sensitiveText = login?.password + } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/AddLoginFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/AddLoginFragment.kt index af63477a63..5b32ede8e0 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/AddLoginFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/AddLoginFragment.kt @@ -75,6 +75,7 @@ class AddLoginFragment : Fragment(R.layout.fragment_add_login), MenuProvider { lifecycleScope = lifecycleScope, navController = findNavController(), loginsFragmentStore = loginsFragmentStore, + clipboardHandler = requireContext().components.clipboardHandler, ), ) diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt index 6f6d455375..c0b6b44d3b 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt @@ -82,6 +82,7 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login), MenuProvider { lifecycleScope = lifecycleScope, navController = findNavController(), loginsFragmentStore = loginsFragmentStore, + clipboardHandler = requireContext().components.clipboardHandler, ), ) diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/LoginDetailFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/LoginDetailFragment.kt index 5d2245c876..1810ae7d7a 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/LoginDetailFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/LoginDetailFragment.kt @@ -5,6 +5,7 @@ package org.mozilla.fenix.settings.logins.fragment import android.content.DialogInterface +import android.os.Build import android.os.Bundle import android.text.InputType import android.view.LayoutInflater @@ -13,7 +14,6 @@ import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup -import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.core.view.MenuProvider import androidx.lifecycle.Lifecycle @@ -89,8 +89,10 @@ class LoginDetailFragment : SecureFragment(R.layout.fragment_login_detail), Menu lifecycleScope = lifecycleScope, navController = findNavController(), loginsFragmentStore = savedLoginsStore, + clipboardHandler = requireContext().components.clipboardHandler, ), ) + interactor.onFetchLoginList(args.savedLoginId) consumeFrom(savedLoginsStore) { @@ -145,14 +147,18 @@ class LoginDetailFragment : SecureFragment(R.layout.fragment_login_detail), Menu } binding.usernameText.text = login?.username - binding.copyUsername.setOnClickListener( - CopyButtonListener(login?.username, R.string.logins_username_copied), - ) + binding.copyUsername.setOnClickListener { + interactor.onCopyUsername(args.savedLoginId) + showCopiedSnackbar(view = it, copiedItem = it.context.getString(R.string.logins_username_copied)) + Logins.copyLogin.record(NoExtras()) + } binding.passwordText.text = login?.password - binding.copyPassword.setOnClickListener( - CopyButtonListener(login?.password, R.string.logins_password_copied), - ) + binding.copyPassword.setOnClickListener { + interactor.onCopyPassword(args.savedLoginId) + showCopiedSnackbar(view = it, copiedItem = it.context.getString(R.string.logins_password_copied)) + Logins.copyLogin.record(NoExtras()) + } } override fun onCreateMenu(menu: Menu, inflater: MenuInflater) { @@ -172,6 +178,17 @@ class LoginDetailFragment : SecureFragment(R.layout.fragment_login_detail), Menu else -> false } + private fun showCopiedSnackbar(view: View, copiedItem: String) { + // Only show a toast for Android 12 and lower. + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + FenixSnackbar.make( + view, + duration = Snackbar.LENGTH_SHORT, + isDisplayedWithBrowserToolbar = false, + ).setText(copiedItem).show() + } + } + private fun navigateToBrowser(address: String) { (activity as HomeActivity).openToBrowserAndLoad( address, @@ -206,33 +223,6 @@ class LoginDetailFragment : SecureFragment(R.layout.fragment_login_detail), Menu } } - /** - * Click listener for a textview's copy button. - * @param value Value to be copied - * @param snackbarText Text to display in snackbar after copying. - */ - private inner class CopyButtonListener( - private val value: String?, - @StringRes private val snackbarText: Int, - ) : View.OnClickListener { - override fun onClick(view: View) { - val clipboard = view.context.components.clipboardHandler - clipboard.text = value - showCopiedSnackbar(view.context.getString(snackbarText)) - Logins.copyLogin.record(NoExtras()) - } - - private fun showCopiedSnackbar(copiedItem: String) { - view?.let { - FenixSnackbar.make( - view = it, - duration = Snackbar.LENGTH_SHORT, - isDisplayedWithBrowserToolbar = false, - ).setText(copiedItem).show() - } - } - } - override fun onDestroyView() { super.onDestroyView() _binding = null diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt index 2d02903a98..4e9b7d9139 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt @@ -88,6 +88,7 @@ class SavedLoginsFragment : SecureFragment(), MenuProvider { lifecycleScope = viewLifecycleOwner.lifecycleScope, navController = findNavController(), loginsFragmentStore = savedLoginsStore, + clipboardHandler = requireContext().components.clipboardHandler, ) savedLoginsInteractor = diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/LoginDetailInteractor.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/LoginDetailInteractor.kt index 2f23eb52e7..bd0149e448 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/LoginDetailInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/LoginDetailInteractor.kt @@ -21,4 +21,20 @@ class LoginDetailInteractor( fun onDeleteLogin(loginId: String) { savedLoginsController.delete(loginId) } + + /** + * for the copy username button + * @param loginId id of the login entry to copy username from + */ + fun onCopyUsername(loginId: String) { + savedLoginsController.copyUsername(loginId) + } + + /** + * for the copy password button + * @param loginId id of the login entry to copy password from + */ + fun onCopyPassword(loginId: String) { + savedLoginsController.copyPassword(loginId) + } } diff --git a/app/src/main/java/org/mozilla/fenix/utils/ClipboardHandler.kt b/app/src/main/java/org/mozilla/fenix/utils/ClipboardHandler.kt index 17ec1771ee..eb61dd8fd5 100644 --- a/app/src/main/java/org/mozilla/fenix/utils/ClipboardHandler.kt +++ b/app/src/main/java/org/mozilla/fenix/utils/ClipboardHandler.kt @@ -8,6 +8,7 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.os.Build +import android.os.PersistableBundle import android.view.textclassifier.TextClassifier import androidx.annotation.VisibleForTesting import androidx.core.content.getSystemService @@ -47,7 +48,39 @@ class ClipboardHandler(val context: Context) { return null } set(value) { - clipboard.setPrimaryClip(ClipData.newPlainText("Text", value)) + val clipData = ClipData.newPlainText("Text", value) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + clipData.apply { + description.extras = PersistableBundle().apply { + putBoolean("android.content.extra.IS_SENSITIVE", false) + } + } + } + clipboard.setPrimaryClip(clipData) + } + + /** + * Provides access to the sensitive content of the clipboard, be aware this is a sensitive + * API as from Android 12 and above, accessing it will trigger a notification letting the user + * know the app has accessed the clipboard, make sure when you call this API that users are + * completely aware that we are accessing the clipboard. + * See for more details https://github.com/mozilla-mobile/fenix/issues/22271. + * + */ + var sensitiveText: String? + get() { + return text + } + set(value) { + val clipData = ClipData.newPlainText("Text", value) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + clipData.apply { + description.extras = PersistableBundle().apply { + putBoolean("android.content.extra.IS_SENSITIVE", true) + } + } + } + clipboard.setPrimaryClip(clipData) } /** diff --git a/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsStorageControllerTest.kt b/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsStorageControllerTest.kt index 3b48e0812e..25a74d6ece 100644 --- a/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsStorageControllerTest.kt +++ b/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsStorageControllerTest.kt @@ -26,6 +26,7 @@ import org.mozilla.fenix.ext.directionsEq import org.mozilla.fenix.helpers.FenixRobolectricTestRunner import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController import org.mozilla.fenix.settings.logins.fragment.EditLoginFragmentDirections +import org.mozilla.fenix.utils.ClipboardHandler @RunWith(FenixRobolectricTestRunner::class) class SavedLoginsStorageControllerTest { @@ -38,6 +39,7 @@ class SavedLoginsStorageControllerTest { private lateinit var controller: SavedLoginsStorageController private val navController: NavController = mockk(relaxed = true) private val loginsFragmentStore: LoginsFragmentStore = mockk(relaxed = true) + private val clipboardHandler: ClipboardHandler = mockk(relaxed = true) private val loginMock: Login = mockk(relaxed = true) @Before @@ -54,6 +56,7 @@ class SavedLoginsStorageControllerTest { navController = navController, loginsFragmentStore = loginsFragmentStore, ioDispatcher = ioDispatcher, + clipboardHandler = clipboardHandler, ) }