Skip to content

Commit f36d451

Browse files
cttsai-stripecodex
andcommitted
Confirm Address Element dismissal only for changed input
Committed-By-Agent: codex Co-authored-by: codex <noreply@openai.com> Committed-By-Agent: codex Co-authored-by: codex <noreply@openai.com> Committed-By-Agent: codex Co-authored-by: codex <noreply@openai.com> Committed-By-Agent: codex Co-authored-by: codex <noreply@openai.com> Committed-By-Agent: codex Co-authored-by: codex <noreply@openai.com> Committed-By-Agent: codex Co-authored-by: codex <noreply@openai.com> Committed-By-Agent: codex Co-authored-by: codex <noreply@openai.com>
1 parent 226f1e1 commit f36d451

9 files changed

Lines changed: 521 additions & 41 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ NEXT_VERSION_BUMP: MINOR
1515
* [ADDED][14830](https://github.com/stripe/stripe-android/pull/14830) Added Naira USSD API bindings and PaymentSheet support for PaymentIntents.
1616
* [ADDED][14831](https://github.com/stripe/stripe-android/pull/14831) Added Naira Wallet API bindings and PaymentSheet support for PaymentIntents.
1717

18+
### AddressElement
19+
* [Changed] Address Element asks for confirmation before discarding unsaved address changes.
20+
1821
### Identity
1922
* [ADDED] Added test mode support to Stripe Identity, uploading bundled test document and selfie images for test-mode verification sessions.
2023

‎paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivity.kt‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,17 @@ internal class AddressElementActivity : ComponentActivity() {
160160
}
161161

162162
private fun dismiss() {
163+
if (viewModel.resultStateHolder.state.value != State.Idle) return
164+
165+
val navController = requireNotNull(viewModel.navigator.navigationController)
166+
val inputEntry = navController.getBackStackEntry(AddressElementScreen.InputAddress.route)
167+
val inputViewModel = ViewModelProvider(
168+
inputEntry,
169+
InputAddressViewModel.Factory(viewModel.inputAddressViewModelSubcomponentFactoryProvider),
170+
)[InputAddressViewModel::class.java]
171+
163172
viewModel.resultStateHolder.onUserCancel(
164-
shouldConfirmCancellation = starterArgs is AddressElementActivityContract.Args.CheckoutShipping,
173+
shouldConfirmCancellation = inputViewModel.hasChanges(),
165174
)
166175
}
167176

‎paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/AddressFormController.kt‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ internal class AddressFormController(
4040
textFieldControllerIds.lastOrNull()
4141
}
4242

43+
// Rendered defaults and formatting are part of the baseline, not user edits.
44+
private val initialFormValues = getCurrentInputValues()
45+
46+
fun hasChanges(): Boolean = getCurrentInputValues() != initialFormValues
47+
4348
fun getCurrentFormValues() = autocompleteAddressElement.getFormFieldValueFlow()
4449
.value
4550
.filter {
@@ -48,4 +53,18 @@ internal class AddressFormController(
4853
.toMap()
4954

5055
fun setRawValues(values: Map<FormFieldId, String?>) = autocompleteAddressElement.setRawValue(values)
56+
57+
private fun getCurrentInputValues(): Map<FormFieldId, String> {
58+
val addressElement = autocompleteAddressElement.sectionFieldErrorController().addressElementFlow.value
59+
val formValues = addressElement.getFormFieldValueFlow().value.toMap()
60+
val values = formValues
61+
.filterKeys { it != FormFieldId.OneLineAddress }
62+
.mapValues { it.value.value.orEmpty() }
63+
.toMutableMap()
64+
// Condensed autocomplete text is not included in submitted form values.
65+
if (FormFieldId.Line1 !in formValues) {
66+
values[FormFieldId.Line1] = addressElement.inlineQuery.value
67+
}
68+
return values.filterValues { it.isNotEmpty() }
69+
}
5170
}

‎paymentsheet/src/main/java/com/stripe/android/paymentsheet/addresselement/InputAddressViewModel.kt‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,14 @@ internal class InputAddressViewModel @Inject constructor(
133133
private val _checkboxChecked = MutableStateFlow(false)
134134
val checkboxChecked: StateFlow<Boolean> = _checkboxChecked
135135

136+
private val initialCheckboxChecked = unparsedInitialShippingAddress?.isCheckboxSelected ?: false
137+
138+
fun hasChanges(): Boolean {
139+
val checkboxChanged = args.config?.additionalFields?.checkboxLabel != null &&
140+
checkboxChecked.value != initialCheckboxChecked
141+
return addressFormController.hasChanges() || checkboxChanged
142+
}
143+
136144
fun onScreenShown() {
137145
eventReporter.onShown(
138146
country = getCurrentAddress().address?.country,

‎paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityAnalyticsTest.kt‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ internal class AddressElementActivityAnalyticsTest {
7878
addressPage.assertErrorDisplayed(applicationContext.getString(R.string.stripe_something_went_wrong))
7979
awaitAnalytics(failedRequest)
8080
addressPage.assertReadyToSave()
81+
addressPage.editName(EDITED_NAME)
8182

8283
addressPage.clickClose()
8384
addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG)
@@ -91,6 +92,7 @@ internal class AddressElementActivityAnalyticsTest {
9192
@Test
9293
fun `root back reports cancellation after discard`() = runScenario {
9394
addressPage.assertReadyToSave()
95+
addressPage.editName(EDITED_NAME)
9496

9597
activityScenario.onActivity { activity ->
9698
activity.onBackPressedDispatcher.onBackPressed()
@@ -107,6 +109,7 @@ internal class AddressElementActivityAnalyticsTest {
107109
@Test
108110
fun `close and keep editing do not report until discard uses current data`() = runScenario {
109111
addressPage.assertReadyToSave()
112+
addressPage.editName(EDITED_NAME)
110113

111114
addressPage.clickClose()
112115
addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG)
@@ -129,6 +132,7 @@ internal class AddressElementActivityAnalyticsTest {
129132
@Test
130133
fun `scrim accessibility action reports cancellation after discard`() = runScenario {
131134
addressPage.assertReadyToSave()
135+
addressPage.editName(EDITED_NAME)
132136

133137
addressPage.dismissViaScrimAccessibilityAction()
134138
addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG)
@@ -267,6 +271,7 @@ internal class AddressElementActivityAnalyticsTest {
267271
}
268272

269273
private companion object {
274+
const val EDITED_NAME = "Jenny Rosen Updated"
270275
val SHIPPING_ADDRESS = AddressDetails(
271276
name = "Jenny Rosen",
272277
address = PaymentSheet.Address(

‎paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityTest.kt‎

Lines changed: 110 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ internal class AddressElementActivityTest {
265265
}
266266

267267
@Test
268-
fun `standalone shipping can be canceled with close before saving`() = runScenario(
268+
fun `standalone unchanged input can be canceled with close before saving`() = runScenario(
269269
args = AddressElementActivityContract.Args.Standalone(
270270
apiConfiguration = DEFAULT_API_CONFIG,
271271
config = AddressLauncher.Configuration.Builder()
@@ -276,6 +276,7 @@ internal class AddressElementActivityTest {
276276
addressPage.assertReadyToSave()
277277
addressPage.clickClose()
278278

279+
addressPage.assertDiscardConfirmationNotDisplayed()
279280
assertThat(awaitStandaloneResult()).isEqualTo(AddressLauncherResult.Canceled())
280281
}
281282

@@ -330,7 +331,7 @@ internal class AddressElementActivityTest {
330331
}
331332

332333
@Test
333-
fun `checkout shipping root back requests confirmation without popping input`() = runScenario {
334+
fun `checkout shipping can be canceled with root back before saving without popping input`() = runScenario {
334335
addressPage.assertReadyToSave()
335336

336337
activityScenario.onActivity { activity ->
@@ -344,23 +345,42 @@ internal class AddressElementActivityTest {
344345
.isSameInstanceAs(inputEntry)
345346
}
346347

347-
addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG)
348-
assertThat(activityScenario.state).isEqualTo(Lifecycle.State.RESUMED)
349-
addressPage.discardChanges()
348+
assertThat(awaitResult()).isEqualTo(AddressElementActivityContract.Result.Canceled)
349+
}
350+
351+
@Test
352+
fun `checkout shipping can be canceled with scrim accessibility action before saving`() = runScenario {
353+
addressPage.assertReadyToSave()
354+
addressPage.dismissViaScrimAccessibilityAction()
350355

351356
assertThat(awaitResult()).isEqualTo(AddressElementActivityContract.Result.Canceled)
352357
}
353358

354359
@Test
355-
fun `checkout shipping scrim requests confirmation before canceling`() = runScenario {
356-
assertCheckoutDismissalRequestsConfirmation {
360+
fun `checkout shipping close confirms edited input and cancel preserves the form`() = runScenario {
361+
assertDirtyDismissal(editedName = EDITED_NAME) { addressPage.clickClose() }
362+
}
363+
364+
@Test
365+
fun `checkout shipping root back confirms edited input and cancel preserves the form`() = runScenario {
366+
assertDirtyDismissal(editedName = EDITED_NAME) {
367+
activityScenario.onActivity { activity ->
368+
activity.onBackPressedDispatcher.onBackPressed()
369+
}
370+
}
371+
}
372+
373+
@Test
374+
fun `checkout shipping scrim confirms edited input and cancel preserves the form`() = runScenario {
375+
assertDirtyDismissal(editedName = EDITED_NAME) {
357376
addressPage.dismissViaScrimAccessibilityAction()
358377
}
359378
}
360379

361380
@Test
362381
fun `finished cancellation allows the sheet to hide without requesting confirmation again`() = runScenario {
363382
addressPage.assertReadyToSave()
383+
addressPage.editName(EDITED_NAME)
364384

365385
activityScenario.onActivity { activity ->
366386
val stateHolder = ViewModelProvider(activity)[AddressElementViewModel::class.java].resultStateHolder
@@ -482,30 +502,32 @@ internal class AddressElementActivityTest {
482502
}
483503

484504
@Test
485-
fun `checkout shipping close requests confirmation before canceling`() = runScenario {
486-
assertCheckoutDismissalRequestsConfirmation {
487-
addressPage.clickClose()
488-
}
505+
fun `checkout shipping can be canceled with close before saving`() = runScenario {
506+
addressPage.assertReadyToSave()
507+
addressPage.clickClose()
508+
509+
assertThat(awaitResult()).isEqualTo(AddressElementActivityContract.Result.Canceled)
489510
}
490511

491512
@Test
492-
fun `checkout shipping canceling discard confirmation preserves edits and enables the form`() = runScenario {
493-
addressPage.assertReadyToSave()
494-
addressPage.editName(EDITED_NAME)
495-
513+
fun `checkout shipping unchanged name-only input dismisses immediately`() = runScenario(
514+
args = AddressElementActivityContract.Args.CheckoutShipping(
515+
apiConfiguration = DEFAULT_API_CONFIG,
516+
config = AddressLauncher.Configuration.Builder().address(AddressDetails(name = "Jenny Rosen")).build(),
517+
checkoutSessionResponse = CheckoutSessionResponseFactory.create(),
518+
),
519+
) {
520+
addressPage.assertName("Jenny Rosen")
496521
addressPage.clickClose()
497522

498-
addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG)
499-
addressPage.keepEditing()
500523
addressPage.assertDiscardConfirmationNotDisplayed()
501-
addressPage.assertName(EDITED_NAME)
502-
addressPage.assertReadyToSave()
503-
assertThat(activityScenario.state).isEqualTo(Lifecycle.State.RESUMED)
524+
assertThat(awaitResult()).isEqualTo(AddressElementActivityContract.Result.Canceled)
504525
}
505526

506527
@Test
507528
fun `checkout shipping discard dialog survives recreation`() = runScenario {
508529
addressPage.assertReadyToSave()
530+
addressPage.editName(EDITED_NAME)
509531
addressPage.clickClose()
510532
addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG)
511533

@@ -517,30 +539,91 @@ internal class AddressElementActivityTest {
517539
}
518540

519541
@Test
520-
fun `standalone close immediately cancels edited input`() = runScenario(
542+
fun `standalone close confirms edited input and cancel preserves the form`() = runScenario(
521543
args = standaloneArgs(SHIPPING_ADDRESS),
522544
) {
523-
assertStandaloneDismissesImmediately(editedName = EDITED_NAME) { addressPage.clickClose() }
545+
assertDirtyDismissal(editedName = EDITED_NAME) { addressPage.clickClose() }
524546
}
525547

526548
@Test
527-
fun `standalone root back immediately cancels edited input`() = runScenario(
549+
fun `standalone root back confirms edited input and cancel preserves the form`() = runScenario(
528550
args = standaloneArgs(SHIPPING_ADDRESS),
529551
) {
530-
assertStandaloneDismissesImmediately(editedName = EDITED_NAME) {
531-
activityScenario.onActivity { it.onBackPressedDispatcher.onBackPressed() }
552+
assertDirtyDismissal(editedName = EDITED_NAME) {
553+
activityScenario.onActivity { activity ->
554+
activity.onBackPressedDispatcher.onBackPressed()
555+
}
532556
}
533557
}
534558

535559
@Test
536-
fun `standalone scrim immediately cancels edited input`() = runScenario(
560+
fun `standalone scrim confirms edited input and cancel preserves the form`() = runScenario(
537561
args = standaloneArgs(SHIPPING_ADDRESS),
538562
) {
539-
assertStandaloneDismissesImmediately(editedName = EDITED_NAME) {
563+
assertDirtyDismissal(editedName = EDITED_NAME) {
540564
addressPage.dismissViaScrimAccessibilityAction()
541565
}
542566
}
543567

568+
@Test
569+
fun `standalone reverted edits cancel immediately`() = runScenario(
570+
args = standaloneArgs(SHIPPING_ADDRESS),
571+
) {
572+
addressPage.assertReadyToSave()
573+
addressPage.editName(EDITED_NAME)
574+
addressPage.editName(requireNotNull(SHIPPING_ADDRESS.name))
575+
addressPage.assertName(requireNotNull(SHIPPING_ADDRESS.name))
576+
577+
addressPage.clickClose()
578+
579+
addressPage.assertDiscardConfirmationNotDisplayed()
580+
assertThat(awaitStandaloneResult()).isEqualTo(AddressLauncherResult.Canceled())
581+
}
582+
583+
@Test
584+
fun `standalone edited input still requires confirmation after recreation`() = runScenario(
585+
args = standaloneArgs(SHIPPING_ADDRESS),
586+
) {
587+
addressPage.assertReadyToSave()
588+
addressPage.editName(EDITED_NAME)
589+
activityScenario.recreate()
590+
activityScenario.onActivity { activity = it }
591+
composeTestRule.waitForIdle()
592+
addressPage.assertName(EDITED_NAME)
593+
594+
addressPage.clickClose()
595+
addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG)
596+
addressPage.discardChanges()
597+
598+
assertThat(awaitStandaloneResult()).isEqualTo(AddressLauncherResult.Canceled())
599+
}
600+
601+
@Test
602+
fun `standalone discard confirmation survives recreation and keep editing preserves input`() = runScenario(
603+
args = standaloneArgs(SHIPPING_ADDRESS),
604+
) {
605+
addressPage.assertReadyToSave()
606+
addressPage.editName(EDITED_NAME)
607+
addressPage.clickClose()
608+
addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG)
609+
610+
activityScenario.recreate()
611+
activityScenario.onActivity { activity = it }
612+
composeTestRule.waitForIdle()
613+
614+
addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG)
615+
addressPage.keepEditing()
616+
addressPage.assertDiscardConfirmationNotDisplayed()
617+
addressPage.assertName(EDITED_NAME)
618+
addressPage.assertReadyToSave()
619+
620+
addressPage.clickClose()
621+
addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG)
622+
addressPage.discardChanges()
623+
624+
assertThat(awaitStandaloneResult()).isEqualTo(AddressLauncherResult.Canceled())
625+
}
626+
544627
private fun standaloneArgs(address: AddressDetails) = AddressElementActivityContract.Args.Standalone(
545628
apiConfiguration = DEFAULT_API_CONFIG,
546629
config = AddressLauncher.Configuration.Builder().address(address).build(),

‎paymentsheet/src/test/java/com/stripe/android/paymentsheet/addresselement/AddressElementActivityTestRunner.kt‎

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -114,29 +114,32 @@ internal class AddressElementActivityTestRunner(
114114
awaitAnalytics(startedRequest)
115115
}
116116

117-
fun assertCheckoutDismissalRequestsConfirmation(
117+
fun assertDirtyDismissal(
118+
editedName: String,
118119
requestDismissal: () -> Unit,
119120
) {
120121
addressPage.assertReadyToSave()
122+
addressPage.editName(editedName)
121123
requestDismissal()
122124

123125
addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG)
124126
assertThat(activityScenario.state).isEqualTo(Lifecycle.State.RESUMED)
125-
addressPage.discardChanges()
126-
127-
assertThat(awaitResult()).isEqualTo(AddressElementActivityContract.Result.Canceled)
128-
}
129127

130-
fun assertStandaloneDismissesImmediately(
131-
editedName: String,
132-
requestDismissal: () -> Unit,
133-
) {
128+
addressPage.keepEditing()
129+
addressPage.assertDiscardConfirmationNotDisplayed()
130+
addressPage.assertName(editedName)
134131
addressPage.assertReadyToSave()
135-
addressPage.editName(editedName)
132+
136133
requestDismissal()
134+
addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG)
135+
addressPage.discardChanges()
137136

138-
addressPage.assertDiscardConfirmationNotDisplayed()
139-
assertThat(awaitStandaloneResult()).isEqualTo(AddressLauncherResult.Canceled())
137+
when (args) {
138+
is AddressElementActivityContract.Args.Standalone ->
139+
assertThat(awaitStandaloneResult()).isEqualTo(AddressLauncherResult.Canceled())
140+
is AddressElementActivityContract.Args.CheckoutShipping ->
141+
assertThat(awaitResult()).isEqualTo(AddressElementActivityContract.Result.Canceled)
142+
}
140143
}
141144

142145
fun assertDismissalBlocked() {

0 commit comments

Comments
 (0)