Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ internal class AddressElementActivity : ComponentActivity() {

private val viewModel: AddressElementViewModel by viewModels { viewModelFactory }

private val inputAddressViewModel: InputAddressViewModel by viewModels {
InputAddressViewModel.Factory(viewModel.inputAddressViewModelSubcomponentFactoryProvider)
}

private val starterArgs by lazy {
AddressElementActivityContract.Args.fromIntent(intent)
}
Expand Down Expand Up @@ -111,8 +115,7 @@ internal class AddressElementActivity : ComponentActivity() {
) {
composable(AddressElementScreen.InputAddress.route) {
InputAddressScreen(
inputAddressViewModelSubcomponentFactoryProvider =
viewModel.inputAddressViewModelSubcomponentFactoryProvider,
viewModel = inputAddressViewModel,
onCloseClick = ::dismiss,
)
}
Expand Down Expand Up @@ -161,7 +164,7 @@ internal class AddressElementActivity : ComponentActivity() {

private fun dismiss() {
viewModel.resultStateHolder.onUserCancel(
shouldConfirmCancellation = starterArgs is AddressElementActivityContract.Args.CheckoutShipping,
shouldConfirmCancellation = inputAddressViewModel.hasChanges(),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this work across process death/configuration changes? (non-blocking)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For configuration change, yes. For process death, no. I'll address this in a follow up.


fun hasChanges(): Boolean = getCurrentInputValues() != initialFormValues

fun getCurrentFormValues() = autocompleteAddressElement.getFormFieldValueFlow()
.value
.filter {
Expand All @@ -48,4 +53,18 @@ internal class AddressFormController(
.toMap()

fun setRawValues(values: Map<FormFieldId, String?>) = autocompleteAddressElement.setRawValue(values)

private fun getCurrentInputValues(): Map<FormFieldId, String> {
val addressElement = autocompleteAddressElement.sectionFieldErrorController().addressElementFlow.value
val formValues = uncompletedFormValues.value
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() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -112,14 +109,9 @@ internal fun InputAddressScreen(

@Composable
internal fun InputAddressScreen(
inputAddressViewModelSubcomponentFactoryProvider: Provider<InputAddressViewModelSubcomponent.Factory>,
viewModel: InputAddressViewModel,
onCloseClick: () -> Unit,
) {
val viewModel: InputAddressViewModel = viewModel(
factory = InputAddressViewModel.Factory(
inputAddressViewModelSubcomponentFactoryProvider
)
)
LaunchedEffect(Unit) {
viewModel.onScreenShown()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,14 @@ internal class InputAddressViewModel @Inject constructor(
private val _checkboxChecked = MutableStateFlow(false)
val checkboxChecked: StateFlow<Boolean> = _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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ 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"),
Expand All @@ -79,10 +81,8 @@ internal class AddressElementActivityAnalyticsTest {
awaitAnalytics(failedRequest)
addressPage.assertReadyToSave()

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)
Expand All @@ -91,6 +91,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()
Expand All @@ -107,6 +108,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)
Expand All @@ -129,6 +131,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)
Expand Down Expand Up @@ -267,6 +270,7 @@ internal class AddressElementActivityAnalyticsTest {
}

private companion object {
const val EDITED_NAME = "Jenny Rosen Updated"
val SHIPPING_ADDRESS = AddressDetails(
name = "Jenny Rosen",
address = PaymentSheet.Address(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ internal class AddressElementActivityTest {
}

@Test
fun `standalone shipping 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()
Expand All @@ -276,6 +276,7 @@ internal class AddressElementActivityTest {
addressPage.assertReadyToSave()
addressPage.clickClose()

addressPage.assertDiscardConfirmationNotDisplayed()
assertThat(awaitStandaloneResult()).isEqualTo(AddressLauncherResult.Canceled())
}

Expand Down Expand Up @@ -330,7 +331,7 @@ internal class AddressElementActivityTest {
}

@Test
fun `checkout shipping root back requests confirmation without popping input`() = runScenario {
fun `unchanged input can be canceled with root back without popping input`() = runScenario {
addressPage.assertReadyToSave()

activityScenario.onActivity { activity ->
Expand All @@ -344,23 +345,42 @@ 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 `unchanged input can be canceled with scrim accessibility action`() = runScenario {
addressPage.assertReadyToSave()
addressPage.dismissViaScrimAccessibilityAction()

assertThat(awaitResult()).isEqualTo(AddressElementActivityContract.Result.Canceled)
}

@Test
fun `checkout shipping scrim requests confirmation before canceling`() = runScenario {
assertCheckoutDismissalRequestsConfirmation {
fun `close confirms edited input and cancel preserves the form`() = runScenario {
assertDirtyDismissal(editedName = EDITED_NAME) { addressPage.clickClose() }
}

@Test
fun `root back confirms edited input and cancel preserves the form`() = runScenario {
assertDirtyDismissal(editedName = EDITED_NAME) {
activityScenario.onActivity { activity ->
activity.onBackPressedDispatcher.onBackPressed()
}
}
}

@Test
fun `scrim confirms edited input and cancel preserves the form`() = runScenario {
assertDirtyDismissal(editedName = EDITED_NAME) {
addressPage.dismissViaScrimAccessibilityAction()
}
}

@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
Expand Down Expand Up @@ -466,46 +486,44 @@ internal class AddressElementActivityTest {
}

@Test
fun `checkout shipping repeated root back stays on confirmation after editing the country`() = runScenario {
fun `reverted edits cancel immediately`() = runScenario(
args = standaloneArgs(SHIPPING_ADDRESS),
) {
addressPage.assertReadyToSave()
addressPage.selectCountry("Canada")

activityScenario.onActivity { activity ->
activity.onBackPressedDispatcher.onBackPressed()
activity.onBackPressedDispatcher.onBackPressed()
}

addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG)
addressPage.discardChanges()
addressPage.editName(EDITED_NAME)
addressPage.editName(requireNotNull(SHIPPING_ADDRESS.name))
addressPage.assertName(requireNotNull(SHIPPING_ADDRESS.name))

assertThat(awaitResult()).isEqualTo(AddressElementActivityContract.Result.Canceled)
}
addressPage.clickClose()

@Test
fun `checkout shipping close requests confirmation before canceling`() = runScenario {
assertCheckoutDismissalRequestsConfirmation {
addressPage.clickClose()
}
addressPage.assertDiscardConfirmationNotDisplayed()
assertThat(awaitStandaloneResult()).isEqualTo(AddressLauncherResult.Canceled())
}

@Test
fun `checkout shipping canceling discard confirmation preserves edits and enables the form`() = runScenario {
fun `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.keepEditing()
addressPage.assertDiscardConfirmationNotDisplayed()
addressPage.assertName(EDITED_NAME)
addressPage.assertReadyToSave()
assertThat(activityScenario.state).isEqualTo(Lifecycle.State.RESUMED)
addressPage.discardChanges()

assertThat(awaitStandaloneResult()).isEqualTo(AddressLauncherResult.Canceled())
}

@Test
fun `checkout shipping discard dialog survives recreation`() = runScenario {
fun `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)

Expand All @@ -514,31 +532,16 @@ internal class AddressElementActivityTest {
composeTestRule.waitForIdle()

addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG)
}

@Test
fun `standalone close immediately cancels edited input`() = runScenario(
args = standaloneArgs(SHIPPING_ADDRESS),
) {
assertStandaloneDismissesImmediately(editedName = EDITED_NAME) { addressPage.clickClose() }
}
addressPage.keepEditing()
addressPage.assertDiscardConfirmationNotDisplayed()
addressPage.assertName(EDITED_NAME)
addressPage.assertReadyToSave()

@Test
fun `standalone root back immediately cancels edited input`() = runScenario(
args = standaloneArgs(SHIPPING_ADDRESS),
) {
assertStandaloneDismissesImmediately(editedName = EDITED_NAME) {
activityScenario.onActivity { it.onBackPressedDispatcher.onBackPressed() }
}
}
addressPage.clickClose()
addressPage.assertDiscardConfirmation(PRIMARY_BUTTON_LOADING_INDICATOR_TEST_TAG)
addressPage.discardChanges()

@Test
fun `standalone scrim immediately cancels edited input`() = runScenario(
args = standaloneArgs(SHIPPING_ADDRESS),
) {
assertStandaloneDismissesImmediately(editedName = EDITED_NAME) {
addressPage.dismissViaScrimAccessibilityAction()
}
assertThat(awaitStandaloneResult()).isEqualTo(AddressLauncherResult.Canceled())
}

private fun standaloneArgs(address: AddressDetails) = AddressElementActivityContract.Args.Standalone(
Expand Down
Loading
Loading