tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 672a2ccd3d3376eb2306e24c44a9561a2e7d485f
parent 326cf8e6a032402327988b8d04673cb70d11628d
Author: Alexandra Virvara <127850062+alexandra-virvara@users.noreply.github.com>
Date:   Tue, 25 Nov 2025 10:02:41 +0000

Bug 1980027: composify the autofill settings fragment r=android-reviewers,android-l10n-reviewers,flod,sfamisa

TRY link:: https://treeherder.mozilla.org/jobs?repo=try&revision=36d3e5a8222453c138c43f992199cde3c0efee9f

Differential Revision: https://phabricator.services.mozilla.com/D272473

Diffstat:
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/AutofillScreenDestination.kt | 18++++++++++++++++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/AutofillSettingFragment.kt | 172++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/ui/AutofillSettingsAction.kt | 36++++++++++++++++++++++++++++++++++++
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/ui/AutofillSettingsMiddleware.kt | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/ui/AutofillSettingsReducer.kt | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/ui/AutofillSettingsScreen.kt | 395+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/ui/AutofillSettingsState.kt | 48++++++++++++++++++++++++++++++++++++++++++++++++
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/ui/AutofillSettingsStore.kt | 30++++++++++++++++++++++++++++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/Settings.kt | 28++++++++++++++++++++++++++++
Mmobile/android/fenix/app/src/main/res/values/strings.xml | 2++
Amobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/autofill/ui/AutofillSettingsMiddlewareTest.kt | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/autofill/ui/AutofillSettingsReducerTest.kt | 211+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 1290 insertions(+), 1 deletion(-)

diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/AutofillScreenDestination.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/AutofillScreenDestination.kt @@ -0,0 +1,18 @@ +/* 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.autofill + +/** + * Defines destinations used when navigating to other fragments + */ +object AutofillScreenDestination { + const val SYNC_SIGN_IN = "sync sign in" + const val ADD_ADDRESS = "add address" + const val MANAGE_ADDRESSES = "manage addresses" + const val ADD_CREDIT_CARD = "add credit card" + const val MANAGE_CREDIT_CARDS = "manage credit cards" + const val ADDRESS = "address" + const val CREDIT_CARD = "credit card" +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/AutofillSettingFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/AutofillSettingFragment.kt @@ -14,8 +14,11 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.annotation.VisibleForTesting +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController +import androidx.navigation.NavHostController import androidx.navigation.fragment.findNavController import androidx.preference.Preference import androidx.preference.SwitchPreference @@ -25,13 +28,19 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import mozilla.components.lib.state.ext.consumeFrom +import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import mozilla.components.lib.state.helpers.StoreProvider.Companion.storeProvider import mozilla.components.service.fxa.SyncEngine +import mozilla.components.service.fxa.manager.SyncEnginesStorage import mozilla.components.service.sync.autofill.AutofillCreditCardsAddressesStorage import mozilla.components.ui.widgets.withCenterAlignedButtons +import org.mozilla.fenix.Config import org.mozilla.fenix.NavGraphDirections import org.mozilla.fenix.R +import org.mozilla.fenix.components.LogMiddleware import org.mozilla.fenix.components.accounts.FenixFxAEntryPoint +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.hideToolbar import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.runIfFragmentIsAttached import org.mozilla.fenix.ext.secure @@ -39,13 +48,20 @@ import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.settings.SharedPreferenceUpdater import org.mozilla.fenix.settings.SyncPreferenceView +import org.mozilla.fenix.settings.autofill.ui.AccountAuthState +import org.mozilla.fenix.settings.autofill.ui.AutofillSettingsMiddleware +import org.mozilla.fenix.settings.autofill.ui.AutofillSettingsScreen +import org.mozilla.fenix.settings.autofill.ui.AutofillSettingsState +import org.mozilla.fenix.settings.autofill.ui.AutofillSettingsStore import org.mozilla.fenix.settings.biometric.BiometricPromptPreferenceFragment import org.mozilla.fenix.settings.requirePreference +import org.mozilla.fenix.theme.FirefoxTheme +import kotlin.Boolean import com.google.android.material.R as materialR import mozilla.components.ui.icons.R as iconsR /** - * Autofill settings fragment displays a list of settings related to autofilling, adding and + * Autofill settings fragment displays a list of settings related to auto filling, adding and * syncing credit cards and addresses. */ @SuppressWarnings("TooManyFunctions") @@ -82,6 +98,10 @@ class AutofillSettingFragment : BiometricPromptPreferenceFragment() { container: ViewGroup?, savedInstanceState: Bundle?, ): View { + if (requireContext().settings().enableComposeAutofillSettings) { + return autofillSettingsComposeView() + } + store = storeProvider.get { restoredState -> AutofillFragmentStore(restoredState ?: AutofillFragmentState()) } @@ -90,6 +110,10 @@ class AutofillSettingFragment : BiometricPromptPreferenceFragment() { } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + if (requireContext().settings().enableComposeAutofillSettings) { + return + } + setPreferencesFromResource( if (requireComponents.settings.addressFeature) { R.xml.autofill_preferences @@ -126,9 +150,72 @@ class AutofillSettingFragment : BiometricPromptPreferenceFragment() { } } + private fun autofillSettingsComposeView(): View = + ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + val buildStore = { _: NavHostController -> + + val autofillStore by fragmentStore( + AutofillSettingsState.default.copy( + saveFillAddresses = requireContext().settings().shouldAutofillAddressDetails, + saveFillCards = requireContext().settings().shouldAutofillCreditCardDetails, + syncAddresses = requireContext().settings().shouldSyncAddressesAcrossDevices, + syncCreditCards = requireContext().settings().shouldSyncCreditCardsAcrossDevices, + accountAuthState = if (requireContext().settings().signedInFxaAccount) { + AccountAuthState.Authenticated + } else { + AccountAuthState.LoggedOut + }, + ), + ) { + AutofillSettingsStore( + initialState = it, + middleware = listOf( + LogMiddleware( + tag = "AutofillSettingsStore", + shouldIncludeDetailedData = { Config.channel.isDebug }, + ), + createAutofillSettingsMiddleware(), + ), + ) + } + + autofillStore + } + setContent { + FirefoxTheme { + AutofillSettingsScreen( + buildStore = buildStore, + accountManager = requireComponents.backgroundServices.accountManager, + isAddressSyncEnabled = requireComponents.settings.isAddressSyncEnabled, + ) + } + } + } + + private fun createAutofillSettingsMiddleware() = + AutofillSettingsMiddleware( + autofillSettingsStorage = requireContext().components.core.autofillStorage, + accountManager = requireComponents.backgroundServices.accountManager, + updateSaveFillStatus = { destination, newValue -> + updateSaveFillStatus(destination, newValue) + }, + updateSyncStatusAcrossDevices = { destination, newValue -> + updateSyncStatusAcrossDevices(destination, newValue) + }, + goToScreen = { nextFragment -> + goToFragment(nextFragment) + }, + exitAutofillSettings = { this@AutofillSettingFragment.findNavController().popBackStack() }, + ) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + if (requireContext().settings().enableComposeAutofillSettings) { + return + } + requirePreference<SwitchPreference>(R.string.pref_key_credit_cards_save_and_autofill_cards).summary = getString(R.string.preferences_credit_cards_save_and_autofill_cards_summary_2, getString(R.string.app_name)) @@ -150,6 +237,11 @@ class AutofillSettingFragment : BiometricPromptPreferenceFragment() { override fun onResume() { super.onResume() + if (requireContext().settings().enableComposeAutofillSettings) { + hideToolbar() + return + } + if (requireComponents.settings.addressFeature) { showToolbar(getString(R.string.preferences_autofill)) } else { @@ -358,6 +450,50 @@ class AutofillSettingFragment : BiometricPromptPreferenceFragment() { startForResult.launch(intent) } + private fun goToFragment(destination: String) { + with(AutofillScreenDestination) { + when (destination) { + ADD_ADDRESS -> { + navigateToAddAddressFragment() + } + MANAGE_ADDRESSES -> { + navigateToAddressManagementFragment() + } + ADD_CREDIT_CARD -> { + navigateToAddCreditCardFragment() + } + + MANAGE_CREDIT_CARDS -> { + navigateToCreditCardManagementFragment() + } + SYNC_SIGN_IN -> { + syncSignIn() + } + } + } + } + + private fun navigateToAddAddressFragment() { + val directions = + AutofillSettingFragmentDirections + .actionAutofillSettingFragmentToAddressEditorFragment() + findNavController().navigate(directions) + } + + private fun navigateToAddressManagementFragment() { + val directions = + AutofillSettingFragmentDirections + .actionAutofillSettingFragmentToAddressManagementFragment() + findNavController().navigate(directions) + } + + private fun navigateToAddCreditCardFragment() { + val directions = + AutofillSettingFragmentDirections + .actionAutofillSettingFragmentToCreditCardEditorFragment() + findNavController().navigate(directions) + } + private fun navigateToCreditCardManagementFragment() { val directions = AutofillSettingFragmentDirections @@ -365,6 +501,40 @@ class AutofillSettingFragment : BiometricPromptPreferenceFragment() { findNavController().navigate(directions) } + private fun syncSignIn() { + findNavController().navigate( + NavGraphDirections.actionGlobalTurnOnSync(entrypoint = FenixFxAEntryPoint.AutofillSetting), + ) + } + + private fun updateSyncStatusAcrossDevices(destination: String, newValue: Boolean) { + when (destination) { + AutofillScreenDestination.ADDRESS -> { + SyncEnginesStorage(requireContext()).setStatus(SyncEngine.Addresses, newValue) + requireContext().settings().shouldSyncAddressesAcrossDevices = + newValue + } + + AutofillScreenDestination.CREDIT_CARD -> { + SyncEnginesStorage(requireContext()).setStatus(SyncEngine.CreditCards, newValue) + requireContext().settings().shouldSyncCreditCardsAcrossDevices = + newValue + } + } + } + + private fun updateSaveFillStatus(destination: String, newValue: Boolean) { + when (destination) { + AutofillScreenDestination.ADDRESS -> { + requireContext().settings().shouldAutofillAddressDetails = newValue + } + + AutofillScreenDestination.CREDIT_CARD -> { + requireContext().settings().shouldAutofillCreditCardDetails = newValue + } + } + } + companion object { const val SHORT_DELAY_MS = 100L } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/ui/AutofillSettingsAction.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/ui/AutofillSettingsAction.kt @@ -0,0 +1,36 @@ +/* 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.autofill.ui + +import mozilla.components.concept.storage.Address +import mozilla.components.concept.storage.CreditCard +import mozilla.components.lib.state.Action + +/** + * Actions relating to the AutofillSettings screen. + */ +internal sealed interface AutofillSettingsAction : Action +internal data object ViewDisposed : AutofillSettingsAction +internal data object InitializeAddressesAndCreditCards : AutofillSettingsAction +internal data class UpdateAddresses(val addresses: List<Address>) : AutofillSettingsAction +internal data object AddAddressClicked : AutofillSettingsAction +internal data class ChangeAddressSaveFillPreference(val isChecked: Boolean) : AutofillSettingsAction +internal data object SyncAddressesAcrossDevicesClicked : AutofillSettingsAction +internal data class UpdateCreditCards(val creditCards: List<CreditCard>) : AutofillSettingsAction +internal data object AddCardClicked : AutofillSettingsAction +internal data object SyncCardsAcrossDevicesClicked : AutofillSettingsAction +internal data class ChangeCardSaveFillPreference(val isChecked: Boolean) : AutofillSettingsAction +internal data object ManageAddressesClicked : AutofillSettingsAction +internal data object ManageCreditCardsClicked : AutofillSettingsAction +internal data object AutofillSettingsBackClicked : AutofillSettingsAction + +internal sealed class AccountAuthenticationAction : AutofillSettingsAction { + data object Authenticated : AccountAuthenticationAction() + data object NotAuthenticated : AccountAuthenticationAction() + data object Failed : AccountAuthenticationAction() +} + +internal data class UpdateAddressesSyncStatus(val newStatus: Boolean) : AutofillSettingsAction +internal data class UpdateCreditCardsSyncStatus(val newStatus: Boolean) : AutofillSettingsAction diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/ui/AutofillSettingsMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/ui/AutofillSettingsMiddleware.kt @@ -0,0 +1,135 @@ +/* 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.autofill.ui + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import mozilla.components.concept.sync.AccountObserver +import mozilla.components.concept.sync.AuthType +import mozilla.components.concept.sync.OAuthAccount +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.MiddlewareContext +import mozilla.components.lib.state.Store +import mozilla.components.service.fxa.manager.FxaAccountManager +import mozilla.components.service.sync.autofill.AutofillCreditCardsAddressesStorage +import org.mozilla.fenix.settings.autofill.AutofillScreenDestination +import org.mozilla.fenix.settings.logins.ui.LoginsAction + +/** + * A middleware for handling side-effects in response to [LoginsAction]s. + * + * @param autofillSettingsStorage Storage layer for reading and writing addresses/cards. + * @param accountManager The account manager that offers information on account auth state. + * @param updateSaveFillStatus Invoked when changing the save fill status option for addresses or cards. + * @param updateSyncStatusAcrossDevices Invoked when changing the sync status option for addresses or cards. + * @param goToScreen Invoked when navigating to another screen. + * @param exitAutofillSettings Invoked when back is clicked while the navController's backstack is empty. + * @param ioDispatcher Coroutine dispatcher for IO operations. + */ +internal class AutofillSettingsMiddleware( + private val autofillSettingsStorage: AutofillCreditCardsAddressesStorage, + private val accountManager: FxaAccountManager, + private val updateSaveFillStatus: (String, Boolean) -> Unit, + private val updateSyncStatusAcrossDevices: (String, Boolean) -> Unit, + private val goToScreen: (String) -> Unit, + private val exitAutofillSettings: () -> Unit, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : Middleware<AutofillSettingsState, AutofillSettingsAction> { + + private val scope = CoroutineScope(ioDispatcher) + private lateinit var observer: AccountObserver + + override fun invoke( + context: MiddlewareContext<AutofillSettingsState, AutofillSettingsAction>, + next: (AutofillSettingsAction) -> Unit, + action: AutofillSettingsAction, + ) { + next(action) + + when (action) { + is InitializeAddressesAndCreditCards -> { + context.store.registerObserverForAccountChanges(accountManager) + context.store.loadAddressesAndCreditCards() + } + is AddAddressClicked -> { + goToScreen(AutofillScreenDestination.ADD_ADDRESS) + } + is AddCardClicked -> { + goToScreen(AutofillScreenDestination.ADD_CREDIT_CARD) + } + is AutofillSettingsBackClicked -> { + exitAutofillSettings() + } + is ChangeAddressSaveFillPreference -> { + updateSaveFillStatus(AutofillScreenDestination.ADDRESS, action.isChecked) + } + is ChangeCardSaveFillPreference -> { + updateSaveFillStatus(AutofillScreenDestination.CREDIT_CARD, action.isChecked) + } + is SyncAddressesAcrossDevicesClicked -> { + goToScreen(AutofillScreenDestination.SYNC_SIGN_IN) + } + is SyncCardsAcrossDevicesClicked -> { + goToScreen(AutofillScreenDestination.SYNC_SIGN_IN) + } + is UpdateAddressesSyncStatus -> { + updateSyncStatusAcrossDevices(AutofillScreenDestination.ADDRESS, action.newStatus) + } + is UpdateCreditCardsSyncStatus -> { + updateSyncStatusAcrossDevices( + AutofillScreenDestination.CREDIT_CARD, + action.newStatus, + ) + } + is ManageAddressesClicked -> { + goToScreen(AutofillScreenDestination.MANAGE_ADDRESSES) + } + is ManageCreditCardsClicked -> { + goToScreen(AutofillScreenDestination.MANAGE_CREDIT_CARDS) + } + is ViewDisposed -> { + accountManager.unregister(observer) + } + is UpdateAddresses, + is UpdateCreditCards, + is AccountAuthenticationAction.Authenticated, + is AccountAuthenticationAction.Failed, + is AccountAuthenticationAction.NotAuthenticated, + -> Unit + } + } + + private fun Store<AutofillSettingsState, AutofillSettingsAction>.loadAddressesAndCreditCards() = + scope.launch { + val addresses = autofillSettingsStorage.getAllAddresses() + val creditCards = autofillSettingsStorage.getAllCreditCards() + + dispatch(UpdateAddresses(addresses = addresses)) + dispatch(UpdateCreditCards(creditCards = creditCards)) + } + + private fun Store<AutofillSettingsState, AutofillSettingsAction>.registerObserverForAccountChanges( + accountManager: FxaAccountManager, + ) = + scope.launch { + observer = object : AccountObserver { + override fun onAuthenticated(account: OAuthAccount, authType: AuthType) { + dispatch(AccountAuthenticationAction.Authenticated) + } + + override fun onLoggedOut() { + dispatch(AccountAuthenticationAction.NotAuthenticated) + } + + override fun onAuthenticationProblems() { + dispatch(AccountAuthenticationAction.Failed) + } + } + + accountManager.register(observer) + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/ui/AutofillSettingsReducer.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/ui/AutofillSettingsReducer.kt @@ -0,0 +1,63 @@ +/* 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.autofill.ui + +/** + * Function for reducing a new autofill settings state based on the received action. + */ +internal fun autofillSettingsReducer(state: AutofillSettingsState, action: AutofillSettingsAction) = + when (action) { + is UpdateAddresses -> { + state.copy( + addresses = action.addresses, + ) + } + + is UpdateCreditCards -> { + state.copy( + creditCards = action.creditCards, + ) + } + + is AutofillSettingsBackClicked -> { + state.copy( + addresses = listOf(), + creditCards = listOf(), + ) + } + + is ChangeAddressSaveFillPreference -> { + state.copy(saveFillAddresses = action.isChecked) + } + + is ChangeCardSaveFillPreference -> { + state.copy(saveFillCards = action.isChecked) + } + + is AccountAuthenticationAction.Authenticated -> { + state.copy(accountAuthState = AccountAuthState.Authenticated) + } + + is AccountAuthenticationAction.Failed -> { + state.copy(accountAuthState = AccountAuthState.NeedsReauthentication) + } + + is AccountAuthenticationAction.NotAuthenticated -> { + state.copy(accountAuthState = AccountAuthState.LoggedOut) + } + + is UpdateAddressesSyncStatus -> { + state.copy(syncAddresses = action.newStatus) + } + + is UpdateCreditCardsSyncStatus -> { + state.copy(syncCreditCards = action.newStatus) + } + + ViewDisposed, + is InitializeAddressesAndCreditCards, AddAddressClicked, AddCardClicked, SyncAddressesAcrossDevicesClicked, + SyncCardsAcrossDevicesClicked, ManageAddressesClicked, ManageCreditCardsClicked, + -> state + } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/ui/AutofillSettingsScreen.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/ui/AutofillSettingsScreen.kt @@ -0,0 +1,395 @@ +/* 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.autofill.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController +import mozilla.components.compose.base.annotation.FlexibleWindowLightDarkPreview +import mozilla.components.compose.base.button.IconButton +import mozilla.components.lib.state.ext.observeAsState +import mozilla.components.service.fxa.manager.FxaAccountManager +import org.mozilla.fenix.R +import org.mozilla.fenix.compose.list.IconListItem +import org.mozilla.fenix.theme.FirefoxTheme +import mozilla.components.ui.icons.R as iconsR + +private const val WEIGHT_TEXT = 5f +private const val WEIGHT_SWITCH = 2f + +@Composable +internal fun AutofillSettingsScreen( + buildStore: (NavHostController) -> AutofillSettingsStore, + accountManager: FxaAccountManager?, + isAddressSyncEnabled: Boolean, +) { + val navController = rememberNavController() + val store = buildStore(navController) + + DisposableEffect(Unit) { + val authenticated = accountManager?.authenticatedAccount() != null + val needsReauthentication = accountManager?.accountNeedsReauth() == true + + when { + needsReauthentication -> { + store.dispatch(AccountAuthenticationAction.Failed) + } + authenticated -> { + store.dispatch(AccountAuthenticationAction.Authenticated) + } + !authenticated -> { + store.dispatch(AccountAuthenticationAction.NotAuthenticated) + } + } + + onDispose { + store.dispatch(ViewDisposed) + } + } + + Scaffold( + topBar = { + AutofillSettingsTopBar(store) + }, + containerColor = FirefoxTheme.colors.layer1, + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .fillMaxWidth(), + ) { + Spacer(modifier = Modifier.height(FirefoxTheme.layout.space.static200)) + AutofillSettingsAddressSection(store, isAddressSyncEnabled) + + // delimiter line between sections + HorizontalDivider( + modifier = Modifier.padding( + start = 4.dp, + end = 4.dp, + top = 16.dp, + bottom = 16.dp, + ), + color = FirefoxTheme.colors.indicatorInactive, thickness = 0.3.dp, + ) + + AutofillSettingsCreditCardSection(store) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AutofillSettingsTopBar(store: AutofillSettingsStore) { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors(containerColor = FirefoxTheme.colors.layer1), + windowInsets = WindowInsets( + top = 0.dp, + bottom = 0.dp, + ), + title = { + Text( + text = stringResource(R.string.preferences_autofill), + color = FirefoxTheme.colors.textPrimary, + style = FirefoxTheme.typography.headline6, + ) + }, + navigationIcon = { + IconButton( + modifier = Modifier + .padding(horizontal = FirefoxTheme.layout.space.static50), + onClick = { store.dispatch(AutofillSettingsBackClicked) }, + contentDescription = stringResource( + R.string.autofill_settings_navigate_back_button_content_description, + ), + ) { + Icon( + painter = painterResource(iconsR.drawable.mozac_ic_back_24), + contentDescription = null, + tint = FirefoxTheme.colors.iconPrimary, + ) + } + }, + ) +} + +@Composable +private fun AutofillSettingsAddressSection( + store: AutofillSettingsStore, + isAddressSyncEnabled: Boolean, +) { + val state by store.observeAsState(store.state) { it } + + AddSectionLabel(label = stringResource(id = R.string.preferences_addresses)) + Spacer(modifier = Modifier.height(FirefoxTheme.layout.space.static200)) + SaveFillSwitch( + title = stringResource(id = R.string.preferences_addresses_save_and_autofill_addresses_2), + label = stringResource(id = R.string.preferences_addresses_save_and_autofill_addresses_summary_2), + isChecked = state.saveFillAddresses, + onStateChange = { store.dispatch(ChangeAddressSaveFillPreference(!state.saveFillAddresses)) }, + ) + Spacer(modifier = Modifier.height(FirefoxTheme.layout.space.static100)) + + if (isAddressSyncEnabled) { + if (state.accountAuthState == AccountAuthState.Authenticated) { + SyncAcrossDevicesForAuthenticatedAccount( + text = stringResource(id = R.string.preferences_addresses_sync_addresses), + isChecked = state.syncAddresses, + onSyncStateChange = { store.dispatch(UpdateAddressesSyncStatus(!state.syncAddresses)) }, + ) + } else { + SyncAcrossDevicesForNotAuthenticatedAccount( + text = stringResource(id = R.string.preferences_addresses_sync_addresses_across_devices), + onClick = { store.dispatch(SyncAddressesAcrossDevicesClicked) }, + ) + } + } + + if (state.addresses.isEmpty()) { + Spacer(modifier = Modifier.height(FirefoxTheme.layout.space.static100)) + + AddItem( + label = stringResource(R.string.preferences_addresses_add_address), + onAddItemClicked = { store.dispatch(AddAddressClicked) }, + ) + } else { + Spacer(modifier = Modifier.height(FirefoxTheme.layout.space.static150)) + + ManageItem( + text = stringResource(id = R.string.preferences_addresses_manage_addresses), + onClick = { store.dispatch(ManageAddressesClicked) }, + ) + } +} + +@Composable +private fun AutofillSettingsCreditCardSection(store: AutofillSettingsStore) { + val state by store.observeAsState(store.state) { it } + + AddSectionLabel(label = stringResource(id = R.string.preferences_credit_cards_2)) + Spacer(modifier = Modifier.height(FirefoxTheme.layout.space.static200)) + SaveFillSwitch( + title = stringResource(id = R.string.preferences_credit_cards_save_and_autofill_cards_2), + label = stringResource( + id = R.string.preferences_credit_cards_save_and_autofill_cards_summary_2, + stringResource(id = R.string.app_name), + ), + isChecked = state.saveFillCards, + onStateChange = { store.dispatch(ChangeCardSaveFillPreference(!state.saveFillCards)) }, + ) + Spacer(modifier = Modifier.height(FirefoxTheme.layout.space.static100)) + + if (state.accountAuthState == AccountAuthState.Authenticated) { + SyncAcrossDevicesForAuthenticatedAccount( + text = stringResource(id = R.string.preferences_credit_cards_sync_cards), + isChecked = state.syncCreditCards, + onSyncStateChange = { store.dispatch(UpdateCreditCardsSyncStatus(!state.syncCreditCards)) }, + ) + } else { + SyncAcrossDevicesForNotAuthenticatedAccount( + text = stringResource(id = R.string.preferences_credit_cards_sync_cards_across_devices), + onClick = { store.dispatch(SyncCardsAcrossDevicesClicked) }, + ) + } + + if (state.creditCards.isEmpty()) { + Spacer(modifier = Modifier.height(FirefoxTheme.layout.space.static100)) + + AddItem( + label = stringResource(R.string.credit_cards_add_card), + onAddItemClicked = { store.dispatch(AddCardClicked) }, + ) + } else { + Spacer(modifier = Modifier.height(FirefoxTheme.layout.space.static150)) + ManageItem( + text = stringResource(id = R.string.preferences_credit_cards_manage_saved_cards_2), + onClick = { store.dispatch(ManageCreditCardsClicked) }, + ) + } +} + +@Composable +private fun AddSectionLabel(label: String) { + Text( + text = label, + modifier = Modifier.padding(start = FirefoxTheme.layout.space.static200), + style = FirefoxTheme.typography.headline8, + color = MaterialTheme.colorScheme.tertiary, + ) +} + +@Composable +private fun SaveFillSwitch( + title: String, + label: String, + isChecked: Boolean, + onStateChange: (Boolean) -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + Text( + text = title, + modifier = Modifier.padding(start = FirefoxTheme.layout.space.static400), + style = FirefoxTheme.typography.body1, + color = FirefoxTheme.colors.textPrimary, + ) + + Row( + modifier = Modifier + .clickable( + interactionSource = interactionSource, + indication = null, + role = Role.Switch, + onClick = { + onStateChange(!isChecked) + }, + ) + .width(FirefoxTheme.layout.size.containerMaxWidth), + horizontalArrangement = Arrangement.Absolute.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + modifier = Modifier + .padding(start = FirefoxTheme.layout.space.static500) + .weight(WEIGHT_TEXT), + maxLines = 2, + style = FirefoxTheme.typography.body2, + color = FirefoxTheme.colors.textSecondary, + ) + + Switch( + checked = isChecked, + modifier = Modifier.weight(WEIGHT_SWITCH), + onCheckedChange = { + onStateChange(it) + }, + ) + } +} + +@Composable +private fun SyncAcrossDevicesForAuthenticatedAccount( + text: String, + isChecked: Boolean, + onSyncStateChange: (Boolean) -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + Row( + modifier = Modifier + .clickable( + interactionSource = interactionSource, + indication = null, + role = Role.Switch, + onClick = { + onSyncStateChange(!isChecked) + }, + ) + .width(FirefoxTheme.layout.size.containerMaxWidth), + horizontalArrangement = Arrangement.Absolute.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + modifier = Modifier + .padding(start = FirefoxTheme.layout.space.static400) + .weight(WEIGHT_TEXT), + maxLines = 2, + style = FirefoxTheme.typography.body1, + color = FirefoxTheme.colors.textPrimary, + ) + + Switch( + checked = isChecked, + modifier = Modifier.weight(WEIGHT_SWITCH), + onCheckedChange = { + onSyncStateChange(it) + }, + ) + } +} + +@Composable +private fun SyncAcrossDevicesForNotAuthenticatedAccount(text: String, onClick: () -> Unit) { + Text( + text = text, + modifier = Modifier + .padding(start = FirefoxTheme.layout.space.static400) + .clickable(onClick = onClick), + style = FirefoxTheme.typography.body1, + color = FirefoxTheme.colors.textPrimary, + ) +} + +@Composable +private fun AddItem( + label: String, + onAddItemClicked: () -> Unit, +) { + IconListItem( + label = label, + modifier = Modifier + .padding(start = 10.dp) + .width(FirefoxTheme.layout.size.containerMaxWidth), + beforeIconPainter = painterResource(iconsR.drawable.mozac_ic_plus_24), + onClick = { onAddItemClicked() }, + ) +} + +@Composable +private fun ManageItem(text: String, onClick: () -> Unit) { + Text( + text = text, + modifier = Modifier + .padding(start = FirefoxTheme.layout.space.static400) + .clickable(onClick = onClick), + style = FirefoxTheme.typography.body1, + color = FirefoxTheme.colors.textPrimary, + ) +} + +@Composable +@FlexibleWindowLightDarkPreview +private fun AutofillSettingsScreenPreview() { + val store = { _: NavHostController -> + AutofillSettingsStore( + initialState = AutofillSettingsState.default, + ) + } + FirefoxTheme { + Box(modifier = Modifier.background(color = FirefoxTheme.colors.layer1)) { + AutofillSettingsScreen(store, null, true) + } + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/ui/AutofillSettingsState.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/ui/AutofillSettingsState.kt @@ -0,0 +1,48 @@ +/* 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.autofill.ui + +import mozilla.components.concept.storage.Address +import mozilla.components.concept.storage.CreditCard +import mozilla.components.lib.state.State + +/** + * Represents the state of the autofill settings screen. + * + * @property addresses The list of [Address]es to display in the address list. + * @property creditCards The list of [CreditCard]s to display in the credit card list. + * @property saveFillAddresses True if the option of saving and filling addresses info is enabled. + * @property saveFillCards True if the option of saving and filling cards info is enabled. + * @property accountAuthState The account authentication state. + * @property syncAddresses True if the option of syncing addresses across devices is enabled. + * @property syncCreditCards True if the option of syncing credit cards across devices is enabled. + */ +internal data class AutofillSettingsState( + val addresses: List<Address>, + val creditCards: List<CreditCard>, + val saveFillAddresses: Boolean, + val saveFillCards: Boolean, + val accountAuthState: AccountAuthState, + val syncAddresses: Boolean, + val syncCreditCards: Boolean, +) : State { + companion object { + val default: AutofillSettingsState = AutofillSettingsState( + addresses = listOf(), + creditCards = listOf(), + saveFillAddresses = false, + saveFillCards = false, + accountAuthState = AccountAuthState.LoggedOut, + syncAddresses = false, + syncCreditCards = false, + ) + } +} + +internal sealed class AccountAuthState { + data object LoggedOut : AccountAuthState() + data object Authenticated : AccountAuthState() + data object NeedsReauthentication : AccountAuthState() +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/ui/AutofillSettingsStore.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/ui/AutofillSettingsStore.kt @@ -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.autofill.ui + +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.Reducer +import mozilla.components.lib.state.Store + +/** + * A Store for handling [AutofillSettingsState] and dispatching [AutofillSettingsAction]. + * + * @param initialState The initial state for the Store. + * @param reducer Reducer to handle state updates based on dispatched actions. + * @param middleware Middleware to handle side-effects in response to dispatched actions. + */ +internal class AutofillSettingsStore( + initialState: AutofillSettingsState = AutofillSettingsState.default, + reducer: Reducer<AutofillSettingsState, AutofillSettingsAction> = ::autofillSettingsReducer, + middleware: List<Middleware<AutofillSettingsState, AutofillSettingsAction>> = listOf(), +) : Store<AutofillSettingsState, AutofillSettingsAction>( + initialState = initialState, + reducer = reducer, + middleware = middleware, +) { + init { + dispatch(InitializeAddressesAndCreditCards) + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/Settings.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/Settings.kt @@ -2024,6 +2024,16 @@ class Settings( ) /** + * Stores the user choice from the "Autofill" settings for whether + * credit cards should be synced across devices or not, when the user is authenticated. + * If set to `true`, then the credit cards will be synced across devices. + */ + var shouldSyncCreditCardsAcrossDevices by booleanPreference( + appContext.getPreferenceKey(R.string.pref_key_credit_cards_sync_cards_across_devices), + default = false, + ) + + /** * Stores the user choice from the "Autofill Addresses" settings for whether * save and autofill addresses should be enabled or not. * If set to `true` when the user focuses on address fields in a webpage an Android prompt is shown, @@ -2035,6 +2045,16 @@ class Settings( ) /** + * Stores the user choice from the "Autofill" settings for whether + * addresses should be synced across devices or not, when the user is authenticated. + * If set to `true`, then the addresses will be synced across devices. + */ + var shouldSyncAddressesAcrossDevices by booleanPreference( + appContext.getPreferenceKey(R.string.pref_key_addresses_sync_cards_across_devices), + default = false, + ) + + /** * Get the profile id to use in the sponsored stories communications with the Pocket endpoint. */ val pocketSponsoredStoriesProfileId by stringPreference( @@ -2612,6 +2632,14 @@ class Settings( ) /** + * Indicates whether or not we should use the new compose autofill settings UI + */ + var enableComposeAutofillSettings by booleanPreference( + key = appContext.getPreferenceKey(R.string.pref_key_enable_compose_logins), + default = false, + ) + + /** * Indicates whether or not to show the entry point for the DNS over HTTPS settings */ val showDohEntryPoint by lazyFeatureFlagPreference( diff --git a/mobile/android/fenix/app/src/main/res/values/strings.xml b/mobile/android/fenix/app/src/main/res/values/strings.xml @@ -2526,6 +2526,8 @@ <string name="preferences_addresses_sync_addresses_across_devices">Sync addresses across devices</string> <!-- Preference option for syncing addresses across devices. This is displayed when the user is signed into sync --> <string name="preferences_addresses_sync_addresses">Sync addresses</string> + <!-- Content description navigating back from autofill settings screen. --> + <string name="autofill_settings_navigate_back_button_content_description">Back</string> <!-- Title of the "Add card" screen --> <string name="credit_cards_add_card">Add card</string> diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/autofill/ui/AutofillSettingsMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/autofill/ui/AutofillSettingsMiddlewareTest.kt @@ -0,0 +1,153 @@ +/* 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.autofill.ui + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import mozilla.components.lib.dataprotect.SecureAbove22Preferences +import mozilla.components.service.fxa.manager.FxaAccountManager +import mozilla.components.service.sync.autofill.AutofillCreditCardsAddressesStorage +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AutofillSettingsMiddlewareTest { + + private lateinit var securePrefs: SecureAbove22Preferences + private lateinit var autofillSettingsStorage: AutofillCreditCardsAddressesStorage + private lateinit var accountManager: FxaAccountManager + private lateinit var updateSaveFillStatus: (String, Boolean) -> Unit + private lateinit var updateSyncStatusAcrossDevices: (String, Boolean) -> Unit + private lateinit var goToScreen: (String) -> Unit + private lateinit var exitAutofillSettings: () -> Unit + + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setup() { + securePrefs = SecureAbove22Preferences(testContext, "autofill", forceInsecure = true) + autofillSettingsStorage = + AutofillCreditCardsAddressesStorage(testContext, lazy { securePrefs }) + accountManager = mock() + updateSaveFillStatus = { _, _ -> } + updateSyncStatusAcrossDevices = { _, _ -> } + goToScreen = { } + exitAutofillSettings = { } + } + + @Test + fun `GIVEN no addresses in storage WHEN store is initialized THEN list of addresses will be empty`() = + runTest(testDispatcher) { + val middleware = buildMiddleware() + val store = middleware.makeStore() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(0, store.state.addresses.size) + } + + @Test + fun `GIVEN no credit cards in storage WHEN store is initialized THEN list of credit cards will be empty`() = + runTest(testDispatcher) { + val middleware = buildMiddleware() + val store = middleware.makeStore() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(0, store.state.creditCards.size) + } + + @Test + fun `WHEN back is clicked THEN exit autofill settings`() = + runTest(testDispatcher) { + var exited = false + exitAutofillSettings = { exited = true } + val middleware = buildMiddleware() + val store = middleware.makeStore() + + store.dispatch(AutofillSettingsBackClicked) + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(exited) + } + + @Test + fun `GIVEN an autofill settings store WHEN save fill addresses option is changed THEN save the new value`() = + runTest(testDispatcher) { + var newSaveFillAddressesOption = false + updateSaveFillStatus = { _, newOption -> + newSaveFillAddressesOption = newOption + } + val middleware = buildMiddleware() + val store = middleware.makeStore() + store.dispatch(ChangeAddressSaveFillPreference(true)) + testDispatcher.scheduler.advanceUntilIdle() + assertTrue(newSaveFillAddressesOption) + } + + @Test + fun `GIVEN an autofill settings store WHEN save fill credit cards option is changed THEN save the new value`() = + runTest(testDispatcher) { + var newSaveFillCreditCardsOption = true + updateSaveFillStatus = { _, newOption -> + newSaveFillCreditCardsOption = newOption + } + val middleware = buildMiddleware() + val store = middleware.makeStore() + store.dispatch(ChangeCardSaveFillPreference(false)) + testDispatcher.scheduler.advanceUntilIdle() + assertFalse(newSaveFillCreditCardsOption) + } + + @Test + fun `GIVEN an autofill settings store WHEN sync addresses option is changed THEN save the new value`() = + runTest(testDispatcher) { + var newSyncAddressesOption = false + updateSyncStatusAcrossDevices = { _, newOption -> + newSyncAddressesOption = newOption + } + val middleware = buildMiddleware() + val store = middleware.makeStore() + store.dispatch(UpdateAddressesSyncStatus(true)) + testDispatcher.scheduler.advanceUntilIdle() + assertTrue(newSyncAddressesOption) + } + + @Test + fun `GIVEN an autofill settings store WHEN sync credit cards option is changed THEN save the new value`() = + runTest(testDispatcher) { + var newSyncCreditCardsOption = true + updateSyncStatusAcrossDevices = { _, newOption -> + newSyncCreditCardsOption = newOption + } + val middleware = buildMiddleware() + val store = middleware.makeStore() + store.dispatch(UpdateCreditCardsSyncStatus(false)) + testDispatcher.scheduler.advanceUntilIdle() + assertFalse(newSyncCreditCardsOption) + } + + private fun buildMiddleware() = AutofillSettingsMiddleware( + autofillSettingsStorage = autofillSettingsStorage, + accountManager = accountManager, + updateSaveFillStatus = updateSaveFillStatus, + updateSyncStatusAcrossDevices = updateSyncStatusAcrossDevices, + goToScreen = goToScreen, + exitAutofillSettings = exitAutofillSettings, + ioDispatcher = testDispatcher, + ) + + private fun AutofillSettingsMiddleware.makeStore( + initialState: AutofillSettingsState = AutofillSettingsState.default, + ) = AutofillSettingsStore( + initialState = initialState, + middleware = listOf(this), + ) +} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/autofill/ui/AutofillSettingsReducerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/autofill/ui/AutofillSettingsReducerTest.kt @@ -0,0 +1,211 @@ +/* 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.autofill.ui + +import mozilla.components.concept.storage.Address +import mozilla.components.concept.storage.CreditCard +import mozilla.components.concept.storage.CreditCardNumber +import org.junit.Assert.assertEquals +import org.junit.Test + +class AutofillSettingsReducerTest { + + @Test + fun `WHEN addresses are loaded THEN they are added to state`() { + val state = AutofillSettingsState.default + val addresses = List(5) { + Address( + guid = "$it", + name = "name$it", + organization = "organization$it", + streetAddress = "street$it", + addressLevel1 = "", + addressLevel2 = "", + addressLevel3 = "", + postalCode = "40066$it", + country = "country$it", + tel = "123456$it", + email = "user$it@gmail.com", + timeCreated = System.currentTimeMillis(), + timeLastUsed = System.currentTimeMillis(), + timeLastModified = System.currentTimeMillis(), + timesUsed = 1L, + ) + } + + val result = autofillSettingsReducer( + state, + UpdateAddresses( + addresses = addresses, + ), + ) + + val expected = state.copy( + addresses = addresses, + ) + assertEquals(expected, result) + } + + @Test + fun `WHEN creditCards are loaded THEN they are added to state`() { + val state = AutofillSettingsState.default + val creditCards = List(5) { + CreditCard( + guid = "$it", + billingName = "name$it", + encryptedCardNumber = CreditCardNumber.Encrypted("4111111111111111"), + cardNumberLast4 = "211$it", + expiryMonth = 12, + expiryYear = 2029, + cardType = "Visa", + timeCreated = System.currentTimeMillis(), + timeLastUsed = System.currentTimeMillis(), + timeLastModified = System.currentTimeMillis(), + timesUsed = 1L, + ) + } + + val result = autofillSettingsReducer( + state, + UpdateCreditCards( + creditCards = creditCards, + ), + ) + + val expected = state.copy( + creditCards = creditCards, + ) + assertEquals(expected, result) + } + + @Test + fun `WHEN the back button is clicked THEN remove all addresses from state`() { + val addresses = List(5) { + Address( + guid = "$it", + name = "name$it", + organization = "organization$it", + streetAddress = "street$it", + addressLevel1 = "", + addressLevel2 = "", + addressLevel3 = "", + postalCode = "40066$it", + country = "country$it", + tel = "123456$it", + email = "user$it@gmail.com", + timeCreated = System.currentTimeMillis(), + timeLastUsed = System.currentTimeMillis(), + timeLastModified = System.currentTimeMillis(), + timesUsed = 1L, + ) + } + + val state = AutofillSettingsState.default.copy(addresses = addresses) + val result = autofillSettingsReducer(state, AutofillSettingsBackClicked) + + val expectedResult = state.copy(addresses = listOf()) + + assertEquals(result, expectedResult) + } + + @Test + fun `WHEN the back button is clicked THEN remove all credit cards from state`() { + val creditCards = List(5) { + CreditCard( + guid = "$it", + billingName = "name$it", + encryptedCardNumber = CreditCardNumber.Encrypted("4111111111111111"), + cardNumberLast4 = "211$it", + expiryMonth = 12, + expiryYear = 2029, + cardType = "Visa", + timeCreated = System.currentTimeMillis(), + timeLastUsed = System.currentTimeMillis(), + timeLastModified = System.currentTimeMillis(), + timesUsed = 1L, + ) + } + + val state = AutofillSettingsState.default.copy(creditCards = creditCards) + val result = autofillSettingsReducer(state, AutofillSettingsBackClicked) + + val expectedResult = state.copy(creditCards = listOf()) + + assertEquals(result, expectedResult) + } + + @Test + fun `WHEN save and fill addresses option is changed THEN this is reflected in the state`() { + val state = AutofillSettingsState.default.copy(saveFillAddresses = true) + val result = autofillSettingsReducer(state, ChangeAddressSaveFillPreference(false)) + + val expectedResult = state.copy(saveFillAddresses = false) + + assertEquals(result, expectedResult) + } + + @Test + fun `WHEN save and fill credit cards option is changed THEN this is reflected in the state`() { + val state = AutofillSettingsState.default.copy(saveFillAddresses = false) + val result = autofillSettingsReducer(state, ChangeAddressSaveFillPreference(true)) + + val expectedResult = state.copy(saveFillAddresses = true) + + assertEquals(result, expectedResult) + } + + @Test + fun `WHEN sync addresses option is changed THEN this is reflected in the state`() { + val state = AutofillSettingsState.default.copy(syncAddresses = true) + val result = autofillSettingsReducer(state, UpdateAddressesSyncStatus(false)) + + val expectedResult = state.copy(syncAddresses = false) + + assertEquals(result, expectedResult) + } + + @Test + fun `WHEN sync credit cards option is changed THEN this is reflected in the state`() { + val state = AutofillSettingsState.default.copy(syncCreditCards = false) + val result = autofillSettingsReducer(state, UpdateCreditCardsSyncStatus(true)) + + val expectedResult = state.copy(syncCreditCards = true) + + assertEquals(result, expectedResult) + } + + @Test + fun `WHEN account authentication succeeds THEN this is reflected in the state`() { + val state = + AutofillSettingsState.default.copy(accountAuthState = AccountAuthState.LoggedOut) + val result = autofillSettingsReducer(state, AccountAuthenticationAction.Authenticated) + + val expectedResult = state.copy(accountAuthState = AccountAuthState.Authenticated) + + assertEquals(result, expectedResult) + } + + @Test + fun `WHEN account authentication encounters a problem THEN this is reflected in the state`() { + val state = + AutofillSettingsState.default.copy(accountAuthState = AccountAuthState.Authenticated) + val result = autofillSettingsReducer(state, AccountAuthenticationAction.Failed) + + val expectedResult = state.copy(accountAuthState = AccountAuthState.NeedsReauthentication) + + assertEquals(result, expectedResult) + } + + @Test + fun `WHEN account authentication fails THEN this is reflected in the state`() { + val state = + AutofillSettingsState.default.copy(accountAuthState = AccountAuthState.Authenticated) + val result = autofillSettingsReducer(state, AccountAuthenticationAction.NotAuthenticated) + + val expectedResult = state.copy(accountAuthState = AccountAuthState.LoggedOut) + + assertEquals(result, expectedResult) + } +}