tor-browser

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

commit fb78440b8efeb593dbbed31d600603f5a4f30be8
parent 556e226e93d0132c147fe68aec79ff3ce72af5eb
Author: Jeff Boek <j@jboek.com>
Date:   Fri, 17 Oct 2025 11:18:08 +0000

Bug 1978570 - Refactors address editor to use the structure provided by Gecko r=android-reviewers,matt-tighe

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

Diffstat:
Mmobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/AddressAutofillTest.kt | 14+++++++-------
Mmobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuAutofillRobot.kt | 29++++++++++++++++-------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/debugsettings/addresses/FakeCreditCardsAddressesStorage.kt | 4++--
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/AddressEditorFragment.kt | 30+++++++++++++++++++++++++++++-
Dmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/AddressUtils.kt | 158-------------------------------------------------------------------------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressAction.kt | 31+++++++++++++++++++++++++------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressEnvironment.kt | 76++++++++++++++++------------------------------------------------------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressMiddleware.kt | 6+++++-
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressReducer.kt | 76++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressState.kt | 53++++++++++++++++++++++++++++++++++++++++-------------
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressStructureMiddleware.kt | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/ui/edit/EditAddressScreen.kt | 283+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/ui/edit/EditAddressTestTag.kt | 10++++++----
Amobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/store/AddressStateTest.kt | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/store/AddressStoreTest.kt | 166++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
15 files changed, 653 insertions(+), 441 deletions(-)

diff --git a/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/AddressAutofillTest.kt b/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/AddressAutofillTest.kt @@ -29,9 +29,9 @@ class AddressAutofillTest : TestSetup() { var name = "Mozilla Fenix Firefox" var streetAddress = "Harrison Street" var city = "San Francisco" - var state = "Alaska" + var state = "AK" var zipCode = "94105" - var country = "United States" + var country = "US" var phoneNumber = "555-5555" var emailAddress = "foo@bar.com" } @@ -41,9 +41,9 @@ class AddressAutofillTest : TestSetup() { var name = "Android Test Name" var streetAddress = "Fort Street" var city = "Alberta" - var state = "Arizona" + var state = "AZ" var zipCode = "95141" - var country = "Canada" + var country = "CA" var phoneNumber = "777-7777" var emailAddress = "fuu@bar.org" } @@ -346,11 +346,11 @@ class AddressAutofillTest : TestSetup() { verifyAddressAutofillSection(true, false) clickAddAddressButton() clickCountryDropdown() - clickCountryOption("United States") + clickCountryOption("US") verifyCountryOption("United States") verifyStateOption("Alabama") clickCountryDropdown() - clickCountryOption("Canada") + clickCountryOption("CA") verifyStateOption("Alberta") } } @@ -416,7 +416,7 @@ class AddressAutofillTest : TestSetup() { clickManageAddressesButton() verifyManageAddressesSection( FirstAddressAutofillDetails.name, - "Harrison Street, San Francisco, Alaska, US, 94105, 555-5555, foo@bar.com", + "Harrison Street, San Francisco, AK, US, 94105, 555-5555, foo@bar.com", ) } } diff --git a/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuAutofillRobot.kt b/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuAutofillRobot.kt @@ -6,6 +6,7 @@ package org.mozilla.fenix.ui.robots import android.util.Log import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText @@ -206,7 +207,7 @@ class SettingsSubMenuAutofillRobot(private val composeTestRule: ComposeTestRule) fun verifyStateOption(state: String) { Log.i(TAG, "verifyStateOption: Trying to verify that state: $state is displayed") - composeTestRule.subRegionOption(state).assertIsDisplayed() + composeTestRule.subRegionDropDown().assert(hasText(state)) Log.i(TAG, "verifyStateOption: Verified that state: $state is displayed") } @@ -238,7 +239,7 @@ class SettingsSubMenuAutofillRobot(private val composeTestRule: ComposeTestRule) composeTestRule.editAddressToolbarTitle(), composeTestRule.toolbarCheckmarkButton(), composeTestRule.toolbarDeleteAddressButton(), - composeTestRule.nameTextInput().assertIsDisplayed(), + composeTestRule.nameTextInput(), composeTestRule.streetAddressTextInput(), composeTestRule.cityTextInput(), composeTestRule.subRegionDropDown(), @@ -313,7 +314,7 @@ class SettingsSubMenuAutofillRobot(private val composeTestRule: ComposeTestRule) fun clickSubRegionOption(subRegion: String) { composeTestRule.subRegionOption(subRegion).performScrollTo() Log.i(TAG, "clickSubRegionOption: Waiting for $waitingTime ms for the \"State\" $subRegion dropdown option to exist") - composeTestRule.waitUntilAtLeastOneExists(hasText(subRegion), waitingTime) + composeTestRule.waitUntilAtLeastOneExists(hasTestTag(EditAddressTestTag.ADDRESS_LEVEL1_FIELD + ".$subRegion"), waitingTime) Log.i(TAG, "clickSubRegionOption: Waited for $waitingTime ms for the \"State\" $subRegion dropdown option to exist") Log.i(TAG, "clickSubRegionOption: Trying to click the \"State\" $subRegion dropdown option") composeTestRule.subRegionOption(subRegion).performClick() @@ -329,9 +330,11 @@ class SettingsSubMenuAutofillRobot(private val composeTestRule: ComposeTestRule) @OptIn(ExperimentalTestApi::class) fun clickCountryOption(country: String) { Log.i(TAG, "clickCountryOption: Waiting for $waitingTime ms for the \"Country or region\" $country dropdown option to exist") - composeTestRule.waitUntilAtLeastOneExists(hasText(country), waitingTime) + + composeTestRule.waitUntilAtLeastOneExists(hasTestTag(EditAddressTestTag.COUNTRY_FIELD + ".$country"), waitingTime) Log.i(TAG, "clickCountryOption: Waited for $waitingTime ms for the \"Country or region\" $country dropdown option to exist") Log.i(TAG, "clickCountryOption: Trying to click \"Country or region\" $country dropdown option") + composeTestRule.countryOption(country).performScrollTo() composeTestRule.countryOption(country).performClick() Log.i(TAG, "clickCountryOption: Clicked \"Country or region\" $country dropdown option") } @@ -387,9 +390,6 @@ class SettingsSubMenuAutofillRobot(private val composeTestRule: ComposeTestRule) Log.i(TAG, "fillAndSaveAddress: Trying to click $country dropdown option") clickCountryOption(country) Log.i(TAG, "fillAndSaveAddress: Clicked $country dropdown option") - if (composeTestRule.saveButton().isNotDisplayed()) { - composeTestRule.saveButton().performScrollTo() - } Log.i(TAG, "fillAndSaveAddress: Trying to set \"Phone\" to $phoneNumber") composeTestRule.phoneTextInput().performTextInput(phoneNumber) Log.i(TAG, "fillAndSaveAddress: \"Phone\" was set to $phoneNumber") @@ -397,6 +397,9 @@ class SettingsSubMenuAutofillRobot(private val composeTestRule: ComposeTestRule) composeTestRule.emailTextInput().performTextInput(emailAddress) Log.i(TAG, "fillAndSaveAddress: \"Email\" was set to $emailAddress") Log.i(TAG, "fillAndSaveAddress: Trying to click the \"Save\" button") + if (composeTestRule.saveButton().isNotDisplayed()) { + composeTestRule.saveButton().performScrollTo() + } composeTestRule.saveButton().performClick() Log.i(TAG, "fillAndSaveAddress: Clicked the \"Save\" button") Log.i(TAG, "fillAndSaveAddress: Waiting for $waitingTime ms for for \"Manage addresses\" button to exist") @@ -659,11 +662,11 @@ private fun navigateBackButton() = itemWithDescription(getStringResource(R.strin private fun ComposeTestRule.navigateBackButton() = onNodeWithContentDescription("Navigate back") private fun ComposeTestRule.nameTextInput() = onNodeWithTag(EditAddressTestTag.NAME_FIELD).onChildAt(0) private fun ComposeTestRule.streetAddressTextInput() = onNodeWithTag(EditAddressTestTag.STREET_ADDRESS_FIELD).onChildAt(0) -private fun ComposeTestRule.cityTextInput() = onNodeWithTag(EditAddressTestTag.CITY_FIELD).onChildAt(0) -private fun ComposeTestRule.subRegionDropDown() = onNodeWithTag(EditAddressTestTag.SUBREGION_FIELD) -private fun ComposeTestRule.zipCodeTextInput() = onNodeWithTag(EditAddressTestTag.ZIP_FIELD).onChildAt(0) +private fun ComposeTestRule.cityTextInput() = onNodeWithTag(EditAddressTestTag.ADDRESS_LEVEL2_FIELD).onChildAt(0) +private fun ComposeTestRule.subRegionDropDown() = onNodeWithTag(EditAddressTestTag.ADDRESS_LEVEL1_FIELD) +private fun ComposeTestRule.zipCodeTextInput() = onNodeWithTag(EditAddressTestTag.POSTAL_CODE_FIELD).onChildAt(0) private fun ComposeTestRule.countryDropDown() = onNodeWithTag(EditAddressTestTag.COUNTRY_FIELD) -private fun ComposeTestRule.phoneTextInput() = onNodeWithTag(EditAddressTestTag.PHONE_FIELD).onChildAt(0) +private fun ComposeTestRule.phoneTextInput() = onNodeWithTag(EditAddressTestTag.TEL_FIELD).onChildAt(0) private fun ComposeTestRule.emailTextInput() = onNodeWithTag(EditAddressTestTag.EMAIL_FIELD).onChildAt(0) private fun ComposeTestRule.saveButton() = onNodeWithTag(EditAddressTestTag.SAVE_BUTTON) private fun ComposeTestRule.cancelButton() = onNodeWithTag(EditAddressTestTag.CANCEL_BUTTON) @@ -694,8 +697,8 @@ private fun securedCreditCardsLaterButton() = onView(withId(android.R.id.button2 private fun saveButton() = itemWithResId("$packageName:id/save_button") private fun cancelButton() = itemWithResId("$packageName:id/cancel_button") private fun savedAddress(name: String) = mDevice.findObject(UiSelector().textContains(name)) -private fun ComposeTestRule.subRegionOption(subRegion: String) = onNodeWithText(subRegion) -private fun ComposeTestRule.countryOption(country: String) = onNodeWithText(country) +private fun ComposeTestRule.subRegionOption(subRegion: String) = onNodeWithTag(EditAddressTestTag.ADDRESS_LEVEL1_FIELD + ".$subRegion") +private fun ComposeTestRule.countryOption(country: String) = onNodeWithTag(EditAddressTestTag.COUNTRY_FIELD + ".$country") private fun expiryMonthOption(expiryMonth: String) = mDevice.findObject(UiSelector().textContains(expiryMonth)) private fun expiryYearOption(expiryYear: String) = mDevice.findObject(UiSelector().textContains(expiryYear)) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/debugsettings/addresses/FakeCreditCardsAddressesStorage.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/debugsettings/addresses/FakeCreditCardsAddressesStorage.kt @@ -72,9 +72,9 @@ internal fun String.generateFakeAddressForLangTag(): UpdatableAddressFields = wh streetAddress = " 530 E McDowell Rd", addressLevel3 = "", addressLevel2 = "Phoenix", - addressLevel1 = "Arizona", + addressLevel1 = "AZ", postalCode = "85003", - country = "United States", + country = "US", tel = " (602) 555-5555", email = "englishunitedstates@gmail.com", ) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/AddressEditorFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/AddressEditorFragment.kt @@ -8,8 +8,13 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup import androidx.fragment.compose.content +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import mozilla.components.concept.engine.Engine +import mozilla.components.concept.engine.autofill.AddressStructure import org.mozilla.fenix.SecureFragment import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.ext.hideToolbar @@ -18,9 +23,13 @@ import org.mozilla.fenix.settings.address.store.AddressEnvironment import org.mozilla.fenix.settings.address.store.AddressMiddleware import org.mozilla.fenix.settings.address.store.AddressState import org.mozilla.fenix.settings.address.store.AddressStore +import org.mozilla.fenix.settings.address.store.AddressStructureMiddleware import org.mozilla.fenix.settings.address.store.EnvironmentRehydrated import org.mozilla.fenix.settings.address.ui.edit.EditAddressScreen import org.mozilla.fenix.theme.FirefoxTheme +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine /** * Displays an address editor for adding and editing an address. @@ -39,15 +48,22 @@ class AddressEditorFragment : SecureFragment() { region = requireComponents.core.store.state.search.region, address = args.address, ), - middleware = listOf(AddressMiddleware()), + middleware = listOf( + AddressMiddleware(scope = viewLifecycleOwner.lifecycleScope), + AddressStructureMiddleware(scope = viewLifecycleOwner.lifecycleScope), + ), ) }.also { val storage = requireComponents.core.autofillStorage + val engine = requireComponents.core.engine + val crashReporter = requireComponents.analytics.crashReporter val environment = AddressEnvironment( navigateBack = { findNavController().popBackStack() }, createAddress = { fields -> storage.addAddress(fields).guid }, updateAddress = { guid, fields -> storage.updateAddress(guid, fields) }, deleteAddress = { guid -> storage.deleteAddress(guid) }, + getAddressStructure = engine::getAddressStructure, + submitCaughtException = crashReporter::submitCaughtException, ) it.dispatch(EnvironmentRehydrated(environment)) } @@ -61,3 +77,15 @@ class AddressEditorFragment : SecureFragment() { hideToolbar() } } + +private suspend fun Engine.getAddressStructure(countryCode: String): AddressStructure { + return withContext(Dispatchers.Main) { + suspendCoroutine { continuation -> + getAddressStructure( + countryCode = countryCode, + onSuccess = { fields -> continuation.resume(fields) }, + onError = { throwable -> continuation.resumeWithException(throwable) }, + ) + } + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/AddressUtils.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/AddressUtils.kt @@ -1,158 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.settings.address - -import androidx.annotation.StringRes -import mozilla.components.concept.storage.Address -import org.mozilla.fenix.R - -internal const val DEFAULT_COUNTRY = "US" - -/** - * Value type representing properties determined by the country used in an [Address]. - * This data is meant to mirror the data currently represented on desktop here: - * https://searchfox.org/mozilla-central/source/toolkit/components/formautofill/addressmetadata/addressReferences.js - * - * This can be expanded to included things like a list of applicable states/provinces per country - * or the names that should be used for each form field. - * - * Note: Most properties here need to be kept in sync with the data in the above desktop - * address reference file in order to prevent duplications when sync is enabled. There are - * ongoing conversations about how best to share that data cross-platform, if at all. - * Some more detail: https://bugzilla.mozilla.org/show_bug.cgi?id=1769809 - * - * Exceptions: [displayName] is a local property and stop-gap to a more robust solution. - * - * @property countryCode The country code used to lookup the address data. Should match desktop entries. - * @property displayName The name to display when selected. - * @property subregionTitleResource The string resource for the subregion title. - * @property subregions THe list of subregions. - */ -data class Country( - val countryCode: String, - val displayName: String, - @param:StringRes val subregionTitleResource: Int, - val subregions: List<String>, -) - -internal object AddressUtils { - /** - * The current list of supported countries. - */ - val countries = mapOf( - "CA" to Country( - countryCode = "CA", - displayName = "Canada", - subregionTitleResource = R.string.addresses_province, - subregions = Subregions.CA, - ), - "US" to Country( - countryCode = "US", - displayName = "United States", - subregionTitleResource = R.string.addresses_state, - subregions = Subregions.US, - ), - ) - - /** - * Get the country code associated with a [Country.displayName], or the [DEFAULT_COUNTRY] code - * if the display name is not supported. - */ - fun getCountryCode(displayName: String) = countries.values.find { - it.displayName == displayName - }?.countryCode ?: DEFAULT_COUNTRY -} - -/** - * Convert a [Country.displayName] to the associated country code. - */ -fun String.toCountryCode() = AddressUtils.getCountryCode(this) - -private object Subregions { - // This data is meant to mirror the data currently represented on desktop here: - // https://searchfox.org/mozilla-central/source/toolkit/components/formautofill/addressmetadata/addressReferences.js - val CA = listOf( - "Alberta", - "British Columbia", - "Manitoba", - "New Brunswick", - "Newfoundland and Labrador", - "Northwest Territories", - "Nova Scotia", - "Nunavut", - "Ontario", - "Prince Edward Island", - "Quebec", - "Saskatchewan", - "Yukon", - ) - - // This data is meant to mirror the data currently represented on desktop here: - // https://searchfox.org/mozilla-central/source/toolkit/components/formautofill/addressmetadata/addressReferences.js - val US = listOf( - "Alabama", - "Alaska", - "American Samoa", - "Arizona", - "Arkansas", - "Armed Forces (AA)", - "Armed Forces (AE)", - "Armed Forces (AP)", - "California", - "Colorado", - "Connecticut", - "Delaware", - "District of Columbia", - "Florida", - "Georgia", - "Guam", - "Hawaii", - "Idaho", - "Illinois", - "Indiana", - "Iowa", - "Kansas", - "Kentucky", - "Louisiana", - "Maine", - "Marshall Islands", - "Maryland", - "Massachusetts", - "Michigan", - "Micronesia", - "Minnesota", - "Mississippi", - "Missouri", - "Montana", - "Nebraska", - "Nevada", - "New Hampshire", - "New Jersey", - "New Mexico", - "New York", - "North Carolina", - "North Dakota", - "Northern Mariana Islands", - "Ohio", - "Oklahoma", - "Oregon", - "Palau", - "Pennsylvania", - "Puerto Rico", - "Rhode Island", - "South Carolina", - "South Dakota", - "Tennessee", - "Texas", - "Utah", - "Vermont", - "Virgin Islands", - "Virginia", - "Washington", - "West Virginia", - "Wisconsin", - "Wyoming", - ) -} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressAction.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressAction.kt @@ -4,6 +4,7 @@ package org.mozilla.fenix.settings.address.store +import mozilla.components.concept.engine.autofill.AddressStructure import mozilla.components.lib.state.Action /** @@ -21,24 +22,34 @@ sealed class FormChange : AddressAction { data class Name(val updatedText: String) : FormChange() /** + * Organization field has been changed. + */ + data class Organization(val updatedText: String) : FormChange() + + /** * Street Address field has changed. */ data class StreetAddress(val updatedText: String) : FormChange() /** + * Sub region (i.e. state or province) field has changed. + */ + data class AddressLevel1(val updatedText: String) : FormChange() + + /** * City field has changed. */ - data class City(val updatedText: String) : FormChange() + data class AddressLevel2(val updatedText: String) : FormChange() /** - * Postal code field has changed. + * Address Level3 field has changed, used to narrow down an address in some countries. */ - data class PostalCode(val updatedText: String) : FormChange() + data class AddressLevel3(val updatedText: String) : FormChange() /** - * Sub region (i.e. state or province) field has changed. + * Postal code field has changed. */ - data class SubRegion(val subRegion: String) : FormChange() + data class PostalCode(val updatedText: String) : FormChange() /** * Country field has changed. @@ -48,7 +59,7 @@ sealed class FormChange : AddressAction { /** * Telephone field has changed. */ - data class Phone(val updatedText: String) : FormChange() + data class Tel(val updatedText: String) : FormChange() /** * Email field has changed. @@ -100,3 +111,11 @@ data object SaveTapped : AddressAction * Delete button was tapped. */ data object DeleteTapped : AddressAction + +/** + * The Address Structure was loaded. + */ +data class AddressStructureLoaded( + val structure: AddressStructure, + val initialLoad: Boolean, +) : AddressAction diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressEnvironment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressEnvironment.kt @@ -4,74 +4,28 @@ package org.mozilla.fenix.settings.address.store +import mozilla.components.concept.engine.autofill.AddressStructure import mozilla.components.concept.storage.Address import mozilla.components.concept.storage.UpdatableAddressFields /** - * An interface for handling back navigation. - */ -fun interface NavigateBack { - /** - * navigate back. - */ - fun navigateBack() -} - -/** - * An interface for handling creating a new [Address] - */ -fun interface CreateAddress { - /** - * Creates an [Address]. - * - * @param address [UpdatableAddressFields] used to create an address - * @return the guid of the newly created [Address] as a [String] - */ - suspend fun createAddress(address: UpdatableAddressFields): String -} - -/** - * An interface for updating an [Address] - */ -fun interface UpdateAddress { - /** - * Updates an [Address]. - * - * @param guid of the [Address] to update. - * @param address [UpdatableAddressFields] used to update an address - */ - suspend fun updateAddress(guid: String, address: UpdatableAddressFields) -} - -/** - * An interface for handling deleting an [Address] - */ -fun interface DeleteAddress { - /** - * Deletes an [Address]. - * - * @param guid the id of the [Address] to delete. - */ - suspend fun deleteAddress(guid: String) -} - -/** * Groups together all of the [AddressStore] dependencies. * - * @param navigateBack used to navigate back. - * @param createAddress used to create a new [Address]. - * @param updateAddress used to update an existing [Address]. - * @param deleteAddress used to delete an [Address]. + * @property navigateBack used to navigate back. + * @property createAddress used to create a new [Address]. + * @property updateAddress used to update an existing [Address]. + * @property deleteAddress used to delete an [Address]. + * @property getAddressStructure used to fetch an [AddressStructure]. + * @property submitCaughtException used to submit caught exceptions. */ data class AddressEnvironment( - private val navigateBack: NavigateBack, - private val createAddress: CreateAddress, - private val updateAddress: UpdateAddress, - private val deleteAddress: DeleteAddress, -) : NavigateBack by navigateBack, - CreateAddress by createAddress, - UpdateAddress by updateAddress, - DeleteAddress by deleteAddress { + val navigateBack: () -> Unit, + val createAddress: suspend (address: UpdatableAddressFields) -> String, + val updateAddress: suspend (guid: String, address: UpdatableAddressFields) -> Unit, + val deleteAddress: suspend (guid: String) -> Unit, + val getAddressStructure: suspend (countryCode: String) -> AddressStructure, + val submitCaughtException: (Throwable) -> Unit, +) { internal companion object { val empty: AddressEnvironment get() = AddressEnvironment( @@ -79,6 +33,8 @@ data class AddressEnvironment( createAddress = { "empty-guid" }, updateAddress = { _, _ -> }, deleteAddress = { }, + getAddressStructure = { AddressStructure(listOf()) }, + submitCaughtException = { _ -> }, ) } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressMiddleware.kt @@ -4,8 +4,10 @@ package org.mozilla.fenix.settings.address.store +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch import mozilla.components.lib.state.Middleware @@ -17,10 +19,12 @@ import org.mozilla.fenix.GleanMetrics.Addresses * * @param environment used to hold the dependencies. * @param scope a [CoroutineScope] used to launch coroutines. + * @param ioDispatcher the dispatcher to run background code on. */ class AddressMiddleware( private var environment: AddressEnvironment? = null, private val scope: CoroutineScope = MainScope(), + private val ioDispatcher: CoroutineDispatcher = IO, ) : Middleware<AddressState, AddressAction> { override fun invoke( context: MiddlewareContext<AddressState, AddressAction>, @@ -50,7 +54,7 @@ class AddressMiddleware( } } - private fun runAndNavigateBack(action: suspend () -> Unit) = scope.launch { + private fun runAndNavigateBack(action: suspend () -> Unit) = scope.launch(ioDispatcher) { action() scope.launch(Dispatchers.Main) { diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressReducer.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressReducer.kt @@ -4,47 +4,67 @@ package org.mozilla.fenix.settings.address.store -import org.mozilla.fenix.settings.address.DEFAULT_COUNTRY +import mozilla.components.concept.engine.autofill.AddressStructure +import mozilla.components.concept.storage.UpdatableAddressFields /** * Function for reducing a new address state based on the received action. */ fun addressReducer(state: AddressState, action: AddressAction): AddressState { return when (action) { - ViewAppeared -> state.update { - val countryCode = country - .ifBlank { state.region?.home } - .takeIf { it in state.availableCountries.keys } ?: DEFAULT_COUNTRY - - val subRegions = state.availableCountries[countryCode]?.subregions ?: listOf() - val subRegion = addressLevel1 - .takeIf { it in subRegions } ?: subRegions.first() - - copy( - addressLevel1 = subRegion, - country = countryCode, - ) - } is FormChange.Name -> state.update { copy(name = action.updatedText) } + is FormChange.Organization -> state.update { copy(organization = action.updatedText) } is FormChange.StreetAddress -> state.update { copy(streetAddress = action.updatedText) } - is FormChange.City -> state.update { copy(addressLevel2 = action.updatedText) } - is FormChange.SubRegion -> state.update { copy(addressLevel1 = action.subRegion) } + is FormChange.AddressLevel1 -> state.update { copy(addressLevel1 = action.updatedText) } + is FormChange.AddressLevel2 -> state.update { copy(addressLevel2 = action.updatedText) } + is FormChange.AddressLevel3 -> state.update { copy(addressLevel3 = action.updatedText) } is FormChange.PostalCode -> state.update { copy(postalCode = action.updatedText) } - is FormChange.Country -> state.update { - val subRegions = state.availableCountries[action.countryCode]?.subregions ?: listOf() - val subRegion = addressLevel1 - .takeIf { it in subRegions } ?: subRegions.first() - copy( - addressLevel1 = subRegion, - country = action.countryCode, - ) - } - is FormChange.Phone -> state.update { copy(tel = action.updatedText) } + is FormChange.Country -> state.update { copy(country = action.countryCode) } + is FormChange.Tel -> state.update { copy(tel = action.updatedText) } is FormChange.Email -> state.update { copy(email = action.updatedText) } is DeleteTapped -> state.copy(deleteDialog = DialogState.Presenting) is DeleteDialogAction.CancelTapped -> state.copy(deleteDialog = DialogState.Inert) - is DeleteDialogAction.DeleteTapped, + is AddressStructureLoaded -> state.copy( + structureState = AddressStructureState.Loaded(action.structure), + ).update { + // We don't want to reset the values on the initial load of the form so bail out early. + if (action.initialLoad && state.guidToUpdate != null) { + return@update this + } + + // Always clear select values because they are not persisted between countries. + // For example from US with state NY, we don't want the address-level1 to be NY + // when changing to another country that doesn't have state options + val initialState = this.copy(addressLevel1 = "") + + // We want to pre-select a field + action.structure.fields.fold(initialState) { acc, field -> + if (field is AddressStructure.Field.SelectField) { + acc.update(field) + } else { + acc + } + } + } + is DeleteDialogAction.DeleteTapped, ViewAppeared, is EnvironmentRehydrated, BackTapped, CancelTapped, SaveTapped, -> state } } + +private fun UpdatableAddressFields.update(field: AddressStructure.Field.SelectField) = when (field.id) { + AddressStructure.Field.ID.AddressLevel1 -> copy(addressLevel1 = field.value) + AddressStructure.Field.ID.AddressLevel2 -> copy(addressLevel2 = field.value) + AddressStructure.Field.ID.AddressLevel3 -> copy(addressLevel3 = field.value) + AddressStructure.Field.ID.Country -> copy(country = field.value) + AddressStructure.Field.ID.Email -> copy(email = field.value) + AddressStructure.Field.ID.Name -> copy(name = field.value) + AddressStructure.Field.ID.Organization -> copy(organization = field.value) + AddressStructure.Field.ID.PostalCode -> copy(postalCode = field.value) + AddressStructure.Field.ID.StreetAddress -> copy(streetAddress = field.value) + AddressStructure.Field.ID.Tel -> copy(tel = field.value) + is AddressStructure.Field.ID.Unknown -> this +} + +private val AddressStructure.Field.SelectField.value: String + get() = defaultSelectionKey.takeUnless { it.isEmpty() } ?: options.firstOrNull()?.key ?: "" diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressState.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressState.kt @@ -5,12 +5,12 @@ package org.mozilla.fenix.settings.address.store import mozilla.components.browser.state.search.RegionState +import mozilla.components.concept.engine.autofill.AddressStructure import mozilla.components.concept.storage.Address import mozilla.components.concept.storage.UpdatableAddressFields import mozilla.components.lib.state.State -import org.mozilla.fenix.settings.address.AddressUtils -import org.mozilla.fenix.settings.address.Country -import org.mozilla.fenix.settings.address.DEFAULT_COUNTRY + +private const val DEFAULT_COUNTRY = "US" /** * Represents the state of the deletion dialog. @@ -28,20 +28,44 @@ sealed class DialogState { } /** + * Represents the various states of loading an [AddressStructure]. + */ +sealed class AddressStructureState { + /** + * Provides convenient access to the underlying [AddressStructure] + */ + abstract val structure: AddressStructure + + /** + * The initial state before a structure is loaded + */ + data object Inert : AddressStructureState() { + /** + * Provides a default address structure of an empty list of fields. + */ + override val structure: AddressStructure + get() = AddressStructure(listOf()) + } + + /** + * Represents the state of a loaded [AddressStructure]. + */ + data class Loaded(override val structure: AddressStructure) : AddressStructureState() +} + +/** * Represents the state of the Bookmarks list screen and its various subscreens. * * @property guidToUpdate guid of the address we are editing. * @property address updatable properties of the address. + * @property structureState the address structure used render the address edtior. * @property deleteDialog state for the dialog that is presented when deleting an address. - * @property region the region used to calculate the default country. - * @property availableCountries a map containing the available countries. */ data class AddressState( val guidToUpdate: String?, val address: UpdatableAddressFields, + val structureState: AddressStructureState = AddressStructureState.Inert, val deleteDialog: DialogState = DialogState.Inert, - val region: RegionState? = RegionState.Default, - val availableCountries: Map<String, Country> = AddressUtils.countries, ) : State { /** * Static functions for [AddressState] @@ -55,12 +79,18 @@ data class AddressState( * @param address [Address] for creating the [UpdatableAddressFields]. */ fun initial( - region: RegionState? = RegionState.Default, + region: RegionState? = null, address: Address? = null, ): AddressState { + // We want to use the country unless it is empty, we fall back to the users region unless + // it hasn't loaded yet meaning that we will have the Default value of XX falling back to + // DEFAULT_COUNTRY. + val countryCode = address?.country?.takeUnless { it.isEmpty() } + ?: (region?.takeUnless { it == RegionState.Default })?.home + ?: DEFAULT_COUNTRY + return AddressState( guidToUpdate = address?.guid, - region = region, address = UpdatableAddressFields( name = address?.name ?: "", organization = address?.organization ?: "", @@ -69,7 +99,7 @@ data class AddressState( addressLevel2 = address?.addressLevel2 ?: "", addressLevel1 = address?.addressLevel1 ?: "", postalCode = address?.postalCode ?: "", - country = address?.country ?: "", + country = countryCode, tel = address?.tel ?: "", email = address?.email ?: "", ), @@ -78,9 +108,6 @@ data class AddressState( } } -internal val AddressState.selectedCountry: Country? - get() = availableCountries[address.country.ifBlank { DEFAULT_COUNTRY }] - internal val AddressState.isEditing: Boolean get() = guidToUpdate != null diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressStructureMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressStructureMiddleware.kt @@ -0,0 +1,99 @@ +/* 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.address.store + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import mozilla.components.concept.engine.autofill.AddressStructure +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.MiddlewareContext +import mozilla.components.lib.state.Store + +/** + * Exception that is thrown when we encounter an unknown address field ID. + * + * @property countryCode used to load the address structure. + * @property id that was unexpected. + */ +data class UnknownID( + val countryCode: String, + val id: String, +) : IllegalStateException("Unknown ID: $id in: $countryCode") + +/** + * Exception that is submitted when we encounter an unknown localization key. + * + * @property countryCode used to load the address structure. + * @property key that was unexpected. + */ +data class UnknownLocalizationKey( + val countryCode: String, + val key: String, +) : IllegalStateException("Unknown localization key: $key in: $countryCode") + +/** + * Middleware that handles [AddressStore] side-effects. + * + * @param environment used to hold the dependencies. + * @param scope a [CoroutineScope] used to launch coroutines. + * @param ioDispatcher the dispatcher to run background code on. + */ +class AddressStructureMiddleware( + private var environment: AddressEnvironment? = null, + private val scope: CoroutineScope = MainScope(), + private val ioDispatcher: CoroutineDispatcher = IO, +) : Middleware<AddressState, AddressAction> { + override fun invoke( + context: MiddlewareContext<AddressState, AddressAction>, + next: (AddressAction) -> Unit, + action: AddressAction, + ) { + val preReductionCountry = context.state.address.country + next(action) + + when (action) { + is EnvironmentRehydrated -> environment = action.environment + is ViewAppeared -> loadAddressStructure(context.store, true) + is FormChange.Country -> if (preReductionCountry != context.store.state.address.country) { + loadAddressStructure(context.store, false) + } + else -> { /* noop */ } + } + } + + private fun loadAddressStructure( + store: Store<AddressState, AddressAction>, + initialLoad: Boolean, + ) = scope.launch(ioDispatcher) { + val structure = environment?.getAddressStructure(store.state.address.country) ?: return@launch + structure.validate(store.state.address.country) + store.dispatch( + AddressStructureLoaded( + structure, + initialLoad, + ), + ) + } + + private fun AddressStructure.validate(countryCode: String) { + for (field in fields) { + val id = field.id + val localizationKey = field.localizationKey + + if (id is AddressStructure.Field.ID.Unknown) { + throw UnknownID(countryCode, id.value) + } + + if (localizationKey is AddressStructure.Field.LocalizationKey.Unknown) { + environment?.submitCaughtException( + UnknownLocalizationKey(countryCode, localizationKey.value), + ) + } + } + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/ui/edit/EditAddressScreen.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/ui/edit/EditAddressScreen.kt @@ -6,22 +6,23 @@ package org.mozilla.fenix.settings.address.ui.edit -import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -34,20 +35,22 @@ import mozilla.components.compose.base.button.DestructiveButton import mozilla.components.compose.base.button.FilledButton import mozilla.components.compose.base.button.OutlinedButton import mozilla.components.compose.base.menu.MenuItem +import mozilla.components.compose.base.modifier.thenConditional import mozilla.components.compose.base.textfield.TextField import mozilla.components.compose.base.textfield.TextFieldColors +import mozilla.components.concept.engine.autofill.AddressStructure +import mozilla.components.concept.storage.UpdatableAddressFields import mozilla.components.lib.state.ext.observeAsState import org.mozilla.fenix.R -import org.mozilla.fenix.settings.address.Country import org.mozilla.fenix.settings.address.store.AddressState import org.mozilla.fenix.settings.address.store.AddressStore +import org.mozilla.fenix.settings.address.store.AddressStructureState import org.mozilla.fenix.settings.address.store.CancelTapped import org.mozilla.fenix.settings.address.store.DeleteTapped import org.mozilla.fenix.settings.address.store.FormChange import org.mozilla.fenix.settings.address.store.SaveTapped import org.mozilla.fenix.settings.address.store.ViewAppeared import org.mozilla.fenix.settings.address.store.isEditing -import org.mozilla.fenix.settings.address.store.selectedCountry import org.mozilla.fenix.theme.FirefoxTheme import mozilla.components.compose.base.text.Text as DropdownText @@ -64,106 +67,112 @@ fun EditAddressScreen(store: AddressStore) { }, containerColor = FirefoxTheme.colors.layer1, ) { paddingValues -> - DeleteAddressDialog(store) - - val state by store.observeAsState(store.state) { it } - val address = state.address - + val structureState by store.observeAsState(store.state.structureState) { it.structureState } + var hasRequestedFocus by remember { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { - focusRequester.requestFocus() + store.dispatch(ViewAppeared) + } + + LaunchedEffect(structureState) { + if (!hasRequestedFocus && structureState is AddressStructureState.Loaded) { + focusRequester.requestFocus() + hasRequestedFocus = true + } } - Column( + DeleteAddressDialog(store) + + LazyColumn( verticalArrangement = Arrangement.spacedBy(10.dp), + state = rememberLazyListState(), modifier = Modifier .padding(paddingValues) .padding( horizontal = FirefoxTheme.layout.space.static200, vertical = FirefoxTheme.layout.space.static100, ) - .imePadding() - .verticalScroll(state = rememberScrollState()), + .imePadding(), ) { - AddressField( - field = Field.name, - value = address.name, - modifier = Modifier.focusRequester(focusRequester), - ) { - store.dispatch(FormChange.Name(it)) - } - AddressField(field = Field.streetAddress, value = address.streetAddress) { - store.dispatch(FormChange.StreetAddress(it)) - } - AddressField(field = Field.city, value = address.addressLevel2) { - store.dispatch(FormChange.City(it)) + val firstTextField = structureState.structure.fields.firstOrNull { + it is AddressStructure.Field.TextField } - state.selectedCountry?.let { country -> - SubregionDropdown( - subregionTitleResource = country.subregionTitleResource, - subregions = country.subregions, - currentSubregion = state.address.addressLevel1, - onSubregionChange = { store.dispatch(FormChange.SubRegion(it)) }, - ) + items( + items = structureState.structure.fields, + key = { it.id.id }, + ) { item -> + when (item) { + is AddressStructure.Field.TextField -> { + TextField( + store = store, + field = item, + modifier = Modifier.thenConditional( + Modifier.focusRequester(focusRequester), + ) { item == firstTextField }, + ) + } + is AddressStructure.Field.SelectField -> SelectField(store, field = item) + } } - AddressField(field = Field.zip, value = address.postalCode) { - store.dispatch(FormChange.PostalCode(it)) - } - - CountryDropdown( - availableCountries = state.availableCountries, - onCountryChange = { store.dispatch(FormChange.Country(it)) }, - currentCountry = state.address.country, - ) - - AddressField(field = Field.phone, value = address.tel) { - store.dispatch(FormChange.Phone(it)) + item { + if (structureState !is AddressStructureState.Inert) { + FormButtons(store) + } } - AddressField(field = Field.email, value = address.email) { - store.dispatch(FormChange.Email(it)) - } - - FormButtons(store) } } } @Composable -private fun CountryDropdown( - availableCountries: Map<String, Country>, - onCountryChange: (String) -> Unit = {}, - currentCountry: String, +private fun TextField( + store: AddressStore, + field: AddressStructure.Field.TextField, + modifier: Modifier = Modifier, ) { - val countryList = availableCountries.map { (countryKey, countryValue) -> - MenuItem.CheckableItem(DropdownText.String(countryValue.displayName), currentCountry == countryKey) { - onCountryChange(countryKey) - } + val value by store.observeAsState(store.state.address.valueForID(field.id)) { + it.address.valueForID(field.id) } - AddressDropdown(Field.country, countryList) + + TextField( + value = value, + onValueChange = { store.dispatch(field.id.formChangeAction(it)) }, + placeholder = "", + errorText = "", + modifier = modifier.testTag(field.id.testTag), + label = field.localizationKey.localizedString(), + colors = TextFieldColors.default( + placeholderColor = FirefoxTheme.colors.textPrimary, + ), + ) } @Composable -private fun SubregionDropdown( - @StringRes subregionTitleResource: Int, - subregions: List<String>, - currentSubregion: String?, - onSubregionChange: (String) -> Unit = {}, +private fun SelectField( + store: AddressStore, + field: AddressStructure.Field.SelectField, ) { - val countryList = subregions.map { + val value by store.observeAsState(store.state.address.valueForID(field.id)) { + it.address.valueForID(field.id) + } + + val items = field.options.map { MenuItem.CheckableItem( - DropdownText.String(it), - currentSubregion == it, + text = DropdownText.String(it.value), + isChecked = value == it.key, + testTag = field.id.testTag + ".${it.key}", ) { - onSubregionChange(it) + store.dispatch(field.id.formChangeAction(it.key)) } } - AddressDropdown( - Field.subregion(subregionTitleResource), - countryList, + Dropdown( + label = field.localizationKey.localizedString(), + placeholder = "", + dropdownItems = items, + modifier = Modifier.testTag(field.id.testTag), ) } @@ -199,68 +208,84 @@ private fun FormButtons(store: AddressStore) { } } -@Composable -private fun AddressField( - field: Field, - value: String, - modifier: Modifier = Modifier, - onChange: (String) -> Unit, -) { - TextField( - value = value, - onValueChange = onChange, - placeholder = "", - errorText = "", - modifier = modifier.testTag(field.testTag), - label = stringResource(field.labelId), - colors = TextFieldColors.default( - placeholderColor = FirefoxTheme.colors.textPrimary, - ), - - ) +private data class InvalidIDException(val id: String) : IllegalStateException("Invalid id: $id") + +private fun UpdatableAddressFields.valueForID(id: AddressStructure.Field.ID) = when (id) { + is AddressStructure.Field.ID.Name -> name + is AddressStructure.Field.ID.Organization -> organization + is AddressStructure.Field.ID.StreetAddress -> streetAddress + is AddressStructure.Field.ID.AddressLevel1 -> addressLevel1 + is AddressStructure.Field.ID.AddressLevel2 -> addressLevel2 + is AddressStructure.Field.ID.AddressLevel3 -> addressLevel3 + is AddressStructure.Field.ID.PostalCode -> postalCode + is AddressStructure.Field.ID.Country -> country + is AddressStructure.Field.ID.Tel -> tel + is AddressStructure.Field.ID.Email -> email + is AddressStructure.Field.ID.Unknown -> throw InvalidIDException(id.value) } -@Composable -private fun AddressDropdown( - field: Field, - dropdownItems: List<MenuItem.CheckableItem>, -) { - Dropdown( - label = stringResource(field.labelId), - placeholder = "", - dropdownItems = dropdownItems, - modifier = Modifier.testTag(field.testTag), - ) +private fun AddressStructure.Field.ID.formChangeAction(value: String) = when (this) { + is AddressStructure.Field.ID.Name -> FormChange.Name(value) + is AddressStructure.Field.ID.Organization -> FormChange.Organization(value) + is AddressStructure.Field.ID.StreetAddress -> FormChange.StreetAddress(value) + is AddressStructure.Field.ID.AddressLevel1 -> FormChange.AddressLevel1(value) + is AddressStructure.Field.ID.AddressLevel2 -> FormChange.AddressLevel2(value) + is AddressStructure.Field.ID.AddressLevel3 -> FormChange.AddressLevel3(value) + is AddressStructure.Field.ID.PostalCode -> FormChange.PostalCode(value) + is AddressStructure.Field.ID.Country -> FormChange.Country(value) + is AddressStructure.Field.ID.Tel -> FormChange.Tel(value) + is AddressStructure.Field.ID.Email -> FormChange.Email(value) + is AddressStructure.Field.ID.Unknown -> throw InvalidIDException(value) } -internal data class Field( - @get:StringRes val labelId: Int, - val testTag: String, -) { - companion object { - val name: Field - get() = Field(R.string.addresses_name, EditAddressTestTag.NAME_FIELD) - - val streetAddress: Field - get() = Field(R.string.addresses_street_address, EditAddressTestTag.STREET_ADDRESS_FIELD) - - val city: Field - get() = Field(R.string.addresses_city, EditAddressTestTag.CITY_FIELD) - - val zip: Field - get() = Field(R.string.addresses_zip, EditAddressTestTag.ZIP_FIELD) - - val country: Field - get() = Field(R.string.addresses_country, EditAddressTestTag.COUNTRY_FIELD) - - fun subregion(@StringRes titleResource: Int) = Field(titleResource, EditAddressTestTag.SUBREGION_FIELD) - - val phone: Field - get() = Field(R.string.addresses_phone, EditAddressTestTag.PHONE_FIELD) - - val email: Field - get() = Field(R.string.addresses_email, EditAddressTestTag.EMAIL_FIELD) +private val AddressStructure.Field.ID.testTag: String + get() = when (this) { + is AddressStructure.Field.ID.Name -> EditAddressTestTag.NAME_FIELD + is AddressStructure.Field.ID.Organization -> EditAddressTestTag.ORGANIZATION_FIELD + is AddressStructure.Field.ID.StreetAddress -> EditAddressTestTag.STREET_ADDRESS_FIELD + is AddressStructure.Field.ID.AddressLevel1 -> EditAddressTestTag.ADDRESS_LEVEL1_FIELD + is AddressStructure.Field.ID.AddressLevel2 -> EditAddressTestTag.ADDRESS_LEVEL2_FIELD + is AddressStructure.Field.ID.AddressLevel3 -> EditAddressTestTag.ADDRESS_LEVEL3_FIELD + is AddressStructure.Field.ID.PostalCode -> EditAddressTestTag.POSTAL_CODE_FIELD + is AddressStructure.Field.ID.Country -> EditAddressTestTag.COUNTRY_FIELD + is AddressStructure.Field.ID.Tel -> EditAddressTestTag.TEL_FIELD + is AddressStructure.Field.ID.Email -> EditAddressTestTag.EMAIL_FIELD + is AddressStructure.Field.ID.Unknown -> throw InvalidIDException(value) } + +@Composable +private fun AddressStructure.Field.LocalizationKey.localizedString() = when (this) { + is AddressStructure.Field.LocalizationKey.Name -> stringResource(R.string.addresses_name) + is AddressStructure.Field.LocalizationKey.Organization -> stringResource(R.string.addresses_organization) + is AddressStructure.Field.LocalizationKey.StreetAddress -> stringResource(R.string.addresses_street_address) + is AddressStructure.Field.LocalizationKey.Street -> stringResource(R.string.addresses_street_address) + is AddressStructure.Field.LocalizationKey.Neighborhood -> stringResource(R.string.addresses_neighborhood) + is AddressStructure.Field.LocalizationKey.VillageTownship -> stringResource(R.string.addresses_village_township) + is AddressStructure.Field.LocalizationKey.Island -> stringResource(R.string.addresses_island) + is AddressStructure.Field.LocalizationKey.Townland -> stringResource(R.string.addresses_townland) + is AddressStructure.Field.LocalizationKey.City -> stringResource(R.string.addresses_city) + is AddressStructure.Field.LocalizationKey.District -> stringResource(R.string.addresses_district) + is AddressStructure.Field.LocalizationKey.PostTown -> stringResource(R.string.addresses_post_town) + is AddressStructure.Field.LocalizationKey.Suburb -> stringResource(R.string.addresses_suburb) + is AddressStructure.Field.LocalizationKey.Province -> stringResource(R.string.addresses_province) + is AddressStructure.Field.LocalizationKey.State -> stringResource(R.string.addresses_state) + is AddressStructure.Field.LocalizationKey.County -> stringResource(R.string.addresses_county) + is AddressStructure.Field.LocalizationKey.Parish -> stringResource(R.string.addresses_parish) + is AddressStructure.Field.LocalizationKey.Prefecture -> stringResource(R.string.addresses_prefecture) + is AddressStructure.Field.LocalizationKey.Area -> stringResource(R.string.addresses_area) + is AddressStructure.Field.LocalizationKey.DoSi -> stringResource(R.string.addresses_do_si) + is AddressStructure.Field.LocalizationKey.Department -> stringResource(R.string.addresses_department) + is AddressStructure.Field.LocalizationKey.Emirate -> stringResource(R.string.addresses_emirate) + is AddressStructure.Field.LocalizationKey.Oblast -> stringResource(R.string.addresses_oblast) + is AddressStructure.Field.LocalizationKey.Pin -> stringResource(R.string.addresses_pin) + is AddressStructure.Field.LocalizationKey.PostalCode -> stringResource(R.string.addresses_postal_code) + is AddressStructure.Field.LocalizationKey.Zip -> stringResource(R.string.addresses_zip) + is AddressStructure.Field.LocalizationKey.Eircode -> stringResource(R.string.addresses_eircode) + is AddressStructure.Field.LocalizationKey.Country -> stringResource(R.string.addresses_country) + is AddressStructure.Field.LocalizationKey.CountryOnly -> stringResource(R.string.addresses_country_only) + is AddressStructure.Field.LocalizationKey.Tel -> stringResource(R.string.addresses_phone) + is AddressStructure.Field.LocalizationKey.Email -> stringResource(R.string.addresses_email) + is AddressStructure.Field.LocalizationKey.Unknown -> key } @FlexibleWindowLightDarkPreview diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/ui/edit/EditAddressTestTag.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/ui/edit/EditAddressTestTag.kt @@ -6,12 +6,14 @@ package org.mozilla.fenix.settings.address.ui.edit internal object EditAddressTestTag { const val NAME_FIELD = "address.edit.field.name" + const val ORGANIZATION_FIELD = "address.edit.field.organization" const val STREET_ADDRESS_FIELD = "address.edit.field.street_address" - const val CITY_FIELD = "address.edit.field.city" - const val ZIP_FIELD = "address.edit.field.zip" + const val ADDRESS_LEVEL1_FIELD = "address.edit.field.addressLevel1" + const val ADDRESS_LEVEL2_FIELD = "address.edit.field.addressLevel2" + const val ADDRESS_LEVEL3_FIELD = "address.edit.field.addressLevel3" + const val POSTAL_CODE_FIELD = "address.edit.field.postal_code" const val COUNTRY_FIELD = "address.edit.field.country" - const val SUBREGION_FIELD = "address.edit.field.subregion" - const val PHONE_FIELD = "address.edit.field.phone" + const val TEL_FIELD = "address.edit.field.tel" const val EMAIL_FIELD = "address.edit.field.email" const val SAVE_BUTTON = "address.edit.button.save" const val CANCEL_BUTTON = "address.edit.button.cancel" diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/store/AddressStateTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/store/AddressStateTest.kt @@ -0,0 +1,59 @@ +/* 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.address.store + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.browser.state.search.RegionState +import mozilla.components.concept.storage.Address +import mozilla.components.concept.storage.UpdatableAddressFields +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AddressStateTest { + @Test + fun `GIVEN a null Address and null RegionState WHEN an AddressState is constructed THEN the country should be US`() { + val actual = AddressState.initial() + val expected = AddressState( + guidToUpdate = null, + address = emptyUpdatableAddress.copy(country = "US"), + ) + + assertEquals(expected, actual) + } + + @Test + fun `GIVEN a null Address and RegionState WHEN an AddressState is constructed THEN the country should be the RegionState home value`() { + val actual = AddressState.initial( + region = RegionState("CA", "CA"), + ) + val expected = AddressState( + guidToUpdate = null, + address = emptyUpdatableAddress.copy(country = "CA"), + ) + + assertEquals(expected, actual) + } + + @Test + fun `GIVEN an Address WHEN an AddressState is constructed THEN the address should match`() { + val actualAddress = emptyAddress.copy( + guid = "BEEF", + name = "Mozilla", + country = "CA", + ) + val actual = AddressState.initial(address = actualAddress) + + val expected = AddressState( + guidToUpdate = "BEEF", + address = emptyUpdatableAddress.copy(name = "Mozilla", country = "CA"), + ) + + assertEquals(expected, actual) + } +} +private val emptyAddress = Address("", "", "", "", "", "", "", "", "", "", "") +private val emptyUpdatableAddress = UpdatableAddressFields("", "", "", "", "", "", "", "US", "", "") diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/store/AddressStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/store/AddressStoreTest.kt @@ -2,6 +2,7 @@ package org.mozilla.fenix.settings.address.store import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.CoroutineScope +import mozilla.components.concept.engine.autofill.AddressStructure import mozilla.components.concept.storage.Address import mozilla.components.concept.storage.UpdatableAddressFields import mozilla.components.support.test.rule.MainCoroutineRule @@ -18,29 +19,151 @@ class AddressStoreTest { val coroutinesTestRule = MainCoroutineRule() @Test + fun `GIVEN a store WHEN a user edits an address THEN the address structure is loaded`() = runTestOnMain { + val expectedAddressStructure = AddressStructure( + listOf( + AddressStructure.Field.TextField(AddressStructure.Field.ID.Name, AddressStructure.Field.LocalizationKey.Name), + AddressStructure.Field.TextField(AddressStructure.Field.ID.Organization, AddressStructure.Field.LocalizationKey.Organization), + AddressStructure.Field.TextField(AddressStructure.Field.ID.StreetAddress, AddressStructure.Field.LocalizationKey.StreetAddress), + ), + ) + + val store = makeStore(this) { + copy( + getAddressStructure = { _ -> expectedAddressStructure }, + ) + } + + store.dispatch(ViewAppeared) + + assertEquals( + AddressStructureState.Loaded(expectedAddressStructure), + store.state.structureState, + ) + } + + @Test + fun `GIVEN a store WHEN a user edits an address and changes the country THEN the address structure is loaded`() = runTestOnMain { + val expectedAddressStructure = AddressStructure( + listOf( + AddressStructure.Field.TextField(AddressStructure.Field.ID.Name, AddressStructure.Field.LocalizationKey.Name), + AddressStructure.Field.TextField(AddressStructure.Field.ID.Organization, AddressStructure.Field.LocalizationKey.Organization), + AddressStructure.Field.TextField(AddressStructure.Field.ID.StreetAddress, AddressStructure.Field.LocalizationKey.StreetAddress), + AddressStructure.Field.SelectField( + AddressStructure.Field.ID.AddressLevel1, + AddressStructure.Field.LocalizationKey.Province, + "", + listOf( + AddressStructure.Field.SelectField.Option("AL", "Alberta"), + ), + ), + ), + ) + + val countries = mutableListOf<String>() + val store = makeStore( + state = AddressState.initial().copy( + address = emptyUpdatableAddress.copy( + addressLevel1 = "WA", + country = "US", + ), + ), + scope = this, + ) { + copy( + getAddressStructure = { countryCode -> + countries.add(countryCode) + expectedAddressStructure + }, + ) + } + + assertEquals("US", store.state.address.country) + assertEquals("WA", store.state.address.addressLevel1) + + store.dispatch(ViewAppeared) + store.dispatch(FormChange.Country("CA")) + + assertEquals("CA", store.state.address.country) + assertEquals("AL", store.state.address.addressLevel1) + + assertEquals( + listOf("US", "CA"), + countries, + ) + } + + @Test + fun `GIVEN a store WHEN an address structure is loaded with an Unknown LocalizationKey THEN submit an exception`() = runTestOnMain { + val expectedAddressStructure = AddressStructure( + listOf( + AddressStructure.Field.TextField(AddressStructure.Field.ID.Name, AddressStructure.Field.LocalizationKey.Name), + AddressStructure.Field.TextField(AddressStructure.Field.ID.Organization, AddressStructure.Field.LocalizationKey.Organization), + AddressStructure.Field.TextField(AddressStructure.Field.ID.StreetAddress, AddressStructure.Field.LocalizationKey.Unknown("unknown-key")), + ), + ) + + var actualThrowable: Throwable? = null + val store = makeStore(this) { + copy( + getAddressStructure = { _ -> expectedAddressStructure }, + submitCaughtException = { actualThrowable = it }, + ) + } + + store.dispatch(ViewAppeared) + + assertEquals( + UnknownLocalizationKey("US", "unknown-key"), + actualThrowable, + ) + } + + @Test fun `GIVEN a store WHEN a user updates the address THEN the address is updated`() = runTestOnMain { - val store = makeStore(this) + val store = makeStore(this) { + copy( + getAddressStructure = { _ -> + AddressStructure( + listOf( + AddressStructure.Field.TextField(AddressStructure.Field.ID.Name, AddressStructure.Field.LocalizationKey.Name), + AddressStructure.Field.TextField(AddressStructure.Field.ID.Organization, AddressStructure.Field.LocalizationKey.Organization), + AddressStructure.Field.TextField(AddressStructure.Field.ID.StreetAddress, AddressStructure.Field.LocalizationKey.Unknown("unknown-key")), + AddressStructure.Field.SelectField( + AddressStructure.Field.ID.AddressLevel1, + AddressStructure.Field.LocalizationKey.State, + defaultSelectionKey = "", + options = listOf( + AddressStructure.Field.SelectField.Option("AL", "Alabama"), + ), + ), + ), + ) + }, + ) + } + assertEquals(store.state.address, emptyUpdatableAddress) listOf( - FormChange.Name("Work"), FormChange.StreetAddress("Mozilla Lane"), FormChange.City("Level 2"), - FormChange.SubRegion("This Should Change"), FormChange.PostalCode("31337"), FormChange.Country("US"), - FormChange.Phone("555-555-5555"), FormChange.Email("mo@zilla.com"), + FormChange.Name("Work"), FormChange.StreetAddress("Mozilla Lane"), FormChange.AddressLevel2("Level 2"), + FormChange.AddressLevel1("This Should Change"), FormChange.PostalCode("31337"), FormChange.Country("CA"), + FormChange.Tel("555-555-5555"), FormChange.Email("mo@zilla.com"), ).forEach(store::dispatch) val expected = UpdatableAddressFields( name = "Work", organization = "", streetAddress = "Mozilla Lane", - addressLevel1 = "Alabama", + addressLevel1 = "AL", addressLevel2 = "Level 2", addressLevel3 = "", postalCode = "31337", - country = "US", + country = "CA", tel = "555-555-5555", email = "mo@zilla.com", ) - assertEquals(store.state.address, expected) + assertEquals(expected, store.state.address) } @Test @@ -115,17 +238,22 @@ class AddressStoreTest { assertTrue(navigateBackCalled) assertEquals(expected, deletedGuid) } -} -private val emptyUpdatableAddress = UpdatableAddressFields("", "", "", "", "", "", "", "", "", "") - -private fun makeStore( - scope: CoroutineScope, - state: AddressState = AddressState.initial(), - transform: AddressEnvironment.() -> AddressEnvironment = { this }, -): AddressStore { - return AddressStore( - state, - listOf(AddressMiddleware(AddressEnvironment.empty.transform(), scope)), - ) + private fun makeStore( + scope: CoroutineScope, + state: AddressState = AddressState.initial(), + transform: AddressEnvironment.() -> AddressEnvironment = { this }, + ): AddressStore { + val environment = AddressEnvironment.empty.transform() + + return AddressStore( + state, + listOf( + AddressMiddleware(environment, scope, coroutinesTestRule.testDispatcher), + AddressStructureMiddleware(environment, scope, coroutinesTestRule.testDispatcher), + ), + ) + } } + +private val emptyUpdatableAddress = UpdatableAddressFields("", "", "", "", "", "", "", "US", "", "")