Skip to content

PaymentActivity re-runs the purchase flow when the activity is recreated #625

Description

@t-d-d

PaymentActivity.onCreate runs the entire purchase off getIntent() — verify the caller, parse MethodData, connect(), queryProductDetails, launchPaymentFlow — and never reads savedInstanceState.

Android hands that same intent back on every recreation, so every recreation launches the billing flow again. The user sees Play's purchase sheet appear over whatever the TWA is showing, for a purchase they have already finished with, and a user who taps through is charged a second time.

The demo manifest's configChanges list (keyboardHidden|keyboard|orientation|screenLayout|screenSize) mitigates the config-change recreations it covers, but it cannot cover two cases:

  1. Config changes outside the list. uiMode is the notable one — Android flips dark theme on a schedule and with battery saver, often while the app is backgrounded, and applies it on resume.
  2. An activity destroyed under memory pressure and restored with its task. No configChanges list can prevent that, and it is the case that reaches a user long after the purchase, because the payment activity is restored alongside a TWA that has reloaded from scratch.

Version: com.google.androidbrowserhelper:billing:1.2.0

Suggested fix

Refuse to re-run the flow on a recreation, before connecting:

@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    if (savedInstanceState != null) {
        fail("Activity recreated; not re-running the purchase flow.");
        return;
    }

    ComponentName component = getCallingActivity();
    // ... unchanged
}

Cancelling is the safe outcome: a purchase Play did take is left unacknowledged and unconsumed, so it is re-delivered by listPurchases() (or auto-refunded after three days), and a purchase it did not take never happened.

One detail worth keeping

Routing through the existing fail(...) matters, rather than a bare setResult(Activity.RESULT_CANCELED). fail goes through setResultAndFinish, which attaches the methodName and details extras, and Chrome needs them: WebPaymentIntentHelper.parsePaymentResponse checks data == null before resultCode == Activity.RESULT_CANCELED and reports MISSING_INTENT_DATA, so a null result Intent surfaces to the page as a payment error rather than as the cancel. Verified in Chromium at tag 134.0.6998.35; the check order is reversed by 140.0.7339.80, but the older order is still what most devices in the field are running.

Context

Seen in production in a TWA that sells one-time products through the Digital Goods API. Hard to reproduce deliberately, since it needs Android to recreate a payment activity while a purchase is live, but the code path is unconditional and readable from source.


Generated by Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions