tor-browser

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

commit 46303fdd8dae83d16e3c6f49ab2b2d59062455bd
parent b032659478ce02e508ab5acb4440a2e69042de5d
Author: Cristina Horotan <chorotan@mozilla.com>
Date:   Mon, 24 Nov 2025 17:30:59 +0200

Revert "Bug 1998200, Bug 1998196, Bug 1998194 - Remove old XML based logic and code for credit card editor feature r=android-reviewers,boek" for causing lint failures

This reverts commit 802c6f88c0d70f12baa719be3000bd72827db6e9.

Revert "Bug 1998200 - Use new compose card editor in place of the old view-based one r=android-reviewers,boek"

This reverts commit 308e56942794255aa8832bc60789b09ae6dee2e9.

Revert "Bug 1998196 - Add tests for the state management logic for the Credit Card Editor r=android-reviewers,boek"

This reverts commit fdf492bdd079c37419a36b07abd37fdaa83ac7d3.

Revert "Bug 1998196 - Implement state management for the Credit Card Editor r=android-reviewers,boek"

This reverts commit f40847365ade1cfb875f3798bfaa8edd103bcbd3.

Revert "Bug 1998194 - Add compose UI for credit card editor screen r=android-reviewers,android-l10n-reviewers,delphine,007"

This reverts commit 2af8bb670f4aad25dfc8134cca4d1a107ceee94e.

Diffstat:
Dmobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/helpers/ext/SemanticsNodeInteraction.kt | 47-----------------------------------------------
Mmobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuAutofillRobot.kt | 195++++++++++++++++++++++++++++---------------------------------------------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/CreditCardEditorFragment.kt | 211++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/CreditCardEditorState.kt | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/controller/CreditCardEditorController.kt | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/interactor/CreditCardEditorInteractor.kt | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CalendarDataProvider.kt | 84-------------------------------------------------------------------------------
Dmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorAction.kt | 101-------------------------------------------------------------------------------
Dmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorEnvironment.kt | 19-------------------
Dmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorMiddleware.kt | 207-------------------------------------------------------------------------------
Dmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorReducer.kt | 77-----------------------------------------------------------------------------
Dmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorScreen.kt | 471-------------------------------------------------------------------------------
Dmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorState.kt | 50--------------------------------------------------
Dmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorStore.kt | 22----------------------
Dmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorTestTags.kt | 44--------------------------------------------
Dmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/DeleteCreditCardDialog.kt | 72------------------------------------------------------------------------
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/view/CreditCardEditorView.kt | 199+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amobile/android/fenix/app/src/main/res/layout/fragment_credit_card_editor.xml | 216+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amobile/android/fenix/app/src/main/res/menu/credit_card_editor.xml | 20++++++++++++++++++++
Mmobile/android/fenix/app/src/main/res/values/strings.xml | 4++--
Amobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardEditorStateTest.kt | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardEditorViewTest.kt | 346+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/DefaultCreditCardEditorControllerTest.kt | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/DefaultCreditCardEditorInteractorTest.kt | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dmobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorReducerTest.kt | 241-------------------------------------------------------------------------------
Dmobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorStateTestHelper.kt | 36------------------------------------
Dmobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorStoreTest.kt | 325-------------------------------------------------------------------------------
Dmobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/ui/FakeCalendarDataProvider.kt | 22----------------------
Dmobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/ui/FakeCreditCardsStorage.kt | 106-------------------------------------------------------------------------------
29 files changed, 1551 insertions(+), 2102 deletions(-)

diff --git a/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/helpers/ext/SemanticsNodeInteraction.kt b/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/helpers/ext/SemanticsNodeInteraction.kt @@ -1,47 +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.helpers.ext - -import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.test.SemanticsMatcher -import androidx.compose.ui.test.SemanticsNodeInteraction -import androidx.compose.ui.test.assert -import androidx.compose.ui.test.performTextClearance -import androidx.compose.ui.test.performTextInput - -/** - * Asserts that the current semantics node has an error matching the [message]. - * - * Throws [AssertionError] if the node does not have an error component matching [message] - */ -fun SemanticsNodeInteraction.assertHasError( - message: String, - errorMessageOnFail: String? = null, -): SemanticsNodeInteraction { - assert( - SemanticsMatcher.expectValue( - key = SemanticsProperties.Error, - expectedValue = message, - ), - messagePrefixOnError = errorMessageOnFail?.let { message -> - { message } - }, - ) - return this -} - -/** - * Clears the text in the node and then sets the given text. - * - * This is a convenience function that combines [performTextClearance] and [performTextInput]. - * - * @param text The text to be input. - * @return The [SemanticsNodeInteraction] for chaining. - */ -fun SemanticsNodeInteraction.clearAndSetText(text: String): SemanticsNodeInteraction { - performTextClearance() - performTextInput(text) - return this -} 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 @@ -8,7 +8,6 @@ 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.assertTextContains import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isNotDisplayed @@ -19,7 +18,6 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo -import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.test.espresso.Espresso.closeSoftKeyboard import androidx.test.espresso.Espresso.onView @@ -35,22 +33,21 @@ import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.uiautomator.UiSelector import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.endsWith +import org.junit.Assert.assertEquals import org.mozilla.fenix.R import org.mozilla.fenix.helpers.Constants.TAG import org.mozilla.fenix.helpers.DataGenerationHelper.getStringResource import org.mozilla.fenix.helpers.MatcherHelper.assertUIObjectExists import org.mozilla.fenix.helpers.MatcherHelper.itemContainingText import org.mozilla.fenix.helpers.MatcherHelper.itemWithDescription +import org.mozilla.fenix.helpers.MatcherHelper.itemWithResId import org.mozilla.fenix.helpers.TestAssetHelper.waitingTime import org.mozilla.fenix.helpers.TestHelper.hasCousin import org.mozilla.fenix.helpers.TestHelper.mDevice import org.mozilla.fenix.helpers.TestHelper.packageName import org.mozilla.fenix.helpers.TestHelper.waitForAppWindowToBeUpdated import org.mozilla.fenix.helpers.click -import org.mozilla.fenix.helpers.ext.assertHasError -import org.mozilla.fenix.helpers.ext.clearAndSetText import org.mozilla.fenix.settings.address.ui.edit.EditAddressTestTag -import org.mozilla.fenix.settings.creditcards.ui.CreditCardEditorTestTags class SettingsSubMenuAutofillRobot(private val composeTestRule: ComposeTestRule) { @@ -439,30 +436,20 @@ class SettingsSubMenuAutofillRobot(private val composeTestRule: ComposeTestRule) savedCreditCardNumber().clickAndWaitForNewWindow(waitingTime) Log.i(TAG, "clickSavedCreditCard: Clicked the saved credit card and and waited for $waitingTime ms for a new window") } - - @OptIn(ExperimentalTestApi::class) fun clickDeleteCreditCardToolbarButton() { Log.i(TAG, "clickDeleteCreditCardToolbarButton: Waiting for $waitingTime ms for the delete credit card toolbar button to exist") - composeTestRule.waitUntilAtLeastOneExists( - hasTestTag(CreditCardEditorTestTags.TOPBAR_DELETE_BUTTON), - waitingTime, - ) + deleteCreditCardToolbarButton().waitForExists(waitingTime) Log.i(TAG, "clickDeleteCreditCardToolbarButton: Waited for $waitingTime ms for the delete credit card toolbar button to exist") Log.i(TAG, "clickDeleteCreditCardToolbarButton: Trying to click the delete credit card toolbar button") - composeTestRule.deleteCreditCardToolbarButton().performClick() + deleteCreditCardToolbarButton().click() Log.i(TAG, "clickDeleteCreditCardToolbarButton: Clicked the delete credit card toolbar button") } - - @OptIn(ExperimentalTestApi::class) fun clickDeleteCreditCardMenuButton() { Log.i(TAG, "clickDeleteCreditCardMenuButton: Waiting for $waitingTime ms for the delete credit card menu button to exist") - composeTestRule.waitUntilAtLeastOneExists( - hasTestTag(CreditCardEditorTestTags.DELETE_BUTTON), - waitingTime, - ) + deleteCreditCardMenuButton().waitForExists(waitingTime) Log.i(TAG, "clickDeleteCreditCardMenuButton: Waited for $waitingTime ms for the delete credit card menu button to exist") Log.i(TAG, "clickDeleteCreditCardMenuButton: Trying to click the delete credit card menu button") - composeTestRule.deleteFormButton().performClick() + deleteCreditCardMenuButton().click() Log.i(TAG, "clickDeleteCreditCardMenuButton: Clicked the delete credit card menu button") } fun clickSaveAndAutofillCreditCardsOption() { @@ -473,112 +460,89 @@ class SettingsSubMenuAutofillRobot(private val composeTestRule: ComposeTestRule) fun clickConfirmDeleteCreditCardButton() { Log.i(TAG, "clickConfirmDeleteCreditCardButton: Trying to click the \"Delete\" credit card dialog button") - composeTestRule.confirmDeleteCreditCardButton().performClick() + confirmDeleteCreditCardButton().click() Log.i(TAG, "clickConfirmDeleteCreditCardButton: Clicked the \"Delete\" credit card dialog button") } fun clickCancelDeleteCreditCardButton() { Log.i(TAG, "clickCancelDeleteCreditCardButton: Trying to click the \"Cancel\" credit card dialog button") - composeTestRule.cancelDeleteCreditCardButton().performClick() + cancelDeleteCreditCardButton().click() Log.i(TAG, "clickCancelDeleteCreditCardButton: Clicked the \"Cancel\" credit card dialog button") } - @OptIn(ExperimentalTestApi::class) fun clickExpiryMonthOption(expiryMonth: String) { Log.i(TAG, "clickExpiryMonthOption: Waiting for $waitingTime ms for the $expiryMonth expiry month option to exist") - composeTestRule.waitUntilAtLeastOneExists( - hasText(expiryMonth, substring = true, ignoreCase = true), - waitingTime, - ) + expiryMonthOption(expiryMonth).waitForExists(waitingTime) Log.i(TAG, "clickExpiryMonthOption: Waited for $waitingTime ms for the $expiryMonth expiry month option to exist") Log.i(TAG, "clickExpiryMonthOption: Trying to click $expiryMonth expiry month option") - composeTestRule.expiryMonthOption(expiryMonth).performClick() + expiryMonthOption(expiryMonth).click() Log.i(TAG, "clickExpiryMonthOption: Clicked $expiryMonth expiry month option") } - @OptIn(ExperimentalTestApi::class) fun clickExpiryYearOption(expiryYear: String) { Log.i(TAG, "clickExpiryYearOption: Waiting for $waitingTime ms for the $expiryYear expiry year option to exist") - composeTestRule.waitUntilAtLeastOneExists( - hasText(expiryYear, substring = true, ignoreCase = true), - waitingTime, - ) + expiryYearOption(expiryYear).waitForExists(waitingTime) Log.i(TAG, "clickExpiryYearOption: Waited for $waitingTime ms for the $expiryYear expiry year option to exist") - Log.i(TAG, "clickExpiryYearOption: Trying to click $expiryYear expiry year option") - composeTestRule.expiryYearOption(expiryYear).performClick() + expiryYearOption(expiryYear).click() Log.i(TAG, "clickExpiryYearOption: Clicked $expiryYear expiry year option") } fun verifyAddCreditCardsButton() = assertUIObjectExists(addCreditCardButton()) - @OptIn(ExperimentalTestApi::class) fun fillAndSaveCreditCard(cardNumber: String, cardName: String, expiryMonth: String, expiryYear: String) { Log.i(TAG, "fillAndSaveCreditCard: Waiting for $waitingTime ms for the credit card number text field to exist") - composeTestRule.waitUntilAtLeastOneExists( - hasTestTag(CreditCardEditorTestTags.CARD_NUMBER_FIELD), - waitingTime, - ) + creditCardNumberTextInput().waitForExists(waitingTime) Log.i(TAG, "fillAndSaveCreditCard: Waited for $waitingTime ms for the credit card number text field to exist") Log.i(TAG, "fillAndSaveCreditCard: Trying to set the credit card number to: $cardNumber") - composeTestRule.creditCardNumberTextInput().clearAndSetText(cardNumber) + creditCardNumberTextInput().setText(cardNumber) Log.i(TAG, "fillAndSaveCreditCard: The credit card number was set to: $cardNumber") Log.i(TAG, "fillAndSaveCreditCard: Trying to set the name on card to: $cardName") - composeTestRule.nameOnCreditCardTextInput().clearAndSetText(cardName) + nameOnCreditCardTextInput().setText(cardName) Log.i(TAG, "fillAndSaveCreditCard: The credit card name was set to: $cardName") Log.i(TAG, "fillAndSaveCreditCard: Trying to click the expiry month dropdown") - composeTestRule.expiryMonthDropDown().performClick() - + expiryMonthDropDown().click() Log.i(TAG, "fillAndSaveCreditCard: Clicked the expiry month dropdown") Log.i(TAG, "fillAndSaveCreditCard: Trying to click $expiryMonth expiry month option") clickExpiryMonthOption(expiryMonth) Log.i(TAG, "fillAndSaveCreditCard: Clicked $expiryMonth expiry month option") Log.i(TAG, "fillAndSaveCreditCard: Trying to click the expiry year dropdown") - composeTestRule.expiryYearDropDown().performClick() + expiryYearDropDown().click() Log.i(TAG, "fillAndSaveCreditCard: Clicked the expiry year dropdown") Log.i(TAG, "fillAndSaveCreditCard: Trying to click $expiryYear expiry year option") clickExpiryYearOption(expiryYear) Log.i(TAG, "fillAndSaveCreditCard: Clicked $expiryYear expiry year option") Log.i(TAG, "fillAndSaveCreditCard: Trying to click the \"Save\" button") - composeTestRule.saveFormButton().performClick() + saveButton().click() Log.i(TAG, "fillAndSaveCreditCard: Clicked the \"Save\" button") Log.i(TAG, "fillAndSaveCreditCard: Waiting for $waitingTime ms for the \"Manage saved cards\" button to exist") manageSavedCreditCardsButton().waitForExists(waitingTime) Log.i(TAG, "fillAndSaveCreditCard: Waited for $waitingTime ms for the \"Manage saved cards\" button to exist") } - @OptIn(ExperimentalTestApi::class) fun clearCreditCardNumber() = - composeTestRule.creditCardNumberTextInput().also { + creditCardNumberTextInput().also { Log.i(TAG, "clearCreditCardNumber: Waiting for $waitingTime ms for the credit card number text field to exist") - composeTestRule.waitUntilAtLeastOneExists( - hasTestTag(CreditCardEditorTestTags.CARD_NUMBER_FIELD), - waitingTime, - ) + it.waitForExists(waitingTime) Log.i(TAG, "clearCreditCardNumber: Waited for $waitingTime ms for the credit card number text field to exist") - Log.i(TAG, "clearCreditCardNumber: Trying to clear the credit card number text field") - it.performTextClearance() + it.clearTextField() Log.i(TAG, "clearCreditCardNumber: Cleared the credit card number text field") } - @OptIn(ExperimentalTestApi::class) fun clearNameOnCreditCard() = - composeTestRule.nameOnCreditCardTextInput().also { + nameOnCreditCardTextInput().also { Log.i(TAG, "clearNameOnCreditCard: Waiting for $waitingTime ms for name on card text field to exist") - composeTestRule.waitUntilAtLeastOneExists( - hasTestTag(CreditCardEditorTestTags.NAME_ON_CARD_FIELD), - waitingTime, - ) + it.waitForExists(waitingTime) Log.i(TAG, "clearNameOnCreditCard: Waited for $waitingTime ms for name on card text field to exist") Log.i(TAG, "clearNameOnCreditCard: Trying to clear the name on card text field") - it.performTextClearance() + it.clearTextField() Log.i(TAG, "clearNameOnCreditCard: Cleared the name on card text field") } fun clickSaveCreditCardToolbarButton() { Log.i(TAG, "clickSaveCreditCardToolbarButton: Trying to click the save credit card toolbar button") - composeTestRule.saveCreditCardToolbarButton().performClick() + saveCreditCardToolbarButton().click() Log.i(TAG, "clickSaveCreditCardToolbarButton: Clicked the save credit card toolbar button") } @@ -587,58 +551,46 @@ class SettingsSubMenuAutofillRobot(private val composeTestRule: ComposeTestRule) cardName: String, expiryMonth: String, expiryYear: String, - ) = with(composeTestRule) { - editCreditCardToolbarTitle() - .assertExists("Unable to assert that the edit credit card toolbar title exists") - deleteCreditCardToolbarButton() - .assertExists("Unable to assert that the delete credit card toolbar button exists") - saveCreditCardToolbarButton() - .assertExists("Unable to assert that the save credit card toolbar button exists") - + ) { + assertUIObjectExists( + editCreditCardToolbarTitle(), + navigateBackButton(), + deleteCreditCardToolbarButton(), + saveCreditCardToolbarButton(), + ) Log.i(TAG, "verifyEditCreditCardView: Trying to verify that the card number text field is set to: $cardNumber") - creditCardNumberTextInput() - .assertTextContains(cardNumber) + assertEquals(cardNumber, creditCardNumberTextInput().text) Log.i(TAG, "verifyEditCreditCardView: Verified that the card number text field was set to: $cardNumber") Log.i(TAG, "verifyEditCreditCardView: Trying to verify that the card name text field is set to: $cardName") - nameOnCreditCardTextInput().assertTextContains(cardName) + assertEquals(cardName, nameOnCreditCardTextInput().text) Log.i(TAG, "verifyEditCreditCardView: Verified that the card card name text field was set to: $cardName") // Can't get the text from the drop-down items, need to verify them individually - expiryMonthDropDown() - .assertExists("Unable to assert that the expiry month dropdown exists") - expiryYearDropDown() - .assertExists("Unable to assert that the expiry year dropdown exists") + assertUIObjectExists( + expiryYearDropDown(), + expiryMonthDropDown(), + ) - onNodeWithText(expiryMonth, substring = true) - .assertExists("Unable to assert that the $expiryMonth expiry month is shown") - onNodeWithText(expiryYear, substring = true) - .assertExists("Unable to assert that the $expiryYear expiry year is shown") + assertUIObjectExists( + itemContainingText(expiryMonth), + itemContainingText(expiryYear), + ) - saveFormButton().assertExists("Unable to assert that the save button exists") - cancelFormButton().assertExists("Unable to assert that the cancel button exists") - deleteFormButton().assertExists("Unable to assert that the delete button exists") - } + assertUIObjectExists( + saveButton(), + cancelButton(), + ) - fun verifyEditCreditCardToolbarTitle() = composeTestRule.editCreditCardToolbarTitle() - .assertExists("Unable to assert that the edit credit card toolbar title exists") + assertUIObjectExists(deleteCreditCardMenuButton()) + } - fun verifyCreditCardNumberErrorMessage() { - val errorMessage = - getStringResource(R.string.credit_cards_number_validation_error_message_2) + fun verifyEditCreditCardToolbarTitle() = assertUIObjectExists(editCreditCardToolbarTitle()) - composeTestRule.creditCardNumberTextInput() - .assertHasError(errorMessage) - } + fun verifyCreditCardNumberErrorMessage() = + assertUIObjectExists(itemContainingText(getStringResource(R.string.credit_cards_number_validation_error_message_2))) - fun verifyNameOnCreditCardErrorMessage() { - val errorMessage = - getStringResource(R.string.credit_cards_name_on_card_validation_error_message_2) - composeTestRule.nameOnCreditCardTextInput() - .assertHasError( - errorMessage, - "Unable to assert that the name on card validation error message exists", - ) - } + fun verifyNameOnCreditCardErrorMessage() = + assertUIObjectExists(itemContainingText(getStringResource(R.string.credit_cards_name_on_card_validation_error_message_2))) class Transition(private val composeTestRule: ComposeTestRule) { fun goBack(interact: SettingsRobot.() -> Unit): SettingsRobot.Transition { @@ -670,7 +622,7 @@ class SettingsSubMenuAutofillRobot(private val composeTestRule: ComposeTestRule) fun goBackToSavedCreditCards(interact: SettingsSubMenuAutofillRobot.() -> Unit): SettingsSubMenuAutofillRobot.Transition { Log.i(TAG, "goBackToSavedCreditCards: Trying to click the navigate up toolbar button") - composeTestRule.navigateBackButton().performClick() + navigateBackButton().click() Log.i(TAG, "goBackToSavedCreditCards: Clicked the navigate up toolbar button") SettingsSubMenuAutofillRobot(composeTestRule).interact() @@ -738,33 +690,24 @@ private fun saveAndAutofillCreditCardsSummary() = itemContainingText(getStringRe private fun syncCreditCardsAcrossDevicesButton() = itemContainingText(getStringResource(R.string.preferences_credit_cards_sync_cards_across_devices)) private fun addCreditCardButton() = mDevice.findObject(UiSelector().textContains(getStringResource(R.string.preferences_credit_cards_add_credit_card_2))) private fun savedCreditCardsToolbarTitle() = itemContainingText(getStringResource(R.string.credit_cards_saved_cards)) -private fun ComposeTestRule.editCreditCardToolbarTitle() = onNodeWithText(getStringResource(R.string.credit_cards_edit_card)) +private fun editCreditCardToolbarTitle() = itemContainingText(getStringResource(R.string.credit_cards_edit_card)) private fun manageSavedCreditCardsButton() = mDevice.findObject(UiSelector().textContains(getStringResource(R.string.preferences_credit_cards_manage_saved_cards_2))) - -private fun ComposeTestRule.creditCardNumberTextInput() = onNodeWithTag(CreditCardEditorTestTags.CARD_NUMBER_FIELD).onChildAt(0) - -private fun ComposeTestRule.nameOnCreditCardTextInput() = onNodeWithTag(CreditCardEditorTestTags.NAME_ON_CARD_FIELD).onChildAt(0) - -private fun ComposeTestRule.expiryMonthDropDown() = - onNodeWithTag(CreditCardEditorTestTags.EXPIRATION_MONTH_FIELD) - -private fun ComposeTestRule.expiryYearDropDown() = - onNodeWithTag(CreditCardEditorTestTags.EXPIRATION_YEAR_FIELD) - +private fun creditCardNumberTextInput() = mDevice.findObject(UiSelector().resourceId("$packageName:id/card_number_input")) +private fun nameOnCreditCardTextInput() = mDevice.findObject(UiSelector().resourceId("$packageName:id/name_on_card_input")) +private fun expiryMonthDropDown() = mDevice.findObject(UiSelector().resourceId("$packageName:id/expiry_month_drop_down")) +private fun expiryYearDropDown() = mDevice.findObject(UiSelector().resourceId("$packageName:id/expiry_year_drop_down")) private fun savedCreditCardNumber() = mDevice.findObject(UiSelector().resourceId("$packageName:id/credit_card_logo")) -private fun ComposeTestRule.deleteCreditCardToolbarButton() = onNodeWithTag(CreditCardEditorTestTags.TOPBAR_DELETE_BUTTON) -private fun ComposeTestRule.saveCreditCardToolbarButton() = onNodeWithTag(CreditCardEditorTestTags.TOPBAR_SAVE_BUTTON) -private fun ComposeTestRule.confirmDeleteCreditCardButton() = onNodeWithTag(CreditCardEditorTestTags.DELETE_DIALOG_DELETE_BUTTON) -private fun ComposeTestRule.cancelDeleteCreditCardButton() = onNodeWithTag(CreditCardEditorTestTags.DELETE_DIALOG_CANCEL_BUTTON) +private fun deleteCreditCardToolbarButton() = mDevice.findObject(UiSelector().resourceId("$packageName:id/delete_credit_card_button")) +private fun saveCreditCardToolbarButton() = itemWithResId("$packageName:id/save_credit_card_button") +private fun deleteCreditCardMenuButton() = itemContainingText(getStringResource(R.string.credit_cards_delete_card_button)) +private fun confirmDeleteCreditCardButton() = onView(withId(android.R.id.button1)).inRoot(RootMatchers.isDialog()) +private fun cancelDeleteCreditCardButton() = onView(withId(android.R.id.button2)).inRoot(RootMatchers.isDialog()) private fun securedCreditCardsLaterButton() = onView(withId(android.R.id.button2)).inRoot(RootMatchers.isDialog()) -private fun ComposeTestRule.saveFormButton() = onNodeWithTag(CreditCardEditorTestTags.SAVE_BUTTON) -private fun ComposeTestRule.cancelFormButton() = onNodeWithTag(CreditCardEditorTestTags.CANCEL_BUTTON) -private fun ComposeTestRule.deleteFormButton() = onNodeWithTag(CreditCardEditorTestTags.DELETE_BUTTON) - +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) = onNodeWithTag(EditAddressTestTag.ADDRESS_LEVEL1_FIELD + ".$subRegion") private fun ComposeTestRule.countryOption(country: String) = onNodeWithTag(EditAddressTestTag.COUNTRY_FIELD + ".$country") -private fun ComposeTestRule.expiryMonthOption(expiryMonth: String) = - onNodeWithText(expiryMonth, substring = true, ignoreCase = true) - -private fun ComposeTestRule.expiryYearOption(expiryYear: String) = onNodeWithText(expiryYear, substring = true, ignoreCase = true) +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/settings/creditcards/CreditCardEditorFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/CreditCardEditorFragment.kt @@ -4,84 +4,195 @@ package org.mozilla.fenix.settings.creditcards +import android.content.DialogInterface import android.os.Bundle -import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.LaunchedEffect -import androidx.fragment.compose.content +import android.view.autofill.AutofillManager +import android.widget.AdapterView +import androidx.appcompat.app.AlertDialog +import androidx.core.view.MenuProvider +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs -import mozilla.components.lib.state.helpers.StoreProvider.Companion.storeProvider +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import mozilla.components.support.ktx.android.view.hideKeyboard +import mozilla.components.support.ktx.android.view.showKeyboard +import mozilla.components.ui.widgets.withCenterAlignedButtons import org.mozilla.fenix.R import org.mozilla.fenix.SecureFragment -import org.mozilla.fenix.ext.hideToolbar +import org.mozilla.fenix.databinding.FragmentCreditCardEditorBinding +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.placeCursorAtEnd import org.mozilla.fenix.ext.redirectToReAuth -import org.mozilla.fenix.ext.requireComponents -import org.mozilla.fenix.settings.creditcards.ui.CreditCardEditorAction -import org.mozilla.fenix.settings.creditcards.ui.CreditCardEditorEnvironment -import org.mozilla.fenix.settings.creditcards.ui.CreditCardEditorMiddleware -import org.mozilla.fenix.settings.creditcards.ui.CreditCardEditorScreen -import org.mozilla.fenix.settings.creditcards.ui.CreditCardEditorState -import org.mozilla.fenix.settings.creditcards.ui.CreditCardEditorStore -import org.mozilla.fenix.settings.creditcards.ui.DefaultCalendarDataProvider -import org.mozilla.fenix.theme.FirefoxTheme +import org.mozilla.fenix.ext.showToolbar +import org.mozilla.fenix.settings.creditcards.controller.DefaultCreditCardEditorController +import org.mozilla.fenix.settings.creditcards.interactor.CreditCardEditorInteractor +import org.mozilla.fenix.settings.creditcards.interactor.DefaultCreditCardEditorInteractor +import org.mozilla.fenix.settings.creditcards.view.CreditCardEditorView /** * Display a credit card editor for adding and editing a credit card. */ -class CreditCardEditorFragment : SecureFragment() { +class CreditCardEditorFragment : + SecureFragment(R.layout.fragment_credit_card_editor), + MenuProvider { + + private lateinit var creditCardEditorState: CreditCardEditorState + private lateinit var creditCardEditorView: CreditCardEditorView + private lateinit var menu: Menu + + private var deleteDialog: AlertDialog? = null private val args by navArgs<CreditCardEditorFragmentArgs>() - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - hideToolbar() - } + /** + * Returns true if a credit card is being edited, and false otherwise. + */ + private val isEditing: Boolean + get() = args.creditCard != null - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - val store: CreditCardEditorStore = storeProvider.get { state -> - CreditCardEditorStore( - initialState = state - ?: CreditCardEditorState.Default.copy(inEditMode = args.creditCard != null), - middleware = listOf( - CreditCardEditorMiddleware( - CreditCardEditorEnvironment( - navigateBack = { findNavController().popBackStack() }, - ), - storage = requireComponents.core.autofillStorage, - calendarDataProvider = DefaultCalendarDataProvider(), - coroutineScope = lifecycleScope, - ), - ), - ) + private lateinit var interactor: CreditCardEditorInteractor + + private var isAutofillSessionActive = false + + private val autofillCallback = object : AutofillManager.AutofillCallback() { + override fun onAutofillEvent(view: View, event: Int) { + super.onAutofillEvent(view, event) + + when (event) { + EVENT_INPUT_SHOWN -> { + isAutofillSessionActive = true + } + EVENT_INPUT_HIDDEN -> { + isAutofillSessionActive = false + } + EVENT_INPUT_UNAVAILABLE -> { + isAutofillSessionActive = false + } + } } - return content { - LaunchedEffect(Unit) { - store.dispatch(CreditCardEditorAction.Initialization.InitStarted(args.creditCard)) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + + val storage = requireContext().components.core.autofillStorage + interactor = DefaultCreditCardEditorInteractor( + controller = DefaultCreditCardEditorController( + storage = storage, + lifecycleScope = lifecycleScope, + navController = findNavController(), + showDeleteDialog = ::showDeleteDialog, + ), + ) + + val binding = FragmentCreditCardEditorBinding.bind(view) + + lifecycleScope.launch(Dispatchers.Main) { + creditCardEditorState = withContext(Dispatchers.IO) { + args.creditCard?.toCreditCardEditorState(storage) + ?: getInitialCreditCardEditorState() } + creditCardEditorView = CreditCardEditorView(binding, interactor) + creditCardEditorView.bind(creditCardEditorState) - FirefoxTheme { - CreditCardEditorScreen(store) + binding.apply { + cardNumberInput.apply { + requestFocus() + placeCursorAtEnd() + showKeyboard() + } + expiryMonthDropDown.onItemSelectedListener = + object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { + expiryMonthDropDown.hideKeyboard() + } + override fun onNothingSelected(parent: AdapterView<*>) = Unit + } + expiryYearDropDown.onItemSelectedListener = + object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { + expiryYearDropDown.hideKeyboard() + } + override fun onNothingSelected(parent: AdapterView<*>) = Unit + } } } } + override fun onResume() { + super.onResume() + activity?.getSystemService(AutofillManager::class.java)?.registerCallback(autofillCallback) + if (!isEditing) { + showToolbar(getString(R.string.credit_cards_add_card)) + } else { + showToolbar(getString(R.string.credit_cards_edit_card)) + } + } + /** * Close the keyboard, any open dialogs or menus and then reauthenticate if the * fragment is paused and the user is not navigating to [CreditCardsManagementFragment]. */ override fun onPause() { - redirectToReAuth( - listOf(R.id.creditCardsManagementFragment), - findNavController().currentDestination?.id, - R.id.creditCardEditorFragment, - ) + activity?.getSystemService(AutofillManager::class.java)?.unregisterCallback(autofillCallback) + + view?.hideKeyboard() + menu.close() + deleteDialog?.dismiss() + + if (!isAutofillSessionActive) { + redirectToReAuth( + listOf(R.id.creditCardsManagementFragment), + findNavController().currentDestination?.id, + R.id.creditCardEditorFragment, + ) + } super.onPause() } + + override fun onCreateMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.credit_card_editor, menu) + this.menu = menu + + menu.findItem(R.id.delete_credit_card_button).isVisible = isEditing + } + + @Suppress("MagicNumber") + override fun onMenuItemSelected(item: MenuItem): Boolean = when (item.itemId) { + R.id.delete_credit_card_button -> { + args.creditCard?.let { interactor.onDeleteCardButtonClicked(it.guid) } + true + } + R.id.save_credit_card_button -> { + creditCardEditorView.saveCreditCard(creditCardEditorState) + true + } + else -> false + } + + private fun showDeleteDialog(onPositiveClickListener: DialogInterface.OnClickListener) { + deleteDialog = MaterialAlertDialogBuilder(requireContext()).apply { + setMessage(R.string.credit_cards_delete_dialog_confirmation_2) + setNegativeButton(R.string.credit_cards_cancel_button) { dialog: DialogInterface, _ -> + dialog.cancel() + } + setPositiveButton(R.string.credit_cards_delete_dialog_button, onPositiveClickListener) + create().withCenterAlignedButtons() + }.show() + } + + companion object { + // Number of years to show in the expiry year dropdown. + const val NUMBER_OF_YEARS_TO_SHOW = 10 + } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/CreditCardEditorState.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/CreditCardEditorState.kt @@ -0,0 +1,65 @@ +/* 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.creditcards + +import mozilla.components.concept.storage.CreditCard +import mozilla.components.service.sync.autofill.AutofillCreditCardsAddressesStorage +import org.mozilla.fenix.settings.creditcards.CreditCardEditorFragment.Companion.NUMBER_OF_YEARS_TO_SHOW +import java.util.Calendar + +/** + * The state for the [CreditCardEditorFragment]. + * + * @property guid The unique identifier for the edited credit card. + * @property billingName The credit card billing name to display. + * @property cardNumber The credit card number to display. + * @property expiryMonth The selected credit card expiry month. + * @property expiryYears The range of expiry years to display. + * @property isEditing Whether or not the credit card is being edited. + */ +data class CreditCardEditorState( + val guid: String = "", + val billingName: String = "", + val cardNumber: String = "", + val expiryMonth: Int = 1, + val expiryYears: Pair<Int, Int>, + val isEditing: Boolean = false, +) + +/** + * Returns a [CreditCardEditorState] from the given [CreditCard]. + */ +suspend fun CreditCard.toCreditCardEditorState(storage: AutofillCreditCardsAddressesStorage): CreditCardEditorState { + val crypto = storage.getCreditCardCrypto() + val key = crypto.getOrGenerateKey() + val cardNumber = crypto.decrypt(key, encryptedCardNumber)?.number ?: "" + val startYear = expiryYear.toInt() + val endYear = startYear + NUMBER_OF_YEARS_TO_SHOW + + return CreditCardEditorState( + guid = guid, + billingName = billingName, + cardNumber = cardNumber, + expiryMonth = expiryMonth.toInt(), + expiryYears = Pair(startYear, endYear), + isEditing = true, + ) +} + +/** + * Returns the initial credit editor state if no credit card is provided. + * + * @return an empty [CreditCardEditorState] with a range of expiry years based on the latest + * 10 years. + */ +fun getInitialCreditCardEditorState(): CreditCardEditorState { + val calendar = Calendar.getInstance() + val startYear = calendar.get(Calendar.YEAR) + val endYear = startYear + NUMBER_OF_YEARS_TO_SHOW + + return CreditCardEditorState( + expiryYears = Pair(startYear, endYear), + ) +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/controller/CreditCardEditorController.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/controller/CreditCardEditorController.kt @@ -0,0 +1,106 @@ +/* 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.creditcards.controller + +import android.content.DialogInterface +import androidx.navigation.NavController +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import mozilla.components.concept.storage.NewCreditCardFields +import mozilla.components.concept.storage.UpdatableCreditCardFields +import mozilla.components.service.sync.autofill.AutofillCreditCardsAddressesStorage +import mozilla.telemetry.glean.private.NoExtras +import org.mozilla.fenix.GleanMetrics.CreditCards +import org.mozilla.fenix.settings.creditcards.CreditCardEditorFragment +import org.mozilla.fenix.settings.creditcards.interactor.CreditCardEditorInteractor + +/** + * [CreditCardEditorFragment] controller. An interface that handles the view manipulation of the + * credit card editor. + */ +interface CreditCardEditorController { + + /** + * @see [CreditCardEditorInteractor.onCancelButtonClicked] + */ + fun handleCancelButtonClicked() + + /** + * @see [CreditCardEditorInteractor.onDeleteCardButtonClicked] + */ + fun handleDeleteCreditCard(guid: String) + + /** + * @see [CreditCardEditorInteractor.onSaveCreditCard] + */ + fun handleSaveCreditCard(creditCardFields: NewCreditCardFields) + + /** + * @see [CreditCardEditorInteractor.onUpdateCreditCard] + */ + fun handleUpdateCreditCard(guid: String, creditCardFields: UpdatableCreditCardFields) +} + +/** + * The default implementation of [CreditCardEditorController]. + * + * @param storage An instance of the [AutofillCreditCardsAddressesStorage] for adding and retrieving + * credit cards. + * @param lifecycleScope [CoroutineScope] scope to launch coroutines. + * @param navController [NavController] used for navigation. + * @param ioDispatcher [CoroutineDispatcher] used for executing async tasks. Defaults to [Dispatchers.IO]. + * @param showDeleteDialog [DialogInterface.OnClickListener] used to display a confirmation dialog + * before removing credit card. + */ +class DefaultCreditCardEditorController( + private val storage: AutofillCreditCardsAddressesStorage, + private val lifecycleScope: CoroutineScope, + private val navController: NavController, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val showDeleteDialog: (DialogInterface.OnClickListener) -> Unit, +) : CreditCardEditorController { + + override fun handleCancelButtonClicked() { + navController.popBackStack() + } + + override fun handleDeleteCreditCard(guid: String) { + showDeleteDialog { dialog, _ -> + lifecycleScope.launch(ioDispatcher) { + storage.deleteCreditCard(guid) + + lifecycleScope.launch(Dispatchers.Main) { + navController.popBackStack() + } + CreditCards.deleted.add() + } + dialog.dismiss() + } + } + + override fun handleSaveCreditCard(creditCardFields: NewCreditCardFields) { + lifecycleScope.launch(ioDispatcher) { + storage.addCreditCard(creditCardFields) + + lifecycleScope.launch(Dispatchers.Main) { + navController.popBackStack() + } + CreditCards.saved.add() + } + } + + override fun handleUpdateCreditCard(guid: String, creditCardFields: UpdatableCreditCardFields) { + lifecycleScope.launch(ioDispatcher) { + storage.updateCreditCard(guid, creditCardFields) + + lifecycleScope.launch(Dispatchers.Main) { + navController.popBackStack() + } + CreditCards.modified.record(NoExtras()) + } + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/interactor/CreditCardEditorInteractor.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/interactor/CreditCardEditorInteractor.kt @@ -0,0 +1,73 @@ +/* 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.creditcards.interactor + +import mozilla.components.concept.storage.NewCreditCardFields +import mozilla.components.concept.storage.UpdatableCreditCardFields +import org.mozilla.fenix.settings.creditcards.controller.CreditCardEditorController + +/** + * Interface for the credit card editor Interactor. + */ +interface CreditCardEditorInteractor { + + /** + * Navigates back to the credit card preference settings. Called when a user taps on the + * "Cancel" button. + */ + fun onCancelButtonClicked() + + /** + * Deletes the provided credit card in the credit card storage. Called when a user + * taps on the delete menu item or "Delete card" button. + * + * @param guid Unique identifier for the credit card to be deleted. + */ + fun onDeleteCardButtonClicked(guid: String) + + /** + * Saves the provided credit card field into the credit card storage. Called when a user + * taps on the save menu item or "Save" button. + * + * @param creditCardFields A [NewCreditCardFields] record to add. + */ + fun onSaveCreditCard(creditCardFields: NewCreditCardFields) + + /** + * Updates the provided credit card with the new credit card fields. Called when a user + * taps on the save menu item or "Save" button when editing an existing credit card. + * + * @param guid Unique identifier for the desired credit card. + * @param creditCardFields The credit card fields to update. + */ + fun onUpdateCreditCard(guid: String, creditCardFields: UpdatableCreditCardFields) +} + +/** + * The default implementation of [CreditCardEditorInteractor]. + * + * @param controller An instance of [CreditCardEditorController] which will be delegated for all + * user interactions. + */ +class DefaultCreditCardEditorInteractor( + private val controller: CreditCardEditorController, +) : CreditCardEditorInteractor { + + override fun onCancelButtonClicked() { + controller.handleCancelButtonClicked() + } + + override fun onDeleteCardButtonClicked(guid: String) { + controller.handleDeleteCreditCard(guid) + } + + override fun onSaveCreditCard(creditCardFields: NewCreditCardFields) { + controller.handleSaveCreditCard(creditCardFields) + } + + override fun onUpdateCreditCard(guid: String, creditCardFields: UpdatableCreditCardFields) { + controller.handleUpdateCreditCard(guid, creditCardFields) + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CalendarDataProvider.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CalendarDataProvider.kt @@ -1,84 +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.creditcards.ui - -import java.text.SimpleDateFormat -import java.util.Calendar -import java.util.Locale - -/** - * Provider for calendar data used in the credit card editor - */ -interface CalendarDataProvider { - - /** - * Returns a list of months. - * - * @return a list of months - */ - fun months(): List<String> - - /** - * Returns a list of years supported by the credit expiry card year field. - * - * @return a list of years - */ - fun years(): List<String> - - /** - * Returns a list of years supported by the credit expiry card year field. - * - * @param startYear The start year to use for the list of years. - * @return a list of years - */ - fun years(startYear: Long): List<String> -} - -/** - * Default implementation of [CalendarDataProvider] using [Calendar] - * - * @param dateFormat The [SimpleDateFormat] to use for formatting the dates. - */ -class DefaultCalendarDataProvider( - private val dateFormat: SimpleDateFormat = SimpleDateFormat("MMMM (MM)", Locale.getDefault()), -) : CalendarDataProvider { - - override fun months(): List<String> { - val calendar = Calendar.getInstance() - return buildList { - calendar.set(Calendar.DAY_OF_MONTH, 1) - - for (month in 0..NUMBER_OF_MONTHS) { - calendar.set(Calendar.MONTH, month) - add(dateFormat.format(calendar.time)) - } - } - } - - override fun years(): List<String> { - val calendar = Calendar.getInstance() - val startYear = calendar.get(Calendar.YEAR) - return years(startYear = startYear.toLong()) - } - - override fun years(startYear: Long): List<String> { - val endYear = startYear + NUMBER_OF_YEARS_TO_SHOW - return buildList { - for (year in startYear..endYear + NUMBER_OF_YEARS_TO_SHOW) { - add(year.toString()) - } - } - } -} - -/** - * Number of months in a year (0-indexed). - */ -private const val NUMBER_OF_MONTHS = 11 - -/** - * Number of years to show in the credit card expiry year field. - */ -private const val NUMBER_OF_YEARS_TO_SHOW = 10 diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorAction.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorAction.kt @@ -1,101 +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.creditcards.ui - -import mozilla.components.concept.storage.CreditCard -import mozilla.components.lib.state.Action - -/** - * Represents the various actions that can be triggered from the credit card editor screen, - * such as saving a credit card or handling back navigation. - */ -sealed interface CreditCardEditorAction : Action { - - /** - * A group of actions that are dispatched when the credit card editor is initialized. - */ - sealed interface Initialization : CreditCardEditorAction { - /** - * Initialize the credit card editor with the provided [CreditCard]. - * - * @property creditCard Optional [CreditCard] to be initialized in the editor. When - * this property is null, a new credit card will be created. - */ - data class InitStarted(val creditCard: CreditCard?) : Initialization - - /** - * Signal that the initialization process has completed. - * - * @property state The final state of the credit card editor after initialization. - */ - data class InitCompleted(val state: CreditCardEditorState) : Initialization - } - - /** - * An action that signals the intention to navigate back from the current screen. - */ - data object NavigateBack : CreditCardEditorAction - - /** - * An action that represents the user's request to save the entered credit card details. - */ - data object Save : CreditCardEditorAction - - /** - * An action triggered when the user decides to discard the changes and exit the editor. - */ - data object Cancel : CreditCardEditorAction - - /** - * An action triggered when the user wants to delete the current credit card. - */ - data object DeleteClicked : CreditCardEditorAction - - /** - * A group of actions for handling the delete confirmation dialog. - */ - sealed interface DeleteDialogAction : CreditCardEditorAction { - - /** - * An action that signals the user's intention to cancel the delete operation. - */ - data object Cancel : DeleteDialogAction - - /** - * An action that signals the user's confirmation to delete the credit card. - */ - data object Confirm : DeleteDialogAction - } - - /** - * A group of actions that are dispatched whenever a field in the credit card form is modified. - */ - sealed interface FieldChanged : CreditCardEditorAction { - - /** - * Dispatched when the credit card number is changed by the user. - * @property cardNumber The updated credit card number string. - */ - data class CardNumberChanged(val cardNumber: String) : FieldChanged - - /** - * Dispatched when the name on the credit card is changed. - * @property nameOnCard The updated name on the card. - */ - data class NameOnCardChanged(val nameOnCard: String) : FieldChanged - - /** - * Dispatched when the expiration month of the credit card is selected. - * @property index The index of the selected month. - */ - data class MonthSelected(val index: Int) : FieldChanged - - /** - * Dispatched when the expiration year of the credit card is selected. - * @property index The index of the selected year. - */ - data class YearSelected(val index: Int) : FieldChanged - } -} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorEnvironment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorEnvironment.kt @@ -1,19 +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.creditcards.ui - -/** - * Groups together all of the [CreditCardEditorStore] external dependencies. - * - * @property navigateBack used to navigate back. - */ -data class CreditCardEditorEnvironment( - val navigateBack: () -> Unit, -) { - - internal companion object { - val Default = CreditCardEditorEnvironment(navigateBack = {}) - } -} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorMiddleware.kt @@ -1,207 +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.creditcards.ui - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import mozilla.components.concept.storage.CreditCard -import mozilla.components.concept.storage.CreditCardNumber -import mozilla.components.concept.storage.CreditCardsAddressesStorage -import mozilla.components.concept.storage.NewCreditCardFields -import mozilla.components.concept.storage.UpdatableCreditCardFields -import mozilla.components.lib.state.Middleware -import mozilla.components.lib.state.MiddlewareContext -import mozilla.components.support.utils.creditCardIIN -import org.mozilla.fenix.settings.creditcards.last4Digits -import org.mozilla.fenix.settings.creditcards.ui.CreditCardEditorAction.DeleteDialogAction - -/** - * Middleware for the credit card editor feature - * - * @param environment The [CreditCardEditorEnvironment] to use for external lifecycle-sensitive things. - * @param storage The [CreditCardsAddressesStorage] to use for adding and updating credit cards. - * @param calendarDataProvider The [CalendarDataProvider] to use for providing calendar data. - * @param coroutineScope The [CoroutineScope] to use for launching coroutines. - * @param ioDispatcher The [CoroutineDispatcher] to use for executing IO operations. - * @param mainDispatcher The [CoroutineDispatcher] to use for executing main-thread operations. - */ -internal class CreditCardEditorMiddleware( - private var environment: CreditCardEditorEnvironment? = null, - private val storage: CreditCardsAddressesStorage, - private val calendarDataProvider: CalendarDataProvider = DefaultCalendarDataProvider(), - private val coroutineScope: CoroutineScope, - private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, - private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main, -) : Middleware<CreditCardEditorState, CreditCardEditorAction> { - - override fun invoke( - context: MiddlewareContext<CreditCardEditorState, CreditCardEditorAction>, - next: (CreditCardEditorAction) -> Unit, - action: CreditCardEditorAction, - ) { - next(action) - when (action) { - is CreditCardEditorAction.Initialization -> action.handleInitAction(context) - is DeleteDialogAction -> action.handleDeleteDialog(context) - - is CreditCardEditorAction.Save -> handleSaveAction(context) - is CreditCardEditorAction.NavigateBack, - is CreditCardEditorAction.Cancel, - -> navigateBack() - - else -> Unit - } - } - - private fun DeleteDialogAction.handleDeleteDialog( - context: MiddlewareContext<CreditCardEditorState, CreditCardEditorAction>, - ) { - when (this) { - DeleteDialogAction.Confirm -> { - coroutineScope.launch(ioDispatcher) { - storage.deleteCreditCard(context.state.guid) - - withContext(mainDispatcher) { - navigateBack() - } - } - } - - else -> Unit - } - } - - private fun navigateBack() { - environment?.navigateBack() - } - - private fun handleSaveAction( - context: MiddlewareContext<CreditCardEditorState, CreditCardEditorAction>, - ) { - val state = context.state - - if (!state.showCardNumberError && !state.showNameOnCardError) { - addOrUpdateCard(state) - } - } - - private fun addOrUpdateCard(state: CreditCardEditorState) { - if (state.inEditMode) { - updateCreditCard(state) - } else { - addCreditCard(state) - } - } - - private fun updateCreditCard(state: CreditCardEditorState) { - coroutineScope.launch(ioDispatcher) { - val fields = UpdatableCreditCardFields( - billingName = state.nameOnCard, - cardNumber = CreditCardNumber.Plaintext(state.cardNumber), - cardNumberLast4 = state.cardNumber.last4Digits(), - expiryMonth = state.selectedExpiryMonthIndex + 1L, - expiryYear = state.expiryYears[state.selectedExpiryYearIndex].toLong(), - cardType = state.cardNumber.creditCardIIN()?.creditCardIssuerNetwork?.name ?: "", - ) - - storage.updateCreditCard(state.guid, fields) - - withContext(mainDispatcher) { - navigateBack() - } - } - } - - private fun addCreditCard(state: CreditCardEditorState) { - coroutineScope.launch(ioDispatcher) { - val fields = NewCreditCardFields( - billingName = state.nameOnCard, - plaintextCardNumber = CreditCardNumber.Plaintext(state.cardNumber), - cardNumberLast4 = state.cardNumber.last4Digits(), - expiryMonth = state.selectedExpiryMonthIndex + 1L, - expiryYear = state.expiryYears[state.selectedExpiryYearIndex].toLong(), - cardType = state.cardNumber.creditCardIIN()?.creditCardIssuerNetwork?.name ?: "", - ) - - storage.addCreditCard(fields) - - withContext(mainDispatcher) { - navigateBack() - } - } - } - - private fun CreditCardEditorAction.Initialization.handleInitAction( - context: MiddlewareContext<CreditCardEditorState, CreditCardEditorAction>, - ) { - when (this) { - is CreditCardEditorAction.Initialization.InitStarted -> { - if (creditCard != null) { - initializeFromCard(context, creditCard) - } else { - initializeFromScratch(context) - } - } - - else -> Unit - } - } - - private fun initializeFromScratch( - context: MiddlewareContext<CreditCardEditorState, CreditCardEditorAction>, - ) { - val state = context.state - context.store.dispatch( - CreditCardEditorAction.Initialization.InitCompleted( - state = state.copy( - expiryMonths = calendarDataProvider.months(), - selectedExpiryMonthIndex = 0, - expiryYears = calendarDataProvider.years(), - selectedExpiryYearIndex = 0, - inEditMode = false, - showDeleteDialog = false, - ), - ), - ) - } - - private fun initializeFromCard( - context: MiddlewareContext<CreditCardEditorState, CreditCardEditorAction>, - creditCard: CreditCard, - ) { - coroutineScope.launch(ioDispatcher) { - val state = context.state - val crypto = storage.getCreditCardCrypto() - - val plainTextCardNumber = crypto.decrypt( - key = crypto.getOrGenerateKey(), - encryptedCardNumber = creditCard.encryptedCardNumber, - ) - - val years = calendarDataProvider.years(creditCard.expiryYear) - - context.store.dispatch( - CreditCardEditorAction.Initialization.InitCompleted( - state = state.copy( - guid = creditCard.guid, - nameOnCard = creditCard.billingName, - cardNumber = plainTextCardNumber?.number ?: "", - expiryMonths = calendarDataProvider.months(), - selectedExpiryMonthIndex = creditCard.expiryMonth.toInt() - 1, - expiryYears = years, - selectedExpiryYearIndex = years.indexOfFirst { year -> - year == creditCard.expiryYear.toString() - }, - inEditMode = true, - showDeleteDialog = false, - ), - ), - ) - } - } -} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorReducer.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorReducer.kt @@ -1,77 +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.creditcards.ui - -import org.mozilla.fenix.settings.creditcards.validateCreditCardNumber - -/** - * Reducer for the [CreditCardEditorStore]. - * - * @param state The current state of the store. - * @param action The action to be handled. - * - * @return The new state of the store. - */ -internal fun creditCardEditorReducer( - state: CreditCardEditorState, - action: CreditCardEditorAction, -): CreditCardEditorState { - return when (action) { - is CreditCardEditorAction.Initialization -> state.handleInitialization(action) - is CreditCardEditorAction.FieldChanged -> state.handleFieldChange(action) - is CreditCardEditorAction.DeleteClicked -> state.copy(showDeleteDialog = true) - is CreditCardEditorAction.DeleteDialogAction -> state.copy(showDeleteDialog = false) - is CreditCardEditorAction.Save -> state.validateInput() - else -> state - } -} - -private fun CreditCardEditorState.validateInput(): CreditCardEditorState { - val validCardNumber = cardNumber.validateCreditCardNumber() - val validNameOnCard = nameOnCard.isNotBlank() - - return copy(showNameOnCardError = !validNameOnCard, showCardNumberError = !validCardNumber) -} - -private fun CreditCardEditorState.handleInitialization( - action: CreditCardEditorAction.Initialization, -): CreditCardEditorState { - return when (action) { - is CreditCardEditorAction.Initialization.InitCompleted -> copy( - guid = action.state.guid, - cardNumber = action.state.cardNumber, - showCardNumberError = action.state.showCardNumberError, - nameOnCard = action.state.nameOnCard, - showNameOnCardError = action.state.showNameOnCardError, - expiryMonths = action.state.expiryMonths, - selectedExpiryMonthIndex = action.state.selectedExpiryMonthIndex, - expiryYears = action.state.expiryYears, - selectedExpiryYearIndex = action.state.selectedExpiryYearIndex, - inEditMode = action.state.inEditMode, - showDeleteDialog = action.state.showDeleteDialog, - ) - - else -> this - } -} - -private fun CreditCardEditorState.handleFieldChange( - action: CreditCardEditorAction.FieldChanged, -): CreditCardEditorState { - return when (action) { - is CreditCardEditorAction.FieldChanged.CardNumberChanged -> copy( - cardNumber = action.cardNumber, - showCardNumberError = false, - ) - - is CreditCardEditorAction.FieldChanged.MonthSelected -> copy(selectedExpiryMonthIndex = action.index) - is CreditCardEditorAction.FieldChanged.NameOnCardChanged -> copy( - nameOnCard = action.nameOnCard, - showNameOnCardError = false, - ) - - is CreditCardEditorAction.FieldChanged.YearSelected -> copy(selectedExpiryYearIndex = action.index) - } -} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorScreen.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorScreen.kt @@ -1,471 +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.creditcards.ui - -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.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -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.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.autofill.ContentType -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentType -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTagsAsResourceId -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import mozilla.components.compose.base.Dropdown -import mozilla.components.compose.base.annotation.FlexibleWindowLightDarkPreview -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.text.Text -import mozilla.components.compose.base.textfield.TextField -import mozilla.components.compose.base.textfield.TextFieldColors -import mozilla.components.lib.state.ext.observeAsState -import org.mozilla.fenix.R -import org.mozilla.fenix.settings.creditcards.ui.CreditCardEditorAction.DeleteDialogAction -import org.mozilla.fenix.settings.creditcards.ui.CreditCardEditorAction.FieldChanged -import org.mozilla.fenix.theme.FirefoxTheme -import org.mozilla.fenix.theme.Theme -import mozilla.components.ui.icons.R as iconsR - -/** - * Weight for the expiration month dropdown. - */ -private const val EXPIRATION_MONTH_WEIGHT = 5f - -/** - * Weight for the expiration year dropdown. - */ -private const val EXPIRATION_YEAR_WEIGHT = 4f - -/** - * Composable for the credit card editor screen. - * - * @param store The [CreditCardEditorStore] that manages the state in the screen - */ -@Composable -fun CreditCardEditorScreen(store: CreditCardEditorStore) { - val state by store.observeAsState(store.state) { it } - - Scaffold( - modifier = Modifier.semantics { - testTagsAsResourceId = true - }, - topBar = { - EditorTopBar( - inEditMode = state.inEditMode, - onBackClicked = { - store.dispatch(CreditCardEditorAction.NavigateBack) - }, - onSaveActionClicked = { - store.dispatch(CreditCardEditorAction.Save) - }, - onDeleteActionClicked = { - store.dispatch(CreditCardEditorAction.DeleteClicked) - }, - ) - }, - containerColor = FirefoxTheme.colors.layer1, - ) { - if (state.showDeleteDialog) { - DeleteCreditCardDialog( - onCancel = { - store.dispatch(DeleteDialogAction.Cancel) - }, - onConfirm = { - store.dispatch(DeleteDialogAction.Confirm) - }, - ) - } - - Box( - modifier = Modifier - .fillMaxWidth() - .padding(it), - contentAlignment = Alignment.TopCenter, - ) { - EditorContent( - state = state, - onCardNumberChanged = { cardNumber -> - store.dispatch(FieldChanged.CardNumberChanged(cardNumber)) - }, - onNameOnCardChanged = { nameOnCard -> - store.dispatch(FieldChanged.NameOnCardChanged(nameOnCard)) - }, - onMonthSelected = { month -> - store.dispatch(FieldChanged.MonthSelected(month)) - }, - onYearSelected = { year -> - store.dispatch(FieldChanged.YearSelected(year)) - }, - onDeleteClicked = { - store.dispatch(CreditCardEditorAction.DeleteClicked) - }, - onCancelClicked = { - store.dispatch(CreditCardEditorAction.Cancel) - }, - onSaveClicked = { - store.dispatch(CreditCardEditorAction.Save) - }, - ) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) // TopAppBar -@Composable -private fun EditorTopBar( - modifier: Modifier = Modifier, - inEditMode: Boolean, - onDeleteActionClicked: () -> Unit, - onSaveActionClicked: () -> Unit, - onBackClicked: () -> Unit, -) { - TopAppBar( - modifier = modifier, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = FirefoxTheme.colors.layer1, - titleContentColor = FirefoxTheme.colors.textPrimary, - actionIconContentColor = FirefoxTheme.colors.iconPrimary, - ), - title = { - Text( - text = if (inEditMode) { - stringResource(R.string.credit_cards_edit_card) - } else { - stringResource(R.string.credit_cards_add_card) - }, - style = FirefoxTheme.typography.headline6, - ) - }, - navigationIcon = { - IconButton(onClick = onBackClicked) { - Icon( - painter = painterResource(iconsR.drawable.mozac_ic_back_24), - contentDescription = stringResource(R.string.credit_cards_navigate_back_button_content_description), - ) - } - }, - actions = { - if (inEditMode) { - IconButton( - modifier = Modifier.testTag(CreditCardEditorTestTags.TOPBAR_DELETE_BUTTON), - onClick = onDeleteActionClicked, - ) { - Icon( - painter = painterResource(iconsR.drawable.mozac_ic_delete_24), - contentDescription = stringResource(R.string.credit_cards_menu_delete_card), - ) - } - } - IconButton( - modifier = Modifier.testTag(CreditCardEditorTestTags.TOPBAR_SAVE_BUTTON), - onClick = onSaveActionClicked, - ) { - Icon( - painter = painterResource(iconsR.drawable.mozac_ic_checkmark_24), - contentDescription = stringResource(R.string.credit_cards_menu_save), - ) - } - }, - windowInsets = WindowInsets( - top = 0.dp, - bottom = 0.dp, - ), - ) -} - -@Composable -private fun EditorContent( - state: CreditCardEditorState, - onCardNumberChanged: (String) -> Unit = {}, - onNameOnCardChanged: (String) -> Unit = {}, - onMonthSelected: (Int) -> Unit = {}, - onYearSelected: (Int) -> Unit = {}, - onDeleteClicked: () -> Unit = {}, - onCancelClicked: () -> Unit = {}, - onSaveClicked: () -> Unit = {}, -) { - Column( - modifier = Modifier - .widthIn(max = FirefoxTheme.layout.size.containerMaxWidth) - .verticalScroll(rememberScrollState()) - .padding(FirefoxTheme.layout.space.static200) - .imePadding(), - verticalArrangement = Arrangement.spacedBy(FirefoxTheme.layout.space.static200), - ) { - val focusRequester = remember { FocusRequester() } - - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - - TextInput( - modifier = Modifier - .fillMaxWidth() - .testTag(CreditCardEditorTestTags.CARD_NUMBER_FIELD) - .semantics { contentType = ContentType.CreditCardNumber } - .focusRequester(focusRequester), - errorText = stringResource(R.string.credit_cards_number_validation_error_message_2), - label = stringResource(R.string.credit_cards_card_number), - isError = state.showCardNumberError, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Next, - ), - value = state.cardNumber, - onValueChange = onCardNumberChanged, - ) - - TextInput( - modifier = Modifier - .fillMaxWidth() - .testTag(CreditCardEditorTestTags.NAME_ON_CARD_FIELD) - .semantics { contentType = ContentType.PersonFullName }, - errorText = stringResource(R.string.credit_cards_name_on_card_validation_error_message_2), - label = stringResource(R.string.credit_cards_name_on_card), - isError = state.showNameOnCardError, - value = state.nameOnCard, - onValueChange = onNameOnCardChanged, - ) - - ExpirationDateRow( - months = state.expiryMonths, - years = state.expiryYears, - selectedMonthIndex = state.selectedExpiryMonthIndex, - selectedYearIndex = state.selectedExpiryYearIndex, - onMonthSelected = onMonthSelected, - onYearSelected = onYearSelected, - ) - - ButtonsRow( - inEditMode = state.inEditMode, - onDeleteClicked = onDeleteClicked, - onCancelClicked = onCancelClicked, - onSaveClicked = onSaveClicked, - ) - } -} - -@Composable -private fun ExpirationDateRow( - months: List<String>, - selectedMonthIndex: Int, - years: List<String>, - selectedYearIndex: Int, - onMonthSelected: (Int) -> Unit, - onYearSelected: (Int) -> Unit, -) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(FirefoxTheme.layout.space.static200), - ) { - ExpirationDateDropdown( - modifier = Modifier - .weight(EXPIRATION_MONTH_WEIGHT) - .testTag(CreditCardEditorTestTags.EXPIRATION_MONTH_FIELD) - .semantics { contentType = ContentType.CreditCardExpirationMonth }, - label = stringResource(R.string.credit_cards_expiration_date_month), - items = months, - selectedIndex = selectedMonthIndex, - onItemSelected = onMonthSelected, - ) - - ExpirationDateDropdown( - modifier = Modifier - .weight(EXPIRATION_YEAR_WEIGHT) - .testTag(CreditCardEditorTestTags.EXPIRATION_YEAR_FIELD) - .semantics { contentType = ContentType.CreditCardExpirationYear }, - label = stringResource(R.string.credit_cards_expiration_date_year), - items = years, - selectedIndex = selectedYearIndex, - onItemSelected = onYearSelected, - ) - } -} - -@Composable -private fun ButtonsRow( - inEditMode: Boolean, - onDeleteClicked: () -> Unit, - onCancelClicked: () -> Unit, - onSaveClicked: () -> Unit, -) { - Row { - if (inEditMode) { - DestructiveButton( - text = stringResource(R.string.credit_cards_delete_card_button), - modifier = Modifier.testTag(CreditCardEditorTestTags.DELETE_BUTTON), - onClick = onDeleteClicked, - ) - } - - Spacer(Modifier.weight(1f)) - - OutlinedButton( - modifier = Modifier.testTag(CreditCardEditorTestTags.CANCEL_BUTTON), - text = stringResource(R.string.credit_cards_cancel_button), - onClick = onCancelClicked, - ) - - Spacer(Modifier.width(FirefoxTheme.layout.space.static200)) - - FilledButton( - modifier = Modifier.testTag(CreditCardEditorTestTags.SAVE_BUTTON), - text = stringResource(R.string.credit_cards_save_button), - onClick = onSaveClicked, - ) - } -} - -@Composable -private fun ExpirationDateDropdown( - label: String, - selectedIndex: Int, - items: List<String>, - modifier: Modifier = Modifier, - onItemSelected: (Int) -> Unit, -) { - Dropdown( - modifier = modifier, - label = label, - dropdownItems = items.mapIndexed { index, itemLabel -> - MenuItem.CheckableItem( - text = Text.String(value = itemLabel), - isChecked = index == selectedIndex, - onClick = { - onItemSelected(index) - }, - ) - }, - placeholder = "", - ) -} - -@Composable -private fun TextInput( - modifier: Modifier = Modifier, - label: String, - value: String, - isError: Boolean, - errorText: String, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - onValueChange: (String) -> Unit = {}, -) { - TextField( - value = value, - onValueChange = onValueChange, - modifier = modifier, - label = label, - placeholder = "", - isError = isError, - errorText = errorText, - keyboardOptions = keyboardOptions, - colors = TextFieldColors.default(), - ) -} - -@FlexibleWindowLightDarkPreview -@Composable -@Preview -private fun CreditCardEditorScreenPreview() = FirefoxTheme { - val state by remember { - mutableStateOf( - CreditCardEditorState( - guid = "1234", - cardNumber = "5555444433331111", - nameOnCard = "Jane Doe", - expiryMonths = listOf("January (01)", "February (02)"), - selectedExpiryMonthIndex = 1, - expiryYears = listOf("2025", "2026", "2027"), - selectedExpiryYearIndex = 2, - inEditMode = false, - showDeleteDialog = false, - ), - ) - } - CreditCardEditorScreen( - store = CreditCardEditorStore(initialState = state), - ) -} - -@Composable -@Preview -private fun CreditCardEditorScreenPrivateThemePreview() = FirefoxTheme(theme = Theme.Private) { - val state by remember { - mutableStateOf( - CreditCardEditorState( - guid = "1234", - cardNumber = "5555444433331111", - nameOnCard = "Jane Doe", - expiryMonths = listOf("January (01)", "February (02)"), - selectedExpiryMonthIndex = 1, - expiryYears = listOf("2025", "2026", "2027"), - selectedExpiryYearIndex = 2, - inEditMode = false, - showDeleteDialog = false, - ), - ) - } - CreditCardEditorScreen( - store = CreditCardEditorStore(initialState = state), - ) -} - -@FlexibleWindowLightDarkPreview -@Composable -@Preview -private fun CreditCardEditorScreenDeleteDialogPreview() = FirefoxTheme { - val state by remember { - mutableStateOf( - CreditCardEditorState( - guid = "1234", - cardNumber = "5555444433331111", - nameOnCard = "Jane Doe", - expiryMonths = listOf("January (01)", "February (02)"), - selectedExpiryMonthIndex = 1, - expiryYears = listOf("2025", "2026", "2027"), - selectedExpiryYearIndex = 2, - inEditMode = false, - showDeleteDialog = true, - ), - ) - } - CreditCardEditorScreen( - store = CreditCardEditorStore(initialState = state), - ) -} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorState.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorState.kt @@ -1,50 +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.creditcards.ui - -import mozilla.components.lib.state.State - -/** - * State defining the "Edit Credit Card" screen - * - * @property guid The unique identifier for the edited credit card. - * @property cardNumber The credit card number of the card being edited. - * @property showCardNumberError Indicates whether or not to show an error on the "card number" field. - * @property nameOnCard The credit card name. - * @property showNameOnCardError Indicates whether or not to show an error on the "name on card" field. - * @property expiryMonths The non-empty list of expiry month options for display. - * @property selectedExpiryMonthIndex The index of the selected expiry month. - * @property expiryYears The non-empty list of expiry year options for display. - * @property selectedExpiryYearIndex The index of the selected expiry year. - * @property inEditMode Indicates whether or not the state is in edit more, or create mode. - * @property showDeleteDialog Indicates whether or not to show the delete dialog. - */ -data class CreditCardEditorState( - val guid: String, - val cardNumber: String, - val showCardNumberError: Boolean = false, - val nameOnCard: String, - val showNameOnCardError: Boolean = false, - val expiryMonths: List<String>, - val selectedExpiryMonthIndex: Int, - val expiryYears: List<String>, - val selectedExpiryYearIndex: Int, - val inEditMode: Boolean, - val showDeleteDialog: Boolean = false, -) : State { - - companion object { - val Default = CreditCardEditorState( - guid = "", - cardNumber = "", - nameOnCard = "", - inEditMode = false, - expiryMonths = listOf(), - selectedExpiryMonthIndex = 0, - expiryYears = listOf(), - selectedExpiryYearIndex = 0, - ) - } -} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorStore.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorStore.kt @@ -1,22 +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.creditcards.ui - -import mozilla.components.lib.state.Middleware -import mozilla.components.lib.state.Reducer -import mozilla.components.lib.state.Store - -/** - * A Store for handling [CreditCardEditorState] and dispatching [CreditCardEditorAction]. - */ -class CreditCardEditorStore( - initialState: CreditCardEditorState, - reducer: Reducer<CreditCardEditorState, CreditCardEditorAction> = ::creditCardEditorReducer, - middleware: List<Middleware<CreditCardEditorState, CreditCardEditorAction>> = listOf(), -) : Store<CreditCardEditorState, CreditCardEditorAction>( - initialState = initialState, - reducer = reducer, - middleware = middleware, -) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorTestTags.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorTestTags.kt @@ -1,44 +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.creditcards.ui - -/** - * Test tags for the credit card editor screen. - */ -object CreditCardEditorTestTags { - - /** Test tag for the card number input field. */ - const val CARD_NUMBER_FIELD = "credit_card.edit.field.card_number" - - /** Test tag for the name on card input field. */ - const val NAME_ON_CARD_FIELD = "credit_card.edit.field.name_on_card" - - /** Test tag for the expiration month input field. */ - const val EXPIRATION_MONTH_FIELD = "credit_card.edit.field.expiration_month" - - /** Test tag for the expiration year input field. */ - const val EXPIRATION_YEAR_FIELD = "credit_card.edit.field.expiration_year" - - /** Test tag for the save button. */ - const val SAVE_BUTTON = "credit_card.edit.button.save" - - /** Test tag for the cancel button. */ - const val CANCEL_BUTTON = "credit_card.edit.button.cancel" - - /** Test tag for the delete button. */ - const val DELETE_BUTTON = "credit_card.edit.button.delete" - - /** Test tag for the cancel button in the delete confirmation dialog. */ - const val DELETE_DIALOG_CANCEL_BUTTON = "credit_card.edit.dialog.button.cancel" - - /** Test tag for the delete button in the delete confirmation dialog. */ - const val DELETE_DIALOG_DELETE_BUTTON = "credit_card.edit.dialog.button.delete" - - /** Test tag for the delete button in the top bar. */ - const val TOPBAR_DELETE_BUTTON = "credit_card.edit.topbar.button.delete" - - /** Test tag for the save button in the top bar. */ - const val TOPBAR_SAVE_BUTTON = "credit_card.edit.topbar.button.save" -} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/DeleteCreditCardDialog.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/ui/DeleteCreditCardDialog.kt @@ -1,72 +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.creditcards.ui - -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import mozilla.components.compose.base.button.TextButton -import org.mozilla.fenix.R -import org.mozilla.fenix.theme.FirefoxTheme -import org.mozilla.fenix.theme.Theme - -/** - * Composable for the delete credit card dialog. - * - * @param modifier The [Modifier] to be applied to the dialog. - * @param onCancel The callback to be invoked when the cancel button is clicked. - * @param onConfirm The callback to be invoked when the confirm button is clicked. - */ -@Composable -internal fun DeleteCreditCardDialog( - modifier: Modifier = Modifier, - onCancel: () -> Unit = {}, - onConfirm: () -> Unit = {}, -) { - AlertDialog( - modifier = modifier, - title = { - Text( - text = stringResource(R.string.credit_cards_delete_dialog_confirmation_2), - color = FirefoxTheme.colors.textPrimary, - style = FirefoxTheme.typography.headline5, - ) - }, - text = null, - onDismissRequest = onCancel, - confirmButton = { - TextButton( - modifier = Modifier.testTag(CreditCardEditorTestTags.DELETE_DIALOG_DELETE_BUTTON), - text = stringResource(R.string.credit_cards_delete_dialog_button), - onClick = onConfirm, - ) - }, - dismissButton = { - TextButton( - modifier = Modifier.testTag(CreditCardEditorTestTags.DELETE_DIALOG_CANCEL_BUTTON), - text = stringResource(R.string.credit_cards_cancel_button), - onClick = onCancel, - ) - }, - ) -} - -private class ThemePreviewParameterProvider( - override val values: Sequence<Theme> = Theme.entries.asSequence(), -) : PreviewParameterProvider<Theme> - -@Composable -@Preview -private fun PreviewDeleteCreditCardDialog( - @PreviewParameter(ThemePreviewParameterProvider::class) theme: Theme, -) = FirefoxTheme(theme) { - DeleteCreditCardDialog() -} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/view/CreditCardEditorView.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/view/CreditCardEditorView.kt @@ -0,0 +1,199 @@ +/* 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.creditcards.view + +import android.content.res.ColorStateList +import android.view.View +import android.widget.ArrayAdapter +import androidx.annotation.VisibleForTesting +import mozilla.components.concept.storage.CreditCardNumber +import mozilla.components.concept.storage.NewCreditCardFields +import mozilla.components.concept.storage.UpdatableCreditCardFields +import mozilla.components.support.ktx.android.content.getColorFromAttr +import mozilla.components.support.ktx.android.view.hideKeyboard +import mozilla.components.support.utils.creditCardIIN +import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.FragmentCreditCardEditorBinding +import org.mozilla.fenix.ext.toEditable +import org.mozilla.fenix.settings.creditcards.CreditCardEditorFragment +import org.mozilla.fenix.settings.creditcards.CreditCardEditorState +import org.mozilla.fenix.settings.creditcards.interactor.CreditCardEditorInteractor +import org.mozilla.fenix.settings.creditcards.last4Digits +import org.mozilla.fenix.settings.creditcards.toCreditCardNumber +import org.mozilla.fenix.settings.creditcards.validateCreditCardNumber +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale + +/** + * Shows a credit card editor for adding or updating a credit card. + */ +class CreditCardEditorView( + private val binding: FragmentCreditCardEditorBinding, + private val interactor: CreditCardEditorInteractor, +) { + + /** + * Binds the given [CreditCardEditorState] in the [CreditCardEditorFragment]. + */ + fun bind(state: CreditCardEditorState) { + if (state.isEditing) { + binding.deleteButton.apply { + visibility = View.VISIBLE + + setOnClickListener { + interactor.onDeleteCardButtonClicked(state.guid) + } + } + } + + binding.cancelButton.setOnClickListener { + interactor.onCancelButtonClicked() + } + + binding.saveButton.setOnClickListener { + saveCreditCard(state) + } + + binding.cardNumberInput.text = state.cardNumber.toEditable() + binding.nameOnCardInput.text = state.billingName.toEditable() + + binding.cardNumberLayout.setErrorTextColor( + ColorStateList.valueOf( + binding.root.context.getColorFromAttr(R.attr.textCritical), + ), + ) + binding.nameOnCardLayout.setErrorTextColor( + ColorStateList.valueOf( + binding.root.context.getColorFromAttr(R.attr.textCritical), + ), + ) + + bindExpiryMonthDropDown(state.expiryMonth) + bindExpiryYearDropDown(state.expiryYears) + } + + /** + * Saves a new credit card or updates an existing one with data from the user input. + * + * @param state The state of the [CreditCardEditorFragment] containing the edited credit card + * information. + */ + internal fun saveCreditCard(state: CreditCardEditorState) { + binding.root.hideKeyboard() + + if (validateForm()) { + val cardNumber = binding.cardNumberInput.text.toString().toCreditCardNumber() + + if (state.isEditing) { + val fields = UpdatableCreditCardFields( + billingName = binding.nameOnCardInput.text.toString(), + cardNumber = CreditCardNumber.Plaintext(cardNumber), + cardNumberLast4 = cardNumber.last4Digits(), + expiryMonth = (binding.expiryMonthDropDown.selectedItemPosition + 1).toLong(), + expiryYear = binding.expiryYearDropDown.selectedItem.toString().toLong(), + cardType = cardNumber.creditCardIIN()?.creditCardIssuerNetwork?.name ?: "", + ) + interactor.onUpdateCreditCard(state.guid, fields) + } else { + val fields = NewCreditCardFields( + billingName = binding.nameOnCardInput.text.toString(), + plaintextCardNumber = CreditCardNumber.Plaintext(cardNumber), + cardNumberLast4 = cardNumber.last4Digits(), + expiryMonth = (binding.expiryMonthDropDown.selectedItemPosition + 1).toLong(), + expiryYear = binding.expiryYearDropDown.selectedItem.toString().toLong(), + cardType = cardNumber.creditCardIIN()?.creditCardIssuerNetwork?.name ?: "", + ) + interactor.onSaveCreditCard(fields) + } + } + } + + /** + * Validates the credit card information entered by the user. + * + * @return true if the credit card information is valid, false otherwise. + */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun validateForm(): Boolean { + var isValid = true + + if (binding.cardNumberInput.text.toString().validateCreditCardNumber()) { + binding.cardNumberLayout.error = null + binding.cardNumberTitle.setTextColor(binding.root.context.getColorFromAttr(R.attr.textPrimary)) + } else { + isValid = false + + binding.cardNumberLayout.error = + binding.root.context.getString(R.string.credit_cards_number_validation_error_message_2) + binding.cardNumberTitle.setTextColor(binding.root.context.getColorFromAttr(R.attr.textCritical)) + } + + if (binding.nameOnCardInput.text.toString().isNotBlank()) { + binding.nameOnCardLayout.error = null + binding.nameOnCardTitle.setTextColor(binding.root.context.getColorFromAttr(R.attr.textPrimary)) + } else { + isValid = false + + binding.nameOnCardLayout.error = + binding.root.context.getString(R.string.credit_cards_name_on_card_validation_error_message_2) + binding.nameOnCardTitle.setTextColor(binding.root.context.getColorFromAttr(R.attr.textCritical)) + } + + return isValid + } + + /** + * Setup the expiry month dropdown by formatting and populating it with the months in a calendar + * year, and set the selection to the provided expiry month. + * + * @param expiryMonth The selected credit card expiry month to display. + */ + private fun bindExpiryMonthDropDown(expiryMonth: Int) { + val adapter = + ArrayAdapter<String>( + binding.root.context, + android.R.layout.simple_spinner_dropdown_item, + ) + val dateFormat = SimpleDateFormat("MMMM (MM)", Locale.getDefault()) + + val calendar = Calendar.getInstance() + calendar.set(Calendar.DAY_OF_MONTH, 1) + + for (month in 0..NUMBER_OF_MONTHS) { + calendar.set(Calendar.MONTH, month) + adapter.add(dateFormat.format(calendar.time)) + } + + binding.expiryMonthDropDown.adapter = adapter + binding.expiryMonthDropDown.setSelection(expiryMonth - 1) + } + + /** + * Setup the expiry year dropdown with the range specified by the provided expiryYears + * + * @param expiryYears A range specifying the start and end year to display in the expiry year + * dropdown. + */ + private fun bindExpiryYearDropDown(expiryYears: Pair<Int, Int>) { + val adapter = + ArrayAdapter<String>( + binding.root.context, + android.R.layout.simple_spinner_dropdown_item, + ) + val (startYear, endYear) = expiryYears + + for (year in startYear until endYear) { + adapter.add(year.toString()) + } + + binding.expiryYearDropDown.adapter = adapter + } + + companion object { + // Number of months in a year (0-indexed). + const val NUMBER_OF_MONTHS = 11 + } +} diff --git a/mobile/android/fenix/app/src/main/res/layout/fragment_credit_card_editor.xml b/mobile/android/fenix/app/src/main/res/layout/fragment_credit_card_editor.xml @@ -0,0 +1,216 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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/. --> +<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_margin="16dp"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical"> + + <!-- Card Number --> + <TextView + android:id="@+id/card_number_title" + style="@style/CaptionTextStyle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:gravity="center_vertical" + android:letterSpacing="0.05" + android:paddingStart="3dp" + android:paddingEnd="0dp" + android:text="@string/credit_cards_card_number" + android:textColor="?attr/textPrimary" + android:labelFor="@id/card_number_input" /> + + <com.google.android.material.textfield.TextInputLayout + android:id="@+id/card_number_layout" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:textColor="?attr/textPrimary" + app:errorEnabled="true" + app:hintEnabled="false"> + + <com.google.android.material.textfield.TextInputEditText + android:id="@+id/card_number_input" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:digits="0123456789- " + android:ellipsize="end" + android:fontFamily="sans-serif" + android:imeOptions="flagNoExtractUi" + android:inputType="phone" + android:letterSpacing="0.01" + android:lineSpacingExtra="8sp" + android:maxLines="1" + android:singleLine="true" + android:textColor="?attr/textPrimary" + android:textSize="16sp" /> + + </com.google.android.material.textfield.TextInputLayout> + + <!-- Name on Card --> + <TextView + android:id="@+id/name_on_card_title" + style="@style/CaptionTextStyle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:gravity="center_vertical" + android:letterSpacing="0.05" + android:paddingStart="3dp" + android:paddingEnd="0dp" + android:text="@string/credit_cards_name_on_card" + android:textColor="?attr/textPrimary" + android:labelFor="@id/name_on_card_input" /> + + <com.google.android.material.textfield.TextInputLayout + android:id="@+id/name_on_card_layout" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingBottom="11dp" + android:textColor="?attr/textPrimary" + app:hintEnabled="false"> + + <com.google.android.material.textfield.TextInputEditText + android:id="@+id/name_on_card_input" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:ellipsize="end" + android:fontFamily="sans-serif" + android:imeOptions="flagNoExtractUi" + android:letterSpacing="0.01" + android:lineSpacingExtra="8sp" + android:maxLines="1" + android:singleLine="true" + android:textColor="?attr/textPrimary" + android:textSize="16sp" /> + + </com.google.android.material.textfield.TextInputLayout> + + <!-- Expiration Date --> + <TextView + android:id="@+id/expiration_date_title" + style="@style/CaptionTextStyle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:gravity="center_vertical" + android:letterSpacing="0.05" + android:paddingStart="3dp" + android:paddingEnd="0dp" + android:text="@string/credit_cards_expiration_date" + android:textColor="?attr/textPrimary" /> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:baselineAligned="false" + android:orientation="horizontal"> + + <LinearLayout + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginEnd="24dp" + android:layout_weight="1" + android:orientation="vertical"> + + <TextView + android:layout_width="0dp" + android:layout_height="0dp" + android:labelFor="@id/expiry_month_drop_down" + android:text="@string/credit_cards_expiration_date_month" /> + + <androidx.appcompat.widget.AppCompatSpinner + android:id="@+id/expiry_month_drop_down" + android:layout_width="match_parent" + android:layout_height="wrap_content" + tools:listitem="@android:layout/simple_list_item_1" /> + + <View + android:layout_width="match_parent" + android:layout_height="1dp" + android:background="?attr/textPrimary" /> + + </LinearLayout> + + <LinearLayout + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="0.8" + android:orientation="vertical"> + + <TextView + android:layout_width="0dp" + android:layout_height="0dp" + android:labelFor="@id/expiry_year_drop_down" + android:text="@string/credit_cards_expiration_date_year" /> + + <androidx.appcompat.widget.AppCompatSpinner + android:id="@+id/expiry_year_drop_down" + android:layout_width="match_parent" + android:layout_height="wrap_content" + tools:listitem="@android:layout/simple_list_item_1" /> + + <View + android:layout_width="match_parent" + android:layout_height="1dp" + android:background="?attr/textPrimary" /> + + </LinearLayout> + </LinearLayout> + + <androidx.constraintlayout.widget.ConstraintLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingTop="16dp"> + + <com.google.android.material.button.MaterialButton + android:id="@+id/delete_button" + style="@style/Widget.MaterialComponents.Button.TextButton" + android:layout_width="wrap_content" + android:layout_height="0dp" + android:letterSpacing="0" + android:padding="10dp" + android:text="@string/credit_cards_delete_card_button" + android:textAlignment="center" + android:textAllCaps="false" + android:textColor="@color/fx_mobile_text_color_critical" + android:visibility="gone" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" /> + + <com.google.android.material.button.MaterialButton + android:id="@+id/cancel_button" + style="@style/Widget.MaterialComponents.Button.TextButton" + android:layout_width="wrap_content" + android:layout_height="0dp" + android:letterSpacing="0" + android:padding="10dp" + android:text="@string/credit_cards_cancel_button" + android:textAlignment="center" + android:textAllCaps="false" + android:textColor="?attr/textPrimary" + android:textStyle="bold" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@id/save_button" /> + + <com.google.android.material.button.MaterialButton + android:id="@+id/save_button" + style="@style/NeutralButton" + android:layout_width="wrap_content" + android:paddingStart="12dp" + android:paddingEnd="12dp" + android:text="@string/credit_cards_save_button" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" /> + + </androidx.constraintlayout.widget.ConstraintLayout> + + </LinearLayout> +</ScrollView> diff --git a/mobile/android/fenix/app/src/main/res/menu/credit_card_editor.xml b/mobile/android/fenix/app/src/main/res/menu/credit_card_editor.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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/. --> +<menu xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto"> + <item + android:id="@+id/delete_credit_card_button" + android:icon="@drawable/ic_delete" + android:title="@string/credit_cards_menu_delete_card" + android:visible="false" + app:iconTint="?attr/textPrimary" + app:showAsAction="ifRoom" /> + <item + android:id="@+id/save_credit_card_button" + android:icon="@drawable/mozac_ic_checkmark_24" + android:title="@string/credit_cards_menu_save" + app:iconTint="?attr/textPrimary" + app:showAsAction="ifRoom" /> +</menu> diff --git a/mobile/android/fenix/app/src/main/res/values/strings.xml b/mobile/android/fenix/app/src/main/res/values/strings.xml @@ -2529,12 +2529,12 @@ <!-- Title of the "Add card" screen --> <string name="credit_cards_add_card">Add card</string> - <!-- Content description for the "credit card feature" top bar back button --> - <string name="credit_cards_navigate_back_button_content_description">Navigate back</string> <!-- Title of the "Edit card" screen --> <string name="credit_cards_edit_card">Edit card</string> <!-- The header for the card number of a credit card --> <string name="credit_cards_card_number">Card Number</string> + <!-- The header for the expiration date of a credit card --> + <string name="credit_cards_expiration_date">Expiration Date</string> <!-- The label for the expiration date month of a credit card to be used by a11y services--> <string name="credit_cards_expiration_date_month">Expiration Date Month</string> <!-- The label for the expiration date year of a credit card to be used by a11y services--> diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardEditorStateTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardEditorStateTest.kt @@ -0,0 +1,77 @@ +/* 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.creditcards + +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import mozilla.components.concept.storage.CreditCard +import mozilla.components.concept.storage.CreditCardNumber +import mozilla.components.service.sync.autofill.AutofillCreditCardsAddressesStorage +import mozilla.components.service.sync.autofill.AutofillCrypto +import mozilla.components.support.utils.CreditCardNetworkType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mozilla.fenix.settings.creditcards.CreditCardEditorFragment.Companion.NUMBER_OF_YEARS_TO_SHOW +import java.util.Calendar + +class CreditCardEditorStateTest { + + private val cardNumber = "4111111111111110" + private val creditCard = CreditCard( + guid = "id", + billingName = "Banana Apple", + encryptedCardNumber = CreditCardNumber.Encrypted(cardNumber), + cardNumberLast4 = "1110", + expiryMonth = 5, + expiryYear = 2030, + cardType = CreditCardNetworkType.AMEX.cardName, + timeCreated = 1L, + timeLastUsed = 1L, + timeLastModified = 1L, + timesUsed = 1L, + ) + + @Test + fun testToCreditCardEditorState() = runTest { + val storage: AutofillCreditCardsAddressesStorage = mockk(relaxed = true) + val crypto: AutofillCrypto = mockk(relaxed = true) + + every { storage.getCreditCardCrypto() } returns crypto + every { crypto.decrypt(any(), any()) } returns CreditCardNumber.Plaintext(cardNumber) + + val state = creditCard.toCreditCardEditorState(storage) + val startYear = creditCard.expiryYear.toInt() + val endYear = startYear + NUMBER_OF_YEARS_TO_SHOW + + with(state) { + assertEquals(creditCard.guid, guid) + assertEquals(creditCard.billingName, billingName) + assertEquals(creditCard.encryptedCardNumber.number, cardNumber) + assertEquals(creditCard.expiryMonth.toInt(), expiryMonth) + assertEquals(Pair(startYear, endYear), expiryYears) + assertTrue(isEditing) + } + } + + @Test + fun testGetInitialCreditCardEditorState() { + val state = getInitialCreditCardEditorState() + val calendar = Calendar.getInstance() + val startYear = calendar.get(Calendar.YEAR) + val endYear = startYear + NUMBER_OF_YEARS_TO_SHOW + + with(state) { + assertEquals("", guid) + assertEquals("", billingName) + assertEquals("", cardNumber) + assertEquals(1, expiryMonth) + assertEquals(Pair(startYear, endYear), expiryYears) + assertFalse(isEditing) + } + } +} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardEditorViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardEditorViewTest.kt @@ -0,0 +1,346 @@ +/* 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.creditcards + +import android.view.LayoutInflater +import android.view.View +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import mozilla.components.concept.storage.CreditCard +import mozilla.components.concept.storage.CreditCardNumber +import mozilla.components.concept.storage.NewCreditCardFields +import mozilla.components.concept.storage.UpdatableCreditCardFields +import mozilla.components.service.sync.autofill.AutofillCreditCardsAddressesStorage +import mozilla.components.service.sync.autofill.AutofillCrypto +import mozilla.components.support.ktx.android.content.getColorFromAttr +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.utils.CreditCardNetworkType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.FragmentCreditCardEditorBinding +import org.mozilla.fenix.ext.toEditable +import org.mozilla.fenix.settings.creditcards.CreditCardEditorFragment.Companion.NUMBER_OF_YEARS_TO_SHOW +import org.mozilla.fenix.settings.creditcards.interactor.CreditCardEditorInteractor +import org.mozilla.fenix.settings.creditcards.view.CreditCardEditorView +import org.robolectric.RobolectricTestRunner +import java.util.Calendar + +@RunWith(RobolectricTestRunner::class) +class CreditCardEditorViewTest { + + private lateinit var view: View + private lateinit var interactor: CreditCardEditorInteractor + private lateinit var creditCardEditorView: CreditCardEditorView + private lateinit var storage: AutofillCreditCardsAddressesStorage + private lateinit var crypto: AutofillCrypto + private lateinit var fragmentCreditCardEditorBinding: FragmentCreditCardEditorBinding + + private val cardNumber = "4111111111111111" + private val creditCard = CreditCard( + guid = "id", + billingName = "Banana Apple", + encryptedCardNumber = CreditCardNumber.Encrypted(cardNumber), + cardNumberLast4 = "1111", + expiryMonth = 5, + expiryYear = 2030, + cardType = CreditCardNetworkType.VISA.cardName, + timeCreated = 1L, + timeLastUsed = 1L, + timeLastModified = 1L, + timesUsed = 1L, + ) + + @Before + fun setup() { + view = LayoutInflater.from(testContext).inflate(R.layout.fragment_credit_card_editor, null) + fragmentCreditCardEditorBinding = FragmentCreditCardEditorBinding.bind(view) + interactor = mockk(relaxed = true) + storage = mockk(relaxed = true) + crypto = mockk(relaxed = true) + + every { storage.getCreditCardCrypto() } returns crypto + every { crypto.decrypt(any(), any()) } returns CreditCardNumber.Plaintext(cardNumber) + + creditCardEditorView = spyk(CreditCardEditorView(fragmentCreditCardEditorBinding, interactor)) + } + + @Test + fun `GIVEN the initial credit card editor state THEN credit card form inputs are in initial state`() { + creditCardEditorView.bind(getInitialCreditCardEditorState()) + + val calendar = Calendar.getInstance() + val startYear = calendar.get(Calendar.YEAR) + val endYear = startYear + NUMBER_OF_YEARS_TO_SHOW - 1 + + assertEquals("", fragmentCreditCardEditorBinding.cardNumberInput.text.toString()) + assertEquals("", fragmentCreditCardEditorBinding.nameOnCardInput.text.toString()) + + with(fragmentCreditCardEditorBinding.expiryMonthDropDown) { + assertEquals(12, count) + assertEquals("January (01)", selectedItem.toString()) + assertEquals("December (12)", getItemAtPosition(count - 1).toString()) + } + + with(fragmentCreditCardEditorBinding.expiryYearDropDown) { + assertEquals(10, count) + assertEquals(startYear.toString(), selectedItem.toString()) + assertEquals(endYear.toString(), getItemAtPosition(count - 1).toString()) + } + + assertEquals(View.GONE, fragmentCreditCardEditorBinding.deleteButton.visibility) + } + + @Test + fun `GIVEN a credit card THEN credit card form inputs are displaying the provided credit card information`() = runTest { + creditCardEditorView.bind(creditCard.toCreditCardEditorState(storage)) + + assertEquals(cardNumber, fragmentCreditCardEditorBinding.cardNumberInput.text.toString()) + assertEquals(creditCard.billingName, fragmentCreditCardEditorBinding.nameOnCardInput.text.toString()) + + with(fragmentCreditCardEditorBinding.expiryMonthDropDown) { + assertEquals(12, count) + assertEquals("May (05)", selectedItem.toString()) + } + + with(fragmentCreditCardEditorBinding.expiryYearDropDown) { + val endYear = creditCard.expiryYear + NUMBER_OF_YEARS_TO_SHOW - 1 + + assertEquals(10, count) + assertEquals(creditCard.expiryYear.toString(), selectedItem.toString()) + assertEquals(endYear.toString(), getItemAtPosition(count - 1).toString()) + } + } + + @Test + fun `GIVEN a credit card WHEN the delete card button is clicked THEN interactor is called`() = runTest { + creditCardEditorView.bind(creditCard.toCreditCardEditorState(storage)) + + assertEquals(View.VISIBLE, fragmentCreditCardEditorBinding.deleteButton.visibility) + + fragmentCreditCardEditorBinding.deleteButton.performClick() + + verify { interactor.onDeleteCardButtonClicked(creditCard.guid) } + } + + @Test + fun `WHEN the cancel button is clicked THEN interactor is called`() { + creditCardEditorView.bind(getInitialCreditCardEditorState()) + + fragmentCreditCardEditorBinding.cancelButton.performClick() + + verify { interactor.onCancelButtonClicked() } + } + + @Test + fun `GIVEN invalid credit card number WHEN the save button is clicked THEN interactor is not called`() { + creditCardEditorView.bind(getInitialCreditCardEditorState()) + + val calendar = Calendar.getInstance() + + var billingName = "Banana Apple" + val cardNumber = "2221000000000000" + val expiryMonth = 5 + val expiryYear = calendar.get(Calendar.YEAR) + + fragmentCreditCardEditorBinding.cardNumberInput.text = cardNumber.toEditable() + fragmentCreditCardEditorBinding.nameOnCardInput.text = billingName.toEditable() + fragmentCreditCardEditorBinding.expiryMonthDropDown.setSelection(expiryMonth - 1) + + fragmentCreditCardEditorBinding.saveButton.performClick() + + verify { + creditCardEditorView.validateForm() + } + + assertFalse(creditCardEditorView.validateForm()) + assertNotNull(fragmentCreditCardEditorBinding.cardNumberLayout.error) + assertEquals( + fragmentCreditCardEditorBinding.cardNumberLayout.errorCurrentTextColors, + fragmentCreditCardEditorBinding.root.context.getColorFromAttr(R.attr.textCritical), + ) + + verify(exactly = 0) { + interactor.onSaveCreditCard( + NewCreditCardFields( + billingName = billingName, + plaintextCardNumber = CreditCardNumber.Plaintext(cardNumber), + cardNumberLast4 = "0000", + expiryMonth = expiryMonth.toLong(), + expiryYear = expiryYear.toLong(), + cardType = CreditCardNetworkType.MASTERCARD.cardName, + ), + ) + } + + billingName = "" + fragmentCreditCardEditorBinding.nameOnCardInput.text = billingName.toEditable() + + fragmentCreditCardEditorBinding.saveButton.performClick() + + assertFalse(creditCardEditorView.validateForm()) + assertNotNull(fragmentCreditCardEditorBinding.cardNumberLayout.error) + assertEquals( + fragmentCreditCardEditorBinding.cardNumberLayout.errorCurrentTextColors, + fragmentCreditCardEditorBinding.root.context.getColorFromAttr(R.attr.textCritical), + ) + + verify(exactly = 0) { + interactor.onSaveCreditCard( + NewCreditCardFields( + billingName = billingName, + plaintextCardNumber = CreditCardNumber.Plaintext(cardNumber), + cardNumberLast4 = "0000", + expiryMonth = expiryMonth.toLong(), + expiryYear = expiryYear.toLong(), + cardType = CreditCardNetworkType.MASTERCARD.cardName, + ), + ) + } + } + + @Test + fun `GIVEN invalid credit card values WHEN valid values are entered and the save button is clicked THEN error messages are cleared`() { + creditCardEditorView.bind(getInitialCreditCardEditorState()) + + var billingName = "" + var cardNumber = "1234567891234567" + val expiryMonth = 5 + + fragmentCreditCardEditorBinding.cardNumberInput.text = cardNumber.toEditable() + fragmentCreditCardEditorBinding.nameOnCardInput.text = billingName.toEditable() + fragmentCreditCardEditorBinding.expiryMonthDropDown.setSelection(expiryMonth - 1) + + fragmentCreditCardEditorBinding.saveButton.performClick() + + verify { + creditCardEditorView.validateForm() + } + + billingName = "Banana Apple" + cardNumber = "2720994326581252" + fragmentCreditCardEditorBinding.nameOnCardInput.text = billingName.toEditable() + fragmentCreditCardEditorBinding.cardNumberInput.text = cardNumber.toEditable() + + fragmentCreditCardEditorBinding.saveButton.performClick() + + verify { + creditCardEditorView.validateForm() + } + + assertTrue(creditCardEditorView.validateForm()) + assertNull(fragmentCreditCardEditorBinding.cardNumberLayout.error) + assertNull(fragmentCreditCardEditorBinding.nameOnCardLayout.error) + } + + @Test + fun `GIVEN invalid name on card WHEN the save button is clicked THEN interactor is not called`() { + creditCardEditorView.bind(getInitialCreditCardEditorState()) + + val calendar = Calendar.getInstance() + + val billingName = " " + val cardNumber = "2221000000000000" + val expiryMonth = 5 + val expiryYear = calendar.get(Calendar.YEAR) + + fragmentCreditCardEditorBinding.cardNumberInput.text = cardNumber.toEditable() + fragmentCreditCardEditorBinding.nameOnCardInput.text = billingName.toEditable() + fragmentCreditCardEditorBinding.expiryMonthDropDown.setSelection(expiryMonth - 1) + + fragmentCreditCardEditorBinding.saveButton.performClick() + + verify { + creditCardEditorView.validateForm() + } + + assertFalse(creditCardEditorView.validateForm()) + assertNotNull(fragmentCreditCardEditorBinding.nameOnCardLayout.error) + assertEquals( + fragmentCreditCardEditorBinding.nameOnCardLayout.errorCurrentTextColors, + fragmentCreditCardEditorBinding.root.context.getColorFromAttr(R.attr.textCritical), + ) + + verify(exactly = 0) { + interactor.onSaveCreditCard( + NewCreditCardFields( + billingName = billingName, + plaintextCardNumber = CreditCardNumber.Plaintext(cardNumber), + cardNumberLast4 = "0000", + expiryMonth = expiryMonth.toLong(), + expiryYear = expiryYear.toLong(), + cardType = CreditCardNetworkType.MASTERCARD.cardName, + ), + ) + } + } + + @Test + fun `GIVEN valid credit card number WHEN the save button is clicked THEN interactor is called`() { + creditCardEditorView.bind(getInitialCreditCardEditorState()) + + val calendar = Calendar.getInstance() + + val billingName = "Banana Apple" + val cardNumber = "2720994326581252" + val expiryMonth = 5 + val expiryYear = calendar.get(Calendar.YEAR) + + fragmentCreditCardEditorBinding.cardNumberInput.text = cardNumber.toEditable() + fragmentCreditCardEditorBinding.nameOnCardInput.text = billingName.toEditable() + fragmentCreditCardEditorBinding.expiryMonthDropDown.setSelection(expiryMonth - 1) + + fragmentCreditCardEditorBinding.saveButton.performClick() + + verify { + creditCardEditorView.validateForm() + } + + assertTrue(creditCardEditorView.validateForm()) + + verify { + interactor.onSaveCreditCard( + NewCreditCardFields( + billingName = billingName, + plaintextCardNumber = CreditCardNumber.Plaintext(cardNumber), + cardNumberLast4 = "1252", + expiryMonth = expiryMonth.toLong(), + expiryYear = expiryYear.toLong(), + cardType = CreditCardNetworkType.MASTERCARD.cardName, + ), + ) + } + } + + @Test + fun `GIVEN a valid credit card WHEN the save button is clicked THEN interactor is called`() = runTest { + creditCardEditorView.bind(creditCard.toCreditCardEditorState(storage)) + + fragmentCreditCardEditorBinding.saveButton.performClick() + + verify { + interactor.onUpdateCreditCard( + guid = creditCard.guid, + creditCardFields = UpdatableCreditCardFields( + billingName = creditCard.billingName, + cardNumber = CreditCardNumber.Plaintext(cardNumber), + cardNumberLast4 = creditCard.cardNumberLast4, + expiryMonth = creditCard.expiryMonth, + expiryYear = creditCard.expiryYear, + cardType = creditCard.cardType, + ), + ) + } + } +} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/DefaultCreditCardEditorControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/DefaultCreditCardEditorControllerTest.kt @@ -0,0 +1,134 @@ +/* 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.creditcards + +import android.content.DialogInterface +import androidx.navigation.NavController +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify +import mozilla.components.concept.storage.CreditCardNumber +import mozilla.components.concept.storage.NewCreditCardFields +import mozilla.components.concept.storage.UpdatableCreditCardFields +import mozilla.components.service.sync.autofill.AutofillCreditCardsAddressesStorage +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule +import mozilla.components.support.test.rule.runTestOnMain +import mozilla.components.support.utils.CreditCardNetworkType +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.fenix.GleanMetrics.CreditCards +import org.mozilla.fenix.helpers.FenixGleanTestRule +import org.mozilla.fenix.settings.creditcards.controller.DefaultCreditCardEditorController +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) // for gleanTestRule +class DefaultCreditCardEditorControllerTest { + + @get:Rule + val gleanTestRule = FenixGleanTestRule(testContext) + + private val storage: AutofillCreditCardsAddressesStorage = mockk(relaxed = true) + private val navController: NavController = mockk(relaxed = true) + private val showDeleteDialog = mockk<(DialogInterface.OnClickListener) -> Unit>() + + private lateinit var controller: DefaultCreditCardEditorController + + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + private val testDispatcher = coroutinesTestRule.testDispatcher + private val testCoroutineScope = coroutinesTestRule.scope + + @Before + fun setup() { + every { showDeleteDialog(any()) } answers { + firstArg<DialogInterface.OnClickListener>().onClick( + mockk(relaxed = true), + mockk(relaxed = true), + ) + } + controller = spyk( + DefaultCreditCardEditorController( + storage = storage, + lifecycleScope = testCoroutineScope, + navController = navController, + ioDispatcher = testDispatcher, + showDeleteDialog = showDeleteDialog, + ), + ) + } + + @Test + fun handleCancelButtonClicked() { + controller.handleCancelButtonClicked() + + verify { + navController.popBackStack() + } + } + + @Test + fun handleDeleteCreditCard() = runTestOnMain { + val creditCardId = "id" + assertNull(CreditCards.deleted.testGetValue()) + + controller.handleDeleteCreditCard(creditCardId) + + coVerify { + storage.deleteCreditCard(creditCardId) + navController.popBackStack() + } + assertNotNull(CreditCards.deleted.testGetValue()) + } + + @Test + fun handleSaveCreditCard() = runTestOnMain { + val creditCardFields = NewCreditCardFields( + billingName = "Banana Apple", + plaintextCardNumber = CreditCardNumber.Plaintext("4111111111111112"), + cardNumberLast4 = "1112", + expiryMonth = 1, + expiryYear = 2030, + cardType = CreditCardNetworkType.DISCOVER.cardName, + ) + assertNull(CreditCards.saved.testGetValue()) + + controller.handleSaveCreditCard(creditCardFields) + + coVerify { + storage.addCreditCard(creditCardFields) + navController.popBackStack() + } + assertNotNull(CreditCards.saved.testGetValue()) + } + + @Test + fun handleUpdateCreditCard() = runTestOnMain { + val creditCardId = "id" + val creditCardFields = UpdatableCreditCardFields( + billingName = "Banana Apple", + cardNumber = CreditCardNumber.Plaintext("4111111111111112"), + cardNumberLast4 = "1112", + expiryMonth = 1, + expiryYear = 2034, + cardType = CreditCardNetworkType.DISCOVER.cardName, + ) + assertNull(CreditCards.modified.testGetValue()) + + controller.handleUpdateCreditCard(creditCardId, creditCardFields) + + coVerify { + storage.updateCreditCard(creditCardId, creditCardFields) + navController.popBackStack() + } + assertNotNull(CreditCards.modified.testGetValue()) + } +} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/DefaultCreditCardEditorInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/DefaultCreditCardEditorInteractorTest.kt @@ -0,0 +1,83 @@ +/* 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.creditcards + +import io.mockk.mockk +import io.mockk.verify +import mozilla.components.concept.storage.CreditCard +import mozilla.components.concept.storage.CreditCardNumber +import mozilla.components.concept.storage.NewCreditCardFields +import mozilla.components.concept.storage.UpdatableCreditCardFields +import mozilla.components.support.utils.CreditCardNetworkType +import org.junit.Before +import org.junit.Test +import org.mozilla.fenix.settings.creditcards.controller.CreditCardEditorController +import org.mozilla.fenix.settings.creditcards.interactor.DefaultCreditCardEditorInteractor + +class DefaultCreditCardEditorInteractorTest { + + private val controller: CreditCardEditorController = mockk(relaxed = true) + + private lateinit var interactor: DefaultCreditCardEditorInteractor + + @Before + fun setup() { + interactor = DefaultCreditCardEditorInteractor(controller) + } + + @Test + fun onCancelButtonClicked() { + interactor.onCancelButtonClicked() + verify { controller.handleCancelButtonClicked() } + } + + @Test + fun onDeleteCardButtonClicked() { + val creditCard = CreditCard( + guid = "id", + billingName = "Banana Apple", + encryptedCardNumber = CreditCardNumber.Encrypted("4111111111111110"), + cardNumberLast4 = "1110", + expiryMonth = 1, + expiryYear = 2030, + cardType = CreditCardNetworkType.AMEX.cardName, + timeCreated = 1L, + timeLastUsed = 1L, + timeLastModified = 1L, + timesUsed = 1L, + ) + interactor.onDeleteCardButtonClicked(creditCard.guid) + verify { controller.handleDeleteCreditCard(creditCard.guid) } + } + + @Test + fun onSaveButtonClicked() { + val creditCardFields = NewCreditCardFields( + billingName = "Banana Apple", + plaintextCardNumber = CreditCardNumber.Plaintext("4111111111111112"), + cardNumberLast4 = "1112", + expiryMonth = 1, + expiryYear = 2030, + cardType = CreditCardNetworkType.DISCOVER.cardName, + ) + interactor.onSaveCreditCard(creditCardFields) + verify { controller.handleSaveCreditCard(creditCardFields) } + } + + @Test + fun onUpdateCreditCard() { + val guid = "id" + val creditCardFields = UpdatableCreditCardFields( + billingName = "Banana Apple", + cardNumber = CreditCardNumber.Encrypted("4111111111111112"), + cardNumberLast4 = "1112", + expiryMonth = 1, + expiryYear = 2034, + cardType = CreditCardNetworkType.DISCOVER.cardName, + ) + interactor.onUpdateCreditCard(guid, creditCardFields) + verify { controller.handleUpdateCreditCard(guid, creditCardFields) } + } +} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorReducerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorReducerTest.kt @@ -1,241 +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.creditcards.ui - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test -import org.mozilla.fenix.settings.creditcards.ui.CreditCardEditorAction.DeleteDialogAction -import org.mozilla.fenix.settings.creditcards.ui.CreditCardEditorAction.FieldChanged - -class CreditCardEditorReducerTest { - - @Test - fun `GIVEN a state, WHEN a CardNumberChanged action is received, then the card number is updated`() { - val state = createState() - - val result = creditCardEditorReducer( - state = state, - action = FieldChanged.CardNumberChanged("55554444"), - ) - - assertEquals( - "Expected card number to be updated", - "55554444", - result.cardNumber, - ) - } - - @Test - fun `GIVEN a state, WHEN a MonthSelected action is received, then the month is updated`() { - val state = createState( - expiryMonths = listOf("January", "February", "March"), - selectedExpiryMonthIndex = 1, - ) - - val result = creditCardEditorReducer( - state = state, - action = FieldChanged.MonthSelected(index = 2), - ) - - assertEquals( - "Expected month index is updated", - 2, - result.selectedExpiryMonthIndex, - ) - } - - @Test - fun `GIVEN a state, WHEN a YearSelected action is received, then the year is updated`() { - val state = createState( - expiryYears = listOf("2025", "2026", "2027"), - selectedExpiryYearIndex = 0, - ) - - val result = creditCardEditorReducer( - state = state, - action = FieldChanged.YearSelected(index = 1), - ) - - assertEquals( - "Expected year index is updated", - 1, - result.selectedExpiryYearIndex, - ) - } - - @Test - fun `GIVEN a state, WHEN a NameOnCardChanged action is received, then the name on card is updated`() { - val state = createState(nameOnCard = "Jane Doe") - - val result = creditCardEditorReducer( - state = state, - action = FieldChanged.NameOnCardChanged("Janey Doe"), - ) - - assertEquals( - "Expected name on card to be updated", - "Janey Doe", - result.nameOnCard, - ) - } - - @Test - fun `GIVEN a state with name error, WHEN the name changes, then an error is cleared on the name field`() { - val state = createState(nameOnCard = "", showNameOnCardError = true) - - val result = creditCardEditorReducer( - state = state, - action = FieldChanged.NameOnCardChanged("Janey Doe"), - ) - - assertFalse( - "Expected name on card error to be cleared", - result.showNameOnCardError, - ) - } - - @Test - fun `GIVEN a state with card number error, WHEN the card number changes, then an error is cleared on the name field`() { - val state = createState(cardNumber = "5555", showCardNumberError = true) - - val result = creditCardEditorReducer( - state = state, - action = FieldChanged.CardNumberChanged("55554"), - ) - - assertFalse( - "Expected name on card error to be cleared", - result.showCardNumberError, - ) - } - - @Test - fun `GIVEN a state, WHEN a delete dialog cancel action is received, then the dialog is hidden`() { - val state = createState(showDeleteDialog = true) - - val result = creditCardEditorReducer( - state = state, - action = DeleteDialogAction.Cancel, - ) - - assertFalse( - "Expected delete dialog to be hidden", - result.showDeleteDialog, - ) - } - - @Test - fun `GIVEN a state, WHEN a delete dialog confirm action is received, then the dialog is hidden`() { - val state = createState(showDeleteDialog = true) - - val result = creditCardEditorReducer( - state = state, - action = DeleteDialogAction.Confirm, - ) - - assertFalse( - "Expected delete dialog to be hidden", - result.showDeleteDialog, - ) - } - - @Test - fun `GIVEN a state, WHEN a DeleteClicked action is received, then the dialog is shown`() { - val state = createState(showDeleteDialog = false) - - val result = creditCardEditorReducer( - state = state, - action = CreditCardEditorAction.DeleteClicked, - ) - - assertTrue( - "Expected delete dialog to be shown", - result.showDeleteDialog, - ) - } - - @Test - fun `GIVEN a state with invalid card number, WHEN save action is received, then the error is shown`() { - val state = createState(cardNumber = "3333") - - val result = creditCardEditorReducer( - state = state, - action = CreditCardEditorAction.Save, - ) - - assertTrue( - "Expected card number error to be shown", - result.showCardNumberError, - ) - } - - @Test - fun `GIVEN state with card number error, WHEN card number field is changed, then the error is cleared`() { - val state = createState(showCardNumberError = true) - - val result = creditCardEditorReducer( - state = state, - action = FieldChanged.CardNumberChanged(cardNumber = "5555"), - ) - - assertFalse( - "Expected card number error to no longer be shown", - result.showCardNumberError, - ) - } - - @Test - fun `GIVEN state with empty name on card, WHEN a save action is received, then the error is shown`() { - val state = createState(nameOnCard = "") - - val result = creditCardEditorReducer( - state = state, - action = CreditCardEditorAction.Save, - ) - - assertTrue( - "Expected name on card error to be shown", - result.showNameOnCardError, - ) - } - - @Test - fun `GIVEN a state with name on card error, WHEN the name field changes, then the error is cleared`() { - val state = createState(showNameOnCardError = true) - - val result = creditCardEditorReducer( - state = state, - action = FieldChanged.NameOnCardChanged(nameOnCard = "John"), - ) - - assertFalse( - "Expected name on card error to no longer be shown", - result.showNameOnCardError, - ) - } - - @Test - fun `GIVEN a state, WHEN initialization is completed, then the state is updated accordingly`() { - val oldState = createState() - - val newState = createState( - nameOnCard = "New Name", - cardNumber = "1234556789900", - ) - - val result = creditCardEditorReducer( - state = oldState, - action = CreditCardEditorAction.Initialization.InitCompleted(state = newState), - ) - - assertEquals( - "Expected the state to be what was received in the action", - newState, - result, - ) - } -} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorStateTestHelper.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorStateTestHelper.kt @@ -1,36 +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.creditcards.ui - -/** - * Creates a [CreditCardEditorState] with the given parameters for use in tests. - */ -internal fun createState( - guid: String = "", - cardNumber: String = "5555444433331111", - showCardNumberError: Boolean = false, - nameOnCard: String = "Jane Doe", - showNameOnCardError: Boolean = false, - expiryMonths: List<String> = listOf("January", "February", "March"), - selectedExpiryMonthIndex: Int = 0, - expiryYears: List<String> = listOf("2025", "2026", "2027"), - selectedExpiryYearIndex: Int = 1, - inEditMode: Boolean = false, - showDeleteDialog: Boolean = false, -): CreditCardEditorState { - return CreditCardEditorState( - guid = guid, - cardNumber = cardNumber, - showCardNumberError = showCardNumberError, - nameOnCard = nameOnCard, - showNameOnCardError = showNameOnCardError, - expiryMonths = expiryMonths, - selectedExpiryMonthIndex = selectedExpiryMonthIndex, - expiryYears = expiryYears, - selectedExpiryYearIndex = selectedExpiryYearIndex, - inEditMode = inEditMode, - showDeleteDialog = showDeleteDialog, - ) -} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/ui/CreditCardEditorStoreTest.kt @@ -1,325 +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.creditcards.ui - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.test.runTest -import mozilla.components.concept.storage.CreditCard -import mozilla.components.concept.storage.CreditCardNumber -import mozilla.components.concept.storage.CreditCardsAddressesStorage -import mozilla.components.concept.storage.NewCreditCardFields -import mozilla.components.concept.storage.UpdatableCreditCardFields -import mozilla.components.support.test.rule.MainCoroutineRule -import mozilla.components.support.utils.CreditCardNetworkType -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class CreditCardEditorStoreTest { - - @get:Rule - val coroutinesTestRule = MainCoroutineRule() - private val creditCardsStorage = FakeCreditCardsStorage() - private val calendarDataProvider = FakeCalendarDataProvider( - expectedMonths = listOf("January", "February", "March"), - expectedYears = listOf("2025", "2026", "2027"), - ) - - @Test - fun `WHEN DeleteClicked event is received, delete confirmation dialog is shown`() = runTest { - val store = makeStore() - - store.dispatch(CreditCardEditorAction.DeleteClicked) - - assertTrue( - "Delete confirmation dialog is not shown", - store.state.showDeleteDialog, - ) - } - - @Test - fun `WHEN delete confirmation is cancelled, the dialog is hidden`() = runTest { - val store = makeStore() - - store.dispatch(CreditCardEditorAction.DeleteClicked) - store.dispatch(CreditCardEditorAction.DeleteDialogAction.Cancel) - - assertFalse( - "Expected delete confirmation dialog to be hidden", - store.state.showDeleteDialog, - ) - } - - @Test - fun `WHEN delete confirmation is confirmed, the card is deleted`() = runTest { - val store = makeStore( - state = createState(guid = "card-id"), - ) - - store.dispatch(CreditCardEditorAction.DeleteDialogAction.Confirm) - - assertEquals( - "Expected that the deleted card has guid 'card-id'", - "card-id", - creditCardsStorage.deletedCard, - ) - } - - @Test - fun `GIVEN valid form in 'edit' mode, WHEN save action is received, the card is saved`() { - val validMasterCard = "5555444433331111" - val store = makeStore( - state = createState( - guid = "1234", - inEditMode = true, - nameOnCard = "Jane Doe", - cardNumber = validMasterCard, - expiryYears = listOf("2025", "2026", "2027"), - selectedExpiryYearIndex = 1, - expiryMonths = listOf("January", "February", "March"), - selectedExpiryMonthIndex = 0, - ), - ) - - store.dispatch(CreditCardEditorAction.Save) - - val updatedCardGuid = creditCardsStorage.updatedCard?.first - val updatedCardFields = creditCardsStorage.updatedCard?.second - assertEquals( - "Expected that a card is updated with the guid is 1234", - "1234", - updatedCardGuid, - ) - assertEquals( - "Expected that a card is updated with the right fields", - UpdatableCreditCardFields( - billingName = "Jane Doe", - cardNumber = CreditCardNumber.Plaintext(validMasterCard), - cardNumberLast4 = "1111", - expiryMonth = 1, - expiryYear = 2026L, - cardType = "mastercard", - ), - updatedCardFields, - ) - } - - @Test - fun `GIVEN valid form in 'create' mode, WHEN save action is received, the card is saved`() { - val validMasterCard = "5555444433331111" - val store = makeStore( - state = createState( - nameOnCard = "Jane Doe", - cardNumber = validMasterCard, - expiryYears = listOf("2025", "2026", "2027"), - selectedExpiryYearIndex = 1, - expiryMonths = listOf("January", "February", "March"), - selectedExpiryMonthIndex = 0, - ), - ) - - store.dispatch(CreditCardEditorAction.Save) - - assertEquals( - "Expected that a card is successfully saved with the right values", - NewCreditCardFields( - billingName = "Jane Doe", - plaintextCardNumber = CreditCardNumber.Plaintext(validMasterCard), - cardNumberLast4 = "1111", - expiryMonth = 1, - expiryYear = 2026L, - cardType = "mastercard", - ), - creditCardsStorage.newAddedCard, - ) - } - - @Test - fun `GIVEN invalid card number, WHEN save action is received, an error is shown on the card field`() { - val americanExpressCardInvalid = "371449635398432" - val store = makeStore( - state = createState(cardNumber = americanExpressCardInvalid), - ) - - store.dispatch(CreditCardEditorAction.Save) - - assertTrue( - "Expected that an error is shown on the card number field", - store.state.showCardNumberError, - ) - } - - @Test - fun `GIVEN empty card number, WHEN save action is received, an error is shown on the card field`() { - val store = makeStore( - state = createState(cardNumber = ""), - ) - - store.dispatch(CreditCardEditorAction.Save) - - assertTrue( - "Expected that an error is shown on the card number field", - store.state.showCardNumberError, - ) - } - - @Test - fun `GIVEN an empty name on card, WHEN save action is received, an error is shown on the name field`() { - val store = makeStore( - state = createState(nameOnCard = ""), - ) - - store.dispatch(CreditCardEditorAction.Save) - - assertTrue( - "Expected that an error is shown on the name field", - store.state.showNameOnCardError, - ) - } - - @Test - fun `WHEN cancel action is received, user is navigated back`() { - var navigatedBack = false - val store = makeStore( - environment = CreditCardEditorEnvironment.Default.copy( - navigateBack = { - navigatedBack = true - }, - ), - ) - - store.dispatch(CreditCardEditorAction.Cancel) - - assertTrue( - "Expected that we navigate back", - navigatedBack, - ) - } - - @Test - fun `WHEN 'navigate back' action is received, user is navigated back`() { - var navigatedBack = false - val store = makeStore( - environment = CreditCardEditorEnvironment.Default.copy( - navigateBack = { - navigatedBack = true - }, - ), - ) - - store.dispatch(CreditCardEditorAction.NavigateBack) - - assertTrue( - "Expected that we navigate back", - navigatedBack, - ) - } - - @Test - fun `WHEN Initializing without a credit card, the state is correct`() = runTest { - calendarDataProvider.expectedMonths = listOf("January", "February") - calendarDataProvider.expectedYears = listOf("2025", "2026") - - val initialState = CreditCardEditorState.Default - val store = makeStore(state = initialState) - - store.dispatch(CreditCardEditorAction.Initialization.InitStarted(creditCard = null)) - - assertEquals( - "Expected that the state is loaded with everything empty", - CreditCardEditorState( - guid = "", - cardNumber = "", - showCardNumberError = false, - nameOnCard = "", - showNameOnCardError = false, - expiryMonths = listOf("January", "February"), - selectedExpiryMonthIndex = 0, - expiryYears = listOf("2025", "2026"), - selectedExpiryYearIndex = 0, - inEditMode = false, - showDeleteDialog = false, - ), - store.state, - ) - } - - @Test - fun `WHEN Initializing with a credit card, the state is initialized with the card details`() = - runTest { - calendarDataProvider.expectedMonths = listOf("January", "February", "March") - calendarDataProvider.expectedYears = listOf("2025", "2026") - val expectedEncryptedCardNumber = "encryptedCard" - val expectedPlainCardNumber = "5555444433331111" - - creditCardsStorage.expectedPlainCardNumber = expectedPlainCardNumber - creditCardsStorage.expectedEncryptedCardNumber = expectedEncryptedCardNumber - - val creditCard = CreditCard( - guid = "id", - billingName = "Banana Apple", - encryptedCardNumber = CreditCardNumber.Encrypted(expectedEncryptedCardNumber), - cardNumberLast4 = "1111", - expiryMonth = 2, - expiryYear = 2025, - cardType = CreditCardNetworkType.MASTERCARD.cardName, - timeCreated = 1L, - timeLastUsed = 1L, - timeLastModified = 1L, - timesUsed = 1L, - ) - - val store = makeStore(state = CreditCardEditorState.Default) - - store.dispatch(CreditCardEditorAction.Initialization.InitStarted(creditCard = creditCard)) - - assertEquals( - "Expected that the state is initialized with the card details", - CreditCardEditorState( - guid = "id", - cardNumber = expectedPlainCardNumber, - showCardNumberError = false, - nameOnCard = "Banana Apple", - showNameOnCardError = false, - expiryMonths = listOf("January", "February", "March"), - selectedExpiryMonthIndex = 1, - expiryYears = listOf("2025", "2026"), - selectedExpiryYearIndex = 0, - inEditMode = true, - showDeleteDialog = false, - ), - store.state, - ) - } - - private fun makeStore( - state: CreditCardEditorState = createState(), - monthsProvider: CalendarDataProvider = calendarDataProvider, - storage: CreditCardsAddressesStorage = creditCardsStorage, - scope: CoroutineScope = coroutinesTestRule.scope, - dispatcher: CoroutineDispatcher = coroutinesTestRule.testDispatcher, - environment: CreditCardEditorEnvironment = CreditCardEditorEnvironment.Default, - ): CreditCardEditorStore { - return CreditCardEditorStore( - initialState = state, - middleware = listOf( - CreditCardEditorMiddleware( - environment = environment, - calendarDataProvider = monthsProvider, - storage = storage, - coroutineScope = scope, - ioDispatcher = dispatcher, - mainDispatcher = dispatcher, - ), - ), - ) - } -} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/ui/FakeCalendarDataProvider.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/ui/FakeCalendarDataProvider.kt @@ -1,22 +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.creditcards.ui - -class FakeCalendarDataProvider( - var expectedMonths: List<String> = emptyList(), - var expectedYears: List<String> = emptyList(), -) : CalendarDataProvider { - override fun months(): List<String> { - return expectedMonths - } - - override fun years(): List<String> { - return expectedYears - } - - override fun years(startYear: Long): List<String> { - return expectedYears - } -} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/ui/FakeCreditCardsStorage.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/ui/FakeCreditCardsStorage.kt @@ -1,106 +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.creditcards.ui - -import mozilla.components.concept.storage.Address -import mozilla.components.concept.storage.CreditCard -import mozilla.components.concept.storage.CreditCardCrypto -import mozilla.components.concept.storage.CreditCardNumber -import mozilla.components.concept.storage.CreditCardsAddressesStorage -import mozilla.components.concept.storage.ManagedKey -import mozilla.components.concept.storage.NewCreditCardFields -import mozilla.components.concept.storage.UpdatableAddressFields -import mozilla.components.concept.storage.UpdatableCreditCardFields - -/** - * Fake implementation of [CreditCardsAddressesStorage] that is used for testing credit cards feature - */ -class FakeCreditCardsStorage( - var deletedCard: String? = null, - var newAddedCard: NewCreditCardFields? = null, - var updatedCard: Pair<String, UpdatableCreditCardFields>? = null, -) : CreditCardsAddressesStorage { - - /** - * Plain card number - */ - var expectedPlainCardNumber: String = "" - - /** - * Encrypted card number - */ - var expectedEncryptedCardNumber: String = "encrypted" - - override suspend fun addCreditCard(creditCardFields: NewCreditCardFields): CreditCard { - newAddedCard = creditCardFields - return CreditCard( - guid = "new-card-id", - billingName = creditCardFields.billingName, - encryptedCardNumber = CreditCardNumber.Encrypted(data = expectedEncryptedCardNumber), - cardNumberLast4 = creditCardFields.cardNumberLast4, - expiryMonth = creditCardFields.expiryMonth, - expiryYear = creditCardFields.expiryYear, - cardType = creditCardFields.cardType, - ) - } - - override suspend fun updateCreditCard( - guid: String, - creditCardFields: UpdatableCreditCardFields, - ) { - updatedCard = Pair(guid, creditCardFields) - } - - override suspend fun getCreditCard(guid: String): CreditCard? = null - - override suspend fun getAllCreditCards(): List<CreditCard> = emptyList() - - override suspend fun deleteCreditCard(guid: String): Boolean { - deletedCard = guid - return true - } - - override suspend fun touchCreditCard(guid: String) = Unit - - override fun getCreditCardCrypto(): CreditCardCrypto { - return object : CreditCardCrypto { - override fun encrypt( - key: ManagedKey, - plaintextCardNumber: CreditCardNumber.Plaintext, - ): CreditCardNumber.Encrypted { - return CreditCardNumber.Encrypted(data = expectedEncryptedCardNumber) - } - - override fun decrypt( - key: ManagedKey, - encryptedCardNumber: CreditCardNumber.Encrypted, - ): CreditCardNumber.Plaintext { - return CreditCardNumber.Plaintext(data = expectedPlainCardNumber) - } - - override suspend fun getOrGenerateKey(): ManagedKey { - return ManagedKey(key = "key") - } - } - } - - override suspend fun scrubEncryptedData() { - error("Not yet implemented") - } - - override suspend fun addAddress(addressFields: UpdatableAddressFields): Address { - throw NotImplementedError("Address features are not used in this test") - } - - override suspend fun getAddress(guid: String): Address? = null - - override suspend fun getAllAddresses(): List<Address> = emptyList() - - override suspend fun updateAddress(guid: String, address: UpdatableAddressFields) = Unit - - override suspend fun deleteAddress(guid: String): Boolean = false - - override suspend fun touchAddress(guid: String) = Unit -}