From daaee113c26c2bb9d0a0e707f46e560058ed21fa Mon Sep 17 00:00:00 2001 From: Chengtin Tsai Date: Fri, 9 Oct 2026 16:44:49 -0700 Subject: [PATCH 1/7] Confirm Address Element dismissal only for changed input Committed-By-Agent: codex Co-authored-by: codex Committed-By-Agent: codex Co-authored-by: codex Committed-By-Agent: codex Co-authored-by: codex Committed-By-Agent: codex Co-authored-by: codex Committed-By-Agent: codex Co-authored-by: codex Committed-By-Agent: codex Co-authored-by: codex Committed-By-Agent: codex Co-authored-by: codex --- CHANGELOG.md | 3 + .../addresselement/AddressElementActivity.kt | 11 +- .../addresselement/AddressFormController.kt | 19 ++ .../addresselement/InputAddressViewModel.kt | 8 + .../AddressElementActivityAnalyticsTest.kt | 5 + .../AddressElementActivityTest.kt | 137 +++++++++--- .../AddressElementActivityTestRunner.kt | 27 ++- .../AddressFormControllerTest.kt | 147 +++++++++++++ .../InputAddressViewModelTest.kt | 205 +++++++++++++++++- 9 files changed, 521 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb39522219c..8dab5e70346 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ NEXT_VERSION_BUMP: MINOR * [ADDED][14831](https://github.com/stripe/stripe-android/pull/14831) Added Naira Wallet API bindings and PaymentSheet support for PaymentIntents. * [ADDED][14832](https://github.com/stripe/stripe-android/pull/14832) Added Mondu API bindings and PaymentSheet support for PaymentIntents. +### AddressElement +* [Changed] Address Element asks for confirmation before discarding unsaved address changes. + ### Identity * [ADDED] Added test mode support to Stripe Identity, uploading bundled test document and selfie images for test-mode verification sessions. diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivity.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivity.kt index bd27b73ca73..d04f0f1110d 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivity.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivity.kt @@ -160,8 +160,17 @@ internal class AddressElementActivity : ComponentActivity() { } private fun dismiss() { + if (viewModel.resultStateHolder.state.value != State.Idle) return + + val navController = requireNotNull(viewModel.navigator.navigationController) + val inputEntry = navController.getBackStackEntry(AddressElementScreen.InputAddress.route) + val inputViewModel = ViewModelProvider( + inputEntry, + InputAddressViewModel.Factory(viewModel.inputAddressViewModelSubcomponentFactoryProvider), + )[InputAddressViewModel::class.java] + viewModel.resultStateHolder.onUserCancel( - shouldConfirmCancellation = starterArgs is AddressElementActivityContract.Args.CheckoutShipping, + shouldConfirmCancellation = inputViewModel.hasChanges(), ) } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/AddressFormController.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/AddressFormController.kt index 87264089488..659aa48bdc3 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/AddressFormController.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/AddressFormController.kt @@ -40,6 +40,11 @@ internal class AddressFormController( textFieldControllerIds.lastOrNull() } + // Rendered defaults and formatting are part of the baseline, not user edits. + private val initialFormValues = getCurrentInputValues() + + fun hasChanges(): Boolean = getCurrentInputValues() != initialFormValues + fun getCurrentFormValues() = autocompleteAddressElement.getFormFieldValueFlow() .value .filter { @@ -48,4 +53,18 @@ internal class AddressFormController( .toMap() fun setRawValues(values: Map) = autocompleteAddressElement.setRawValue(values) + + private fun getCurrentInputValues(): Map { + val addressElement = autocompleteAddressElement.sectionFieldErrorController().addressElementFlow.value + val formValues = addressElement.getFormFieldValueFlow().value.toMap() + val values = formValues + .filterKeys { it != FormFieldId.OneLineAddress } + .mapValues { it.value.value.orEmpty() } + .toMutableMap() + // Condensed autocomplete text is not included in submitted form values. + if (FormFieldId.Line1 !in formValues) { + values[FormFieldId.Line1] = addressElement.inlineQuery.value + } + return values.filterValues { it.isNotEmpty() } + } } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/InputAddressViewModel.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/InputAddressViewModel.kt index a27f7a11157..b6ea5e8a3a1 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/InputAddressViewModel.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/InputAddressViewModel.kt @@ -133,6 +133,14 @@ internal class InputAddressViewModel @Inject constructor( private val _checkboxChecked = MutableStateFlow(false) val checkboxChecked: StateFlow = _checkboxChecked + private val initialCheckboxChecked = unparsedInitialShippingAddress?.isCheckboxSelected ?: false + + fun hasChanges(): Boolean { + val checkboxChanged = args.config?.additionalFields?.checkboxLabel != null && + checkboxChecked.value != initialCheckboxChecked + return addressFormController.hasChanges() || checkboxChanged + } + fun onScreenShown() { eventReporter.onShown( country = getCurrentAddress().address?.country, diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityAnalyticsTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityAnalyticsTest.kt index b922079e004..d4ed5c81632 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityAnalyticsTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityAnalyticsTest.kt @@ -78,6 +78,7 @@ internal class AddressElementActivityAnalyticsTest { addressPage.assertErrorDisplayed(applicationContext.getString(R.string.stripe_something_went_wrong)) awaitAnalytics(failedRequest) addressPage.assertReadyToSave() + addressPage.editName(EDITED_NAME) addressPage.clickClose() addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG) @@ -91,6 +92,7 @@ internal class AddressElementActivityAnalyticsTest { @Test fun `root back reports cancellation after discard`() = runScenario { addressPage.assertReadyToSave() + addressPage.editName(EDITED_NAME) activityScenario.onActivity { activity -> activity.onBackPressedDispatcher.onBackPressed() @@ -107,6 +109,7 @@ internal class AddressElementActivityAnalyticsTest { @Test fun `close and keep editing do not report until discard uses current data`() = runScenario { addressPage.assertReadyToSave() + addressPage.editName(EDITED_NAME) addressPage.clickClose() addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG) @@ -129,6 +132,7 @@ internal class AddressElementActivityAnalyticsTest { @Test fun `scrim accessibility action reports cancellation after discard`() = runScenario { addressPage.assertReadyToSave() + addressPage.editName(EDITED_NAME) addressPage.dismissViaScrimAccessibilityAction() addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG) @@ -267,6 +271,7 @@ internal class AddressElementActivityAnalyticsTest { } private companion object { + const val EDITED_NAME = "Jenny Rosen Updated" val SHIPPING_ADDRESS = AddressDetails( name = "Jenny Rosen", address = PaymentSheet.Address( diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityTest.kt index fdab184a9a3..2d44f83a386 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityTest.kt @@ -265,7 +265,7 @@ internal class AddressElementActivityTest { } @Test - fun `standalone shipping can be canceled with close before saving`() = runScenario( + fun `standalone unchanged input can be canceled with close before saving`() = runScenario( args = AddressElementActivityContract.Args.Standalone( apiConfiguration = DEFAULT_API_CONFIG, config = AddressLauncher.Configuration.Builder() @@ -276,6 +276,7 @@ internal class AddressElementActivityTest { addressPage.assertReadyToSave() addressPage.clickClose() + addressPage.assertDiscardConfirmationNotDisplayed() assertThat(awaitStandaloneResult()).isEqualTo(AddressLauncherResult.Canceled()) } @@ -330,7 +331,7 @@ internal class AddressElementActivityTest { } @Test - fun `checkout shipping root back requests confirmation without popping input`() = runScenario { + fun `checkout shipping can be canceled with root back before saving without popping input`() = runScenario { addressPage.assertReadyToSave() activityScenario.onActivity { activity -> @@ -344,16 +345,34 @@ internal class AddressElementActivityTest { .isSameInstanceAs(inputEntry) } - addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG) - assertThat(activityScenario.state).isEqualTo(Lifecycle.State.RESUMED) - addressPage.discardChanges() + assertThat(awaitResult()).isEqualTo(AddressElementActivityContract.Result.Canceled) + } + + @Test + fun `checkout shipping can be canceled with scrim accessibility action before saving`() = runScenario { + addressPage.assertReadyToSave() + addressPage.dismissViaScrimAccessibilityAction() assertThat(awaitResult()).isEqualTo(AddressElementActivityContract.Result.Canceled) } @Test - fun `checkout shipping scrim requests confirmation before canceling`() = runScenario { - assertCheckoutDismissalRequestsConfirmation { + fun `checkout shipping close confirms edited input and cancel preserves the form`() = runScenario { + assertDirtyDismissal(editedName = EDITED_NAME) { addressPage.clickClose() } + } + + @Test + fun `checkout shipping root back confirms edited input and cancel preserves the form`() = runScenario { + assertDirtyDismissal(editedName = EDITED_NAME) { + activityScenario.onActivity { activity -> + activity.onBackPressedDispatcher.onBackPressed() + } + } + } + + @Test + fun `checkout shipping scrim confirms edited input and cancel preserves the form`() = runScenario { + assertDirtyDismissal(editedName = EDITED_NAME) { addressPage.dismissViaScrimAccessibilityAction() } } @@ -361,6 +380,7 @@ internal class AddressElementActivityTest { @Test fun `finished cancellation allows the sheet to hide without requesting confirmation again`() = runScenario { addressPage.assertReadyToSave() + addressPage.editName(EDITED_NAME) activityScenario.onActivity { activity -> val stateHolder = ViewModelProvider(activity)[AddressElementViewModel::class.java].resultStateHolder @@ -482,30 +502,32 @@ internal class AddressElementActivityTest { } @Test - fun `checkout shipping close requests confirmation before canceling`() = runScenario { - assertCheckoutDismissalRequestsConfirmation { - addressPage.clickClose() - } + fun `checkout shipping can be canceled with close before saving`() = runScenario { + addressPage.assertReadyToSave() + addressPage.clickClose() + + assertThat(awaitResult()).isEqualTo(AddressElementActivityContract.Result.Canceled) } @Test - fun `checkout shipping canceling discard confirmation preserves edits and enables the form`() = runScenario { - addressPage.assertReadyToSave() - addressPage.editName(EDITED_NAME) - + fun `checkout shipping unchanged name-only input dismisses immediately`() = runScenario( + args = AddressElementActivityContract.Args.CheckoutShipping( + apiConfiguration = DEFAULT_API_CONFIG, + config = AddressLauncher.Configuration.Builder().address(AddressDetails(name = "Jenny Rosen")).build(), + checkoutSessionResponse = CheckoutSessionResponseFactory.create(), + ), + ) { + addressPage.assertName("Jenny Rosen") addressPage.clickClose() - addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG) - addressPage.keepEditing() addressPage.assertDiscardConfirmationNotDisplayed() - addressPage.assertName(EDITED_NAME) - addressPage.assertReadyToSave() - assertThat(activityScenario.state).isEqualTo(Lifecycle.State.RESUMED) + assertThat(awaitResult()).isEqualTo(AddressElementActivityContract.Result.Canceled) } @Test fun `checkout shipping discard dialog survives recreation`() = runScenario { addressPage.assertReadyToSave() + addressPage.editName(EDITED_NAME) addressPage.clickClose() addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG) @@ -517,30 +539,91 @@ internal class AddressElementActivityTest { } @Test - fun `standalone close immediately cancels edited input`() = runScenario( + fun `standalone close confirms edited input and cancel preserves the form`() = runScenario( args = standaloneArgs(SHIPPING_ADDRESS), ) { - assertStandaloneDismissesImmediately(editedName = EDITED_NAME) { addressPage.clickClose() } + assertDirtyDismissal(editedName = EDITED_NAME) { addressPage.clickClose() } } @Test - fun `standalone root back immediately cancels edited input`() = runScenario( + fun `standalone root back confirms edited input and cancel preserves the form`() = runScenario( args = standaloneArgs(SHIPPING_ADDRESS), ) { - assertStandaloneDismissesImmediately(editedName = EDITED_NAME) { - activityScenario.onActivity { it.onBackPressedDispatcher.onBackPressed() } + assertDirtyDismissal(editedName = EDITED_NAME) { + activityScenario.onActivity { activity -> + activity.onBackPressedDispatcher.onBackPressed() + } } } @Test - fun `standalone scrim immediately cancels edited input`() = runScenario( + fun `standalone scrim confirms edited input and cancel preserves the form`() = runScenario( args = standaloneArgs(SHIPPING_ADDRESS), ) { - assertStandaloneDismissesImmediately(editedName = EDITED_NAME) { + assertDirtyDismissal(editedName = EDITED_NAME) { addressPage.dismissViaScrimAccessibilityAction() } } + @Test + fun `standalone reverted edits cancel immediately`() = runScenario( + args = standaloneArgs(SHIPPING_ADDRESS), + ) { + addressPage.assertReadyToSave() + addressPage.editName(EDITED_NAME) + addressPage.editName(requireNotNull(SHIPPING_ADDRESS.name)) + addressPage.assertName(requireNotNull(SHIPPING_ADDRESS.name)) + + addressPage.clickClose() + + addressPage.assertDiscardConfirmationNotDisplayed() + assertThat(awaitStandaloneResult()).isEqualTo(AddressLauncherResult.Canceled()) + } + + @Test + fun `standalone edited input still requires confirmation after recreation`() = runScenario( + args = standaloneArgs(SHIPPING_ADDRESS), + ) { + addressPage.assertReadyToSave() + addressPage.editName(EDITED_NAME) + activityScenario.recreate() + activityScenario.onActivity { activity = it } + composeTestRule.waitForIdle() + addressPage.assertName(EDITED_NAME) + + addressPage.clickClose() + addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG) + addressPage.discardChanges() + + assertThat(awaitStandaloneResult()).isEqualTo(AddressLauncherResult.Canceled()) + } + + @Test + fun `standalone discard confirmation survives recreation and keep editing preserves input`() = runScenario( + args = standaloneArgs(SHIPPING_ADDRESS), + ) { + addressPage.assertReadyToSave() + addressPage.editName(EDITED_NAME) + addressPage.clickClose() + addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG) + + activityScenario.recreate() + activityScenario.onActivity { activity = it } + composeTestRule.waitForIdle() + + addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG) + addressPage.keepEditing() + addressPage.assertDiscardConfirmationNotDisplayed() + addressPage.assertName(EDITED_NAME) + addressPage.assertReadyToSave() + + addressPage.clickClose() + addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG) + addressPage.discardChanges() + + assertThat(awaitStandaloneResult()).isEqualTo(AddressLauncherResult.Canceled()) + } + private fun standaloneArgs(address: AddressDetails) = AddressElementActivityContract.Args.Standalone( apiConfiguration = DEFAULT_API_CONFIG, config = AddressLauncher.Configuration.Builder().address(address).build(), diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityTestRunner.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityTestRunner.kt index b185ec29781..0fb081468c6 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityTestRunner.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityTestRunner.kt @@ -114,29 +114,32 @@ internal class AddressElementActivityTestRunner( awaitAnalytics(startedRequest) } - fun assertCheckoutDismissalRequestsConfirmation( + fun assertDirtyDismissal( + editedName: String, requestDismissal: () -> Unit, ) { addressPage.assertReadyToSave() + addressPage.editName(editedName) requestDismissal() addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG) assertThat(activityScenario.state).isEqualTo(Lifecycle.State.RESUMED) - addressPage.discardChanges() - - assertThat(awaitResult()).isEqualTo(AddressElementActivityContract.Result.Canceled) - } - fun assertStandaloneDismissesImmediately( - editedName: String, - requestDismissal: () -> Unit, - ) { + addressPage.keepEditing() + addressPage.assertDiscardConfirmationNotDisplayed() + addressPage.assertName(editedName) addressPage.assertReadyToSave() - addressPage.editName(editedName) + requestDismissal() + addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG) + addressPage.discardChanges() - addressPage.assertDiscardConfirmationNotDisplayed() - assertThat(awaitStandaloneResult()).isEqualTo(AddressLauncherResult.Canceled()) + when (args) { + is AddressElementActivityContract.Args.Standalone -> + assertThat(awaitStandaloneResult()).isEqualTo(AddressLauncherResult.Canceled()) + is AddressElementActivityContract.Args.CheckoutShipping -> + assertThat(awaitResult()).isEqualTo(AddressElementActivityContract.Result.Canceled) + } } fun assertDismissalBlocked() { diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressFormControllerTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressFormControllerTest.kt index dda11f64bfd..a4d3bcf478b 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressFormControllerTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressFormControllerTest.kt @@ -7,6 +7,8 @@ import com.google.common.truth.Truth.assertThat import com.stripe.android.isInstanceOf import com.stripe.android.paymentsheet.PaymentSheet import com.stripe.android.uicore.elements.AddressElement +import com.stripe.android.uicore.elements.AddressInputMode +import com.stripe.android.uicore.elements.AddressTextFieldElement import com.stripe.android.uicore.elements.AutocompleteAddressElement import com.stripe.android.uicore.elements.AutocompleteAddressInteractor import com.stripe.android.uicore.elements.CountryConfig @@ -23,6 +25,18 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class AddressFormControllerTest { + private val usOnlyLauncherConfig = AddressLauncher.Configuration( + allowedCountries = setOf("US"), + ) + + private val inlineAutocompleteConfig = AutocompleteAddressInteractor.Config( + autocompleteCountries = setOf("US"), + googlePlacesApiKey = null, + isPlacesAvailable = true, + isInlineAutocompleteEnabled = true, + shouldUseStripeHostedAutocomplete = true, + ) + @Test fun `Ensure expected set of elements`() = fieldsTest( autocompleteConfig = AutocompleteAddressInteractor.Config( @@ -280,6 +294,129 @@ class AddressFormControllerTest { } } + @Test + fun `empty form starts clean`() = test { + val controller = createAddressFormController(launcherConfig = usOnlyLauncherConfig) + registerCalls.awaitItem() + + assertThat(controller.initialValues.containsKey(FormFieldId.Country)).isFalse() + assertThat(controller.getCurrentFormValues()[FormFieldId.Country]?.value).isEqualTo("US") + assertThat(controller.hasChanges()).isFalse() + } + + @Test + fun `prefilled form starts clean`() = test { + val controller = createAddressFormController( + initialValues = mapOf( + FormFieldId.Name to "Jane Doe", + FormFieldId.Line1 to "123 Apple Street", + FormFieldId.City to "San Francisco", + FormFieldId.State to "CA", + FormFieldId.Country to "US", + FormFieldId.PostalCode to "94107 ", + ), + launcherConfig = usOnlyLauncherConfig, + ) + registerCalls.awaitItem() + + assertThat(controller.getCurrentFormValues()[FormFieldId.PostalCode]?.value) + .isEqualTo("94107") + assertThat(controller.hasChanges()).isFalse() + } + + @Test + fun `partially prefilled form starts clean`() = test { + val initialValues = mapOf(FormFieldId.Name to "Jane") + val controller = createAddressFormController( + initialValues = initialValues, + launcherConfig = usOnlyLauncherConfig, + ) + registerCalls.awaitItem() + + assertThat(initialValues.containsKey(FormFieldId.Country)).isFalse() + assertThat(controller.getCurrentFormValues()[FormFieldId.Country]?.value).isEqualTo("US") + assertThat(controller.completeFormValues.value).isNull() + assertThat(controller.hasChanges()).isFalse() + } + + @Test + fun `inline query edit makes form dirty and clearing it restores clean state`() = + test(autocompleteConfig = inlineAutocompleteConfig) { + val controller = createAddressFormController() + registerCalls.awaitItem() + val addressElement = controller.currentAddressElement() + val inlineField = addressElement.addressTextField(FormFieldId.OneLineAddress) + + assertThat(addressElement.addressInputMode) + .isInstanceOf(AddressInputMode.AutocompleteInline::class.java) + assertThat(controller.hasChanges()).isFalse() + + inlineField.controller.onInlineQueryChanged("1") + + assertThat(addressElement.inlineQuery.value).isEqualTo("1") + assertThat(controller.hasChanges()).isTrue() + assertThat(controller.completeFormValues.value).isNull() + assertThat(controller.uncompletedFormValues.value[FormFieldId.OneLineAddress]?.value) + .isEmpty() + + inlineField.controller.onInlineQueryChanged("") + + assertThat(controller.hasChanges()).isFalse() + } + + @Test + fun `expanding an unchanged inline form keeps it clean`() = test( + autocompleteConfig = inlineAutocompleteConfig, + ) { + val controller = createAddressFormController( + initialValues = mapOf(FormFieldId.Country to "US"), + ) + val registerCall = registerCalls.awaitItem() + val originalAddressElement = controller.currentAddressElement() + + assertThat(originalAddressElement.addressInputMode) + .isInstanceOf(AddressInputMode.AutocompleteInline::class.java) + + registerCall.onEvent( + AutocompleteAddressInteractor.Event.OnExpandForm( + values = mapOf(FormFieldId.Country to "US"), + ) + ) + + val expandedAddressElement = controller.currentAddressElement() + assertThat(expandedAddressElement).isNotSameInstanceAs(originalAddressElement) + assertThat(expandedAddressElement.addressInputMode) + .isInstanceOf(AddressInputMode.NoAutocomplete::class.java) + assertThat(controller.hasChanges()).isFalse() + } + + @Test + fun `incomplete address edit makes form dirty and reverting clears it`() = + test(autocompleteConfig = inlineAutocompleteConfig) { + val controller = createAddressFormController( + initialValues = mapOf(FormFieldId.Country to "US"), + ) + val registerCall = registerCalls.awaitItem() + + registerCall.onEvent( + AutocompleteAddressInteractor.Event.OnExpandForm( + values = mapOf(FormFieldId.Country to "US"), + ) + ) + + val line1Field = controller.currentAddressElement() + .addressTextField(FormFieldId.Line1) + + line1Field.controller.onInlineQueryChanged("123 Main") + + assertThat(controller.completeFormValues.value).isNull() + assertThat(controller.hasChanges()).isTrue() + + line1Field.controller.onInlineQueryChanged("") + + assertThat(controller.hasChanges()).isFalse() + } + private fun phoneNumberTest( phoneNumberConfiguration: AddressLauncher.AdditionalFieldsConfiguration.FieldConfiguration, phoneNumberElementIsVisible: Boolean, @@ -379,4 +516,14 @@ class AddressFormControllerTest { config = launcherConfig, initialValues = initialValues, ) + + private fun AddressFormController.currentAddressElement(): AddressElement { + val autocompleteAddressElement = + (elements.single() as SectionElement).fields.single() as AutocompleteAddressElement + return autocompleteAddressElement.sectionFieldErrorController().addressElementFlow.value + } + + private fun AddressElement.addressTextField(identifier: FormFieldId): AddressTextFieldElement = + addressController.value.fieldsFlowable.value + .single { it.identifier == identifier } as AddressTextFieldElement } diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/InputAddressViewModelTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/InputAddressViewModelTest.kt index 0325e4e7538..e5f881eeb59 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/InputAddressViewModelTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/InputAddressViewModelTest.kt @@ -19,14 +19,20 @@ import com.stripe.android.paymentsheet.utils.ViewModelStoreTestRule import com.stripe.android.testing.CoroutineTestRule import com.stripe.android.ui.core.elements.autocomplete.PlacesClientProxy import com.stripe.android.ui.core.elements.autocomplete.model.FindAutocompletePredictionsResponse +import com.stripe.android.uicore.elements.AddressTextFieldElement +import com.stripe.android.uicore.elements.AutocompleteAddressController import com.stripe.android.uicore.elements.AutocompleteAddressElement import com.stripe.android.uicore.elements.AutocompleteAddressInteractor import com.stripe.android.uicore.elements.FormFieldId import com.stripe.android.uicore.elements.SectionElement import com.stripe.android.uicore.forms.FormFieldEntry import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -39,6 +45,7 @@ import org.robolectric.RobolectricTestRunner class InputAddressViewModelTest { private val navigator = mock() private val resultStateHolder = AddressElementResultStateHolder() + private val testDispatcher = UnconfinedTestDispatcher() private fun createViewModel( address: AddressDetails? = null, @@ -72,7 +79,7 @@ class InputAddressViewModelTest { val viewModelStoreRule = ViewModelStoreTestRule() @get:Rule - val coroutineTestRule = CoroutineTestRule() + val coroutineTestRule = CoroutineTestRule(testDispatcher) @Test fun `onScreenShown fires onShow with the form country from the initial address`() = runScenario( @@ -475,6 +482,9 @@ class InputAddressViewModelTest { @Test fun `clickPrimaryButton accepts a second click when first submission fails`() = runPrimaryButtonScenario { primaryButtonAction, expectedAddressCall -> + viewModel.onEnterManuallyFromInline() + viewModel.setRawValues(mapOf(FormFieldId.Line1 to "123 Main")) + assertThat(viewModel.hasChanges()).isTrue() val firstError = IllegalStateException("first submission failed") primaryButtonAction.result = Result.failure(firstError) assertThat(viewModel.saveError.value).isNull() @@ -490,6 +500,7 @@ class InputAddressViewModelTest { assertThat(viewModel.saveError.value).isEqualTo(firstError.stripeErrorMessage()) eventReporter.saveCompletedCalls.expectNoEvents() assertThat(resultStateHolder.state.value).isEqualTo(State.Idle) + assertThat(viewModel.hasChanges()).isTrue() primaryButtonAction.result = Result.success( AddressElementActivityContract.Result.StandaloneSucceeded(EXPECTED_ADDRESS) @@ -614,6 +625,189 @@ class InputAddressViewModelTest { assertThat(viewModel.checkboxChecked.value).isFalse() } + @Test + fun `inline query edit updates hasChanges before coroutine collection`() = runTest { + val eventReporter = FakeAddressElementEventReporter() + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val viewModel = createViewModel(eventReporter = eventReporter) + assertThat(viewModel.hasChanges()).isFalse() + + viewModel.inlineAddressField().controller.onInlineQueryChanged("1") + + assertThat(viewModel.hasChanges()).isTrue() + } finally { + Dispatchers.setMain(testDispatcher) + } + eventReporter.validate() + } + + @Test + fun `raw field edit updates hasChanges before coroutine collection`() = runTest { + val eventReporter = FakeAddressElementEventReporter() + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val viewModel = createViewModel( + address = EXPECTED_ADDRESS, + eventReporter = eventReporter, + ) + assertThat(viewModel.hasChanges()).isFalse() + + viewModel.setRawValues(mapOf(FormFieldId.Name to "Jane Doe")) + + assertThat(viewModel.hasChanges()).isTrue() + } finally { + Dispatchers.setMain(testDispatcher) + } + eventReporter.validate() + } + + @Test + fun `validation without input edits keeps the form clean`() = runScenario { + val controller = viewModel.addressController() + assertThat(controller.validationMessage.value).isNull() + + viewModel.clickPrimaryButton(completedFormValues = null, checkboxChecked = false) + + assertThat(controller.validationMessage.value).isNotNull() + assertThat(viewModel.hasChanges()).isFalse() + assertThat(viewModel.formEnabled.value).isTrue() + assertThat(resultStateHolder.state.value).isEqualTo(State.Idle) + } + + @Test + fun `changing an invisible checkbox keeps the form clean`() = runScenario { + viewModel.clickCheckbox(true) + + assertThat(viewModel.checkboxChecked.value).isTrue() + assertThat(viewModel.hasChanges()).isFalse() + } + + @Test + fun `selecting an inline autocomplete prediction makes the form dirty`() = runScenario { + assertThat(viewModel.hasChanges()).isFalse() + + selectAutocompletePrediction(SELECTED_AUTOCOMPLETE_ADDRESS) + + assertThat(viewModel.addressFormController.uncompletedFormValues.value[FormFieldId.Line1]?.value) + .isEqualTo(SELECTED_AUTOCOMPLETE_ADDRESS.line1) + assertThat(viewModel.hasChanges()).isTrue() + } + + @OptIn(AddressElementSameAsBillingPreview::class) + @Test + fun `same as billing form starts clean`() = runScenario( + config = AddressLauncher.Configuration.Builder() + .allowedCountries(setOf("US")) + .billingAddress( + PaymentSheet.BillingDetails( + name = "John Doe", + address = PaymentSheet.Address( + city = "San Francisco", + country = "US", + line1 = "123 Apple Street", + postalCode = "94107", + state = "CA", + ), + ) + ) + .build() + ) { + assertThat(viewModel.hasChanges()).isFalse() + } + + @OptIn(AddressElementSameAsBillingPreview::class) + @Test + fun `same as billing edit makes form dirty and reverting clears it`() = + runScenario( + config = AddressLauncher.Configuration.Builder() + .allowedCountries(setOf("US")) + .billingAddress( + PaymentSheet.BillingDetails( + name = "John Doe", + address = PaymentSheet.Address( + city = "San Francisco", + country = "US", + line1 = "123 Apple Street", + postalCode = "94107", + state = "CA", + ), + ) + ) + .build() + ) { + viewModel.clickBillingSameAsShipping(newValue = false) + testDispatcher.scheduler.advanceUntilIdle() + assertThat( + viewModel.addressFormController.uncompletedFormValues.value[FormFieldId.Line1]?.value + ).isNotEqualTo("123 Apple Street") + assertThat(viewModel.hasChanges()).isTrue() + + viewModel.clickBillingSameAsShipping(newValue = true) + testDispatcher.scheduler.advanceUntilIdle() + assertThat(placesClient.findPredictionsCalls.awaitItem().query).isEqualTo("123 Apple Street") + assertThat(viewModel.hasChanges()).isFalse() + } + + @Test + fun `autocomplete edit makes form dirty`() { + val autocompleteEvents = MutableStateFlow(null) + + runScenario( + config = AddressLauncher.Configuration.Builder() + .allowedCountries(setOf("US")) + .build(), + beforeViewModelCreation = { + whenever( + navigator.getResultFlow( + AddressElementNavigator.AutocompleteEvent.KEY + ) + ).thenReturn(autocompleteEvents) + }, + ) { + autocompleteEvents.value = AddressElementNavigator.AutocompleteEvent.OnBack( + PaymentSheet.Address( + city = "San Francisco", + country = "US", + line1 = "123 Apple Street", + postalCode = "94107", + state = "CA", + ) + ) + testDispatcher.scheduler.advanceUntilIdle() + assertThat(placesClient.findPredictionsCalls.awaitItem().query).isEqualTo("123 Apple Street") + + assertThat( + viewModel.addressFormController.uncompletedFormValues.value[FormFieldId.Line1]?.value + ).isEqualTo("123 Apple Street") + assertThat(viewModel.hasChanges()).isTrue() + + autocompleteEvents.value = AddressElementNavigator.AutocompleteEvent.OnBack( + PaymentSheet.Address(country = "US") + ) + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(viewModel.hasChanges()).isFalse() + } + } + + @Test + fun `additional checkbox edit makes form dirty and reverting clears it`() = runScenario( + config = AddressLauncher.Configuration.Builder() + .additionalFields( + AddressLauncher.AdditionalFieldsConfiguration( + checkboxLabel = "Use this address", + ) + ) + .build() + ) { + viewModel.clickCheckbox(true) + assertThat(viewModel.hasChanges()).isTrue() + + viewModel.clickCheckbox(false) + assertThat(viewModel.hasChanges()).isFalse() + } + @Test fun `clicking the checkbox should change the internal state`() = runScenario(withPlacesClient = false) { assertThat(viewModel.checkboxChecked.value).isFalse() @@ -1314,6 +1508,15 @@ class InputAddressViewModelTest { } } + private fun InputAddressViewModel.addressController(): AutocompleteAddressController { + val section = addressFormController.elements.single() as SectionElement + return (section.fields.single() as AutocompleteAddressElement).sectionFieldErrorController() + } + + private fun InputAddressViewModel.inlineAddressField(): AddressTextFieldElement = + addressController().addressController.value.fieldsFlowable.value + .single { it.identifier == FormFieldId.OneLineAddress } as AddressTextFieldElement + private fun InputAddressViewModel.setRawValues( values: Map ) { From a5722ebdaaa7c444e9b7c68a5941bced32930379 Mon Sep 17 00:00:00 2001 From: Chengtin Tsai Date: Tue, 20 Oct 2026 11:05:10 -0700 Subject: [PATCH 2/7] Simplify Address Element dismissal wiring Committed-By-Agent: codex Co-authored-by: codex --- .../addresselement/AddressElementActivity.kt | 20 +++++++++---------- .../addresselement/AddressFormController.kt | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivity.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivity.kt index d04f0f1110d..698c23abb9d 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivity.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivity.kt @@ -47,6 +47,15 @@ internal class AddressElementActivity : ComponentActivity() { private val viewModel: AddressElementViewModel by viewModels { viewModelFactory } + private val inputAddressViewModel: InputAddressViewModel by lazy { + val navController = requireNotNull(viewModel.navigator.navigationController) + val inputEntry = navController.getBackStackEntry(AddressElementScreen.InputAddress.route) + ViewModelProvider( + inputEntry, + InputAddressViewModel.Factory(viewModel.inputAddressViewModelSubcomponentFactoryProvider), + )[InputAddressViewModel::class.java] + } + private val starterArgs by lazy { AddressElementActivityContract.Args.fromIntent(intent) } @@ -160,17 +169,8 @@ internal class AddressElementActivity : ComponentActivity() { } private fun dismiss() { - if (viewModel.resultStateHolder.state.value != State.Idle) return - - val navController = requireNotNull(viewModel.navigator.navigationController) - val inputEntry = navController.getBackStackEntry(AddressElementScreen.InputAddress.route) - val inputViewModel = ViewModelProvider( - inputEntry, - InputAddressViewModel.Factory(viewModel.inputAddressViewModelSubcomponentFactoryProvider), - )[InputAddressViewModel::class.java] - viewModel.resultStateHolder.onUserCancel( - shouldConfirmCancellation = inputViewModel.hasChanges(), + shouldConfirmCancellation = inputAddressViewModel.hasChanges(), ) } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/AddressFormController.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/AddressFormController.kt index 659aa48bdc3..4b56979c545 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/AddressFormController.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/AddressFormController.kt @@ -56,7 +56,7 @@ internal class AddressFormController( private fun getCurrentInputValues(): Map { val addressElement = autocompleteAddressElement.sectionFieldErrorController().addressElementFlow.value - val formValues = addressElement.getFormFieldValueFlow().value.toMap() + val formValues = uncompletedFormValues.value val values = formValues .filterKeys { it != FormFieldId.OneLineAddress } .mapValues { it.value.value.orEmpty() } From eabc4fbd4a3141cf9c3cb705e5606efb4e2cd6da Mon Sep 17 00:00:00 2001 From: Chengtin Tsai Date: Tue, 20 Oct 2026 11:18:07 -0700 Subject: [PATCH 3/7] Consolidate Address Element dismissal tests Committed-By-Agent: codex Co-authored-by: codex --- .../AddressElementActivityAnalyticsTest.kt | 7 +- .../AddressElementActivityTest.kt | 85 +++---------------- .../AddressElementActivityTestRunner.kt | 7 +- 3 files changed, 13 insertions(+), 86 deletions(-) diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityAnalyticsTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityAnalyticsTest.kt index d4ed5c81632..ba5389c0244 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityAnalyticsTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityAnalyticsTest.kt @@ -66,7 +66,7 @@ internal class AddressElementActivityAnalyticsTest { } @Test - fun `failed save reports failure and close reports cancellation`() = runTaxScenario(fails = true) { taxUpdate -> + fun `failed save and unchanged close report failure and cancellation`() = runTaxScenario(fails = true) { taxUpdate -> val failedRequest = expectShippingAnalytics( "elements.shipping_address.save_failed", analyticsPayloadField("analytics_value", "apiError"), @@ -78,12 +78,9 @@ internal class AddressElementActivityAnalyticsTest { addressPage.assertErrorDisplayed(applicationContext.getString(R.string.stripe_something_went_wrong)) awaitAnalytics(failedRequest) addressPage.assertReadyToSave() - addressPage.editName(EDITED_NAME) - addressPage.clickClose() - addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG) val canceledRequest = expectShippingAnalytics("elements.shipping_address.canceled") - addressPage.discardChanges() + addressPage.clickClose() assertThat(awaitResult()).isEqualTo(AddressElementActivityContract.Result.Canceled) awaitAnalytics(canceledRequest) diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityTest.kt index 2d44f83a386..fc7b831a2b6 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityTest.kt @@ -265,7 +265,7 @@ internal class AddressElementActivityTest { } @Test - fun `standalone unchanged input can be canceled with close before saving`() = runScenario( + fun `unchanged input can be canceled with close before saving`() = runScenario( args = AddressElementActivityContract.Args.Standalone( apiConfiguration = DEFAULT_API_CONFIG, config = AddressLauncher.Configuration.Builder() @@ -331,7 +331,7 @@ internal class AddressElementActivityTest { } @Test - fun `checkout shipping can be canceled with root back before saving without popping input`() = runScenario { + fun `unchanged input can be canceled with root back without popping input`() = runScenario { addressPage.assertReadyToSave() activityScenario.onActivity { activity -> @@ -349,7 +349,7 @@ internal class AddressElementActivityTest { } @Test - fun `checkout shipping can be canceled with scrim accessibility action before saving`() = runScenario { + fun `unchanged input can be canceled with scrim accessibility action`() = runScenario { addressPage.assertReadyToSave() addressPage.dismissViaScrimAccessibilityAction() @@ -357,12 +357,12 @@ internal class AddressElementActivityTest { } @Test - fun `checkout shipping close confirms edited input and cancel preserves the form`() = runScenario { + fun `close confirms edited input and cancel preserves the form`() = runScenario { assertDirtyDismissal(editedName = EDITED_NAME) { addressPage.clickClose() } } @Test - fun `checkout shipping root back confirms edited input and cancel preserves the form`() = runScenario { + fun `root back confirms edited input and cancel preserves the form`() = runScenario { assertDirtyDismissal(editedName = EDITED_NAME) { activityScenario.onActivity { activity -> activity.onBackPressedDispatcher.onBackPressed() @@ -371,7 +371,7 @@ internal class AddressElementActivityTest { } @Test - fun `checkout shipping scrim confirms edited input and cancel preserves the form`() = runScenario { + fun `scrim confirms edited input and cancel preserves the form`() = runScenario { assertDirtyDismissal(editedName = EDITED_NAME) { addressPage.dismissViaScrimAccessibilityAction() } @@ -486,31 +486,7 @@ internal class AddressElementActivityTest { } @Test - fun `checkout shipping repeated root back stays on confirmation after editing the country`() = runScenario { - addressPage.assertReadyToSave() - addressPage.selectCountry("Canada") - - activityScenario.onActivity { activity -> - activity.onBackPressedDispatcher.onBackPressed() - activity.onBackPressedDispatcher.onBackPressed() - } - - addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG) - addressPage.discardChanges() - - assertThat(awaitResult()).isEqualTo(AddressElementActivityContract.Result.Canceled) - } - - @Test - fun `checkout shipping can be canceled with close before saving`() = runScenario { - addressPage.assertReadyToSave() - addressPage.clickClose() - - assertThat(awaitResult()).isEqualTo(AddressElementActivityContract.Result.Canceled) - } - - @Test - fun `checkout shipping unchanged name-only input dismisses immediately`() = runScenario( + fun `unchanged name-only input dismisses immediately`() = runScenario( args = AddressElementActivityContract.Args.CheckoutShipping( apiConfiguration = DEFAULT_API_CONFIG, config = AddressLauncher.Configuration.Builder().address(AddressDetails(name = "Jenny Rosen")).build(), @@ -525,48 +501,7 @@ internal class AddressElementActivityTest { } @Test - fun `checkout shipping discard dialog survives recreation`() = runScenario { - addressPage.assertReadyToSave() - addressPage.editName(EDITED_NAME) - addressPage.clickClose() - addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG) - - activityScenario.recreate() - activityScenario.onActivity { activity = it } - composeTestRule.waitForIdle() - - addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG) - } - - @Test - fun `standalone close confirms edited input and cancel preserves the form`() = runScenario( - args = standaloneArgs(SHIPPING_ADDRESS), - ) { - assertDirtyDismissal(editedName = EDITED_NAME) { addressPage.clickClose() } - } - - @Test - fun `standalone root back confirms edited input and cancel preserves the form`() = runScenario( - args = standaloneArgs(SHIPPING_ADDRESS), - ) { - assertDirtyDismissal(editedName = EDITED_NAME) { - activityScenario.onActivity { activity -> - activity.onBackPressedDispatcher.onBackPressed() - } - } - } - - @Test - fun `standalone scrim confirms edited input and cancel preserves the form`() = runScenario( - args = standaloneArgs(SHIPPING_ADDRESS), - ) { - assertDirtyDismissal(editedName = EDITED_NAME) { - addressPage.dismissViaScrimAccessibilityAction() - } - } - - @Test - fun `standalone reverted edits cancel immediately`() = runScenario( + fun `reverted edits cancel immediately`() = runScenario( args = standaloneArgs(SHIPPING_ADDRESS), ) { addressPage.assertReadyToSave() @@ -581,7 +516,7 @@ internal class AddressElementActivityTest { } @Test - fun `standalone edited input still requires confirmation after recreation`() = runScenario( + fun `edited input still requires confirmation after recreation`() = runScenario( args = standaloneArgs(SHIPPING_ADDRESS), ) { addressPage.assertReadyToSave() @@ -599,7 +534,7 @@ internal class AddressElementActivityTest { } @Test - fun `standalone discard confirmation survives recreation and keep editing preserves input`() = runScenario( + fun `discard confirmation survives recreation and keep editing preserves input`() = runScenario( args = standaloneArgs(SHIPPING_ADDRESS), ) { addressPage.assertReadyToSave() diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityTestRunner.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityTestRunner.kt index 0fb081468c6..17e452ec424 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityTestRunner.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityTestRunner.kt @@ -134,12 +134,7 @@ internal class AddressElementActivityTestRunner( addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG) addressPage.discardChanges() - when (args) { - is AddressElementActivityContract.Args.Standalone -> - assertThat(awaitStandaloneResult()).isEqualTo(AddressLauncherResult.Canceled()) - is AddressElementActivityContract.Args.CheckoutShipping -> - assertThat(awaitResult()).isEqualTo(AddressElementActivityContract.Result.Canceled) - } + assertThat(awaitResult()).isEqualTo(AddressElementActivityContract.Result.Canceled) } fun assertDismissalBlocked() { From ada3d06155440e83da88878945ae79348c40928e Mon Sep 17 00:00:00 2001 From: Chengtin Tsai Date: Tue, 20 Oct 2026 11:32:03 -0700 Subject: [PATCH 4/7] Remove redundant address dismissal coverage Committed-By-Agent: codex Co-authored-by: codex --- .../AddressElementActivityTest.kt | 15 ------ .../InputAddressViewModelTest.kt | 49 ------------------- 2 files changed, 64 deletions(-) diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityTest.kt index fc7b831a2b6..9dc1953466f 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityTest.kt @@ -485,21 +485,6 @@ internal class AddressElementActivityTest { } } - @Test - fun `unchanged name-only input dismisses immediately`() = runScenario( - args = AddressElementActivityContract.Args.CheckoutShipping( - apiConfiguration = DEFAULT_API_CONFIG, - config = AddressLauncher.Configuration.Builder().address(AddressDetails(name = "Jenny Rosen")).build(), - checkoutSessionResponse = CheckoutSessionResponseFactory.create(), - ), - ) { - addressPage.assertName("Jenny Rosen") - addressPage.clickClose() - - addressPage.assertDiscardConfirmationNotDisplayed() - assertThat(awaitResult()).isEqualTo(AddressElementActivityContract.Result.Canceled) - } - @Test fun `reverted edits cancel immediately`() = runScenario( args = standaloneArgs(SHIPPING_ADDRESS), diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/InputAddressViewModelTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/InputAddressViewModelTest.kt index e5f881eeb59..19228aae526 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/InputAddressViewModelTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/InputAddressViewModelTest.kt @@ -19,7 +19,6 @@ import com.stripe.android.paymentsheet.utils.ViewModelStoreTestRule import com.stripe.android.testing.CoroutineTestRule import com.stripe.android.ui.core.elements.autocomplete.PlacesClientProxy import com.stripe.android.ui.core.elements.autocomplete.model.FindAutocompletePredictionsResponse -import com.stripe.android.uicore.elements.AddressTextFieldElement import com.stripe.android.uicore.elements.AutocompleteAddressController import com.stripe.android.uicore.elements.AutocompleteAddressElement import com.stripe.android.uicore.elements.AutocompleteAddressInteractor @@ -27,12 +26,9 @@ import com.stripe.android.uicore.elements.FormFieldId import com.stripe.android.uicore.elements.SectionElement import com.stripe.android.uicore.forms.FormFieldEntry import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -482,9 +478,6 @@ class InputAddressViewModelTest { @Test fun `clickPrimaryButton accepts a second click when first submission fails`() = runPrimaryButtonScenario { primaryButtonAction, expectedAddressCall -> - viewModel.onEnterManuallyFromInline() - viewModel.setRawValues(mapOf(FormFieldId.Line1 to "123 Main")) - assertThat(viewModel.hasChanges()).isTrue() val firstError = IllegalStateException("first submission failed") primaryButtonAction.result = Result.failure(firstError) assertThat(viewModel.saveError.value).isNull() @@ -500,7 +493,6 @@ class InputAddressViewModelTest { assertThat(viewModel.saveError.value).isEqualTo(firstError.stripeErrorMessage()) eventReporter.saveCompletedCalls.expectNoEvents() assertThat(resultStateHolder.state.value).isEqualTo(State.Idle) - assertThat(viewModel.hasChanges()).isTrue() primaryButtonAction.result = Result.success( AddressElementActivityContract.Result.StandaloneSucceeded(EXPECTED_ADDRESS) @@ -625,43 +617,6 @@ class InputAddressViewModelTest { assertThat(viewModel.checkboxChecked.value).isFalse() } - @Test - fun `inline query edit updates hasChanges before coroutine collection`() = runTest { - val eventReporter = FakeAddressElementEventReporter() - Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - try { - val viewModel = createViewModel(eventReporter = eventReporter) - assertThat(viewModel.hasChanges()).isFalse() - - viewModel.inlineAddressField().controller.onInlineQueryChanged("1") - - assertThat(viewModel.hasChanges()).isTrue() - } finally { - Dispatchers.setMain(testDispatcher) - } - eventReporter.validate() - } - - @Test - fun `raw field edit updates hasChanges before coroutine collection`() = runTest { - val eventReporter = FakeAddressElementEventReporter() - Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - try { - val viewModel = createViewModel( - address = EXPECTED_ADDRESS, - eventReporter = eventReporter, - ) - assertThat(viewModel.hasChanges()).isFalse() - - viewModel.setRawValues(mapOf(FormFieldId.Name to "Jane Doe")) - - assertThat(viewModel.hasChanges()).isTrue() - } finally { - Dispatchers.setMain(testDispatcher) - } - eventReporter.validate() - } - @Test fun `validation without input edits keeps the form clean`() = runScenario { val controller = viewModel.addressController() @@ -1513,10 +1468,6 @@ class InputAddressViewModelTest { return (section.fields.single() as AutocompleteAddressElement).sectionFieldErrorController() } - private fun InputAddressViewModel.inlineAddressField(): AddressTextFieldElement = - addressController().addressController.value.fieldsFlowable.value - .single { it.identifier == FormFieldId.OneLineAddress } as AddressTextFieldElement - private fun InputAddressViewModel.setRawValues( values: Map ) { From b7ae062494ae2ec12db28f36fe2186d4aaf4a5bf Mon Sep 17 00:00:00 2001 From: Chengtin Tsai Date: Tue, 20 Oct 2026 13:17:17 -0700 Subject: [PATCH 5/7] Wrap address analytics test declaration Committed-By-Agent: codex Co-authored-by: codex --- .../addresselement/AddressElementActivityAnalyticsTest.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityAnalyticsTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityAnalyticsTest.kt index ba5389c0244..8c5e0eb9890 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityAnalyticsTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityAnalyticsTest.kt @@ -66,7 +66,9 @@ internal class AddressElementActivityAnalyticsTest { } @Test - fun `failed save and unchanged close report failure and cancellation`() = runTaxScenario(fails = true) { taxUpdate -> + fun `failed save and unchanged close report failure and cancellation`() = runTaxScenario( + fails = true, + ) { taxUpdate -> val failedRequest = expectShippingAnalytics( "elements.shipping_address.save_failed", analyticsPayloadField("analytics_value", "apiError"), From 9a1385369ebf0ab3e03564ae363f967ea7604e71 Mon Sep 17 00:00:00 2001 From: cttsai-stripe Date: Tue, 20 Oct 2026 13:36:37 -0700 Subject: [PATCH 6/7] Modify CHANGELOG for AddressElement updates Updated CHANGELOG to reflect changes in AddressElement and PaymentSheet support. --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dab5e70346..dd91be6f473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ NEXT_VERSION_BUMP: MINOR * [ADDED][14589](https://github.com/stripe/stripe-android/pull/14589) Added Touch 'n Go API bindings and PaymentSheet support for payments and setup. * [ADDED][14590](https://github.com/stripe/stripe-android/pull/14590) Added TrueMoney API bindings and PaymentSheet support. +### AddressElement +* [Changed] Address Element asks for confirmation before discarding unsaved address changes. + ## 23.24.0 - 2026-10-19 ### Crypto Onramp @@ -22,9 +25,6 @@ NEXT_VERSION_BUMP: MINOR * [ADDED][14831](https://github.com/stripe/stripe-android/pull/14831) Added Naira Wallet API bindings and PaymentSheet support for PaymentIntents. * [ADDED][14832](https://github.com/stripe/stripe-android/pull/14832) Added Mondu API bindings and PaymentSheet support for PaymentIntents. -### AddressElement -* [Changed] Address Element asks for confirmation before discarding unsaved address changes. - ### Identity * [ADDED] Added test mode support to Stripe Identity, uploading bundled test document and selfie images for test-mode verification sessions. From 946b646e3d6f50e689f9021c009cffe32ca6fc82 Mon Sep 17 00:00:00 2001 From: Chengtin Tsai Date: Wed, 21 Oct 2026 09:35:38 -0700 Subject: [PATCH 7/7] Share Activity-scoped address input ViewModel Committed-By-Agent: codex Co-authored-by: codex Committed-By-Agent: codex Co-authored-by: codex Committed-By-Agent: codex Co-authored-by: codex --- .../addresselement/AddressElementActivity.kt | 12 +++--------- .../addresselement/InputAddressScreen.kt | 10 +--------- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivity.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivity.kt index 698c23abb9d..7accea49aa7 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivity.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivity.kt @@ -47,13 +47,8 @@ internal class AddressElementActivity : ComponentActivity() { private val viewModel: AddressElementViewModel by viewModels { viewModelFactory } - private val inputAddressViewModel: InputAddressViewModel by lazy { - val navController = requireNotNull(viewModel.navigator.navigationController) - val inputEntry = navController.getBackStackEntry(AddressElementScreen.InputAddress.route) - ViewModelProvider( - inputEntry, - InputAddressViewModel.Factory(viewModel.inputAddressViewModelSubcomponentFactoryProvider), - )[InputAddressViewModel::class.java] + private val inputAddressViewModel: InputAddressViewModel by viewModels { + InputAddressViewModel.Factory(viewModel.inputAddressViewModelSubcomponentFactoryProvider) } private val starterArgs by lazy { @@ -120,8 +115,7 @@ internal class AddressElementActivity : ComponentActivity() { ) { composable(AddressElementScreen.InputAddress.route) { InputAddressScreen( - inputAddressViewModelSubcomponentFactoryProvider = - viewModel.inputAddressViewModelSubcomponentFactoryProvider, + viewModel = inputAddressViewModel, onCloseClick = ::dismiss, ) } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/InputAddressScreen.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/InputAddressScreen.kt index 692d0e344a6..11d6ef611fd 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/InputAddressScreen.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/InputAddressScreen.kt @@ -16,13 +16,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import com.stripe.android.common.ui.PrimaryButton import com.stripe.android.core.strings.ResolvableString import com.stripe.android.core.strings.resolvableString import com.stripe.android.paymentsheet.R import com.stripe.android.paymentsheet.addresselement.AddressElementResultStateHolder.State -import com.stripe.android.paymentsheet.injection.InputAddressViewModelSubcomponent import com.stripe.android.paymentsheet.ui.AddressOptionsAppBar import com.stripe.android.paymentsheet.ui.ErrorMessage import com.stripe.android.ui.core.FormUI @@ -32,7 +30,6 @@ import com.stripe.android.uicore.strings.resolve import com.stripe.android.uicore.stripeFormInsets import com.stripe.android.uicore.utils.collectAsState import com.stripe.android.uicore.utils.stateFlowOf -import javax.inject.Provider @Composable internal fun InputAddressScreen( @@ -112,14 +109,9 @@ internal fun InputAddressScreen( @Composable internal fun InputAddressScreen( - inputAddressViewModelSubcomponentFactoryProvider: Provider, + viewModel: InputAddressViewModel, onCloseClick: () -> Unit, ) { - val viewModel: InputAddressViewModel = viewModel( - factory = InputAddressViewModel.Factory( - inputAddressViewModelSubcomponentFactoryProvider - ) - ) LaunchedEffect(Unit) { viewModel.onScreenShown() }