Skip to content

[Feat]: Android in-app PalsHub checkout + Play External Content Links - #766

Merged
a-ghorbani merged 23 commits into
mainfrom
feature/TASK-20260607-2338
Jun 23, 2026
Merged

[Feat]: Android in-app PalsHub checkout + Play External Content Links#766
a-ghorbani merged 23 commits into
mainfrom
feature/TASK-20260607-2338

Conversation

@a-ghorbani

@a-ghorbani a-ghorbani commented Jun 8, 2026

Copy link
Copy Markdown
Owner

Summary

Brings the authenticated in-app PalsHub purchase flow to US Android via Google
Play's External Content Links program (Billing 8.2.1+), matching the iOS
slice. The Android Buy button runs the same CheckoutFlowStore machinery as iOS
instead of opening the product page in the system browser.

This revises an earlier draft that targeted the wrong, deprecated program (EEA
External Offers, Billing 7.1.1, with an app-rendered disclosure sheet). It is now
the correct US program: enableBillingProgram(EXTERNAL_CONTENT_LINK),
launchExternalLink, and a Play-rendered disclosure — no app disclosure sheet.

  • In-app Chrome Custom Tab checkout: un-branches the old Android
    Linking.openURL web path. US Android premium purchases reuse
    createCheckoutSession + the reconcile poll, opening the Stripe-hosted
    checkout_url in a Chrome Custom Tab via the thin Kotlin AuthSessionModule
    that satisfies the existing NativeAuthSession spec. The
    pocketpal://checkout/* callback returns as a host=checkout BROWSABLE intent,
    forwarded from MainActivity.onNewIntent to the module.
  • Native link-out prep (Android only): a new ExternalContentLinkModule
    (com.android.billingclient:billing:8.2.1) runs eligibility
    (isBillingProgramAvailableAsync) -> fresh external-transaction token
    (createBillingProgramReportingDetailsAsync, never cached) ->
    launchExternalLink(LINK_TO_DIGITAL_CONTENT_OFFER, CALLER_WILL_LAUNCH_LINK)
    before the Custom Tab. Google Play renders its own disclosure during that
    call, so the app shows none. The store opens the Custom Tab only when the prep
    returns launched; user_canceled/ineligible are silent cancels and
    error surfaces a retryable error. A new transient linking status covers
    this step and is included in both in-flight guards.
  • Best-effort reporting: the prep token is threaded to a post-ownership
    report fired only after the reconcile poll confirms ownership. It never blocks
    or fails the checkout and is a logged no-op today (US reporting enforcement is
    off); the token + launch path is built now.
  • Removed: the app-rendered pre-purchase disclosure sheet, its handlers, and
    its localized strings (Play renders the disclosure now).
  • iOS code path is unchanged (no iOS files touched); non-US users are unaffected
    (the existing US-region gate is untouched).

Dependencies (Android)

  • androidx.browser:browser:1.8.0 (Chrome Custom Tabs) — unchanged
  • com.android.billingclient:billing bumped 7.1.1 -> 8.2.1 (External Content
    Links APIs; 8.2.1 fixes the eligibility/token calls used here)

Verification

  • Unit: CheckoutFlowStore + PalDetailSheet suites pass (93 tests). New
    coverage: the linking transitions (launched -> browser, user_canceled/
    ineligible -> cancelled, error -> error), reset-during-linking drops the
    prep result, the in-flight guard includes linking, the report fires once on
    reconcile-success ownership only (with the prep token) — not on the
    already-owned path, cancel, or deferred — and an iOS-shaped path (no prep, no
    report).
  • Lint / typecheck: clean.
  • Android native: :app:assembleProdRelease (new arch) BUILD SUCCESSFUL
    (APK ~221 MB); the TurboModule codegen spec generated; billing resolved to
    8.2.1.
  • iOS native (no-regression) and Android E2E: to be run by the test
    stage (no iOS files changed in this revision).

Deferred (operational, not gating)

These mirror the iOS slice's deferred live round-trip and depend on Play Console
state, not on this code:

  • Live verification that the External Content Links (US) program is Active and
    reported transactions appear — until then the report is a logged no-op by
    design; a live report needs no app-code change beyond the server-side
    submission.
  • Live Google Pay round-trip on a real US Android device (the emulator/harness
    has no wallet and auto-completes the test checkout). Note the un-enrolled
    program may report ineligible/error in the emulator; the test stage records the
    observed behaviour.

Generated by PocketPal Dev Team

@a-ghorbani a-ghorbani added the enhancement New feature or request label Jun 8, 2026
@a-ghorbani a-ghorbani changed the title [Feat]: Android in-app PalsHub checkout + Play External Offers [Feat]: Android in-app PalsHub checkout + Play External Content Links Jun 8, 2026
@a-ghorbani
a-ghorbani marked this pull request as ready for review June 23, 2026 13:29
Mirror the iOS AuthSessionModule on Android: launch the Stripe checkout
URL in a Chrome Custom Tab and resolve the existing NativeAuthSession
spec with the captured pocketpal://checkout/* callback. A host=checkout
BROWSABLE intent routes the callback to the module via MainActivity,
not the hub deep-link path; a tab dismiss resolves to a silent cancel.

Registered in MainApplication.getPackages() so the JS spec resolves;
the Android checkout caller still uses the legacy web path until the
buy flow is un-branched.
Wrap the Play Billing External Offers reporting handshake
(createExternalOfferReportingDetailsAsync) behind an optional
ExternalOfferModule, exposed to JS as NativeExternalOffer
(TurboModuleRegistry.get, null on iOS). The call is best-effort: when
the program is inactive or uncredentialed it is a logged no-op that
still resolves, so reporting never blocks, fails, or queues a checkout.

Not yet invoked from JS; the store hook follows.
After the reconcile poll confirms ownership, fire a best-effort
External Offers report keyed on the purchase id. The report is
fire-and-forget: a rejection is swallowed and never changes checkout
state. It is skipped on iOS (spec null), on the already-owned (400)
path (no external transaction), and on cancel/processing_deferred.
Un-branch handleBuyPress: the authenticated checkout now runs on both
US iOS and US Android. The legacy Android Linking.openURL web path is
removed. The auth guard runs first on both platforms; Android then
shows a pre-purchase external-offers disclosure sheet before the Custom
Tab opens, declining it starts nothing. iOS reaches checkout directly
with no gate, unchanged.
Generalise the purchase-flow spec to both platforms and add a
disclosure-consent helper. On Android the buy press routes through the
pre-purchase disclosure gate into the Custom Tab; reaching the gate
proves the native auth-session module is registered. Adds an
Android-only declined-disclosure case (no checkout starts).
…olate state

- target android.widget.EditText for email/password (Paper outlined
  inputs expose a non-editable container at the resource-id)
- dismiss the native Android sign-in alert via android:id/button1
- relaunch the app per test for clean navigation state
The non-iOS Linking.openURL path that used it is gone; Android now runs
the in-app checkout. Drops the function, its only env import, and its test.
start() had no epoch guard across the createCheckoutSession await: closing
the sheet mid-create could reopen a stray browser tab and wedge the flow.
Pin the epoch and bail if it moved. Cover the create and openAuth reject
races.
Keep the buy-button spinner through the linking state (Play disclosure /
link-out prep), and cover prep rejection + tokenless launch in tests.
@a-ghorbani
a-ghorbani force-pushed the feature/TASK-20260607-2338 branch from f6b00c8 to 658faab Compare June 23, 2026 13:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a unified, authenticated in-app PalsHub checkout flow for US Android by integrating Google Play’s External Content Links program, aligning Android behavior with the existing iOS CheckoutFlowStore flow and removing the legacy Android web link-out path.

Changes:

  • Route Android “Buy” through CheckoutFlowStore and a new native Chrome Custom Tab auth-session module, including pocketpal://checkout/* callback forwarding.
  • Add Android External Content Links preparation/reporting via a new TurboModule and bump Play Billing to 8.2.1.
  • Update unit/E2E tests to cover the new linking phase, cancellation/back-out behavior, and Android-specific UI/input handling.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/utils/palshub-display.ts Removes legacy “buy URL” helper used by the old Android browser link-out path.
src/utils/tests/palshub-display.test.ts Removes tests for the deleted buy-URL helper.
src/store/CheckoutFlowStore.ts Adds linking state, Android link-out prep + best-effort reporting, and epoch-guards to avoid stale callbacks.
src/store/tests/CheckoutFlowStore.test.ts Adds coverage for linking outcomes, epoch-drop behavior, and Android/iOS-shaped paths.
src/specs/NativeExternalContentLink.ts Introduces optional Android TurboModule spec for External Content Links prep/reporting.
src/components/PalsHub/PalDetailSheet/PalDetailSheet.tsx Makes Buy start checkout on both platforms; includes linking in in-flight/loader logic.
src/components/PalsHub/PalDetailSheet/tests/PalDetailSheet.test.tsx Updates tests for direct checkout on Android + new sign-in behavior; removes old web-link expectations.
e2e/specs/features/purchase-flow.spec.ts Extends purchase flow E2E coverage to Android and adds an Android Custom Tab back-out scenario.
e2e/pages/PalPurchasePage.ts Improves Android input targeting and adds adb-based foreground activity checks for Custom Tab dismissal tests.
android/app/src/main/java/com/pocketpalai/MainApplication.kt Registers new TurboReactPackages for AuthSession and ExternalContentLink modules.
android/app/src/main/java/com/pocketpalai/MainActivity.kt Forwards pocketpal://checkout/* intents to AuthSessionModule instead of RN Linking.
android/app/src/main/java/com/pocketpalai/ExternalContentLinkPackage.kt Adds TurboReactPackage wiring for ExternalContentLinkModule.
android/app/src/main/java/com/pocketpalai/ExternalContentLinkModule.kt Implements BillingClient External Content Links eligibility/token/link-out + best-effort reporting.
android/app/src/main/java/com/pocketpalai/AuthSessionPackage.kt Adds TurboReactPackage wiring for AuthSessionModule.
android/app/src/main/java/com/pocketpalai/AuthSessionModule.kt Implements Custom Tab checkout open + callback intent consumption and cancel-on-resume behavior.
android/app/src/main/AndroidManifest.xml Adds pocketpal://checkout BROWSABLE intent filter for the checkout callback route.
android/app/build.gradle Adds Custom Tabs dependency and bumps Play Billing to 8.2.1.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +56 to +60
val settled = AtomicBoolean(false)
val client =
BillingClient.newBuilder(appContext)
.enableBillingProgram(BillingProgram.EXTERNAL_CONTENT_LINK)
.build()
Comment on lines +4 to +13
export interface Spec extends TurboModule {
// Eligibility gate -> fresh external-transaction token -> Play link-out.
// outcome: 'launched' (open the URL), 'user_canceled', 'ineligible', 'error'.
prepareExternalLink(checkoutUrl: string): Promise<{
outcome: string;
token?: string;
}>;
// Best-effort post-ownership report; a logged no-op while US enforcement is off.
reportExternalContentLink(purchaseId: string, token: string): Promise<void>;
}
Comment on lines +633 to +656
Platform.OS = 'android';
(palStore as any).isUSRegion = true;
(authService as any).isAuthenticated = false;
const onSignInPress = jest.fn();

const {getByTestId} = render(
<PalDetailSheet
{...defaultProps}
pal={mockPremiumPalsHubPal}
onSignInPress={onSignInPress}
/>,
);

await waitFor(() => {
expect(getByTestId('buy-button')).toBeTruthy();
});

fireEvent.press(getByTestId('buy-button'));

expect(onSignInPress).toHaveBeenCalled();
expect(checkoutFlowStore.start).not.toHaveBeenCalled();

Platform.OS = original;
});
Comment on lines 730 to 734
await waitFor(() => {
expect(getByTestId('buy-button')).toBeTruthy();
});

fireEvent.press(getByTestId('buy-button'));
@a-ghorbani
a-ghorbani merged commit 42d443d into main Jun 23, 2026
5 checks passed
@a-ghorbani
a-ghorbani deleted the feature/TASK-20260607-2338 branch June 23, 2026 17:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants