diff --git a/app/build.gradle b/app/build.gradle index 1d7c96619..35ab82695 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -384,6 +384,7 @@ dependencies { implementation Deps.mozilla_feature_sendtab implementation Deps.mozilla_feature_webcompat + implementation Deps.mozilla_service_sync_logins implementation Deps.mozilla_service_firefox_accounts implementation Deps.mozilla_service_fretboard implementation Deps.mozilla_service_glean diff --git a/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt b/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt index c53ee89a2..84bd81d25 100644 --- a/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt +++ b/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt @@ -56,4 +56,9 @@ object FeatureFlags { val progressiveWebApps = nightly or debug val forceZoomPreference = nightly or debug + + /** + * Gives option in Settings to see logins and sync logins + */ + const val logins = false } diff --git a/app/src/main/java/org/mozilla/fenix/components/BackgroundServices.kt b/app/src/main/java/org/mozilla/fenix/components/BackgroundServices.kt index b3d610196..cede0b2f8 100644 --- a/app/src/main/java/org/mozilla/fenix/components/BackgroundServices.kt +++ b/app/src/main/java/org/mozilla/fenix/components/BackgroundServices.kt @@ -85,6 +85,7 @@ class BackgroundServices( val syncConfig = if (context.isInExperiment(Experiments.asFeatureSyncDisabled)) { null } else { + // TODO Add Passwords Here Waiting On https://github.com/mozilla-mobile/android-components/issues/4741 SyncConfig(setOf(SyncEngine.History, SyncEngine.Bookmarks), syncPeriodInMinutes = 240L) // four hours } @@ -93,9 +94,11 @@ class BackgroundServices( val push by lazy { makePushConfig()?.let { makePush(it) } } init { - // Make the "history" and "bookmark" stores accessible to workers spawned by the sync manager. + // Make the "history", "bookmark", and "logins" stores accessible to workers spawned by the sync manager. GlobalSyncableStoreProvider.configureStore(SyncEngine.History to historyStorage) GlobalSyncableStoreProvider.configureStore(SyncEngine.Bookmarks to bookmarkStorage) + // TODO Add Passwords Here Waiting On https://github.com/mozilla-mobile/android-components/issues/4741 + // GlobalSyncableStoreProvider.configureStore(SyncEngine.Passwords to loginsStorage) } private val deviceEventObserver = object : DeviceEventsObserver { diff --git a/app/src/main/java/org/mozilla/fenix/components/Core.kt b/app/src/main/java/org/mozilla/fenix/components/Core.kt index 2a6da4051..020ea6ff9 100644 --- a/app/src/main/java/org/mozilla/fenix/components/Core.kt +++ b/app/src/main/java/org/mozilla/fenix/components/Core.kt @@ -7,6 +7,7 @@ package org.mozilla.fenix.components import GeckoProvider import android.content.Context import android.content.res.Configuration +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -31,11 +32,14 @@ import mozilla.components.feature.media.RecordingDevicesNotificationFeature import mozilla.components.feature.media.state.MediaStateMachine import mozilla.components.feature.session.HistoryDelegate import mozilla.components.feature.webcompat.WebCompatFeature +import mozilla.components.service.sync.logins.AsyncLoginsStorageAdapter +import mozilla.components.service.sync.logins.SyncableLoginsStore import org.mozilla.fenix.AppRequestInterceptor import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.settings import org.mozilla.fenix.test.Mockable +import java.io.File import java.util.concurrent.TimeUnit /** @@ -152,6 +156,19 @@ class Core(private val context: Context) { val permissionStorage by lazy { PermissionStorage(context) } + val loginsStorage by lazy { + SyncableLoginsStore( + AsyncLoginsStorageAdapter.forDatabase( + File( + context.filesDir, + "logins.sqlite" + ).canonicalPath + ) + ) { + CompletableDeferred("very-insecure-key") + } + } + /** * Constructs a [TrackingProtectionPolicy] based on current preferences. * diff --git a/app/src/main/java/org/mozilla/fenix/logins/SavedLoginSiteInfoFragment.kt b/app/src/main/java/org/mozilla/fenix/logins/SavedLoginSiteInfoFragment.kt new file mode 100644 index 000000000..be3a2735a --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/logins/SavedLoginSiteInfoFragment.kt @@ -0,0 +1,85 @@ +/* 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.logins + +import android.os.Bundle +import android.text.InputType +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import com.google.android.material.snackbar.Snackbar +import kotlinx.android.synthetic.main.fragment_saved_login_site_info.* +import org.mozilla.fenix.R +import org.mozilla.fenix.components.FenixSnackbar +import org.mozilla.fenix.ext.components + +class SavedLoginSiteInfoFragment : Fragment(R.layout.fragment_saved_login_site_info) { + private val safeArguments get() = requireNotNull(arguments) + + private val savedLoginItem: SavedLoginsItem by lazy { + SavedLoginSiteInfoFragmentArgs.fromBundle( + safeArguments + ).savedLoginItem + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + siteInfoText.text = savedLoginItem.url + copySiteItem.setOnClickListener { + val clipboard = view.context.components.clipboardHandler + clipboard.text = savedLoginItem.url + showCopiedSnackbar(getString(R.string.logins_site_copied)) + } + + usernameInfoText.text = savedLoginItem.userName + copyUsernameItem.setOnClickListener { + val clipboard = view.context.components.clipboardHandler + clipboard.text = savedLoginItem.userName + showCopiedSnackbar(getString(R.string.logins_username_copied)) + } + + passwordInfoText.inputType = + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + passwordInfoText.text = savedLoginItem.password + revealPasswordItem.setOnClickListener { + togglePasswordReveal() + } + copyPasswordItem.setOnClickListener { + val clipboard = view.context.components.clipboardHandler + clipboard.text = savedLoginItem.password + showCopiedSnackbar(getString(R.string.logins_password_copied)) + } + } + + private fun showCopiedSnackbar(copiedItem: String) { + view?.let { + FenixSnackbar.make(it, Snackbar.LENGTH_SHORT).setText(copiedItem).show() + } + } + + private fun togglePasswordReveal() { + if (passwordInfoText.inputType == InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT) { + revealPasswordItem.setImageDrawable(context?.getDrawable(R.drawable.ic_password_hide)) + passwordInfoText.inputType = InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD + revealPasswordItem.contentDescription = + context?.getString(R.string.saved_login_hide_password) + } else { + revealPasswordItem.setImageDrawable(context?.getDrawable(R.drawable.ic_password_reveal)) + passwordInfoText.inputType = + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + revealPasswordItem.contentDescription = + context?.getString(R.string.saved_login_reveal_password) + } + // For the new type to take effect you need to reset the text + passwordInfoText.text = savedLoginItem.password + } + + override fun onResume() { + super.onResume() + activity?.title = savedLoginItem.url + (activity as AppCompatActivity).supportActionBar?.show() + } +} diff --git a/app/src/main/java/org/mozilla/fenix/logins/SavedLoginsAdapter.kt b/app/src/main/java/org/mozilla/fenix/logins/SavedLoginsAdapter.kt new file mode 100644 index 000000000..a1548010a --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/logins/SavedLoginsAdapter.kt @@ -0,0 +1,56 @@ +/* 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.logins + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView + +private sealed class AdapterItem { + data class Item(val item: SavedLoginsItem) : AdapterItem() +} + +private class SavedLoginsList(savedLogins: List) { + val items: List = savedLogins.map { AdapterItem.Item(it) } +} + +class SavedLoginsAdapter( + private val interactor: SavedLoginsInteractor +) : RecyclerView.Adapter() { + private var savedLoginsList: SavedLoginsList = SavedLoginsList(emptyList()) + + fun updateData(items: List) { + this.savedLoginsList = SavedLoginsList(items) + notifyDataSetChanged() + } + + override fun getItemCount(): Int = savedLoginsList.items.size + + override fun getItemViewType(position: Int): Int { + return when (savedLoginsList.items[position]) { + is AdapterItem.Item -> SavedLoginsListItemViewHolder.LAYOUT_ID + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false) + + return when (viewType) { + SavedLoginsListItemViewHolder.LAYOUT_ID -> SavedLoginsListItemViewHolder( + view, + interactor + ) + else -> throw IllegalStateException() + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is SavedLoginsListItemViewHolder -> (savedLoginsList.items[position] as AdapterItem.Item).also { + holder.bind(it.item) + } + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/logins/SavedLoginsFragment.kt b/app/src/main/java/org/mozilla/fenix/logins/SavedLoginsFragment.kt new file mode 100644 index 000000000..ade3b4b2f --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/logins/SavedLoginsFragment.kt @@ -0,0 +1,90 @@ +/* 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.logins + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +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.Dispatchers +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.ObsoleteCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import mozilla.components.lib.state.ext.consumeFrom +import org.mozilla.fenix.R +import org.mozilla.fenix.components.StoreProvider +import org.mozilla.fenix.ext.components + +class SavedLoginsFragment : Fragment() { + private lateinit var savedLoginsStore: SavedLoginsFragmentStore + private lateinit var savedLoginsView: SavedLoginsView + private lateinit var savedLoginsInteractor: SavedLoginsInteractor + + override fun onResume() { + super.onResume() + activity?.title = getString(R.string.preferences_passwords_saved_logins) + (activity as AppCompatActivity).supportActionBar?.show() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val view = inflater.inflate(R.layout.fragment_saved_logins, container, false) + savedLoginsStore = StoreProvider.get(this) { + SavedLoginsFragmentStore( + SavedLoginsFragmentState( + items = listOf() + ) + ) + } + savedLoginsInteractor = SavedLoginsInteractor(::itemClicked) + savedLoginsView = SavedLoginsView(view.savedLoginsLayout, savedLoginsInteractor) + loadAndMapLogins() + return view + } + + @ObsoleteCoroutinesApi + @ExperimentalCoroutinesApi + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + consumeFrom(savedLoginsStore) { + savedLoginsView.update(it) + } + } + + private fun itemClicked(item: SavedLoginsItem) { + val directions = + SavedLoginsFragmentDirections.actionSavedLoginsFragmentToSavedLoginSiteInfoFragment(item) + findNavController().navigate(directions) + } + + private fun loadAndMapLogins() { + lifecycleScope.launch(IO) { + val syncedLogins = async { + context!!.components.core.loginsStorage.withUnlocked { + it.list().await().map { item -> + SavedLoginsItem( + item.hostname, + item.username, + item.password + ) + } + } + }.await() + launch(Dispatchers.Main) { + savedLoginsStore.dispatch(SavedLoginsFragmentAction.UpdateLogins(syncedLogins)) + } + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/logins/SavedLoginsFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/logins/SavedLoginsFragmentStore.kt new file mode 100644 index 000000000..9ff517dc0 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/logins/SavedLoginsFragmentStore.kt @@ -0,0 +1,55 @@ +/* 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.logins + +import android.os.Parcelable +import kotlinx.android.parcel.Parcelize +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store + +/** + * Class representing an saved logins item + * @property url Site of the saved login + * @property userName Username that's saved for this site + * @property password Password that's saved for this site + */ +@Parcelize +data class SavedLoginsItem(val url: String, val userName: String?, val password: String?) : + Parcelable + +/** + * The [Store] for holding the [SavedLoginsFragmentState] and applying [SavedLoginsFragmentAction]s. + */ +class SavedLoginsFragmentStore(initialState: SavedLoginsFragmentState) : + Store( + initialState, + ::savedLoginsStateReducer + ) + +/** + * Actions to dispatch through the `SavedLoginsStore` to modify `SavedLoginsFragmentState` through the reducer. + */ +sealed class SavedLoginsFragmentAction : Action { + data class UpdateLogins(val list: List) : SavedLoginsFragmentAction() +} + +/** + * The state for the Saved Logins Screen + * @property items List of logins to display + */ +data class SavedLoginsFragmentState(val items: List) : State + +/** + * The SavedLoginsState Reducer. + */ +private fun savedLoginsStateReducer( + state: SavedLoginsFragmentState, + action: SavedLoginsFragmentAction +): SavedLoginsFragmentState { + return when (action) { + is SavedLoginsFragmentAction.UpdateLogins -> state.copy(items = action.list) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/logins/SavedLoginsInteractor.kt b/app/src/main/java/org/mozilla/fenix/logins/SavedLoginsInteractor.kt new file mode 100644 index 000000000..09f773615 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/logins/SavedLoginsInteractor.kt @@ -0,0 +1,17 @@ +/* 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.logins + +/** + * Interactor for the saved logins screen + * Provides implementations for the SavedLoginsViewInteractor + */ +class SavedLoginsInteractor( + private val itemClicked: (SavedLoginsItem) -> Unit +) : SavedLoginsViewInteractor { + override fun itemClicked(item: SavedLoginsItem) { + itemClicked.invoke(item) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/logins/SavedLoginsListItemViewHolder.kt b/app/src/main/java/org/mozilla/fenix/logins/SavedLoginsListItemViewHolder.kt new file mode 100644 index 000000000..e4ac8e1a9 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/logins/SavedLoginsListItemViewHolder.kt @@ -0,0 +1,42 @@ +/* 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.logins + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import kotlinx.android.synthetic.main.logins_item.view.* +import org.mozilla.fenix.R +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.loadIntoView + +class SavedLoginsListItemViewHolder( + private val view: View, + private val interactor: SavedLoginsInteractor +) : RecyclerView.ViewHolder(view) { + + private val favicon = view.favicon_image + private val url = view.domainView + private val userName = view.userView + + private var item: SavedLoginsItem? = null + + fun bind(item: SavedLoginsItem) { + this.item = item + url.text = item.url + userName.text = item.userName + updateFavIcon(item.url) + view.setOnClickListener { + interactor.itemClicked(item) + } + } + + private fun updateFavIcon(url: String) { + favicon.context.components.core.icons.loadIntoView(favicon, url) + } + + companion object { + const val LAYOUT_ID = R.layout.logins_item + } +} diff --git a/app/src/main/java/org/mozilla/fenix/logins/SavedLoginsView.kt b/app/src/main/java/org/mozilla/fenix/logins/SavedLoginsView.kt new file mode 100644 index 000000000..4e886f2e7 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/logins/SavedLoginsView.kt @@ -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.logins + +import android.view.LayoutInflater +import android.view.View +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 + +/** + * Interface for the SavedLoginsViewInteractor. This interface is implemented by objects that want + * to respond to user interaction on the SavedLoginsView + */ +interface SavedLoginsViewInteractor { + /** + * Called whenever one item is clicked + */ + fun itemClicked(item: SavedLoginsItem) +} + +/** + * View that contains and configures the Saved Logins List + */ +class SavedLoginsView( + private val container: ViewGroup, + val interactor: SavedLoginsInteractor +) : LayoutContainer { + + val view: FrameLayout = LayoutInflater.from(container.context) + .inflate(R.layout.component_saved_logins, container, true) + .findViewById(R.id.saved_logins_wrapper) + + override val containerView: View? + get() = container + + init { + view.saved_logins_list.apply { + adapter = SavedLoginsAdapter(interactor) + layoutManager = LinearLayoutManager(container.context) + } + } + + fun update(state: SavedLoginsFragmentState) { + view.saved_logins_list.isVisible = state.items.isNotEmpty() + (view.saved_logins_list.adapter as SavedLoginsAdapter).updateData(state.items) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/LoginsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/LoginsFragment.kt new file mode 100644 index 000000000..5e44aedfd --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/LoginsFragment.kt @@ -0,0 +1,114 @@ +/* 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 + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.navigation.fragment.findNavController +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import mozilla.components.concept.sync.AccountObserver +import mozilla.components.concept.sync.AuthType +import mozilla.components.concept.sync.OAuthAccount +import mozilla.components.service.fxa.SyncEngine +import mozilla.components.service.fxa.manager.SyncEnginesStorage +import org.mozilla.fenix.R +import org.mozilla.fenix.ext.getPreferenceKey +import org.mozilla.fenix.ext.requireComponents + +@Suppress("TooManyFunctions") +class LoginsFragment : PreferenceFragmentCompat(), AccountObserver { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.logins_preferences, rootKey) + } + + override fun onResume() { + super.onResume() + activity?.title = getString(R.string.preferences_passwords_logins_and_passwords) + (activity as AppCompatActivity).supportActionBar?.show() + + val savedLoginsKey = getPreferenceKey(R.string.pref_key_saved_logins) + findPreference(savedLoginsKey)?.setOnPreferenceClickListener { + navigateToLoginsSettingsFragment() + true + } + + val accountManager = requireComponents.backgroundServices.accountManager + accountManager.register(this, owner = this) + + val accountExists = accountManager.authenticatedAccount() != null + val needsReauth = accountManager.accountNeedsReauth() + when { + needsReauth -> updateSyncPreferenceNeedsReauth() + accountExists -> updateSyncPreferenceStatus() + !accountExists -> updateSyncPreferenceNeedsLogin() + } + } + + override fun onAuthenticated(account: OAuthAccount, authType: AuthType) = + updateSyncPreferenceStatus() + + override fun onLoggedOut() = updateSyncPreferenceNeedsLogin() + + override fun onAuthenticationProblems() = updateSyncPreferenceNeedsReauth() + + private fun updateSyncPreferenceStatus() { + val syncLogins = getPreferenceKey(R.string.pref_key_password_sync_logins) + findPreference(syncLogins)?.apply { + val syncEnginesStatus = SyncEnginesStorage(context!!).getStatus() + val loginsSyncStatus = syncEnginesStatus.getOrElse(SyncEngine.Passwords) { false } + summary = getString( + if (loginsSyncStatus) R.string.preferences_passwords_sync_logins_on + else R.string.preferences_passwords_sync_logins_off + ) + setOnPreferenceClickListener { + navigateToAccountSettingsFragment() + true + } + } + } + + private fun updateSyncPreferenceNeedsLogin() { + val syncLogins = getPreferenceKey(R.string.pref_key_password_sync_logins) + findPreference(syncLogins)?.apply { + summary = getString(R.string.preferences_passwords_sync_logins_sign_in) + setOnPreferenceClickListener { + navigateToTurnOnSyncFragment() + true + } + } + } + + private fun updateSyncPreferenceNeedsReauth() { + val syncLogins = getPreferenceKey(R.string.pref_key_password_sync_logins) + findPreference(syncLogins)?.apply { + summary = getString(R.string.preferences_passwords_sync_logins_reconnect) + setOnPreferenceClickListener { + navigateToAccountProblemFragment() + true + } + } + } + + private fun navigateToLoginsSettingsFragment() { + val directions = LoginsFragmentDirections.actionLoginsFragmentToSavedLoginsFragment() + findNavController().navigate(directions) + } + + private fun navigateToAccountSettingsFragment() { + val directions = LoginsFragmentDirections.actionLoginsFragmentToAccountSettingsFragment() + findNavController().navigate(directions) + } + + private fun navigateToAccountProblemFragment() { + val directions = LoginsFragmentDirections.actionLoginsFragmentToAccountProblemFragment() + findNavController().navigate(directions) + } + + private fun navigateToTurnOnSyncFragment() { + val directions = LoginsFragmentDirections.actionLoginsFragmentToTurnOnSyncFragment() + findNavController().navigate(directions) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt index 85b7308bb..92d7d107f 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt @@ -25,6 +25,7 @@ import mozilla.components.concept.sync.OAuthAccount import mozilla.components.concept.sync.Profile import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.Config +import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.FenixApplication import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R @@ -41,6 +42,7 @@ import org.mozilla.fenix.R.string.pref_key_help import org.mozilla.fenix.R.string.pref_key_language import org.mozilla.fenix.R.string.pref_key_leakcanary import org.mozilla.fenix.R.string.pref_key_make_default_browser +import org.mozilla.fenix.R.string.pref_key_passwords import org.mozilla.fenix.R.string.pref_key_privacy_link import org.mozilla.fenix.R.string.pref_key_rate import org.mozilla.fenix.R.string.pref_key_remote_debugging @@ -105,7 +107,7 @@ class SettingsFragment : PreferenceFragmentCompat(), AccountObserver { (activity as AppCompatActivity).supportActionBar?.show() val trackingProtectionPreference = - findPreference(getPreferenceKey(R.string.pref_key_tracking_protection_settings)) + findPreference(getPreferenceKey(pref_key_tracking_protection_settings)) trackingProtectionPreference?.summary = context?.let { if (it.settings().shouldUseTrackingProtection) { getString(R.string.tracking_protection_on) @@ -115,7 +117,7 @@ class SettingsFragment : PreferenceFragmentCompat(), AccountObserver { } val themesPreference = - findPreference(getPreferenceKey(R.string.pref_key_theme)) + findPreference(getPreferenceKey(pref_key_theme)) themesPreference?.summary = context?.settings()?.themeSettingString val aboutPreference = findPreference(getPreferenceKey(R.string.pref_key_about)) @@ -123,7 +125,11 @@ class SettingsFragment : PreferenceFragmentCompat(), AccountObserver { aboutPreference?.title = getString(R.string.preferences_about, appName) val deleteBrowsingDataPreference = - findPreference(getPreferenceKey(R.string.pref_key_delete_browsing_data_on_quit_preference)) + findPreference( + getPreferenceKey( + pref_key_delete_browsing_data_on_quit_preference + ) + ) deleteBrowsingDataPreference?.summary = context?.let { if (it.settings().shouldDeleteBrowsingDataOnQuit) { getString(R.string.delete_browsing_data_quit_on) @@ -132,13 +138,21 @@ class SettingsFragment : PreferenceFragmentCompat(), AccountObserver { } } - findPreference(getPreferenceKey(R.string.pref_key_add_private_browsing_shortcut))?.apply { + findPreference(getPreferenceKey(pref_key_add_private_browsing_shortcut))?.apply { isVisible = !PrivateShortcutCreateManager.doesPrivateBrowsingPinnedShortcutExist(context) } setupPreferences() updateAccountUIState(context!!, requireComponents.backgroundServices.accountManager.accountProfile()) + + updatePreferenceVisibilityForFeatureFlags() + } + + private fun updatePreferenceVisibilityForFeatureFlags() { + findPreference(getPreferenceKey(pref_key_passwords))?.apply { + isVisible = FeatureFlags.logins + } } @Suppress("ComplexMethod", "LongMethod") @@ -191,6 +205,9 @@ class SettingsFragment : PreferenceFragmentCompat(), AccountObserver { ) } } + resources.getString(pref_key_passwords) -> { + navigateToLoginsSettingsFragment() + } resources.getString(pref_key_about) -> { navigateToAbout() } @@ -255,13 +272,18 @@ class SettingsFragment : PreferenceFragmentCompat(), AccountObserver { } preferenceRemoteDebugging?.setOnPreferenceChangeListener { preference, newValue -> - preference.context.settings().preferences.edit() + preference.context.settings().preferences.edit() .putBoolean(preference.key, newValue as Boolean).apply() requireComponents.core.engine.settings.remoteDebuggingEnabled = newValue true } } + private fun navigateToLoginsSettingsFragment() { + val directions = SettingsFragmentDirections.actionSettingsFragmentToLoginsFragment() + Navigation.findNavController(view!!).navigate(directions) + } + private fun navigateToSearchEngineSettings() { val directions = SettingsFragmentDirections.actionSettingsFragmentToSearchEngineFragment() Navigation.findNavController(view!!).navigate(directions) diff --git a/app/src/main/java/org/mozilla/fenix/settings/account/AccountSettingsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/account/AccountSettingsFragment.kt index 2ff291e25..41cae992e 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/account/AccountSettingsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/account/AccountSettingsFragment.kt @@ -28,6 +28,7 @@ import mozilla.components.service.fxa.manager.SyncEnginesStorage import mozilla.components.service.fxa.sync.SyncReason import mozilla.components.service.fxa.sync.SyncStatusObserver import mozilla.components.service.fxa.sync.getLastSynced +import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.R import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.StoreProvider @@ -36,7 +37,7 @@ import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.getPreferenceKey import org.mozilla.fenix.ext.requireComponents -@SuppressWarnings("TooManyFunctions") +@SuppressWarnings("TooManyFunctions", "LargeClass") class AccountSettingsFragment : PreferenceFragmentCompat() { private lateinit var accountManager: FxaAccountManager private lateinit var accountSettingsStore: AccountSettingsFragmentStore @@ -96,7 +97,7 @@ class AccountSettingsFragment : PreferenceFragmentCompat() { ) } - @Suppress("ComplexMethod") + @Suppress("ComplexMethod", "LongMethod") override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.account_settings_preferences, rootKey) @@ -108,7 +109,9 @@ class AccountSettingsFragment : PreferenceFragmentCompat() { LastSyncTime.Never else LastSyncTime.Success(getLastSynced(requireContext())), - deviceName = requireComponents.backgroundServices.defaultDeviceName(requireContext()) + deviceName = requireComponents.backgroundServices.defaultDeviceName( + requireContext() + ) ) ) } @@ -158,6 +161,7 @@ class AccountSettingsFragment : PreferenceFragmentCompat() { findPreference(historyNameKey)?.apply { setOnPreferenceChangeListener { _, newValue -> SyncEnginesStorage(context).setStatus(SyncEngine.History, newValue as Boolean) + @Suppress("DeferredResultUnused") context.components.backgroundServices.accountManager.syncNowAsync(SyncReason.EngineChange) true } @@ -167,6 +171,17 @@ class AccountSettingsFragment : PreferenceFragmentCompat() { findPreference(bookmarksNameKey)?.apply { setOnPreferenceChangeListener { _, newValue -> SyncEnginesStorage(context).setStatus(SyncEngine.Bookmarks, newValue as Boolean) + @Suppress("DeferredResultUnused") + context.components.backgroundServices.accountManager.syncNowAsync(SyncReason.EngineChange) + true + } + } + + val loginsNameKey = getPreferenceKey(R.string.pref_key_sync_logins) + findPreference(loginsNameKey)?.apply { + setOnPreferenceChangeListener { _, newValue -> + SyncEnginesStorage(context).setStatus(SyncEngine.Passwords, newValue as Boolean) + @Suppress("DeferredResultUnused") context.components.backgroundServices.accountManager.syncNowAsync(SyncReason.EngineChange) true } @@ -193,13 +208,20 @@ class AccountSettingsFragment : PreferenceFragmentCompat() { isEnabled = syncEnginesStatus.containsKey(SyncEngine.History) isChecked = syncEnginesStatus.getOrElse(SyncEngine.History) { true } } + val loginsNameKey = getPreferenceKey(R.string.pref_key_sync_logins) + findPreference(loginsNameKey)?.apply { + isVisible = FeatureFlags.logins + isEnabled = syncEnginesStatus.containsKey(SyncEngine.Passwords) + isChecked = syncEnginesStatus.getOrElse(SyncEngine.Passwords) { false } + } } private fun syncNow() { lifecycleScope.launch { requireComponents.analytics.metrics.track(Event.SyncAccountSyncNow) // Trigger a sync. - requireComponents.backgroundServices.accountManager.syncNowAsync(SyncReason.User).await() + requireComponents.backgroundServices.accountManager.syncNowAsync(SyncReason.User) + .await() // Poll for device events & update devices. accountManager.authenticatedAccount() ?.deviceConstellation()?.run { @@ -243,8 +265,8 @@ class AccountSettingsFragment : PreferenceFragmentCompat() { return Preference.OnPreferenceChangeListener { _, newValue -> accountSettingsInteractor.onChangeDeviceName(newValue as String) { FenixSnackbar.make(view!!, FenixSnackbar.LENGTH_LONG) - .setText(getString(R.string.empty_device_name_error)) - .show() + .setText(getString(R.string.empty_device_name_error)) + .show() } } } @@ -284,7 +306,11 @@ class AccountSettingsFragment : PreferenceFragmentCompat() { pref.isEnabled = true val failedTime = getLastSynced(requireContext()) - accountSettingsStore.dispatch(AccountSettingsFragmentAction.SyncFailed(failedTime)) + accountSettingsStore.dispatch( + AccountSettingsFragmentAction.SyncFailed( + failedTime + ) + ) } } } diff --git a/app/src/main/res/drawable/ic_copy.xml b/app/src/main/res/drawable/ic_copy.xml new file mode 100644 index 000000000..a50b2a6d5 --- /dev/null +++ b/app/src/main/res/drawable/ic_copy.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_login.xml b/app/src/main/res/drawable/ic_login.xml index 008f6f413..e95281678 100644 --- a/app/src/main/res/drawable/ic_login.xml +++ b/app/src/main/res/drawable/ic_login.xml @@ -3,11 +3,11 @@ - 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/. --> + android:width="20dp" + android:height="10dp" + android:viewportWidth="20" + android:viewportHeight="10"> diff --git a/app/src/main/res/drawable/ic_password_hide.xml b/app/src/main/res/drawable/ic_password_hide.xml new file mode 100644 index 000000000..90a896e19 --- /dev/null +++ b/app/src/main/res/drawable/ic_password_hide.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_password_reveal.xml b/app/src/main/res/drawable/ic_password_reveal.xml new file mode 100644 index 000000000..61ffc6182 --- /dev/null +++ b/app/src/main/res/drawable/ic_password_reveal.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/app/src/main/res/layout/component_saved_logins.xml b/app/src/main/res/layout/component_saved_logins.xml new file mode 100644 index 000000000..28ff14a99 --- /dev/null +++ b/app/src/main/res/layout/component_saved_logins.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/layout/fragment_saved_login_site_info.xml b/app/src/main/res/layout/fragment_saved_login_site_info.xml new file mode 100644 index 000000000..d61c978ce --- /dev/null +++ b/app/src/main/res/layout/fragment_saved_login_site_info.xml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_saved_logins.xml b/app/src/main/res/layout/fragment_saved_logins.xml new file mode 100644 index 000000000..217d61182 --- /dev/null +++ b/app/src/main/res/layout/fragment_saved_logins.xml @@ -0,0 +1,10 @@ + + diff --git a/app/src/main/res/layout/logins_item.xml b/app/src/main/res/layout/logins_item.xml new file mode 100644 index 000000000..eb4eb07f6 --- /dev/null +++ b/app/src/main/res/layout/logins_item.xml @@ -0,0 +1,62 @@ + + + + + + + + + diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index e8a6824fc..3672f9c74 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -181,8 +181,8 @@ android:id="@+id/action_browserFragment_to_createCollectionFragment" app:destination="@id/collectionCreationFragment" /> + android:id="@+id/action_browserFragment_to_createShortcutFragment" + app:destination="@id/createShortcutFragment" /> @@ -198,8 +198,14 @@ android:id="@+id/externalAppBrowserFragment" android:name="org.mozilla.fenix.customtabs.ExternalAppBrowserFragment" tools:layout="@layout/fragment_browser"> - - + + @@ -314,6 +320,24 @@ app:destination="@id/bookmarkSelectFolderFragment" /> + + + + + + + + @@ -494,10 +521,10 @@ app:nullable="false" /> + android:id="@+id/createShortcutFragment" + android:name="org.mozilla.fenix.shortcut.CreateShortcutFragment" + android:label="fragment_create_shortcut" + tools:layout="@layout/fragment_create_shortcut" /> + + + + + + diff --git a/app/src/main/res/values/preference_keys.xml b/app/src/main/res/values/preference_keys.xml index 58dcc8adc..c285081c0 100644 --- a/app/src/main/res/values/preference_keys.xml +++ b/app/src/main/res/values/preference_keys.xml @@ -55,6 +55,7 @@ pref_key_sync_now pref_key_sync_history pref_key_sync_bookmarks + pref_key_sync_logins pref_key_sign_out pref_key_cached_account pref_key_sync_pair @@ -100,6 +101,10 @@ pref_key_tracking_protection_strict pref_key_tracking_protection_onboarding + + pref_key_saved_logins + pref_key_password_sync_logins + pref_key_open_links_in_a_private_tab diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fca35c000..91e9f658d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -196,6 +196,8 @@ History Bookmarks + + Logins Sign out @@ -920,7 +922,7 @@ Paste & Go Paste - + Add to Home screen @@ -984,5 +986,20 @@ Save Don\'t save - + + Password copied to clipboard + + Username copied to clipboard + + Site copied to clipboard + + Copy password + + Copy username + + Copy site + + Show password + + Hide password diff --git a/app/src/main/res/xml/account_settings_preferences.xml b/app/src/main/res/xml/account_settings_preferences.xml index 808b28283..104b4c7e0 100644 --- a/app/src/main/res/xml/account_settings_preferences.xml +++ b/app/src/main/res/xml/account_settings_preferences.xml @@ -2,33 +2,39 @@ - - + + android:key="@string/pref_key_sync_now" + android:title="@string/preferences_sync_now" /> + + + android:key="@string/preferences_sync_category" + android:title="@string/preferences_sync_category"> + android:defaultValue="true" + android:key="@string/pref_key_sync_bookmarks" + android:title="@string/preferences_sync_bookmarks" /> + android:defaultValue="true" + android:key="@string/pref_key_sync_history" + android:title="@string/preferences_sync_history" /> + + + android:key="@string/pref_key_sync_device_name" + android:title="@string/preferences_sync_device_name" /> - - \ No newline at end of file diff --git a/app/src/main/res/xml/logins_preferences.xml b/app/src/main/res/xml/logins_preferences.xml new file mode 100644 index 000000000..cb677de05 --- /dev/null +++ b/app/src/main/res/xml/logins_preferences.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 2973e5d75..63da0050c 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -59,6 +59,11 @@ android:icon="@drawable/ic_tracking_protection_enabled" android:key="@string/pref_key_tracking_protection_settings" android:title="@string/preference_enhanced_tracking_protection" /> + Unit = mockk(relaxed = true) + val interactor = SavedLoginsInteractor( + savedLoginClicked + ) + + val item = SavedLoginsItem("mozilla.org", "username", "password") + interactor.itemClicked(item) + + verify { + savedLoginClicked.invoke(item) + } + } +} diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index cdf5aac7e..2ff799007 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -136,6 +136,8 @@ object Deps { const val mozilla_feature_sendtab = "org.mozilla.components:feature-sendtab:${Versions.mozilla_android_components}" const val mozilla_feature_webcompat = "org.mozilla.components:feature-webcompat:${Versions.mozilla_android_components}" + const val mozilla_service_sync_logins = + "org.mozilla.components:service-sync-logins:${Versions.mozilla_android_components}" const val mozilla_service_firefox_accounts = "org.mozilla.components:service-firefox-accounts:${Versions.mozilla_android_components}" const val mozilla_service_fretboard = "org.mozilla.components:service-fretboard:${Versions.mozilla_android_components}" const val mozilla_service_glean = "org.mozilla.components:service-glean:${Versions.mozilla_android_components}"